From b8e90bf38eaca6a774433813938e2f00a4538254 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 23 Jul 2026 15:01:04 -0700 Subject: [PATCH] feat(trace): trace Agent lifecycles across restarts --- src/callLifecycle.ts | 392 ++++++++++++++++++++++++++++------ src/hookHandler.ts | 300 +++++++++++++++++++++++++- src/session.ts | 53 ++--- src/transcriptFile.ts | 99 ++++++--- tests/agent-lifecycle.test.ts | 288 +++++++++++++++++++++++++ tests/agent-nesting.test.ts | 218 +++++++++++++++++++ tests/agent-recovery.test.ts | 352 ++++++++++++++++++++++++++++++ tests/agent-test-helpers.ts | 48 +++++ tests/helpers.ts | 62 ++++++ tests/tool-lifecycle.test.ts | 19 +- 10 files changed, 1667 insertions(+), 164 deletions(-) create mode 100644 tests/agent-lifecycle.test.ts create mode 100644 tests/agent-nesting.test.ts create mode 100644 tests/agent-recovery.test.ts create mode 100644 tests/agent-test-helpers.ts diff --git a/src/callLifecycle.ts b/src/callLifecycle.ts index 9fe5f06..aaf2449 100644 --- a/src/callLifecycle.ts +++ b/src/callLifecycle.ts @@ -2,8 +2,10 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -import type { Tool } from 'weave'; -import { ATTR, jsonStr } from './genaiSpans.js'; +import type { SubAgent, Tool } from 'weave'; +import { ATTR, assistantOutputMessages, jsonStr } from './genaiSpans.js'; +import type { SpanParent } from './genaiSpans.js'; +import { VERSION } from './setup.js'; import type { TurnTrace } from './session.js'; export type JsonValue = @@ -26,92 +28,344 @@ export type ToolResult = | { ok: true; output: JsonValue } | { ok: false; error: string }; -type OpenTool = { +export type CallParent = TurnTrace | TracedAgent; + +type CallScope = { + parent: CallParent; + root: TurnTrace; +}; + +type TracedTool = CallScope & { + kind: 'tool'; span: Tool; - turn: TurnTrace; toolUseId: string; }; -/** Owns the exact hook identities for ordinary tools in one session. */ -export class ToolLifecycle { - private readonly openById = new Map(); - private readonly openIdsByTurn = new Map>(); - private readonly tombstones = new Set(); +export type TracedAgent = CallScope & { + kind: 'agent'; + span: SubAgent; + toolUseId?: string; + /** Agent span name chosen from Agent input; `name` is only an instance alias. */ + agentType: string; + /** Lifecycle identity, unknown when Agent input omitted `subagent_type`. */ + declaredAgentType?: string; + prompt: string; + agentId?: string; + outcome?: ToolResult; + stopSeen: boolean; + children: Set; + /** Chat responses already emitted from this Agent's Stop snapshots. */ + seenResponses: Set; +}; - start(turn: TurnTrace, call: ToolCall): boolean { - return Boolean(this.open(turn, call)); - } +export type TracedCall = TracedTool | TracedAgent; - private open(turn: TurnTrace, call: ToolCall): OpenTool | undefined { - if (this.openById.has(call.toolUseId) || this.tombstones.has(call.toolUseId)) { - return undefined; - } +/** Secondary indexes for the identities exposed by Claude's hooks. */ +export type CallState = { + byToolUseId: Map; + byAgentId: Map; + /** Prevent duplicate or delayed hooks from reopening completed calls. */ + toolUseTombstones: Set; + agentTombstones: Set; + /** Dedupe state for Stop snapshots whose Agent call is still ambiguous. */ + uncorrelatedAgentResponses: Map>; +}; - const span = turn.span.startTool({ - name: call.name, - args: jsonStr(call.input), - toolCallId: call.toolUseId, +export function newCallState(): CallState { + return { + byToolUseId: new Map(), + byAgentId: new Map(), + toolUseTombstones: new Set(), + agentTombstones: new Set(), + uncorrelatedAgentResponses: new Map(), + }; +} + +function attachCall(state: CallState, call: TracedCall): void { + call.parent.children.add(call); + if (call.toolUseId) state.byToolUseId.set(call.toolUseId, call); + if (call.kind === 'agent' && call.agentId) state.byAgentId.set(call.agentId, call); +} + +/** Open the span represented by PreToolUse. Agent is the only special tool: + * its invoke-agent span becomes the parent of later child hooks. */ +export function beginCall( + state: CallState, + parent: CallParent, + args: ToolCall, +): TracedCall | undefined { + if (parent.kind === 'agent' && parent.stopSeen && parent.outcome) return undefined; + if (state.byToolUseId.has(args.toolUseId) + || state.toolUseTombstones.has(args.toolUseId)) return undefined; + + const root = parent.kind === 'turn' ? parent : parent.root; + let call: TracedCall; + if (args.name === 'Agent') { + const agentType = agentTypeFor(args.input); + const prompt = typeof args.input['prompt'] === 'string' ? args.input['prompt'] : ''; + const span = startAgentSpan(parent.span, agentType, prompt); + span.setAttributes({ [ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID]: args.toolUseId }); + call = { + kind: 'agent', + span, + toolUseId: args.toolUseId, + agentType, + declaredAgentType: declaredAgentTypeFor(args.input), + prompt, + parent, + root, + stopSeen: false, + children: new Set(), + seenResponses: new Set(), + }; + } else { + const span = parent.span.startTool({ + name: args.name, + args: jsonStr(args.input), + toolCallId: args.toolUseId, }); - const open = { span, turn, toolUseId: call.toolUseId }; - const openIds = this.openIdsByTurn.get(turn) ?? new Set(); - openIds.add(call.toolUseId); - this.openIdsByTurn.set(turn, openIds); - this.openById.set(call.toolUseId, open); - return open; + if (parent.kind === 'agent') { + span.setAttributes({ [ATTR.AGENT_NAME]: parent.agentType }); + } + call = { + kind: 'tool', + span, + toolUseId: args.toolUseId, + parent, + root, + }; } - /** A terminal hook may be the first hook observed after a daemon restart. */ - finishOrRecover( - turn: () => TurnTrace, - call: ToolCall, - result: ToolResult, - ): boolean { - if (this.tombstones.has(call.toolUseId)) return false; - const open = this.openById.get(call.toolUseId) ?? this.open(turn(), call); - if (!open) return false; - - if (result.ok) { - open.span.result = jsonStr(result.output); - open.span.end(); - } else { - const error = result.error; - open.span.result = error; - open.span.setAttributes({ [ATTR.ERROR_TYPE]: errorType(error) }); - open.span.end({ error: new Error(error) }); - } - this.complete(call.toolUseId, open); - return true; + attachCall(state, call); + return call; +} + +function declaredAgentTypeFor(input: Record): string | undefined { + const value = input['subagent_type']; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function agentTypeFor(input: Record): string { + const name = input['name']; + return declaredAgentTypeFor(input) + ?? (typeof name === 'string' && name.trim() ? name.trim() : 'Agent'); +} + +function startAgentSpan(parent: SpanParent, agentName: string, prompt: string): SubAgent { + const span = parent.startSubagent({ name: agentName, agentVersion: VERSION }); + if (prompt) { + span.setAttributes({ [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]) }); } + return span; +} - /** End every unfinished child before its owning turn. */ - finalizeChildren(turn: TurnTrace, reason: string): string[] { - const closed: string[] = []; - for (const toolUseId of [...(this.openIdsByTurn.get(turn) ?? [])].reverse()) { - const open = this.openById.get(toolUseId); - if (!open) continue; - open.span.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: reason }); - open.span.end({ error: new Error(`call did not complete (${reason})`) }); - this.complete(toolUseId, open); - closed.push(toolUseId); - } - return closed; +type RecoverAgentArgs = { + agentId: string; + agentType: string; + prompt: string; + event: 'SubagentStart' | 'SubagentStop'; +}; + +/** Recreate an Agent marker when a lifecycle hook is first after restart. */ +export function recoverAgentCall( + state: CallState, + parent: CallParent, + args: RecoverAgentArgs, +): TracedAgent { + const root = parent.kind === 'turn' ? parent : parent.root; + const span = startAgentSpan(parent.span, args.agentType, args.prompt); + span.record({ agentId: args.agentId }); + const call: TracedAgent = { + kind: 'agent', + span, + agentType: args.agentType, + declaredAgentType: args.agentType, + prompt: args.prompt, + parent, + root, + agentId: args.agentId, + stopSeen: args.event === 'SubagentStop', + children: new Set(), + seenResponses: new Set(), + }; + attachCall(state, call); + return call; +} + +export function backfillAgentPrompt(call: TracedAgent, prompt: string): void { + if (call.prompt.trim() || !prompt.trim()) return; + call.prompt = prompt; + call.span.setAttributes({ + [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]), + }); +} + +/** Prefer lifecycle-owned response dedupe, retaining a fallback only while + * correlation is ambiguous. */ +export function responseKeysForAgent( + state: CallState, + agentId: string, + call?: TracedAgent, +): Set { + const uncorrelated = state.uncorrelatedAgentResponses.get(agentId); + if (!call) { + const seen = uncorrelated ?? new Set(); + state.uncorrelatedAgentResponses.set(agentId, seen); + return seen; + } + if (uncorrelated) { + for (const key of uncorrelated) call.seenResponses.add(key); + state.uncorrelatedAgentResponses.delete(agentId); } + return call.seenResponses; +} + +/** Apply the exact tool_use_id terminal event once. */ +export function recordCallOutcome( + state: CallState, + toolUseId: string, + outcome: ToolResult, +): void { + const call = state.byToolUseId.get(toolUseId); + if (!call) return; + + if (call.kind === 'tool') { + finishToolCall(call, outcome); + completeCall(state, call); + return; + } + call.outcome ??= outcome; + finishAgentIfReady(state, call); +} - hasOpenTools(turn?: TurnTrace): boolean { - return turn - ? Boolean(this.openIdsByTurn.get(turn)?.size) - : this.openById.size > 0; +function finishToolCall(call: TracedTool, outcome: ToolResult): void { + if (outcome.ok) { + call.span.result = jsonStr(outcome.output); + call.span.end(); + return; } + const error = outcome.error; + call.span.result = error; + call.span.setAttributes({ [ATTR.ERROR_TYPE]: errorType(error) }); + call.span.end({ error: new Error(error) }); +} - private complete(toolUseId: string, open: OpenTool): void { - const openIds = this.openIdsByTurn.get(open.turn); - openIds?.delete(toolUseId); - if (openIds?.size === 0) this.openIdsByTurn.delete(open.turn); - this.openById.delete(toolUseId); - this.tombstones.add(toolUseId); +function finishAgentSpan(call: TracedAgent, outcome: ToolResult): void { + const output = outcome.ok ? outcome.output : outcome.error; + if (output !== undefined && output !== null && output !== '') { + const text = typeof output === 'string' ? output : jsonStr(output); + call.span.setAttributes({ [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([text]) }); + } + if (outcome.ok) { + call.span.end(); + return; } + call.span.setAttributes({ [ATTR.ERROR_TYPE]: errorType(outcome.error) }); + call.span.end({ error: new Error(outcome.error) }); } function errorType(error: string): string { return error.trim().match(/^[A-Z][A-Za-z_]*Error/)?.[0] ?? 'tool_error'; } + +export type AgentMatch = + | { kind: 'found'; call: TracedAgent } + | { kind: 'missing' } + | { kind: 'ambiguous' }; + +export function matchAgent( + state: CallState, + agentType: string, + prompt: string | undefined, + promptId?: string, +): AgentMatch { + const candidates = [...state.byToolUseId.values()].filter((call): call is TracedAgent => + call.kind === 'agent' + && !call.agentId + && call.root.promptId === promptId + && (call.declaredAgentType === undefined || call.declaredAgentType === agentType)); + const matches = prompt === undefined + ? candidates + : candidates.filter(call => call.prompt.trim() === prompt.trim()); + if (matches.length === 1) return { kind: 'found', call: matches[0] }; + if (matches.length > 1 || (matches.length === 0 && candidates.length > 1)) { + return { kind: 'ambiguous' }; + } + return { kind: 'missing' }; +} + +export function bindAgent( + state: CallState, + match: Extract, + agentId: string, + agentType: string, +): void { + if (state.byAgentId.has(agentId) || match.call.agentId) return; + match.call.declaredAgentType ??= agentType; + match.call.agentId = agentId; + match.call.span.record({ agentId }); + state.byAgentId.set(agentId, match.call); +} + +/** Record a blockable Stop snapshot, completing only when Post already arrived. */ +export function recordAgentStop( + state: CallState, + match: Extract, +): void { + match.call.stopSeen = true; + finishAgentIfReady(state, match.call); +} + +function finishAgentIfReady(state: CallState, call: TracedAgent): void { + if (!call.stopSeen || !call.outcome || call.children.size) return; + finishAgentSpan(call, call.outcome); + completeCall(state, call); +} + +function completeCall( + state: CallState, + call: TracedCall, + finishParent = true, +): void { + call.parent.children.delete(call); + if (call.toolUseId) { + state.byToolUseId.delete(call.toolUseId); + state.toolUseTombstones.add(call.toolUseId); + } + if (call.kind === 'agent' && call.agentId) { + state.byAgentId.delete(call.agentId); + state.agentTombstones.add(call.agentId); + } + if (finishParent && call.parent.kind === 'agent') { + finishAgentIfReady(state, call.parent); + } +} + +/** Close children before parents. Preserve real Agent results and completed + * recovered snapshots; only genuinely unfinished calls are marked orphaned. */ +export function finalizeOpenCalls( + state: CallState, + roots: Iterable, + reason: string, +): string[] { + const closed: string[] = []; + const closeChildren = (parent: CallParent) => { + for (const call of [...parent.children].reverse()) { + if (call.kind === 'agent') closeChildren(call); + if (call.kind === 'agent' && call.outcome) { + finishAgentSpan(call, call.outcome); + } else if (call.kind === 'agent' && !call.toolUseId && call.stopSeen) { + call.span.end(); + } else { + call.span.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: reason }); + call.span.end({ + error: new Error(`call did not complete (${reason})`), + }); + } + completeCall(state, call, false); + closed.push(call.toolUseId ?? `agent:${call.kind === 'agent' ? call.agentId : 'unknown'}`); + } + }; + for (const root of [...roots].reverse()) closeChildren(root); + return closed; +} diff --git a/src/hookHandler.ts b/src/hookHandler.ts index b875b2d..4841f9b 100644 --- a/src/hookHandler.ts +++ b/src/hookHandler.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { + BaseHookInput, HookInput, InstructionsLoadedHookInput, PreToolUseHookInput, @@ -12,19 +13,42 @@ import type { SessionEndHookInput, SessionStartHookInput, StopHookInput, + SubagentStartHookInput, + SubagentStopHookInput, UserPromptSubmitHookInput, } from '@anthropic-ai/claude-agent-sdk'; import * as weave from 'weave'; +import { emitChatSpans } from './chatSpans.js'; +import { + backfillAgentPrompt, + beginCall, + bindAgent, + matchAgent, + recordAgentStop, + recordCallOutcome, + recoverAgentCall, + responseKeysForAgent, +} from './callLifecycle.js'; import type { + AgentMatch, + CallParent, JsonObject, JsonValue, ToolCall, ToolResult, + TracedAgent, + TracedCall, } from './callLifecycle.js'; import type { CompactionAttrs } from './genaiSpans.js'; -import { snippet } from './genaiSpans.js'; +import { ATTR, assistantOutputMessages, snippet } from './genaiSpans.js'; +import type { SpanParent } from './genaiSpans.js'; +import { parseSessionFd } from './parser.js'; import { Session } from './session.js'; -import { TranscriptFile } from './transcriptFile.js'; +import { + TranscriptFile, + readSubagentPrompt, + subagentTranscriptPath, +} from './transcriptFile.js'; type TraceLog = (level: 'DEBUG' | 'INFO' | 'ERROR', message: string) => void; @@ -33,6 +57,23 @@ type HookInputFor = Extract< { hook_event_name: Event } >; type PostToolResultHookInput = HookInputFor<'PostToolUse' | 'PostToolUseFailure'>; +type RecoverCallHookInput = PostToolResultHookInput; + +function mergeSubagentOutput(transcriptText?: string, lastMessage?: string): string | undefined { + const transcript = transcriptText?.trim(); + const latest = lastMessage?.trim(); + if (!transcript) return latest || undefined; + if (!latest) return transcript; + + const contains = (text: string, message: string) => + text === message + || text.startsWith(`${message}\n`) + || text.endsWith(`\n${message}`) + || text.includes(`\n${message}\n`); + if (contains(transcript, latest)) return transcript; + if (contains(latest, transcript)) return latest; + return `${transcript}\n${latest}`; +} function isJsonValue(value: unknown): value is JsonValue { if (value === null) return true; @@ -132,6 +173,12 @@ export class HookHandler { case 'PostToolUseFailure': await this.handlePostToolResult(sessionId, input); return; + case 'SubagentStart': + await this.handleSubagentStart(sessionId, input); + return; + case 'SubagentStop': + await this.handleSubagentStop(sessionId, input); + return; case 'PreCompact': this.handlePreCompact(sessionId, input); return; @@ -304,25 +351,95 @@ export class HookHandler { sessionId: string, input: PreToolUseHookInput, ): Promise { - if (input.tool_name === 'Agent' || input.agent_id) return; - const call = this.toolCall(input); - if (!call) return; + const descriptor = this.toolCall(input); + if (!descriptor) return; const session = await this.getOrReconstructSession(sessionId, input); if (!session) return; - session.startTool(input.prompt_id, call); + + const parent = await this.resolveCallParent(session, input); + if (!parent) { + this.log( + 'ERROR', + `PreToolUse: unknown parent session=${sessionId} tool=${input.tool_name} agent=${input.agent_id ?? 'root'}`, + ); + return; + } + const call = beginCall(session.calls, parent, descriptor); + if (call && !input.agent_id) call.root.phase = 'active'; + } + + /** Resolve a call's owning span. After restart, nested hooks can arrive + * before SubagentStart; recover only from the stable id, type, and prompt. */ + private async resolveCallParent( + session: Session, + input: Pick, + ): Promise { + if (!input.agent_id) return session.ensureTurn(input.prompt_id); + const active = session.calls.byAgentId.get(input.agent_id); + if (active) return active; + if (!input.agent_type || session.calls.agentTombstones.has(input.agent_id)) { + return undefined; + } + + const transcriptPath = subagentTranscriptPath( + session.transcriptPath, + input.agent_id, + ); + const prompt = await readSubagentPrompt(transcriptPath); + if (!prompt) { + this.log( + 'ERROR', + `Nested hook: cannot recover owner agentId=${input.agent_id} type=${input.agent_type} without its dispatch prompt`, + ); + return undefined; + } + return this.recoverAgent( + session, + input.agent_id, + input.agent_type, + input.prompt_id, + prompt, + 'SubagentStart', + ); + } + + /** Recreate a call from its exact tool_use_id after a restart. */ + private async recoverCall( + session: Session, + input: RecoverCallHookInput, + descriptor: ToolCall, + ): Promise { + const existing = session.calls.byToolUseId.get(descriptor.toolUseId); + if (existing || session.calls.toolUseTombstones.has(descriptor.toolUseId)) { + return existing; + } + const parent = input.agent_id + ? await this.resolveCallParent(session, input) + : session.ensureToolTurn(input.prompt_id, descriptor.toolUseId); + if (!parent) return undefined; + return beginCall(session.calls, parent, descriptor); } private async handlePostToolResult( sessionId: string, input: PostToolResultHookInput, ): Promise { - if (input.tool_name === 'Agent' || input.agent_id) return; - const call = this.toolCall(input); const result = this.toolResult(input); - if (!call || !result) return; - const session = await this.getOrReconstructSession(sessionId, input); + if (typeof input.tool_use_id !== 'string' || !result) return; + + const existingSession = this.sessions.get(sessionId); + if (existingSession?.calls.toolUseTombstones.has(input.tool_use_id)) return; + const existingCall = existingSession?.calls.byToolUseId.get(input.tool_use_id); + const descriptor = existingCall ? undefined : this.toolCall(input); + if (!existingCall && !descriptor) return; + + const session = existingSession + ?? await this.getOrReconstructSession(sessionId, input); if (!session) return; - session.finishTool(input.prompt_id, call, result); + + if (!existingCall) await this.recoverCall(session, input, descriptor!); + recordCallOutcome(session.calls, input.tool_use_id, result); + session.finishSupersededTurns(); } private toolCall( @@ -358,6 +475,167 @@ export class HookHandler { return { ok: false, error: input.error }; } + private async handleSubagentStart( + sessionId: string, + input: SubagentStartHookInput, + ): Promise { + const session = await this.getOrReconstructSession(sessionId, input); + if (!session + || session.calls.agentTombstones.has(input.agent_id) + || session.calls.byAgentId.has(input.agent_id)) return; + + const transcriptPath = subagentTranscriptPath(session.transcriptPath, input.agent_id); + const prompt = await readSubagentPrompt(transcriptPath); + const match = matchAgent(session.calls, input.agent_type, prompt, input.prompt_id); + if (match.kind === 'ambiguous') { + this.log( + 'ERROR', + `SubagentStart: ambiguous dispatch agentId=${input.agent_id} type=${input.agent_type}`, + ); + return; + } + + if (match.kind === 'found') { + bindAgent(session.calls, match, input.agent_id, input.agent_type); + } else { + this.recoverAgent( + session, + input.agent_id, + input.agent_type, + input.prompt_id, + prompt ?? '', + 'SubagentStart', + ); + } + this.log('INFO', `Subagent started: agentId=${input.agent_id} type=${input.agent_type}`); + } + + private recoverAgent( + session: Session, + agentId: string, + agentType: string, + promptId: string | undefined, + prompt: string, + event: 'SubagentStart' | 'SubagentStop', + ): TracedAgent { + const recovered = recoverAgentCall(session.calls, session.ensureTurn(promptId), { + agentId, + agentType, + prompt, + event, + }); + this.log('INFO', `${event}: recovered agentId=${agentId} type=${agentType}`); + return recovered; + } + + private emitSubagentTranscript( + parent: SpanParent, + transcriptPath: string, + agentType: string, + seen?: Set, + ): { model?: string; text?: string } { + let transcript: TranscriptFile | undefined; + try { + transcript = new TranscriptFile(transcriptPath); + const turn = parseSessionFd(transcript.getFd())?.turns.at(-1); + if (!turn) return {}; + emitChatSpans(parent, turn.responses, { agentName: agentType, seen }); + return { model: turn.model, text: turn.text.join('\n') || undefined }; + } catch (error) { + this.log('DEBUG', `SubagentStop: could not parse transcript: ${error}`); + return {}; + } finally { + transcript?.close(); + } + } + + private async handleSubagentStop( + sessionId: string, + input: SubagentStopHookInput, + ): Promise { + const session = await this.getOrReconstructSession(sessionId, input); + if (!session || session.calls.agentTombstones.has(input.agent_id)) return; + + const transcriptPath = input.agent_transcript_path + ?? subagentTranscriptPath(session.transcriptPath, input.agent_id); + const active = session.calls.byAgentId.get(input.agent_id); + let prompt = active?.prompt; + if (!prompt) { + prompt = await readSubagentPrompt(transcriptPath); + if (active && prompt) backfillAgentPrompt(active, prompt); + } + const match: AgentMatch = active + ? { kind: 'found', call: active } + : matchAgent(session.calls, input.agent_type, prompt, input.prompt_id); + + if (match.kind === 'found' && !match.call.agentId) { + bindAgent(session.calls, match, input.agent_id, input.agent_type); + this.log( + 'INFO', + `SubagentStop: late-matched agentId=${input.agent_id} type=${input.agent_type}`, + ); + } + + const turn = session.turnForPrompt(input.prompt_id); + const recovered = match.kind === 'missing' + ? this.recoverAgent( + session, + input.agent_id, + input.agent_type, + input.prompt_id, + prompt ?? '', + 'SubagentStop', + ) + : undefined; + const lifecycle = match.kind === 'found' ? match.call : recovered; + const parent = match.kind === 'found' + ? match.call.span + : recovered?.span ?? turn?.span; + if (!parent) { + this.log( + 'ERROR', + `SubagentStop: no parent agentId=${input.agent_id} type=${input.agent_type}`, + ); + return; + } + + const seen = responseKeysForAgent(session.calls, input.agent_id, lifecycle); + const transcript = this.emitSubagentTranscript( + parent, + transcriptPath, + input.agent_type, + seen, + ); + const text = mergeSubagentOutput(transcript.text, input.last_assistant_message); + + if (match.kind === 'found') { + if (transcript.model) { + match.call.span.setAttributes({ [ATTR.RESPONSE_MODEL]: transcript.model }); + } + if (!match.call.toolUseId && text) { + match.call.span.setAttributes({ + [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([text]), + }); + } + recordAgentStop(session.calls, match); + } else if (recovered) { + if (transcript.model) { + recovered.span.setAttributes({ [ATTR.RESPONSE_MODEL]: transcript.model }); + } + if (text) { + recovered.span.setAttributes({ + [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([text]), + }); + } + } + + this.log( + 'DEBUG', + `Subagent stopped: agentId=${input.agent_id} type=${input.agent_type} model=${transcript.model ?? 'unknown'} match=${match.kind}`, + ); + session.finishSupersededTurns(); + } + private async handleStop(sessionId: string, input: StopHookInput): Promise { const session = await this.getOrReconstructSession(sessionId, input); if (!session) return; diff --git a/src/session.ts b/src/session.ts index c6e4fcd..9d9942d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -15,11 +15,8 @@ import { setCompactionAttrs, } from './genaiSpans.js'; import type { CompactionAttrs } from './genaiSpans.js'; -import { ToolLifecycle } from './callLifecycle.js'; -import type { - ToolCall, - ToolResult, -} from './callLifecycle.js'; +import { finalizeOpenCalls, newCallState } from './callLifecycle.js'; +import type { TracedCall } from './callLifecycle.js'; import { assistantResponses, extractAssistantTextBlocks, @@ -34,11 +31,14 @@ import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; type TraceLog = (level: 'DEBUG' | 'INFO' | 'ERROR', message: string) => void; export type TurnTrace = { + kind: 'turn'; span: weave.Turn; promptId?: string; userText?: string; /** A Stop snapshot is quiescent but remains reopenable because hooks block. */ phase: 'active' | 'stopped'; + /** Calls are owned by their parent span; hook ids are only lookup indexes. */ + children: Set; /** Number of provider responses already present when this prompt began. */ responseOffset: number; /** Upper transcript boundary once this turn is known to be complete. */ @@ -83,7 +83,7 @@ export class Session { /** File path → latest loaded contents, preserving first-load order. */ private readonly systemInstructions = new Map(); private readonly turns = new Set(); - private readonly tools = new ToolLifecycle(); + readonly calls = newCallState(); private currentTurn?: TurnTrace; private pendingCompaction?: CompactionAttrs; @@ -130,27 +130,6 @@ export class Session { this.systemInstructions.set(filePath, content); } - startTool(promptId: string | undefined, call: ToolCall): boolean { - const turn = this.ensureToolTurn(promptId, call.toolUseId); - const started = this.tools.start(turn, call); - if (started) turn.phase = 'active'; - return started; - } - - finishTool( - promptId: string | undefined, - call: ToolCall, - result: ToolResult, - ): boolean { - const finished = this.tools.finishOrRecover( - () => this.ensureToolTurn(promptId, call.toolUseId), - call, - result, - ); - if (finished) this.finalizeIdleSupersededTurns(); - return finished; - } - submitPrompt( promptId: string | undefined, prompt: string, @@ -166,7 +145,7 @@ export class Session { parseSessionFd(this.transcript.getFd()) ?? { turns: [] }, ).length; responseOffsetFloor = previous.responseLimit; - if (promptId === undefined || !this.tools.hasOpenTools(previous)) { + if (promptId === undefined || previous.children.size === 0) { this.finalizeTurn(previous, 'superseded_by_next_prompt'); } } @@ -231,7 +210,7 @@ export class Session { } hasInFlightWork(): boolean { - return this.tools.hasOpenTools() + return [...this.turns].some(turn => turn.children.size > 0) || [...this.turns].some(turn => turn.phase === 'active'); } @@ -239,7 +218,7 @@ export class Session { this.transcript.close(); } - private turnForPrompt(promptId: string | undefined): TurnTrace | undefined { + turnForPrompt(promptId: string | undefined): TurnTrace | undefined { return promptId === undefined ? this.currentTurn : [...this.turns].find(turn => turn.promptId === promptId); @@ -308,10 +287,12 @@ export class Session { [ATTR.WEAVE_SOURCE]: this.source, }); const turn: TurnTrace = { + kind: 'turn', span, promptId: options.promptId, userText: cursor.userText, phase: 'active', + children: new Set(), responseOffset: cursor.responseOffset, seenResponses: new Set(), }; @@ -321,7 +302,7 @@ export class Session { return turn; } - private ensureTurn(promptId: string | undefined): TurnTrace { + ensureTurn(promptId: string | undefined): TurnTrace { return this.turnForPrompt(promptId) ?? this.startTurn({ promptId, // An exact prompt_id must not claim the last transcript turn. A legacy @@ -331,7 +312,7 @@ export class Session { }); } - private ensureToolTurn(promptId: string | undefined, toolUseId: string): TurnTrace { + ensureToolTurn(promptId: string | undefined, toolUseId: string): TurnTrace { const existing = this.turnForPrompt(promptId); if (existing) return existing; @@ -437,17 +418,17 @@ export class Session { } private closeTurn(turn: TurnTrace, orphanReason: string): void { - for (const toolUseId of this.tools.finalizeChildren(turn, orphanReason)) { - this.log('DEBUG', `Closed pending tool: ${toolUseId}`); + for (const toolUseId of finalizeOpenCalls(this.calls, [turn], orphanReason)) { + this.log('DEBUG', `Closed pending call: ${toolUseId}`); } turn.span.end(); this.turns.delete(turn); if (this.currentTurn === turn) this.currentTurn = undefined; } - private finalizeIdleSupersededTurns(): void { + finishSupersededTurns(): void { for (const turn of [...this.turns]) { - if (turn.responseLimit !== undefined && !this.tools.hasOpenTools(turn)) { + if (turn.responseLimit !== undefined && turn.children.size === 0) { this.finalizeTurn(turn, 'superseded_by_next_prompt'); } } diff --git a/src/transcriptFile.ts b/src/transcriptFile.ts index 3a65a67..18ce8b9 100644 --- a/src/transcriptFile.ts +++ b/src/transcriptFile.ts @@ -8,6 +8,9 @@ import * as path from 'path'; import { isPathWithinBase } from './utils.js'; const O_RDONLY_NOFOLLOW = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; +// Injected subagent context can make the first JSONL record much larger than a +// normal prompt. Bound the synchronous work used to correlate an Agent hook. +const TRANSCRIPT_SCAN_LIMIT_BYTES = 8 * 1024 * 1024; export type TranscriptHead = Record & { version?: string; @@ -68,44 +71,80 @@ export class TranscriptFile { } } -/** - * Open `transcriptPath` read-only (no symlink following, regular file only) - * and read the first line as JSON. Returns the parsed object or undefined on - * any failure (missing file, unparseable line, empty file). Caller-safe for - * ancestor transcripts in the fork chain: opens its own fd and closes it - * before returning. - */ -export function readFirstTranscriptLine(transcriptPath: string): TranscriptHead | undefined { - const resolved = path.resolve(transcriptPath); - if (!isPathWithinBase(resolved, os.homedir())) return undefined; - - let fd: number | undefined; +/** Read a bounded transcript snapshot without leaving an fd open. + * If the bound cuts a record, exclude that partial JSONL line. */ +function readTranscriptPrefix(transcriptPath: string): string | undefined { + let transcript: TranscriptFile | undefined; try { - fd = fs.openSync(resolved, O_RDONLY_NOFOLLOW); - const stat = fs.fstatSync(fd); - if (!stat.isFile() || stat.size === 0) return undefined; - - const want = Math.min(stat.size, 64 * 1024); - const buf = Buffer.allocUnsafe(want); + transcript = new TranscriptFile(transcriptPath); + const fd = transcript.getFd(); + const fileSize = fs.fstatSync(fd).size; + const want = Math.min(fileSize, TRANSCRIPT_SCAN_LIMIT_BYTES); + if (!want) return undefined; + const buffer = Buffer.allocUnsafe(want); let read = 0; while (read < want) { - const n = fs.readSync(fd, buf, read, want - read, read); - if (n === 0) break; - read += n; + const count = fs.readSync(fd, buffer, read, want - read, read); + if (!count) break; + read += count; } - if (read === 0) return undefined; - - const text = buf.toString('utf8', 0, read); - const nl = text.indexOf('\n'); - const line = nl === -1 ? text : text.slice(0, nl); - if (!line.trim()) return undefined; + if (read < fileSize) { + const lastNewline = read ? buffer.lastIndexOf(0x0a, read - 1) : -1; + read = lastNewline + 1; + } + return read ? buffer.toString('utf8', 0, read) : undefined; + } catch { + return undefined; + } finally { + transcript?.close(); + } +} +/** Read the first transcript line as JSON without leaving an fd open. */ +export function readFirstTranscriptLine(transcriptPath: string): TranscriptHead | undefined { + const line = readTranscriptPrefix(transcriptPath)?.split('\n', 1)[0]; + if (!line?.trim()) return undefined; + try { return JSON.parse(line) as TranscriptHead; } catch { return undefined; - } finally { - if (fd !== undefined) { - try { fs.closeSync(fd); } catch { /* ignore */ } + } +} + +export function subagentTranscriptPath(parentTranscriptPath: string, agentId: string): string { + const projectDir = path.dirname(parentTranscriptPath); + const sessionId = path.basename(parentTranscriptPath, '.jsonl'); + return path.join(projectDir, sessionId, 'subagents', `agent-${agentId}.jsonl`); +} + +/** Read the dispatch prompt used to join an id-less lifecycle hook to Agent. */ +export async function readSubagentPrompt(transcriptPath: string): Promise { + for (const delay of [0, 50, 100, 150]) { + if (delay) await new Promise(resolve => setTimeout(resolve, delay)); + const prompt = readTypedUserPrompt(transcriptPath); + if (prompt) return prompt; + } + return undefined; +} + +/** Injected context uses array content; the dispatch prompt is the first + * bare-string user message in the bounded scan. */ +function readTypedUserPrompt(transcriptPath: string): string | undefined { + const prefix = readTranscriptPrefix(transcriptPath); + if (!prefix) return undefined; + for (const raw of prefix.split('\n')) { + if (!raw.trim()) continue; + let line: Record; + try { + line = JSON.parse(raw) as Record; + } catch { + continue; } + if (line['type'] !== 'user') continue; + const message = line['message']; + if (!message || typeof message !== 'object') continue; + const content = (message as Record)['content']; + if (typeof content === 'string' && content) return content; } + return undefined; } diff --git a/tests/agent-lifecycle.test.ts b/tests/agent-lifecycle.test.ts new file mode 100644 index 0000000..451488f --- /dev/null +++ b/tests/agent-lifecycle.test.ts @@ -0,0 +1,288 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import { ATTR } from '../src/genaiSpans.ts'; +import { + assistantEntry, + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + makeTranscript, + spanParentId, + userEntry, +} from './helpers.ts'; +import { boundAgent, finish } from './agent-test-helpers.ts'; + +test('subagent topology: marker owns its chat, tools, identity, and canonical result', async (t) => { + const { exporter, daemon, sid, agentId, subPath } = await boundAgent(t, 'topology'); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: agentId, + tool_use_id: 'read-call', tool_name: 'Read', tool_input: { file_path: '/flaky.test.ts' }, + }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'read-call', tool_response: 'contents' }); + await daemon.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'agent-call', tool_response: 'canonical result' }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const agent = spans.find(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore'); + const chat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'sub-msg-1'); + const tool = spans.find(span => span.attributes['gen_ai.tool.name'] === 'Read'); + assert.ok(turn && agent && chat && tool); + assert.equal(spanParentId(agent), turn.spanContext().spanId); + assert.equal(spanParentId(chat), agent.spanContext().spanId); + assert.equal(spanParentId(tool), agent.spanContext().spanId); + assert.equal(agent.attributes[ATTR.AGENT_ID], agentId); + assert.equal(agent.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID], 'agent-call'); + assert.equal(agent.attributes[ATTR.OUTPUT_MESSAGES], JSON.stringify([ + { role: 'assistant', content: 'canonical result' }, + ])); + assert.equal(tool.attributes[ATTR.AGENT_NAME], 'Explore'); + assert.equal(chat.attributes[ATTR.USAGE_INPUT_TOKENS], 120); + for (const span of [agent, chat, tool]) { + assert.equal(span.attributes[ATTR.CONVERSATION_ID], sid); + assert.equal(span.attributes[ATTR.WEAVE_INTEGRATION_NAME], 'weave-claude-code'); + } +}); + +test('an Agent stays open until its last nested tool completes', async (t) => { + const { exporter, daemon, sid, agentId, subPath } = await boundAgent(t, 'late-child'); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: agentId, + tool_use_id: 'late-read', tool_name: 'Read', tool_input: { file_path: '/late.ts' }, + }); + await daemon.routeEvent({ + hook_event_name: 'SubagentStop', session_id: sid, + agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath, + }); + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', session_id: sid, + tool_use_id: 'agent-call', tool_response: 'agent result', + }); + await flushWeave(); + + assert.equal(exporter.getFinishedSpans().some(span => + span.attributes[ATTR.AGENT_ID] === agentId), false); + + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', session_id: sid, + agent_id: agentId, tool_use_id: 'late-read', tool_response: 'contents', + }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const agent = spans.find(span => span.attributes[ATTR.AGENT_ID] === agentId); + const tool = spans.find(span => span.attributes['gen_ai.tool.call.id'] === 'late-read'); + assert.ok(agent && tool); + assert.equal(spanParentId(tool), agent.spanContext().spanId); + assert.equal(agent.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined); + assert.equal(tool.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined); +}); + +for (const { title, label, toolInput, displayName } of [ + { + title: 'Agent name is display-only when lifecycle agent_type differs', + label: 'named', toolInput: { name: 'instance-alias', prompt: 'research it' }, + displayName: 'instance-alias', + }, + { + title: 'Agent without subtype or name uses the stable display fallback', + label: 'unnamed', toolInput: { prompt: 'research it' }, displayName: 'Agent', + }, +]) { + test(title, async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = `sub-${label}-agent`; + const agentId = `${label}-agent-id`; + const transcript = makeTranscript(t, sid, sid); + transcript.append(userEntry('delegate it')); + const subPath = transcript.subagent( + agentId, + userEntry('research it'), + assistantEntry(`${label}-msg`, { type: 'text', text: 'researched' }), + ); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sid, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'delegate it', + }); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: `${label}-agent-call`, + tool_name: 'Agent', tool_input: toolInput, + }); + await daemon.routeEvent({ + hook_event_name: 'SubagentStart', session_id: sid, + agent_id: agentId, agent_type: 'general-purpose', + }); + await daemon.routeEvent({ + hook_event_name: 'SubagentStop', session_id: sid, + agent_id: agentId, agent_type: 'general-purpose', agent_transcript_path: subPath, + }); + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', session_id: sid, + tool_use_id: `${label}-agent-call`, tool_response: 'research result', + }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const agent = spans.find(span => span.attributes[ATTR.AGENT_ID] === agentId); + assert.ok(agent); + assert.equal(agent.attributes[ATTR.AGENT_NAME], displayName); + assert.equal(agent.attributes[ATTR.OPERATION_NAME], 'invoke_agent'); + assert.equal(spans.filter(span => span.attributes[ATTR.AGENT_ID] === agentId).length, 1); + assert.equal(spans.some(span => + span.attributes['gen_ai.tool.call.id'] === `${label}-agent-call`), false); + }); +} + +test('Agent Post before SubagentStop does not create a recovered duplicate', async (t) => { + const { exporter, daemon, sid, agentId, subPath } = await boundAgent(t, 'post-first'); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'agent-call', tool_response: 'done' }); + await daemon.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath }); + await finish(daemon, sid); + + const agents = exporter.getFinishedSpans().filter(span => + span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.equal(agents.length, 1); + const chat = exporter.getFinishedSpans().find(span => + span.attributes[ATTR.RESPONSE_ID] === 'sub-msg-1'); + assert.ok(chat); + assert.equal(spanParentId(chat), agents[0].spanContext().spanId); + assert.equal( + agents[0].attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'done' }]), + ); +}); + +test('terminal-first Agent without subtype learns its lifecycle type, not its name', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-terminal-unknown-type'; + const agentId = 'terminal-unknown-type-agent'; + const transcript = makeTranscript(t, sid, sid); + transcript.append(userEntry('delegate it')); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', session_id: sid, + transcript_path: transcript.file, cwd: '/x', + tool_use_id: 'terminal-agent-call', tool_name: 'Agent', + tool_input: { name: 'instance-alias', prompt: 'research it' }, + tool_response: 'canonical result', + }); + const subPath = transcript.subagent( + agentId, + userEntry('research it'), + assistantEntry('terminal-msg', { type: 'text', text: 'researched' }), + ); + await daemon.routeEvent({ + hook_event_name: 'SubagentStop', session_id: sid, + agent_id: agentId, agent_type: 'general-purpose', agent_transcript_path: subPath, + }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const agents = spans.filter(span => + span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'instance-alias'); + const agent = agents[0]; + const chat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'terminal-msg'); + assert.equal(agents.length, 1); + assert.ok(agent && chat); + assert.equal(agent.attributes[ATTR.AGENT_ID], agentId); + assert.equal( + agent.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID], + 'terminal-agent-call', + ); + assert.equal( + agent.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'canonical result' }]), + ); + assert.equal(spanParentId(chat), agent.spanContext().spanId); +}); + +test('duplicate late SubagentStop after completion emits no extra marker or chat', async (t) => { + const { exporter, daemon, sid, agentId, subPath } = await boundAgent(t, 'late-stop'); + const stop = { + hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, + agent_type: 'Explore', agent_transcript_path: subPath, + }; + await daemon.routeEvent(stop); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'agent-call', tool_response: 'done' }); + await daemon.routeEvent(stop); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + assert.equal(spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore').length, 1); + assert.equal(spans.filter(span => span.attributes[ATTR.RESPONSE_ID] === 'sub-msg-1').length, 1); +}); + +test('Agent failure before SubagentStop closes the original marker once', async (t) => { + const { exporter, daemon, sid, agentId, subPath } = await boundAgent(t, 'failure-first'); + await daemon.routeEvent({ + hook_event_name: 'PostToolUseFailure', session_id: sid, tool_use_id: 'agent-call', + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: 'do it' }, + error: 'AgentError: failed', + }); + await daemon.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const agents = spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.equal(agents.length, 1); + assert.equal(agents[0].attributes[ATTR.ERROR_TYPE], 'AgentError'); + assert.equal( + agents[0].attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'AgentError: failed' }]), + ); + assert.equal(spans.filter(span => span.attributes[ATTR.RESPONSE_ID] === 'sub-msg-1').length, 1); +}); + +test('repeated SubagentStop snapshots retain parenting and emit only new responses', async (t) => { + const { exporter, daemon, sid, agentId, subPath } = await boundAgent(t, 'repeated-stop'); + await daemon.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath }); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: agentId, + tool_use_id: 'late-tool', tool_name: 'Read', tool_input: { file_path: '/after-stop' }, + }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'late-tool', tool_response: 'ok' }); + fs.appendFileSync(subPath, JSON.stringify(assistantEntry( + 'sub-msg-2', + { type: 'text', text: 'continued' }, + { finishReason: 'end_turn' }, + )) + '\n'); + await daemon.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'agent-call', tool_response: 'done' }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const agent = spans.find(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore'); + const tool = spans.find(span => span.attributes['gen_ai.tool.call.id'] === 'late-tool'); + assert.ok(agent && tool); + assert.equal(spanParentId(tool), agent.spanContext().spanId); + assert.equal( + agent.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'done' }]), + ); + assert.deepEqual( + spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'chat') + .map(span => span.attributes[ATTR.RESPONSE_ID]).sort(), + ['sub-msg-1', 'sub-msg-2'], + ); +}); diff --git a/tests/agent-nesting.test.ts b/tests/agent-nesting.test.ts new file mode 100644 index 0000000..69ef795 --- /dev/null +++ b/tests/agent-nesting.test.ts @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ATTR } from '../src/genaiSpans.ts'; +import { + assistantEntry, + initWeaveInMemory, + makeGenaiDaemon, + makeTranscript, + spanParentId, + userEntry, +} from './helpers.ts'; +import { boundAgent, finish } from './agent-test-helpers.ts'; + +test('nested Agent call stays inside its owning subagent', async (t) => { + const { + exporter, daemon, sid, agentId: outerId, transcript, subPath: outerPath, + } = await boundAgent(t, 'nested-agent'); + const innerId = 'inner-agent'; + const innerPath = transcript.subagent( + innerId, + userEntry('inner task'), + assistantEntry('inner-msg', { type: 'text', text: 'inner done' }), + ); + + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: outerId, + tool_use_id: 'inner-call', tool_name: 'Agent', + tool_input: { subagent_type: 'Reviewer', prompt: 'inner task' }, + }); + await daemon.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: innerId, agent_type: 'Reviewer' }); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: innerId, + tool_use_id: 'inner-read', tool_name: 'Read', tool_input: { file_path: '/nested.ts' }, + }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: innerId, tool_use_id: 'inner-read', tool_response: 'contents' }); + await daemon.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: innerId, agent_type: 'Reviewer', agent_transcript_path: innerPath }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: outerId, tool_use_id: 'inner-call', tool_response: 'inner result' }); + await daemon.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: outerId, agent_type: 'Explore', agent_transcript_path: outerPath }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'agent-call', tool_response: 'outer result' }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const outer = spans.find(span => span.attributes[ATTR.AGENT_ID] === outerId); + const inner = spans.find(span => span.attributes[ATTR.AGENT_ID] === innerId); + const innerChat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'inner-msg'); + const innerTool = spans.find(span => span.attributes['gen_ai.tool.call.id'] === 'inner-read'); + assert.ok(turn && outer && inner && innerChat && innerTool); + assert.equal(spanParentId(outer), turn.spanContext().spanId); + assert.equal(spanParentId(inner), outer.spanContext().spanId); + assert.equal(spanParentId(innerChat), inner.spanContext().spanId); + assert.equal(spanParentId(innerTool), inner.spanContext().spanId); + assert.equal(inner.attributes[ATTR.OUTPUT_MESSAGES], JSON.stringify([ + { role: 'assistant', content: 'inner result' }, + ])); + assert.equal(outer.attributes[ATTR.OUTPUT_MESSAGES], JSON.stringify([ + { role: 'assistant', content: 'outer result' }, + ])); +}); + +test('a nested Agent result cannot claim a matching root recovery', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-owner-scoped-recovery'; + const rootAgentId = 'root-reviewer'; + const ownerAgentId = 'nested-owner'; + const transcript = makeTranscript(t, sid, sid); + transcript.append(userEntry('delegate it')); + transcript.subagent(rootAgentId, userEntry('same task')); + transcript.subagent(ownerAgentId, userEntry('outer task')); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sid, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sid, + prompt_id: 'prompt-1', prompt: 'delegate it', + }); + await daemon.routeEvent({ + hook_event_name: 'SubagentStart', session_id: sid, prompt_id: 'prompt-1', + agent_id: rootAgentId, agent_type: 'Reviewer', + }); + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', session_id: sid, + transcript_path: transcript.file, cwd: '/x', prompt_id: 'prompt-1', + agent_id: ownerAgentId, agent_type: 'Explore', + tool_use_id: 'nested-agent-result', tool_name: 'Agent', + tool_input: { subagent_type: 'Reviewer', prompt: 'same task' }, + tool_response: 'nested result', + }); + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', session_id: sid, + transcript_path: transcript.file, cwd: '/x', prompt_id: 'prompt-1', + agent_id: 'unknown-owner', + tool_use_id: 'unowned-agent-result', tool_name: 'Agent', + tool_input: { subagent_type: 'Reviewer', prompt: 'same task' }, + tool_response: 'must stay unowned', + }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const rootAgent = spans.find(span => span.attributes[ATTR.AGENT_ID] === rootAgentId); + const owner = spans.find(span => span.attributes[ATTR.AGENT_ID] === ownerAgentId); + const nested = spans.find(span => + span.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID] === 'nested-agent-result'); + assert.ok(rootAgent && owner && nested); + assert.equal(spanParentId(nested), owner.spanContext().spanId); + assert.equal(rootAgent.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID], undefined); + assert.equal( + nested.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'nested result' }]), + ); + assert.equal(spans.some(span => + span.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID] === 'unowned-agent-result'), false); +}); + +test('unknown agent_id is rejected instead of flattened under the turn', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-unknown'; + const transcript = makeTranscript(t, sid, 'sub-unknown'); + transcript.append(userEntry('start')); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: transcript.file, source: 'startup', cwd: '/x' }); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'start' }); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'not-bound', + tool_use_id: 'wrong-parent', tool_name: 'Read', tool_input: {}, + }); + await finish(daemon, sid); + assert.equal(exporter.getFinishedSpans().filter(span => + span.attributes[ATTR.OPERATION_NAME] === 'execute_tool').length, 0); +}); + +for (const firstHook of ['PreToolUse', 'PostToolUse'] as const) { + test(`restart-first nested ${firstHook} reconstructs the owning Agent`, async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = `nested-restart-${firstHook}`; + const agentId = `owner-${firstHook}`; + const transcript = makeTranscript(t, sid, sid); + transcript.append(userEntry('delegate it')); + transcript.subagent(agentId, userEntry('inspect the child')); + const daemon = makeGenaiDaemon(); + const tool = { + session_id: sid, + transcript_path: transcript.file, + cwd: '/x', + prompt_id: 'prompt-1', + agent_id: agentId, + agent_type: 'Explore', + tool_use_id: `read-${firstHook}`, + tool_name: 'Read', + tool_input: { file_path: '/nested.ts' }, + }; + + if (firstHook === 'PreToolUse') { + await daemon.routeEvent({ hook_event_name: firstHook, ...tool }); + } + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', + ...tool, + tool_response: 'contents', + }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const owner = spans.find(span => span.attributes[ATTR.AGENT_ID] === agentId); + const child = spans.find(span => + span.attributes['gen_ai.tool.call.id'] === tool.tool_use_id); + const turn = spans.find(span => + span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'claude-code'); + assert.ok(turn && owner && child); + assert.equal(spanParentId(owner), turn.spanContext().spanId); + assert.equal(spanParentId(child), owner.spanContext().spanId); + assert.equal( + owner.attributes[ATTR.INPUT_MESSAGES], + JSON.stringify([{ role: 'user', content: 'inspect the child' }]), + ); + assert.equal(child.attributes['gen_ai.tool.call.result'], 'contents'); + }); +} + +test('restart-first nested terminal hook without agent_type stays fail-closed', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'nested-restart-no-type'; + const transcript = makeTranscript(t, sid, sid); + transcript.append(userEntry('delegate it')); + transcript.subagent('unknown-owner', userEntry('inspect the child')); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', + session_id: sid, + transcript_path: transcript.file, + cwd: '/x', + agent_id: 'unknown-owner', + tool_use_id: 'unowned-read', + tool_name: 'Read', + tool_input: { file_path: '/nested.ts' }, + tool_response: 'contents', + }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + assert.equal(spans.some(span => span.attributes[ATTR.AGENT_ID] === 'unknown-owner'), false); + assert.equal(spans.some(span => span.attributes['gen_ai.tool.call.id'] === 'unowned-read'), false); +}); diff --git a/tests/agent-recovery.test.ts b/tests/agent-recovery.test.ts new file mode 100644 index 0000000..b530422 --- /dev/null +++ b/tests/agent-recovery.test.ts @@ -0,0 +1,352 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ATTR } from '../src/genaiSpans.ts'; +import { + assistantEntry, + initWeaveInMemory, + makeGenaiDaemon, + makeTranscript, + spanParentId, + userEntry, +} from './helpers.ts'; +import { finish } from './agent-test-helpers.ts'; + +test('declared and wildcard Agent candidates with the same prompt stay ambiguous', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-mixed-type-candidates'; + const agentId = 'mixed-type-agent'; + const transcript = makeTranscript(t, sid, sid); + transcript.append(userEntry('delegate twice')); + transcript.subagent(agentId, userEntry('same task')); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sid, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'delegate twice', + }); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, + tool_use_id: 'declared-call', tool_name: 'Agent', + tool_input: { subagent_type: 'general-purpose', prompt: 'same task' }, + }); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, + tool_use_id: 'wildcard-call', tool_name: 'Agent', + tool_input: { prompt: 'same task' }, + }); + await daemon.routeEvent({ + hook_event_name: 'SubagentStart', session_id: sid, + agent_id: agentId, agent_type: 'general-purpose', + }); + await finish(daemon, sid); + + const agents = exporter.getFinishedSpans().filter(span => + span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] !== 'claude-code'); + assert.equal(agents.length, 2); + assert.equal(agents.some(span => span.attributes[ATTR.AGENT_ID] === agentId), false); +}); + +test('exact prompt beyond the first 64 KiB selects the right Agent dispatch', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-exact-prompt'; + const agentId = 'exact-agent'; + const transcript = makeTranscript(t, sid, 'sub-exact-prompt'); + transcript.append(userEntry('dispatch twice')); + const subPath = transcript.subagent( + agentId, + { + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: `${'x'.repeat(70 * 1024)}` }], + }, + }, + userEntry('second task'), + assistantEntry('exact-msg', { type: 'text', text: 'second done' }), + ); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: transcript.file, source: 'startup', cwd: '/x' }); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'dispatch twice' }); + for (const [toolUseId, prompt] of [['agent-first', 'task'], ['agent-second', 'second task']]) { + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: toolUseId, + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt }, + }); + } + await daemon.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); + await daemon.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); + await daemon.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'agent-second', tool_response: 'second result' }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'agent-first', tool_response: 'first result' }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const agents = spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore'); + const matched = agents.find(span => span.attributes[ATTR.AGENT_ID] === agentId); + const chat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'exact-msg'); + assert.equal(agents.length, 2); + assert.ok(matched && chat); + assert.equal( + matched.attributes[ATTR.INPUT_MESSAGES], + JSON.stringify([{ role: 'user', content: 'second task' }]), + ); + assert.equal(spanParentId(chat), matched.spanContext().spanId); +}); + +test('prefix-colliding restart prompts remain separate partial Agent markers', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-incompatible-recovery'; + const agentId = 'prefix-agent'; + const transcript = makeTranscript(t, sid, sid); + transcript.append(userEntry('dispatch')); + const subPath = transcript.subagent( + agentId, + userEntry('prefix task'), + assistantEntry('prefix-msg', { type: 'text', text: 'prefix output' }), + ); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'PostToolUseFailure', session_id: sid, transcript_path: transcript.file, + tool_use_id: 'fix-call', tool_name: 'Agent', + tool_input: { subagent_type: 'Explore', prompt: 'fix' }, error: 'AgentError: fix failed', + }); + await daemon.routeEvent({ + hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, + agent_type: 'Explore', agent_transcript_path: subPath, + }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const agents = spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore'); + const failed = agents.find(span => span.attributes[ATTR.ERROR_TYPE] === 'AgentError'); + const recovered = agents.find(span => span.attributes[ATTR.AGENT_ID] === agentId); + const chat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'prefix-msg'); + assert.equal(agents.length, 2); + assert.ok(failed && recovered && chat); + assert.equal(failed.attributes[ATTR.AGENT_ID], undefined); + assert.equal(recovered.attributes[ATTR.ERROR_TYPE], undefined); + assert.equal(spanParentId(chat), recovered.spanContext().spanId); +}); + +test('ambiguous prompt correlation never fabricates a third marker', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-ambiguous'; + const agentId = 'ambiguous-agent'; + const transcript = makeTranscript(t, sid, 'sub-ambiguous'); + transcript.append(userEntry('dispatch twice')); + const subPath = transcript.subagent( + agentId, + userEntry('different prompt'), + assistantEntry('ambiguous-msg', { type: 'text', text: 'done' }), + ); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: transcript.file, source: 'startup', cwd: '/x' }); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'dispatch twice' }); + for (const [toolUseId, prompt] of [['agent-a', 'first'], ['agent-b', 'second']]) { + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: toolUseId, + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt }, + }); + } + await daemon.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'agent-a', tool_response: 'a' }); + await daemon.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath }); + await daemon.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'agent-b', tool_response: 'b' }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + assert.equal(spans.filter(span => + span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore').length, 2); + const turn = spans.find(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const chat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'ambiguous-msg'); + assert.ok(turn && chat); + assert.equal(spans.filter(span => span.attributes[ATTR.RESPONSE_ID] === 'ambiguous-msg').length, 1); + assert.equal(spanParentId(chat), turn.spanContext().spanId); +}); + +test('restart-first SubagentStart recovers a parent for child hooks', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-recovery-start-first'; + const agentId = 'start-first-agent'; + const transcript = makeTranscript(t, sid, sid); + transcript.append(userEntry('delegate it')); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SubagentStart', session_id: sid, transcript_path: transcript.file, + agent_id: agentId, agent_type: 'Explore', + }); + const subPath = transcript.subagent( + agentId, + userEntry('inspect it'), + assistantEntry('start-first-msg', { type: 'text', text: 'inspected' }), + ); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: agentId, + tool_use_id: 'start-first-tool', tool_name: 'Read', tool_input: { file_path: '/x' }, + }); + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', session_id: sid, agent_id: agentId, + tool_use_id: 'start-first-tool', tool_response: 'contents', + }); + await daemon.routeEvent({ + hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, + agent_type: 'Explore', agent_transcript_path: subPath, + }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const agents = spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore'); + const agent = agents.find(span => span.attributes[ATTR.AGENT_ID] === agentId); + const tool = spans.find(span => span.attributes['gen_ai.tool.call.id'] === 'start-first-tool'); + const chat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'start-first-msg'); + assert.equal(agents.length, 1); + assert.ok(agent && tool && chat); + assert.equal(spanParentId(tool), agent.spanContext().spanId); + assert.equal(spanParentId(chat), agent.spanContext().spanId); + assert.equal( + agent.attributes[ATTR.INPUT_MESSAGES], + JSON.stringify([{ role: 'user', content: 'inspect it' }]), + ); + assert.equal( + agent.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'inspected' }]), + ); +}); + +test('a Stop-first recovered Agent keeps its prompt turn open', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-recovery-retained-turn'; + const transcript = makeTranscript(t, sid, sid); + transcript.append(userEntry('older prompt')); + const agentId = 'retained-agent'; + const subPath = transcript.subagent( + agentId, + userEntry('background task'), + assistantEntry('retained-msg', { type: 'text', text: 'still working' }), + ); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: transcript.file, source: 'startup', cwd: '/x' }); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt_id: 'older', prompt: 'older prompt' }); + await daemon.routeEvent({ + hook_event_name: 'SubagentStop', session_id: sid, prompt_id: 'older', + agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath, + }); + await daemon.routeEvent({ + hook_event_name: 'SubagentStop', session_id: sid, prompt_id: 'older', + agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath, + last_assistant_message: 'continued', + }); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt_id: 'newer', prompt: 'newer prompt' }); + + assert.equal(exporter.getFinishedSpans().filter(span => + span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'claude-code').length, 0); + + await finish(daemon, sid); + const spans = exporter.getFinishedSpans(); + const turns = spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const agent = spans.find(span => span.attributes[ATTR.AGENT_ID] === agentId); + assert.equal(spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore').length, 1); + assert.equal(turns.length, 2); + assert.ok(agent); + const parent = turns.find(turn => turn.spanContext().spanId === spanParentId(agent)); + assert.ok(parent); + assert.equal( + agent.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'still working\ncontinued' }]), + ); + assert.deepEqual( + spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'chat') + .map(span => span.attributes[ATTR.RESPONSE_ID]).sort(), + ['retained-msg'], + ); +}); + +test('restart recovery keeps a later Agent call separate', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-recovery-separate'; + const agentId = 'recovered-agent'; + const transcript = makeTranscript(t, sid, 'sub-recovery'); + transcript.append( + userEntry('delegate it'), + assistantEntry('main-msg', { type: 'text', text: 'working' }), + ); + const subPath = transcript.subagent( + agentId, + userEntry('recover me'), + assistantEntry('recovered-msg', { type: 'text', text: 'done' }, { + usage: { input_tokens: 200, output_tokens: 40 }, + }), + ); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SubagentStop', session_id: sid, transcript_path: transcript.file, + agent_id: agentId, agent_type: 'Explore', agent_transcript_path: subPath, + }); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, + tool_use_id: 'lost-agent-call', tool_name: 'Agent', + tool_input: { subagent_type: 'Explore', prompt: 'different task' }, + }); + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', session_id: sid, transcript_path: transcript.file, + tool_use_id: 'lost-agent-call', tool_name: 'Agent', + tool_input: { subagent_type: 'Explore', prompt: 'different task' }, + tool_response: 'must not replace the recovered transcript', + }); + await finish(daemon, sid); + + const spans = exporter.getFinishedSpans(); + const turns = spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const agents = spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === 'Explore'); + const recovered = agents.find(span => span.attributes[ATTR.AGENT_ID] === agentId); + const later = agents.find(span => span !== recovered); + const chat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'recovered-msg'); + assert.equal(turns.length, 1); + assert.equal(agents.length, 2); + assert.ok(recovered && later && chat); + assert.equal(spanParentId(recovered), turns[0].spanContext().spanId); + assert.equal(spanParentId(chat), recovered.spanContext().spanId); + assert.equal(spanParentId(later), turns[0].spanContext().spanId); + assert.equal(recovered.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined); + assert.equal(later.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined); + assert.equal( + recovered.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'done' }]), + ); + assert.equal( + later.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'must not replace the recovered transcript' }]), + ); +}); diff --git a/tests/agent-test-helpers.ts b/tests/agent-test-helpers.ts new file mode 100644 index 0000000..1757756 --- /dev/null +++ b/tests/agent-test-helpers.ts @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import type { TestContext } from 'node:test'; +import { + assistantEntry, + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + makeTranscript, + userEntry, +} from './helpers.ts'; + +export async function boundAgent(t: TestContext, label: string) { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = `sub-${label}`; + const agentId = `${label}-agent`; + const transcript = makeTranscript(t, sid, label); + transcript.append(userEntry('delegate it')); + const subPath = transcript.subagent( + agentId, + userEntry('do it'), + assistantEntry('sub-msg-1', { type: 'text', text: 'done' }, { + usage: { input_tokens: 120, output_tokens: 30 }, + finishReason: 'end_turn', + }), + ); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: transcript.file, source: 'startup', cwd: '/x' }); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'delegate it' }); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'agent-call', + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: 'do it' }, + }); + await daemon.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); + return { exporter, daemon, sid, agentId, transcript, subPath }; +} + +export async function finish( + daemon: ReturnType, + sid: string, +) { + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 3bd02cf..0bb59b3 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -7,6 +7,7 @@ import * as net from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; import { spawn, type ChildProcess } from 'node:child_process'; +import type { TestContext } from 'node:test'; import { fileURLToPath } from 'node:url'; import { InMemorySpanExporter, SimpleSpanProcessor, type ReadableSpan } from '@opentelemetry/sdk-trace-base'; import * as weave from 'weave'; @@ -117,6 +118,67 @@ export type DaemonDriver = { drain(reason: string): Promise; }; +export type TranscriptHarness = { + file: string; + append(...entries: unknown[]): void; + subagent(agentId: string, ...entries: unknown[]): string; +}; + +/** JSONL fixture that can create Claude's colocated subagent transcripts. */ +export function makeTranscript( + t: TestContext, + sessionId: string, + label = 'trace', +): TranscriptHarness { + const dir = fs.mkdtempSync(path.join(os.homedir(), `.weave-${label}-`)); + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, ''); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + + const write = (target: string, entries: unknown[]) => { + fs.appendFileSync( + target, + entries.map(entry => typeof entry === 'string' ? entry : JSON.stringify(entry)).join('\n') + '\n', + ); + }; + return { + file, + append: (...entries) => write(file, entries), + subagent: (agentId, ...entries) => { + const target = path.join(dir, sessionId, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(target), { recursive: true }); + write(target, entries); + return target; + }, + }; +} + +export function userEntry(text: string): Record { + return { type: 'user', message: { role: 'user', content: text } }; +} + +export function assistantEntry( + id: string, + content: Record | Array>, + options: { + model?: string; + usage?: Record; + finishReason?: string; + } = {}, +): Record { + return { + type: 'assistant', + message: { + role: 'assistant', + id, + model: options.model ?? 'claude-opus-4-8', + usage: options.usage ?? { input_tokens: 100, output_tokens: 50 }, + content: Array.isArray(content) ? content : [content], + ...(options.finishReason ? { stop_reason: options.finishReason } : {}), + }, + }; +} + export function makeGenaiDaemon(agentName = 'claude-code'): DaemonDriver { const logFile = path.join(os.tmpdir(), `wcp-genai-${process.pid}.log`); const d = new Daemon('/tmp/unused.sock', logFile, { diff --git a/tests/tool-lifecycle.test.ts b/tests/tool-lifecycle.test.ts index 4178812..8ca586c 100644 --- a/tests/tool-lifecycle.test.ts +++ b/tests/tool-lifecycle.test.ts @@ -70,7 +70,7 @@ function turnSpans(spans: ReadableSpan[]): ReadableSpan[] { return spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); } -test('ordinary tool calls are traced once while Agent-owned calls remain deferred', async (t) => { +test('ordinary tool calls are traced once', async (t) => { const exporter = await initWeaveInMemory(); exporter.reset(); const sessionId = 'ordinary-tool'; @@ -96,23 +96,6 @@ test('ordinary tool calls are traced once while Agent-owned calls remain deferre await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...tool }); await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...tool, tool_response: 'duplicate' }); - const agent = { - session_id: sessionId, - tool_use_id: 'agent-1', - tool_name: 'Agent', - tool_input: { subagent_type: 'Explore', prompt: 'inspect it' }, - }; - await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...agent }); - await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...agent, tool_response: 'done' }); - const child = { - session_id: sessionId, - agent_id: 'untraced-agent', - tool_use_id: 'child-read', - tool_name: 'Read', - tool_input: { file_path: '/tmp/child.txt' }, - }; - await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...child }); - await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...child, tool_response: 'child' }); await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); await flushWeave();