Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
dcacf19
fix(hook): trace Claude Code subagent transcripts
milanagm Jul 1, 2026
a1d9d7d
refactor(hook): centralize Langfuse setup
milanagm Jul 1, 2026
e071eca
refactor(hook): remove unused final output metadata
milanagm Jul 1, 2026
259b6ae
refactor(hook): move payload validation out of main
milanagm Jul 1, 2026
ba07f5d
refactor(hook): reorder sections to match main flow
milanagm Jul 1, 2026
63c81ba
refactor(hook): better naming
milanagm Jul 1, 2026
153f883
refactor(hook): clarify state helpers
milanagm Jul 1, 2026
2dae246
refactor(hook): aligned naming across functions
milanagm Jul 1, 2026
c131312
refactor(hook): organize transcript parsing helpers
milanagm Jul 1, 2026
4e58996
refactor(hook): simplify main emit flow
milanagm Jul 1, 2026
2b635e9
refactor(hook): organize turn assembly helpers
milanagm Jul 1, 2026
507f65a
refactor(hook): clarify emit observation hierarchy
milanagm Jul 1, 2026
1d63cad
refactor(hook): aligned method names
milanagm Jul 1, 2026
302c9e0
refactor(hook): added Classe sand script substructure
milanagm Jul 1, 2026
6c072f0
refactor(hook): split turn observation emission
milanagm Jul 1, 2026
53e2332
adjusted naming
milanagm Jul 2, 2026
8e65360
fix(hook): order async subagent spans after launch response
milanagm Jul 2, 2026
f93d942
fix(hook): avoid full transcript paths in metadata
milanagm Jul 2, 2026
662007c
fix(hook): avoid tracing task notifications as turns
milanagm Jul 2, 2026
e32b07d
fix(hook): map task notifications to agent tool calls
milanagm Jul 2, 2026
d362db1
fix(hook): avoid losing deferred async agent turns
milanagm Jul 2, 2026
557323a
test(hook): cover Claude Code subagent transcript tracing
milanagm Jul 3, 2026
8ccfd82
fix(hook): validate payload before creating Langfuse client
milanagm Jul 7, 2026
e408f71
fix(hook): store deferred agent turns once and remove duplicates (no.8)
milanagm Jul 7, 2026
72f653b
fix(hook): only resolve agent notifications from task rows (no.4)
milanagm Jul 7, 2026
29ea004
fix(hook): skip malformed subagent transcript lines (no.7)
milanagm Jul 7, 2026
45e959c
fix(hook): rebuild deferred agent turns in isolation (no.1)
milanagm Jul 7, 2026
65f5fe2
fix(hook): defer only explicitly async agent turns (no.2)
milanagm Jul 7, 2026
f8534cd
fix(hook): merge ready async results into generation input (no.5)
milanagm Jul 7, 2026
17cdf79
fix(hook): retry unresolved task notifications across hook runs (no.6)
milanagm Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,871 changes: 1,418 additions & 453 deletions hooks/langfuse_hook.py

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[project]
name = "claude-observability-plugin"
version = "0.0.0"
requires-python = ">=3.10"
dependencies = []

[dependency-groups]
dev = [
"pytest>=8.0,<9",
]

[tool.uv]
package = false

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
addopts = "-ra"
133 changes: 133 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
from __future__ import annotations

import contextlib
import importlib.util
import json
import sys
import types
from pathlib import Path
from typing import Any, Iterator

import pytest


REPO_ROOT = Path(__file__).resolve().parents[1]
FIXTURE_ROOT = REPO_ROOT / "tests" / "fixtures" / "transcripts"


def _install_langfuse_stubs() -> None:
langfuse_module = types.ModuleType("langfuse")

class Langfuse:
pass

@contextlib.contextmanager
def propagate_attributes(**_: Any) -> Iterator[None]:
yield

langfuse_module.Langfuse = Langfuse
langfuse_module.propagate_attributes = propagate_attributes
sys.modules["langfuse"] = langfuse_module

opentelemetry_module = types.ModuleType("opentelemetry")
trace_module = types.ModuleType("opentelemetry.trace")

@contextlib.contextmanager
def use_span(*_: Any, **__: Any) -> Iterator[None]:
yield

trace_module.use_span = use_span
opentelemetry_module.trace = trace_module
sys.modules["opentelemetry"] = opentelemetry_module
sys.modules["opentelemetry.trace"] = trace_module


@pytest.fixture(scope="session")
def hook_module() -> Any:
_install_langfuse_stubs()
module_path = REPO_ROOT / "hooks" / "langfuse_hook.py"
spec = importlib.util.spec_from_file_location("langfuse_hook_under_test", module_path)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module


@pytest.fixture
def fixture_transcript_path() -> Any:
def _path(name: str) -> Path:
return FIXTURE_ROOT / name / "transcript.jsonl"

return _path


def read_jsonl(path: Path) -> list[dict[str, Any]]:
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]


@pytest.fixture
def read_fixture_jsonl() -> Any:
return read_jsonl


class FakeOtelSpan:
def __init__(self, name: str, start_time: int | None) -> None:
self.name = name
self.start_time = start_time


class FakeTracer:
def start_span(self, *, name: str, start_time: int | None = None) -> FakeOtelSpan:
return FakeOtelSpan(name, start_time)


class FakeObservation:
def __init__(self, otel_span: FakeOtelSpan, as_type: str, kwargs: dict[str, Any]) -> None:
self._otel_span = otel_span
self.name = otel_span.name
self.as_type = as_type
self.kwargs = kwargs
self.output: Any = None
self.end_time: int | None = None

def update(self, **kwargs: Any) -> None:
if "output" in kwargs:
self.output = kwargs["output"]
self.kwargs.update(kwargs)

def end(self, *, end_time: int | None = None) -> None:
self.end_time = end_time


class FakeLangfuse:
def __init__(self) -> None:
self._otel_tracer = FakeTracer()
self.observations: list[FakeObservation] = []

def _create_observation_from_otel_span(
self,
*,
otel_span: FakeOtelSpan,
as_type: str,
**kwargs: Any,
) -> FakeObservation:
observation = FakeObservation(otel_span, as_type, kwargs)
self.observations.append(observation)
return observation


@pytest.fixture
def fake_langfuse() -> FakeLangfuse:
return FakeLangfuse()


@pytest.fixture
def isolated_hook_state(tmp_path: Path, hook_module: Any, monkeypatch: pytest.MonkeyPatch) -> Path:
state_dir = tmp_path / "claude-state"
monkeypatch.setattr(hook_module, "STATE_DIR", state_dir)
monkeypatch.setattr(hook_module, "STATE_FILE", state_dir / "langfuse_state.json")
monkeypatch.setattr(hook_module, "LOCK_FILE", state_dir / "langfuse_state.lock")
monkeypatch.setattr(hook_module, "LOG_FILE", state_dir / "langfuse_hook.log")
return state_dir
15 changes: 15 additions & 0 deletions tests/fixtures/transcripts/async_agent_completed/transcript.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{"type":"mode","sessionId":"session-agent-complete","mode":"default"}
{"type":"permission-mode","sessionId":"session-agent-complete","permissionMode":"default"}
{"type":"file-history-snapshot","messageId":"snapshot-agent-complete","snapshot":{},"isSnapshotUpdate":false}
{"type":"user","timestamp":"2026-01-01T00:02:00.000Z","sessionId":"session-agent-complete","uuid":"user-agent-complete-1","cwd":"/repo","gitBranch":"feature/test","origin":{"kind":"human"},"parentUuid":null,"permissionMode":"default","promptId":"prompt-agent-complete","promptSource":"user","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"message":{"role":"user","content":"Ask a subagent for a summary."}}
{"type":"attachment","timestamp":"2026-01-01T00:02:00.000Z","sessionId":"session-agent-complete","uuid":"attachment-agent-complete-1","cwd":"/repo","gitBranch":"feature/test","parentUuid":"user-agent-complete-1","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"attachment":{"type":"text","name":"README.md"}}
{"type":"ai-title","sessionId":"session-agent-complete","aiTitle":"Subagent summary"}
{"type":"assistant","timestamp":"2026-01-01T00:02:01.000Z","sessionId":"session-agent-complete","uuid":"assistant-agent-complete-1","requestId":"req-agent-complete-1","message":{"id":"msg-agent-complete-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_agent_complete","name":"Agent","input":{"description":"Summarize docs","prompt":"Summarize the docs","subagent_type":"general-purpose"}}]}}
{"type":"user","timestamp":"2026-01-01T00:02:02.000Z","sessionId":"session-agent-complete","uuid":"tool-result-agent-complete","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_agent_complete","content":[{"type":"text","text":"Async agent launched successfully.\nagentId: agent-complete (internal ID - do not mention to user. Use SendMessage to communicate.)\noutput_file: /private/tmp/claude-agent-complete.txt\nYou will be notified automatically when the agent completes."}]}]}}
{"type":"assistant","timestamp":"2026-01-01T00:02:03.000Z","sessionId":"session-agent-complete","uuid":"assistant-agent-complete-2","requestId":"req-agent-complete-2","message":{"id":"msg-agent-complete-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"I launched the subagent."}]}}
{"type":"system","timestamp":"2026-01-01T00:02:03.500Z","sessionId":"session-agent-complete","uuid":"system-agent-complete-1","cwd":"/repo","gitBranch":"feature/test","parentUuid":"assistant-agent-complete-2","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"level":"info","subtype":"hook","hookCount":1,"hookErrors":[],"hookInfos":[],"hookAdditionalContext":null,"hasOutput":false,"preventedContinuation":false,"stopReason":"stop","toolUseID":null}
{"type":"queue-operation","timestamp":"2026-01-01T00:02:04.000Z","sessionId":"session-agent-complete","operation":"append","content":"<task-notification><task-id>agent-complete</task-id><tool-use-id>toolu_agent_complete</tool-use-id><result>Subagent summary is ready.</result></task-notification>"}
{"type":"queue-operation","timestamp":"2026-01-01T00:02:04.500Z","sessionId":"session-agent-complete","operation":"flush"}
{"type":"user","timestamp":"2026-01-01T00:02:05.000Z","sessionId":"session-agent-complete","uuid":"task-notification-agent-complete","origin":{"kind":"task-notification"},"message":{"role":"user","content":"<task-notification><task-id>agent-complete</task-id><tool-use-id>toolu_agent_complete</tool-use-id><result>Subagent summary is ready.</result></task-notification>"}}
{"type":"assistant","timestamp":"2026-01-01T00:02:06.000Z","sessionId":"session-agent-complete","uuid":"assistant-agent-complete-3","requestId":"req-agent-complete-3","message":{"id":"msg-agent-complete-3","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Here is the summary."}]}}
{"type":"last-prompt","sessionId":"session-agent-complete","leafUuid":"assistant-agent-complete-3","lastPrompt":"Ask a subagent for a summary."}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{"type":"user","timestamp":"2026-01-01T00:02:01.500Z","sessionId":"session-agent-complete","agentId":"agent-complete","uuid":"subagent-complete-user-1","message":{"role":"user","content":"Summarize the docs"}}
{"type":"assistant","timestamp":"2026-01-01T00:02:03.500Z","sessionId":"session-agent-complete","agentId":"agent-complete","uuid":"subagent-complete-assistant-1","requestId":"req-subagent-complete-1","message":{"id":"msg-subagent-complete-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_subagent_search","name":"ToolSearch","input":{"query":"docs summary","max_results":3}}]}}
{"type":"user","timestamp":"2026-01-01T00:02:04.000Z","sessionId":"session-agent-complete","agentId":"agent-complete","uuid":"subagent-complete-tool-result-1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_subagent_search","content":"Found docs."}]}}
{"type":"assistant","timestamp":"2026-01-01T00:02:04.500Z","sessionId":"session-agent-complete","agentId":"agent-complete","uuid":"subagent-complete-assistant-2","requestId":"req-subagent-complete-2","message":{"id":"msg-subagent-complete-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Subagent summary is ready."}]}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"agentType":"general-purpose","description":"Summarize docs","toolUseId":"toolu_agent_complete","spawnDepth":1}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{"type":"mode","sessionId":"session-agent-deferred","mode":"default"}
{"type":"permission-mode","sessionId":"session-agent-deferred","permissionMode":"acceptEdits"}
{"type":"user","timestamp":"2026-01-01T00:03:00.000Z","sessionId":"session-agent-deferred","uuid":"user-agent-deferred-1","cwd":"/repo","gitBranch":"feature/test","origin":{"kind":"human"},"parentUuid":null,"permissionMode":"acceptEdits","promptId":"prompt-agent-deferred","promptSource":"user","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"message":{"role":"user","content":"Start a long-running subagent."}}
{"type":"assistant","timestamp":"2026-01-01T00:03:01.000Z","sessionId":"session-agent-deferred","uuid":"assistant-agent-deferred-1","requestId":"req-agent-deferred-1","message":{"id":"msg-agent-deferred-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_agent_deferred","name":"Agent","input":{"description":"Long research","prompt":"Research slowly","subagent_type":"general-purpose"}}]}}
{"type":"user","timestamp":"2026-01-01T00:03:02.000Z","sessionId":"session-agent-deferred","uuid":"tool-result-agent-deferred","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_agent_deferred","content":[{"type":"text","text":"Async agent launched successfully.\nagentId: agent-deferred (internal ID - do not mention to user. Use SendMessage to communicate.)\noutput_file: /private/tmp/claude-agent-deferred.txt\nYou will be notified automatically when the agent completes."}]}]}}
{"type":"assistant","timestamp":"2026-01-01T00:03:03.000Z","sessionId":"session-agent-deferred","uuid":"assistant-agent-deferred-2","requestId":"req-agent-deferred-2","message":{"id":"msg-agent-deferred-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"The subagent is still running."}]}}
{"type":"system","timestamp":"2026-01-01T00:03:03.500Z","sessionId":"session-agent-deferred","uuid":"system-agent-deferred-1","cwd":"/repo","gitBranch":"feature/test","parentUuid":"assistant-agent-deferred-2","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"level":"info","subtype":"hook","hookCount":1,"hookErrors":[],"hookInfos":[],"hookAdditionalContext":null,"hasOutput":false,"preventedContinuation":false,"stopReason":"stop","toolUseID":null}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"type":"user","timestamp":"2026-01-01T00:03:01.500Z","sessionId":"session-agent-deferred","agentId":"agent-deferred","uuid":"subagent-deferred-user-1","message":{"role":"user","content":"Research slowly"}}
{"type":"assistant","timestamp":"2026-01-01T00:03:04.000Z","sessionId":"session-agent-deferred","agentId":"agent-deferred","uuid":"subagent-deferred-assistant-1","requestId":"req-subagent-deferred-1","message":{"id":"msg-subagent-deferred-1","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Partial research note."}]}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"agentType":"general-purpose","description":"Long research","toolUseId":"toolu_agent_deferred","spawnDepth":1}
9 changes: 9 additions & 0 deletions tests/fixtures/transcripts/nested_subagents/transcript.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{"type":"mode","sessionId":"session-nested","mode":"default"}
{"type":"permission-mode","sessionId":"session-nested","permissionMode":"default"}
{"type":"file-history-snapshot","messageId":"snapshot-nested","snapshot":{},"isSnapshotUpdate":false}
{"type":"user","timestamp":"2026-01-01T00:05:00.000Z","sessionId":"session-nested","uuid":"user-nested-1","cwd":"/repo","gitBranch":"feature/test","origin":{"kind":"human"},"parentUuid":null,"permissionMode":"default","promptId":"prompt-nested","promptSource":"user","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"message":{"role":"user","content":"Start an outer agent."}}
{"type":"attachment","timestamp":"2026-01-01T00:05:00.000Z","sessionId":"session-nested","uuid":"attachment-nested-1","cwd":"/repo","gitBranch":"feature/test","parentUuid":"user-nested-1","entrypoint":"cli","userType":"external","version":"1.0.0","isSidechain":false,"attachment":{"type":"text","name":"plan.md"}}
{"type":"assistant","timestamp":"2026-01-01T00:05:01.000Z","sessionId":"session-nested","uuid":"assistant-nested-1","requestId":"req-nested-1","message":{"id":"msg-nested-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_outer_agent","name":"Agent","input":{"description":"Outer agent","prompt":"Delegate once","subagent_type":"general-purpose"}}]}}
{"type":"user","timestamp":"2026-01-01T00:05:02.000Z","sessionId":"session-nested","uuid":"tool-result-nested","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_outer_agent","content":[{"type":"text","text":"Async agent launched successfully.\nagentId: outer-agent (internal ID - do not mention to user. Use SendMessage to communicate.)\noutput_file: /private/tmp/claude-outer-agent.txt\nYou will be notified automatically when the agent completes."}]}]}}
{"type":"queue-operation","timestamp":"2026-01-01T00:05:06.000Z","sessionId":"session-nested","operation":"append","content":"<task-notification><task-id>outer-agent</task-id><tool-use-id>toolu_outer_agent</tool-use-id><result>Outer result.</result></task-notification>"}
{"type":"assistant","timestamp":"2026-01-01T00:05:07.000Z","sessionId":"session-nested","uuid":"assistant-nested-2","requestId":"req-nested-2","message":{"id":"msg-nested-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Outer agent finished."}]}}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
{"type":"user","timestamp":"2026-01-01T00:05:02.750Z","sessionId":"session-nested","agentId":"inner-agent","uuid":"subagent-inner-user-1","message":{"role":"user","content":"Return inner result"}}
{"type":"assistant","timestamp":"2026-01-01T00:05:04.500Z","sessionId":"session-nested","agentId":"inner-agent","uuid":"subagent-inner-assistant-1","requestId":"req-subagent-inner-1","message":{"id":"msg-subagent-inner-1","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Inner result."}]}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"agentType":"fork","description":"Inner agent","toolUseId":"toolu_inner_agent","spawnDepth":2,"isFork":true}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{"type":"user","timestamp":"2026-01-01T00:05:01.500Z","sessionId":"session-nested","agentId":"outer-agent","uuid":"subagent-outer-user-1","message":{"role":"user","content":"Delegate once"}}
{"type":"assistant","timestamp":"2026-01-01T00:05:02.500Z","sessionId":"session-nested","agentId":"outer-agent","uuid":"subagent-outer-assistant-1","requestId":"req-subagent-outer-1","message":{"id":"msg-subagent-outer-1","role":"assistant","model":"claude-test","content":[{"type":"tool_use","id":"toolu_inner_agent","name":"Agent","input":{"description":"Inner agent","prompt":"Return inner result","subagent_type":"general-purpose"}}]}}
{"type":"user","timestamp":"2026-01-01T00:05:03.000Z","sessionId":"session-nested","agentId":"outer-agent","uuid":"subagent-outer-tool-result-1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_inner_agent","content":[{"type":"text","text":"Async agent launched successfully.\nagentId: inner-agent (internal ID - do not mention to user. Use SendMessage to communicate.)\noutput_file: /private/tmp/claude-inner-agent.txt\nYou will be notified automatically when the agent completes."}]}]}}
{"type":"assistant","timestamp":"2026-01-01T00:05:05.000Z","sessionId":"session-nested","agentId":"outer-agent","uuid":"subagent-outer-assistant-2","requestId":"req-subagent-outer-2","message":{"id":"msg-subagent-outer-2","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Outer result."}]}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"agentType":"general-purpose","description":"Outer agent","toolUseId":"toolu_outer_agent","spawnDepth":1}
3 changes: 3 additions & 0 deletions tests/fixtures/transcripts/simple_turn/transcript.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{"type":"user","timestamp":"2026-01-01T00:00:00.000Z","sessionId":"session-simple","uuid":"user-simple-1","cwd":"/repo","gitBranch":"feature/test","message":{"role":"user","content":"Say hello."}}
{"type":"assistant","timestamp":"2026-01-01T00:00:01.000Z","sessionId":"session-simple","uuid":"assistant-simple-1a","requestId":"req-simple-1","message":{"id":"msg-simple-1","role":"assistant","model":"claude-test","content":[{"type":"text","text":"Hello"}],"usage":{"input_tokens":10,"output_tokens":2}}}
{"type":"assistant","timestamp":"2026-01-01T00:00:02.000Z","sessionId":"session-simple","uuid":"assistant-simple-1b","requestId":"req-simple-1","message":{"id":"msg-simple-1","role":"assistant","model":"claude-test","content":[{"type":"text","text":"from Claude Code."}],"usage":{"input_tokens":10,"output_tokens":5}}}
Loading