Skip to content

Commit bdfb285

Browse files
Activate fixture 037 cases 1-4 in langfuse harness (#101)
* Activate fixture 037 cases 1-4 in langfuse harness Wires the langfuse conformance harness for proposal 0043's §8.4.1 trace.input/output sourcing fixture. The four decision-tree cases (default stub / disable_state_payload=False / hooks non-null / hooks null-fallthrough) now run end-to-end against the InMemoryLangfuseClient. Adds a per-case skip mechanism (`_DEFERRED_CASES`) keyed by `(fixture_stem, case_name)` so case 5 (resume re-fire) defers without blocking the rest of the fixture. Case 5 needs harness extensions tracked separately — checkpointer wiring, flaky-node test seam, two-phase multi-trace assertion — that land in a follow-up PR. A small caller-hook registry (`_TRACE_IO_HOOK_REGISTRY`) maps the YAML hook names the spec fixture references (`returns_job_input_summary`, `returns_job_output_summary`, `returns_null`) to Python callables matching the spec's documented mock convention. `returns_state_snapshot` (only used by case 5) lands with PR 8.6. Adjacent harness fixes the new fixture required: - `update_pure: {field: value}` directive support in `_build_node_body` (treated identically to `update:`; previously only the topology-fixture adapter handled `update_pure`). - Initial-state factory on the simple-path build wires the case's `initial_state:` overrides instead of always defaulting (case 2 needs `{msg: "start"}` to materialize on `trace.input`). - `_assert_trace` checks `expected.input` / `expected.output` and skips the observation-tree check when the fixture omits the `observations:` block (older fixtures specified observation trees; 037 focuses purely on trace-level fields). Cross-cap parser deferral for 037 stays in place — that parser still doesn't model `langfuse_trace` shape (same reason as 035 / 036). Activation lives in the langfuse-specific harness only. * Fix stale comment refs in fixture 037 activation PR #101 review caught two comment inaccuracies in the fixture 037 activation block: - The block-level comment at line 84 referenced `_DEFERRED_037_CASES`, but the symbol was renamed to the generic `_DEFERRED_CASES` during drafting (other fixtures may want per-case deferrals later). - The block-level comment at line 108 described the deferral mechanism as `pytest.skip`, but the runner uses `continue` — matching the inline comment at the skip site, which correctly explains that `pytest.skip` would mask the surrounding cases that DO run. Comment-only diff; no behavior change.
1 parent b2d22b1 commit bdfb285

2 files changed

Lines changed: 131 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
2929

3030
- **Pinned spec version bumped from v0.31.0 to v0.35.0.** Absorbs proposals 0042 (reserved-key extension), 0043 (Langfuse trace.input/output sourcing), and the textual additions in v0.32.0 (Gemini wire-format mapping, 0038, not yet implemented) and v0.33.0 (sessions capability, 0020, not yet implemented).
3131
- `LangfuseSDKAdapter` now applies `trace.input` / `trace.output` to the live Langfuse Trace. Input lands on the first real observation under the trace via `set_trace_io`; output uses a synthetic short-lived `openarmature.trace_io` observation as the carrier. The InMemoryLangfuseClient used by tests applies the fields directly.
32+
- Conformance fixture `observability/conformance/037-langfuse-trace-input-output` activated for the four decision-tree cases (default stub / `disable_state_payload=False` / hooks non-null / hooks null-fallthrough). Case 5 (resume re-fire) is deferred to a follow-up — needs the langfuse harness to grow checkpointer wiring + flaky-node test seam + two-phase multi-trace assertion.
3233
- The Langfuse v4 SDK marks `set_current_trace_io` / `Span.set_trace_io` deprecated ("removal in a future major version"). Empirical verification against Langfuse Cloud (v4.7.1, 2026-05-29) confirms it remains the **only** path that populates the Traces list view's headline `Input` / `Output` columns; `propagate_attributes(metadata=...)` does not substitute for it in the current UI. We will revisit when Langfuse publishes a concrete migration guide for v5.
3334

3435
## [0.10.0] — 2026-05-27

tests/conformance/test_observability_langfuse.py

Lines changed: 130 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from __future__ import annotations
1717

1818
import json
19-
from collections.abc import Mapping, Sequence
19+
from collections.abc import Callable, Mapping, Sequence
2020
from datetime import UTC, datetime
2121
from pathlib import Path
2222
from typing import Any, cast
@@ -74,16 +74,16 @@
7474
# before the LLM call, exercising the §3.4 MUST that open
7575
# spans in the augmenting context's lineage update in place.
7676
"034-caller-metadata-open-span-update-serial",
77-
# 037 stays deferred in v0.11.0: the conformance fixture
78-
# exercises hook-based cases (caller-supplied callables) that
79-
# the YAML-only fixture format can't express directly without
80-
# a harness extension. The five-case decision tree
81-
# (default stub / disable_state_payload=False / hooks
82-
# non-null / hooks null-fallthrough / resume) is verified
83-
# end-to-end by the unit tests in
84-
# ``tests/unit/test_observability_langfuse.py::test_trace_input_output_*``.
85-
# Wiring fixture 037 lands when the harness grows directive
86-
# support for caller-supplied hook returns.
77+
# 037 — proposal 0043 (trace.input/output sourcing). The four
78+
# decision-tree cases (default stub / disable_state_payload=False
79+
# / hooks non-null / hooks null-fallthrough) activate via the
80+
# caller-hook registry below; case 5 (resume re-fire) stays
81+
# deferred to a follow-up PR — it needs the langfuse harness to
82+
# grow checkpointer wiring + flaky-node test seam + two-phase
83+
# multi-trace assertion. Listed individually in
84+
# ``_DEFERRED_CASES`` rather than at the fixture level so the
85+
# four other cases run.
86+
"037-langfuse-trace-input-output",
8787
# 029 + 030 stay deferred in v0.11.0:
8888
# - 029 (fan-out per-instance): fixture omits ``collect_field``
8989
# and ``target_field`` on the fan_out cfg, plus the inner
@@ -105,6 +105,62 @@
105105
)
106106

107107

108+
# Per-case deferrals within an otherwise-activated fixture. Each entry is
109+
# ``(fixture_stem, case_name)``. The case-loop in the runner ``continue``s
110+
# past matching cases — NOT ``pytest.skip``, which would skip the whole
111+
# fixture's test invocation and hide the surrounding cases that DO run.
112+
# Used for proposal-0043 case 5 (resume re-fire) which needs harness
113+
# extensions tracked separately — checkpointer wiring + flaky-node test
114+
# seam + two-phase multi-trace assertion — landed in a follow-up PR.
115+
_DEFERRED_CASES: frozenset[tuple[str, str]] = frozenset(
116+
{
117+
(
118+
"037-langfuse-trace-input-output",
119+
"resume_hooks_refire_to_resumed_trace",
120+
),
121+
}
122+
)
123+
124+
125+
# Mocks the spec fixture 037 references for ``trace_input_from_state`` /
126+
# ``trace_output_from_state`` caller hooks. Each YAML hook name maps to
127+
# a Python callable matching the spec fixture's documented mock
128+
# convention (see fixture 037's case 3 / case 4 inline comments).
129+
# ``returns_state_snapshot`` is intentionally absent — only case 5
130+
# references it, and case 5 is deferred per ``_DEFERRED_CASES``.
131+
def _returns_job_input_summary(_state: Any) -> dict[str, Any]:
132+
return {"summary": "job-input"}
133+
134+
135+
def _returns_job_output_summary(_state: Any) -> dict[str, Any]:
136+
return {"summary": "job-output"}
137+
138+
139+
def _returns_null(_state: Any) -> None:
140+
return None
141+
142+
143+
_TRACE_IO_HOOK_REGISTRY: dict[str, Callable[[Any], Any]] = {
144+
"returns_job_input_summary": _returns_job_input_summary,
145+
"returns_job_output_summary": _returns_job_output_summary,
146+
"returns_null": _returns_null,
147+
}
148+
149+
150+
def _resolve_trace_io_hook(name: str) -> Callable[[Any], Any]:
151+
"""Look up a YAML-named trace_io hook in the registry. Raises a
152+
clear KeyError when the fixture references a name the harness
153+
hasn't mocked yet — surfaces missing-mock issues at test setup
154+
rather than as a downstream None/AttributeError.
155+
"""
156+
try:
157+
return _TRACE_IO_HOOK_REGISTRY[name]
158+
except KeyError as exc:
159+
raise KeyError(
160+
f"trace_io hook {name!r} not registered; known: {sorted(_TRACE_IO_HOOK_REGISTRY)}"
161+
) from exc
162+
163+
108164
def _normalize_fan_out_subgraph_keys(spec: dict[str, Any]) -> None:
109165
"""In-place rename of fan-out config keys that fixture 029 uses
110166
but the cross-capability adapter doesn't:
@@ -304,6 +360,7 @@ async def fetch(self, name: str, label: str = "production") -> Prompt:
304360
@pytest.mark.parametrize("fixture_path", _fixture_paths(), ids=_fixture_id)
305361
async def test_langfuse_fixture(fixture_path: Path) -> None:
306362
spec = _load(fixture_path)
363+
fixture_stem = fixture_path.stem
307364
if "cases" in spec:
308365
# Fold fixture-level ``subgraphs`` / ``inner_subgraphs`` into
309366
# each case so the per-case runner sees them locally. Fixture
@@ -313,14 +370,21 @@ async def test_langfuse_fixture(fixture_path: Path) -> None:
313370
fixture_subgraphs = cast("dict[str, Any] | None", spec.get("subgraphs"))
314371
fixture_inner_subgraphs = cast("dict[str, Any] | None", spec.get("inner_subgraphs"))
315372
for case in cast("list[dict[str, Any]]", spec["cases"]):
373+
case_name = cast("str", case.get("name") or "<unnamed>")
374+
if (fixture_stem, case_name) in _DEFERRED_CASES:
375+
# Per-case deferral. Skipping inside the loop rather
376+
# than emitting a separate pytest.skip lets us keep the
377+
# surrounding cases running under the same parametrized
378+
# test id.
379+
continue
316380
if fixture_subgraphs is not None and "subgraphs" not in case:
317381
case["subgraphs"] = fixture_subgraphs
318382
if fixture_inner_subgraphs is not None and "inner_subgraphs" not in case:
319383
case["inner_subgraphs"] = fixture_inner_subgraphs
320384
try:
321385
await _run_case(case)
322386
except AssertionError as e:
323-
raise AssertionError(f"case {case.get('name')!r}: {e}") from e
387+
raise AssertionError(f"case {case_name!r}: {e}") from e
324388
else:
325389
await _run_case(spec)
326390

@@ -602,7 +666,14 @@ async def _run_case(case: Mapping[str, Any]) -> None:
602666
builder.add_edge(edge["from"], target)
603667
builder.set_entry(entry)
604668
graph = builder.compile()
605-
initial_state_factory = graph.state_cls
669+
# ``initial_state`` overrides on the case populate caller-
670+
# supplied fields; remaining fields fall back to the State
671+
# class's declared defaults. Proposal 0043's case 2 relies on
672+
# this — it ships ``initial_state: {msg: "start"}`` to assert
673+
# the raw-state ``trace.input`` carries the caller-supplied
674+
# value rather than the default.
675+
case_initial_state = cast("dict[str, Any]", case.get("initial_state") or {})
676+
initial_state_factory = lambda: graph.state_cls(**case_initial_state) # noqa: E731
606677

607678
# ---- Observer
608679
observer_cfg = cast("dict[str, Any]", case.get("langfuse_observer") or {})
@@ -613,6 +684,17 @@ async def _run_case(case: Mapping[str, Any]) -> None:
613684
observer_kwargs["disable_llm_spans"] = bool(observer_cfg["disable_llm_spans"])
614685
if "payload_byte_cap" in observer_cfg:
615686
observer_kwargs["payload_byte_cap"] = int(observer_cfg["payload_byte_cap"])
687+
# Proposal 0043 (§8.4.1 trace.input/output sourcing).
688+
if "disable_state_payload" in observer_cfg:
689+
observer_kwargs["disable_state_payload"] = bool(observer_cfg["disable_state_payload"])
690+
if "trace_input_from_state" in observer_cfg:
691+
observer_kwargs["trace_input_from_state"] = _resolve_trace_io_hook(
692+
cast("str", observer_cfg["trace_input_from_state"])
693+
)
694+
if "trace_output_from_state" in observer_cfg:
695+
observer_kwargs["trace_output_from_state"] = _resolve_trace_io_hook(
696+
cast("str", observer_cfg["trace_output_from_state"])
697+
)
616698
detached_subgraphs = _resolve_detached_wrapper_names(case)
617699
if detached_subgraphs:
618700
observer_kwargs["detached_subgraphs"] = detached_subgraphs
@@ -705,6 +787,20 @@ async def _node(_s: Any) -> dict[str, Any]:
705787

706788
return _node
707789

790+
# ``update_pure: {...}`` is the spec's literal-value update directive
791+
# (paralleling ``update_pure`` in tests/conformance/adapter.py:727).
792+
# Treated identically to ``update`` here — the langfuse harness only
793+
# needs the literal-value form to drive proposal 0043's simple
794+
# decision-tree cases.
795+
update_pure_spec = cast("dict[str, Any] | None", node_spec.get("update_pure"))
796+
if update_pure_spec is not None:
797+
798+
async def _node_pure(_s: Any) -> dict[str, Any]:
799+
_maybe_augment()
800+
return dict(update_pure_spec)
801+
802+
return _node_pure
803+
708804
calls_llm_spec = cast("dict[str, Any] | None", node_spec.get("calls_llm"))
709805
renders_prompt_name = cast("str | None", node_spec.get("renders_prompt"))
710806

@@ -920,9 +1016,27 @@ def _assert_trace(
9201016
_assert_string_or_placeholder("trace.name", trace.name, expected.get("name"))
9211017
expected_metadata = cast("dict[str, Any]", expected.get("metadata") or {})
9221018
_assert_metadata_subset("trace.metadata", trace.metadata, expected_metadata)
923-
expected_observations = cast("list[dict[str, Any]]", expected.get("observations") or [])
924-
root_observations = trace.children_of(None)
925-
_assert_observation_tree(trace, root_observations, expected_observations)
1019+
# Proposal 0043 (§8.4.1 trace.input/output sourcing). Fixtures that
1020+
# opt in supply these as YAML maps; older fixtures leave them absent.
1021+
if "input" in expected:
1022+
expected_input = expected["input"]
1023+
assert trace.input == expected_input, (
1024+
f"trace.input mismatch: expected {expected_input!r}, got {trace.input!r}"
1025+
)
1026+
if "output" in expected:
1027+
expected_output = expected["output"]
1028+
assert trace.output == expected_output, (
1029+
f"trace.output mismatch: expected {expected_output!r}, got {trace.output!r}"
1030+
)
1031+
# ``observations:`` is asserted only when the fixture supplies it.
1032+
# Older fixtures that omit the block implicitly say "I'm asserting
1033+
# trace-level fields only; don't care about the observation tree"
1034+
# (proposal 0043's fixture 037 is the first to use this shape — it
1035+
# focuses purely on trace.input/output).
1036+
if "observations" in expected:
1037+
expected_observations = cast("list[dict[str, Any]]", expected["observations"])
1038+
root_observations = trace.children_of(None)
1039+
_assert_observation_tree(trace, root_observations, expected_observations)
9261040

9271041
# Invariants: cross-cutting checks that hold across the full Trace.
9281042
if expected_invariants.get("trace_id_equals_invocation_id"):

0 commit comments

Comments
 (0)