Skip to content

Commit 295737a

Browse files
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.
1 parent b2d22b1 commit 295737a

2 files changed

Lines changed: 130 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: 129 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_037_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,61 @@
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 skips matching
110+
# cases with a clear pytest.skip reason. Used for proposal-0043 case 5
111+
# (resume re-fire) which needs harness extensions tracked separately —
112+
# checkpointer wiring + flaky-node test seam + two-phase multi-trace
113+
# assertion — landed in a follow-up PR.
114+
_DEFERRED_CASES: frozenset[tuple[str, str]] = frozenset(
115+
{
116+
(
117+
"037-langfuse-trace-input-output",
118+
"resume_hooks_refire_to_resumed_trace",
119+
),
120+
}
121+
)
122+
123+
124+
# Mocks the spec fixture 037 references for ``trace_input_from_state`` /
125+
# ``trace_output_from_state`` caller hooks. Each YAML hook name maps to
126+
# a Python callable matching the spec fixture's documented mock
127+
# convention (see fixture 037's case 3 / case 4 inline comments).
128+
# ``returns_state_snapshot`` is intentionally absent — only case 5
129+
# references it, and case 5 is deferred per ``_DEFERRED_CASES``.
130+
def _returns_job_input_summary(_state: Any) -> dict[str, Any]:
131+
return {"summary": "job-input"}
132+
133+
134+
def _returns_job_output_summary(_state: Any) -> dict[str, Any]:
135+
return {"summary": "job-output"}
136+
137+
138+
def _returns_null(_state: Any) -> None:
139+
return None
140+
141+
142+
_TRACE_IO_HOOK_REGISTRY: dict[str, Callable[[Any], Any]] = {
143+
"returns_job_input_summary": _returns_job_input_summary,
144+
"returns_job_output_summary": _returns_job_output_summary,
145+
"returns_null": _returns_null,
146+
}
147+
148+
149+
def _resolve_trace_io_hook(name: str) -> Callable[[Any], Any]:
150+
"""Look up a YAML-named trace_io hook in the registry. Raises a
151+
clear KeyError when the fixture references a name the harness
152+
hasn't mocked yet — surfaces missing-mock issues at test setup
153+
rather than as a downstream None/AttributeError.
154+
"""
155+
try:
156+
return _TRACE_IO_HOOK_REGISTRY[name]
157+
except KeyError as exc:
158+
raise KeyError(
159+
f"trace_io hook {name!r} not registered; known: {sorted(_TRACE_IO_HOOK_REGISTRY)}"
160+
) from exc
161+
162+
108163
def _normalize_fan_out_subgraph_keys(spec: dict[str, Any]) -> None:
109164
"""In-place rename of fan-out config keys that fixture 029 uses
110165
but the cross-capability adapter doesn't:
@@ -304,6 +359,7 @@ async def fetch(self, name: str, label: str = "production") -> Prompt:
304359
@pytest.mark.parametrize("fixture_path", _fixture_paths(), ids=_fixture_id)
305360
async def test_langfuse_fixture(fixture_path: Path) -> None:
306361
spec = _load(fixture_path)
362+
fixture_stem = fixture_path.stem
307363
if "cases" in spec:
308364
# Fold fixture-level ``subgraphs`` / ``inner_subgraphs`` into
309365
# each case so the per-case runner sees them locally. Fixture
@@ -313,14 +369,21 @@ async def test_langfuse_fixture(fixture_path: Path) -> None:
313369
fixture_subgraphs = cast("dict[str, Any] | None", spec.get("subgraphs"))
314370
fixture_inner_subgraphs = cast("dict[str, Any] | None", spec.get("inner_subgraphs"))
315371
for case in cast("list[dict[str, Any]]", spec["cases"]):
372+
case_name = cast("str", case.get("name") or "<unnamed>")
373+
if (fixture_stem, case_name) in _DEFERRED_CASES:
374+
# Per-case deferral. Skipping inside the loop rather
375+
# than emitting a separate pytest.skip lets us keep the
376+
# surrounding cases running under the same parametrized
377+
# test id.
378+
continue
316379
if fixture_subgraphs is not None and "subgraphs" not in case:
317380
case["subgraphs"] = fixture_subgraphs
318381
if fixture_inner_subgraphs is not None and "inner_subgraphs" not in case:
319382
case["inner_subgraphs"] = fixture_inner_subgraphs
320383
try:
321384
await _run_case(case)
322385
except AssertionError as e:
323-
raise AssertionError(f"case {case.get('name')!r}: {e}") from e
386+
raise AssertionError(f"case {case_name!r}: {e}") from e
324387
else:
325388
await _run_case(spec)
326389

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

607677
# ---- Observer
608678
observer_cfg = cast("dict[str, Any]", case.get("langfuse_observer") or {})
@@ -613,6 +683,17 @@ async def _run_case(case: Mapping[str, Any]) -> None:
613683
observer_kwargs["disable_llm_spans"] = bool(observer_cfg["disable_llm_spans"])
614684
if "payload_byte_cap" in observer_cfg:
615685
observer_kwargs["payload_byte_cap"] = int(observer_cfg["payload_byte_cap"])
686+
# Proposal 0043 (§8.4.1 trace.input/output sourcing).
687+
if "disable_state_payload" in observer_cfg:
688+
observer_kwargs["disable_state_payload"] = bool(observer_cfg["disable_state_payload"])
689+
if "trace_input_from_state" in observer_cfg:
690+
observer_kwargs["trace_input_from_state"] = _resolve_trace_io_hook(
691+
cast("str", observer_cfg["trace_input_from_state"])
692+
)
693+
if "trace_output_from_state" in observer_cfg:
694+
observer_kwargs["trace_output_from_state"] = _resolve_trace_io_hook(
695+
cast("str", observer_cfg["trace_output_from_state"])
696+
)
616697
detached_subgraphs = _resolve_detached_wrapper_names(case)
617698
if detached_subgraphs:
618699
observer_kwargs["detached_subgraphs"] = detached_subgraphs
@@ -705,6 +786,20 @@ async def _node(_s: Any) -> dict[str, Any]:
705786

706787
return _node
707788

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

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

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

0 commit comments

Comments
 (0)