diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6b52808..f7ce8f2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "langfuse-observability", "description": "The Langfuse x Claude Code Observability Plugin", - "version": "1.2.2", + "version": "1.3.0", "author": { "name": "Langfuse" }, @@ -67,6 +67,12 @@ "description": "Send the conversation so far as each LLM call's input. On long sessions this grows quadratically and span batches can exceed the exporter's 64 MiB limit, which drops them. Set to false to send each call's own new input only.", "default": true }, + "CC_LANGFUSE_TC_HEADERS": { + "type": "boolean", + "title": "Lift [tc] prompt headers", + "description": "Copy a leading \"[tc key=value ...]\" prompt line into the agent span's metadata (tc_, plus tc_source own|inherited|none), into every agent below it that has no header of its own, and into tc:migration= / tc:work= trace tags.", + "default": true + }, "CC_LANGFUSE_DETERMINISTIC_IDS": { "type": "boolean", "title": "Deterministic observation ids", diff --git a/README.md b/README.md index 6c37580..fcaa0c0 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,40 @@ claude plugin update langfuse-observability@langfuse-observability Restart Claude Code afterwards. `claude plugin list` shows the installed version. +A session that was already running keeps the hook version it started with, +including a resumed one: the update reaches it only after a restart. Since +1.2.2 every firing writes `langfuse_agents..json` beside the state file +(`~/.claude/state` by default), so a state directory with no such file has not +run 1.2.2 or later yet. + +## Prompt headers + +A launcher can start an agent's prompt with one line that says what the work +is for: + +```text +[tc migration=m1 batch=b2 fqn=pkg.a:svc phase=codegen] +``` + +The hook reads it from the agent's full prompt, not from the span input, +which is truncated to `CC_LANGFUSE_MAX_CHARS`. It counts on the first non-blank +line, or on any line that begins with it in the first 4000 characters. A +workflow agent's computed task is read before the relayed user request above +it. + +- Each pair becomes `tc_` in the agent span's metadata. Empty values + (`pr=`) are dropped. +- `tc_source` says where they came from: `own`, `inherited` (from the agent + or main-thread turn that launched it, at any depth, workflow agents + included), or `none`. An own header replaces the inherited one whole. +- A main-thread turn whose prompt has a header carries it on its root span. + The root also gets `tc_session_agents` when it ends: the session's agents + so far by source, with `unattributed` for those with no header to own or + inherit. +- `migration` and `work` also become trace tags (`tc:migration=m1`). + +Set `CC_LANGFUSE_TC_HEADERS=false` to turn this off. + ## Backfilling a past session The hook can re-upload a finished session from its transcript, for example diff --git a/hooks/langfuse_hook.py b/hooks/langfuse_hook.py index 11f3013..d9f42a3 100644 --- a/hooks/langfuse_hook.py +++ b/hooks/langfuse_hook.py @@ -17,6 +17,7 @@ import logging import os import random +import re import sys import threading import time @@ -40,6 +41,10 @@ def _opt(name: str) -> str: DEBUG = _opt("CC_LANGFUSE_DEBUG").lower() == "true" SKILL_TAGS = (_opt("CC_LANGFUSE_SKILL_TAGS") or "true").lower() == "true" AGENT_TYPE_TAGS = (_opt("CC_LANGFUSE_AGENT_TYPE_TAGS") or "true").lower() == "true" +# Lift a "[tc key=value ...]" header line at the top of a prompt into the +# metadata of the agent span it belongs to (tc_), and into every agent +# below it that carries none of its own (tc_source "inherited"). +TC_HEADERS = (_opt("CC_LANGFUSE_TC_HEADERS") or "true").lower() == "true" CAPTURE_SKILL_CONTENT = _opt("CC_LANGFUSE_CAPTURE_SKILL_CONTENT").lower() == "true" CAPTURE_IMAGES = (_opt("CC_LANGFUSE_CAPTURE_IMAGES") or "true").lower() == "true" # Each generation's input carries the conversation so far. On a long session that is @@ -2384,6 +2389,8 @@ def get_trace_tags( tags += collect_subagent_skill_tags(turn, subagent_transcripts_by_tool_use_id) if AGENT_TYPE_TAGS: tags += collect_subagent_agent_type_tags(turn, subagent_transcripts_by_tool_use_id) + if TC_HEADERS: + tags += [tag for tag in collect_tc_tags(turn, subagent_transcripts_by_tool_use_id) if tag not in tags] tags += [tag for tag in OPERATOR_TAGS if tag not in tags] return tags @@ -2417,6 +2424,167 @@ def collect_subagent_agent_type_tags( names.append(tag) return names + +# ---- "[tc ...]" prompt headers ---- +# A launcher can start a prompt with one line such as +# [tc migration=m1 batch=b2 fqn=pkg:svc phase=codegen] +# to say what the agent's work is for. The header is lifted from the prompt +# itself (never from the span input, which is truncated to MAX_CHARS and, on +# some Claude Code versions, preceded by injected context), so a long prompt +# keeps it. Two places count, both near the top: the first non-blank line +# (anywhere in it), or any line that BEGINS with the header within the first +# TC_HEADER_SEARCH_CHARS characters (a workflow brief is relayed indented +# below the harness's own line). A header mentioned mid-line further down is +# not one. Empty values ("pr= issue=") are dropped, not rejected. +TC_HEADER_SEARCH_CHARS = 4000 +TC_MAX_KEYS = 20 +TC_MAX_VALUE_CHARS = 200 +_TC_PAIRS = r"((?:[ \t]+[a-z][a-z0-9_]*=[^\s\]]*)+)" +_TC_HEADER_LINE_START_RE = re.compile(r"^[ \t]*\[tc" + _TC_PAIRS + r"[ \t]*\]", re.MULTILINE) +_TC_HEADER_IN_LINE_RE = re.compile(r"\[tc" + _TC_PAIRS + r"[ \t]*\]") +TC_SOURCE_OWN = "own" +TC_SOURCE_INHERITED = "inherited" +TC_SOURCE_NONE = "none" + + +def parse_tc_header(text: Any) -> Optional[Dict[str, str]]: + """The key=value pairs of a prompt's "[tc ...]" header, or None.""" + if not isinstance(text, str) or "[tc" not in text: + return None + head = text[:TC_HEADER_SEARCH_CHARS] + first_line = next((line for line in head.splitlines() if line.strip()), "") + match = _TC_HEADER_IN_LINE_RE.search(first_line) or _TC_HEADER_LINE_START_RE.search(head) + if match is None: + return None + pairs: Dict[str, str] = {} + for item in match.group(1).split(): + key, _, value = item.partition("=") + if not value or key in pairs or len(pairs) >= TC_MAX_KEYS: + continue + pairs[key] = value[:TC_MAX_VALUE_CHARS] + return pairs or None + + +def get_prompt_texts(rows: List[Dict[str, Any]]) -> List[str]: + """The texts of the user rows before the first assistant row: an agent's + prompt. A workflow agent gets two (the relayed user request, then the + computed task); a classic agent gets one.""" + texts: List[str] = [] + for row in rows: + if not isinstance(row, dict) or row.get("isMeta"): + continue + role = get_user_or_assistant_role_from_row(row) + if role == "assistant": + break + if role != "user" or is_tool_result(row): + continue + text = extract_text_from_content(get_content_from_row(row)) + if text: + texts.append(text) + return texts + + +def tc_header_from_rows(rows: List[Dict[str, Any]]) -> Optional[Dict[str, str]]: + """The agent's own header. The last prompt row wins: for a workflow + agent that is the computed task, whose header is the agent's own, while + the relayed request above it may carry the launching session's.""" + for text in reversed(get_prompt_texts(rows)): + header = parse_tc_header(text) + if header: + return header + return None + + +# The effective header of the agent (or main-thread turn) whose observations +# are being emitted, inherited by every agent launched below it that carries +# no header of its own. +_TC_CONTEXT: "contextvars.ContextVar[Optional[Dict[str, str]]]" = contextvars.ContextVar( + "cc_langfuse_tc_context", default=None +) + + +@contextlib.contextmanager +def tc_context(header: Optional[Dict[str, str]]) -> Any: + token = _TC_CONTEXT.set(dict(header) if header else None) + try: + yield + finally: + _TC_CONTEXT.reset(token) + + +def resolve_tc(own: Optional[Dict[str, str]], + inherited: Optional[Dict[str, str]]) -> Tuple[Optional[Dict[str, str]], str]: + """(effective header, source): an own header replaces the inherited one + whole, never merged key by key, so a child cannot end up claiming a + combination nobody wrote.""" + if own: + return dict(own), TC_SOURCE_OWN + if inherited: + return dict(inherited), TC_SOURCE_INHERITED + return None, TC_SOURCE_NONE + + +def tc_metadata(effective: Optional[Dict[str, str]], source: str) -> Dict[str, Any]: + """Flat metadata keys: tc_source always, tc_ per header pair.""" + metadata: Dict[str, Any] = {"tc_source": source} + for key, value in (effective or {}).items(): + metadata[f"tc_{key}"] = value + return metadata + + +def count_tc_sources(entries: Dict[str, Dict[str, Any]]) -> Dict[str, int]: + """Agents registered in this session so far, by where their header came + from. "unattributed" is an agent with no header of its own and none to + inherit; a reader shows that count rather than folding it into zero.""" + counts = {"agents": 0, TC_SOURCE_OWN: 0, TC_SOURCE_INHERITED: 0, "unattributed": 0} + for entry in entries.values(): + source = entry.get("tc_source") if isinstance(entry, dict) else None + if source not in (TC_SOURCE_OWN, TC_SOURCE_INHERITED, TC_SOURCE_NONE): + continue + counts["agents"] += 1 + counts["unattributed" if source == TC_SOURCE_NONE else source] += 1 + return counts + +def get_turn_tc_header(turn: Turn) -> Optional[Dict[str, str]]: + """The header of a main-thread turn's own prompt.""" + return parse_tc_header(extract_text_from_content(get_content_from_row(turn.user_msg))) + + +# Low-cardinality header keys that also become trace tags ("tc:migration=m1"), +# so the Langfuse UI can filter traces by them. Per-agent keys (fqn, batch) +# stay in observation metadata only. +TC_TAG_KEYS = ("migration", "work") + + +def collect_tc_tags( + turn: Turn, + subagent_transcripts_by_tool_use_id: Optional[Dict[str, Dict[str, Any]]] = None, +) -> List[str]: + """'tc:=' tags from the turn's own header and from the + headers of the agents it launched directly.""" + headers: List[Dict[str, str]] = [] + own = get_turn_tc_header(turn) + if own: + headers.append(own) + for assistant_message in turn.assistant_msgs: + for tool_use in get_tool_use_blocks(get_content_from_row(assistant_message)): + tool_use_id = str(tool_use.get("id") or "") + subagent = (subagent_transcripts_by_tool_use_id or {}).get(tool_use_id) if tool_use_id else None + if not isinstance(subagent, dict) or not isinstance(subagent.get("path"), Path): + continue + rows = read_subagent_jsonl(subagent["path"]) + header = tc_header_from_rows(rows) if rows else None + if header: + headers.append(header) + tags: List[str] = [] + for header in headers: + for key in TC_TAG_KEYS: + if header.get(key): + tag = f"tc:{key}={header[key]}" + if tag not in tags: + tags.append(tag) + return tags + # ---- Generation payloads ---- def build_generation_input( assistant_index: int, @@ -3580,6 +3748,16 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, } if extra_metadata: subagent_metadata.update(extra_metadata) + # The agent's own "[tc ...]" header from its full prompt, else the one in + # force where it was launched. A registered agent keeps what it inherited + # at first sight, since a refresh re-emits it outside its launcher. + if entry is not None and "tc_inherited" in entry: + tc_inherited = entry.get("tc_inherited") if isinstance(entry.get("tc_inherited"), dict) else None + else: + tc_inherited = _TC_CONTEXT.get() + tc_effective, tc_source = resolve_tc(tc_header_from_rows(rows) if TC_HEADERS else None, tc_inherited) + if TC_HEADERS: + subagent_metadata.update(tc_metadata(tc_effective, tc_source)) span_kwargs: Dict[str, Any] = dict( name=span_name, as_type="span", @@ -3641,7 +3819,7 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, # main conversation. subagent_history: List[Dict[str, Any]] = [] for turn_index, turn in enumerate(turns): - with id_scope(f"{agent_id}:{turn_index}"): + with id_scope(f"{agent_id}:{turn_index}"), tc_context(tc_effective): latest_turn_timestamp = emit_turn_observations( langfuse, children_parent, @@ -3697,6 +3875,8 @@ def emit_subagent_observations(langfuse: Langfuse, parent_otel_span: Any, }, "agent_type": subagent.get("agent_type"), "description": description, + "tc_inherited": tc_inherited, + "tc_source": tc_source, } return latest_end_timestamp @@ -3945,11 +4125,14 @@ def open_turn_root_span(langfuse: Langfuse, session_id: str, turn_num: int, turn # scannable; the conversation history lives on the LLM call inputs. root_input = {"role": "user", "content": build_user_history_content(turn.user_msg)} trace_metadata = build_trace_metadata(session_id, turn_num, turn, transcript_path, user_text_meta) + if TC_HEADERS: + own = get_turn_tc_header(turn) + trace_metadata.update(tc_metadata(own, TC_SOURCE_OWN if own else TC_SOURCE_NONE)) if parent_context is not None: parent_trace_id, parent_span_id = parent_context trace_metadata["parent_trace_id"] = parent_trace_id trace_metadata["parent_span_id"] = parent_span_id - return _start_backdated( + root = _start_backdated( langfuse, name="Conversational Turn", as_type="span", @@ -3962,6 +4145,8 @@ def open_turn_root_span(langfuse: Langfuse, session_id: str, turn_num: int, turn metadata=trace_metadata, **get_status_kwargs(get_turn_status(turn)), ) + _ROOT_METADATA[id(root)] = trace_metadata + return root # Opt-in deterministic trace ids: fail open to the carrier-derived id. forced_trace_id: Optional[str] = None if trace_seed: @@ -3969,7 +4154,7 @@ def open_turn_root_span(langfuse: Langfuse, session_id: str, turn_num: int, turn forced_trace_id = derive_turn_trace_id(trace_seed, turn_num) except Exception as e: debug(f"trace id derivation failed for turn {turn_num}: {e}") - return _start_backdated( + root = _start_backdated( langfuse, name="Conversational Turn", as_type="span", @@ -3982,6 +4167,27 @@ def open_turn_root_span(langfuse: Langfuse, session_id: str, turn_num: int, turn metadata=trace_metadata, **get_status_kwargs(get_turn_status(turn)), ) + _ROOT_METADATA[id(root)] = trace_metadata + return root + + +# Root span -> the metadata it was opened with, for the one update it gets +# before it ends (the session's agent counts are known only then). +_ROOT_METADATA: Dict[int, Dict[str, Any]] = {} + + +def root_end_metadata(trace_span: Any) -> Optional[Dict[str, Any]]: + """The root's metadata plus tc_session_agents: every agent registered in + this session so far, by header source. Taken when the root ends, after + this turn's agents were emitted, so the latest root of a session carries + the session's count; None outside a firing with a registry.""" + opened = _ROOT_METADATA.pop(id(trace_span), None) + registry = _ACTIVE_AGENT_REGISTRY.get() + # Without deterministic ids no agent is registered, and a count of zero + # would read as "no agents" rather than "not counted". + if not TC_HEADERS or not DETERMINISTIC_IDS or opened is None or registry is None: + return None + return {**opened, "tc_session_agents": count_tc_sources(registry.entries)} @@ -4060,20 +4266,27 @@ def emit_turn(langfuse: Langfuse, session_id: str, turn_num: int, root_span_id=root_span_id, trace_id=progress.get("trace_id") or (parent_context[0] if parent_context else None), ) - obs_end_ts = emit_turn_observations( - langfuse, - parent_otel_span, - turn, - parse_timestamp(turn.user_msg), - subagent_transcripts_by_tool_use_id=subagent_transcripts_by_tool_use_id, - cursor=cursor, - workflow_agent_transcripts_by_run_id=workflow_agent_transcripts_by_run_id, - history_prefix=history_prefix, - ) + # A main-thread turn whose prompt carries a header hands it to every + # agent it launches; a turn without one hands down nothing. + with tc_context(get_turn_tc_header(turn) if TC_HEADERS else None): + obs_end_ts = emit_turn_observations( + langfuse, + parent_otel_span, + turn, + parse_timestamp(turn.user_msg), + subagent_transcripts_by_tool_use_id=subagent_transcripts_by_tool_use_id, + cursor=cursor, + workflow_agent_transcripts_by_run_id=workflow_agent_transcripts_by_run_id, + history_prefix=history_prefix, + ) if trace_span is not None: # The root exports exactly once: end time and output are the # values known now, and stay — exported fields are immutable. - trace_span.update(output=build_turn_output_payload(turn)) + end_metadata = root_end_metadata(trace_span) + trace_span.update( + output=build_turn_output_payload(turn), + **({"metadata": end_metadata} if end_metadata is not None else {}), + ) trace_span.end( end_time=to_otel_nanoseconds(get_root_span_end_time(turn, obs_end_ts)) ) @@ -4440,6 +4653,7 @@ def __init__(self, recorder: "RecordingLangfuse", otel_span: Any, as_type: str, self.usage_details = kwargs.get("usage_details") metadata = kwargs.get("metadata") if isinstance(kwargs.get("metadata"), dict) else {} self.transcript_path = metadata.get("transcript_path") if as_type == "span" else None + self.tc_source = metadata.get("tc_source") recorder.record(self) def update(self, **kwargs: Any) -> None: @@ -4763,6 +4977,15 @@ def run_backfill(argv: List[str]) -> int: summary.update({"session_id": session_id, "namespace": TRACE_SEED_NAMESPACE, "turns": turns, "agent_spans": sum(1 for o in recorder.by_id.values() if o.as_type == "span" and o.name.startswith(("Subagent", "Workflow agent")))}) + if TC_HEADERS: + # Where each agent's "[tc ...]" header would come from: check + # attribution coverage before uploading anything. + tc_sources: Dict[str, int] = {} + for o in recorder.by_id.values(): + if o.as_type == "span" and o.name.startswith(("Subagent", "Workflow agent")): + key = str(o.tc_source or TC_SOURCE_NONE) + tc_sources[key] = tc_sources.get(key, 0) + 1 + summary["agent_tc_sources"] = tc_sources if args.replace_session_traces: if existing is None: summary["replace_plan"] = "not listed: no Langfuse keys in the environment" diff --git a/tests/fixtures/transcripts/tc_headers/transcript.jsonl b/tests/fixtures/transcripts/tc_headers/transcript.jsonl new file mode 100644 index 0000000..5b091d7 --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript.jsonl @@ -0,0 +1,7 @@ +{"sessionId": "session-tc", "cwd": "/repo", "gitBranch": "main", "isSidechain": false, "userType": "external", "version": "2.1.301", "entrypoint": "cli", "type": "user", "uuid": "u-main-1", "parentUuid": null, "timestamp": "2026-10-13T08:00:00.000Z", "promptId": "p-main-1", "origin": {"kind": "human"}, "message": {"role": "user", "content": "[tc migration=m-main work=harness pr= issue=]\n(prompt body scrubbed)"}} +{"sessionId": "session-tc", "cwd": "/repo", "gitBranch": "main", "isSidechain": false, "userType": "external", "version": "2.1.301", "entrypoint": "cli", "type": "assistant", "uuid": "a-main-1", "parentUuid": "u-main-1", "timestamp": "2026-10-13T08:00:05.000Z", "requestId": "req-main-1", "message": {"id": "msg-main-1", "type": "message", "role": "assistant", "model": "claude-test", "stop_reason": "tool_use", "content": [{"type": "tool_use", "id": "toolu_017Tw3pcsMjUaY4TbWy1DSs3", "name": "Agent", "input": {"description": "(scrubbed)", "prompt": "(scrubbed)", "subagent_type": "general-purpose"}}, {"type": "tool_use", "id": "toolu_01SPk9Mw2nQn8gmRfitpdZPg", "name": "Agent", "input": {"description": "(scrubbed)", "prompt": "(scrubbed)", "subagent_type": "general-purpose"}}, {"type": "tool_use", "id": "toolu_01A83GbLQE1GpyChQZesHBS2", "name": "Agent", "input": {"description": "(scrubbed)", "prompt": "(scrubbed)", "subagent_type": "general-purpose"}}], "usage": {"input_tokens": 10, "output_tokens": 30}}} +{"sessionId": "session-tc", "cwd": "/repo", "gitBranch": "main", "isSidechain": false, "userType": "external", "version": "2.1.301", "entrypoint": "cli", "type": "user", "uuid": "u-result-0", "parentUuid": "a-main-1", "timestamp": "2026-10-19T00:00:00.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_017Tw3pcsMjUaY4TbWy1DSs3", "content": "(scrubbed)"}]}} +{"sessionId": "session-tc", "cwd": "/repo", "gitBranch": "main", "isSidechain": false, "userType": "external", "version": "2.1.301", "entrypoint": "cli", "type": "user", "uuid": "u-result-1", "parentUuid": "a-main-1", "timestamp": "2026-10-19T00:01:00.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01SPk9Mw2nQn8gmRfitpdZPg", "content": "(scrubbed)"}]}} +{"sessionId": "session-tc", "cwd": "/repo", "gitBranch": "main", "isSidechain": false, "userType": "external", "version": "2.1.301", "entrypoint": "cli", "type": "user", "uuid": "u-result-2", "parentUuid": "a-main-1", "timestamp": "2026-10-19T00:02:00.000Z", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01A83GbLQE1GpyChQZesHBS2", "content": "(scrubbed)"}]}} +{"sessionId": "session-tc", "cwd": "/repo", "gitBranch": "main", "isSidechain": false, "userType": "external", "version": "2.1.301", "entrypoint": "cli", "type": "assistant", "uuid": "a-main-2", "parentUuid": "u-result-2", "timestamp": "2026-10-19T00:05:00.000Z", "requestId": "req-main-2", "message": {"id": "msg-main-2", "type": "message", "role": "assistant", "model": "claude-test", "stop_reason": "end_turn", "content": [{"type": "text", "text": "(scrubbed)"}], "usage": {"input_tokens": 10, "output_tokens": 5}}} +{"sessionId": "session-tc", "cwd": "/repo", "gitBranch": "main", "isSidechain": false, "userType": "external", "version": "2.1.301", "entrypoint": "cli", "type": "user", "uuid": "u-main-2", "parentUuid": "a-main-2", "timestamp": "2026-10-19T00:06:00.000Z", "promptId": "p-main-2", "origin": {"kind": "human"}, "message": {"role": "user", "content": "Next question, no header."}} diff --git a/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-echo.jsonl b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-echo.jsonl new file mode 100644 index 0000000..4959183 --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-echo.jsonl @@ -0,0 +1,7 @@ +{"type": "user", "uuid": "4b909391-f26d-4607-9083-c6ff6dff6889", "parentUuid": null, "timestamp": "2026-10-18T22:19:25.728Z", "isSidechain": true, "userType": "external", "version": "2.1.301", "promptId": "24bf0b46-58ef-40cd-a36d-5f1cc93d7595", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-echo", "cwd": "/repo", "gitBranch": "main", "message": {"role": "user", "content": "[tc work=refute pr=1 issue=]\n(prompt body scrubbed)"}} +{"type": "api-request-shape", "timestamp": "2026-10-18T22:19:52.322Z", "version": "2.1.301", "sessionId": "session-tc", "agentId": "tc-echo", "cwd": "/repo", "gitBranch": "main"} +{"type": "api-request-blob", "sessionId": "session-tc", "agentId": "tc-echo", "cwd": "/repo", "gitBranch": "main", "message": {"role": "user", "content": [{"type": "text", "text": "\n(injected context scrubbed)\n"}, {"type": "text", "text": "\n(injected context scrubbed)\n"}, {"type": "text", "text": "\n(injected context scrubbed)\n"}, {"type": "text", "text": "[tc work=refute pr=1 issue=]\n(prompt body scrubbed)"}]}} +{"type": "api-request-blob", "sessionId": "session-tc", "agentId": "tc-echo", "cwd": "/repo", "gitBranch": "main", "message": {"role": "system", "content": [{"type": "text", "text": "(scrubbed)"}]}} +{"type": "api-request", "timestamp": "2026-10-18T22:19:52.322Z", "version": "2.1.301", "sessionId": "session-tc", "agentId": "tc-echo", "cwd": "/repo", "gitBranch": "main"} +{"type": "assistant", "uuid": "909347f4-d2b9-4f51-b75a-dae977c4ac2a", "parentUuid": "6bc81206-6884-42ad-9fd6-48e2321ad996", "timestamp": "2026-10-18T22:20:05.135Z", "isSidechain": true, "userType": "external", "version": "2.1.301", "requestId": "req_011CgAUomKKaJwsA6MXRX3FL", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-echo", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011CgAUomaSn3HCCbWFPnp2Q", "type": "message", "role": "assistant", "model": "claude-opus-5-5", "stop_reason": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 61401, "cache_read_input_tokens": 17402, "cache_creation": {"ephemeral_5m_input_tokens": 61401, "ephemeral_1h_input_tokens": 0}, "output_tokens": 8, "service_tier": "standard", "inference_geo": "global", "speed": "standard"}, "content": []}} +{"type": "assistant", "uuid": "3309e220-4517-4c22-9db6-ecd5c53ba92f", "parentUuid": "e362f57b-18d9-4499-a225-9e12fcb3b8ec", "timestamp": "2026-10-18T23:12:25.573Z", "isSidechain": true, "userType": "external", "version": "2.1.301", "requestId": "req_011CgAYnqsM1htYguBR5mTnH", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-echo", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011CgAYnrAi7aPSJyGyqzB3d", "type": "message", "role": "assistant", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 289, "cache_read_input_tokens": 326655, "output_tokens": 1906, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 289}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 1906, "cache_read_input_tokens": 326655, "cache_creation_input_tokens": 289, "cache_creation": {"ephemeral_5m_input_tokens": 289, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "text", "text": "(scrubbed)"}]}} diff --git a/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-echo.meta.json b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-echo.meta.json new file mode 100644 index 0000000..319a3d0 --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-echo.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Refute PR", "toolUseId": "toolu_01SPk9Mw2nQn8gmRfitpdZPg", "spawnDepth": 1, "requestShape": "background"} diff --git a/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-inner.jsonl b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-inner.jsonl new file mode 100644 index 0000000..b061608 --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-inner.jsonl @@ -0,0 +1,3 @@ +{"type": "user", "uuid": "64e36885-833d-4ee1-9480-f19c1002971c", "parentUuid": null, "timestamp": "2026-10-13T20:50:09.020Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "promptId": "67052380-3a79-4ceb-b524-0d4e0f197b58", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-inner", "cwd": "/repo", "gitBranch": "main", "message": {"role": "user", "content": "(scrubbed)"}} +{"type": "assistant", "uuid": "e4f7615d-ebbe-4d27-aa4a-f4a772671199", "parentUuid": "66c9c108-27f2-4820-a929-5945d19710d1", "timestamp": "2026-10-13T20:50:12.713Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "requestId": "req_011CfztvRQSbm26Gm2SSU2Tc", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-inner", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011CfztvRfa8CZgN6BEHkKn1", "type": "message", "role": "assistant", "model": "claude-sonnet-5-5", "stop_reason": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 57536, "cache_read_input_tokens": 5027, "cache_creation": {"ephemeral_5m_input_tokens": 57536, "ephemeral_1h_input_tokens": 0}, "output_tokens": 21, "service_tier": "standard", "inference_geo": "global"}, "content": [{"type": "tool_use", "id": "toolu_01UEJBUx9GPBPLQaLG3oT7xH", "name": "Read", "input": {}}]}} +{"type": "assistant", "uuid": "f4a093ae-7332-4f41-aca4-f434455ad4d0", "parentUuid": "d66853f7-a35c-46bf-818a-42fab9474202", "timestamp": "2026-10-13T20:50:17.842Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "requestId": "req_011CfztvkA4dCmZHhefre6WR", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-inner", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011CfztvkQh4WYfv7HZMHnv4", "type": "message", "role": "assistant", "model": "claude-sonnet-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 3599, "cache_read_input_tokens": 62563, "output_tokens": 405, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 3599}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 405, "cache_read_input_tokens": 62563, "cache_creation_input_tokens": 3599, "cache_creation": {"ephemeral_5m_input_tokens": 3599, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "text", "text": "(scrubbed)"}]}} diff --git a/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-inner.meta.json b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-inner.meta.json new file mode 100644 index 0000000..5804518 --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-inner.meta.json @@ -0,0 +1 @@ +{"agentType": "fix-codegen-worker", "description": "Review svcA fix diff", "toolUseId": "toolu_01BS3Np19DP2JfdVg7YSieZf", "spawnDepth": 3, "requestShape": "background", "parentAgentId": "tc-middle"} diff --git a/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-middle.jsonl b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-middle.jsonl new file mode 100644 index 0000000..524e46f --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-middle.jsonl @@ -0,0 +1,5 @@ +{"type": "user", "uuid": "f6402398-30ff-4df0-b9d2-263192429e48", "parentUuid": null, "timestamp": "2026-10-13T20:48:35.057Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "promptId": "67052380-3a79-4ceb-b524-0d4e0f197b58", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-middle", "cwd": "/repo", "gitBranch": "main", "message": {"role": "user", "content": "[tc migration=m-test batch=b-test phase=fix]\n(prompt body scrubbed)"}} +{"type": "assistant", "uuid": "79c56e50-25bf-434a-9ef7-020f6459b827", "parentUuid": "3af03ca6-2a18-456f-a3e0-efcbacc65e4c", "timestamp": "2026-10-13T20:48:39.152Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "requestId": "req_011CfztoVXoaxWEhBxNN2XAP", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-middle", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011CfztoVmgaPXDQVT1ffrBs", "type": "message", "role": "assistant", "model": "claude-sonnet-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 62602, "cache_read_input_tokens": 0, "output_tokens": 121, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 62602}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 121, "cache_read_input_tokens": 0, "cache_creation_input_tokens": 62602, "cache_creation": {"ephemeral_5m_input_tokens": 62602, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use", "id": "toolu_013qMuPZmDqEWLFs8z8wp8k7", "name": "Skill", "input": {}}]}} +{"type": "assistant", "uuid": "663cd260-b665-4f7e-a274-2fbaddbdf698", "parentUuid": "435a0008-7879-409e-876c-9f218e6eb95f", "timestamp": "2026-10-13T20:50:08.628Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "requestId": "req_011Cfztv3CUHd25V1vLKSSvU", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-middle", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011Cfztv3TLbcJUtoMNSFvcJ", "type": "message", "role": "assistant", "model": "claude-sonnet-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 1557, "cache_read_input_tokens": 110629, "output_tokens": 600, "output_tokens_details": {"thinking_tokens": 40}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 1557}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 600, "cache_read_input_tokens": 110629, "cache_creation_input_tokens": 1557, "cache_creation": {"ephemeral_5m_input_tokens": 1557, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use", "id": "toolu_01BS3Np19DP2JfdVg7YSieZf", "name": "Agent", "input": {"description": "(scrubbed)", "prompt": "(scrubbed)", "subagent_type": "fix-codegen-worker"}}]}} +{"type": "user", "uuid": "013203c7-d4e2-4850-af94-96acb10a04ca", "parentUuid": "663cd260-b665-4f7e-a274-2fbaddbdf698", "timestamp": "2026-10-13T20:50:09.023Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "promptId": "67052380-3a79-4ceb-b524-0d4e0f197b58", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-middle", "cwd": "/repo", "gitBranch": "main", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01BS3Np19DP2JfdVg7YSieZf", "content": "Async agent launched successfully. (scrubbed)"}]}, "toolUseResult": {"status": "async_launched", "isAsync": true, "agentId": "a16e7321b98e480f6"}} +{"type": "assistant", "uuid": "ba78c098-c7d8-4944-bb7e-46188c2f9ad4", "parentUuid": "e7c16da7-4c3f-41d7-aea1-999713564ad5", "timestamp": "2026-10-13T20:51:51.559Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "requestId": "req_011Cfzu3FDiDfcRY4gc1xTwL", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-middle", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011Cfzu3FanbtyjEZL3evUSZ", "type": "message", "role": "assistant", "model": "claude-sonnet-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 919, "cache_read_input_tokens": 118121, "output_tokens": 1450, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 919}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 1450, "cache_read_input_tokens": 118121, "cache_creation_input_tokens": 919, "cache_creation": {"ephemeral_5m_input_tokens": 919, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "text", "text": "(scrubbed)"}]}} diff --git a/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-middle.meta.json b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-middle.meta.json new file mode 100644 index 0000000..a440f55 --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-middle.meta.json @@ -0,0 +1 @@ +{"agentType": "fix-codegen-agent", "description": "Fix-codegen svcA", "toolUseId": "toolu_01UeZexoKQJUmHskT7JHUrue", "spawnDepth": 2, "requestShape": "background", "parentAgentId": "tc-outer"} diff --git a/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-outer.jsonl b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-outer.jsonl new file mode 100644 index 0000000..6ac520b --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-outer.jsonl @@ -0,0 +1,5 @@ +{"type": "user", "uuid": "594cd4dd-972f-4e32-b499-a3fd081643ef", "parentUuid": null, "timestamp": "2026-10-13T18:12:49.981Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "promptId": "ff819975-9c49-4d1a-bac4-1051fb4858f3", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-outer", "cwd": "/repo", "gitBranch": "main", "message": {"role": "user", "content": "[tc migration=m-test batch=b-test phase=batch]\n(prompt body scrubbed)"}} +{"type": "assistant", "uuid": "11a75657-b0f1-4ecd-b9b7-5cb8301ac91c", "parentUuid": "efd06945-a8a2-489a-acc1-f1815cdf383b", "timestamp": "2026-10-13T18:12:52.842Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "requestId": "req_011CfzgveDEuWrw78rMLzgu2", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-outer", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011CfzgveX6GdwgAbPZWm97p", "type": "message", "role": "assistant", "model": "claude-opus-5-5", "stop_reason": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 61390, "cache_read_input_tokens": 17178, "cache_creation": {"ephemeral_5m_input_tokens": 61390, "ephemeral_1h_input_tokens": 0}, "output_tokens": 8, "service_tier": "standard", "inference_geo": "global"}, "content": []}} +{"type": "assistant", "uuid": "1bf40ddf-921c-4940-9e19-c084dd27dd29", "parentUuid": "13e5f5d5-9c8c-4b0e-84a5-4e6763dc9381", "timestamp": "2026-10-13T20:48:34.235Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "requestId": "req_011CfztnfbVr4hfnyAXy3aTB", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-outer", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011CfztnftMmkr6c9gGSqRPH", "type": "message", "role": "assistant", "model": "claude-opus-5-5", "stop_reason": "tool_use", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 1162, "cache_read_input_tokens": 251619, "output_tokens": 778, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 1162}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 778, "cache_read_input_tokens": 251619, "cache_creation_input_tokens": 1162, "cache_creation": {"ephemeral_5m_input_tokens": 1162, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "tool_use", "id": "toolu_01UeZexoKQJUmHskT7JHUrue", "name": "Agent", "input": {"description": "(scrubbed)", "prompt": "[tc migration=m-test batch=b-test phase=fix]\n(prompt body scrubbed)", "subagent_type": "fix-codegen-agent"}}]}} +{"type": "user", "uuid": "7719d127-c558-4ae2-8356-f4c87378eb52", "parentUuid": "1bf40ddf-921c-4940-9e19-c084dd27dd29", "timestamp": "2026-10-13T20:48:35.061Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "promptId": "67052380-3a79-4ceb-b524-0d4e0f197b58", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-outer", "cwd": "/repo", "gitBranch": "main", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_01UeZexoKQJUmHskT7JHUrue", "content": "Async agent launched successfully. (scrubbed)"}]}, "toolUseResult": {"status": "async_launched", "isAsync": true, "agentId": "ae44fd2038e789fe9"}} +{"type": "assistant", "uuid": "a687ef1b-2bad-402b-8eb0-c334ef22a9c9", "parentUuid": "464de9e1-9331-4f5d-bbc4-5470e28cdfee", "timestamp": "2026-10-13T21:07:47.349Z", "isSidechain": true, "userType": "external", "version": "2.1.296", "requestId": "req_011CfzvEuZjTrG81xiRxLFvp", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-outer", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011CfzvEuu5KHVKNNiyzNL5C", "type": "message", "role": "assistant", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 354, "cache_read_input_tokens": 305126, "output_tokens": 1572, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 354}, "inference_geo": "global", "iterations": [{"input_tokens": 2, "output_tokens": 1572, "cache_read_input_tokens": 305126, "cache_creation_input_tokens": 354, "cache_creation": {"ephemeral_5m_input_tokens": 354, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "text", "text": "(scrubbed)"}]}} diff --git a/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-outer.meta.json b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-outer.meta.json new file mode 100644 index 0000000..f6755a6 --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-outer.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Batch b-test", "toolUseId": "toolu_017Tw3pcsMjUaY4TbWy1DSs3", "spawnDepth": 1, "requestShape": "background"} diff --git a/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-plain.jsonl b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-plain.jsonl new file mode 100644 index 0000000..ac5fa4e --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-plain.jsonl @@ -0,0 +1,3 @@ +{"type": "user", "uuid": "7b95e512-c098-4dee-bc5b-1d9f1293d29f", "parentUuid": null, "timestamp": "2026-10-06T00:45:12.268Z", "isSidechain": true, "userType": "external", "version": "2.1.289", "promptId": "23e08c5f-ed4f-4be8-bf5c-7a04dfbc966c", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-plain", "cwd": "/repo", "gitBranch": "main", "message": {"role": "user", "content": "(scrubbed)"}} +{"type": "assistant", "uuid": "abd1fd33-5079-46c2-a7e8-107c74539894", "parentUuid": "fa302676-993e-425d-b1d9-024356146de3", "timestamp": "2026-10-06T00:45:14.277Z", "isSidechain": true, "userType": "external", "version": "2.1.289", "requestId": "req_011Cfk4NhUJSy8xVHYtXzT7b", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-plain", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011Cfk4Nhhh9AQ25HDPqMJ3W", "type": "message", "role": "assistant", "model": "claude-opus-5-5", "stop_reason": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 76689, "cache_read_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 76689, "ephemeral_1h_input_tokens": 0}, "output_tokens": 6, "service_tier": "standard", "inference_geo": "not_available"}, "content": []}} +{"type": "assistant", "uuid": "8e878bd8-082a-44f3-96fd-092271c03c48", "parentUuid": "8a50278e-303c-4a29-a256-f2d3a6b3db91", "timestamp": "2026-10-06T01:00:00.547Z", "isSidechain": true, "userType": "external", "version": "2.1.289", "requestId": "req_011Cfk5T6U95ELHgX56RqRQk", "entrypoint": "cli", "sessionId": "session-tc", "agentId": "tc-plain", "cwd": "/repo", "gitBranch": "main", "message": {"id": "msg_011Cfk5T6mFf9UyxAr3F8Gcf", "type": "message", "role": "assistant", "model": "claude-opus-5-5", "stop_reason": "end_turn", "usage": {"input_tokens": 2, "cache_creation_input_tokens": 1251, "cache_read_input_tokens": 230973, "output_tokens": 3838, "output_tokens_details": {"thinking_tokens": 159}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 1251}, "inference_geo": "not_available", "iterations": [{"input_tokens": 2, "output_tokens": 3838, "cache_read_input_tokens": 230973, "cache_creation_input_tokens": 1251, "cache_creation": {"ephemeral_5m_input_tokens": 1251, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard", "fallback_credit": null}, "content": [{"type": "text", "text": "(scrubbed)"}]}} diff --git a/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-plain.meta.json b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-plain.meta.json new file mode 100644 index 0000000..3a190e2 --- /dev/null +++ b/tests/fixtures/transcripts/tc_headers/transcript/subagents/agent-tc-plain.meta.json @@ -0,0 +1 @@ +{"agentType": "general-purpose", "description": "Lib instance repairs", "toolUseId": "toolu_01A83GbLQE1GpyChQZesHBS2", "spawnDepth": 1, "requestShape": "background"} diff --git a/tests/integration/test_tc_headers.py b/tests/integration/test_tc_headers.py new file mode 100644 index 0000000..211e934 --- /dev/null +++ b/tests/integration/test_tc_headers.py @@ -0,0 +1,264 @@ +"""Lifting "[tc key=value ...]" prompt headers into agent span metadata. + +The tc_headers fixture is sliced from real Claude Code 2.1.x transcripts with +every text body scrubbed: only the header lines survive, with neutral values. +Its tree: + + main turn [tc migration=m-main work=harness pr= issue=] + ├── tc-outer [tc migration=m-test batch=b-test phase=batch] + │ └── tc-middle [tc migration=m-test batch=b-test phase=fix] + │ └── tc-inner (no header: inherits tc-middle's) + ├── tc-echo [tc work=refute pr=1 issue=] (prompt followed by the + │ api-request-* echo rows newer Claude Code writes) + └── tc-plain (no header: inherits the main turn's) +""" +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +import pytest + +from conftest import nests_under + + +def copy_tc_fixture(fixture_transcript_path: Any, tmp_path: Path) -> Path: + source = fixture_transcript_path("tc_headers") + shutil.copytree(source.with_suffix(""), tmp_path / "transcript") + shutil.copy(source, tmp_path / "transcript.jsonl") + return tmp_path / "transcript.jsonl" + + +def agent_span(fake_langfuse: Any, agent_id: str) -> Any: + spans = [ + o for o in fake_langfuse.observations + if o.name.startswith("Subagent") and o.as_type == "span" + and str(o.kwargs.get("metadata", {}).get("transcript_path", "")).endswith(f"agent-{agent_id}.jsonl") + ] + assert len(spans) == 1, f"expected one span for {agent_id}, got {len(spans)}" + return spans[0] + + +def tc_of(span: Any) -> dict: + return {k: v for k, v in span.kwargs["metadata"].items() if k.startswith("tc_")} + + +@pytest.fixture +def emitted(hook_module, fake_langfuse, isolated_hook_state, fixture_transcript_path, tmp_path): + transcript = copy_tc_fixture(fixture_transcript_path, tmp_path) + config = hook_module.LangfuseConfig("public", "secret", "https://example.test", "user-1") + hook_module.emit_new_turns_from_transcript( + fake_langfuse, config, "session-tc", transcript, flush_deferred_agent_turns=True + ) + return fake_langfuse + + +# ---- parse_tc_header ---- + +def test_header_on_the_first_line(hook_module): + assert hook_module.parse_tc_header("[tc migration=m1 batch=b2 fqn=pkg.a:svc phase=codegen]\nbody") == { + "migration": "m1", "batch": "b2", "fqn": "pkg.a:svc", "phase": "codegen", + } + + +def test_empty_values_are_dropped_not_fatal(hook_module): + # The "[tc work= pr= issue=]" convention is often sent with blanks. + assert hook_module.parse_tc_header("[tc work=harness pr= issue=]\nbody") == {"work": "harness"} + assert hook_module.parse_tc_header("[tc pr= issue=]\nbody") is None + + +def test_header_anywhere_in_the_first_line(hook_module): + assert hook_module.parse_tc_header("Read brief.md (it starts with [tc work=x]) and do it") == {"work": "x"} + + +def test_indented_header_line_near_the_top(hook_module): + text = "[Workflow harness — computed task] relayed below:\n [tc migration=m1 phase=verify]\n body" + assert hook_module.parse_tc_header(text) == {"migration": "m1", "phase": "verify"} + + +def test_mid_line_mention_further_down_is_not_a_header(hook_module): + text = "Do the work.\nThe convention is to start with [tc migration=x] but this prompt does not.\n" + assert hook_module.parse_tc_header(text) is None + + +def test_header_past_the_search_window_is_not_a_header(hook_module): + text = "intro\n" + "x" * hook_module.TC_HEADER_SEARCH_CHARS + "\n[tc migration=late]\n" + assert hook_module.parse_tc_header(text) is None + + +def test_incomplete_header_is_not_a_header(hook_module): + assert hook_module.parse_tc_header("[tc migration=m1\nbody") is None + assert hook_module.parse_tc_header("[tc]\nbody") is None + + +# ---- emission over the real-shaped fixture ---- + +def test_every_agent_carries_its_own_or_inherited_header(emitted): + assert tc_of(agent_span(emitted, "tc-outer")) == { + "tc_source": "own", "tc_migration": "m-test", "tc_batch": "b-test", "tc_phase": "batch", + } + assert tc_of(agent_span(emitted, "tc-middle")) == { + "tc_source": "own", "tc_migration": "m-test", "tc_batch": "b-test", "tc_phase": "fix", + } + # Depth 3, no header of its own: its launcher's, whole. + assert tc_of(agent_span(emitted, "tc-inner")) == { + "tc_source": "inherited", "tc_migration": "m-test", "tc_batch": "b-test", "tc_phase": "fix", + } + # Depth 1, no header: the main turn's. + assert tc_of(agent_span(emitted, "tc-plain")) == { + "tc_source": "inherited", "tc_migration": "m-main", "tc_work": "harness", + } + + +def test_own_header_replaces_the_inherited_one_whole(emitted): + # tc-echo runs under a main turn that names migration m-main; its own + # header names none, and it must not pick m-main up key by key. + assert tc_of(agent_span(emitted, "tc-echo")) == {"tc_source": "own", "tc_work": "refute", "tc_pr": "1"} + + +def test_nested_agents_hang_under_their_launchers(emitted): + outer, middle, inner = (agent_span(emitted, a) for a in ("tc-outer", "tc-middle", "tc-inner")) + assert nests_under(middle, outer) + assert nests_under(inner, middle) + + +def test_prompt_followed_by_request_echo_rows_keeps_its_input(emitted): + # Claude Code 2.1.301+ writes api-request-blob rows (message.role "user", + # the injected context) after the prompt. Read as a chat + # row, a blob started a second turn, the prompt turn had no reply and was + # dropped, and the span input became the injected context: no header. + span = agent_span(emitted, "tc-echo") + assert span.kwargs["input"]["content"].startswith("[tc work=refute pr=1 issue=]") + + +def test_main_turn_root_carries_its_header_and_the_session_count(emitted): + roots = [o for o in emitted.observations if o.name == "Conversational Turn"] + assert len(roots) == 1 + metadata = roots[0].kwargs["metadata"] + assert metadata["tc_source"] == "own" + assert metadata["tc_migration"] == "m-main" + assert metadata["tc_work"] == "harness" + assert "tc_pr" not in metadata + # Five agents: three with their own header, two inheriting, none unattributed. + assert metadata["tc_session_agents"] == {"agents": 5, "own": 3, "inherited": 2, "unattributed": 0} + + +def test_headerless_agent_under_a_headerless_turn_is_counted_unattributed( + hook_module, fake_langfuse, isolated_hook_state, fixture_transcript_path, tmp_path +): + transcript = copy_tc_fixture(fixture_transcript_path, tmp_path) + rows = [json.loads(line) for line in transcript.read_text(encoding="utf-8").splitlines() if line.strip()] + rows[0]["message"]["content"] = "No header on this prompt." + transcript.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8") + config = hook_module.LangfuseConfig("public", "secret", "https://example.test", "user-1") + hook_module.emit_new_turns_from_transcript( + fake_langfuse, config, "session-tc", transcript, flush_deferred_agent_turns=True + ) + assert tc_of(agent_span(fake_langfuse, "tc-plain")) == {"tc_source": "none"} + root = next(o for o in fake_langfuse.observations if o.name == "Conversational Turn") + assert root.kwargs["metadata"]["tc_source"] == "none" + assert root.kwargs["metadata"]["tc_session_agents"] == {"agents": 5, "own": 3, "inherited": 1, "unattributed": 1} + + +def test_trace_tags_name_the_turn_and_direct_agent_headers(hook_module, fixture_transcript_path, tmp_path): + transcript = copy_tc_fixture(fixture_transcript_path, tmp_path) + rows = hook_module.read_subagent_jsonl(transcript) + turn = hook_module.build_turns(rows)[0] + by_id = hook_module.get_subagent_transcripts_by_tool_use_id(transcript) + tags = hook_module.get_trace_tags(turn, by_id) + assert {"tc:migration=m-main", "tc:work=harness", "tc:migration=m-test", "tc:work=refute"} <= set(tags) + # Per-agent keys stay out of the trace tags. + assert not any(t.startswith(("tc:batch=", "tc:fqn=", "tc:phase=")) for t in tags) + + +def test_no_session_count_without_a_registry_to_count(hook_module, fake_langfuse, isolated_hook_state, fixture_transcript_path, tmp_path, monkeypatch): + # Without deterministic ids no agent is registered: the root says nothing + # rather than a count of zero that would read as "no agents". + monkeypatch.setattr(hook_module, "DETERMINISTIC_IDS", False) + transcript = copy_tc_fixture(fixture_transcript_path, tmp_path) + config = hook_module.LangfuseConfig("public", "secret", "https://example.test", "user-1") + hook_module.emit_new_turns_from_transcript( + fake_langfuse, config, "session-tc", transcript, flush_deferred_agent_turns=True + ) + root = next(o for o in fake_langfuse.observations if o.name == "Conversational Turn") + assert "tc_session_agents" not in root.kwargs["metadata"] + assert tc_of(agent_span(fake_langfuse, "tc-inner"))["tc_source"] == "inherited" + + +def test_option_off_lifts_nothing(hook_module, fake_langfuse, isolated_hook_state, fixture_transcript_path, tmp_path, monkeypatch): + monkeypatch.setattr(hook_module, "TC_HEADERS", False) + transcript = copy_tc_fixture(fixture_transcript_path, tmp_path) + config = hook_module.LangfuseConfig("public", "secret", "https://example.test", "user-1") + hook_module.emit_new_turns_from_transcript( + fake_langfuse, config, "session-tc", transcript, flush_deferred_agent_turns=True + ) + for o in fake_langfuse.observations: + assert not any(k.startswith("tc_") for k in (o.kwargs.get("metadata") or {})) + + +def test_refresh_keeps_what_an_agent_inherited_at_first_sight(hook_module): + # A registered agent re-emitted by refresh_grown_agents runs outside its + # launcher; its stored inheritance, not the empty context, decides. + registry = hook_module.AgentRegistry(None) + registry.entries["x"] = {"tc_inherited": {"migration": "m1"}, "tc_source": "inherited"} + assert hook_module.count_tc_sources(registry.entries) == {"agents": 1, "own": 0, "inherited": 1, "unattributed": 0} + assert hook_module.resolve_tc(None, registry.entries["x"]["tc_inherited"]) == ({"migration": "m1"}, "inherited") + + +def test_backfill_dry_run_reports_header_coverage(hook_module, isolated_hook_state, fixture_transcript_path, tmp_path, capsys, monkeypatch): + for name in ("LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "CLAUDE_PLUGIN_OPTION_LANGFUSE_PUBLIC_KEY", "CLAUDE_PLUGIN_OPTION_LANGFUSE_SECRET_KEY"): + monkeypatch.delenv(name, raising=False) + # run_backfill sets these module globals; restore them for later tests. + monkeypatch.setattr(hook_module, "TRACE_SEED_NAMESPACE", hook_module.TRACE_SEED_NAMESPACE) + monkeypatch.setattr(hook_module, "BACKFILL_VERIFY_ATTEMPTS", hook_module.BACKFILL_VERIFY_ATTEMPTS) + # A backfill names the transcript by its session id. + source = fixture_transcript_path("tc_headers") + shutil.copytree(source.with_suffix(""), tmp_path / "session-tc") + transcript = tmp_path / "session-tc.jsonl" + shutil.copy(source, transcript) + assert hook_module.run_backfill(["--backfill", str(transcript), "--dry-run", "--force"]) == 0 + summary = json.loads(capsys.readouterr().out) + assert summary["agent_spans"] == 5 + assert summary["agent_tc_sources"] == {"own": 3, "inherited": 2} + + +# ---- workflow agents ---- + +def test_workflow_agent_takes_the_computed_task_header_not_the_relayed_request( + hook_module, fake_langfuse, isolated_hook_state, fixture_transcript_path, tmp_path +): + source = fixture_transcript_path("workflow_subagents") + shutil.copytree(source.with_suffix(""), tmp_path / "transcript") + rows = [json.loads(line) for line in source.read_text(encoding="utf-8").splitlines() if line.strip()] + # The launching main turn carries a header r2 (no header) must inherit. + rows[0]["message"]["content"] = "[tc migration=m-main work=harness]\nRun the two-agent verification workflow." + transcript = tmp_path / "transcript.jsonl" + transcript.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8") + + # r1 gets the real workflow prompt shape: the harness relays the user's + # request (which itself carries a header), then the computed task. + r1 = tmp_path / "transcript" / "subagents" / "workflows" / "wf_test001" / "agent-r1.jsonl" + r1_rows = [json.loads(line) for line in r1.read_text(encoding="utf-8").splitlines() if line.strip()] + relay = dict(r1_rows[0], uuid="wf-r1-relay", message={"role": "user", "content": ( + "[Workflow harness — user request] The harness relays, verbatim and indented below, the user request:\n" + " [tc work=relayed-request]\n Run the two-agent verification workflow." + )}) + task = dict(r1_rows[0], parentUuid="wf-r1-relay", message={"role": "user", "content": ( + "[Workflow harness — computed task] The task text below was computed at runtime by a workflow script.\n" + " [tc migration=m-wf batch=b-wf fqn=pkg.test:svcW phase=verify]\n Verify claim A." + )}) + r1.write_text("".join(json.dumps(r) + "\n" for r in [relay, task, *r1_rows[1:]]), encoding="utf-8") + + config = hook_module.LangfuseConfig("public", "secret", "https://example.test", "user-1") + hook_module.emit_new_turns_from_transcript( + fake_langfuse, config, "session-workflow", transcript, flush_deferred_agent_turns=True + ) + spans = {o.name: o for o in fake_langfuse.observations if o.name.startswith("Workflow agent: ")} + assert tc_of(spans["Workflow agent: verify-claims/r1"]) == { + "tc_source": "own", "tc_migration": "m-wf", "tc_batch": "b-wf", "tc_fqn": "pkg.test:svcW", "tc_phase": "verify", + } + assert tc_of(spans["Workflow agent: verify-claims/r2"]) == { + "tc_source": "inherited", "tc_migration": "m-main", "tc_work": "harness", + }