diff --git a/src/callSpans.ts b/src/callSpans.ts new file mode 100644 index 0000000..38201e6 --- /dev/null +++ b/src/callSpans.ts @@ -0,0 +1,393 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import type { Attributes } from '@opentelemetry/api'; +import type { SubAgent, Tool } from 'weave'; +import { VERSION } from './setup.js'; +import { + ATTR, + assistantOutputMessages, + jsonStr, + toolDisplayName, +} from './genaiSpans.js'; +import type { SpanParent } from './genaiSpans.js'; + +export type CallOutcome = + | { kind: 'success'; value: unknown } + | { kind: 'failure'; error: unknown }; + +export type CallOwner = + | { kind: 'root' } + | { kind: 'agent'; id: string } + | { kind: 'unknown' }; + +type CallScope = { + /** `undefined` is the single foreground legacy stream; a later legacy + * prompt is a hard boundary because the protocol supplies no prompt id. */ + promptId?: string; + owner: CallOwner; +}; + +export function callOwnerFor(agentId?: string): CallOwner { + return agentId ? { kind: 'agent', id: agentId } : { kind: 'root' }; +} + +type ToolCall = CallScope & { + kind: 'tool'; + span: Tool; +}; + +/** Agent completes only after both its tool result and a transcript snapshot. */ +type AgentPhase = + | { kind: 'running' } + | { kind: 'awaiting-post' } + | { kind: 'awaiting-stop'; outcome: CallOutcome }; + +export type AgentCall = CallScope & { + kind: 'agent'; + span: SubAgent; + toolUseId?: string; + /** Display name chosen from AgentInput; `name` is only an instance alias. */ + agentType: string; + /** Lifecycle identity, unknown when AgentInput omitted `subagent_type`. */ + declaredAgentType?: string; + prompt: string; + agentId?: string; + phase: AgentPhase; + /** Chat responses already emitted from this Agent's Stop snapshots. */ + seenResponses: Set; +}; + +type OpenCall = ToolCall | AgentCall; + +/** One call registry, indexed by the identities exposed by Claude's hooks. */ +export type CallState = { + byToolUseId: Map; + byAgentId: Map; + /** Prevent duplicate or delayed hooks from reopening calls while this + * reconstructed session state remains live. */ + toolUseTombstones: Set; + agentTombstones: Set; + /** Dedupe state for Stop snapshots whose Agent call is still ambiguous. */ + uncorrelatedAgentResponses: Map>; +}; + +export function newCallState(): CallState { + return { + byToolUseId: new Map(), + byAgentId: new Map(), + toolUseTombstones: new Set(), + agentTombstones: new Set(), + uncorrelatedAgentResponses: new Map(), + }; +} + +/** Normal agents appear in both indexes; return every live call once. */ +export function openCalls(state: CallState): OpenCall[] { + return [...new Set([...state.byToolUseId.values(), ...state.byAgentId.values()])]; +} + +type BeginCallArgs = CallScope & { + toolUseId: string; + toolName: string; + toolInput: Record; +}; + +/** 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: SpanParent, + args: BeginCallArgs, +): OpenCall | undefined { + if (state.byToolUseId.has(args.toolUseId) + || state.toolUseTombstones.has(args.toolUseId)) return undefined; + + let call: OpenCall; + if (args.toolName === 'Agent') { + const agentType = agentTypeFor(args.toolInput); + const prompt = typeof args.toolInput['prompt'] === 'string' ? args.toolInput['prompt'] : ''; + const span = startAgentSpan(parent, agentType, prompt); + span.setAttributes({ [ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID]: args.toolUseId }); + call = { + kind: 'agent', + span, + toolUseId: args.toolUseId, + agentType, + declaredAgentType: declaredAgentTypeFor(args.toolInput), + prompt, + promptId: args.promptId, + owner: args.owner, + phase: { kind: 'running' }, + seenResponses: new Set(), + }; + } else { + const span = parent.startTool({ + name: args.toolName, + args: jsonStr(args.toolInput), + toolCallId: args.toolUseId, + }); + const attributes: Attributes = { + [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(args.toolName, args.toolInput), + }; + if ('name' in parent && typeof parent.name === 'string') { + attributes[ATTR.AGENT_NAME] = parent.name; + } + span.setAttributes(attributes); + call = { + kind: 'tool', + span, + promptId: args.promptId, + owner: args.owner, + }; + } + + state.byToolUseId.set(args.toolUseId, 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; +} + +type RecoverAgentArgs = Pick & { + 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: SpanParent, + args: RecoverAgentArgs, +): AgentCall { + const span = startAgentSpan(parent, args.agentType, args.prompt); + span.setAttributes({ [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${args.agentType}` }); + span.record({ agentId: args.agentId }); + const call: AgentCall = { + kind: 'agent', + span, + agentType: args.agentType, + declaredAgentType: args.agentType, + prompt: args.prompt, + promptId: args.promptId, + owner: { kind: 'unknown' }, + agentId: args.agentId, + phase: args.event === 'SubagentStop' ? { kind: 'awaiting-post' } : { kind: 'running' }, + seenResponses: new Set(), + }; + state.byAgentId.set(args.agentId, call); + return call; +} + +export function backfillAgentPrompt(call: AgentCall, 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?: AgentCall, +): 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 settleCall( + state: CallState, + toolUseId: string, + outcome: CallOutcome, +): void { + const call = state.byToolUseId.get(toolUseId); + if (!call) return; + + if (call.kind === 'tool') { + finishToolCall(call, outcome); + completeCall(state, toolUseId, call); + return; + } + if (call.phase.kind === 'running') { + call.phase = { kind: 'awaiting-stop', outcome }; + return; + } + if (call.phase.kind === 'awaiting-stop') return; + + finishAgentSpan(call.span, outcome); + completeCall(state, toolUseId, call); +} + +function finishToolCall(call: ToolCall, outcome: CallOutcome): void { + if (outcome.kind === 'success') { + call.span.result = jsonStr(outcome.value); + call.span.end(); + return; + } + const error = String(outcome.error); + call.span.result = error; + call.span.setAttributes({ [ATTR.ERROR_TYPE]: errorType(outcome.error) }); + call.span.end({ error: new Error(error) }); +} + +function finishAgentSpan(span: SubAgent, outcome: CallOutcome): void { + const output = outcome.kind === 'success' ? outcome.value : outcome.error; + if (output !== undefined && output !== null && output !== '') { + const text = typeof output === 'string' ? output : jsonStr(output); + span.setAttributes({ [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([text]) }); + } + if (outcome.kind === 'success') { + span.end(); + return; + } + span.setAttributes({ [ATTR.ERROR_TYPE]: errorType(outcome.error) }); + span.end({ + error: new Error(typeof outcome.error === 'string' ? outcome.error : 'subagent failed'), + }); +} + +function errorType(error: unknown): string { + if (typeof error === 'string') { + const match = error.trim().match(/^[A-Z][A-Za-z_]*Error/); + return match?.[0] ?? 'tool_error'; + } + if (error && typeof error === 'object' && 'type' in error) { + const type = (error as Record)['type']; + if (typeof type === 'string' && type) return type; + } + return 'tool_error'; +} + +export type AgentMatch = + | { kind: 'found'; call: AgentCall } + | { kind: 'missing' } + | { kind: 'ambiguous' }; + +function matchingPrompt(candidates: AgentCall[], prompt: string | undefined): AgentCall[] { + if (prompt === undefined) return candidates; + const observed = prompt.trim(); + return candidates.filter(call => call.prompt.trim() === observed); +} + +export function matchAgent( + state: CallState, + agentType: string, + prompt: string | undefined, + promptId?: string, +): AgentMatch { + const candidates = [...state.byToolUseId.values()].filter((call): call is AgentCall => + call.kind === 'agent' + && !call.agentId + && call.promptId === promptId + && (call.declaredAgentType === undefined || call.declaredAgentType === agentType)); + const matches = matchingPrompt(candidates, prompt); + 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 { + if (match.call.phase.kind === 'running') { + match.call.phase = { kind: 'awaiting-post' }; + return; + } + if (match.call.phase.kind === 'awaiting-post') return; + + finishAgentSpan(match.call.span, match.call.phase.outcome); + if (match.call.toolUseId) completeCall(state, match.call.toolUseId, match.call); + else completeAgent(state, match.call); +} + +function completeCall(state: CallState, toolUseId: string, call: OpenCall): void { + state.byToolUseId.delete(toolUseId); + state.toolUseTombstones.add(toolUseId); + if (call.kind === 'agent') completeAgent(state, call); +} + +function completeAgent(state: CallState, call: AgentCall): void { + if (!call.agentId) return; + if (state.byAgentId.get(call.agentId) === call) state.byAgentId.delete(call.agentId); + state.agentTombstones.add(call.agentId); +} + +/** Close unfinished children before their owning turns. */ +export function finalizeOpenCalls(state: CallState, reason: string): string[] { + const closed: string[] = []; + for (const [toolUseId, call] of [...state.byToolUseId.entries()].reverse()) { + if (call.kind === 'agent' && call.phase.kind === 'awaiting-stop') { + finishAgentSpan(call.span, call.phase.outcome); + } else { + call.span.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: reason }); + call.span.end({ error: new Error(`call did not complete (${reason})`) }); + } + completeCall(state, toolUseId, call); + closed.push(toolUseId); + } + const recovered = [...state.byAgentId.entries()] + .filter(([, call]) => call.toolUseId === undefined) + .reverse(); + for (const [agentId, call] of recovered) { + if (call.phase.kind === 'awaiting-stop') { + finishAgentSpan(call.span, call.phase.outcome); + } else if (call.phase.kind === 'awaiting-post') { + call.span.end(); + } else { + call.span.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: reason }); + call.span.end({ error: new Error(`call did not complete (${reason})`) }); + } + completeAgent(state, call); + closed.push(`agent:${agentId}`); + } + return closed; +} diff --git a/src/chatSpans.ts b/src/chatSpans.ts index 08b1afb..3e17a76 100644 --- a/src/chatSpans.ts +++ b/src/chatSpans.ts @@ -2,85 +2,54 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -import * as weave from 'weave'; -import type { AssistantCallDetail } from './parser.js'; -import { isToolUseBlock } from './parser.js'; +import type { AssistantResponse } from './parser.js'; import { ATTR, buildUsage, contentBlocksToParts, - providerFromModel, parseTimestamp, + providerFromModel, } from './genaiSpans.js'; +import type { SpanParent } from './genaiSpans.js'; -/** Response `message.id`, or the call index for legacy transcripts without ids. */ -export function chatMessageKey(call: AssistantCallDetail, callIdx: number): string { - return call.responseId ?? `idx:${callIdx}`; -} +type ChatOptions = { + agentName?: string; + /** Used by blockable/repeated stop hooks to emit each response once. */ + seen?: Set; +}; -/** All calls of one assistant response, in transcript order: Claude Code - * splits a response across transcript lines sharing a `message.id`. */ -export function callsForResponseKey( - calls: AssistantCallDetail[], - key: string, -): AssistantCallDetail[] { - const group: AssistantCallDetail[] = []; - for (let i = 0; i < calls.length; i++) { - if (chatMessageKey(calls[i], i) === key) group.push(calls[i]); - } - return group; -} - -/** Response key of the call carrying `tool_use` block `toolUseId`; undefined if unflushed. */ -export function findToolUseResponseKey( - calls: AssistantCallDetail[], - toolUseId: string, -): string | undefined { - for (let ci = 0; ci < calls.length; ci++) { - for (const block of calls[ci].contentBlocks) { - if (isToolUseBlock(block) && block.id === toolUseId) { - return chatMessageKey(calls[ci], ci); - } - } - } - return undefined; +function responseKey(response: AssistantResponse, index: number): string { + return response.id + ? `id:${response.id}:${index}` + : `legacy:${response.startTime ?? ''}:${response.endTime ?? ''}:${index}`; } -export function parseIsoOrNow(ts: string | undefined): Date { - return parseTimestamp(ts) ?? new Date(); -} - -/** Open a chat (LLM) span backdated to the request start; undefined until a - * call in the group has a model (LLMInit requires one). */ -export function openChatForGroup(parent: weave.Turn | weave.SubAgent, group: AssistantCallDetail[]): weave.LLM | undefined { - const model = group.map(c => c.model).find(Boolean); - if (!model) return undefined; - const provider = providerFromModel(model); - return parent.startLLM({ - model, - ...(provider ? { providerName: provider } : {}), - startTime: parseIsoOrNow(group[0].prevTimestamp ?? group[0].timestamp), - }); -} - -/** Populate a chat span from one response's calls, then end it. Split lines share - * the response's usage: take the last line's (has stop_reason), don't sum. */ -export function recordChat( - llm: weave.LLM, - group: AssistantCallDetail[], - agentName?: string, +/** Emit one LLM span per normalized provider response. */ +export function emitChatSpans( + parent: SpanParent, + responses: AssistantResponse[], + options: ChatOptions = {}, ): void { - const last = group.at(-1)!; - const parts = contentBlocksToParts(group.flatMap(c => c.contentBlocks)); - const finishReason = group.map(c => c.finishReason).find(Boolean); - llm.record({ - ...(parts.length ? { outputMessages: [{ role: 'assistant', parts }] } : {}), - usage: buildUsage(last.usage, last.reasoningTokens), - outputType: 'text', - ...(last.responseId ? { responseId: last.responseId } : {}), - ...(finishReason ? { finishReasons: [finishReason] } : {}), - }); - // agent.name isn't on record()'s surface, so set it directly. - if (agentName) llm.setAttributes({ [ATTR.AGENT_NAME]: agentName }); - llm.end({ endTime: parseIsoOrNow(last.timestamp) }); + for (const [index, response] of responses.entries()) { + const key = responseKey(response, index); + if (!response.model || options.seen?.has(key)) continue; + + const provider = providerFromModel(response.model); + const llm = parent.startLLM({ + model: response.model, + ...(provider ? { providerName: provider } : {}), + startTime: parseTimestamp(response.startTime ?? response.endTime) ?? new Date(), + }); + const parts = contentBlocksToParts(response.content); + llm.record({ + ...(parts.length ? { outputMessages: [{ role: 'assistant', parts }] } : {}), + usage: buildUsage(response.usage, response.reasoningTokens), + outputType: 'text', + ...(response.id ? { responseId: response.id } : {}), + ...(response.finishReason ? { finishReasons: [response.finishReason] } : {}), + }); + if (options.agentName) llm.setAttributes({ [ATTR.AGENT_NAME]: options.agentName }); + llm.end({ endTime: parseTimestamp(response.endTime) ?? new Date() }); + options.seen?.add(key); + } } diff --git a/src/daemon.ts b/src/daemon.ts index ad14044..b8982c6 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -8,6 +8,7 @@ import * as path from 'path'; import { diag, DiagLogLevel } from '@opentelemetry/api'; import type { Attributes } from '@opentelemetry/api'; import type { + BaseHookInput, HookInput, SessionStartHookInput, InstructionsLoadedHookInput, @@ -24,26 +25,46 @@ import type { import * as weave from 'weave'; import { loadSettings, VERSION } from './setup.js'; import { appendToLog } from './utils.js'; -import { parseSessionFd } from './parser.js'; -import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; +import { + assistantResponses, + extractAssistantTextBlocks, + lastAssistantTextEndsWith, + parseSessionFd, +} from './parser.js'; +import { + TranscriptFile, + readFirstTranscriptLine, + readSubagentPrompt, + subagentTranscriptPath, +} from './transcriptFile.js'; import { ATTR, CompactionAttrs, setCompactionAttrs, assistantOutputMessages, + parseTimestamp, snippet, } from './genaiSpans.js'; +import type { SpanParent } from './genaiSpans.js'; import { resolveDaemonConfig, daemonConfigFingerprint, missingConfig } from './config.js'; import type { DaemonConfig } from './config.js'; +import { emitChatSpans } from './chatSpans.js'; +import { newSessionState, turnForPrompt } from './sessionState.js'; +import type { SessionState, TurnTrace } from './sessionState.js'; import { - lastAssistantTextEndsWith, - newSessionState, - upsertInstruction, -} from './sessionState.js'; -import type { - SessionState, - LoadedInstruction, -} from './sessionState.js'; + backfillAgentPrompt, + beginCall, + bindAgent, + callOwnerFor, + finalizeOpenCalls, + matchAgent, + openCalls, + recordAgentStop, + recoverAgentCall, + responseKeysForAgent, + settleCall, +} from './callSpans.js'; +import type { AgentCall, AgentMatch, CallOutcome } from './callSpans.js'; // ───────────────────────────────────────────────────────────────────────────── // Types @@ -60,6 +81,12 @@ type ControlMessage = { /** Raw hook-event payload forwarded by hook-handler.sh. */ type HookPayload = Record; +type HookInputFor = Extract< + HookInput, + { hook_event_name: Event } +>; +type PostToolResultHookInput = HookInputFor<'PostToolUse' | 'PostToolUseFailure'>; + function isControlMessage(payload: unknown): payload is ControlMessage { if (typeof payload !== 'object' || payload === null) return false; const cmd = (payload as Record).command; @@ -87,6 +114,22 @@ const CONNECTION_TIMEOUT_MS = 5_000; // 5 seconds per connection const MAX_SOCKET_PAYLOAD_BYTES = 4 * 1024 * 1024; // 4 MiB per message +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}`; +} + export class GlobalDaemon { private server?: net.Server; private running = false; @@ -99,7 +142,7 @@ export class GlobalDaemon { /** InstructionsLoaded files that arrived before their session existed (the * hook can fire before SessionStart). Keyed by session_id; drained into the * session at SessionStart / reconstruction and cleared (also on SessionEnd). */ - private pendingInstructions = new Map(); + private pendingInstructions = new Map>(); private tracingEnabled = false; constructor( @@ -350,6 +393,7 @@ export class GlobalDaemon { break; case 'PostToolUse': case 'PostToolUseFailure': + await this.handlePostToolResult(sessionId, input); break; case 'SubagentStart': await this.handleSubagentStart(sessionId, input); @@ -379,8 +423,32 @@ export class GlobalDaemon { // ── event handlers ──────────────────────────────────────────────────────── + private async buildSession( + sessionId: string, + transcript: TranscriptFile, + options: { source: string; cwd: string; initialRequestModel?: string }, + ): Promise { + const conversationId = await this.resolveConversationId( + sessionId, + transcript.resolvedPath, + options.source, + ); + const session = newSessionState({ + sessionId, + conversationId, + transcript, + cwd: options.cwd, + source: options.source, + initialRequestModel: options.initialRequestModel, + agentName: this.config.agentName, + }); + this.sessions.set(sessionId, session); + this.drainPendingInstructions(session); + return session; + } + private async handleSessionStart(sessionId: string, input: SessionStartHookInput): Promise { - if (this.sessions.has(sessionId)) return; // idempotent + if (!this.tracingEnabled || this.sessions.has(sessionId)) return; const rawPath = input.transcript_path; if (!rawPath) { @@ -396,30 +464,17 @@ export class GlobalDaemon { return; } - const source = input.source; - const initialRequestModel = input.model; - const cwd = input.cwd; - - const conversationId = await this.resolveConversationId(sessionId, transcript.resolvedPath, source); - - const session = newSessionState({ - sessionId, - conversationId, - transcript, - cwd, - source, - initialRequestModel, - agentName: this.config.agentName, - tracingEnabled: this.tracingEnabled, + const session = await this.buildSession(sessionId, transcript, { + source: input.source, + cwd: input.cwd, + initialRequestModel: input.model, }); - this.sessions.set(sessionId, session); - this.drainPendingInstructions(session); - const resumed = conversationId !== sessionId; - this.log('INFO', `Session created: ${sessionId}${resumed ? ` (resumed; conversation=${conversationId})` : ''}`); + const resumed = session.conversationId !== sessionId; + this.log('INFO', `Session created: ${sessionId}${resumed ? ` (resumed; conversation=${session.conversationId})` : ''}`); this.log( 'DEBUG', - `SessionStart details: session=${sessionId} conversation=${conversationId} source=${source} model=${initialRequestModel ?? 'unknown'} cwd=${cwd || '(empty)'} transcript_path=${transcript.resolvedPath} transcript_file=${path.basename(transcript.resolvedPath)} active_sessions=${this.sessions.size}`, + `SessionStart details: session=${sessionId} conversation=${session.conversationId} source=${session.source} model=${session.initialRequestModel ?? 'unknown'} cwd=${session.cwd || '(empty)'} transcript_path=${transcript.resolvedPath} transcript_file=${path.basename(transcript.resolvedPath)} active_sessions=${this.sessions.size}`, ); } @@ -443,10 +498,8 @@ export class GlobalDaemon { const attempts = depth === 0 ? MAX_HEAD_READ_ATTEMPTS : 1; for (let i = 0; i < attempts; i++) { const head = readFirstTranscriptLine(currentPath); - const ff = head?.['forkedFrom'] as Record | undefined; - const ffId = ff?.['sessionId']; - if (typeof ffId === 'string' && ffId) { - parent = ffId; + if (head?.forkedFrom?.sessionId) { + parent = head.forkedFrom.sessionId; break; } if (head !== undefined) break; // head parseable but no fork — root @@ -486,6 +539,7 @@ export class GlobalDaemon { sessionId: string, input: HookInput, ): Promise { + if (!this.tracingEnabled) return undefined; const existing = this.sessions.get(sessionId); if (existing) return existing; @@ -506,23 +560,14 @@ export class GlobalDaemon { const source = (raw['source'] as string | undefined) ?? 'reconstructed'; const cwd = input.cwd; const initialRequestModel = raw['model'] as string | undefined; - const conversationId = await this.resolveConversationId(sessionId, transcript.resolvedPath, source); - - const session = newSessionState({ - sessionId, - conversationId, - transcript, - cwd, + const session = await this.buildSession(sessionId, transcript, { source, + cwd, initialRequestModel, - agentName: this.config.agentName, - tracingEnabled: this.tracingEnabled, }); - this.sessions.set(sessionId, session); - this.drainPendingInstructions(session); this.log( 'INFO', - `Session reconstructed after restart: ${sessionId} (conversation=${conversationId})`, + `Session reconstructed after restart: ${sessionId} (conversation=${session.conversationId})`, ); return session; } @@ -538,14 +583,13 @@ export class GlobalDaemon { return; } - const instruction: LoadedInstruction = { filePath, content }; const session = this.sessions.get(sessionId); if (session) { - upsertInstruction(session.systemInstructions, instruction); + session.systemInstructions.set(filePath, content); } else { // Session not set up yet; buffer until SessionStart / reconstruct drains it. - const pending = this.pendingInstructions.get(sessionId) ?? []; - upsertInstruction(pending, instruction); + const pending = this.pendingInstructions.get(sessionId) ?? new Map(); + pending.set(filePath, content); this.pendingInstructions.set(sessionId, pending); } this.log( @@ -559,51 +603,131 @@ export class GlobalDaemon { private drainPendingInstructions(session: SessionState): void { const pending = this.pendingInstructions.get(session.sessionId); this.pendingInstructions.delete(session.sessionId); - if (!pending?.length) return; - for (const instruction of pending) upsertInstruction(session.systemInstructions, instruction); - this.log('DEBUG', `Drained ${pending.length} buffered instruction file(s) into session ${session.sessionId}`); + if (!pending) return; + for (const [filePath, content] of pending) session.systemInstructions.set(filePath, content); + this.log('DEBUG', `Drained ${pending.size} buffered instruction file(s) into session ${session.sessionId}`); + } + + private transcriptCursor( + session: SessionState, + options: { + userMessage?: string; + recoverCurrentTurn?: boolean; + responseOffsetFloor?: number; + }, + ): { responseOffset: number; startTime?: Date; userText?: string } { + const parsed = parseSessionFd(session.transcript.getFd()); + if (!parsed) return { responseOffset: 0, userText: options.userMessage }; + + const responses = assistantResponses(parsed); + const current = parsed.turns.at(-1); + const transcriptHasPrompt = options.userMessage !== undefined + && current?.userText === options.userMessage; + const includeCurrent = options.recoverCurrentTurn || transcriptHasPrompt; + const responseOffset = includeCurrent + ? responses.length - (current?.responses.length ?? 0) + : responses.length; + return { + responseOffset: Math.max(responseOffset, options.responseOffsetFloor ?? 0), + startTime: includeCurrent ? parseTimestamp(current?.startTime) : undefined, + userText: options.userMessage ?? (includeCurrent ? current?.userText : undefined), + }; } - private startSessionTurn(session: SessionState, userMessage?: string): weave.Turn | undefined { - if (!session.conversation) return undefined; - const turn = session.conversation.startTurn({ + private startSessionTurn( + session: SessionState, + options: { + promptId?: string; + userMessage?: string; + recoverCurrentTurn?: boolean; + responseOffsetFloor?: number; + makeCurrent?: boolean; + } = {}, + ): TurnTrace { + const cursor = this.transcriptCursor(session, options); + const span = session.conversation.startTurn({ agentVersion: VERSION, model: session.initialRequestModel, - userMessage, - systemInstructions: session.systemInstructions.map((i) => i.content), - startTime: new Date(), + userMessage: cursor.userText, + systemInstructions: [...session.systemInstructions.values()], + startTime: cursor.startTime, }); - turn.setAttributes({ + span.setAttributes({ [ATTR.WEAVE_CWD]: session.cwd, [ATTR.WEAVE_SOURCE]: session.source, }); - session.currentTurn = turn; + const turn: TurnTrace = { + span, + promptId: options.promptId, + userText: cursor.userText, + phase: 'active', + responseOffset: cursor.responseOffset, + seenResponses: new Set(), + }; + session.turns.add(turn); + if (options.makeCurrent !== false) session.currentTurn = turn; + if (options.promptId !== undefined) { + session.turnsByPromptId.set(options.promptId, turn); + } return turn; } + private ensureTurn(session: SessionState, promptId: string | undefined): TurnTrace { + return turnForPrompt(session, promptId) ?? this.startSessionTurn(session, { + promptId, + // A protocol prompt_id cannot safely be joined to the last transcript + // turn. Legacy hooks have no competing identity and may recover it. + recoverCurrentTurn: promptId === undefined, + makeCurrent: !session.currentTurn || session.currentTurn.promptId === promptId, + }); + } + private async handleUserPromptSubmit(sessionId: string, input: UserPromptSubmitHookInput): Promise { const session = await this.getOrReconstructSession(sessionId, input); if (!session) { this.log('ERROR', `Unknown session (no transcript_path to reconstruct): ${sessionId}`); return; } - if (!this.tracingEnabled) return; - const prompt = input.prompt; + const previous = session.currentTurn; + if (input.prompt_id !== undefined && session.turnsByPromptId.has(input.prompt_id)) return; this.log( 'DEBUG', - `UserPromptSubmit: session=${sessionId} current_turn=${session.currentTurn ? 'open' : 'none'} prompt=${snippet(prompt, 120)}`, + `UserPromptSubmit: session=${sessionId} current_turn=${previous ? 'open' : 'none'} prompt=${snippet(prompt, 120)}`, ); - // Close interrupted turns that never received a Stop hook. - this.finalizeOpenTurn(session, 'superseded_by_next_prompt'); + let responseOffsetFloor: number | undefined; + if (previous) { + previous.responseLimit ??= assistantResponses( + parseSessionFd(session.transcript.getFd()) ?? { turns: [] }, + ).length; + responseOffsetFloor = previous.responseLimit; + const hasBackgroundWork = openCalls(session.calls) + .some(call => call.promptId === previous.promptId); + if (input.prompt_id === undefined) { + // Legacy streams cannot identify concurrent prompts. Treat the next + // prompt as a hard boundary and orphan any still-open child calls. + this.recordFinalTurnOutput( + previous, + 'superseded_by_next_prompt', + this.parseTranscript(session), + ); + this.finalizeCalls(session, 'superseded_by_next_prompt'); + this.endTurn(session, previous); + } else if (!hasBackgroundWork) { + this.finalizeTurn(session, previous, 'superseded_by_next_prompt'); + } + } - const turn = this.startSessionTurn(session, prompt); - if (!turn) return; + const turn = this.startSessionTurn(session, { + promptId: input.prompt_id, + userMessage: prompt, + responseOffsetFloor, + }); // Drain compaction attrs buffered while no turn was open. if (session.pendingCompaction) { - setCompactionAttrs(turn, session.pendingCompaction); + setCompactionAttrs(turn.span, session.pendingCompaction); session.pendingCompaction = undefined; } @@ -611,9 +735,110 @@ export class GlobalDaemon { } private async handlePreToolUse(sessionId: string, input: PreToolUseHookInput): Promise { - const session = this.sessions.get(sessionId); - if (!session || !this.tracingEnabled) return; - this.log('DEBUG', `PreToolUse (not yet traced): session=${sessionId} tool=${input.tool_name}`); + const session = await this.getOrReconstructSession(sessionId, input); + if (!session) return; + if (!input.agent_id && !turnForPrompt(session, input.prompt_id)) { + this.ensureTurn(session, input.prompt_id); + } + + const resolved = await this.resolveCallParent(session, input, true); + if (!resolved) { + this.log( + 'ERROR', + `PreToolUse: unknown parent session=${sessionId} tool=${input.tool_name} agent=${input.agent_id ?? 'root'}`, + ); + return; + } + const call = beginCall(session.calls, resolved.parent, { + toolUseId: input.tool_use_id, + toolName: input.tool_name, + toolInput: this.asRecord(input.tool_input), + promptId: resolved.promptId, + owner: callOwnerFor(input.agent_id), + }); + if (call && !input.agent_id) { + const turn = turnForPrompt(session, input.prompt_id); + if (turn) turn.phase = 'active'; + } + } + + /** Resolve a call's owning span. After restart, nested tool hooks can arrive + * before SubagentStart; reconstruct that owner only when Claude supplies its + * stable id, type, and readable dispatch prompt. */ + private async resolveCallParent( + session: SessionState, + input: Pick, + recoverOwner = false, + ): Promise<{ parent: SpanParent; promptId?: string } | undefined> { + if (input.agent_id) { + let agent = session.calls.byAgentId.get(input.agent_id); + if (!agent && recoverOwner) { + agent = await this.recoverCallOwner(session, input); + } + return agent ? { parent: agent.span, promptId: agent.promptId } : undefined; + } + const turn = turnForPrompt(session, input.prompt_id); + return turn ? { parent: turn.span, promptId: turn.promptId } : undefined; + } + + private async recoverCallOwner( + session: SessionState, + input: Pick, + ): Promise { + const { agent_id: agentId, agent_type: agentType, prompt_id: promptId } = input; + if (!agentId + || !agentType + || session.calls.agentTombstones.has(agentId)) return undefined; + + const transcriptPath = subagentTranscriptPath(session.transcript.resolvedPath, agentId); + const prompt = await readSubagentPrompt(transcriptPath); + if (!prompt) { + this.log( + 'ERROR', + `Nested hook: cannot recover owner agentId=${agentId} type=${agentType} without its dispatch prompt`, + ); + return undefined; + } + return this.recoverAgent( + session, + agentId, + agentType, + promptId, + prompt, + 'SubagentStart', + ); + } + + /** Recreate a call from its exact tool_use_id after a restart. */ + private async recoverCall(session: SessionState, input: PostToolResultHookInput): Promise { + if (session.calls.byToolUseId.has(input.tool_use_id) + || session.calls.toolUseTombstones.has(input.tool_use_id)) return; + if (!input.agent_id) this.ensureTurn(session, input.prompt_id); + + const resolved = await this.resolveCallParent(session, input, true); + if (!resolved) return; + beginCall(session.calls, resolved.parent, { + toolUseId: input.tool_use_id, + toolName: input.tool_name, + toolInput: this.asRecord(input.tool_input), + promptId: resolved.promptId, + owner: callOwnerFor(input.agent_id), + }); + } + + private async handlePostToolResult( + sessionId: string, + input: PostToolResultHookInput, + ): Promise { + const session = await this.getOrReconstructSession(sessionId, input); + if (!session || session.calls.toolUseTombstones.has(input.tool_use_id)) return; + + const outcome: CallOutcome = input.hook_event_name === 'PostToolUse' + ? { kind: 'success', value: input.tool_response } + : { kind: 'failure', error: input.error }; + await this.recoverCall(session, input); + settleCall(session.calls, input.tool_use_id, outcome); + this.finalizeIdleSupersededTurns(session); } private async handlePermissionRequest(sessionId: string, input: PermissionRequestHookInput): Promise { @@ -622,16 +847,165 @@ export class GlobalDaemon { this.log('DEBUG', `PermissionRequest (not yet traced): session=${sessionId} tool=${input.tool_name}`); } + private async correlateAgent( + session: SessionState, + agentType: string, + transcriptPath: string, + promptId: string | undefined, + ): Promise<{ match: AgentMatch; prompt?: string }> { + const prompt = await readSubagentPrompt(transcriptPath); + return { match: matchAgent(session.calls, agentType, prompt, promptId), prompt }; + } + private async handleSubagentStart(sessionId: string, input: SubagentStartHookInput): Promise { - const session = this.sessions.get(sessionId); - if (!session || !this.tracingEnabled) return; - this.log('DEBUG', `SubagentStart (not yet traced): session=${sessionId} agent=${input.agent_id}`); + 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.transcript.resolvedPath, + input.agent_id, + ); + const { match, prompt } = await this.correlateAgent( + session, + input.agent_type, + transcriptPath, + 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: SessionState, + agentId: string, + agentType: string, + promptId: string | undefined, + prompt: string, + event: 'SubagentStart' | 'SubagentStop', + ): AgentCall { + const recovered = recoverAgentCall(session.calls, this.ensureTurn(session, promptId).span, { + agentId, + agentType, + promptId, + 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 || !this.tracingEnabled) return; - this.log('DEBUG', `SubagentStop (not yet traced): session=${sessionId} agent=${input.agent_id}`); + if (!session || session.calls.agentTombstones.has(input.agent_id)) return; + + const transcriptPath = input.agent_transcript_path + ?? subagentTranscriptPath(session.transcript.resolvedPath, input.agent_id); + const active = session.calls.byAgentId.get(input.agent_id); + if (active && !active.prompt) { + const prompt = await readSubagentPrompt(transcriptPath); + if (prompt) backfillAgentPrompt(active, prompt); + } + const correlation = active + ? { match: { kind: 'found', call: active } as const, prompt: active.prompt } + : await this.correlateAgent(session, input.agent_type, transcriptPath, input.prompt_id); + const match: AgentMatch = correlation.match; + + 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 = turnForPrompt(session, input.prompt_id); + const recovered = match.kind === 'missing' + ? this.recoverAgent( + session, + input.agent_id, + input.agent_type, + input.prompt_id, + correlation.prompt ?? '', + 'SubagentStop', + ) + : undefined; + 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 lifecycle = match.kind === 'found' ? match.call : recovered; + 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); + this.finalizeIdleSupersededTurns(session); + } 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}`, + ); } private async handleTeammateIdle(sessionId: string, input: TeammateIdleHookInput): Promise { @@ -654,8 +1028,9 @@ export class GlobalDaemon { itemsAfter: typeof itemsAfter === 'number' ? itemsAfter : undefined, }; - if (session.currentTurn) { - setCompactionAttrs(session.currentTurn, attrs); + const turn = turnForPrompt(session, input.prompt_id); + if (turn) { + setCompactionAttrs(turn.span, attrs); this.log('INFO', `PreCompact attached to active turn (session ${sessionId})`); } else { // Buffer until the next UserPromptSubmit opens a turn span. @@ -664,78 +1039,181 @@ export class GlobalDaemon { } } + private responsesForTurn( + parsed: NonNullable>, + turn: TurnTrace, + ) { + return assistantResponses(parsed).slice(turn.responseOffset, turn.responseLimit); + } + + private recordTurnOutput( + turn: TurnTrace, + responses: ReturnType, + options: { lastMessage?: string; orphanReason?: string } = {}, + ): void { + emitChatSpans(turn.span, responses, { seen: turn.seenResponses }); + + const text = responses.flatMap(response => extractAssistantTextBlocks(response.content)); + if (!text.length && options.lastMessage) text.push(options.lastMessage); + const attributes: Attributes = {}; + if (text.length) attributes[ATTR.OUTPUT_MESSAGES] = assistantOutputMessages(text); + const finishReasons = responses + .map(response => response.finishReason) + .filter((reason): reason is string => Boolean(reason)); + if (finishReasons.length) attributes[ATTR.RESPONSE_FINISH_REASONS] = finishReasons; + if (options.orphanReason) attributes[ATTR.WEAVE_ORPHAN_REASON] = options.orphanReason; + if (Object.keys(attributes).length) turn.span.setAttributes(attributes); + + const model = responses.filter(response => response.model).at(-1)?.model; + if (model) turn.span.record({ model }); + } + + private endTurn(session: SessionState, turn: TurnTrace): void { + turn.span.end(); + session.turns.delete(turn); + if (turn.promptId !== undefined) { + session.turnsByPromptId.delete(turn.promptId); + } + if (session.currentTurn === turn) session.currentTurn = undefined; + } + private async handleStop(sessionId: string, input: StopHookInput): Promise { - const session = this.sessions.get(sessionId); - if (!session?.currentTurn) return; + const session = await this.getOrReconstructSession(sessionId, input); + if (!session) return; + const turn = turnForPrompt(session, input.prompt_id) + ?? this.ensureTurn(session, input.prompt_id); - // Wait for transcript synthesis to flush before reading the final response. - const finalAssistantMessage = input.last_assistant_message; - const parsedSession = await this.parseSessionFileWithRetry( + const parsed = await this.parseSessionFileWithRetry( session.transcript, - finalAssistantMessage, + input.last_assistant_message, ); - const currentTurn = parsedSession?.turns.at(-1); - const model = currentTurn?.primaryModel(); - const transcriptTurns = parsedSession?.turns.length ?? 0; + const responses = parsed ? this.responsesForTurn(parsed, turn) : []; + const model = responses.filter(response => response.model).at(-1)?.model; this.log( 'DEBUG', - `Stop: session=${sessionId} transcript_path=${session.transcript.resolvedPath} transcript_turns=${transcriptTurns} parsed_model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(input.last_assistant_message)}`, + `Stop: session=${sessionId} transcript_path=${session.transcript.resolvedPath} responses=${responses.length} model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(input.last_assistant_message)}`, ); - const parsedTexts = currentTurn?.textBlocks() ?? []; - const lastMessage = input.last_assistant_message ?? ''; - const assistantMessages = parsedTexts.length > 0 ? parsedTexts : (lastMessage ? [lastMessage] : []); - - const turnAttrs: Attributes = {}; - if (assistantMessages.length) { - turnAttrs[ATTR.OUTPUT_MESSAGES] = assistantOutputMessages(assistantMessages); - } - const finishReasons = currentTurn?.assistantCalls().map(c => c.finishReason).filter((r): r is string => !!r); - if (finishReasons?.length) { - turnAttrs[ATTR.RESPONSE_FINISH_REASONS] = finishReasons; - } - if (Object.keys(turnAttrs).length) session.currentTurn.setAttributes(turnAttrs); - // Turn.end() re-emits its request model, so update it through record(). - if (model) { - session.currentTurn.record({ model }); - } - session.currentTurn.end(); - session.currentTurn = undefined; - - this.log('INFO', 'Finished turn'); + // Stop hooks are blockable. Snapshot output now, but retain the root so a + // continuation can add responses under the same prompt. + this.recordTurnOutput(turn, responses, { + lastMessage: input.last_assistant_message, + }); + turn.phase = 'stopped'; + this.log('INFO', 'Recorded turn stop snapshot'); } private async handleSessionEnd(sessionId: string, input: SessionEndHookInput): Promise { - // Discard any never-drained instruction buffer (e.g. a session that emitted - // InstructionsLoaded but never SessionStart) so the map can't leak. this.pendingInstructions.delete(sessionId); - const session = this.sessions.get(sessionId); + const session = this.sessions.get(sessionId) + ?? await this.getOrReconstructSession(sessionId, input); if (!session) return; + const parsed = this.parseTranscript(session); + const finalTranscriptTurn = parsed?.turns.at(-1); + if (parsed && finalTranscriptTurn) { + let turn = input.prompt_id === undefined + ? [...session.turns].find(candidate => + candidate.userText !== undefined + && candidate.userText === finalTranscriptTurn.userText) + ?? (session.currentTurn?.promptId === undefined ? session.currentTurn : undefined) + : turnForPrompt(session, input.prompt_id); + const legacyTurn = session.currentTurn; + if (!turn && input.prompt_id !== undefined && legacyTurn && legacyTurn.promptId === undefined) { + turn = legacyTurn; + turn.promptId = input.prompt_id; + session.turnsByPromptId.set(input.prompt_id, turn); + } + const stoppedUnknownPrompt = input.prompt_id === undefined + && session.currentTurn?.promptId !== undefined + && session.currentTurn.phase === 'stopped'; + if (!turn && !stoppedUnknownPrompt) { + turn = this.startSessionTurn(session, { + promptId: input.prompt_id, + userMessage: finalTranscriptTurn.userText, + recoverCurrentTurn: true, + }); + } + if (turn && turn.responseLimit === undefined) { + const finalResponseOffset = assistantResponses(parsed).length + - finalTranscriptTurn.responses.length; + if (turn.userText === undefined) { + // An exact prompt_id can safely bind a root reconstructed from an + // earlier terminal hook to the final transcript turn. + turn.responseOffset = finalResponseOffset; + turn.userText = finalTranscriptTurn.userText; + if (turn.userText !== undefined) { + turn.span.record({ + messages: [{ role: 'user', parts: [{ type: 'text', content: turn.userText }] }], + }); + } + } else { + turn.responseOffset = Math.max(turn.responseOffset, finalResponseOffset); + } + } + } + this.log( 'DEBUG', - `SessionEnd: session=${sessionId} reason=${input.reason} transcript_path=${session.transcript.resolvedPath} pending_tools=${session.pendingToolCalls.size} open_subagents=${session.subagents.size()}`, + `SessionEnd: session=${sessionId} reason=${input.reason} transcript_path=${session.transcript.resolvedPath} calls=${openCalls(session.calls).length} turns=${session.turns.size}`, ); - - this.finalizeSession(session, 'session_ended'); - - this.log('INFO', `Finished session ${sessionId}`); + this.finalizeSession(session, 'session_ended', parsed); this.sessions.delete(sessionId); this.sessionQueues.delete(sessionId); session.transcript.close(); + this.log('INFO', `Finished session ${sessionId}`); + } + + private parseTranscript( + session: SessionState, + ): ReturnType { + try { + return parseSessionFd(session.transcript.getFd()); + } catch (error) { + this.log('DEBUG', `Could not recover chat spans while closing turn: ${error}`); + return null; + } } - private finalizeSession(session: SessionState, orphanReason: string): void { - this.finalizeOpenTurn(session, orphanReason); + private recordFinalTurnOutput( + turn: TurnTrace, + orphanReason: string, + parsed: ReturnType, + ): void { + const responses = parsed ? this.responsesForTurn(parsed, turn) : []; + const actualOrphanReason = turn.phase === 'active' ? orphanReason : undefined; + this.recordTurnOutput(turn, responses, { orphanReason: actualOrphanReason }); } - private finalizeOpenTurn(session: SessionState, orphanReason: string): void { - if (session.currentTurn) { - session.currentTurn.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); - session.currentTurn.end(); - session.currentTurn = undefined; - this.log('DEBUG', `Closed orphaned turn span (${orphanReason})`); + private finalizeTurn(session: SessionState, turn: TurnTrace, orphanReason: string): void { + this.recordFinalTurnOutput(turn, orphanReason, this.parseTranscript(session)); + this.endTurn(session, turn); + } + + private finalizeSession( + session: SessionState, + orphanReason: string, + parsed = this.parseTranscript(session), + ): void { + const turns = [...session.turns]; + for (const turn of turns) this.recordFinalTurnOutput(turn, orphanReason, parsed); + this.finalizeCalls(session, orphanReason); + for (const turn of turns) this.endTurn(session, turn); + } + + private finalizeCalls(session: SessionState, orphanReason: string): void { + for (const toolUseId of finalizeOpenCalls(session.calls, orphanReason)) { + this.log('DEBUG', `Closed pending call: ${toolUseId}`); + } + } + + private finalizeIdleSupersededTurns(session: SessionState): void { + const activePrompts = new Set(openCalls(session.calls).map(call => call.promptId)); + for (const turn of [...session.turns]) { + if (turn.responseLimit !== undefined && !activePrompts.has(turn.promptId)) { + this.finalizeTurn(session, turn, 'superseded_by_next_prompt'); + } } } @@ -753,14 +1231,12 @@ export class GlobalDaemon { void this.shutdown('inactivity'); } - /** True if any session has work in flight: an open turn span, a pending tool - * call, or a tracked subagent. Keeps the daemon alive across the inactivity - * timeout so in-flight work isn't cut off mid-flight (see checkInactivity). */ + /** A blockable Stop leaves its root reopenable but quiescent. Open calls and + * active roots still pin the daemon while work can produce more events. */ private hasInFlightWork(): boolean { for (const s of this.sessions.values()) { - if (s.currentTurn) return true; - if (s.pendingToolCalls.size > 0) return true; - if (s.subagents.size() > 0) return true; + if (openCalls(s.calls).length > 0 + || [...s.turns].some(turn => turn.phase === 'active')) return true; } return false; } @@ -799,6 +1275,12 @@ export class GlobalDaemon { // ── helpers ─────────────────────────────────────────────────────────────── + private asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; + } + /** Retry parseSessionFile while the transcript writer catches up to Stop. * If `finalAssistantMessage` is set, require the last assistant call's * text to end with it (mod trailing whitespace) — guards against reading diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index 4090ae2..bef4259 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -5,10 +5,14 @@ // Attribute-key constants and formatting helpers typed against the `weave` SDK. import type { Attributes } from '@opentelemetry/api'; -import type { MessagePart, Tool, Turn, Usage } from 'weave'; +import type { MessagePart, SubAgent, Tool, Turn, Usage } from 'weave'; import { isTextBlock, isThinkingBlock, isRedactedThinkingBlock, isToolUseBlock } from './parser.js'; import type { UsageSummary } from './parser.js'; +/** Weave's two public invoke-agent handles that can own chat, tool, and + * subagent spans. The SDK does not currently export a common parent type. */ +export type SpanParent = Turn | SubAgent; + // Attribute keys: `gen_ai.*` from the OTel GenAI semconv // (https://github.com/open-telemetry/semantic-conventions-genai); `weave.*` // are Claude-Code-specific extensions the backend routes into its queryable diff --git a/src/parser.ts b/src/parser.ts index 71ec2b0..bf091a3 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -3,61 +3,39 @@ // SPDX-PackageName: weave-claude-code import * as fs from 'fs'; +import type { SDKAssistantMessage } from '@anthropic-ai/claude-agent-sdk'; -export interface UsageSummary { - input_tokens: number; - output_tokens: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; -} - -/** - * Per-API-call detail for a single assistant message in the transcript. - * Each entry corresponds to one LLM invocation within a turn, used to emit - * one `chat ` span per call at Stop time. - */ -export interface AssistantCallDetail { - timestamp: string; // ISO timestamp of the assistant message - prevTimestamp?: string; // ISO timestamp of preceding transcript line (proxy for "request started") - model?: string; - usage: UsageSummary; // per-call usage - reasoningTokens?: number; // reasoning/thinking tokens, if any - contentBlocks: unknown[]; // raw assistant content blocks (text, tool_use, thinking, ...) - responseId?: string; // provider message id - finishReason?: string; // stop_reason / finish_reason if present -} - -export interface Turn { - totalUsage(): UsageSummary; - primaryModel(): string | undefined; - textBlocks(): string[]; - assistantCalls(): AssistantCallDetail[]; -} +type AnthropicMessage = SDKAssistantMessage['message']; +type AnthropicUsage = AnthropicMessage['usage']; +type OptionalUsageKey = 'cache_read_input_tokens' | 'cache_creation_input_tokens'; -export interface ParsedSession { - turns: Turn[]; -} +/** The normalized subset of Anthropic usage serialized by Claude Code. */ +export type UsageSummary = Pick + & Partial<{ [Key in OptionalUsageKey]: NonNullable }>; -export function rawToUsageSummary(raw: Record): UsageSummary { - return { - input_tokens: raw['input_tokens'] ?? 0, - output_tokens: raw['output_tokens'] ?? 0, - cache_read_input_tokens: raw['cache_read_input_tokens'], - cache_creation_input_tokens: raw['cache_creation_input_tokens'], - }; +/** One provider response. Claude Code may serialize its content across several + * adjacent assistant records; the parser folds records sharing a response id. */ +export interface AssistantResponse { + startTime?: string; + endTime?: string; + model?: string; + usage: UsageSummary; + reasoningTokens?: number; + content: unknown[]; + id?: string; + finishReason?: string; } -export function addUsage(a: UsageSummary, b: UsageSummary): UsageSummary { - return { - input_tokens: a.input_tokens + b.input_tokens, - output_tokens: a.output_tokens + b.output_tokens, - cache_read_input_tokens: (a.cache_read_input_tokens ?? 0) + (b.cache_read_input_tokens ?? 0), - cache_creation_input_tokens: (a.cache_creation_input_tokens ?? 0) + (b.cache_creation_input_tokens ?? 0), - }; +export interface ParsedTurn { + startTime?: string; + userText?: string; + model?: string; + text: string[]; + responses: AssistantResponse[]; } -export function parseSessionFile(filePath: string): ParsedSession | null { - return parseSessionReader(() => fs.readFileSync(filePath, 'utf8')); +export interface ParsedSession { + turns: ParsedTurn[]; } export function parseSessionFd(fd: number): ParsedSession | null { @@ -68,9 +46,8 @@ function parseSessionReader(read: () => string): ParsedSession | null { try { const lines = read() .split('\n') - .filter(l => l.trim()) - .map(l => JSON.parse(l) as unknown); - + .filter(line => line.trim()) + .map(line => JSON.parse(line) as unknown); return buildSession(lines); } catch { return null; @@ -78,128 +55,186 @@ function parseSessionReader(read: () => string): ParsedSession | null { } function readUtf8FromFd(fd: number): string { - const stat = fs.fstatSync(fd); - const size = stat.size; - if (size === 0) { - return ''; - } + const size = fs.fstatSync(fd).size; + if (size === 0) return ''; const buffer = Buffer.allocUnsafe(size); let bytesRead = 0; - while (bytesRead < size) { - const n = fs.readSync(fd, buffer, bytesRead, size - bytesRead, bytesRead); - if (n === 0) break; - bytesRead += n; + const count = fs.readSync(fd, buffer, bytesRead, size - bytesRead, bytesRead); + if (count === 0) break; + bytesRead += count; } - return buffer.toString('utf8', 0, bytesRead); } -interface AssistantLine { +type TranscriptLine = { + message?: Record; + type?: string; + role?: string; + timestamp?: string; +}; + +type AssistantLine = { line: Record; - prevTimestamp?: string; + previousTimestamp?: string; +}; + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function readTranscriptLine(value: unknown): TranscriptLine { + if (!isObject(value)) return {}; + const message = isObject(value['message']) ? value['message'] : undefined; + return { + message, + type: typeof value['type'] === 'string' ? value['type'] : undefined, + role: typeof message?.['role'] === 'string' ? message['role'] : undefined, + timestamp: typeof value['timestamp'] === 'string' ? value['timestamp'] : undefined, + }; } function buildSession(lines: unknown[]): ParsedSession { - const turns: Turn[] = []; - let currentAssistantLines: AssistantLine[] = []; - let prevTimestamp: string | undefined; - - for (const line of lines) { - const entry = line as Record; - const message = entry['message'] as Record | undefined; - const type = entry['type'] as string | undefined; - const role = (message?.['role'] as string | undefined) ?? type; - const timestamp = entry['timestamp'] as string | undefined; - - if (role === 'assistant') { - currentAssistantLines.push({ line: entry, prevTimestamp }); + const turns: ParsedTurn[] = []; + let assistantLines: AssistantLine[] = []; + let turnStarted = false; + let turnStartTime: string | undefined; + let turnUserText: string | undefined; + let previousTimestamp: string | undefined; + + for (const value of lines) { + const decoded = readTranscriptLine(value); + const role = decoded.role ?? decoded.type; + + if (role === 'assistant' && isObject(value)) { + assistantLines.push({ line: value, previousTimestamp }); } else if (role === 'user') { - const rawContent = message?.['content']; - const content = Array.isArray(rawContent) ? rawContent as Array> : []; - - // A user message with text content marks the end of the previous turn. - const hasText = typeof rawContent === 'string' || content.some(block => block['type'] === 'text'); - if (hasText && currentAssistantLines.length > 0) { - turns.push(buildTurn(currentAssistantLines)); - currentAssistantLines = []; + const content = decoded.message?.['content']; + // Typed prompts are bare strings. Array-form user content is injected + // context (tool results, skills, reminders), so it stays in this turn. + if (typeof content === 'string' && content) { + if (turnStarted || assistantLines.length > 0) { + turns.push(buildTurn(assistantLines, turnStartTime, turnUserText)); + } + assistantLines = []; + turnStarted = true; + turnStartTime = decoded.timestamp; + turnUserText = content; } } - if (timestamp) prevTimestamp = timestamp; + if (decoded.timestamp) previousTimestamp = decoded.timestamp; } - if (currentAssistantLines.length > 0) { - turns.push(buildTurn(currentAssistantLines)); + if (turnStarted || assistantLines.length > 0) { + turns.push(buildTurn(assistantLines, turnStartTime, turnUserText)); } - return { turns }; } -function buildTurn(assistantLines: AssistantLine[]): Turn { - const calls: AssistantCallDetail[] = assistantLines.map(({ line, prevTimestamp }) => { - const message = line['message'] as Record | undefined; - const rawUsage = (message?.['usage'] ?? line['usage'] ?? {}) as Record; - const usage = rawToUsageSummary(rawUsage); - const reasoningTokens = typeof rawUsage['reasoning_tokens'] === 'number' ? rawUsage['reasoning_tokens'] : undefined; - const model = (message?.['model'] ?? line['model']) as string | undefined; +function readUsage(value: unknown): Record { + if (!isObject(value)) return {}; + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, number] => typeof entry[1] === 'number'), + ); +} + +function toUsage(raw: Record): UsageSummary { + return { + input_tokens: raw['input_tokens'] ?? 0, + output_tokens: raw['output_tokens'] ?? 0, + cache_read_input_tokens: raw['cache_read_input_tokens'], + cache_creation_input_tokens: raw['cache_creation_input_tokens'], + }; +} + +function buildTurn( + lines: AssistantLine[], + startTime?: string, + userText?: string, +): ParsedTurn { + const responses: AssistantResponse[] = []; + + for (const { line, previousTimestamp } of lines) { + const { message, timestamp } = readTranscriptLine(line); + const rawUsage = readUsage(message?.['usage'] ?? line['usage']); const rawContent = message?.['content']; - // `content` is either an array of blocks (the common assistant shape), a - // bare string (legacy single-text format), or missing. Fall back to [] for - // the missing / unknown case so downstream code sees a well-typed empty - // list instead of `undefined`. - const contentBlocks: unknown[] = Array.isArray(rawContent) - ? (rawContent as unknown[]) + const content = Array.isArray(rawContent) + ? rawContent : typeof rawContent === 'string' ? [{ type: 'text', text: rawContent }] : []; - const responseId = (message?.['id'] ?? line['id']) as string | undefined; - const stopReason = (message?.['stop_reason'] ?? message?.['finish_reason']) as string | undefined; - const timestamp = (line['timestamp'] as string | undefined) ?? ''; + const idValue = message?.['id'] ?? line['id']; + const modelValue = message?.['model'] ?? line['model']; + const finishValue = message?.['stop_reason'] ?? message?.['finish_reason']; + const id = typeof idValue === 'string' ? idValue : undefined; + const model = typeof modelValue === 'string' ? modelValue : undefined; + const finishReason = typeof finishValue === 'string' ? finishValue : undefined; + const reasoningTokens = rawUsage['reasoning_tokens']; + + const previous = responses.at(-1); + if (id && previous?.id === id) { + previous.content.push(...content); + previous.endTime = timestamp; + previous.usage = toUsage(rawUsage); + previous.reasoningTokens = reasoningTokens ?? previous.reasoningTokens; + previous.model = model ?? previous.model; + previous.finishReason = finishReason ?? previous.finishReason; + continue; + } - return { - timestamp, - prevTimestamp, + responses.push({ + startTime: previousTimestamp, + endTime: timestamp, model, - usage, + usage: toUsage(rawUsage), reasoningTokens, - contentBlocks, - responseId, - finishReason: stopReason, - }; - }); - - const totalUsageValue = calls.reduce( - (acc, call) => addUsage(acc, call.usage), - { input_tokens: 0, output_tokens: 0 }, - ); - - const model = calls.map(call => call.model).filter(Boolean).pop(); - - const texts = calls.flatMap(call => extractAssistantTextBlocks(call.contentBlocks)); + content, + id, + finishReason, + }); + } return { - totalUsage: () => totalUsageValue, - primaryModel: () => model, - textBlocks: () => texts, - assistantCalls: () => calls, + startTime: startTime ?? responses.at(0)?.startTime, + userText, + model: responses.filter(response => response.model).at(-1)?.model, + text: responses.flatMap(response => extractAssistantTextBlocks(response.content)), + responses, }; } -// The assistant content-block shapes we act on (Anthropic Messages API). -// Blocks reach us as `unknown` from the transcript; the guards below narrow the -// ones we care about. Any other block type falls through every guard and is -// ignored. -type TextBlock = { type: 'text'; text: string }; -type ThinkingBlock = { type: 'thinking'; thinking: string }; -type RedactedThinkingBlock = { type: 'redacted_thinking'; data?: string }; -type ToolUseBlock = { type: 'tool_use'; id: string; name: string; input?: unknown }; +/** Flatten responses in transcript order. Useful when a live span remembers the + * response offset at which its prompt began. */ +export function assistantResponses(session: ParsedSession): AssistantResponse[] { + return session.turns.flatMap(turn => turn.responses); +} -function isObject(v: unknown): v is Record { - return typeof v === 'object' && v !== null; +export function lastAssistantTextEndsWith(session: ParsedSession, suffix: string): boolean { + const response = assistantResponses(session).at(-1); + return response !== undefined + && extractAssistantTextBlocks(response.content).join('\n').trimEnd().endsWith(suffix); } +type AnthropicContentBlock = AnthropicMessage['content'][number]; +type AnthropicContentBlockFor = Extract< + AnthropicContentBlock, + { type: Type } +>; +type TextBlock = Pick, 'type' | 'text'>; +type ThinkingBlock = Pick< + AnthropicContentBlockFor<'thinking'>, + 'type' | 'thinking' +>; +type RedactedThinkingBlock = Pick< + AnthropicContentBlockFor<'redacted_thinking'>, + 'type' +>; +type AnthropicToolUseBlock = AnthropicContentBlockFor<'tool_use'>; +type ToolUseBlock = Pick + & Partial>; + export function isTextBlock(block: unknown): block is TextBlock { return isObject(block) && block['type'] === 'text' && typeof block['text'] === 'string'; } @@ -213,23 +248,17 @@ export function isRedactedThinkingBlock(block: unknown): block is RedactedThinki } export function isToolUseBlock(block: unknown): block is ToolUseBlock { - return isObject(block) && block['type'] === 'tool_use' - && typeof block['id'] === 'string' && typeof block['name'] === 'string'; + return isObject(block) + && block['type'] === 'tool_use' + && typeof block['id'] === 'string' + && typeof block['name'] === 'string'; } -/** - * Pull human-readable text out of assistant `content` blocks. Accepts the raw - * union (string entries, `{type: 'text', text}` objects, etc.) and returns - * only non-empty text. `thinking` and other block types are skipped. - */ export function extractAssistantTextBlocks(blocks: unknown[]): string[] { - const out: string[] = []; + const text: string[] = []; for (const block of blocks) { - if (typeof block === 'string' && block.trim()) { - out.push(block); - } else if (isTextBlock(block) && block.text.trim()) { - out.push(block.text); - } + if (typeof block === 'string' && block.trim()) text.push(block); + else if (isTextBlock(block) && block.text.trim()) text.push(block.text); } - return out; + return text; } diff --git a/src/sessionState.ts b/src/sessionState.ts index 288a677..e2b1a5f 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -2,113 +2,27 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -import * as path from 'path'; import * as weave from 'weave'; import { VERSION } from './setup.js'; -import { parseSessionFd, extractAssistantTextBlocks, isTextBlock } from './parser.js'; import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; -import { sha256Hex } from './utils.js'; -import { ATTR, buildIntegrationAttrs, addPermissionResolvedEvent } from './genaiSpans.js'; +import { ATTR, buildIntegrationAttrs } from './genaiSpans.js'; import type { CompactionAttrs } from './genaiSpans.js'; - -/** Stores the tool span opened at PreToolUse so PostToolUse can close it. */ -export type PendingToolCall = { - tool: weave.Tool; - toolName: string; - toolInput: Record; - /** True once a PermissionRequest event has been emitted for this tool. */ - permissionRequested?: boolean; -} - -type ActiveChat = { - responseKey: string; - llm: weave.LLM; -} - -/** Emit `weave.permission_resolved` on a pending tool call's span, if one was requested. */ -export function resolvePermissionIfPending(pending: PendingToolCall, approved: boolean): void { - if (!pending.permissionRequested) return; - addPermissionResolvedEvent(pending.tool, { - approved, - timestamp: new Date(), - }); -} - -export function hashPrompt(prompt: string): string { - return sha256Hex(prompt); -} - -export function subagentsDirFor(sessionTranscriptPath: string): string { - const projectDir = path.dirname(sessionTranscriptPath); - const sessionDirName = path.basename(sessionTranscriptPath, '.jsonl'); - return path.join(projectDir, sessionDirName, 'subagents'); -} - -export function computeSubagentTranscriptPath(parentTranscriptPath: string, agentId: string): string { - return path.join(subagentsDirFor(parentTranscriptPath), `agent-${agentId}.jsonl`); -} - -export function extractUserMessageContent(line: Record | undefined): string | undefined { - if (!line || line['type'] !== 'user') return undefined; - const msg = line['message']; - if (!msg || typeof msg !== 'object') return undefined; - const content = (msg as Record)['content']; - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - const parts = content.filter(isTextBlock).map(block => block.text); - return parts.length > 0 ? parts.join('') : undefined; - } - return undefined; -} - -export function lastAssistantTextEndsWith( - result: NonNullable>, - suffix: string, -): boolean { - const call = result.turns.at(-1)?.assistantCalls().at(-1); - if (!call) return false; - return extractAssistantTextBlocks(call.contentBlocks).join('\n').trimEnd().endsWith(suffix); -} - -export type LoadedInstruction = { filePath: string; content: string }; - -export function upsertInstruction(list: LoadedInstruction[], item: LoadedInstruction): void { - const idx = list.findIndex((i) => i.filePath === item.filePath); - if (idx >= 0) list[idx] = item; - else list.push(item); -} - -const SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS = [0, 50, 100, 150]; -export async function readSubagentFirstLineWithRetry( - transcriptPath: string, -): Promise | undefined> { - for (const delay of SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS) { - if (delay > 0) await new Promise(r => setTimeout(r, delay)); - const line = readFirstTranscriptLine(transcriptPath); - if (line && line['type'] === 'user') return line; - } - return undefined; -} - -export type SubagentTracker = { - subagentType: string; - detectedAt: Date; - toolUseId?: string; - subAgent?: weave.SubAgent; - agentId?: string; - promptHash?: string; - ended?: boolean; - transcriptPath?: string; - pendingTeammateIdle?: boolean; - teamName?: string; -} - -export type TeamMember = { - subAgent: weave.SubAgent; - conversation: weave.Conversation; - coordinatorTranscriptPath: string; - emitted: boolean; -} +import { newCallState } from './callSpans.js'; +import type { CallState } from './callSpans.js'; + +export type TurnTrace = { + span: weave.Turn; + promptId?: string; + userText?: string; + /** A Stop snapshot is quiescent but remains reopenable because hooks block. */ + phase: 'active' | 'stopped'; + /** Number of provider responses already present when this prompt began. */ + responseOffset: number; + /** Frozen when a newer prompt starts, preventing cross-turn replay. */ + responseLimit?: number; + /** Supports repeated/blockable Stop hooks without duplicate chat spans. */ + seenResponses: Set; +}; export type SessionState = { sessionId: string; @@ -117,72 +31,30 @@ export type SessionState = { cwd: string; source: string; initialRequestModel?: string; + conversation: weave.Conversation; - conversation?: weave.Conversation; - - currentTurn?: weave.Turn; - - pendingToolCalls: Map; - subagents: SubagentTracking; + /** Canonical live turns plus foreground/prompt lookup indexes. */ + turns: Set; + currentTurn?: TurnTrace; + turnsByPromptId: Map; - activeChat?: ActiveChat; - emittedChatSpanResponseKeys: Set; + /** One state machine owns ordinary tools and Agent lifecycle identities. */ + calls: CallState; - /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ + /** Compaction attrs buffered while no turn span is open. */ pendingCompaction?: CompactionAttrs; - systemInstructions: LoadedInstruction[]; -} - -export class SubagentTracking { - private trackers: SubagentTracker[] = []; - - /** Add a pending tracker at PreToolUse, before SubagentStart correlates an agent_id. */ - add(tracker: SubagentTracker): void { - this.trackers.push(tracker); - } - - findUnmatchedByContent(promptHash: string, subagentType: string): SubagentTracker | undefined { - let best: SubagentTracker | undefined; - for (const t of this.trackers) { - if (t.agentId) continue; - if (t.promptHash !== promptHash) continue; - if (t.subagentType !== subagentType) continue; - if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; - } - return best; - } - - byAgentId(agentId: string): SubagentTracker | undefined { - return this.trackers.find(t => t.agentId === agentId); - } - - findPendingTeammateIdle(subagentType: string): SubagentTracker | undefined { - let best: SubagentTracker | undefined; - for (const t of this.trackers) { - if (!t.pendingTeammateIdle) continue; - if (t.subagentType !== subagentType) continue; - if (!best || t.detectedAt.getTime() < best.detectedAt.getTime()) best = t; - } - return best; - } - - byToolUseId(toolUseId: string): SubagentTracker | undefined { - return this.trackers.find(t => t.toolUseId === toolUseId); - } - - remove(tracker: SubagentTracker): void { - const idx = this.trackers.indexOf(tracker); - if (idx >= 0) this.trackers.splice(idx, 1); - } - - size(): number { - return this.trackers.length; - } + /** File path → latest loaded contents, preserving first-load order. */ + systemInstructions: Map; +}; - all(): SubagentTracker[] { - return [...this.trackers]; - } +export function turnForPrompt( + session: SessionState, + promptId: string | undefined, +): TurnTrace | undefined { + return promptId === undefined + ? session.currentTurn + : session.turnsByPromptId.get(promptId); } type NewSessionStateOptions = { @@ -193,39 +65,31 @@ type NewSessionStateOptions = { source: string; initialRequestModel: string | undefined; agentName: string; - tracingEnabled: boolean; }; export function newSessionState(options: NewSessionStateOptions): SessionState { - const { sessionId, conversationId, transcript, cwd, source, initialRequestModel } = - options; - // Preserve the Claude Code version when reconstructing a session. - const headLine = readFirstTranscriptLine(transcript.resolvedPath); - const version = headLine?.['version']; - const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; + const version = readFirstTranscriptLine(options.transcript.resolvedPath)?.version; const integrationAttrs = buildIntegrationAttrs({ version: VERSION, - meta: { claude_code_app_version: claudeCodeAppVersion }, + meta: { claude_code_app_version: version }, + }); + const conversation = weave.startConversation({ + conversationId: options.conversationId, + agentName: options.agentName, + attributes: { ...integrationAttrs, [ATTR.WEAVE_PLUGIN_VERSION]: VERSION }, }); - const conversation = options.tracingEnabled - ? weave.startConversation({ - conversationId, - agentName: options.agentName, - attributes: { ...integrationAttrs, [ATTR.WEAVE_PLUGIN_VERSION]: VERSION }, - }) - : undefined; return { - sessionId, - conversationId, - transcript, - cwd, - source, - initialRequestModel, + sessionId: options.sessionId, + conversationId: options.conversationId, + transcript: options.transcript, + cwd: options.cwd, + source: options.source, + initialRequestModel: options.initialRequestModel, conversation, - pendingToolCalls: new Map(), - subagents: new SubagentTracking(), - emittedChatSpanResponseKeys: new Set(), - systemInstructions: [], + turns: new Set(), + turnsByPromptId: new Map(), + calls: newCallState(), + systemInstructions: new Map(), }; } diff --git a/src/transcriptFile.ts b/src/transcriptFile.ts index 8de363f..5947dea 100644 --- a/src/transcriptFile.ts +++ b/src/transcriptFile.ts @@ -8,6 +8,15 @@ 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. Eight MiB comfortably covers that preamble while bounding the +// synchronous work and memory used to correlate an id-less lifecycle hook. +const TRANSCRIPT_SCAN_LIMIT_BYTES = 8 * 1024 * 1024; + +export type TranscriptHead = Record & { + version?: string; + forkedFrom?: { sessionId: string }; +}; /** * Represents a Claude Code transcript file. @@ -63,44 +72,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): Record | 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 === 0) 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 === 0) 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; - - return JSON.parse(line) as Record; + 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 { - if (fd !== undefined) { - try { fs.closeSync(fd); } catch { /* ignore */ } + 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; + } +} + +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/chat-spans.test.ts b/tests/chat-spans.test.ts new file mode 100644 index 0000000..890c638 --- /dev/null +++ b/tests/chat-spans.test.ts @@ -0,0 +1,121 @@ +// 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 { emitChatSpans } from '../src/chatSpans.ts'; +import { ATTR } from '../src/genaiSpans.ts'; +import type { SpanParent } from '../src/genaiSpans.ts'; +import type { AssistantResponse } from '../src/parser.ts'; + +type StartedChat = { + init: unknown; + record?: unknown; + attributes?: unknown; + end?: unknown; +}; + +test('emits each normalized response once without regrouping its content', () => { + const started: StartedChat[] = []; + const parent = { + startLLM(init: unknown) { + const chat: StartedChat = { init }; + started.push(chat); + return { + record(value: unknown) { chat.record = value; }, + setAttributes(value: unknown) { chat.attributes = value; }, + end(value: unknown) { chat.end = value; }, + }; + }, + } as unknown as SpanParent; + const response: AssistantResponse = { + id: 'msg-a', + model: 'claude-opus-4-8', + startTime: '2026-01-01T00:00:01.000Z', + endTime: '2026-01-01T00:00:02.000Z', + usage: { + input_tokens: 10, + output_tokens: 4, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 5, + }, + reasoningTokens: 3, + content: [ + { type: 'thinking', thinking: 'considering' }, + { type: 'text', text: 'editing' }, + { type: 'tool_use', id: 'tool-1', name: 'Edit', input: { file_path: '/x' } }, + ], + finishReason: 'tool_use', + }; + const seen = new Set(); + + emitChatSpans(parent, [response], { agentName: 'researcher', seen }); + emitChatSpans(parent, [response], { agentName: 'researcher', seen }); + + assert.equal(started.length, 1); + assert.deepEqual(started[0].init, { + model: 'claude-opus-4-8', + providerName: 'anthropic', + startTime: new Date('2026-01-01T00:00:01.000Z'), + }); + assert.deepEqual(started[0].record, { + outputMessages: [{ + role: 'assistant', + parts: [ + { type: 'reasoning', content: 'considering' }, + { type: 'text', content: 'editing' }, + { + type: 'tool_call', + toolCallId: 'tool-1', + toolName: 'Edit', + arguments: '{"file_path":"/x"}', + }, + ], + }], + usage: { + inputTokens: 35, + outputTokens: 4, + cacheReadInputTokens: 20, + cacheCreationInputTokens: 5, + reasoningTokens: 3, + }, + outputType: 'text', + responseId: 'msg-a', + finishReasons: ['tool_use'], + }); + assert.deepEqual(started[0].attributes, { [ATTR.AGENT_NAME]: 'researcher' }); + assert.deepEqual(started[0].end, { + endTime: new Date('2026-01-01T00:00:02.000Z'), + }); + assert.deepEqual([...seen], ['id:msg-a:0']); +}); + +test('does not confuse nonconsecutive responses that reuse an id', () => { + const recordedIds: string[] = []; + const parent = { + startLLM() { + return { + record(value: { responseId?: string }) { + if (value.responseId) recordedIds.push(value.responseId); + }, + setAttributes() {}, + end() {}, + }; + }, + } as unknown as SpanParent; + const response = (id: string, text: string): AssistantResponse => ({ + id, + model: 'claude-opus-4-8', + usage: { input_tokens: 1, output_tokens: 1 }, + content: [{ type: 'text', text }], + }); + + emitChatSpans(parent, [ + response('shared', 'first'), + response('other', 'middle'), + response('shared', 'last'), + ], { seen: new Set() }); + + assert.deepEqual(recordedIds, ['shared', 'other', 'shared']); +}); diff --git a/tests/daemon-idle-inflight.test.ts b/tests/daemon-idle-inflight.test.ts index c52fbb7..1848159 100644 --- a/tests/daemon-idle-inflight.test.ts +++ b/tests/daemon-idle-inflight.test.ts @@ -2,14 +2,8 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// The daemon idles out after a quiet window, but the inactivity check only held -// it open for in-flight cross-session *team* work. A plain long-running tool or -// turn (longer than the timeout, with no other session active) tripped the -// timeout mid-flight: the daemon exited, dropped the still-open turn/tool spans, -// and the resumed work landed on a fresh, amnesiac daemon. -// -// The fix: also hold the daemon open while any session has an open turn span, a -// pending tool call, or a tracked subagent. +// Active root or tool work pins the daemon across its idle window. A blockable +// Stop keeps its root reopenable but makes it quiescent when no call is open. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -56,7 +50,7 @@ test('daemon stays up past the inactivity timeout while a turn span is open', as } }); -test('daemon still idles out once the turn closes and nothing is in flight', async () => { +test('daemon idles out once a stopped turn is quiescent', async () => { const d = await startTestDaemon({ env: { WEAVE_INACTIVITY_MS: '1000' } }); try { const sessionId = 'inflight-002'; @@ -65,10 +59,35 @@ test('daemon still idles out once the turn closes and nothing is in flight', asy await d.send({ hook_event_name: 'UserPromptSubmit', session_id: sessionId, transcript_path: transcript, prompt: 'a quick task' }); await d.send({ hook_event_name: 'Stop', session_id: sessionId, transcript_path: transcript }); - // Turn span closed → nothing in flight → the daemon must still decide to - // idle out (the in-flight hold must not pin it open forever). + // Stop is blockable and retains the root for a continuation, but without + // active work it must not pin the daemon open indefinitely. const shuttingDown = await d.waitForLog(/Inactivity timeout — shutting down/, 3500); - assert.ok(shuttingDown, `daemon should idle out after the turn closes; log was:\n${d.readLog()}`); + assert.ok(shuttingDown, `daemon should idle out after the turn becomes quiescent; log was:\n${d.readLog()}`); + } finally { + await d.stop(); + } +}); + +test('an open tool keeps a stopped turn alive', async () => { + const d = await startTestDaemon({ env: { WEAVE_INACTIVITY_MS: '1000' } }); + try { + const sessionId = 'inflight-tool'; + const transcript = writeTranscript(d.home, sessionId); + await d.send({ hook_event_name: 'SessionStart', session_id: sessionId, transcript_path: transcript }); + await d.send({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, + transcript_path: transcript, prompt: 'a background tool', + }); + await d.send({ + hook_event_name: 'PreToolUse', session_id: sessionId, + transcript_path: transcript, tool_use_id: 'long-read', + tool_name: 'Read', tool_input: { file_path: '/tmp/slow' }, + }); + await d.send({ hook_event_name: 'Stop', session_id: sessionId, transcript_path: transcript }); + + const stayedUp = await d.waitForLog(/work in flight — staying up/, 3000); + assert.ok(stayedUp, `daemon should hold open for the tool; log was:\n${d.readLog()}`); + assert.equal(d.hasExited(), false); } finally { await d.stop(); } diff --git a/tests/helpers.ts b/tests/helpers.ts index 8541f43..b1c605b 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 GlobalDaemon('/tmp/unused.sock', logFile, { diff --git a/tests/parser.test.ts b/tests/parser.test.ts new file mode 100644 index 0000000..2aef439 --- /dev/null +++ b/tests/parser.test.ts @@ -0,0 +1,180 @@ +// 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 * as os from 'node:os'; +import * as path from 'node:path'; +import { + assistantResponses, + lastAssistantTextEndsWith, + parseSessionFd, +} from '../src/parser.ts'; +import type { ParsedSession } from '../src/parser.ts'; + +function parseLines(lines: unknown[]): ParsedSession { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'weave-parser-')); + const file = path.join(dir, 'session.jsonl'); + fs.writeFileSync(file, `${lines.map(line => JSON.stringify(line)).join('\n')}\n`); + const fd = fs.openSync(file, 'r'); + try { + const parsed = parseSessionFd(fd); + assert.ok(parsed); + return parsed; + } finally { + fs.closeSync(fd); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function assistant( + id: string | undefined, + timestamp: string, + content: unknown, + options: { + model?: string; + usage?: Record; + finishReason?: string; + } = {}, +): Record { + return { + type: 'assistant', + timestamp, + message: { + role: 'assistant', + ...(id ? { id } : {}), + model: options.model ?? 'claude-opus-4-8', + usage: options.usage ?? { input_tokens: 1, output_tokens: 1 }, + content, + ...(options.finishReason ? { stop_reason: options.finishReason } : {}), + }, + }; +} + +test('normalizes responses and splits turns only at typed prompts', () => { + const session = parseLines([ + { + type: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + message: { role: 'user', content: 'do it' }, + }, + assistant( + 'msg-a', + '2026-01-01T00:00:01.000Z', + [{ type: 'thinking', thinking: 'first block' }], + { model: 'first-model', usage: { input_tokens: 1, output_tokens: 2 } }, + ), + { + type: 'user', + timestamp: '2026-01-01T00:00:01.500Z', + message: { + role: 'user', + content: [{ type: 'text', text: 'keep going' }], + }, + }, + assistant( + 'msg-a', + '2026-01-01T00:00:02.000Z', + [{ type: 'text', text: 'second block' }], + { + model: 'final-model', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + reasoning_tokens: 7, + }, + finishReason: 'end_turn', + }, + ), + assistant( + 'msg-b', + '2026-01-01T00:00:03.000Z', + [{ type: 'text', text: 'still the same turn' }], + ), + { + type: 'user', + timestamp: '2026-01-01T00:00:04.000Z', + message: { role: 'user', content: 'second prompt' }, + }, + assistant('msg-c', '2026-01-01T00:00:05.000Z', 'second answer'), + { + type: 'user', + timestamp: '2026-01-01T00:00:06.000Z', + message: { role: 'user', content: 'prompt without an answer yet' }, + }, + ]); + + assert.equal(session.turns.length, 3); + assert.deepEqual(session.turns[0], { + startTime: '2026-01-01T00:00:00.000Z', + userText: 'do it', + model: 'claude-opus-4-8', + text: ['second block', 'still the same turn'], + responses: [ + { + startTime: '2026-01-01T00:00:00.000Z', + endTime: '2026-01-01T00:00:02.000Z', + model: 'final-model', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + }, + reasoningTokens: 7, + content: [ + { type: 'thinking', thinking: 'first block' }, + { type: 'text', text: 'second block' }, + ], + id: 'msg-a', + finishReason: 'end_turn', + }, + { + startTime: '2026-01-01T00:00:02.000Z', + endTime: '2026-01-01T00:00:03.000Z', + model: 'claude-opus-4-8', + usage: { + input_tokens: 1, + output_tokens: 1, + cache_read_input_tokens: undefined, + cache_creation_input_tokens: undefined, + }, + reasoningTokens: undefined, + content: [{ type: 'text', text: 'still the same turn' }], + id: 'msg-b', + finishReason: undefined, + }, + ], + }); + assert.equal(session.turns[1].userText, 'second prompt'); + assert.deepEqual(session.turns[1].text, ['second answer']); + assert.deepEqual(session.turns[2], { + startTime: '2026-01-01T00:00:06.000Z', + userText: 'prompt without an answer yet', + model: undefined, + text: [], + responses: [], + }); + assert.equal(assistantResponses(session).length, 3); + assert.equal(lastAssistantTextEndsWith(session, 'second answer'), true); +}); + +test('folds only consecutive assistant lines with the same response id', () => { + const session = parseLines([ + { type: 'user', message: { role: 'user', content: 'go' } }, + assistant('shared', '2026-01-01T00:00:01.000Z', [{ type: 'text', text: 'one' }]), + assistant('other', '2026-01-01T00:00:02.000Z', [{ type: 'text', text: 'two' }]), + assistant('shared', '2026-01-01T00:00:03.000Z', [{ type: 'text', text: 'three' }]), + assistant(undefined, '2026-01-01T00:00:04.000Z', [{ type: 'text', text: 'four' }]), + assistant(undefined, '2026-01-01T00:00:05.000Z', [{ type: 'text', text: 'five' }]), + ]); + + assert.deepEqual( + session.turns[0].responses.map(response => response.id), + ['shared', 'other', 'shared', undefined, undefined], + ); +}); diff --git a/tests/subagent-nesting.test.ts b/tests/subagent-nesting.test.ts new file mode 100644 index 0000000..81df476 --- /dev/null +++ b/tests/subagent-nesting.test.ts @@ -0,0 +1,823 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import { test, type TestContext } 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'; + +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 }; +} + +async function finish(daemon: ReturnType, sid: string) { + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); +} + +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'); + } +}); + +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.OPERATION_NAME] === 'invoke_agent' + && span.attributes[ATTR.AGENT_NAME] === displayName).length, 1); + assert.equal(spans.some(span => + span.attributes['gen_ai.tool.call.id'] === `${label}-agent-call`), false); + }); +} + +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: { name: 'instance-alias', 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('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('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('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('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('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'], + ); +}); + +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('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); +}); + +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/system-instructions-integration.test.ts b/tests/system-instructions-integration.test.ts index 09c84e9..0a7cc13 100644 --- a/tests/system-instructions-integration.test.ts +++ b/tests/system-instructions-integration.test.ts @@ -62,6 +62,7 @@ test('buffers InstructionsLoaded fired before SessionStart, then accumulates in await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'PROJECT', 'session_start')); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); await flushWeave(); const [turn] = turnRoots(exporter.getFinishedSpans()); @@ -92,6 +93,7 @@ test('re-loading the same file replaces its content rather than duplicating', as await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'V2', 'compact')); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); await flushWeave(); const [turn] = turnRoots(exporter.getFinishedSpans()); @@ -119,6 +121,7 @@ test('stamps system instructions on every turn root (no session span to hang the await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'turn two' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); await flushWeave(); const turns = turnRoots(exporter.getFinishedSpans()); @@ -142,6 +145,7 @@ test('omits gen_ai.system_instructions when no instructions were loaded', async await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); await flushWeave(); const [turn] = turnRoots(exporter.getFinishedSpans()); diff --git a/tests/tool-lifecycle.test.ts b/tests/tool-lifecycle.test.ts new file mode 100644 index 0000000..ccd0a32 --- /dev/null +++ b/tests/tool-lifecycle.test.ts @@ -0,0 +1,419 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ATTR } from '../src/genaiSpans.ts'; +import { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + spanParentId, +} from './helpers.ts'; + +function makeTranscript(t: TestContext, sessionId: string, prompt: string) { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-tool-lifecycle-')); + const file = path.join(dir, `${sessionId}.jsonl`); + const append = (entry: Record) => { + fs.appendFileSync(file, JSON.stringify(entry) + '\n'); + }; + fs.writeFileSync(file, ''); + append({ type: 'user', message: { role: 'user', content: prompt } }); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return { + file, + appendPrompt(text: string) { + append({ type: 'user', message: { role: 'user', content: text } }); + }, + appendResponse(id: string, text: string) { + append({ + type: 'assistant', + message: { + role: 'assistant', + id, + model: 'claude-opus-4-8', + usage: { input_tokens: 10, output_tokens: 5 }, + content: [{ type: 'text', text }], + }, + }); + }, + }; +} + +function toolSpans(spans: ReadableSpan[]): ReadableSpan[] { + return spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); +} + +function turnSpans(spans: ReadableSpan[]): ReadableSpan[] { + return spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); +} + +test('ordinary tool calls are traced once', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'ordinary-tool'; + const transcript = makeTranscript(t, sessionId, 'read it'); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, prompt: 'read it', + }); + const tool = { + session_id: sessionId, + tool_use_id: 'read-1', + tool_name: 'Read', + tool_input: { file_path: '/tmp/input.txt' }, + }; + await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...tool }); + await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...tool }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...tool, tool_response: 'contents' }); + await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...tool }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...tool, tool_response: 'duplicate' }); + + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const tools = toolSpans(spans); + const [turn] = turnSpans(spans); + assert.equal(tools.length, 1); + assert.ok(turn); + assert.equal(tools[0].attributes['gen_ai.tool.call.id'], 'read-1'); + assert.equal(tools[0].attributes['gen_ai.tool.call.result'], 'contents'); + assert.equal(tools[0].attributes[ATTR.WEAVE_DISPLAY_NAME], 'Read: /tmp/input.txt'); + assert.equal(spanParentId(tools[0]), turn.spanContext().spanId); +}); + +test('PostToolUseFailure records the tool result and error type', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'failed-tool'; + const transcript = makeTranscript(t, sessionId, 'run it'); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sessionId, prompt: 'run it' }); + const tool = { + session_id: sessionId, + tool_use_id: 'bash-1', + tool_name: 'Bash', + tool_input: { command: 'exit 1' }, + }; + await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...tool }); + await daemon.routeEvent({ + hook_event_name: 'PostToolUseFailure', ...tool, error: 'CommandError: exit 1', + }); + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); + await flushWeave(); + + const [span] = toolSpans(exporter.getFinishedSpans()); + assert.ok(span); + assert.equal(span.attributes['gen_ai.tool.call.result'], 'CommandError: exit 1'); + assert.equal(span.attributes[ATTR.ERROR_TYPE], 'CommandError'); + assert.equal(span.status.code, 2); +}); + +test('a restart-first terminal hook recovers one exact tool and turn', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'terminal-tool-restart'; + const transcript = makeTranscript(t, sessionId, 'read after restart'); + const daemon = makeGenaiDaemon(); + const tool = { + session_id: sessionId, + transcript_path: transcript.file, + cwd: '/x', + tool_use_id: 'recovered-read', + tool_name: 'Read', + tool_input: { file_path: '/tmp/recovered.txt' }, + }; + + await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...tool, tool_response: 'contents' }); + await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...tool }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...tool, tool_response: 'duplicate' }); + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const tools = toolSpans(spans); + const turns = turnSpans(spans); + assert.equal(tools.length, 1); + assert.equal(turns.length, 1); + assert.equal(spanParentId(tools[0]), turns[0].spanContext().spanId); + assert.equal( + turns[0].attributes[ATTR.INPUT_MESSAGES], + JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'read after restart' }] }]), + ); +}); + +test('restart-first tool results preserve SessionEnd prompt identity', async (t) => { + const scenarios: Array<{ + name: string; + eventPrompt?: string; + endPrompt?: string; + sameRoot: boolean; + }> = [ + { name: 'same explicit prompt', eventPrompt: 'prompt-a', endPrompt: 'prompt-a', sameRoot: true }, + { name: 'different explicit prompts', eventPrompt: 'prompt-a', endPrompt: 'prompt-b', sameRoot: false }, + { name: 'legacy result then explicit end', endPrompt: 'prompt-b', sameRoot: true }, + { name: 'explicit result then legacy end', eventPrompt: 'prompt-a', sameRoot: false }, + ]; + + for (const scenario of scenarios) { + await t.test(scenario.name, async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = `restart-prompt-${scenario.name.replaceAll(' ', '-')}`; + const transcript = makeTranscript(t, sessionId, 'older'); + transcript.appendResponse('older-response', 'old'); + transcript.appendPrompt('final'); + transcript.appendResponse('final-response', 'finished'); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', session_id: sessionId, + prompt_id: scenario.eventPrompt, transcript_path: transcript.file, + tool_use_id: 'restart-tool', tool_name: 'Read', + tool_input: { file_path: '/tmp/restart' }, tool_response: 'contents', + }); + await daemon.routeEvent({ + hook_event_name: 'SessionEnd', session_id: sessionId, + prompt_id: scenario.endPrompt, transcript_path: transcript.file, reason: 'clear', + }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const tool = toolSpans(spans).find(span => + span.attributes['gen_ai.tool.call.id'] === 'restart-tool'); + const chat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'final-response'); + assert.ok(tool && chat); + const turns = turnSpans(spans); + assert.equal(turns.length, scenario.sameRoot ? 1 : 2); + const finalTurn = turns.find(turn => turn.spanContext().spanId === spanParentId(chat)); + assert.ok(String(finalTurn?.attributes[ATTR.INPUT_MESSAGES]).includes('final')); + assert.equal( + spanParentId(tool) === spanParentId(chat), + scenario.sameRoot, + 'prompt identity determines whether recovery reuses the same root', + ); + }); + } +}); + +test('SessionEnd orphans unfinished tools before closing their turn', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'open-tool-session-end'; + const transcript = makeTranscript(t, sessionId, 'keep reading'); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, prompt: 'keep reading', + }); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sessionId, + tool_use_id: 'open-read', tool_name: 'Read', tool_input: { file_path: '/tmp/open.txt' }, + }); + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const [tool] = toolSpans(spans); + const [turn] = turnSpans(spans); + assert.ok(tool && turn); + assert.equal(tool.attributes[ATTR.WEAVE_ORPHAN_REASON], 'session_ended'); + assert.equal(tool.status.code, 2); + assert.equal(spanParentId(tool), turn.spanContext().spanId); +}); + +test('prompt_id keeps background tools attached to their original turns', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'tool-prompt-ownership'; + const transcript = makeTranscript(t, sessionId, 'first'); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, + prompt_id: 'prompt-1', prompt: 'first', + }); + const first = { + session_id: sessionId, prompt_id: 'prompt-1', + tool_use_id: 'read-first', tool_name: 'Read', tool_input: { file_path: '/tmp/first' }, + }; + await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...first }); + + transcript.appendPrompt('second'); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, + prompt_id: 'prompt-2', prompt: 'second', + }); + const second = { + session_id: sessionId, prompt_id: 'prompt-2', + tool_use_id: 'read-second', tool_name: 'Read', tool_input: { file_path: '/tmp/second' }, + }; + await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...second }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...second, tool_response: 'second' }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...first, tool_response: 'first' }); + await flushWeave(); + assert.equal(turnSpans(exporter.getFinishedSpans()).length, 1); + + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turns = turnSpans(spans); + const tools = toolSpans(spans); + assert.equal(turns.length, 2); + assert.equal(tools.length, 2); + const firstTurn = turns.find(span => String(span.attributes[ATTR.INPUT_MESSAGES]).includes('first')); + const secondTurn = turns.find(span => String(span.attributes[ATTR.INPUT_MESSAGES]).includes('second')); + const firstTool = tools.find(span => span.attributes['gen_ai.tool.call.id'] === 'read-first'); + const secondTool = tools.find(span => span.attributes['gen_ai.tool.call.id'] === 'read-second'); + assert.ok(firstTurn && secondTurn && firstTool && secondTool); + assert.equal(spanParentId(firstTool), firstTurn.spanContext().spanId); + assert.equal(spanParentId(secondTool), secondTurn.spanContext().spanId); +}); + +test('Stop(prompt_id) snapshots only its turn and later tools keep their owners', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'stop-selected-prompt'; + const transcript = makeTranscript(t, sessionId, 'older prompt'); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, + prompt_id: 'prompt-1', prompt: 'older prompt', + }); + await daemon.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sessionId, prompt_id: 'prompt-1', + tool_use_id: 'hold-older', tool_name: 'Read', tool_input: { file_path: '/tmp/hold' }, + }); + + transcript.appendResponse('older-response', 'older answer'); + transcript.appendPrompt('newer prompt'); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, + prompt_id: 'prompt-2', prompt: 'newer prompt', + }); + transcript.appendResponse('newer-response', 'newer answer'); + await daemon.routeEvent({ + hook_event_name: 'Stop', session_id: sessionId, prompt_id: 'prompt-1', + }); + await flushWeave(); + + const afterStop = exporter.getFinishedSpans(); + assert.deepEqual(chatsById(afterStop), ['older-response']); + assert.equal(turnSpans(afterStop).length, 0, 'blockable Stop retains both roots'); + + for (const [promptId, toolUseId] of [ + ['prompt-1', 'continued-older'], + ['prompt-2', 'newer-tool'], + ] as const) { + const tool = { + session_id: sessionId, prompt_id: promptId, tool_use_id: toolUseId, + tool_name: 'Read', tool_input: { file_path: `/tmp/${toolUseId}` }, + }; + await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...tool }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...tool, tool_response: 'done' }); + } + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const olderChat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'older-response'); + const newerChat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'newer-response'); + const olderTool = toolSpans(spans).find(span => + span.attributes['gen_ai.tool.call.id'] === 'continued-older'); + const newerTool = toolSpans(spans).find(span => + span.attributes['gen_ai.tool.call.id'] === 'newer-tool'); + assert.ok(olderChat && newerChat && olderTool && newerTool); + assert.equal(spanParentId(olderTool), spanParentId(olderChat)); + assert.equal(spanParentId(newerTool), spanParentId(newerChat)); + assert.notEqual(spanParentId(olderChat), spanParentId(newerChat)); +}); + +test('a legacy next prompt orphans its open tool and starts a clean turn', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'legacy-tool-prompt-boundary'; + const transcript = makeTranscript(t, sessionId, 'first'); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, prompt: 'first', + }); + const interrupted = { + session_id: sessionId, tool_use_id: 'interrupted-tool', + tool_name: 'Bash', tool_input: { command: 'sleep 999' }, + }; + await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...interrupted }); + + transcript.appendPrompt('second'); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, prompt: 'second', + }); + await daemon.routeEvent({ + hook_event_name: 'PostToolUse', ...interrupted, tool_response: 'too late', + }); + const next = { + session_id: sessionId, tool_use_id: 'next-tool', + tool_name: 'Read', tool_input: { file_path: '/tmp/next' }, + }; + await daemon.routeEvent({ hook_event_name: 'PreToolUse', ...next }); + await daemon.routeEvent({ hook_event_name: 'PostToolUse', ...next, tool_response: 'next result' }); + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const firstTurn = turnSpans(spans).find(span => + String(span.attributes[ATTR.INPUT_MESSAGES]).includes('first')); + const secondTurn = turnSpans(spans).find(span => + String(span.attributes[ATTR.INPUT_MESSAGES]).includes('second')); + const oldTool = toolSpans(spans).find(span => + span.attributes['gen_ai.tool.call.id'] === 'interrupted-tool'); + const nextTool = toolSpans(spans).find(span => + span.attributes['gen_ai.tool.call.id'] === 'next-tool'); + assert.ok(firstTurn && secondTurn && oldTool && nextTool); + assert.equal(oldTool.attributes[ATTR.WEAVE_ORPHAN_REASON], 'superseded_by_next_prompt'); + assert.equal(oldTool.attributes['gen_ai.tool.call.result'], undefined); + assert.equal(spanParentId(oldTool), firstTurn.spanContext().spanId); + assert.equal(nextTool.attributes['gen_ai.tool.call.result'], 'next result'); + assert.equal(nextTool.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined); + assert.equal(spanParentId(nextTool), secondTurn.spanContext().spanId); +}); + +function chatsById(spans: ReadableSpan[]): unknown[] { + return spans + .filter(span => span.attributes[ATTR.OPERATION_NAME] === 'chat') + .map(span => span.attributes[ATTR.RESPONSE_ID]); +} diff --git a/tests/turn-lifecycle.test.ts b/tests/turn-lifecycle.test.ts new file mode 100644 index 0000000..e7567ae --- /dev/null +++ b/tests/turn-lifecycle.test.ts @@ -0,0 +1,380 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ATTR } from '../src/genaiSpans.ts'; +import { VERSION } from '../src/setup.ts'; +import { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + spanParentId, +} from './helpers.ts'; + +type Transcript = { + file: string; + append(...entries: Record[]): void; +}; + +function makeTranscript(t: TestContext, sessionId: string): Transcript { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-turn-lifecycle-')); + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, ''); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return { + file, + append(...entries) { + fs.appendFileSync(file, entries.map(entry => JSON.stringify(entry)).join('\n') + '\n'); + }, + }; +} + +function userEntry( + text: string, + options: { timestamp?: string; version?: string } = {}, +): Record { + return { + type: 'user', + ...options, + message: { role: 'user', content: text }, + }; +} + +function assistantEntry( + id: string, + text: string, + options: { + timestamp?: string; + usage?: Record; + finishReason?: string; + } = {}, +): Record { + return { + type: 'assistant', + ...(options.timestamp ? { timestamp: options.timestamp } : {}), + message: { + role: 'assistant', + id, + model: 'claude-opus-4-8', + usage: options.usage ?? { input_tokens: 100, output_tokens: 50 }, + content: [{ type: 'text', text }], + ...(options.finishReason ? { stop_reason: options.finishReason } : {}), + }, + }; +} + +function turns(spans: ReadableSpan[]): ReadableSpan[] { + return spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); +} + +function chats(spans: ReadableSpan[]): ReadableSpan[] { + return spans.filter(span => span.attributes[ATTR.OPERATION_NAME] === 'chat'); +} + +test('Stop snapshots only new normalized responses and SessionEnd closes the root', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'root-stop-snapshots'; + const transcript = makeTranscript(t, sessionId); + transcript.append(userEntry('do it', { + timestamp: '2026-01-01T00:00:00.000Z', + version: '1.2.3', + })); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, prompt: 'do it', + }); + transcript.append(assistantEntry('response-a', 'working', { + timestamp: '2026-01-01T00:00:01.000Z', + usage: { input_tokens: 10, output_tokens: 4, cache_read_input_tokens: 20 }, + })); + await daemon.routeEvent({ hook_event_name: 'Stop', session_id: sessionId }); + await flushWeave(); + + assert.equal(turns(exporter.getFinishedSpans()).length, 0, 'blockable Stop retains the root'); + assert.deepEqual(chats(exporter.getFinishedSpans()).map(span => span.attributes[ATTR.RESPONSE_ID]), [ + 'response-a', + ]); + + transcript.append(assistantEntry('response-b', 'done', { + timestamp: '2026-01-01T00:00:02.000Z', + finishReason: 'end_turn', + })); + await daemon.routeEvent({ hook_event_name: 'Stop', session_id: sessionId }); + await daemon.routeEvent({ + hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear', + }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const [turn] = turns(spans); + const responseSpans = chats(spans); + assert.ok(turn); + assert.equal(responseSpans.length, 2, 'repeated Stop does not replay response-a'); + assert.deepEqual(responseSpans.map(span => span.attributes[ATTR.RESPONSE_ID]), [ + 'response-a', + 'response-b', + ]); + assert.ok(responseSpans.every(span => spanParentId(span) === turn.spanContext().spanId)); + assert.equal(turn.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined); + assert.deepEqual(turn.attributes[ATTR.RESPONSE_FINISH_REASONS], ['end_turn']); + assert.equal(turn.attributes[ATTR.WEAVE_INTEGRATION_NAME], 'weave-claude-code'); + assert.equal(turn.attributes[ATTR.WEAVE_INTEGRATION_VERSION], VERSION); + assert.equal(turn.attributes['weave.integration.meta.claude_code_app_version'], '1.2.3'); + assert.equal(responseSpans[0].attributes[ATTR.USAGE_INPUT_TOKENS], 30); +}); + +test('a newer prompt closes an interrupted root without replaying its response', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'root-interrupted'; + const transcript = makeTranscript(t, sessionId); + transcript.append(userEntry('first')); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sessionId, prompt: 'first' }); + transcript.append( + assistantEntry('only-once', 'first answer'), + userEntry('second'), + ); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sessionId, prompt: 'second' }); + transcript.append(userEntry('third')); + await daemon.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sessionId, prompt: 'third' }); + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + assert.equal(spans.filter(span => span.attributes[ATTR.RESPONSE_ID] === 'only-once').length, 1); + const rootSpans = turns(spans); + assert.equal(rootSpans.length, 3); + const first = rootSpans.find(span => String(span.attributes[ATTR.INPUT_MESSAGES]).includes('first')); + const second = rootSpans.find(span => String(span.attributes[ATTR.INPUT_MESSAGES]).includes('second')); + assert.ok(first && second); + assert.equal(first.attributes[ATTR.WEAVE_ORPHAN_REASON], 'superseded_by_next_prompt'); + assert.equal(second.attributes[ATTR.WEAVE_ORPHAN_REASON], 'superseded_by_next_prompt'); +}); + +test('an identical prompt submitted during transcript lag does not replay prior output', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'root-repeated-prompt-race'; + const transcript = makeTranscript(t, sessionId); + transcript.append(userEntry('same prompt')); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, + prompt_id: 'prompt-a', prompt: 'same prompt', + }); + transcript.append(assistantEntry('response-a', 'first answer')); + await daemon.routeEvent({ + hook_event_name: 'Stop', session_id: sessionId, prompt_id: 'prompt-a', + }); + + // The second hook can arrive before its identical user line reaches JSONL. + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, + prompt_id: 'prompt-b', prompt: 'same prompt', + }); + await daemon.routeEvent({ + hook_event_name: 'Stop', session_id: sessionId, prompt_id: 'prompt-b', + }); + await flushWeave(); + + const laggingSpans = exporter.getFinishedSpans(); + assert.equal(turns(laggingSpans).length, 1, 'only the completed first root exports during lag'); + assert.deepEqual( + chats(laggingSpans).map(span => span.attributes[ATTR.RESPONSE_ID]), + ['response-a'], + 'the lagging second root does not replay the first response', + ); + + transcript.append( + userEntry('same prompt'), + assistantEntry('response-b', 'second answer'), + ); + await daemon.routeEvent({ + hook_event_name: 'Stop', session_id: sessionId, prompt_id: 'prompt-b', + }); + await daemon.routeEvent({ + hook_event_name: 'SessionEnd', session_id: sessionId, + prompt_id: 'prompt-b', reason: 'clear', + }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + assert.equal(turns(spans).length, 2); + assert.deepEqual( + chats(spans).map(span => span.attributes[ATTR.RESPONSE_ID]), + ['response-a', 'response-b'], + ); +}); + +test('duplicate prompt_id is idempotent', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'root-prompt-id'; + const transcript = makeTranscript(t, sessionId); + transcript.append(userEntry('once')); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + const prompt = { + hook_event_name: 'UserPromptSubmit', session_id: sessionId, + prompt_id: 'prompt-1', prompt: 'once', + }; + await daemon.routeEvent(prompt); + await daemon.routeEvent(prompt); + await daemon.routeEvent({ + hook_event_name: 'SessionEnd', session_id: sessionId, + prompt_id: 'prompt-1', reason: 'clear', + }); + await flushWeave(); + + assert.equal(turns(exporter.getFinishedSpans()).length, 1); +}); + +test('an out-of-order Stop does not replace the foreground prompt', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'root-out-of-order-stop'; + const transcript = makeTranscript(t, sessionId); + transcript.append(userEntry('foreground')); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionStart', session_id: sessionId, + transcript_path: transcript.file, source: 'startup', cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, + prompt_id: 'foreground-id', prompt: 'foreground', + }); + await daemon.routeEvent({ + hook_event_name: 'Stop', session_id: sessionId, + prompt_id: 'background-id', transcript_path: transcript.file, + }); + transcript.append(userEntry('next')); + await daemon.routeEvent({ + hook_event_name: 'UserPromptSubmit', session_id: sessionId, + prompt_id: 'next-id', prompt: 'next', + }); + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); + await flushWeave(); + + const foreground = turns(exporter.getFinishedSpans()).find(span => + String(span.attributes[ATTR.INPUT_MESSAGES]).includes('foreground')); + assert.ok(foreground); + assert.equal(foreground.attributes[ATTR.WEAVE_ORPHAN_REASON], 'superseded_by_next_prompt'); +}); + +test('SessionEnd alone reconstructs the final turn, including its input', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'root-session-end-restart'; + const transcript = makeTranscript(t, sessionId); + transcript.append( + userEntry('finish it', { timestamp: '2026-01-01T00:00:00.000Z' }), + assistantEntry('restart-final', 'finished', { + timestamp: '2026-01-01T00:00:01.000Z', + finishReason: 'end_turn', + }), + ); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'SessionEnd', session_id: sessionId, + prompt_id: 'final-prompt', transcript_path: transcript.file, + cwd: '/x', reason: 'clear', + }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const [turn] = turns(spans); + const chat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'restart-final'); + assert.ok(turn && chat); + assert.equal( + turn.attributes[ATTR.INPUT_MESSAGES], + JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'finish it' }] }]), + ); + assert.equal(spanParentId(chat), turn.spanContext().spanId); +}); + +test('restart-first Stop does not claim another transcript prompt', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'root-stop-restart'; + const transcript = makeTranscript(t, sessionId); + transcript.append( + userEntry('older'), + assistantEntry('older-response', 'old'), + userEntry('newer'), + assistantEntry('newer-response', 'new'), + ); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'Stop', session_id: sessionId, prompt_id: 'older-prompt', + transcript_path: transcript.file, cwd: '/x', + }); + await daemon.routeEvent({ hook_event_name: 'SessionEnd', session_id: sessionId, reason: 'clear' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + assert.equal(turns(spans).length, 1); + assert.equal(chats(spans).length, 0); +}); + +test('SessionEnd binds a restart-first root with the same prompt_id', async (t) => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sessionId = 'root-stop-same-prompt-restart'; + const transcript = makeTranscript(t, sessionId); + transcript.append( + userEntry('final prompt'), + assistantEntry('final-response', 'finished'), + ); + const daemon = makeGenaiDaemon(); + + await daemon.routeEvent({ + hook_event_name: 'Stop', session_id: sessionId, prompt_id: 'prompt-a', + transcript_path: transcript.file, cwd: '/x', + }); + await daemon.routeEvent({ + hook_event_name: 'SessionEnd', session_id: sessionId, prompt_id: 'prompt-a', + transcript_path: transcript.file, reason: 'clear', + }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const [turn] = turns(spans); + const chat = spans.find(span => span.attributes[ATTR.RESPONSE_ID] === 'final-response'); + assert.ok(turn && chat); + assert.equal( + turn.attributes[ATTR.INPUT_MESSAGES], + JSON.stringify([{ role: 'user', parts: [{ type: 'text', content: 'final prompt' }] }]), + ); + assert.equal(spanParentId(chat), turn.spanContext().spanId); +});