From 03aef7a329b89c23ab7369087df36f2c8706eaf1 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Sat, 18 Jul 2026 23:07:26 -0700 Subject: [PATCH 01/11] feat(daemon): chat + tool spans via the SDK Emit a chat span (LLM) per assistant API response and execute_tool spans for tool calls, restoring the coverage parked by the turn-core swap: - advanceMainAgentChatSpan maps a PreToolUse tool_use_id to its response via the transcript and opens the response's chat span; tools nest under it. Split transcript lines sharing a message.id group into one span so usage is never double-counted. - Stop finalizes the active chat span and emits spans for tool-less responses; an interrupted turn closes its chat span as superseded instead of crashing on a stale response key. - PostToolUse[Failure] closes the pending tool span with its result (ERROR + error.type on failure); PermissionRequest stamps span events. Agent dispatches still get a plain execute_tool span; the subagent invoke_agent marker lands in the next PR of this stack. Co-Authored-By: Claude Fable 5 --- src/daemon.ts | 232 +++++++++++++++++++++++++- tests/genai-span-usage-tokens.test.ts | 73 ++++++++ tests/interleave-handlers.test.ts | 168 +++++++++++++++++++ tests/interleave-split-lines.test.ts | 79 +++++++++ tests/interrupted-turn.test.ts | 73 ++++++++ tests/turn-span-integration.test.ts | 75 +++++++++ 6 files changed, 697 insertions(+), 3 deletions(-) create mode 100644 tests/genai-span-usage-tokens.test.ts create mode 100644 tests/interleave-handlers.test.ts create mode 100644 tests/interleave-split-lines.test.ts create mode 100644 tests/interrupted-turn.test.ts create mode 100644 tests/turn-span-integration.test.ts diff --git a/src/daemon.ts b/src/daemon.ts index ad14044..d161fa1 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -13,6 +13,8 @@ import type { InstructionsLoadedHookInput, UserPromptSubmitHookInput, PreToolUseHookInput, + PostToolUseHookInput, + PostToolUseFailureHookInput, PermissionRequestHookInput, SubagentStartHookInput, SubagentStopHookInput, @@ -23,27 +25,40 @@ import type { } from '@anthropic-ai/claude-agent-sdk'; import * as weave from 'weave'; import { loadSettings, VERSION } from './setup.js'; -import { appendToLog } from './utils.js'; +import { appendToLog, deepEqual } from './utils.js'; import { parseSessionFd } from './parser.js'; import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; import { ATTR, CompactionAttrs, + addPermissionRequestEvent, setCompactionAttrs, + toolDisplayName, assistantOutputMessages, snippet, + jsonStr, } from './genaiSpans.js'; import { resolveDaemonConfig, daemonConfigFingerprint, missingConfig } from './config.js'; import type { DaemonConfig } from './config.js'; import { + chatMessageKey, + callsForResponseKey, + findToolUseResponseKey, + openChatForGroup, + recordChat, +} from './chatSpans.js'; +import { + resolvePermissionIfPending, lastAssistantTextEndsWith, newSessionState, upsertInstruction, } from './sessionState.js'; import type { + PendingToolCall, SessionState, LoadedInstruction, } from './sessionState.js'; +import type { AssistantCallDetail, ParsedSession } from './parser.js'; // ───────────────────────────────────────────────────────────────────────────── // Types @@ -349,7 +364,10 @@ export class GlobalDaemon { await this.handlePermissionRequest(sessionId, input); break; case 'PostToolUse': + await this.handlePostToolUse(sessionId, input); + break; case 'PostToolUseFailure': + await this.handlePostToolUseFailure(sessionId, input); break; case 'SubagentStart': await this.handleSubagentStart(sessionId, input); @@ -598,6 +616,7 @@ export class GlobalDaemon { // Close interrupted turns that never received a Stop hook. this.finalizeOpenTurn(session, 'superseded_by_next_prompt'); + session.emittedChatSpanResponseKeys.clear(); const turn = this.startSessionTurn(session, prompt); if (!turn) return; @@ -613,13 +632,160 @@ 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 agentId = input.agent_id; + const toolUseId = input.tool_use_id; + const toolName = input.tool_name; + if (!toolUseId || !toolName) return; + + const toolInput = (input.tool_input ?? {}) as Record; + const tracker = agentId ? session.subagents.byAgentId(agentId) : undefined; + const parent: weave.Turn | weave.SubAgent | weave.LLM | undefined = agentId + ? tracker?.subAgent ?? session.currentTurn + : this.advanceMainAgentChatSpan(session, toolUseId) ?? session.currentTurn; + if (!parent) { + this.log('ERROR', `PreToolUse: no parent for session=${sessionId} tool=${toolName}`); + return; + } + + const tool = parent.startTool({ + name: toolName, + args: jsonStr(toolInput), + toolCallId: toolUseId, + startTime: new Date(), + }); + const toolAttrs: Attributes = { [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput) }; + if (tracker) toolAttrs[ATTR.AGENT_NAME] = tracker.subagentType; + tool.setAttributes(toolAttrs); + session.pendingToolCalls.set(toolUseId, { tool, toolName, toolInput }); + } + + private advanceMainAgentChatSpan(session: SessionState, toolUseId: string): weave.LLM | undefined { + if (!session.currentTurn) return undefined; + + let fd: number; + try { + fd = session.transcript.getFd(); + } catch { + return undefined; + } + const parsed = parseSessionFd(fd); + if (!parsed) return undefined; + const lastTurn = parsed.turns.at(-1); + if (!lastTurn) return undefined; + const calls = lastTurn.assistantCalls(); + const key = findToolUseResponseKey(calls, toolUseId); + if (!key) return undefined; + + if (session.activeChat && session.activeChat.responseKey !== key) { + this.finalizeActiveChatSpan(session, calls); + } + + if (!session.activeChat) { + const group = callsForResponseKey(calls, key); + const llm = openChatForGroup(session.currentTurn, group); + if (!llm) return undefined; + session.activeChat = { responseKey: key, llm }; + session.emittedChatSpanResponseKeys.add(key); + } + + return session.activeChat.llm; + } + + private finalizeActiveChatSpan(session: SessionState, calls: AssistantCallDetail[]): void { + const active = session.activeChat; + if (!active) return; + this.emitChatSpanForResponse(session, calls, active.responseKey, active.llm); + session.activeChat = undefined; + } + + private emitChatSpanForResponse( + session: SessionState, + calls: AssistantCallDetail[], + key: string, + existingLlm?: weave.LLM, + ): void { + if (!session.currentTurn) return; + const group = callsForResponseKey(calls, key); + // A stale response key can outlive an interrupted turn. + if (!group.length) { + existingLlm?.end(); + return; + } + const llm = existingLlm ?? openChatForGroup(session.currentTurn, group); + if (!llm) { + this.log('DEBUG', `Chat span skipped (no model flushed for response ${key}); usage not recorded`); + return; + } + recordChat(llm, group); + session.emittedChatSpanResponseKeys.add(key); } private async handlePermissionRequest(sessionId: string, input: PermissionRequestHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; - this.log('DEBUG', `PermissionRequest (not yet traced): session=${sessionId} tool=${input.tool_name}`); + + const toolName = input.tool_name; + if (!toolName) return; + + let pending: PendingToolCall | undefined; + for (const call of session.pendingToolCalls.values()) { + if (call.toolName === toolName && !call.permissionRequested && deepEqual(call.toolInput, input.tool_input)) { + pending = call; + break; + } + } + if (!pending) { + this.log('DEBUG', `PermissionRequest: no pending tool call for tool_name=${toolName}`); + return; + } + + pending.permissionRequested = true; + addPermissionRequestEvent(pending.tool, { + suggestions: input.permission_suggestions, + timestamp: new Date(), + }); + + this.log('DEBUG', `Permission request recorded for ${toolName}`); + } + + private async handlePostToolUse(sessionId: string, input: PostToolUseHookInput): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + + const toolUseId = input.tool_use_id; + if (!toolUseId) return; + + const pending = session.pendingToolCalls.get(toolUseId); + if (!pending) return; + + resolvePermissionIfPending(pending, true); + + pending.tool.result = jsonStr(input.tool_response); + pending.tool.end(); + + session.pendingToolCalls.delete(toolUseId); + } + + private async handlePostToolUseFailure(sessionId: string, input: PostToolUseFailureHookInput): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + + const toolUseId = input.tool_use_id; + if (!toolUseId) return; + + const error = input.error; + + const pending = session.pendingToolCalls.get(toolUseId); + if (!pending) return; + + resolvePermissionIfPending(pending, false); + + pending.tool.result = error; + pending.tool.setAttributes({ [ATTR.ERROR_TYPE]: this.errorTypeFor(error) }); + pending.tool.end({ error: new Error(error) }); + + session.pendingToolCalls.delete(toolUseId); } private async handleSubagentStart(sessionId: string, input: SubagentStartHookInput): Promise { @@ -682,6 +848,21 @@ export class GlobalDaemon { `Stop: session=${sessionId} transcript_path=${session.transcript.resolvedPath} transcript_turns=${transcriptTurns} parsed_model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(input.last_assistant_message)}`, ); + if (currentTurn) { + const calls = currentTurn.assistantCalls(); + if (session.activeChat) { + this.finalizeActiveChatSpan(session, calls); + } + for (let i = 0; i < calls.length; i++) { + const key = chatMessageKey(calls[i], i); + if (session.emittedChatSpanResponseKeys.has(key)) continue; + this.emitChatSpanForResponse(session, calls, key); + } + } else if (session.activeChat) { + session.activeChat.llm.end(); + session.activeChat = undefined; + } + const parsedTexts = currentTurn?.textBlocks() ?? []; const lastMessage = input.last_assistant_message ?? ''; const assistantMessages = parsedTexts.length > 0 ? parsedTexts : (lastMessage ? [lastMessage] : []); @@ -731,6 +912,37 @@ export class GlobalDaemon { } private finalizeOpenTurn(session: SessionState, orphanReason: string): void { + for (const [toolUseId, pending] of session.pendingToolCalls) { + resolvePermissionIfPending(pending, false); + pending.tool.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + pending.tool.end({ error: new Error(`tool did not complete (${orphanReason})`) }); + this.log('DEBUG', `Closed orphaned tool span: ${toolUseId} (${pending.toolName})`); + } + session.pendingToolCalls.clear(); + + if (session.activeChat) { + let finalized = false; + if (session.currentTurn) { + let parsed: ParsedSession | null = null; + try { + parsed = parseSessionFd(session.transcript.getFd()); + } catch { + parsed = null; + } + const lastTurn = parsed?.turns.at(-1); + if (lastTurn) { + this.finalizeActiveChatSpan(session, lastTurn.assistantCalls()); + finalized = true; + } + } + if (session.activeChat) { + session.activeChat.llm.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + session.activeChat.llm.end(); + session.activeChat = undefined; + } + this.log('DEBUG', finalized ? `Finalized active chat span` : `Closed orphaned chat span`); + } + if (session.currentTurn) { session.currentTurn.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); session.currentTurn.end(); @@ -837,6 +1049,20 @@ export class GlobalDaemon { this.sessionQueues.set(sessionId, next); } + private errorTypeFor(error: unknown): string { + if (typeof error === 'string') { + const trimmed = error.trim(); + if (!trimmed) return 'tool_error'; + const match = trimmed.match(/^[A-Z][A-Za-z_]*Error/); + return match ? match[0] : 'tool_error'; + } + if (error && typeof error === 'object' && 'type' in error) { + const t = (error as Record)['type']; + if (typeof t === 'string' && t) return t; + } + return 'tool_error'; + } + private log(level: 'DEBUG' | 'INFO' | 'ERROR', msg: string): void { if (level === 'DEBUG' && !this.config.debug) return; appendToLog(this.logFile, level, msg); diff --git a/tests/genai-span-usage-tokens.test.ts b/tests/genai-span-usage-tokens.test.ts new file mode 100644 index 0000000..505c14d --- /dev/null +++ b/tests/genai-span-usage-tokens.test.ts @@ -0,0 +1,73 @@ +// 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 type { InMemorySpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; + +function aLine(id: string, ts: string, text: string, usage: Record) { + return { + type: 'assistant', + timestamp: ts, + message: { role: 'assistant', id, model: 'claude-opus-4-7', content: [{ type: 'text', text }], usage, stop_reason: 'end_turn' }, + }; +} +function userText(ts: string, text: string) { + return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; +} + +async function chatSpanForUsage(exporter: InMemorySpanExporter, sid: string, usage: Record): Promise { + exporter.reset(); + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-usage-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, [ + JSON.stringify(userText('2026-01-01T00:00:00Z', 'do it')), + JSON.stringify(aLine('msgA', '2026-01-01T00:00:01Z', 'all done', usage)), + ].join('\n') + '\n'); + const d = makeGenaiDaemon(); + try { + 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 flushWeave(); + const chat = exporter.getFinishedSpans().find(s => s.name === 'chat'); + assert.ok(chat, 'chat span should be emitted'); + return chat; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('chat span: input_tokens includes cache_read + cache_creation (OTel semconv)', async () => { + const exporter = await initWeaveInMemory(); + const chatSpan = await chatSpanForUsage(exporter, 'sess-usage-1', { + input_tokens: 7600, + output_tokens: 528, + cache_read_input_tokens: 36500, + cache_creation_input_tokens: 4100, + }); + + assert.equal( + chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], + 48200, + 'gen_ai.usage.input_tokens must include cache_read and cache_creation per OTel semconv', + ); + assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_READ_INPUT_TOKENS], 36500); + assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS], 4100); + assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 528); +}); + +test('chat span: input_tokens unchanged when no cache fields present', async () => { + const exporter = await initWeaveInMemory(); + const chatSpan = await chatSpanForUsage(exporter, 'sess-usage-2', { input_tokens: 1000, output_tokens: 200 }); + + assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1000); + assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_READ_INPUT_TOKENS], undefined); + assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS], undefined); +}); diff --git a/tests/interleave-handlers.test.ts b/tests/interleave-handlers.test.ts new file mode 100644 index 0000000..043f524 --- /dev/null +++ b/tests/interleave-handlers.test.ts @@ -0,0 +1,168 @@ +// 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 type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ATTR } from '../src/genaiSpans.ts'; +import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; + +const USAGE = { input_tokens: 100, output_tokens: 1508, cache_read_input_tokens: 400 }; + +function aLine(id: string, ts: string, block: Record, stop?: string) { + return { + type: 'assistant', + timestamp: ts, + message: { + role: 'assistant', + id, + model: 'claude-opus-4-8', + content: [block], + usage: USAGE, + ...(stop ? { stop_reason: stop } : {}), + }, + }; +} + +function userText(ts: string, text: string) { + return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; +} + +function makeTranscript(sessionId: string): { file: string; append: (line: unknown) => void; dir: string } { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-itest-')); + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, ''); + return { + file, + dir, + append: (line: unknown) => fs.appendFileSync(file, JSON.stringify(line) + '\n'), + }; +} + +function chatByResponse(spans: ReadableSpan[], id: string): ReadableSpan[] { + return spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === 'chat' && s.attributes[ATTR.RESPONSE_ID] === id); +} +function partsOf(span: ReadableSpan): Array> { + const msgs = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string) as Array<{ parts?: Array> }>; + return msgs[0]?.parts ?? []; +} + +test('handlers: PreToolUse opens the chat span, Stop finalizes; text + tool interleave, usage once, no double-emit', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-A'; + const { file, append, dir } = makeTranscript(sid); + append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); + + const d = makeGenaiDaemon(); + try { + 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 the thing' }); + + append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' })); + append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); + + append(aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn')); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + + assert.equal(chatByResponse(spans, 'msgA').length, 1, 'one chat span for msgA'); + assert.equal(chatByResponse(spans, 'msgB').length, 1, 'one chat span for msgB'); + + const chatA = chatByResponse(spans, 'msgA')[0]; + assert.deepEqual(partsOf(chatA), [ + { type: 'text', content: 'first I will edit' }, + { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, + ], 'msgA: text then tool_call, in transcript order, as output parts'); + const aKids = childrenOf(spans, chatA).map(s => s.attributes[ATTR.OPERATION_NAME]); + assert.deepEqual(aKids, ['execute_tool'], 'msgA: the execute_tool span nests under the chat span'); + + assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); + assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); + + const chatB = chatByResponse(spans, 'msgB')[0]; + assert.deepEqual(partsOf(chatB), [{ type: 'text', content: 'all done' }]); + assert.equal(childrenOf(spans, chatB).length, 0, 'tool-less msgB has no execute_tool children'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('handlers: a new response transitions and finalizes the previous chat span', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-B'; + const { file, append, dir } = makeTranscript(sid); + append(userText('2026-01-01T00:00:00.000Z', 'do two things')); + + const d = makeGenaiDaemon(); + try { + 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 two things' }); + + append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'editing A' })); + append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_A', name: 'Edit', input: {} }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_A', tool_name: 'Edit', tool_input: {} }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_A', tool_response: 'ok' }); + + append(aLine('msgB', '2026-01-01T00:00:05.000Z', { type: 'text', text: 'editing B' })); + append(aLine('msgB', '2026-01-01T00:00:06.000Z', { type: 'tool_use', id: 'tool_B', name: 'Edit', input: {} }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_B', tool_name: 'Edit', tool_input: {} }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_B', tool_response: 'ok' }); + + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + assert.equal(chatByResponse(spans, 'msgA').length, 1, 'msgA finalized exactly once at the transition'); + assert.equal(chatByResponse(spans, 'msgB').length, 1, 'msgB finalized exactly once at Stop'); + + for (const id of ['msgA', 'msgB']) { + const chat = chatByResponse(spans, id)[0]; + const parts = partsOf(chat).map(p => p['type']); + assert.deepEqual(parts, ['text', 'tool_call'], `${id}: text + tool_call output parts`); + const kids = childrenOf(spans, chat).map(s => s.attributes[ATTR.OPERATION_NAME]); + assert.deepEqual(kids, ['execute_tool'], `${id}: execute_tool nests under its chat span`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('handlers: SessionEnd finalizes a still-open chat span with its output + usage (not an empty orphan)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-C'; + const { file, append, dir } = makeTranscript(sid); + append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); + + const d = makeGenaiDaemon(); + try { + 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 the thing' }); + + append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' })); + append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: {} }); + + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const chatA = chatByResponse(spans, 'msgA')[0]; + assert.ok(chatA, 'chat span for msgA was finalized at SessionEnd (has a response id)'); + assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508, 'usage recovered at SessionEnd'); + const types = partsOf(chatA).map(p => p['type']); + assert.ok(types.includes('text'), 'assistant text output part recovered at SessionEnd'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/interleave-split-lines.test.ts b/tests/interleave-split-lines.test.ts new file mode 100644 index 0000000..9433fba --- /dev/null +++ b/tests/interleave-split-lines.test.ts @@ -0,0 +1,79 @@ +// 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 { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; + +function aLine(id: string, ts: string, block: Record, stop?: string) { + return { + type: 'assistant', + timestamp: ts, + message: { + role: 'assistant', + id, + model: 'claude-opus-4-8', + content: [block], + usage: { input_tokens: 100, output_tokens: 1508, cache_read_input_tokens: 400 }, + ...(stop ? { stop_reason: stop } : {}), + }, + }; +} + +function userText(ts: string, text: string) { + return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; +} + +function partsOf(span: import('@opentelemetry/sdk-trace-base').ReadableSpan): Array> { + const msgs = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string) as Array<{ parts?: Array> }>; + return msgs[0]?.parts ?? []; +} + +test('reconstruction: split thinking/redacted_thinking/text/tool_use lines interleave as ordered parts, none dropped, usage once', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-split'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-splitlines-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, [ + userText('2026-01-01T00:00:00.000Z', 'do the thing'), + aLine('msgA', '2026-01-01T00:00:01.000Z', { type: 'thinking', thinking: 'let me think' }), + aLine('msgA', '2026-01-01T00:00:01.500Z', { type: 'redacted_thinking', data: 'ENCRYPTED' }), + aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' }), + aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use'), + aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn'), + ].map(l => JSON.stringify(l)).join('\n') + '\n'); + + const d = makeGenaiDaemon(); + try { + 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 the thing' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const chatA = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgA'); + assert.ok(chatA, 'chat span for msgA emitted'); + + assert.deepEqual(partsOf(chatA), [ + { type: 'reasoning', content: 'let me think' }, + { type: 'reasoning', content: '[redacted]' }, + { type: 'text', content: 'first I will edit' }, + { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, + ], 'thinking, redacted placeholder, text, tool_call: all present, in order'); + + assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); + assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); + + const chatB = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgB'); + assert.ok(chatB, 'chat span for tool-less msgB emitted'); + assert.deepEqual(partsOf(chatB), [{ type: 'text', content: 'all done' }]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/interrupted-turn.test.ts b/tests/interrupted-turn.test.ts new file mode 100644 index 0000000..b03f5e2 --- /dev/null +++ b/tests/interrupted-turn.test.ts @@ -0,0 +1,73 @@ +// 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 { ATTR } from '../src/genaiSpans.ts'; +import { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + transcriptUserLine, +} from './helpers.ts'; + +function assistantToolUseLine(msgId: string, toolUseId: string, ts: string): string { + return JSON.stringify({ + type: 'assistant', + timestamp: ts, + message: { + role: 'assistant', + id: msgId, + model: 'claude-opus-4-8', + usage: { input_tokens: 100, output_tokens: 10 }, + stop_reason: 'tool_use', + content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'sleep 999' } }], + }, + }); +} + +test('interrupted turn: next prompt closes the open turn and tool tracing survives', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-interrupt'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-interrupt-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, transcriptUserLine('turn one', { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }) + '\n'); + + const d = makeGenaiDaemon(); + try { + 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: 'turn one' }); + + fs.appendFileSync(file, assistantToolUseLine('msgA', 'tool_1', '2026-01-01T00:00:02.000Z') + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Bash', tool_input: { command: 'sleep 999' } }); + + fs.appendFileSync(file, transcriptUserLine('turn two', { timestamp: '2026-01-01T00:00:10.000Z' }) + '\n'); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'turn two' }); + + fs.appendFileSync(file, assistantToolUseLine('msgB', 'tool_2', '2026-01-01T00:00:12.000Z') + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_2', tool_name: 'Bash', tool_input: { command: 'sleep 999' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_2', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turns = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.equal(turns.length, 2, 'both turn roots exported (interrupted turn not leaked)'); + + const superseded = turns.find((s) => s.attributes[ATTR.WEAVE_ORPHAN_REASON] === 'superseded_by_next_prompt'); + assert.ok(superseded, 'interrupted turn closed with the superseded orphan reason'); + + const tools = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); + assert.equal(tools.length, 2, 'tool spans from both turns exported (turn 2 tracing survived)'); + + const chats = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat'); + assert.ok(chats.some((c) => c.attributes[ATTR.RESPONSE_ID] === 'msgA'), 'interrupted chat span exported'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/turn-span-integration.test.ts b/tests/turn-span-integration.test.ts new file mode 100644 index 0000000..4764f70 --- /dev/null +++ b/tests/turn-span-integration.test.ts @@ -0,0 +1,75 @@ +// 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 { VERSION } from '../src/setup.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; + +const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; + +function userText(ts: string, text: string, version: string) { + return { type: 'user', version, timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; +} + +function aLine(id: string, ts: string, block: Record, stop?: string) { + return { + type: 'assistant', + timestamp: ts, + message: { + role: 'assistant', + id, + model: 'claude-opus-4-8', + content: [block], + usage: USAGE, + ...(stop ? { stop_reason: stop } : {}), + }, + }; +} + +test('integration identity propagates weave.integration.* to every span (turn, chat, tool)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-bag'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-integ-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.appendFileSync(file, JSON.stringify(userText('2026-01-01T00:00:00.000Z', 'do it', '1.2.3')) + '\n'); + + const d = makeGenaiDaemon(); + try { + 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' }); + + fs.appendFileSync(file, JSON.stringify(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'editing' })) + '\n'); + fs.appendFileSync(file, JSON.stringify(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')) + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const ops = new Set(spans.map((s) => s.attributes['gen_ai.operation.name'])); + assert.ok(ops.has('invoke_agent'), 'turn span present'); + assert.ok(ops.has('chat'), 'chat span present'); + assert.ok(ops.has('execute_tool'), 'tool span present'); + + const turn = spans.find((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent'); + assert.ok(turn, 'turn span present'); + assert.equal(spanParentId(turn), undefined, 'turn span is a trace root'); + for (const s of spans) { + assert.equal(s.spanContext().traceId, turn.spanContext().traceId, `${s.name} shares the turn trace`); + } + + for (const s of spans) { + assert.equal(s.attributes['weave.integration.name'], 'weave-claude-code', `${s.name}: integration name`); + assert.equal(s.attributes['weave.integration.version'], VERSION, `${s.name}: integration version`); + assert.equal(s.attributes['weave.integration.meta.claude_code_app_version'], '1.2.3', `${s.name}: cc app version`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 001854b788badc994f1bdecb299f87bddd8da448 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Tue, 21 Jul 2026 23:03:03 -0700 Subject: [PATCH 02/11] refactor(daemon): extract tool parent resolution --- src/daemon.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index d161fa1..616085c 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -75,6 +75,9 @@ type ControlMessage = { /** Raw hook-event payload forwarded by hook-handler.sh. */ type HookPayload = Record; +/** SDK span handle that can explicitly parent a tool across hook frames. */ +type ToolParent = Pick; + function isControlMessage(payload: unknown): payload is ControlMessage { if (typeof payload !== 'object' || payload === null) return false; const cmd = (payload as Record).command; @@ -640,9 +643,7 @@ export class GlobalDaemon { const toolInput = (input.tool_input ?? {}) as Record; const tracker = agentId ? session.subagents.byAgentId(agentId) : undefined; - const parent: weave.Turn | weave.SubAgent | weave.LLM | undefined = agentId - ? tracker?.subAgent ?? session.currentTurn - : this.advanceMainAgentChatSpan(session, toolUseId) ?? session.currentTurn; + const parent = this.resolveToolParent(session, agentId, toolUseId); if (!parent) { this.log('ERROR', `PreToolUse: no parent for session=${sessionId} tool=${toolName}`); return; @@ -660,6 +661,23 @@ export class GlobalDaemon { session.pendingToolCalls.set(toolUseId, { tool, toolName, toolInput }); } + /** + * Resolve the explicit SDK handle for a tool hook. Subagent tools nest under + * their invoke_agent marker; main-agent tools nest under the response's chat + * span when transcript correlation succeeds. Both paths fall back to the + * current turn. + */ + private resolveToolParent( + session: SessionState, + agentId: string | undefined, + toolUseId: string, + ): ToolParent | undefined { + if (agentId) { + return session.subagents.byAgentId(agentId)?.subAgent ?? session.currentTurn; + } + return this.advanceMainAgentChatSpan(session, toolUseId) ?? session.currentTurn; + } + private advanceMainAgentChatSpan(session: SessionState, toolUseId: string): weave.LLM | undefined { if (!session.currentTurn) return undefined; From abc8b095545c846489bb2c4f2f0bf5224e95006e Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Wed, 22 Jul 2026 10:05:08 -0700 Subject: [PATCH 03/11] feat(daemon): trace subagents under agent turns Move ordinary Agent dispatch and SubagentStart/SubagentStop tracing into the chat/tool layer. Main-agent tools parent to the turn while subagent tools and chats parent to the SubAgent marker. Reconstruct chat siblings from transcripts at Stop or interrupted-turn finalization. --- src/chatSpans.ts | 16 - src/daemon.ts | 421 ++++++++++++++----- src/sessionState.ts | 9 - tests/daemon-shutdown-finalizes-turn.test.ts | 137 ++++++ tests/daemon-subagent-recovery.test.ts | 117 ++++++ tests/interleave-handlers.test.ts | 28 +- tests/subagent-nesting.test.ts | 100 +++++ 7 files changed, 679 insertions(+), 149 deletions(-) create mode 100644 tests/daemon-shutdown-finalizes-turn.test.ts create mode 100644 tests/daemon-subagent-recovery.test.ts create mode 100644 tests/subagent-nesting.test.ts diff --git a/src/chatSpans.ts b/src/chatSpans.ts index 08b1afb..5ba7037 100644 --- a/src/chatSpans.ts +++ b/src/chatSpans.ts @@ -4,7 +4,6 @@ import * as weave from 'weave'; import type { AssistantCallDetail } from './parser.js'; -import { isToolUseBlock } from './parser.js'; import { ATTR, buildUsage, @@ -31,21 +30,6 @@ export function callsForResponseKey( 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; -} - export function parseIsoOrNow(ts: string | undefined): Date { return parseTimestamp(ts) ?? new Date(); } diff --git a/src/daemon.ts b/src/daemon.ts index 616085c..aa643a1 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -43,22 +43,26 @@ import type { DaemonConfig } from './config.js'; import { chatMessageKey, callsForResponseKey, - findToolUseResponseKey, openChatForGroup, recordChat, } from './chatSpans.js'; import { resolvePermissionIfPending, + hashPrompt, + computeSubagentTranscriptPath, + extractUserMessageContent, lastAssistantTextEndsWith, + readSubagentFirstLineWithRetry, newSessionState, upsertInstruction, } from './sessionState.js'; import type { PendingToolCall, + SubagentTracker, SessionState, LoadedInstruction, } from './sessionState.js'; -import type { AssistantCallDetail, ParsedSession } from './parser.js'; +import type { AssistantCallDetail } from './parser.js'; // ───────────────────────────────────────────────────────────────────────────── // Types @@ -75,8 +79,8 @@ type ControlMessage = { /** Raw hook-event payload forwarded by hook-handler.sh. */ type HookPayload = Record; -/** SDK span handle that can explicitly parent a tool across hook frames. */ -type ToolParent = Pick; +/** The agent span that owns an execute_tool span. */ +type ToolParent = weave.Turn | weave.SubAgent; function isControlMessage(payload: unknown): payload is ControlMessage { if (typeof payload !== 'object' || payload === null) return false; @@ -619,7 +623,6 @@ export class GlobalDaemon { // Close interrupted turns that never received a Stop hook. this.finalizeOpenTurn(session, 'superseded_by_next_prompt'); - session.emittedChatSpanResponseKeys.clear(); const turn = this.startSessionTurn(session, prompt); if (!turn) return; @@ -642,8 +645,46 @@ export class GlobalDaemon { if (!toolUseId || !toolName) return; const toolInput = (input.tool_input ?? {}) as Record; - const tracker = agentId ? session.subagents.byAgentId(agentId) : undefined; - const parent = this.resolveToolParent(session, agentId, toolUseId); + // Agent tool with subagent_type opens a nested `invoke_agent` marker, not an + // `execute_tool Agent` span: the chat view renders nested invoke_agent as an + // `agent_start` event, while a tool wrapper would mis-render the dispatch. + // PostToolUse(Agent) closes the marker. Also fires when a subagent spawns its + // own subagent (agentId set); the parent then resolves to the spawning + // subagent's marker so the grandchild nests under it. `promptHash` (sha256 of + // the firing prompt) lets SubagentStart correlate deterministically. + if (toolName === 'Agent' && toolInput['subagent_type']) { + // Parent: the spawning subagent's own marker when this dispatch comes + // from inside a subagent, else the current turn. + const spawnParent: weave.Turn | weave.SubAgent | undefined = agentId + ? session.subagents.byAgentId(agentId)?.subAgent ?? session.currentTurn + : session.currentTurn; + if (!spawnParent) { + this.log('ERROR', `PreToolUse(Agent): no parent for session=${sessionId}`); + return; + } + const subagentType = toolInput['subagent_type'] as string; + const prompt = typeof toolInput['prompt'] === 'string' ? toolInput['prompt'] : ''; + const subAgent = spawnParent.startSubagent({ name: subagentType, agentVersion: VERSION, startTime: new Date() }); + const subAttrs: Attributes = { + [ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID]: toolUseId, + [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput), + }; + if (prompt) subAttrs[ATTR.INPUT_MESSAGES] = jsonStr([{ role: 'user', content: prompt }]); + subAgent.setAttributes(subAttrs); + session.subagents.add({ + toolUseId, + subagentType, + detectedAt: new Date(), + subAgent, + promptHash: hashPrompt(prompt), + }); + return; + } + + // Main-agent tools belong to the turn. Subagent tools belong to that + // subagent's invoke_agent marker, falling back to the turn when correlation + // is unavailable. + const parent = this.resolveToolParent(session, agentId); if (!parent) { this.log('ERROR', `PreToolUse: no parent for session=${sessionId} tool=${toolName}`); return; @@ -656,87 +697,23 @@ export class GlobalDaemon { startTime: new Date(), }); const toolAttrs: Attributes = { [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput) }; - if (tracker) toolAttrs[ATTR.AGENT_NAME] = tracker.subagentType; + if ('name' in parent && typeof parent.name === 'string') { + toolAttrs[ATTR.AGENT_NAME] = parent.name; + } tool.setAttributes(toolAttrs); session.pendingToolCalls.set(toolUseId, { tool, toolName, toolInput }); } /** - * Resolve the explicit SDK handle for a tool hook. Subagent tools nest under - * their invoke_agent marker; main-agent tools nest under the response's chat - * span when transcript correlation succeeds. Both paths fall back to the - * current turn. + * Resolve the agent span that owns a tool hook. Main-agent tools belong to + * the current turn; subagent tools belong to their invoke_agent marker. */ private resolveToolParent( session: SessionState, agentId: string | undefined, - toolUseId: string, ): ToolParent | undefined { - if (agentId) { - return session.subagents.byAgentId(agentId)?.subAgent ?? session.currentTurn; - } - return this.advanceMainAgentChatSpan(session, toolUseId) ?? session.currentTurn; - } - - private advanceMainAgentChatSpan(session: SessionState, toolUseId: string): weave.LLM | undefined { - if (!session.currentTurn) return undefined; - - let fd: number; - try { - fd = session.transcript.getFd(); - } catch { - return undefined; - } - const parsed = parseSessionFd(fd); - if (!parsed) return undefined; - const lastTurn = parsed.turns.at(-1); - if (!lastTurn) return undefined; - const calls = lastTurn.assistantCalls(); - const key = findToolUseResponseKey(calls, toolUseId); - if (!key) return undefined; - - if (session.activeChat && session.activeChat.responseKey !== key) { - this.finalizeActiveChatSpan(session, calls); - } - - if (!session.activeChat) { - const group = callsForResponseKey(calls, key); - const llm = openChatForGroup(session.currentTurn, group); - if (!llm) return undefined; - session.activeChat = { responseKey: key, llm }; - session.emittedChatSpanResponseKeys.add(key); - } - - return session.activeChat.llm; - } - - private finalizeActiveChatSpan(session: SessionState, calls: AssistantCallDetail[]): void { - const active = session.activeChat; - if (!active) return; - this.emitChatSpanForResponse(session, calls, active.responseKey, active.llm); - session.activeChat = undefined; - } - - private emitChatSpanForResponse( - session: SessionState, - calls: AssistantCallDetail[], - key: string, - existingLlm?: weave.LLM, - ): void { - if (!session.currentTurn) return; - const group = callsForResponseKey(calls, key); - // A stale response key can outlive an interrupted turn. - if (!group.length) { - existingLlm?.end(); - return; - } - const llm = existingLlm ?? openChatForGroup(session.currentTurn, group); - if (!llm) { - this.log('DEBUG', `Chat span skipped (no model flushed for response ${key}); usage not recorded`); - return; - } - recordChat(llm, group); - session.emittedChatSpanResponseKeys.add(key); + if (!agentId) return session.currentTurn; + return session.subagents.byAgentId(agentId)?.subAgent ?? session.currentTurn; } private async handlePermissionRequest(sessionId: string, input: PermissionRequestHookInput): Promise { @@ -767,6 +744,25 @@ export class GlobalDaemon { this.log('DEBUG', `Permission request recorded for ${toolName}`); } + /** + * Settle the Agent-dispatch tracker for `toolUseId` at PostToolUse[Failure], + * if one exists. An Agent dispatch has no pendingToolCall; its span is the + * subagent's `invoke_agent` marker, closed here with the tool's return. + * Returns true when the tool call was a subagent dispatch. + */ + private settleSubagentDispatch( + session: SessionState, + toolUseId: string, + output: unknown, + failure: boolean, + ): boolean { + const tracker = session.subagents.byToolUseId(toolUseId); + if (!tracker?.subAgent) return false; + this.closeSubagent(tracker, output, failure); + session.subagents.remove(tracker); + return true; + } + private async handlePostToolUse(sessionId: string, input: PostToolUseHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; @@ -774,6 +770,8 @@ export class GlobalDaemon { const toolUseId = input.tool_use_id; if (!toolUseId) return; + if (this.settleSubagentDispatch(session, toolUseId, input.tool_response, /*failure*/ false)) return; + const pending = session.pendingToolCalls.get(toolUseId); if (!pending) return; @@ -794,6 +792,8 @@ export class GlobalDaemon { const error = input.error; + if (this.settleSubagentDispatch(session, toolUseId, error, /*failure*/ true)) return; + const pending = session.pendingToolCalls.get(toolUseId); if (!pending) return; @@ -806,16 +806,200 @@ export class GlobalDaemon { session.pendingToolCalls.delete(toolUseId); } + private closeSubagent( + tracker: SubagentTracker, + output: unknown, + failure: boolean, + ): void { + const sub = tracker.subAgent; + if (!sub || tracker.ended) return; + + if (output !== undefined && output !== null && output !== '') { + const outputText = typeof output === 'string' ? output : jsonStr(output); + sub.setAttributes({ [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([outputText]) }); + } + if (failure) { + sub.setAttributes({ [ATTR.ERROR_TYPE]: this.errorTypeFor(output) }); + sub.end({ error: new Error(typeof output === 'string' ? output : 'subagent failed') }); + } else { + sub.end(); + } + tracker.ended = true; + } + 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 agentId = input.agent_id; + if (!agentId) return; + + const agentType = input.agent_type; + + // Content-based deterministic correlation: SubagentStart carries no + // pointer back to the parent's `tool_use_id`, so we read the subagent's + // transcript line 1 (the firing user prompt — byte-identical to the + // parent Agent tool's `tool_input.prompt`) and match by sha256 of that + // string plus the subagent_type. No temporal window. + const subagentPath = computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); + const firstLine = await readSubagentFirstLineWithRetry(subagentPath); + const firingPrompt = extractUserMessageContent(firstLine); + + let bestTracker: SubagentTracker | undefined; + if (firingPrompt !== undefined) { + bestTracker = session.subagents.findUnmatchedByContent(hashPrompt(firingPrompt), agentType); + } + + const matched = !!bestTracker; + if (!bestTracker) { + // No matching Agent tool call (the parent's PreToolUse never fired, or + // the firing prompt couldn't be read). Create an orphan tracker + marker + // so the subagent still renders as a nested invocation; closed at + // SubagentStop since no PostToolUse will come for it. + const reason = firingPrompt === undefined + ? 'transcript line 1 missing or non-user' + : `no tracker matches (promptHash, type=${agentType})`; + this.log('ERROR', `SubagentStart: ${reason}; creating orphan for agentId=${agentId} path=${subagentPath}`); + bestTracker = { + subagentType: agentType, + detectedAt: new Date(), + transcriptPath: subagentPath, + }; + if (session.currentTurn) { + bestTracker.subAgent = this.startOrphanSubagent(session.currentTurn, agentType, reason); + } + session.subagents.add(bestTracker); + } + + bestTracker.agentId = agentId; + if (bestTracker.subAgent) { + // The chat view uses gen_ai.agent.id to label the subagent's subtree. + bestTracker.subAgent.record({ agentId }); + } + + this.log('INFO', `Subagent started: agentId=${agentId} type=${agentType} matched=${matched}`); + } + + /** Open an orphan `invoke_agent` marker under `turn` for a subagent with no + * matched Agent tool call, recording why it exists outside the normal path. */ + private startOrphanSubagent(turn: weave.Turn, agentType: string, orphanReason: string): weave.SubAgent { + const subAgent = turn.startSubagent({ name: agentType, agentVersion: VERSION, startTime: new Date() }); + subAgent.setAttributes({ + [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${agentType}`, + [ATTR.WEAVE_ORPHAN_REASON]: orphanReason, + }); + return subAgent; + } + + /** The session's open turn, opening a fresh one if a restart left the + * session without a turn, so a subagent recovered at SubagentStop has a parent. */ + private getOrReconstructTurn(session: SessionState): weave.Turn | undefined { + if (session.currentTurn) return session.currentTurn; + const turn = this.startSessionTurn(session); + if (turn) this.log('INFO', 'Reconstructed turn span after restart'); + return turn; + } + + /** Rebuild a subagent tracker when SubagentStop finds none: the subagent + * started under a daemon that has since restarted. Opens an invoke_agent + * span under the turn so the normal emit path records it instead of + * dropping it. */ + private recoverSubagentTracker( + session: SessionState, + agentId: string, + agentType: string, + ): SubagentTracker | undefined { + const turn = this.getOrReconstructTurn(session); + if (!turn) return undefined; + const subAgent = this.startOrphanSubagent(turn, agentType, 'recovered at SubagentStop after daemon restart (no tracker)'); + subAgent.record({ agentId }); + const tracker: SubagentTracker = { + subagentType: agentType, + detectedAt: new Date(), + agentId, + subAgent, + transcriptPath: computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId), + }; + session.subagents.add(tracker); + this.log('INFO', `SubagentStop: recovered subagent agentId=${agentId} type=${agentType} after restart`); + return tracker; } private async handleSubagentStop(sessionId: string, input: SubagentStopHookInput): Promise { + // Reconstruct the session if a restart lost it (see getOrReconstructSession). 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}`); + + const agentId = input.agent_id; + if (!agentId) return; + + // No tracker: the subagent started under a since-restarted daemon. Recover it. + const tracker = session.subagents.byAgentId(agentId) + ?? this.recoverSubagentTracker(session, agentId, input.agent_type); + if (!tracker) { + this.log('ERROR', `SubagentStop: no tracker for agentId=${agentId} and none recoverable`); + return; + } + + // The subagent's LLM calls nest under its `invoke_agent` marker, so its + // work (and token usage) reads as the subagent's own subtree. Orphans that + // never got a marker fall back to the turn; the `gen_ai.agent.name` tag on + // each chat keeps them queryable by agent either way. + const chatParent = tracker.subAgent ?? session.currentTurn; + + // Fall back to the stored or agentId-derived path when the payload omits it. + const agentTranscriptPath = + input.agent_transcript_path ?? + tracker.transcriptPath ?? + computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); + let model: string | undefined; + let lastAssistantText: string | undefined; + if (agentTranscriptPath && chatParent) { + let agentTranscript: TranscriptFile | undefined; + try { + agentTranscript = new TranscriptFile(agentTranscriptPath); + const parsed = parseSessionFd(agentTranscript.getFd()); + // Last turn only: a subagent transcript occasionally carries the + // parent's prior assistant message as pre-context (a 2-turn parse); + // emitting earlier turns would mis-attribute the parent's LLM call + // to this subagent invocation. + const lastTurn = parsed?.turns.at(-1); + model = lastTurn?.primaryModel(); + lastAssistantText = lastTurn?.textBlocks().join('\n'); + + if (lastTurn) { + this.emitChatSpans(chatParent, lastTurn.assistantCalls(), tracker.subagentType); + } + } catch (err) { + this.log('DEBUG', `SubagentStop: could not parse transcript: ${err}`); + } finally { + agentTranscript?.close(); + } + } + + if (tracker.subAgent) { + // Record the model the subagent actually ran on (Claude Code's + // SubagentStart payload doesn't carry the model; the transcript does). + if (model) { + tracker.subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); + } + // Close only orphans (no PostToolUse will fire for them). Matched + // trackers wait for PostToolUse's canonical tool_response. + if (!tracker.ended && !tracker.toolUseId) { + this.closeSubagent(tracker, lastAssistantText, /*failure*/ false); + } + } + + this.log( + 'DEBUG', + `Subagent stopped: agentId=${agentId} type=${tracker.subagentType} model=${model ?? 'unknown'} wall_clock=${Date.now() - tracker.detectedAt.getTime()}ms`, + ); + + // Only remove orphan trackers here. Matched trackers stay until + // PostToolUse(Agent) closes the invoke_agent span and removes them. + if (!tracker.toolUseId) { + session.subagents.remove(tracker); + } } private async handleTeammateIdle(sessionId: string, input: TeammateIdleHookInput): Promise { @@ -823,6 +1007,29 @@ export class GlobalDaemon { this.log('DEBUG', `TeammateIdle (not yet traced): session=${sessionId} teammate=${input.teammate_name}`); } + /** + * Emit one chat span (LLM) per assistant API response under `parent`, + * reconstructed from transcript data. Split transcript lines sharing a + * `message.id` are grouped into one span so a response's usage isn't counted + * more than once. `agentName` tags each span so a subagent's calls stay + * queryable by agent. + */ + private emitChatSpans( + parent: weave.Turn | weave.SubAgent, + calls: AssistantCallDetail[], + agentName?: string, + ): void { + const emitted = new Set(); + for (let i = 0; i < calls.length; i++) { + const key = chatMessageKey(calls[i], i); + if (emitted.has(key)) continue; + emitted.add(key); + const group = callsForResponseKey(calls, key); + const llm = openChatForGroup(parent, group); + if (llm) recordChat(llm, group, agentName); + } + } + private async handlePreCompact(sessionId: string, input: PreCompactHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; @@ -867,18 +1074,7 @@ export class GlobalDaemon { ); if (currentTurn) { - const calls = currentTurn.assistantCalls(); - if (session.activeChat) { - this.finalizeActiveChatSpan(session, calls); - } - for (let i = 0; i < calls.length; i++) { - const key = chatMessageKey(calls[i], i); - if (session.emittedChatSpanResponseKeys.has(key)) continue; - this.emitChatSpanForResponse(session, calls, key); - } - } else if (session.activeChat) { - session.activeChat.llm.end(); - session.activeChat = undefined; + this.emitChatSpans(session.currentTurn, currentTurn.assistantCalls()); } const parsedTexts = currentTurn?.textBlocks() ?? []; @@ -927,6 +1123,17 @@ export class GlobalDaemon { private finalizeSession(session: SessionState, orphanReason: string): void { this.finalizeOpenTurn(session, orphanReason); + + // Close any subagent invoke_agent spans that didn't receive PostToolUse + // or SubagentStop. Without this they'd leak open and never export. + for (const tracker of session.subagents.all()) { + if (tracker.subAgent && !tracker.ended) { + tracker.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + tracker.subAgent.end({ error: new Error('subagent did not complete before shutdown') }); + tracker.ended = true; + } + this.log('DEBUG', `Subagent tracker not stopped: ${tracker.agentId ?? '(unmatched)'} type=${tracker.subagentType}`); + } } private finalizeOpenTurn(session: SessionState, orphanReason: string): void { @@ -938,30 +1145,13 @@ export class GlobalDaemon { } session.pendingToolCalls.clear(); - if (session.activeChat) { - let finalized = false; - if (session.currentTurn) { - let parsed: ParsedSession | null = null; - try { - parsed = parseSessionFd(session.transcript.getFd()); - } catch { - parsed = null; - } - const lastTurn = parsed?.turns.at(-1); - if (lastTurn) { - this.finalizeActiveChatSpan(session, lastTurn.assistantCalls()); - finalized = true; - } - } - if (session.activeChat) { - session.activeChat.llm.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); - session.activeChat.llm.end(); - session.activeChat = undefined; - } - this.log('DEBUG', finalized ? `Finalized active chat span` : `Closed orphaned chat span`); - } - if (session.currentTurn) { + try { + const lastTurn = parseSessionFd(session.transcript.getFd())?.turns.at(-1); + if (lastTurn) this.emitChatSpans(session.currentTurn, lastTurn.assistantCalls()); + } catch (err) { + this.log('DEBUG', `Could not recover chat spans while closing turn: ${err}`); + } session.currentTurn.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); session.currentTurn.end(); session.currentTurn = undefined; @@ -974,7 +1164,6 @@ export class GlobalDaemon { private checkInactivity(): void { const idle = Date.now() - this.lastActivity; if (idle <= this.inactivityMs) return; - // Keep in-flight work alive up to the hard hold limit. if (idle < INFLIGHT_HOLD_MAX_MS && this.hasInFlightWork()) { this.log('DEBUG', 'Inactivity timeout reached but work in flight — staying up'); return; diff --git a/src/sessionState.ts b/src/sessionState.ts index 288a677..d9fc249 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -20,11 +20,6 @@ export type PendingToolCall = { 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; @@ -125,9 +120,6 @@ export type SessionState = { pendingToolCalls: Map; subagents: SubagentTracking; - activeChat?: ActiveChat; - emittedChatSpanResponseKeys: Set; - /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ pendingCompaction?: CompactionAttrs; @@ -225,7 +217,6 @@ export function newSessionState(options: NewSessionStateOptions): SessionState { conversation, pendingToolCalls: new Map(), subagents: new SubagentTracking(), - emittedChatSpanResponseKeys: new Set(), systemInstructions: [], }; } diff --git a/tests/daemon-shutdown-finalizes-turn.test.ts b/tests/daemon-shutdown-finalizes-turn.test.ts new file mode 100644 index 0000000..d0b60ab --- /dev/null +++ b/tests/daemon-shutdown-finalizes-turn.test.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// A turn's root span (`invoke_agent`) is created at UserPromptSubmit and only +// ended at Stop or SessionEnd. When the daemon exits for any other reason +// (inactivity timeout, SIGTERM/SIGINT/SIGHUP, or a restart control message), its +// already-ended children (completed tool spans, finalized chat spans, closed +// subagent spans) have been exported, but the still-open root had not. The +// result was a rootless trace: tool spans with no user turn to attribute them +// to. +// +// The fix finalizes every live session (ending its turn root) inside the +// shutdown drain, before the exporter is flushed. These tests drive a turn to a +// mid-flight state, run the drain, and assert the root is exported. + +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 { ATTR } from '../src/genaiSpans.ts'; +import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; + +const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; + +function aLine(id: string, ts: string, block: Record, stop?: string) { + return { + type: 'assistant', + timestamp: ts, + message: { role: 'assistant', id, model: 'claude-opus-4-8', content: [block], usage: USAGE, ...(stop ? { stop_reason: stop } : {}) }, + }; +} +function userText(ts: string, text: string) { + return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; +} + +function makeTranscript(sessionId: string): { file: string; append: (line: unknown) => void; dir: string } { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-shutdown-itest-')); + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, ''); + return { file, dir, append: (line: unknown) => fs.appendFileSync(file, JSON.stringify(line) + '\n') }; +} + +/** Drive a session to a mid-turn state: turn open, one tool completed. */ +async function openTurnWithOneCompletedTool(d: Harness, sid: string, append: (l: unknown) => void, file: string) { + append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); + 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 the thing' }); + append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'reading' })); + append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Read', input: {} }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Read', tool_input: { file_path: '/foo' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); +} + +test('daemon shutdown mid-turn exports the turn root span (children are not left rootless)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-shutdown'; + const { file, append, dir } = makeTranscript(sid); + const d = makeGenaiDaemon(); + try { + await openTurnWithOneCompletedTool(d, sid, append, file); + + // Neither Stop nor SessionEnd fired: the daemon exits (inactivity / signal + // / restart). The drain must finalize the open turn before flushing. + await d.drain('inactivity'); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const tool = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); + assert.ok(tool, 'the completed tool span exported as a child'); + + const root = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.ok(root, 'the turn root span must be exported on shutdown, not leaked'); + assert.equal(root!.attributes[ATTR.AGENT_NAME], 'claude-code'); + assert.equal(root!.attributes[ATTR.CONVERSATION_ID], sid); + assert.equal(root!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'daemon_shutdown'); + + // The trace is well-formed: the child shares the exported root's trace id. + assert.equal(tool!.spanContext().traceId, root!.spanContext().traceId, 'child and root share one trace'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('daemon shutdown ends an open subagent invoke_agent span under the same trace', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-shutdown-subagent'; + const { file, append, dir } = makeTranscript(sid); + const d = makeGenaiDaemon(); + try { + append(userText('2026-01-01T00:00:00.000Z', 'spawn a reviewer')); + 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: 'spawn a reviewer' }); + + // Agent tool with subagent_type opens a nested invoke_agent span (Subagent) + // that a mid-flight shutdown would otherwise leave open. + append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'tool_use', id: 'agent_1', name: 'Agent', input: { subagent_type: 'code-reviewer', prompt: 'review' } }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'agent_1', tool_name: 'Agent', tool_input: { subagent_type: 'code-reviewer', prompt: 'review' } }); + + await d.drain('SIGTERM'); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const invokeAgents = spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + const root = invokeAgents.find(s => s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const sub = invokeAgents.find(s => s.attributes[ATTR.AGENT_NAME] === 'code-reviewer'); + assert.ok(root, 'turn root exported'); + assert.ok(sub, 'open subagent invoke_agent span exported on shutdown'); + assert.equal(sub!.spanContext().traceId, root!.spanContext().traceId, 'subagent nests under the same trace as the root'); + assert.equal(spanParentId(sub!), root!.spanContext().spanId, 'subagent parents under the turn root'); + assert.equal(sub!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'daemon_shutdown'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('SessionEnd still exports the turn root span after the finalize refactor', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-sessionend'; + const { file, append, dir } = makeTranscript(sid); + const d = makeGenaiDaemon(); + try { + await openTurnWithOneCompletedTool(d, sid, append, file); + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); + + const root = exporter.getFinishedSpans().find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.ok(root, 'SessionEnd exports the turn root'); + assert.equal(root!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'session_ended'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/daemon-subagent-recovery.test.ts b/tests/daemon-subagent-recovery.test.ts new file mode 100644 index 0000000..dc95dd0 --- /dev/null +++ b/tests/daemon-subagent-recovery.test.ts @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// Regression for subagent spans dropped after a daemon restart: reconstruction +// (#92) rebuilds the session but not its subagent trackers, so handleSubagentStop +// found no tracker and dropped the subagent's spans. These drive the real +// routeEvent with an in-memory exporter and assert the recovered span tree. + +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 { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + spanParentId, + transcriptAssistantLine, + transcriptUserLine, +} from './helpers.ts'; + +test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_agent + chat with tokens', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subrecover-')); + const sid = 'sub-recover-001'; + const agentId = 'a1234567890abcdef'; + + // Main transcript: the in-progress turn the subagent ran under, already on disk. + const mainPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(mainPath, transcriptUserLine('spawn a subagent') + '\n' + transcriptAssistantLine('working', { input_tokens: 10, output_tokens: 5 }) + '\n'); + + // Subagent transcript where the daemon derives it (agentId-based sibling dir). + const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(subPath), { recursive: true }); + fs.writeFileSync(subPath, transcriptUserLine('do the subtask') + '\n' + transcriptAssistantLine('subtask done', { input_tokens: 200, output_tokens: 40 }) + '\n'); + + const d = makeGenaiDaemon(); + try { + // Fresh daemon that only sees the subagent's completion, not its start. + await d.routeEvent({ + hook_event_name: 'SubagentStop', + session_id: sid, + transcript_path: mainPath, + agent_id: agentId, + agent_transcript_path: subPath, + agent_type: 'general-purpose', + }); + // SessionEnd closes the reconstructed turn so it exports. + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const names = spans.map((s) => `${s.name}[${s.attributes['gen_ai.agent.name']}]`).join(', '); + + const subInvoke = spans.find( + (s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'general-purpose', + ); + assert.ok(subInvoke, `expected a recovered subagent invoke_agent span; got: ${names}`); + + // The subagent's chat spans nest under its invoke_agent marker — and carry + // its tokens. + const chat = spans.find( + (s) => s.attributes['gen_ai.operation.name'] === 'chat' && s.attributes['gen_ai.agent.name'] === 'general-purpose', + ); + assert.ok(chat, `expected the subagent chat span; got: ${names}`); + assert.ok(Number(chat.attributes['gen_ai.usage.output_tokens']) > 0, 'chat span carries the subagent token usage'); + assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the subagent invoke_agent span'); + + // Recovery reconstructs the turn; the subagent nests under it. + const turn = spans.find( + (s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude-code', + ); + assert.ok(turn, `expected a reconstructed turn span to parent the subagent; got: ${names}`); + assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'subagent invoke_agent nests under the reconstructed turn'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('recovery reuses an already-open turn span instead of creating a spurious second turn', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subrecover2-')); + const sid = 'sub-recover-002'; + const agentId = 'b1234567890abcdef'; + + const mainPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(mainPath, transcriptUserLine('start') + '\n'); + const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(subPath), { recursive: true }); + fs.writeFileSync(subPath, transcriptUserLine('subtask') + '\n' + transcriptAssistantLine('done', { input_tokens: 50, output_tokens: 7 }) + '\n'); + + const d = makeGenaiDaemon(); + try { + // UserPromptSubmit reconstructs the session and opens a turn first; recovery + // must nest under that existing turn, not create a second one. + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, transcript_path: mainPath, prompt: 'go' }); + await d.routeEvent({ + hook_event_name: 'SubagentStop', session_id: sid, transcript_path: mainPath, + agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore', + }); + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turns = spans.filter((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude-code'); + assert.equal(turns.length, 1, `exactly one turn span expected, no spurious reconstructed turn; got ${turns.length}`); + const subInvoke = spans.find((s) => s.attributes['gen_ai.agent.name'] === 'Explore' && s.attributes['gen_ai.operation.name'] === 'invoke_agent'); + assert.ok(subInvoke, 'recovered subagent invoke_agent span present'); + assert.equal(spanParentId(subInvoke), turns[0].spanContext().spanId, 'subagent nests under the pre-existing turn'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/interleave-handlers.test.ts b/tests/interleave-handlers.test.ts index 043f524..8282eeb 100644 --- a/tests/interleave-handlers.test.ts +++ b/tests/interleave-handlers.test.ts @@ -51,7 +51,7 @@ function partsOf(span: ReadableSpan): Array> { return msgs[0]?.parts ?? []; } -test('handlers: PreToolUse opens the chat span, Stop finalizes; text + tool interleave, usage once, no double-emit', async () => { +test('handlers: Stop emits each chat once; text + tool output parts preserve interleave', async () => { const exporter = await initWeaveInMemory(); exporter.reset(); const sid = 'sess-A'; @@ -82,8 +82,13 @@ test('handlers: PreToolUse opens the chat span, Stop finalizes; text + tool inte { type: 'text', content: 'first I will edit' }, { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, ], 'msgA: text then tool_call, in transcript order, as output parts'); - const aKids = childrenOf(spans, chatA).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.deepEqual(aKids, ['execute_tool'], 'msgA: the execute_tool span nests under the chat span'); + assert.equal(childrenOf(spans, chatA).length, 0, 'chat span does not own tool execution'); + + const turn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.ok(turn, 'main-agent turn exported'); + const tool = spans.find(s => s.attributes[ATTR.TOOL_CALL_ID] === 'tool_1'); + assert.ok(tool, 'execute_tool span exported'); + assert.ok(childrenOf(spans, turn).includes(tool), 'main-agent tool nests directly under the turn'); assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); @@ -96,7 +101,7 @@ test('handlers: PreToolUse opens the chat span, Stop finalizes; text + tool inte } }); -test('handlers: a new response transitions and finalizes the previous chat span', async () => { +test('handlers: Stop emits multiple tool-calling responses once under the turn', async () => { const exporter = await initWeaveInMemory(); exporter.reset(); const sid = 'sess-B'; @@ -122,16 +127,23 @@ test('handlers: a new response transitions and finalizes the previous chat span' await flushWeave(); const spans = exporter.getFinishedSpans(); - assert.equal(chatByResponse(spans, 'msgA').length, 1, 'msgA finalized exactly once at the transition'); - assert.equal(chatByResponse(spans, 'msgB').length, 1, 'msgB finalized exactly once at Stop'); + assert.equal(chatByResponse(spans, 'msgA').length, 1, 'msgA emitted exactly once'); + assert.equal(chatByResponse(spans, 'msgB').length, 1, 'msgB emitted exactly once'); for (const id of ['msgA', 'msgB']) { const chat = chatByResponse(spans, id)[0]; const parts = partsOf(chat).map(p => p['type']); assert.deepEqual(parts, ['text', 'tool_call'], `${id}: text + tool_call output parts`); - const kids = childrenOf(spans, chat).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.deepEqual(kids, ['execute_tool'], `${id}: execute_tool nests under its chat span`); + assert.equal(childrenOf(spans, chat).length, 0, `${id}: chat does not own tool execution`); } + + const turn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.ok(turn, 'main-agent turn exported'); + const toolIds = childrenOf(spans, turn) + .filter(s => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool') + .map(s => s.attributes[ATTR.TOOL_CALL_ID]) + .sort(); + assert.deepEqual(toolIds, ['tool_A', 'tool_B'], 'both main-agent tools nest directly under the turn'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/subagent-nesting.test.ts b/tests/subagent-nesting.test.ts new file mode 100644 index 0000000..b1399d4 --- /dev/null +++ b/tests/subagent-nesting.test.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// The matched subagent path end-to-end: PreToolUse(Agent) opens the +// invoke_agent marker under the turn, SubagentStart correlates the agent_id by +// firing-prompt hash, the subagent's own tools and chat spans nest under the +// marker (weave 0.16.3 Subagent parents children), and PostToolUse(Agent) +// closes the marker with the tool's canonical return. Conversation id and +// integration identity must reach every nested span through the handle chain. + +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 { ATTR } from '../src/genaiSpans.ts'; +import { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + spanParentId, + transcriptAssistantLine, + transcriptUserLine, +} from './helpers.ts'; + +const userLine = (text: string): string => + transcriptUserLine(text, { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }); +const assistantLine = (text: string, usage: Record): string => + transcriptAssistantLine(text, usage, { timestamp: '2026-01-01T00:00:05.000Z' }); + +test('matched subagent: tools and chats nest under its invoke_agent marker with full identity', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-nest-001'; + const agentId = 'nest-agent-1'; + const firingPrompt = 'find the flaky test'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest-')); + const coordPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(coordPath, userLine('kick off') + '\n'); + + // Subagent transcript at the derived path; line 1 is the firing prompt + // (byte-identical to the Agent tool's prompt) for content-based correlation. + const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(subPath), { recursive: true }); + fs.writeFileSync(subPath, userLine(firingPrompt) + '\n' + assistantLine('found it', { input_tokens: 120, output_tokens: 30 }) + '\n'); + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tu-agent', + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: firingPrompt, description: 'Find it' }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); + // The subagent runs its own tool. + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', + tool_name: 'Read', tool_input: { file_path: '/flaky.test.ts' }, + }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', tool_response: 'contents' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent', tool_response: 'found the flaky test' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + assert.ok(turn, 'coordinator turn exported'); + const subInvoke = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(subInvoke, 'subagent invoke_agent marker exported'); + assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'marker nests under the turn'); + assert.equal(subInvoke.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID], 'tu-agent'); + assert.equal(subInvoke.attributes[ATTR.AGENT_ID], agentId, 'agent id recorded at SubagentStart'); + assert.equal( + subInvoke.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'found the flaky test' }]), + 'PostToolUse(Agent) closes the marker with the canonical tool return', + ); + + const readTool = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool' && s.attributes['gen_ai.tool.name'] === 'Read'); + assert.ok(readTool, 'subagent tool span exported'); + assert.equal(spanParentId(readTool), subInvoke.spanContext().spanId, 'subagent tool nests under the marker'); + assert.equal(readTool.attributes[ATTR.AGENT_NAME], 'Explore', 'subagent tool tagged with the subagent name'); + + const chat = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(chat, 'subagent chat span exported'); + assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the marker'); + assert.equal(chat.attributes[ATTR.USAGE_INPUT_TOKENS], 120); + + // Identity flows through the handle chain to every nested span. + for (const s of [subInvoke, readTool, chat]) { + assert.equal(s.attributes[ATTR.CONVERSATION_ID], sid, `${s.name}: conversation id`); + assert.equal(s.attributes['weave.integration.name'], 'weave-claude-code', `${s.name}: integration name`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 7a469297c11a3e61e10f8dc9c5836aedea8e9754 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Wed, 22 Jul 2026 10:22:37 -0700 Subject: [PATCH 04/11] refactor(daemon): simplify subagent tracing --- src/daemon.ts | 83 ++------------------ tests/daemon-shutdown-finalizes-turn.test.ts | 18 ----- tests/daemon-subagent-recovery.test.ts | 14 ---- tests/subagent-nesting.test.ts | 12 --- 4 files changed, 7 insertions(+), 120 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index aa643a1..41cdb42 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -79,7 +79,6 @@ type ControlMessage = { /** Raw hook-event payload forwarded by hook-handler.sh. */ type HookPayload = Record; -/** The agent span that owns an execute_tool span. */ type ToolParent = weave.Turn | weave.SubAgent; function isControlMessage(payload: unknown): payload is ControlMessage { @@ -645,16 +644,7 @@ export class GlobalDaemon { if (!toolUseId || !toolName) return; const toolInput = (input.tool_input ?? {}) as Record; - // Agent tool with subagent_type opens a nested `invoke_agent` marker, not an - // `execute_tool Agent` span: the chat view renders nested invoke_agent as an - // `agent_start` event, while a tool wrapper would mis-render the dispatch. - // PostToolUse(Agent) closes the marker. Also fires when a subagent spawns its - // own subagent (agentId set); the parent then resolves to the spawning - // subagent's marker so the grandchild nests under it. `promptHash` (sha256 of - // the firing prompt) lets SubagentStart correlate deterministically. if (toolName === 'Agent' && toolInput['subagent_type']) { - // Parent: the spawning subagent's own marker when this dispatch comes - // from inside a subagent, else the current turn. const spawnParent: weave.Turn | weave.SubAgent | undefined = agentId ? session.subagents.byAgentId(agentId)?.subAgent ?? session.currentTurn : session.currentTurn; @@ -665,12 +655,9 @@ export class GlobalDaemon { const subagentType = toolInput['subagent_type'] as string; const prompt = typeof toolInput['prompt'] === 'string' ? toolInput['prompt'] : ''; const subAgent = spawnParent.startSubagent({ name: subagentType, agentVersion: VERSION, startTime: new Date() }); - const subAttrs: Attributes = { - [ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID]: toolUseId, - [ATTR.WEAVE_DISPLAY_NAME]: toolDisplayName(toolName, toolInput), - }; - if (prompt) subAttrs[ATTR.INPUT_MESSAGES] = jsonStr([{ role: 'user', content: prompt }]); - subAgent.setAttributes(subAttrs); + if (prompt) { + subAgent.setAttributes({ [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]) }); + } session.subagents.add({ toolUseId, subagentType, @@ -681,9 +668,6 @@ export class GlobalDaemon { return; } - // Main-agent tools belong to the turn. Subagent tools belong to that - // subagent's invoke_agent marker, falling back to the turn when correlation - // is unavailable. const parent = this.resolveToolParent(session, agentId); if (!parent) { this.log('ERROR', `PreToolUse: no parent for session=${sessionId} tool=${toolName}`); @@ -704,10 +688,6 @@ export class GlobalDaemon { session.pendingToolCalls.set(toolUseId, { tool, toolName, toolInput }); } - /** - * Resolve the agent span that owns a tool hook. Main-agent tools belong to - * the current turn; subagent tools belong to their invoke_agent marker. - */ private resolveToolParent( session: SessionState, agentId: string | undefined, @@ -744,12 +724,6 @@ export class GlobalDaemon { this.log('DEBUG', `Permission request recorded for ${toolName}`); } - /** - * Settle the Agent-dispatch tracker for `toolUseId` at PostToolUse[Failure], - * if one exists. An Agent dispatch has no pendingToolCall; its span is the - * subagent's `invoke_agent` marker, closed here with the tool's return. - * Returns true when the tool call was a subagent dispatch. - */ private settleSubagentDispatch( session: SessionState, toolUseId: string, @@ -836,11 +810,7 @@ export class GlobalDaemon { const agentType = input.agent_type; - // Content-based deterministic correlation: SubagentStart carries no - // pointer back to the parent's `tool_use_id`, so we read the subagent's - // transcript line 1 (the firing user prompt — byte-identical to the - // parent Agent tool's `tool_input.prompt`) and match by sha256 of that - // string plus the subagent_type. No temporal window. + // SubagentStart has no tool_use_id, so correlate by firing-prompt hash and agent type. const subagentPath = computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); const firstLine = await readSubagentFirstLineWithRetry(subagentPath); const firingPrompt = extractUserMessageContent(firstLine); @@ -852,10 +822,6 @@ export class GlobalDaemon { const matched = !!bestTracker; if (!bestTracker) { - // No matching Agent tool call (the parent's PreToolUse never fired, or - // the firing prompt couldn't be read). Create an orphan tracker + marker - // so the subagent still renders as a nested invocation; closed at - // SubagentStop since no PostToolUse will come for it. const reason = firingPrompt === undefined ? 'transcript line 1 missing or non-user' : `no tracker matches (promptHash, type=${agentType})`; @@ -872,16 +838,11 @@ export class GlobalDaemon { } bestTracker.agentId = agentId; - if (bestTracker.subAgent) { - // The chat view uses gen_ai.agent.id to label the subagent's subtree. - bestTracker.subAgent.record({ agentId }); - } + if (bestTracker.subAgent) bestTracker.subAgent.record({ agentId }); this.log('INFO', `Subagent started: agentId=${agentId} type=${agentType} matched=${matched}`); } - /** Open an orphan `invoke_agent` marker under `turn` for a subagent with no - * matched Agent tool call, recording why it exists outside the normal path. */ private startOrphanSubagent(turn: weave.Turn, agentType: string, orphanReason: string): weave.SubAgent { const subAgent = turn.startSubagent({ name: agentType, agentVersion: VERSION, startTime: new Date() }); subAgent.setAttributes({ @@ -891,8 +852,6 @@ export class GlobalDaemon { return subAgent; } - /** The session's open turn, opening a fresh one if a restart left the - * session without a turn, so a subagent recovered at SubagentStop has a parent. */ private getOrReconstructTurn(session: SessionState): weave.Turn | undefined { if (session.currentTurn) return session.currentTurn; const turn = this.startSessionTurn(session); @@ -900,10 +859,6 @@ export class GlobalDaemon { return turn; } - /** Rebuild a subagent tracker when SubagentStop finds none: the subagent - * started under a daemon that has since restarted. Opens an invoke_agent - * span under the turn so the normal emit path records it instead of - * dropping it. */ private recoverSubagentTracker( session: SessionState, agentId: string, @@ -926,14 +881,12 @@ export class GlobalDaemon { } private async handleSubagentStop(sessionId: string, input: SubagentStopHookInput): Promise { - // Reconstruct the session if a restart lost it (see getOrReconstructSession). const session = await this.getOrReconstructSession(sessionId, input); if (!session || !this.tracingEnabled) return; const agentId = input.agent_id; if (!agentId) return; - // No tracker: the subagent started under a since-restarted daemon. Recover it. const tracker = session.subagents.byAgentId(agentId) ?? this.recoverSubagentTracker(session, agentId, input.agent_type); if (!tracker) { @@ -941,13 +894,8 @@ export class GlobalDaemon { return; } - // The subagent's LLM calls nest under its `invoke_agent` marker, so its - // work (and token usage) reads as the subagent's own subtree. Orphans that - // never got a marker fall back to the turn; the `gen_ai.agent.name` tag on - // each chat keeps them queryable by agent either way. const chatParent = tracker.subAgent ?? session.currentTurn; - // Fall back to the stored or agentId-derived path when the payload omits it. const agentTranscriptPath = input.agent_transcript_path ?? tracker.transcriptPath ?? @@ -959,10 +907,7 @@ export class GlobalDaemon { try { agentTranscript = new TranscriptFile(agentTranscriptPath); const parsed = parseSessionFd(agentTranscript.getFd()); - // Last turn only: a subagent transcript occasionally carries the - // parent's prior assistant message as pre-context (a 2-turn parse); - // emitting earlier turns would mis-attribute the parent's LLM call - // to this subagent invocation. + // Earlier turns may be coordinator pre-context, so emit only the last turn. const lastTurn = parsed?.turns.at(-1); model = lastTurn?.primaryModel(); lastAssistantText = lastTurn?.textBlocks().join('\n'); @@ -978,13 +923,10 @@ export class GlobalDaemon { } if (tracker.subAgent) { - // Record the model the subagent actually ran on (Claude Code's - // SubagentStart payload doesn't carry the model; the transcript does). if (model) { tracker.subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); } - // Close only orphans (no PostToolUse will fire for them). Matched - // trackers wait for PostToolUse's canonical tool_response. + // Matched subagents close on PostToolUse with the canonical tool response. if (!tracker.ended && !tracker.toolUseId) { this.closeSubagent(tracker, lastAssistantText, /*failure*/ false); } @@ -995,8 +937,6 @@ export class GlobalDaemon { `Subagent stopped: agentId=${agentId} type=${tracker.subagentType} model=${model ?? 'unknown'} wall_clock=${Date.now() - tracker.detectedAt.getTime()}ms`, ); - // Only remove orphan trackers here. Matched trackers stay until - // PostToolUse(Agent) closes the invoke_agent span and removes them. if (!tracker.toolUseId) { session.subagents.remove(tracker); } @@ -1007,13 +947,6 @@ export class GlobalDaemon { this.log('DEBUG', `TeammateIdle (not yet traced): session=${sessionId} teammate=${input.teammate_name}`); } - /** - * Emit one chat span (LLM) per assistant API response under `parent`, - * reconstructed from transcript data. Split transcript lines sharing a - * `message.id` are grouped into one span so a response's usage isn't counted - * more than once. `agentName` tags each span so a subagent's calls stay - * queryable by agent. - */ private emitChatSpans( parent: weave.Turn | weave.SubAgent, calls: AssistantCallDetail[], @@ -1124,8 +1057,6 @@ export class GlobalDaemon { private finalizeSession(session: SessionState, orphanReason: string): void { this.finalizeOpenTurn(session, orphanReason); - // Close any subagent invoke_agent spans that didn't receive PostToolUse - // or SubagentStop. Without this they'd leak open and never export. for (const tracker of session.subagents.all()) { if (tracker.subAgent && !tracker.ended) { tracker.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); diff --git a/tests/daemon-shutdown-finalizes-turn.test.ts b/tests/daemon-shutdown-finalizes-turn.test.ts index d0b60ab..9178f91 100644 --- a/tests/daemon-shutdown-finalizes-turn.test.ts +++ b/tests/daemon-shutdown-finalizes-turn.test.ts @@ -2,18 +2,6 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// A turn's root span (`invoke_agent`) is created at UserPromptSubmit and only -// ended at Stop or SessionEnd. When the daemon exits for any other reason -// (inactivity timeout, SIGTERM/SIGINT/SIGHUP, or a restart control message), its -// already-ended children (completed tool spans, finalized chat spans, closed -// subagent spans) have been exported, but the still-open root had not. The -// result was a rootless trace: tool spans with no user turn to attribute them -// to. -// -// The fix finalizes every live session (ending its turn root) inside the -// shutdown drain, before the exporter is flushed. These tests drive a turn to a -// mid-flight state, run the drain, and assert the root is exported. - import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs'; @@ -42,7 +30,6 @@ function makeTranscript(sessionId: string): { file: string; append: (line: unkno return { file, dir, append: (line: unknown) => fs.appendFileSync(file, JSON.stringify(line) + '\n') }; } -/** Drive a session to a mid-turn state: turn open, one tool completed. */ async function openTurnWithOneCompletedTool(d: Harness, sid: string, append: (l: unknown) => void, file: string) { append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); @@ -62,8 +49,6 @@ test('daemon shutdown mid-turn exports the turn root span (children are not left try { await openTurnWithOneCompletedTool(d, sid, append, file); - // Neither Stop nor SessionEnd fired: the daemon exits (inactivity / signal - // / restart). The drain must finalize the open turn before flushing. await d.drain('inactivity'); await flushWeave(); @@ -77,7 +62,6 @@ test('daemon shutdown mid-turn exports the turn root span (children are not left assert.equal(root!.attributes[ATTR.CONVERSATION_ID], sid); assert.equal(root!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'daemon_shutdown'); - // The trace is well-formed: the child shares the exported root's trace id. assert.equal(tool!.spanContext().traceId, root!.spanContext().traceId, 'child and root share one trace'); } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -95,8 +79,6 @@ test('daemon shutdown ends an open subagent invoke_agent span under the same tra 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: 'spawn a reviewer' }); - // Agent tool with subagent_type opens a nested invoke_agent span (Subagent) - // that a mid-flight shutdown would otherwise leave open. append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'tool_use', id: 'agent_1', name: 'Agent', input: { subagent_type: 'code-reviewer', prompt: 'review' } }, 'tool_use')); await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'agent_1', tool_name: 'Agent', tool_input: { subagent_type: 'code-reviewer', prompt: 'review' } }); diff --git a/tests/daemon-subagent-recovery.test.ts b/tests/daemon-subagent-recovery.test.ts index dc95dd0..96a18d6 100644 --- a/tests/daemon-subagent-recovery.test.ts +++ b/tests/daemon-subagent-recovery.test.ts @@ -2,11 +2,6 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Regression for subagent spans dropped after a daemon restart: reconstruction -// (#92) rebuilds the session but not its subagent trackers, so handleSubagentStop -// found no tracker and dropped the subagent's spans. These drive the real -// routeEvent with an in-memory exporter and assert the recovered span tree. - import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs'; @@ -28,18 +23,15 @@ test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_a const sid = 'sub-recover-001'; const agentId = 'a1234567890abcdef'; - // Main transcript: the in-progress turn the subagent ran under, already on disk. const mainPath = path.join(dir, `${sid}.jsonl`); fs.writeFileSync(mainPath, transcriptUserLine('spawn a subagent') + '\n' + transcriptAssistantLine('working', { input_tokens: 10, output_tokens: 5 }) + '\n'); - // Subagent transcript where the daemon derives it (agentId-based sibling dir). const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); fs.mkdirSync(path.dirname(subPath), { recursive: true }); fs.writeFileSync(subPath, transcriptUserLine('do the subtask') + '\n' + transcriptAssistantLine('subtask done', { input_tokens: 200, output_tokens: 40 }) + '\n'); const d = makeGenaiDaemon(); try { - // Fresh daemon that only sees the subagent's completion, not its start. await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, @@ -48,7 +40,6 @@ test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_a agent_transcript_path: subPath, agent_type: 'general-purpose', }); - // SessionEnd closes the reconstructed turn so it exports. await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid }); await flushWeave(); @@ -60,8 +51,6 @@ test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_a ); assert.ok(subInvoke, `expected a recovered subagent invoke_agent span; got: ${names}`); - // The subagent's chat spans nest under its invoke_agent marker — and carry - // its tokens. const chat = spans.find( (s) => s.attributes['gen_ai.operation.name'] === 'chat' && s.attributes['gen_ai.agent.name'] === 'general-purpose', ); @@ -69,7 +58,6 @@ test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_a assert.ok(Number(chat.attributes['gen_ai.usage.output_tokens']) > 0, 'chat span carries the subagent token usage'); assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the subagent invoke_agent span'); - // Recovery reconstructs the turn; the subagent nests under it. const turn = spans.find( (s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude-code', ); @@ -95,8 +83,6 @@ test('recovery reuses an already-open turn span instead of creating a spurious s const d = makeGenaiDaemon(); try { - // UserPromptSubmit reconstructs the session and opens a turn first; recovery - // must nest under that existing turn, not create a second one. await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, transcript_path: mainPath, prompt: 'go' }); await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, transcript_path: mainPath, diff --git a/tests/subagent-nesting.test.ts b/tests/subagent-nesting.test.ts index b1399d4..20d1c8a 100644 --- a/tests/subagent-nesting.test.ts +++ b/tests/subagent-nesting.test.ts @@ -2,13 +2,6 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// The matched subagent path end-to-end: PreToolUse(Agent) opens the -// invoke_agent marker under the turn, SubagentStart correlates the agent_id by -// firing-prompt hash, the subagent's own tools and chat spans nest under the -// marker (weave 0.16.3 Subagent parents children), and PostToolUse(Agent) -// closes the marker with the tool's canonical return. Conversation id and -// integration identity must reach every nested span through the handle chain. - import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs'; @@ -39,8 +32,6 @@ test('matched subagent: tools and chats nest under its invoke_agent marker with const coordPath = path.join(dir, `${sid}.jsonl`); fs.writeFileSync(coordPath, userLine('kick off') + '\n'); - // Subagent transcript at the derived path; line 1 is the firing prompt - // (byte-identical to the Agent tool's prompt) for content-based correlation. const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); fs.mkdirSync(path.dirname(subPath), { recursive: true }); fs.writeFileSync(subPath, userLine(firingPrompt) + '\n' + assistantLine('found it', { input_tokens: 120, output_tokens: 30 }) + '\n'); @@ -54,7 +45,6 @@ test('matched subagent: tools and chats nest under its invoke_agent marker with tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: firingPrompt, description: 'Find it' }, }); await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); - // The subagent runs its own tool. await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', tool_name: 'Read', tool_input: { file_path: '/flaky.test.ts' }, @@ -71,7 +61,6 @@ test('matched subagent: tools and chats nest under its invoke_agent marker with const subInvoke = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); assert.ok(subInvoke, 'subagent invoke_agent marker exported'); assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'marker nests under the turn'); - assert.equal(subInvoke.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID], 'tu-agent'); assert.equal(subInvoke.attributes[ATTR.AGENT_ID], agentId, 'agent id recorded at SubagentStart'); assert.equal( subInvoke.attributes[ATTR.OUTPUT_MESSAGES], @@ -89,7 +78,6 @@ test('matched subagent: tools and chats nest under its invoke_agent marker with assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the marker'); assert.equal(chat.attributes[ATTR.USAGE_INPUT_TOKENS], 120); - // Identity flows through the handle chain to every nested span. for (const s of [subInvoke, readTool, chat]) { assert.equal(s.attributes[ATTR.CONVERSATION_ID], sid, `${s.name}: conversation id`); assert.equal(s.attributes['weave.integration.name'], 'weave-claude-code', `${s.name}: integration name`); From d67bbb378edd7e3c58721baa4c5f7c3ec760af51 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Wed, 22 Jul 2026 11:26:56 -0700 Subject: [PATCH 05/11] refactor(daemon): replace subagent tracker with call indexes --- src/daemon.ts | 284 +++++++++++++-------------------- src/sessionState.ts | 94 +++-------- tests/subagent-nesting.test.ts | 49 ++++++ 3 files changed, 179 insertions(+), 248 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 41cdb42..a5996ef 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -58,7 +58,7 @@ import { } from './sessionState.js'; import type { PendingToolCall, - SubagentTracker, + PendingSubagentCall, SessionState, LoadedInstruction, } from './sessionState.js'; @@ -646,7 +646,7 @@ export class GlobalDaemon { const toolInput = (input.tool_input ?? {}) as Record; if (toolName === 'Agent' && toolInput['subagent_type']) { const spawnParent: weave.Turn | weave.SubAgent | undefined = agentId - ? session.subagents.byAgentId(agentId)?.subAgent ?? session.currentTurn + ? session.activeSubagents.get(agentId)?.subAgent ?? session.currentTurn : session.currentTurn; if (!spawnParent) { this.log('ERROR', `PreToolUse(Agent): no parent for session=${sessionId}`); @@ -658,10 +658,9 @@ export class GlobalDaemon { if (prompt) { subAgent.setAttributes({ [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]) }); } - session.subagents.add({ - toolUseId, + session.pendingCalls.set(toolUseId, { + kind: 'subagent', subagentType, - detectedAt: new Date(), subAgent, promptHash: hashPrompt(prompt), }); @@ -685,7 +684,7 @@ export class GlobalDaemon { toolAttrs[ATTR.AGENT_NAME] = parent.name; } tool.setAttributes(toolAttrs); - session.pendingToolCalls.set(toolUseId, { tool, toolName, toolInput }); + session.pendingCalls.set(toolUseId, { kind: 'tool', tool, toolName, toolInput }); } private resolveToolParent( @@ -693,7 +692,7 @@ export class GlobalDaemon { agentId: string | undefined, ): ToolParent | undefined { if (!agentId) return session.currentTurn; - return session.subagents.byAgentId(agentId)?.subAgent ?? session.currentTurn; + return session.activeSubagents.get(agentId)?.subAgent ?? session.currentTurn; } private async handlePermissionRequest(sessionId: string, input: PermissionRequestHookInput): Promise { @@ -704,7 +703,8 @@ export class GlobalDaemon { if (!toolName) return; let pending: PendingToolCall | undefined; - for (const call of session.pendingToolCalls.values()) { + for (const call of session.pendingCalls.values()) { + if (call.kind !== 'tool') continue; if (call.toolName === toolName && !call.permissionRequested && deepEqual(call.toolInput, input.tool_input)) { pending = call; break; @@ -724,70 +724,40 @@ export class GlobalDaemon { this.log('DEBUG', `Permission request recorded for ${toolName}`); } - private settleSubagentDispatch( - session: SessionState, - toolUseId: string, - output: unknown, - failure: boolean, - ): boolean { - const tracker = session.subagents.byToolUseId(toolUseId); - if (!tracker?.subAgent) return false; - this.closeSubagent(tracker, output, failure); - session.subagents.remove(tracker); - return true; - } - private async handlePostToolUse(sessionId: string, input: PostToolUseHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session) return; - - const toolUseId = input.tool_use_id; - if (!toolUseId) return; - - if (this.settleSubagentDispatch(session, toolUseId, input.tool_response, /*failure*/ false)) return; - - const pending = session.pendingToolCalls.get(toolUseId); - if (!pending) return; - - resolvePermissionIfPending(pending, true); - - pending.tool.result = jsonStr(input.tool_response); - pending.tool.end(); - - session.pendingToolCalls.delete(toolUseId); + if (!session || !input.tool_use_id) return; + this.settleCall(session, input.tool_use_id, input.tool_response, false); } private async handlePostToolUseFailure(sessionId: string, input: PostToolUseFailureHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session) return; - - const toolUseId = input.tool_use_id; - if (!toolUseId) return; - - const error = input.error; - - if (this.settleSubagentDispatch(session, toolUseId, error, /*failure*/ true)) return; - - const pending = session.pendingToolCalls.get(toolUseId); - if (!pending) return; + if (!session || !input.tool_use_id) return; + this.settleCall(session, input.tool_use_id, input.error, true); + } - resolvePermissionIfPending(pending, false); + private settleCall(session: SessionState, toolUseId: string, output: unknown, failure: boolean): void { + const call = session.pendingCalls.get(toolUseId); + if (!call) return; - pending.tool.result = error; - pending.tool.setAttributes({ [ATTR.ERROR_TYPE]: this.errorTypeFor(error) }); - pending.tool.end({ error: new Error(error) }); + if (call.kind === 'subagent') { + this.closeSubagent(call.subAgent, output, failure); + if (call.agentId) session.activeSubagents.delete(call.agentId); + } else { + resolvePermissionIfPending(call, !failure); + call.tool.result = failure ? String(output) : jsonStr(output); + if (failure) { + call.tool.setAttributes({ [ATTR.ERROR_TYPE]: this.errorTypeFor(output) }); + call.tool.end({ error: new Error(String(output)) }); + } else { + call.tool.end(); + } + } - session.pendingToolCalls.delete(toolUseId); + session.pendingCalls.delete(toolUseId); } - private closeSubagent( - tracker: SubagentTracker, - output: unknown, - failure: boolean, - ): void { - const sub = tracker.subAgent; - if (!sub || tracker.ended) return; - + private closeSubagent(sub: weave.SubAgent, output: unknown, failure: boolean): void { if (output !== undefined && output !== null && output !== '') { const outputText = typeof output === 'string' ? output : jsonStr(output); sub.setAttributes({ [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([outputText]) }); @@ -798,7 +768,24 @@ export class GlobalDaemon { } else { sub.end(); } - tracker.ended = true; + } + + private matchPendingSubagent( + session: SessionState, + subagentType: string, + prompt: string | undefined, + ): { call: PendingSubagentCall | undefined; candidateCount: number } { + const promptHash = prompt === undefined ? undefined : hashPrompt(prompt); + let exact: PendingSubagentCall | undefined; + let only: PendingSubagentCall | undefined; + let candidateCount = 0; + for (const call of session.pendingCalls.values()) { + if (call.kind !== 'subagent' || call.agentId || call.subagentType !== subagentType) continue; + candidateCount++; + only = call; + if (!exact && promptHash !== undefined && call.promptHash === promptHash) exact = call; + } + return { call: exact ?? (candidateCount === 1 ? only : undefined), candidateCount }; } private async handleSubagentStart(sessionId: string, input: SubagentStartHookInput): Promise { @@ -814,70 +801,34 @@ export class GlobalDaemon { const subagentPath = computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); const firstLine = await readSubagentFirstLineWithRetry(subagentPath); const firingPrompt = extractUserMessageContent(firstLine); - - let bestTracker: SubagentTracker | undefined; - if (firingPrompt !== undefined) { - bestTracker = session.subagents.findUnmatchedByContent(hashPrompt(firingPrompt), agentType); - } - - const matched = !!bestTracker; - if (!bestTracker) { - const reason = firingPrompt === undefined - ? 'transcript line 1 missing or non-user' - : `no tracker matches (promptHash, type=${agentType})`; - this.log('ERROR', `SubagentStart: ${reason}; creating orphan for agentId=${agentId} path=${subagentPath}`); - bestTracker = { - subagentType: agentType, - detectedAt: new Date(), - transcriptPath: subagentPath, - }; - if (session.currentTurn) { - bestTracker.subAgent = this.startOrphanSubagent(session.currentTurn, agentType, reason); - } - session.subagents.add(bestTracker); + const { call } = this.matchPendingSubagent(session, agentType, firingPrompt); + if (!call) { + const reason = firingPrompt === undefined ? 'transcript unavailable' : 'dispatch correlation ambiguous'; + this.log('ERROR', `SubagentStart: ${reason}; leaving agentId=${agentId} type=${agentType} unbound`); + return; } - bestTracker.agentId = agentId; - if (bestTracker.subAgent) bestTracker.subAgent.record({ agentId }); - - this.log('INFO', `Subagent started: agentId=${agentId} type=${agentType} matched=${matched}`); + call.agentId = agentId; + call.subAgent.record({ agentId }); + session.activeSubagents.set(agentId, call); + this.log('INFO', `Subagent started: agentId=${agentId} type=${agentType}`); } - private startOrphanSubagent(turn: weave.Turn, agentType: string, orphanReason: string): weave.SubAgent { - const subAgent = turn.startSubagent({ name: agentType, agentVersion: VERSION, startTime: new Date() }); - subAgent.setAttributes({ - [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${agentType}`, - [ATTR.WEAVE_ORPHAN_REASON]: orphanReason, - }); - return subAgent; - } - - private getOrReconstructTurn(session: SessionState): weave.Turn | undefined { - if (session.currentTurn) return session.currentTurn; - const turn = this.startSessionTurn(session); - if (turn) this.log('INFO', 'Reconstructed turn span after restart'); - return turn; - } - - private recoverSubagentTracker( + private recoverSubagent( session: SessionState, agentId: string, agentType: string, - ): SubagentTracker | undefined { - const turn = this.getOrReconstructTurn(session); + ): weave.SubAgent | undefined { + const turn = session.currentTurn ?? this.startSessionTurn(session); if (!turn) return undefined; - const subAgent = this.startOrphanSubagent(turn, agentType, 'recovered at SubagentStop after daemon restart (no tracker)'); + const subAgent = turn.startSubagent({ name: agentType, agentVersion: VERSION, startTime: new Date() }); + subAgent.setAttributes({ + [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${agentType}`, + [ATTR.WEAVE_ORPHAN_REASON]: 'recovered at SubagentStop after daemon restart', + }); subAgent.record({ agentId }); - const tracker: SubagentTracker = { - subagentType: agentType, - detectedAt: new Date(), - agentId, - subAgent, - transcriptPath: computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId), - }; - session.subagents.add(tracker); this.log('INFO', `SubagentStop: recovered subagent agentId=${agentId} type=${agentType} after restart`); - return tracker; + return subAgent; } private async handleSubagentStop(sessionId: string, input: SubagentStopHookInput): Promise { @@ -887,21 +838,33 @@ export class GlobalDaemon { const agentId = input.agent_id; if (!agentId) return; - const tracker = session.subagents.byAgentId(agentId) - ?? this.recoverSubagentTracker(session, agentId, input.agent_type); - if (!tracker) { - this.log('ERROR', `SubagentStop: no tracker for agentId=${agentId} and none recoverable`); - return; + const agentType = input.agent_type; + const agentTranscriptPath = input.agent_transcript_path + ?? computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); + let tracked = session.activeSubagents.get(agentId); + let candidateCount = 0; + if (!tracked) { + const firstLine = await readSubagentFirstLineWithRetry(agentTranscriptPath); + const match = this.matchPendingSubagent(session, agentType, extractUserMessageContent(firstLine)); + tracked = match.call; + candidateCount = match.candidateCount; + if (tracked) { + tracked.agentId = agentId; + tracked.subAgent.record({ agentId }); + this.log('INFO', `SubagentStop: late-matched agentId=${agentId} type=${agentType}`); + } } - const chatParent = tracker.subAgent ?? session.currentTurn; + // With no plausible live dispatch, this is restart recovery. If multiple + // dispatches are ambiguous, keep their markers intact and flatten the chat + // under the turn rather than manufacture a duplicate invoke_agent span. + const recovered = !tracked && candidateCount === 0 + ? this.recoverSubagent(session, agentId, agentType) + : undefined; + const chatParent = tracked?.subAgent ?? recovered ?? session.currentTurn; - const agentTranscriptPath = - input.agent_transcript_path ?? - tracker.transcriptPath ?? - computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); let model: string | undefined; - let lastAssistantText: string | undefined; + let lastAssistantText = input.last_assistant_message; if (agentTranscriptPath && chatParent) { let agentTranscript: TranscriptFile | undefined; try { @@ -910,10 +873,10 @@ export class GlobalDaemon { // Earlier turns may be coordinator pre-context, so emit only the last turn. const lastTurn = parsed?.turns.at(-1); model = lastTurn?.primaryModel(); - lastAssistantText = lastTurn?.textBlocks().join('\n'); + lastAssistantText ??= lastTurn?.textBlocks().join('\n'); if (lastTurn) { - this.emitChatSpans(chatParent, lastTurn.assistantCalls(), tracker.subagentType); + this.emitChatSpans(chatParent, lastTurn.assistantCalls(), agentType); } } catch (err) { this.log('DEBUG', `SubagentStop: could not parse transcript: ${err}`); @@ -922,24 +885,11 @@ export class GlobalDaemon { } } - if (tracker.subAgent) { - if (model) { - tracker.subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); - } - // Matched subagents close on PostToolUse with the canonical tool response. - if (!tracker.ended && !tracker.toolUseId) { - this.closeSubagent(tracker, lastAssistantText, /*failure*/ false); - } - } - - this.log( - 'DEBUG', - `Subagent stopped: agentId=${agentId} type=${tracker.subagentType} model=${model ?? 'unknown'} wall_clock=${Date.now() - tracker.detectedAt.getTime()}ms`, - ); - - if (!tracker.toolUseId) { - session.subagents.remove(tracker); - } + const subAgent = tracked?.subAgent ?? recovered; + if (model && subAgent) subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); + if (recovered) this.closeSubagent(recovered, lastAssistantText, false); + session.activeSubagents.delete(agentId); + this.log('DEBUG', `Subagent stopped: agentId=${agentId} type=${agentType} model=${model ?? 'unknown'}`); } private async handleTeammateIdle(sessionId: string, input: TeammateIdleHookInput): Promise { @@ -1042,10 +992,10 @@ export class GlobalDaemon { 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} pending_calls=${session.pendingCalls.size} active_subagents=${session.activeSubagents.size}`, ); - this.finalizeSession(session, 'session_ended'); + this.finalizeOpenTurn(session, 'session_ended'); this.log('INFO', `Finished session ${sessionId}`); @@ -1054,27 +1004,16 @@ export class GlobalDaemon { session.transcript.close(); } - private finalizeSession(session: SessionState, orphanReason: string): void { - this.finalizeOpenTurn(session, orphanReason); - - for (const tracker of session.subagents.all()) { - if (tracker.subAgent && !tracker.ended) { - tracker.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); - tracker.subAgent.end({ error: new Error('subagent did not complete before shutdown') }); - tracker.ended = true; - } - this.log('DEBUG', `Subagent tracker not stopped: ${tracker.agentId ?? '(unmatched)'} type=${tracker.subagentType}`); - } - } - private finalizeOpenTurn(session: SessionState, orphanReason: string): void { - for (const [toolUseId, pending] of session.pendingToolCalls) { - resolvePermissionIfPending(pending, false); - pending.tool.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); - pending.tool.end({ error: new Error(`tool did not complete (${orphanReason})`) }); - this.log('DEBUG', `Closed orphaned tool span: ${toolUseId} (${pending.toolName})`); + for (const [toolUseId, call] of session.pendingCalls) { + const span = call.kind === 'tool' ? call.tool : call.subAgent; + if (call.kind === 'tool') resolvePermissionIfPending(call, false); + span.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + span.end({ error: new Error(`call did not complete (${orphanReason})`) }); + this.log('DEBUG', `Closed orphaned call: ${toolUseId}`); } - session.pendingToolCalls.clear(); + session.pendingCalls.clear(); + session.activeSubagents.clear(); if (session.currentTurn) { try { @@ -1103,14 +1042,13 @@ 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 + /** True if any session has work in flight: an open turn span or pending call. + * Keeps the daemon alive across the inactivity * timeout so in-flight work isn't cut off mid-flight (see checkInactivity). */ 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 (s.pendingCalls.size > 0) return true; } return false; } @@ -1127,7 +1065,7 @@ export class GlobalDaemon { this.server?.close(); for (const session of this.sessions.values()) { try { - this.finalizeSession(session, 'daemon_shutdown'); + this.finalizeOpenTurn(session, 'daemon_shutdown'); } catch (err) { this.log('ERROR', `Error finalizing session ${session.sessionId} at shutdown: ${err}`); } diff --git a/src/sessionState.ts b/src/sessionState.ts index d9fc249..9f7bff0 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -13,6 +13,7 @@ import type { CompactionAttrs } from './genaiSpans.js'; /** Stores the tool span opened at PreToolUse so PostToolUse can close it. */ export type PendingToolCall = { + kind: 'tool'; tool: weave.Tool; toolName: string; toolInput: Record; @@ -20,6 +21,18 @@ export type PendingToolCall = { permissionRequested?: boolean; } +/** Agent dispatch opened at PreToolUse and settled at PostToolUse. */ +export type PendingSubagentCall = { + kind: 'subagent'; + subAgent: weave.SubAgent; + subagentType: string; + promptHash: string; + /** Added when SubagentStart bridges this call to agent-scoped hooks. */ + agentId?: string; +} + +export type PendingCall = PendingToolCall | PendingSubagentCall; + /** 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; @@ -85,26 +98,6 @@ export async function readSubagentFirstLineWithRetry( 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; -} - export type SessionState = { sessionId: string; conversationId: string; @@ -117,8 +110,10 @@ export type SessionState = { currentTurn?: weave.Turn; - pendingToolCalls: Map; - subagents: SubagentTracking; + /** Calls owned by tool_use_id from PreToolUse through PostToolUse. */ + pendingCalls: Map; + /** Secondary agent_id index used only while subagent hooks are active. */ + activeSubagents: Map; /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ pendingCompaction?: CompactionAttrs; @@ -126,57 +121,6 @@ export type SessionState = { 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; - } - - all(): SubagentTracker[] { - return [...this.trackers]; - } -} - type NewSessionStateOptions = { sessionId: string; conversationId: string; @@ -215,8 +159,8 @@ export function newSessionState(options: NewSessionStateOptions): SessionState { source, initialRequestModel, conversation, - pendingToolCalls: new Map(), - subagents: new SubagentTracking(), + pendingCalls: new Map(), + activeSubagents: new Map(), systemInstructions: [], }; } diff --git a/tests/subagent-nesting.test.ts b/tests/subagent-nesting.test.ts index 20d1c8a..47d09f6 100644 --- a/tests/subagent-nesting.test.ts +++ b/tests/subagent-nesting.test.ts @@ -86,3 +86,52 @@ test('matched subagent: tools and chats nest under its invoke_agent marker with fs.rmSync(dir, { recursive: true, force: true }); } }); + +test('ambiguous correlation does not manufacture a duplicate subagent marker', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-nest-ambiguous'; + const agentId = 'ambiguous-agent'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest-ambiguous-')); + const coordPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(coordPath, userLine('dispatch two explorers') + '\n'); + + const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(subPath), { recursive: true }); + fs.writeFileSync(subPath, userLine('prompt not present on either dispatch') + '\n' + + assistantLine('ambiguous result', { input_tokens: 20, output_tokens: 5 }) + '\n'); + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'dispatch two explorers' }); + for (const [toolUseId, prompt] of [['tu-agent-a', 'first task'], ['tu-agent-b', 'second task']] as const) { + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: toolUseId, + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt }, + }); + } + + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent-a', tool_response: 'first result' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent-b', tool_response: 'second result' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + assert.ok(turn, 'coordinator turn exported'); + const subagents = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.equal(subagents.length, 2, 'only the two actual Agent dispatches produce markers'); + + const chat = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat' + && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(chat, 'ambiguous subagent chat still exported'); + assert.equal(spanParentId(chat), turn.spanContext().spanId, 'ambiguous chat safely falls back to the turn'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From ef1b58f02fd08d9a2aa28ff66cab8fa26b86edcc Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Wed, 22 Jul 2026 10:06:38 -0700 Subject: [PATCH 06/11] feat(daemon): trace agent-team nesting via the SDK Keep cross-session team correlation, TeammateIdle transcript emission, FIFO re-spawn handling, and team lifecycle guards in the team-specific layer. Ordinary subagent tracing now comes from the parent branch. --- src/daemon.ts | 244 +++++++++++- tests/teammate-idle.test.ts | 739 ++++++++++++++++++++++++++++++++++++ 2 files changed, 977 insertions(+), 6 deletions(-) create mode 100644 tests/teammate-idle.test.ts diff --git a/src/daemon.ts b/src/daemon.ts index a5996ef..50e8c76 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -43,6 +43,7 @@ import type { DaemonConfig } from './config.js'; import { chatMessageKey, callsForResponseKey, + parseIsoOrNow, openChatForGroup, recordChat, } from './chatSpans.js'; @@ -50,6 +51,7 @@ import { resolvePermissionIfPending, hashPrompt, computeSubagentTranscriptPath, + subagentsDirFor, extractUserMessageContent, lastAssistantTextEndsWith, readSubagentFirstLineWithRetry, @@ -81,6 +83,22 @@ type HookPayload = Record; type ToolParent = weave.Turn | weave.SubAgent; +type TeamMember = { + toolUseId: string; + call: PendingSubagentCall; + conversation: weave.Conversation; + coordinatorTranscriptPath: string; + ownerSessionId: string; +}; + +type IdleSubagent = { + sessionId: string; + subAgent: weave.SubAgent; + conversation: weave.Conversation; + subagentType: string; + transcriptPath: string; +}; + function isControlMessage(payload: unknown): payload is ControlMessage { if (typeof payload !== 'object' || payload === null) return false; const cmd = (payload as Record).command; @@ -122,6 +140,12 @@ export class GlobalDaemon { * session at SessionStart / reconstruction and cleared (also on SessionEnd). */ private pendingInstructions = new Map(); private tracingEnabled = false; + /** Cross-session team members awaiting TeammateIdle, queued by team + name. */ + private teamMembers = new Map(); + /** Agent calls whose span completion belongs to TeammateIdle, not PostToolUse. */ + private teamDispatches = new Set(); + /** Unmatched per-session teammates awaiting TeammateIdle. */ + private idleSubagents = new Map(); constructor( private readonly socketPath: string, @@ -658,12 +682,32 @@ export class GlobalDaemon { if (prompt) { subAgent.setAttributes({ [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]) }); } - session.pendingCalls.set(toolUseId, { + const call: PendingSubagentCall = { kind: 'subagent', subagentType, subAgent, promptHash: hashPrompt(prompt), - }); + }; + session.pendingCalls.set(toolUseId, call); + + const teamName = typeof toolInput['team_name'] === 'string' ? toolInput['team_name'] : undefined; + const memberName = typeof toolInput['name'] === 'string' && toolInput['name'] + ? toolInput['name'] + : subagentType; + if (teamName && session.conversation) { + const key = `${teamName}::${memberName}`; + const queue = this.teamMembers.get(key) ?? []; + queue.push({ + toolUseId, + call, + conversation: session.conversation, + coordinatorTranscriptPath: session.transcript.resolvedPath, + ownerSessionId: sessionId, + }); + this.teamMembers.set(key, queue); + this.teamDispatches.add(call); + this.log('INFO', `Team member registered: ${key} (cross-session nesting, queue depth ${queue.length})`); + } return; } @@ -741,8 +785,12 @@ export class GlobalDaemon { if (!call) return; if (call.kind === 'subagent') { - this.closeSubagent(call.subAgent, output, failure); - if (call.agentId) session.activeSubagents.delete(call.agentId); + // Team dispatches are complete only when TeammateIdle supplies the + // teammate transcript. PostToolUse merely acknowledges the dispatch. + if (!this.teamDispatches.has(call)) { + this.closeSubagent(call.subAgent, output, failure); + if (call.agentId) session.activeSubagents.delete(call.agentId); + } } else { resolvePermissionIfPending(call, !failure); call.tool.result = failure ? String(output) : jsonStr(output); @@ -801,9 +849,29 @@ export class GlobalDaemon { const subagentPath = computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); const firstLine = await readSubagentFirstLineWithRetry(subagentPath); const firingPrompt = extractUserMessageContent(firstLine); - const { call } = this.matchPendingSubagent(session, agentType, firingPrompt); + const { call, candidateCount } = this.matchPendingSubagent(session, agentType, firingPrompt); if (!call) { const reason = firingPrompt === undefined ? 'transcript unavailable' : 'dispatch correlation ambiguous'; + // A start with no plausible dispatch is the per-session teammate shape: + // keep one marker open until TeammateIdle emits its full transcript. Do + // not do this for ambiguous live dispatches, which would duplicate them. + if (candidateCount === 0 && session.currentTurn && session.conversation) { + const subAgent = session.currentTurn.startSubagent({ name: agentType, agentVersion: VERSION, startTime: new Date() }); + subAgent.setAttributes({ + [ATTR.WEAVE_DISPLAY_NAME]: `Agent: ${agentType}`, + [ATTR.WEAVE_ORPHAN_REASON]: 'awaiting TeammateIdle without Agent dispatch', + }); + subAgent.record({ agentId }); + this.idleSubagents.set(`${sessionId}::${agentId}`, { + sessionId, + subAgent, + conversation: session.conversation, + subagentType: agentType, + transcriptPath: subagentPath, + }); + this.log('INFO', `Subagent started: agentId=${agentId} type=${agentType} awaiting TeammateIdle`); + return; + } this.log('ERROR', `SubagentStart: ${reason}; leaving agentId=${agentId} type=${agentType} unbound`); return; } @@ -841,6 +909,13 @@ export class GlobalDaemon { const agentType = input.agent_type; const agentTranscriptPath = input.agent_transcript_path ?? computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId); + const idle = this.idleSubagents.get(`${sessionId}::${agentId}`); + if (idle) { + idle.transcriptPath = agentTranscriptPath; + this.log('DEBUG', `Subagent stopped: agentId=${agentId} type=${idle.subagentType} awaiting TeammateIdle`); + return; + } + let tracked = session.activeSubagents.get(agentId); let candidateCount = 0; if (!tracked) { @@ -855,6 +930,12 @@ export class GlobalDaemon { } } + if (tracked && this.teamDispatches.has(tracked)) { + session.activeSubagents.delete(agentId); + this.log('DEBUG', `Subagent stopped: agentId=${agentId} type=${agentType} awaiting TeammateIdle`); + return; + } + // With no plausible live dispatch, this is restart recovery. If multiple // dispatches are ambiguous, keep their markers intact and flatten the chat // under the turn rather than manufacture a duplicate invoke_agent span. @@ -894,7 +975,131 @@ export class GlobalDaemon { private async handleTeammateIdle(sessionId: string, input: TeammateIdleHookInput): Promise { if (!this.tracingEnabled) return; - this.log('DEBUG', `TeammateIdle (not yet traced): session=${sessionId} teammate=${input.teammate_name}`); + const session = this.sessions.get(sessionId); + const agentType = input.teammate_name; + const teamName = input.team_name; + const key = `${teamName}::${agentType}`; + const queue = this.teamMembers.get(key); + + // Agent teams run the teammate in a different session. The coordinator's + // Agent dispatch registered this FIFO entry before that session existed. + if (queue?.length) { + const member = queue.shift()!; + if (!queue.length) this.teamMembers.delete(key); + + const idleTranscript = session?.transcript.resolvedPath ?? input.transcript_path; + const teammateTranscriptPath = this.resolveTeammateTranscript( + member.coordinatorTranscriptPath, + agentType, + idleTranscript, + ); + this.emitTeammateTurnTrace(member.call.subAgent, member.conversation, agentType, teammateTranscriptPath); + this.teamDispatches.delete(member.call); + const owner = this.sessions.get(member.ownerSessionId); + owner?.pendingCalls.delete(member.toolUseId); + if (member.call.agentId) owner?.activeSubagents.delete(member.call.agentId); + this.log('INFO', `TeammateIdle: traced ${agentType} team=${teamName} (cross-session) transcript=${teammateTranscriptPath ?? '(none)'} (queue depth now ${queue.length})`); + return; + } + + if (this.teamMembers.size > 0) { + this.log('INFO', `TeammateIdle: no team entry for ${key} (registered: ${[...this.teamMembers.keys()].join(', ')}) — check teammate_name === Agent.name`); + } + + // A same-session teammate has no Agent dispatch to bridge from. Consume the + // oldest unmatched marker of this type that SubagentStart left behind. + const idleEntry = [...this.idleSubagents].find(([, candidate]) => + candidate.sessionId === sessionId && candidate.subagentType === agentType, + ); + if (!idleEntry) { + this.log('DEBUG', `TeammateIdle: no pending tracker for ${agentType} team=${teamName} — skipping`); + return; + } + + const [idleKey, candidate] = idleEntry; + const model = this.emitTeammateTurnTrace( + candidate.subAgent, + candidate.conversation, + agentType, + candidate.transcriptPath, + ); + this.idleSubagents.delete(idleKey); + this.log('INFO', `TeammateIdle: traced ${agentType} model=${model ?? 'unknown'} path=${candidate.transcriptPath}`); + } + + /** Find the teammate transcript paired with its coordinator-side Agent call. */ + private resolveTeammateTranscript( + coordinatorTranscriptPath: string, + teammateName: string, + idleTranscriptPath: string | undefined, + ): string | undefined { + try { + const subagentsDir = subagentsDirFor(coordinatorTranscriptPath); + if (fs.existsSync(subagentsDir)) { + let best: { path: string; mtime: number } | undefined; + for (const meta of fs.readdirSync(subagentsDir).filter(f => f.endsWith('.meta.json'))) { + try { + const info = JSON.parse(fs.readFileSync(path.join(subagentsDir, meta), 'utf8')) as { agentType?: string }; + if (info.agentType !== teammateName) continue; + const transcriptPath = path.join(subagentsDir, meta.replace(/\.meta\.json$/, '.jsonl')); + if (!fs.existsSync(transcriptPath)) continue; + const mtime = fs.statSync(transcriptPath).mtimeMs; + if (!best || mtime > best.mtime) best = { path: transcriptPath, mtime }; + } catch { /* skip malformed metadata */ } + } + if (best) return best.path; + } + } catch (err) { + this.log('DEBUG', `resolveTeammateTranscript(${teammateName}): ${err}`); + } + return idleTranscriptPath; + } + + /** Emit all teammate turns in a fresh trace, then close its dispatch marker. */ + private emitTeammateTurnTrace( + subAgent: weave.SubAgent, + conversation: weave.Conversation, + agentType: string, + transcriptPath: string | undefined, + ): string | undefined { + let model: string | undefined; + let lastAssistantText: string | undefined; + let transcript: TranscriptFile | undefined; + try { + if (!transcriptPath) throw new Error('no teammate transcript path'); + transcript = new TranscriptFile(transcriptPath); + const parsed = parseSessionFd(transcript.getFd()); + if (parsed?.turns.length) { + const calls = parsed.turns.flatMap(turn => turn.assistantCalls()); + const first = calls[0]; + const turn = conversation.startTurn({ + agentName: agentType, + agentVersion: VERSION, + startTime: parseIsoOrNow(first?.prevTimestamp ?? first?.timestamp), + }); + turn.setAttributes({ [ATTR.WEAVE_DISPLAY_NAME]: `Teammate: ${agentType}` }); + try { + for (const parsedTurn of parsed.turns) { + this.emitChatSpans(turn, parsedTurn.assistantCalls(), agentType); + } + } finally { + turn.end({ endTime: parseIsoOrNow(calls.at(-1)?.timestamp) }); + } + const lastTurn = parsed.turns.at(-1); + model = lastTurn?.primaryModel(); + lastAssistantText = lastTurn?.textBlocks().join('\n'); + } + } catch (err) { + this.log('DEBUG', `emitTeammateTurnTrace: could not parse ${transcriptPath}: ${err}`); + } finally { + transcript?.close(); + } + if (model) subAgent.setAttributes({ [ATTR.RESPONSE_MODEL]: model }); + if (lastAssistantText) { + subAgent.setAttributes({ [ATTR.OUTPUT_MESSAGES]: assistantOutputMessages([lastAssistantText]) }); + } + subAgent.end(); + return model; } private emitChatSpans( @@ -996,6 +1201,7 @@ export class GlobalDaemon { ); this.finalizeOpenTurn(session, 'session_ended'); + this.finalizeIdleSubagents(sessionId, 'session_ended'); this.log('INFO', `Finished session ${sessionId}`); @@ -1006,6 +1212,10 @@ export class GlobalDaemon { private finalizeOpenTurn(session: SessionState, orphanReason: string): void { for (const [toolUseId, call] of session.pendingCalls) { + if (call.kind === 'subagent' && this.teamDispatches.has(call)) { + this.log('DEBUG', `Deferred team call to TeammateIdle: ${toolUseId}`); + continue; + } const span = call.kind === 'tool' ? call.tool : call.subAgent; if (call.kind === 'tool') resolvePermissionIfPending(call, false); span.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); @@ -1034,6 +1244,10 @@ export class GlobalDaemon { private checkInactivity(): void { const idle = Date.now() - this.lastActivity; if (idle <= this.inactivityMs) return; + if (idle < INFLIGHT_HOLD_MAX_MS && (this.teamMembers.size > 0 || this.idleSubagents.size > 0)) { + this.log('DEBUG', 'Inactivity timeout reached but team correlation in flight — staying up'); + return; + } if (idle < INFLIGHT_HOLD_MAX_MS && this.hasInFlightWork()) { this.log('DEBUG', 'Inactivity timeout reached but work in flight — staying up'); return; @@ -1063,13 +1277,22 @@ export class GlobalDaemon { private async drain(reason: string): Promise { this.log('INFO', `Shutdown: ${reason}`); this.server?.close(); + for (const queue of this.teamMembers.values()) { + for (const member of queue) { + member.call.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: 'daemon_shutdown' }); + member.call.subAgent.end({ error: new Error('teammate did not complete before shutdown') }); + } + } + this.teamMembers.clear(); for (const session of this.sessions.values()) { try { this.finalizeOpenTurn(session, 'daemon_shutdown'); + this.finalizeIdleSubagents(session.sessionId, 'daemon_shutdown'); } catch (err) { this.log('ERROR', `Error finalizing session ${session.sessionId} at shutdown: ${err}`); } } + this.teamDispatches.clear(); if (this.tracingEnabled) { try { await weave.flushOTel(); @@ -1085,6 +1308,15 @@ export class GlobalDaemon { } } + private finalizeIdleSubagents(sessionId: string, orphanReason: string): void { + for (const [key, idle] of this.idleSubagents) { + if (idle.sessionId !== sessionId) continue; + idle.subAgent.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + idle.subAgent.end({ error: new Error(`teammate did not complete (${orphanReason})`) }); + this.idleSubagents.delete(key); + } + } + // ── helpers ─────────────────────────────────────────────────────────────── /** Retry parseSessionFile while the transcript writer catches up to Stop. diff --git a/tests/teammate-idle.test.ts b/tests/teammate-idle.test.ts new file mode 100644 index 0000000..a93a91f --- /dev/null +++ b/tests/teammate-idle.test.ts @@ -0,0 +1,739 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// Tests for the TeammateIdle handler's transcript parsing behaviour. +// +// Teammate transcripts differ from subagent transcripts in two ways: +// 1. They live at /.jsonl (not under subagents/) +// 2. The first line is an agent-setting record, not a user message: +// {"type":"agent-setting","agentSetting":"cks-specialist","sessionId":"..."} +// +// TeammateIdle payload fields the handler reads: teammate_name (agent name), +// team_name, and transcript_path (the teammate's, not the coordinator's). The +// integration test below drives this payload through the daemon to catch any +// regression in field-name reading. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFirstTranscriptLine } from '../src/transcriptFile.ts'; +import { parseSessionFile } from '../src/parser.ts'; +import { ATTR } from '../src/genaiSpans.ts'; +import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/** Write a fake teammate transcript to a temp file and return its path. + * + * readFirstTranscriptLine requires the path to be within os.homedir() (security + * check). We use a subdir of the home directory rather than /tmp to satisfy it. + */ +function writeTeammateTranscript(lines: object[]): string { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-test-')); + const filePath = path.join(dir, 'abc123.jsonl'); + fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n') + '\n'); + return filePath; +} + +// ── test data ───────────────────────────────────────────────────────────────── + +const AGENT_SETTING_LINE = { + type: 'agent-setting', + agentSetting: 'cks-specialist', + sessionId: 'abc123-session-id', +}; + +const MODE_LINE = { type: 'mode', mode: 'normal', sessionId: 'abc123-session-id' }; + +const USER_LINE = { + parentUuid: null, + isSidechain: false, + teamName: 'triage-supp-12345', + agentName: 'cks-specialist', + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: 'Investigate the CKS cluster health.' }], + }, + timestamp: '2026-06-05T10:00:00.000Z', +}; + +const ASSISTANT_LINE = { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + id: 'msg_test123', + usage: { + input_tokens: 1000, + output_tokens: 200, + cache_read_input_tokens: 500, + cache_creation_input_tokens: 0, + }, + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'The cluster looks healthy. No anomalies detected.' }], + }, + timestamp: '2026-06-05T10:00:05.000Z', +}; + +// ── tests ───────────────────────────────────────────────────────────────────── + +test('readFirstTranscriptLine: returns agentSetting from teammate transcript', () => { + const filePath = writeTeammateTranscript([AGENT_SETTING_LINE, MODE_LINE, USER_LINE, ASSISTANT_LINE]); + try { + const firstLine = readFirstTranscriptLine(filePath); + assert.ok(firstLine, 'should read first line'); + assert.equal(firstLine['type'], 'agent-setting'); + assert.equal(firstLine['agentSetting'], 'cks-specialist'); + assert.equal(firstLine['sessionId'], 'abc123-session-id'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true }); + } +}); + +test('parseSessionFile: skips agent-setting lines, parses LLM calls from teammate transcript', () => { + const filePath = writeTeammateTranscript([AGENT_SETTING_LINE, MODE_LINE, USER_LINE, ASSISTANT_LINE]); + try { + const parsed = parseSessionFile(filePath); + assert.ok(parsed, 'parseSessionFile should return non-null'); + assert.equal(parsed.turns.length, 1, 'should produce exactly one turn'); + + const turn = parsed.turns[0]; + const calls = turn.assistantCalls(); + assert.equal(calls.length, 1, 'should have one assistant call'); + + const call = calls[0]; + assert.equal(call.model, 'claude-opus-4-8'); + assert.equal(call.usage.input_tokens, 1000); + assert.equal(call.usage.output_tokens, 200); + assert.equal(call.usage.cache_read_input_tokens, 500); + assert.equal(call.finishReason, 'end_turn'); + assert.equal(call.responseId, 'msg_test123'); + + assert.deepEqual(turn.textBlocks(), ['The cluster looks healthy. No anomalies detected.']); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true }); + } +}); + +test('TeammateIdle span tree: teammate turn carries the teammate chat span, tagged by agent name', async () => { + // Drive the per-session teammate path end-to-end in-process: SubagentStart + // (orphan) creates the Subagent marker; SubagentStop keeps it open; + // TeammateIdle emits the teammate's chat spans under a fresh teammate turn + // (the Subagent is a leaf and can't parent them). Each teammate chat span is + // tagged with `gen_ai.agent.name` so the Agents view groups it. + const exporter = await initWeaveInMemory(); + exporter.reset(); + + const home = os.homedir(); + const coordSid = 'coord-span-001'; + const coordDir = fs.mkdtempSync(path.join(home, '.weave-tmspan-')); + const coordPath = path.join(coordDir, `${coordSid}.jsonl`); + fs.writeFileSync(coordPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + // Subagent transcript at the path the daemon derives: + // //subagents/agent-.jsonl + const agentId = 'agent-span-abc'; + const subDir = path.join(coordDir, coordSid, 'subagents'); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, `agent-${agentId}.jsonl`), + [USER_LINE, ASSISTANT_LINE].map(l => JSON.stringify(l)).join('\n') + '\n'); + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: coordSid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordSid, prompt: '/triage' }); + // Orphan SubagentStart (no matching PreToolUse tracker). + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: coordSid, agent_id: agentId, agent_type: 'cks-specialist' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: coordSid, agent_id: agentId }); + await d.routeEvent({ hook_event_name: 'TeammateIdle', session_id: coordSid, teammate_name: 'cks-specialist', team_name: 'triage-span' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + + // The teammate's own turn root (fresh trace), tagged with the teammate name. + const teammateTurn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'cks-specialist'); + assert.ok(teammateTurn, 'teammate turn span exists tagged with gen_ai.agent.name'); + + // The teammate chat span nests under the teammate turn and is tagged too. + const chatKids = childrenOf(spans, teammateTurn).filter(s => s.attributes[ATTR.OPERATION_NAME] === 'chat'); + assert.equal(chatKids.length, 1, 'one chat span under the teammate turn'); + const chatSpan = chatKids[0]; + assert.equal(chatSpan.attributes[ATTR.AGENT_NAME], 'cks-specialist', 'chat span tagged with the teammate name'); + + // Token counts are correct (cache-inclusive total for input). + assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1500, 'input_tokens = 1000 + 500 cache_read'); + assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 200); + } finally { + fs.rmSync(coordDir, { recursive: true, force: true }); + } +}); + +test('TeammateIdle: multi-turn transcript emits chat spans from all turns', () => { + const turn2User = { + ...USER_LINE, + message: { ...USER_LINE.message, content: [{ type: 'text', text: 'Follow-up question.' }] }, + timestamp: '2026-06-05T10:01:00.000Z', + }; + const turn2Assistant = { + ...ASSISTANT_LINE, + message: { + ...ASSISTANT_LINE.message, + id: 'msg_turn2', + usage: { input_tokens: 800, output_tokens: 150, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + content: [{ type: 'text', text: 'Follow-up answer.' }], + }, + timestamp: '2026-06-05T10:01:05.000Z', + }; + + const filePath = writeTeammateTranscript([ + AGENT_SETTING_LINE, MODE_LINE, + USER_LINE, ASSISTANT_LINE, + turn2User, turn2Assistant, + ]); + try { + const parsed = parseSessionFile(filePath); + assert.ok(parsed); + assert.equal(parsed.turns.length, 2, 'should have 2 turns'); + + let totalCalls = 0; + for (const turn of parsed.turns) { + totalCalls += turn.assistantCalls().length; + } + assert.equal(totalCalls, 2, 'should have 2 assistant calls across both turns'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true }); + } +}); + +// ── integration: actual payload field names ─────────────────────────────────── + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..'); +const CLI = path.join(REPO_ROOT, 'src', 'cli.ts'); + +test('TeammateIdle: full sequence SubagentStart -> SubagentStop -> TeammateIdle traces with all turns', async () => { + // Per-session teammate sequence (SubagentStart is the entry point; PreToolUse + // not tested here): + // 1. SubagentStart (orphan, no matching tracker): creates invoke_agent span, stores transcript path + // 2. SubagentStop: span kept open (pendingTeammateIdle=true), tracker stays in SubagentTracking + // 3. TeammateIdle: finds tracker, emits all-turns chat spans, closes span + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-inttest-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + const coordinatorSessionId = 'inttest-coord-001'; + + // Subagent transcript must live where the daemon expects it: + // /subagents/agent-.jsonl + const coordinatorTranscriptDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); + const subagentsDir = path.join(coordinatorTranscriptDir, 'subagents'); + const agentId = 'agent-abc123def456'; + const agentTranscriptPath = path.join(subagentsDir, `agent-${agentId}.jsonl`); + + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.mkdirSync(subagentsDir, { recursive: true }); + + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + weave_project: 'test/test', + wandb_api_key: 'fake-key-for-test', + daemon_socket: socketPath, + log_file: logPath, + debug: true, + })); + + // Multi-turn teammate transcript (two investigation turns) + fs.writeFileSync(agentTranscriptPath, [ + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Investigate CKS health' }] } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg1', + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: 'Phase 1: cluster looks healthy.' }] } }), + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Dig deeper' }] } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg2', + usage: { input_tokens: 200, output_tokens: 80, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: 'Phase 2: no anomalies detected.' }] } }), + ].join('\n') + '\n'); + + const coordinatorPath = path.join(coordinatorTranscriptDir, `${coordinatorSessionId}.jsonl`); + fs.mkdirSync(coordinatorTranscriptDir, { recursive: true }); + fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + const daemon = spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { + env: { ...process.env, HOME: home }, + stdio: 'ignore', + }); + + const sendEvent = (payload: object): Promise => new Promise((resolve, reject) => { + const s = net.createConnection(socketPath); + s.on('error', reject); + s.on('connect', () => { s.end(JSON.stringify(payload)); }); + s.on('close', () => resolve()); + }); + + const waitForSocket = (): Promise => new Promise((resolve) => { + const poll = setInterval(() => { + if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } + }, 50); + }); + + const readLog = () => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; + + try { + await waitForSocket(); + await new Promise(r => setTimeout(r, 200)); + + // Step 1: Coordinator session starts and submits prompt + await sendEvent({ hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); + await new Promise(r => setTimeout(r, 100)); + await sendEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-99999' }); + await new Promise(r => setTimeout(r, 100)); + + // Step 2: SubagentStart (orphan — no matching PreToolUse) + await sendEvent({ + hook_event_name: 'SubagentStart', + session_id: coordinatorSessionId, + agent_id: agentId, + agent_type: 'cks-specialist', + transcript_path: agentTranscriptPath, + }); + await new Promise(r => setTimeout(r, 100)); + + // Step 3: SubagentStop — should keep span open (pendingTeammateIdle) + await sendEvent({ + hook_event_name: 'SubagentStop', + session_id: coordinatorSessionId, + agent_id: agentId, + agent_transcript_path: agentTranscriptPath, + }); + await new Promise(r => setTimeout(r, 100)); + + // Step 4: TeammateIdle — should close span with all-turns content + // CC sends coordinator's transcript_path (not the agent's) — daemon uses stored path instead + await sendEvent({ + hook_event_name: 'TeammateIdle', + session_id: coordinatorSessionId, + transcript_path: coordinatorPath, // coordinator's path (as CC sends it) + teammate_name: 'cks-specialist', + team_name: 'triage-inttest', + }); + await new Promise(r => setTimeout(r, 400)); + + const log = readLog(); + assert.match(log, /TeammateIdle: traced cks-specialist/, 'should trace cks-specialist'); + assert.doesNotMatch(log, /missing agent_id/, 'should not error on missing agent_id'); + assert.doesNotMatch(log, /no pending tracker for cks-specialist/, 'should find the pending tracker from SubagentStart'); + } finally { + daemon.kill(); + await new Promise(resolve => daemon.once('exit', () => resolve())); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +// ── cross-session: agent-teams (TeamCreate) model ─────────────────────────── +// +// In agent-teams, the teammate is an independent Claude session. SubagentStart +// does NOT fire for teammates. The sequence is: +// 1. Coordinator: PreToolUse(Agent, team_name) → creates tracker + team member +// 2. Teammate: SessionStart (new session_id) +// 3. Teammate: TeammateIdle (from teammate's session, NOT coordinator's) +// The cross-session team registry bridges coordinator → teammate. + +test('Cross-session: TeammateIdle from teammate session finds coordinator team member', async () => { + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-crosstest-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + const coordinatorSessionId = 'cross-coord-001'; + const teammateSessionId = 'cross-teammate-001'; + const teamName = 'triage-crosstest'; + const teammateName = 'cks-specialist'; + + // Coordinator transcript dir with subagents/ for transcript resolution + const coordinatorTranscriptDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); + const subagentsDir = path.join(coordinatorTranscriptDir, 'subagents'); + const agentId = 'agent-cross-abc123'; + const agentTranscriptPath = path.join(subagentsDir, `agent-${agentId}.jsonl`); + const agentMetaPath = path.join(subagentsDir, `agent-${agentId}.meta.json`); + + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.mkdirSync(subagentsDir, { recursive: true }); + + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + weave_project: 'test/crosstest', + wandb_api_key: 'fake-key-for-crosstest', + daemon_socket: socketPath, + log_file: logPath, + debug: true, + })); + + // Teammate transcript (the specialist's own investigation) + fs.writeFileSync(agentTranscriptPath, [ + JSON.stringify({ type: 'agent-setting', agentSetting: teammateName, sessionId: teammateSessionId }), + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Investigate CKS health' }] } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg-cross-1', + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: 'CKS cluster is healthy.' }] } }), + ].join('\n') + '\n'); + + // Meta file for transcript resolution (resolveTeammateTranscript reads this) + fs.writeFileSync(agentMetaPath, JSON.stringify({ agentType: teammateName })); + + // Coordinator and teammate transcript files + const coordinatorPath = path.join(coordinatorTranscriptDir, `${coordinatorSessionId}.jsonl`); + fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + const teammateTranscriptDir = path.join(home, '.claude', 'projects', 'test', teammateSessionId); + fs.mkdirSync(teammateTranscriptDir, { recursive: true }); + const teammatePath = path.join(teammateTranscriptDir, `${teammateSessionId}.jsonl`); + fs.writeFileSync(teammatePath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + const daemon = spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { + env: { ...process.env, HOME: home }, + stdio: 'ignore', + }); + + const sendEvent = (payload: object): Promise => new Promise((resolve, reject) => { + const s = net.createConnection(socketPath); + s.on('error', reject); + s.on('connect', () => { s.end(JSON.stringify(payload)); }); + s.on('close', () => resolve()); + }); + + const waitForSocket = (): Promise => new Promise((resolve) => { + const poll = setInterval(() => { + if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } + }, 50); + }); + + const readLog = () => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; + + try { + await waitForSocket(); + await new Promise(r => setTimeout(r, 200)); + + // Step 1: Coordinator starts and submits prompt + await sendEvent({ hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); + await new Promise(r => setTimeout(r, 100)); + await sendEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-crosstest' }); + await new Promise(r => setTimeout(r, 100)); + + // Step 2: PreToolUse(Agent, team_name) in coordinator session + await sendEvent({ + hook_event_name: 'PreToolUse', + session_id: coordinatorSessionId, + tool_use_id: 'toolu_cross_001', + tool_name: 'Agent', + tool_input: { + prompt: 'Investigate CKS health', + subagent_type: teammateName, + team_name: teamName, + name: teammateName, + }, + }); + await new Promise(r => setTimeout(r, 100)); + + // Verify team member was registered + let log = readLog(); + assert.match(log, /Team member registered/, 'coordinator PreToolUse should register team member'); + + // Step 3: PostToolUse(Agent) — should NOT close the span (team mode) + await sendEvent({ + hook_event_name: 'PostToolUse', + session_id: coordinatorSessionId, + tool_use_id: 'toolu_cross_001', + tool_name: 'Agent', + tool_response: 'Agent dispatched', + }); + await new Promise(r => setTimeout(r, 100)); + + // Step 4: Teammate session starts (DIFFERENT session_id) + await sendEvent({ hook_event_name: 'SessionStart', session_id: teammateSessionId, transcript_path: teammatePath }); + await new Promise(r => setTimeout(r, 100)); + + // Step 5: TeammateIdle fires from TEAMMATE's session (the cross-session case) + await sendEvent({ + hook_event_name: 'TeammateIdle', + session_id: teammateSessionId, + transcript_path: teammatePath, + teammate_name: teammateName, + team_name: teamName, + }); + await new Promise(r => setTimeout(r, 400)); + + log = readLog(); + assert.match(log, /TeammateIdle: traced cks-specialist team=triage-crosstest \(cross-session\)/, 'should trace via cross-session path'); + assert.doesNotMatch(log, /no pending tracker for cks-specialist/, 'should NOT fall through to per-session path'); + } finally { + daemon.kill(); + await new Promise(resolve => daemon.once('exit', () => resolve())); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test('Cross-session: re-spawn of same team::name nests BOTH (FIFO queue, no overwrite)', async () => { + // Regression for the re-spawn bug: the same team::name is spawned twice in one + // run. A second PreToolUse(Agent) for the same `${team}::${name}` must append + // to the FIFO queue, not overwrite the first still-open span (which would leak + // it and mis-attribute the first teammate's transcript). + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-respawntest-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + const coordinatorSessionId = 'respawn-coord-001'; + const teamName = 'triage-respawn'; + const teammateName = 'cks-specialist'; + const tm1 = 'respawn-tm-001'; + const tm2 = 'respawn-tm-002'; + + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + weave_project: 'test/respawn', wandb_api_key: 'fake-key', daemon_socket: socketPath, log_file: logPath, debug: true, + })); + + const coordDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); + const subagentsDir = path.join(coordDir, 'subagents'); + fs.mkdirSync(subagentsDir, { recursive: true }); + const coordinatorPath = path.join(coordDir, `${coordinatorSessionId}.jsonl`); + fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + const mkTeammate = (agentId: string, sid: string, text: string): string => { + fs.writeFileSync(path.join(subagentsDir, `agent-${agentId}.jsonl`), [ + JSON.stringify({ type: 'agent-setting', agentSetting: teammateName, sessionId: sid }), + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: `msg-${agentId}`, + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text }] } }), + ].join('\n') + '\n'); + fs.writeFileSync(path.join(subagentsDir, `agent-${agentId}.meta.json`), JSON.stringify({ agentType: teammateName })); + const tdir = path.join(home, '.claude', 'projects', 'test', sid); + fs.mkdirSync(tdir, { recursive: true }); + const tp = path.join(tdir, `${sid}.jsonl`); + fs.writeFileSync(tp, JSON.stringify({ type: 'system', content: [] }) + '\n'); + return tp; + }; + const tp1 = mkTeammate('respawn-a1', tm1, 'first cks investigation'); + const tp2 = mkTeammate('respawn-a2', tm2, 'second cks investigation'); + + const daemon = spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { + env: { ...process.env, HOME: home }, stdio: 'ignore', + }); + const sendEvent = (payload: object): Promise => new Promise((resolve, reject) => { + const s = net.createConnection(socketPath); + s.on('error', reject); + s.on('connect', () => { s.end(JSON.stringify(payload)); }); + s.on('close', () => resolve()); + }); + const waitForSocket = (): Promise => new Promise((resolve) => { + const poll = setInterval(() => { if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } }, 50); + }); + const readLog = () => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; + + try { + await waitForSocket(); + await new Promise(r => setTimeout(r, 200)); + await sendEvent({ hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); + await new Promise(r => setTimeout(r, 100)); + await sendEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-respawn' }); + await new Promise(r => setTimeout(r, 100)); + + // FIRST spawn of cks-specialist + await sendEvent({ hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_r1', + tool_name: 'Agent', tool_input: { prompt: 'first', subagent_type: teammateName, team_name: teamName, name: teammateName } }); + await new Promise(r => setTimeout(r, 80)); + // SECOND spawn of the SAME team::name (the re-spawn) BEFORE the first idles + await sendEvent({ hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_r2', + tool_name: 'Agent', tool_input: { prompt: 'second', subagent_type: teammateName, team_name: teamName, name: teammateName } }); + await new Promise(r => setTimeout(r, 120)); + + let log = readLog(); + assert.match(log, /queue depth 2/, 'second spawn of same key should APPEND to FIFO queue (depth 2), not overwrite'); + + // both teammate sessions start, then both idle + await sendEvent({ hook_event_name: 'SessionStart', session_id: tm1, transcript_path: tp1 }); + await sendEvent({ hook_event_name: 'SessionStart', session_id: tm2, transcript_path: tp2 }); + await new Promise(r => setTimeout(r, 100)); + await sendEvent({ hook_event_name: 'TeammateIdle', session_id: tm1, transcript_path: tp1, teammate_name: teammateName, team_name: teamName }); + await new Promise(r => setTimeout(r, 200)); + await sendEvent({ hook_event_name: 'TeammateIdle', session_id: tm2, transcript_path: tp2, teammate_name: teammateName, team_name: teamName }); + await new Promise(r => setTimeout(r, 400)); + + log = readLog(); + const traced = log.match(/TeammateIdle: traced cks-specialist team=triage-respawn \(cross-session\)/g) ?? []; + assert.equal(traced.length, 2, `BOTH re-spawned teammates should nest (no overwrite/leak) — got ${traced.length}`); + } finally { + daemon.kill(); + await new Promise(resolve => daemon.once('exit', () => resolve())); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test('Inactivity guard: daemon stays up past timeout while team correlation is in flight', async () => { + // Regression for the daemon-restart-wipes-map failure: an agent-teams run has + // quiet windows after spawn (waiting on specialists). The daemon must NOT hit + // its inactivity timeout while team members are unemitted, or the restart wipes + // teamMembers and breaks nesting. Uses WEAVE_INACTIVITY_MS to make it fast. + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-inacttest-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + const coordinatorSessionId = 'inact-coord-001'; + const teamName = 'triage-inact'; + const teammateName = 'cks-specialist'; + + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + weave_project: 'test/inact', wandb_api_key: 'fake-key', daemon_socket: socketPath, log_file: logPath, debug: true, + })); + const coordDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); + fs.mkdirSync(coordDir, { recursive: true }); + const coordinatorPath = path.join(coordDir, `${coordinatorSessionId}.jsonl`); + fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + // 800ms inactivity timeout so the test runs in seconds (vs the 10-min default). + const daemon = spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { + env: { ...process.env, HOME: home, WEAVE_INACTIVITY_MS: '800' }, stdio: 'ignore', + }); + const sendEvent = (payload: object): Promise => new Promise((resolve, reject) => { + const s = net.createConnection(socketPath); + s.on('error', reject); + s.on('connect', () => { s.end(JSON.stringify(payload)); }); + s.on('close', () => resolve()); + }); + const isAlive = (): Promise => new Promise((resolve) => { + const s = net.createConnection(socketPath); + s.on('error', () => resolve(false)); + s.on('connect', () => { s.destroy(); resolve(true); }); + }); + const waitForSocket = (): Promise => new Promise((resolve) => { + const poll = setInterval(() => { if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } }, 50); + }); + const readLog = () => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; + + try { + await waitForSocket(); + await new Promise(r => setTimeout(r, 100)); + await sendEvent({ hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); + await sendEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-inact' }); + // Register a team member (unemitted), then go quiet — NO TeammateIdle. + await sendEvent({ hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_inact_1', + tool_name: 'Agent', tool_input: { prompt: 'x', subagent_type: teammateName, team_name: teamName, name: teammateName } }); + + // Wait well past the 800ms timeout (multiple ~500ms check intervals) with no activity. + await new Promise(r => setTimeout(r, 2600)); + + assert.equal(await isAlive(), true, 'daemon must stay UP past the inactivity timeout while a team member is unemitted'); + assert.match(readLog(), /team correlation in flight — staying up/, 'should log that it stayed up for in-flight team work'); + } finally { + daemon.kill(); + await new Promise(resolve => daemon.once('exit', () => resolve())); + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test('Cross-session: duplicate TeammateIdle does not double-emit', async () => { + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-duptest-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + const coordinatorSessionId = 'dup-coord-001'; + const teammateSessionId = 'dup-teammate-001'; + + const coordinatorTranscriptDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); + const subagentsDir = path.join(coordinatorTranscriptDir, 'subagents'); + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.mkdirSync(subagentsDir, { recursive: true }); + + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + weave_project: 'test/duptest', + wandb_api_key: 'fake-key-for-duptest', + daemon_socket: socketPath, + log_file: logPath, + debug: true, + })); + + const agentId = 'agent-dup-xyz'; + const agentTranscriptPath = path.join(subagentsDir, `agent-${agentId}.jsonl`); + fs.writeFileSync(agentTranscriptPath, [ + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Check storage' }] } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg-dup', + usage: { input_tokens: 50, output_tokens: 30, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: 'Storage OK.' }] } }), + ].join('\n') + '\n'); + fs.writeFileSync(path.join(subagentsDir, `agent-${agentId}.meta.json`), JSON.stringify({ agentType: 'storage-specialist' })); + + const coordinatorPath = path.join(coordinatorTranscriptDir, `${coordinatorSessionId}.jsonl`); + fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + const teammateTranscriptDir = path.join(home, '.claude', 'projects', 'test', teammateSessionId); + fs.mkdirSync(teammateTranscriptDir, { recursive: true }); + const teammatePath = path.join(teammateTranscriptDir, `${teammateSessionId}.jsonl`); + fs.writeFileSync(teammatePath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + const daemon = spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { + env: { ...process.env, HOME: home }, + stdio: 'ignore', + }); + const sendEvent = (payload: object): Promise => new Promise((resolve, reject) => { + const s = net.createConnection(socketPath); + s.on('error', reject); + s.on('connect', () => { s.end(JSON.stringify(payload)); }); + s.on('close', () => resolve()); + }); + const waitForSocket = (): Promise => new Promise((resolve) => { + const poll = setInterval(() => { + if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } + }, 50); + }); + const readLog = () => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; + + try { + await waitForSocket(); + await new Promise(r => setTimeout(r, 200)); + + await sendEvent({ hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); + await new Promise(r => setTimeout(r, 100)); + await sendEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage' }); + await new Promise(r => setTimeout(r, 100)); + await sendEvent({ + hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, + tool_use_id: 'toolu_dup_001', tool_name: 'Agent', + tool_input: { prompt: 'Check storage', subagent_type: 'storage-specialist', team_name: 'triage-duptest', name: 'storage-specialist' }, + }); + await new Promise(r => setTimeout(r, 100)); + await sendEvent({ hook_event_name: 'PostToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_dup_001', tool_name: 'Agent', tool_response: 'dispatched' }); + await new Promise(r => setTimeout(r, 100)); + await sendEvent({ hook_event_name: 'SessionStart', session_id: teammateSessionId, transcript_path: teammatePath }); + await new Promise(r => setTimeout(r, 100)); + + // First TeammateIdle — should trace + await sendEvent({ + hook_event_name: 'TeammateIdle', session_id: teammateSessionId, transcript_path: teammatePath, + teammate_name: 'storage-specialist', team_name: 'triage-duptest', + }); + await new Promise(r => setTimeout(r, 300)); + + // Second TeammateIdle (duplicate) — should skip + await sendEvent({ + hook_event_name: 'TeammateIdle', session_id: teammateSessionId, transcript_path: teammatePath, + teammate_name: 'storage-specialist', team_name: 'triage-duptest', + }); + await new Promise(r => setTimeout(r, 300)); + + const log = readLog(); + const traceMatches = log.match(/TeammateIdle: traced storage-specialist/g) ?? []; + assert.equal(traceMatches.length, 1, 'should trace exactly once, not twice'); + + // The second one should either hit "already emitted" or "no pending tracker" — not trace again + const skipOrFallthrough = log.includes('already emitted') || log.includes('no pending tracker'); + assert.ok(skipOrFallthrough, 'duplicate idle should be skipped'); + } finally { + daemon.kill(); + await new Promise(resolve => daemon.once('exit', () => resolve())); + fs.rmSync(home, { recursive: true, force: true }); + } +}); From 9dc84392f2056cfce6d5795f1af5b0bd9ad1d149 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 23:46:46 -0700 Subject: [PATCH 07/11] chore(daemon): delete the legacy span builders + old OTel deps Pure deletion: the hand-rolled builders, baggage plumbing, and their builder-only ATTR/OP keys became unreferenced in the SDK swap. Drop the now-unused exporter/provider packages; sdk-trace-base moves to devDeps for the tests' in-memory exporter. Co-Authored-By: Claude Fable 5 --- package-lock.json | 140 +------------ package.json | 6 +- src/genaiSpans.ts | 505 +--------------------------------------------- 3 files changed, 7 insertions(+), 644 deletions(-) diff --git a/package-lock.json b/package-lock.json index e97f106..90d2b1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,11 +10,6 @@ "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", - "@opentelemetry/resources": "^2.7.1", - "@opentelemetry/sdk-trace-base": "^2.7.1", - "@opentelemetry/sdk-trace-node": "^2.7.1", - "@opentelemetry/semantic-conventions": "^1.41.1", "uuidv7": "1.2.1", "weave": "^0.16.3" }, @@ -23,6 +18,7 @@ }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.178", + "@opentelemetry/sdk-trace-base": "^2.7.1", "@types/node": "^18.19.0", "tsx": "^4.19.0", "typescript": "^6.0.2" @@ -709,34 +705,11 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", - "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", - "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/core": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -748,65 +721,11 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.219.0.tgz", - "integrity": "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/otlp-exporter-base": "0.219.0", - "@opentelemetry/otlp-transformer": "0.219.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", - "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/otlp-transformer": "0.219.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", - "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.219.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/sdk-logs": "0.219.0", - "@opentelemetry/sdk-metrics": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, "node_modules/@opentelemetry/resources": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "2.8.0", @@ -819,44 +738,11 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-logs": { - "version": "0.219.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", - "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.219.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", - "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.8.0", - "@opentelemetry/resources": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, "node_modules/@opentelemetry/sdk-trace-base": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@opentelemetry/core": "2.8.0", @@ -870,27 +756,11 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz", - "integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.8.0", - "@opentelemetry/core": "2.8.0", - "@opentelemetry/sdk-trace-base": "2.8.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.41.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" diff --git a/package.json b/package.json index 583de74..2392af5 100644 --- a/package.json +++ b/package.json @@ -17,16 +17,12 @@ }, "dependencies": { "@opentelemetry/api": "^1.9.1", - "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", - "@opentelemetry/resources": "^2.7.1", - "@opentelemetry/sdk-trace-base": "^2.7.1", - "@opentelemetry/sdk-trace-node": "^2.7.1", - "@opentelemetry/semantic-conventions": "^1.41.1", "uuidv7": "1.2.1", "weave": "^0.16.3" }, "devDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.178", + "@opentelemetry/sdk-trace-base": "^2.7.1", "@types/node": "^18.19.0", "tsx": "^4.19.0", "typescript": "^6.0.2" diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index f818192..4090ae2 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -4,22 +4,8 @@ // Attribute-key constants and formatting helpers typed against the `weave` SDK. -import { - Attributes, - Baggage, - Span, - SpanKind, - Tracer, - Context, - TimeInput, - context as otelContext, - propagation, - trace, -} from '@opentelemetry/api'; -import type { ReadableSpan, Span as SdkSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'; +import type { Attributes } from '@opentelemetry/api'; import type { MessagePart, Tool, Turn, Usage } from 'weave'; -import { extractAssistantTextBlocks } from './parser.js'; -import type { AssistantCallDetail } from './parser.js'; import { isTextBlock, isThinkingBlock, isRedactedThinkingBlock, isToolUseBlock } from './parser.js'; import type { UsageSummary } from './parser.js'; @@ -57,7 +43,6 @@ export const ATTR = { ERROR_TYPE: 'error.type', // Weave extensions - claude_code per-turn metadata - WEAVE_SESSION_ID: 'weave.claude_code.session.id', WEAVE_CWD: 'weave.claude_code.cwd', WEAVE_SOURCE: 'weave.claude_code.source', WEAVE_PLUGIN_VERSION: 'weave.claude_code.plugin.version', @@ -83,17 +68,6 @@ export const ATTR = { EVT_PERMISSION_RESOLVED: 'weave.permission_resolved', EVT_PERMISSION_APPROVED: 'weave.permission.approved', EVT_PERMISSION_SUGGESTIONS: 'weave.permission.suggestions', - // Legacy keys used only by the hand-rolled builders below; deleted next PR. - PROVIDER_NAME: 'gen_ai.provider.name', - AGENT_DESCRIPTION: 'gen_ai.agent.description', - AGENT_VERSION: 'gen_ai.agent.version', - REQUEST_MODEL: 'gen_ai.request.model', - USAGE_REASONING_TOKENS: 'gen_ai.usage.reasoning_tokens', - TOOL_NAME: 'gen_ai.tool.name', - TOOL_CALL_ID: 'gen_ai.tool.call.id', - TOOL_CALL_ARGUMENTS: 'gen_ai.tool.call.arguments', - TOOL_CALL_RESULT: 'gen_ai.tool.call.result', - OUTPUT_TYPE: 'gen_ai.output.type', } as const; /** Top-level `gen_ai.agent.name` fallback; users override via settings @@ -280,480 +254,3 @@ export function toolDisplayName(toolName: string, input: Record } } } - -/** Common prefix for all integration-identity attributes. The span processor - * copies baggage entries under this prefix onto each span. */ -export const WEAVE_INTEGRATION_PREFIX = 'weave.integration.'; - -/** - * Build the per-session integration Baggage. `name` is the fixed integration - * id; `version` is the plugin version; `meta` is free-form per-session context - * flattened to `weave.integration.meta.` (falsy values skipped). The - * daemon activates this baggage for each session event so - * `IntegrationBaggageSpanProcessor` stamps it onto every span the event emits. - */ -export function createIntegrationBaggage(args: { - version: string; - meta?: Record; -}): Baggage { - const entries: Record = { - [ATTR.WEAVE_INTEGRATION_NAME]: { value: INTEGRATION_NAME }, - [ATTR.WEAVE_INTEGRATION_VERSION]: { value: args.version }, - }; - if (args.meta) { - for (const [key, value] of Object.entries(args.meta)) { - if (value) entries[`${WEAVE_INTEGRATION_META_PREFIX}${key}`] = { value }; - } - } - return propagation.createBaggage(entries); -} - -/** - * Copies `weave.integration.*` baggage entries off the active context onto each - * span at start. This is how integration identity reaches every span (turn - * root and all children) from a single per-session baggage attribution, instead - * of stamping each builder. Runs at `onStart` because attributes set after a - * span ends are dropped; the copy is a one-time snapshot (baggage is static per - * session). Baggage itself is never exported, only the copied attributes. - */ -export class IntegrationBaggageSpanProcessor implements SpanProcessor { - onStart(span: SdkSpan, parentContext: Context): void { - const baggage = propagation.getBaggage(parentContext); - if (!baggage) return; - for (const [key, entry] of baggage.getAllEntries()) { - if (key.startsWith(WEAVE_INTEGRATION_PREFIX)) { - span.setAttribute(key, entry.value); - } - } - } - onEnd(_span: ReadableSpan): void {} - forceFlush(): Promise { - return Promise.resolve(); - } - shutdown(): Promise { - return Promise.resolve(); - } -} - -// Values for `gen_ai.operation.name`. `invoke_agent`, `chat`, and -// `execute_tool` are well-known values from the OTel GenAI semantic conventions -// (https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/registry/attributes/gen-ai.md#gen-ai-operation-name); -// the spec mandates the well-known value whenever one applies. `assistant_text` -// and `thinking` have no well-known equivalent, so they're spec-permitted custom -// values — the model's natural-language output and its private reasoning, each -// emitted as a `chat` child so they interleave with sibling `execute_tool` spans. -export const OP = { - INVOKE_AGENT: 'invoke_agent', - CHAT: 'chat', - EXECUTE_TOOL: 'execute_tool', - ASSISTANT_TEXT: 'assistant_text', - THINKING: 'thinking', -} as const; - -/** - * Context carrying `parent` as the active span for child-span creation. - * Builds on `context.active()` (not `ROOT_CONTEXT`) so baggage on the active - * context propagates to children — relevant if we ever wire baggage for - * cross-process trace continuity. - */ -export function ctxWithParent(parent: Span): Context { - return trace.setSpan(otelContext.active(), parent); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Span builders -// ───────────────────────────────────────────────────────────────────────────── - -type TurnSpanArgs = { - /** Current process's Claude Code session id — stamped on the span as a - * debug breadcrumb (`weave.claude_code.session.id`). Per resume, this - * changes; the conversation id does not. */ - sessionId: string; - /** Stitching key for the multi-turn conversation. For resumed sessions, - * this is the root ancestor's session id (so turns from before and after - * resume share `gen_ai.conversation.id`). For fresh sessions, equals - * `sessionId`. */ - conversationId: string; - prompt: string; - cwd: string; - source: string; - pluginVersion: string; - /** Top-level agent name; becomes the second word of the span name and is - * stamped as `gen_ai.agent.name`. Defaults to `DEFAULT_AGENT_NAME`; - * the daemon resolves any user override before calling. */ - agentName: string; - requestModel?: string; - displayName?: string; - /** Loaded instruction-file contents (global/project CLAUDE.md, .claude/rules, - * @-imports) in load order, stamped as `gen_ai.system_instructions` (one text - * part per file) when non-empty. */ - systemInstructions?: string[]; -}; - -/** - * Start a turn span. Each turn is the root of its own trace; the Weave Agents - * backend stitches turns into a conversation via `gen_ai.conversation.id`. - * Session-level metadata (cwd, source, plugin.version) is stamped on every - * turn span so it's queryable without a separate session-level span. - */ -export function startTurnSpan(tracer: Tracer, args: TurnSpanArgs): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.INVOKE_AGENT, - [ATTR.AGENT_NAME]: args.agentName, - [ATTR.AGENT_VERSION]: args.pluginVersion, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.WEAVE_SESSION_ID]: args.sessionId, - [ATTR.WEAVE_CWD]: args.cwd, - [ATTR.WEAVE_SOURCE]: args.source, - [ATTR.WEAVE_PLUGIN_VERSION]: args.pluginVersion, - [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: args.prompt }]), - }; - if (args.requestModel) attrs[ATTR.REQUEST_MODEL] = args.requestModel; - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - if (args.systemInstructions?.length) { - attrs[ATTR.SYSTEM_INSTRUCTIONS] = jsonStr( - args.systemInstructions.map((content) => ({ type: 'text', content })), - ); - } - - // `weave.integration.*` is not set here — it rides the active session baggage - // and is stamped on this span (and all children) by - // IntegrationBaggageSpanProcessor at onStart. - // - // No parent span in context — turn spans are roots, one trace per turn. (The - // active context carries integration baggage but no span, so this stays a - // root.) - return tracer.startSpan( - `${OP.INVOKE_AGENT} ${args.agentName}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ); -} - -type InvokeAgentSpanArgs = { - /** Agent type label — becomes the second word of the span name and is - * stamped as `gen_ai.agent.name`. For Claude Code subagents this is the - * `subagent_type` from the spawning `Agent` tool call (e.g. "Explore", - * "general-purpose"). */ - agentType: string; - /** Stitching key inherited from the parent turn span. */ - conversationId: string; - /** Plugin version, stamped as `gen_ai.agent.version` for parity with the - * outer turn span. */ - pluginVersion: string; - /** Initial input passed to the agent — typically the firing prompt from - * the parent agent's `Agent` tool call. Stamped as - * `gen_ai.input.messages`. */ - inputMessages?: unknown; - /** tool_use_id of the parent's `Agent` tool call. Stamped as a - * back-pointer attribute so queries can correlate the subagent - * invocation with the spawning tool call. */ - spawningToolCallId?: string; - displayName?: string; -}; - -/** - * Start a nested `invoke_agent` span — used for subagents Claude Code - * dispatches via the `Agent` tool. Child of the parent turn (or, for nested - * subagent calls, of the spawning subagent's invoke_agent span). Subagent - * `chat` spans and any tool calls the subagent runs parent under this span, - * which the Weave Agents chat view renders as an `agent_start` lifecycle - * marker followed by the subagent's own assistant text. - */ -export function startInvokeAgentSpan( - tracer: Tracer, - parentSpan: Span, - args: InvokeAgentSpanArgs, -): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.INVOKE_AGENT, - [ATTR.AGENT_NAME]: args.agentType, - [ATTR.AGENT_VERSION]: args.pluginVersion, - [ATTR.CONVERSATION_ID]: args.conversationId, - }; - if (args.inputMessages !== undefined) { - attrs[ATTR.INPUT_MESSAGES] = jsonStr(args.inputMessages); - } - if (args.spawningToolCallId) { - attrs[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID] = args.spawningToolCallId; - } - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - - return tracer.startSpan( - `${OP.INVOKE_AGENT} ${args.agentType}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ctxWithParent(parentSpan), - ); -} - -type ToolSpanArgs = { - toolName: string; - toolUseId: string; - toolInput: Record; - /** Stitching key — same as the enclosing turn's `gen_ai.conversation.id`. */ - conversationId: string; - displayName?: string; -}; - -export function startToolSpan(tracer: Tracer, parentSpan: Span, args: ToolSpanArgs): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.EXECUTE_TOOL, - [ATTR.TOOL_NAME]: args.toolName, - [ATTR.TOOL_CALL_ID]: args.toolUseId, - [ATTR.TOOL_CALL_ARGUMENTS]: jsonStr(args.toolInput), - [ATTR.CONVERSATION_ID]: args.conversationId, - }; - if (args.displayName) attrs[ATTR.WEAVE_DISPLAY_NAME] = args.displayName; - - return tracer.startSpan( - `${OP.EXECUTE_TOOL} ${args.toolName}`, - { kind: SpanKind.INTERNAL, attributes: attrs }, - ctxWithParent(parentSpan), - ); -} - -type ChatSpanArgs = { - /** Stitching key — same value as the parent turn span's - * `gen_ai.conversation.id`. For subagent chats this is suffixed with - * `:${agent_id}` upstream so the subagent's calls form their own - * conversation under the spawning tool span. */ - conversationId: string; - model: string; - startedAt: TimeInput; - endedAt: TimeInput; - usage: UsageSummary; - reasoningTokens?: number; - responseId?: string; - finishReasons?: string[]; - inputMessages?: unknown; - outputMessages?: unknown; -}; - -/** - * Emit a chat span as a child of `parentSpan`. The span is started AND ended - * inside this helper. Used by code paths that construct the chat span from - * transcript data after the fact (SubagentStop, TeammateIdle). For the main - * agent path — where the chat span parents the assistant_text / thinking / - * execute_tool spans that occur during the API call — use `startChatSpan` / - * `finalizeChatSpan` instead. - */ -export function emitChatSpan( - tracer: Tracer, - parentSpan: Span, - args: ChatSpanArgs, -): void { - const span = startChatSpan(tracer, parentSpan, { - conversationId: args.conversationId, - model: args.model, - startedAt: args.startedAt, - }); - finalizeChatSpan(span, { - usage: args.usage, - reasoningTokens: args.reasoningTokens, - responseId: args.responseId, - finishReasons: args.finishReasons, - inputMessages: args.inputMessages, - outputMessages: args.outputMessages, - endedAt: args.endedAt, - }); -} - -type StartChatSpanArgs = { - conversationId: string; - model?: string; - startedAt: TimeInput; -}; - -/** - * Start a chat span (open). Caller is responsible for emitting any child - * spans and calling `finalizeChatSpan` with the usage data and end time. - * - * `model` is optional at open time — Anthropic returns it in the response, so - * it may not be known until the assistant message is parsed. When omitted, - * the span name uses a placeholder; `finalizeChatSpan` overwrites the name - * with the actual model once it's known. - */ -export function startChatSpan( - tracer: Tracer, - parentSpan: Span, - args: StartChatSpanArgs, -): Span { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.CHAT, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_TYPE]: 'text', - }; - if (args.model) { - attrs[ATTR.REQUEST_MODEL] = args.model; - const provider = providerFromModel(args.model); - if (provider) attrs[ATTR.PROVIDER_NAME] = provider; - } - const name = args.model ? `${OP.CHAT} ${args.model}` : OP.CHAT; - return tracer.startSpan( - name, - { kind: SpanKind.CLIENT, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); -} - -type FinalizeChatSpanArgs = { - usage: UsageSummary; - reasoningTokens?: number; - responseId?: string; - finishReasons?: string[]; - inputMessages?: unknown; - outputMessages?: unknown; - /** If set and the span was opened without a model, attaches the model - * attribute and updates the span name. */ - model?: string; - endedAt?: TimeInput; -}; - -/** Stamp usage / response attrs on an open chat span and end it. */ -export function finalizeChatSpan(span: Span, args: FinalizeChatSpanArgs): void { - // OTel `input_tokens` is the total prompt; Anthropic splits it into three - // disjoint fields (uncached + cache_read + cache_creation), so sum them. - // https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md - const totalInputTokens = - args.usage.input_tokens - + (args.usage.cache_read_input_tokens ?? 0) - + (args.usage.cache_creation_input_tokens ?? 0); - - span.setAttribute(ATTR.USAGE_INPUT_TOKENS, totalInputTokens); - span.setAttribute(ATTR.USAGE_OUTPUT_TOKENS, args.usage.output_tokens); - if (args.usage.cache_read_input_tokens !== undefined) { - span.setAttribute(ATTR.USAGE_CACHE_READ_INPUT_TOKENS, args.usage.cache_read_input_tokens); - } - if (args.usage.cache_creation_input_tokens !== undefined) { - span.setAttribute(ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS, args.usage.cache_creation_input_tokens); - } - if (args.reasoningTokens !== undefined && args.reasoningTokens > 0) { - span.setAttribute(ATTR.USAGE_REASONING_TOKENS, args.reasoningTokens); - } - if (args.responseId) { - span.setAttribute(ATTR.RESPONSE_ID, args.responseId); - } - if (args.finishReasons?.length) { - span.setAttribute(ATTR.RESPONSE_FINISH_REASONS, args.finishReasons); - } - if (args.inputMessages !== undefined) { - span.setAttribute(ATTR.INPUT_MESSAGES, jsonStr(args.inputMessages)); - } - if (args.outputMessages !== undefined) { - span.setAttribute(ATTR.OUTPUT_MESSAGES, jsonStr(args.outputMessages)); - } - if (args.model) { - span.setAttribute(ATTR.REQUEST_MODEL, args.model); - const provider = providerFromModel(args.model); - if (provider) span.setAttribute(ATTR.PROVIDER_NAME, provider); - span.updateName(`${OP.CHAT} ${args.model}`); - } - span.end(args.endedAt); -} - -type AssistantTextSpanArgs = { - conversationId: string; - text: string; - startedAt?: TimeInput; - endedAt?: TimeInput; -}; - -/** - * Emit a span representing one text content block from an assistant message. - * Renders in the trace tree between sibling `execute_tool` spans so the - * model's natural interleave (say something → call tool → say something → - * call tool) is visible. Carries the text on `gen_ai.output.messages` so - * Weave's UI shows the content; no token attributes — tokens live on the - * parent chat span. - */ -export function emitAssistantTextSpan( - tracer: Tracer, - parentSpan: Span, - args: AssistantTextSpanArgs, -): void { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.ASSISTANT_TEXT, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_MESSAGES]: jsonStr([ - { role: 'assistant', parts: [{ type: 'text', content: args.text }] }, - ]), - }; - const span = tracer.startSpan( - OP.ASSISTANT_TEXT, - { kind: SpanKind.INTERNAL, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); - span.end(args.endedAt ?? args.startedAt); -} - -type ThinkingSpanArgs = { - conversationId: string; - text: string; - startedAt?: TimeInput; - endedAt?: TimeInput; -}; - -/** - * Emit a span representing one thinking content block. Like - * `emitAssistantTextSpan` but for `{type: 'thinking'}` blocks — Claude's - * private reasoning surfaced in its content stream. Kept distinct so callers - * can hide thinking spans in the UI without hiding ordinary assistant text. - */ -export function emitThinkingSpan( - tracer: Tracer, - parentSpan: Span, - args: ThinkingSpanArgs, -): void { - const attrs: Attributes = { - [ATTR.OPERATION_NAME]: OP.THINKING, - [ATTR.CONVERSATION_ID]: args.conversationId, - [ATTR.OUTPUT_MESSAGES]: jsonStr([ - { role: 'assistant', parts: [{ type: 'thinking', content: args.text }] }, - ]), - }; - const span = tracer.startSpan( - OP.THINKING, - { kind: SpanKind.INTERNAL, attributes: attrs, startTime: args.startedAt }, - ctxWithParent(parentSpan), - ); - span.end(args.endedAt ?? args.startedAt); -} - -/** - * Walk a parsed list of per-message details and emit one chat span per - * assistant message. `parentSpan` is the turn-level span (for the main agent) - * or the spawning Agent tool span (for a subagent). - */ -export function emitChatSpansFromAssistantCalls( - tracer: Tracer, - parentSpan: Span, - conversationId: string, - calls: AssistantCallDetail[], -): void { - for (const c of calls) { - if (!c.model) continue; - const startedAt = parseTimestamp(c.prevTimestamp) ?? parseTimestamp(c.timestamp) ?? new Date(); - const endedAt = parseTimestamp(c.timestamp) ?? new Date(); - emitChatSpan(tracer, parentSpan, { - conversationId, - model: c.model, - startedAt, - endedAt, - usage: c.usage, - reasoningTokens: c.reasoningTokens, - responseId: c.responseId, - finishReasons: c.finishReason ? [c.finishReason] : undefined, - outputMessages: c.contentBlocks.length - ? [{ role: 'assistant', content: assistantBlocksToText(c.contentBlocks), parts: c.contentBlocks }] - : undefined, - }); - } -} - - -function assistantBlocksToText(blocks: unknown[]): string { - return extractAssistantTextBlocks(blocks).join('\n'); -} - -export function promptSnippet(prompt: string, maxLen = 60): string { - return snippet(prompt, maxLen); -} From 95b09b271f32d8f2c3615f0bd61edfd3e9297f8e Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 23:47:47 -0700 Subject: [PATCH 08/11] refactor(parser): one AssistantCallDetail per API response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code splits one response's blocks across transcript lines sharing a message.id; buildTurn now folds them (last line's usage/timestamp — it accompanies stop_reason — first line's prevTimestamp), so consumers see one call per response. The daemon's response-grouping layer collapses to direct lookups and chatSpans.ts goes away, its three survivors inlined as module helpers in daemon.ts, their only consumer. Type-guards (readTranscriptLine) replace the parser's remaining as-casts; dead totalUsage() deleted. Also a comment pass across the touched files: doc blocks cut to the load-bearing why. Co-Authored-By: Claude Fable 5 --- src/chatSpans.ts | 70 --------------- src/daemon.ts | 139 ++++++++++++++++++------------ src/parser.ts | 113 +++++++++++++----------- tests/interleave-handlers.test.ts | 5 +- 4 files changed, 147 insertions(+), 180 deletions(-) delete mode 100644 src/chatSpans.ts diff --git a/src/chatSpans.ts b/src/chatSpans.ts deleted file mode 100644 index 5ba7037..0000000 --- a/src/chatSpans.ts +++ /dev/null @@ -1,70 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -import * as weave from 'weave'; -import type { AssistantCallDetail } from './parser.js'; -import { - ATTR, - buildUsage, - contentBlocksToParts, - providerFromModel, - parseTimestamp, -} 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}`; -} - -/** 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; -} - -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, -): 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) }); -} diff --git a/src/daemon.ts b/src/daemon.ts index 50e8c76..b8ac0af 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -35,18 +35,15 @@ import { setCompactionAttrs, toolDisplayName, assistantOutputMessages, + buildUsage, + contentBlocksToParts, + providerFromModel, + parseTimestamp, snippet, jsonStr, } from './genaiSpans.js'; import { resolveDaemonConfig, daemonConfigFingerprint, missingConfig } from './config.js'; import type { DaemonConfig } from './config.js'; -import { - chatMessageKey, - callsForResponseKey, - parseIsoOrNow, - openChatForGroup, - recordChat, -} from './chatSpans.js'; import { resolvePermissionIfPending, hashPrompt, @@ -70,10 +67,9 @@ import type { AssistantCallDetail } from './parser.js'; // Types // ───────────────────────────────────────────────────────────────────────────── -/** Inbound control message sent directly to the socket (not a hook event). - * `shutdown` stops the daemon; `config-hash` asks it to reply with the - * fingerprint of the config it loaded (used by `status` for drift detection) - * plus the daemon's runtime identity (pid, version, entry path). */ +/** Socket control message (not a hook event): `shutdown` stops the daemon; + * `config-hash` replies with the loaded config's fingerprint (drift + * detection) plus the daemon's identity (pid, version, entry path). */ type ControlMessage = { command: 'shutdown' | 'config-hash'; } @@ -105,10 +101,8 @@ function isControlMessage(payload: unknown): payload is ControlMessage { return cmd === 'shutdown' || cmd === 'config-hash'; } -/** Absolute real path of the daemon's own entry script, resolving the npm bin - * symlink to the actual dist/cli.js (or src/cli.ts under tsx). Lets `status` - * report which build the running daemon is executing. Falls back to the raw - * argv path if it can't be resolved. */ +/** Real path of the daemon's entry script (npm bin symlink resolved), so + * `status` can report which build is actually running. */ function daemonEntryPath(): string { const entry = process.argv[1] ?? ''; try { @@ -118,6 +112,37 @@ function daemonEntryPath(): string { } } +function chatMessageKey(call: AssistantCallDetail, callIdx: number): string { + return call.responseId ?? `idx:${callIdx}`; +} + +function parseIsoOrNow(ts: string | undefined): Date { + return parseTimestamp(ts) ?? new Date(); +} + +function openChat(parent: weave.Turn | weave.SubAgent, call: AssistantCallDetail): weave.LLM | undefined { + if (!call.model) return undefined; + const provider = providerFromModel(call.model); + return parent.startLLM({ + model: call.model, + ...(provider ? { providerName: provider } : {}), + startTime: parseIsoOrNow(call.prevTimestamp ?? call.timestamp), + }); +} + +function recordChat(llm: weave.LLM, call: AssistantCallDetail, agentName?: string): void { + const parts = contentBlocksToParts(call.contentBlocks); + llm.record({ + ...(parts.length ? { outputMessages: [{ role: 'assistant', parts }] } : {}), + usage: buildUsage(call.usage, call.reasoningTokens), + outputType: 'text', + ...(call.responseId ? { responseId: call.responseId } : {}), + ...(call.finishReason ? { finishReasons: [call.finishReason] } : {}), + }); + if (agentName) llm.setAttributes({ [ATTR.AGENT_NAME]: agentName }); + llm.end({ endTime: parseIsoOrNow(call.timestamp) }); +} + // Keep resumed sessions warm across long idle gaps. const INACTIVITY_TIMEOUT_MS = 120 * 60 * 1_000; // 120 minutes // Bound how long stuck in-flight work can keep the daemon alive. @@ -140,7 +165,9 @@ export class GlobalDaemon { * session at SessionStart / reconstruction and cleared (also on SessionEnd). */ private pendingInstructions = new Map(); private tracingEnabled = false; - /** Cross-session team members awaiting TeammateIdle, queued by team + name. */ + /** Cross-session team correlation (coordinator's PreToolUse(Agent) → the + * teammate's TeammateIdle), keyed `${team_name}::${name}`. FIFO queue per + * key so a re-spawned name never overwrites a live span. */ private teamMembers = new Map(); /** Agent calls whose span completion belongs to TeammateIdle, not PostToolUse. */ private teamDispatches = new Set(); @@ -167,10 +194,8 @@ export class GlobalDaemon { this.log('INFO', 'No weave_project / API key configured — tracing disabled'); } - // Bind the socket, exiting cleanly if another daemon already owns it. - // Concurrent hook invocations can each cold-start a daemon, but only one - // can bind; the losers exit (process.exit(0)) and their hook still reaches - // the winner over the socket. See bindSocketWithHerdProtection. + // Concurrent hook invocations can each cold-start a daemon; only one + // binds, the losers exit and their event reaches the winner over the socket. await this.bindSocketWithHerdProtection(); this.running = true; @@ -224,12 +249,9 @@ export class GlobalDaemon { }); } - /** - * Bind the daemon socket, tolerant of a herd of concurrent starts. Listen; on - * EADDRINUSE/EEXIST, re-probe: a live listener means another daemon won → exit - * 0; a stale inode is unlinked and retried. Only a confirmed-stale socket is - * ever unlinked, so a late starter can't delete the winner's live socket. - */ + /** Bind the socket, tolerant of a start herd: on EADDRINUSE/EEXIST, a live + * listener means another daemon won (exit 0); only a confirmed-stale inode + * is unlinked and retried, so a late starter can't delete the winner's. */ private async bindSocketWithHerdProtection(): Promise { const MAX_RECLAIM_ATTEMPTS = 5; for (let attempt = 0; ; attempt++) { @@ -506,9 +528,8 @@ export class GlobalDaemon { const parentPath = path.join(transcriptDir, `${parent}.jsonl`); current = parent; if (!fs.existsSync(parentPath)) { - // Parent transcript not on disk (e.g., resumed across machines). - // Stop here — the recorded parent id is still the best stitching - // key we have, even though we can't verify if IT was a fork too. + // Parent transcript not on disk (e.g. resumed across machines): stop — + // the recorded parent id is still the best stitching key we have. this.log( 'DEBUG', `resolveConversationId: parent transcript not on disk: ${parentPath} — stopping chain walk at ${parent}`, @@ -519,9 +540,8 @@ export class GlobalDaemon { } if (current !== sessionId && source !== 'resume') { - // Fork detected but `source` doesn't say resume — log so the mismatch - // is visible. We still stitch by the chain root because that's the - // correct behavior; this just surfaces an unexpected hook payload. + // Unexpected payload (fork found but source isn't 'resume') — still + // stitch by the chain root; just surface the mismatch. this.log( 'DEBUG', `resolveConversationId: forkedFrom chain found but source='${source}' (expected 'resume') session=${sessionId} root=${current}`, @@ -1002,6 +1022,8 @@ export class GlobalDaemon { return; } + // Other team keys registered but not this one: most likely the + // teammate_name ≠ Agent.name invariant broke — log it, then fall through. if (this.teamMembers.size > 0) { this.log('INFO', `TeammateIdle: no team entry for ${key} (registered: ${[...this.teamMembers.keys()].join(', ')}) — check teammate_name === Agent.name`); } @@ -1027,7 +1049,10 @@ export class GlobalDaemon { this.log('INFO', `TeammateIdle: traced ${agentType} model=${model ?? 'unknown'} path=${candidate.transcriptPath}`); } - /** Find the teammate transcript paired with its coordinator-side Agent call. */ + /** Resolve a teammate's OWN transcript: the coordinator's subagents dir + * holds `agent-.jsonl` + `agent-.meta.json` ({"agentType": name}); + * match by agentType, newest mtime wins (re-spawns). Falls back to the idle + * session's transcript (TeammateIdle.session_id is unreliable). */ private resolveTeammateTranscript( coordinatorTranscriptPath: string, teammateName: string, @@ -1055,7 +1080,27 @@ export class GlobalDaemon { return idleTranscriptPath; } - /** Emit all teammate turns in a fresh trace, then close its dispatch marker. */ + /** Emit one chat span per assistant response under `parent`, reconstructed + * from transcript data (backdated times, usage, ordered output parts). */ + private emitChatSpans( + parent: weave.Turn | weave.SubAgent, + calls: AssistantCallDetail[], + agentName?: string, + ): void { + for (const call of calls) { + const llm = openChat(parent, call); + if (llm) recordChat(llm, call, agentName); + } + } + + /** + * Emit a teammate's whole transcript as its OWN turn trace (the spawning + * coordinator turn has long closed), then close the Subagent marker. The + * coordinator's Conversation handle seeds conversation.id + integration + * identity, neither of which inherits cross-session; the turn is backdated + * to span the transcript so its chat children stay inside its window. + * Returns the teammate's model, if known. + */ private emitTeammateTurnTrace( subAgent: weave.SubAgent, conversation: weave.Conversation, @@ -1102,22 +1147,6 @@ export class GlobalDaemon { return model; } - private emitChatSpans( - parent: weave.Turn | weave.SubAgent, - calls: AssistantCallDetail[], - agentName?: string, - ): void { - const emitted = new Set(); - for (let i = 0; i < calls.length; i++) { - const key = chatMessageKey(calls[i], i); - if (emitted.has(key)) continue; - emitted.add(key); - const group = callsForResponseKey(calls, key); - const llm = openChatForGroup(parent, group); - if (llm) recordChat(llm, group, agentName); - } - } - private async handlePreCompact(sessionId: string, input: PreCompactHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; @@ -1319,10 +1348,9 @@ export class GlobalDaemon { // ── helpers ─────────────────────────────────────────────────────────────── - /** 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 - * before the synthesis line lands. Default budget: 5 × 200ms = 1s. */ + /** Retry the transcript parse while the writer catches up to Stop; when + * `finalAssistantMessage` is set, require the last assistant text to end + * with it. Budget: 5 × 200ms. */ private async parseSessionFileWithRetry( transcript: TranscriptFile, finalAssistantMessage?: string, @@ -1340,12 +1368,9 @@ export class GlobalDaemon { let result: ReturnType = null; for (let i = 0; i < attempts; i++) { result = parseSessionFd(fd); - // Writer caught up: parsed at least one turn AND (no synthesis to verify, - // OR the last assistant call ends with it). if (result?.turns.length && (!expected || lastAssistantTextEndsWith(result, expected))) { return result; } - // No next parse to wait for on the last iteration, so skip the sleep. if (i < attempts - 1) await new Promise(r => setTimeout(r, delayMs)); } return result; diff --git a/src/parser.ts b/src/parser.ts index 71ec2b0..08fb5a1 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -12,23 +12,22 @@ export interface UsageSummary { } /** - * 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. + * One assistant API response within a turn. Claude Code splits a response's + * thinking / text / tool_use blocks across transcript lines sharing a + * `message.id`; `buildTurn` folds those back into a single entry. */ export interface AssistantCallDetail { - timestamp: string; // ISO timestamp of the assistant message - prevTimestamp?: string; // ISO timestamp of preceding transcript line (proxy for "request started") + timestamp: string; // ISO timestamp of the response's last transcript line + prevTimestamp?: string; // ISO timestamp of the line preceding it (proxy for "request started") model?: string; - usage: UsageSummary; // per-call usage + usage: UsageSummary; reasoningTokens?: number; // reasoning/thinking tokens, if any - contentBlocks: unknown[]; // raw assistant content blocks (text, tool_use, thinking, ...) + contentBlocks: unknown[]; // raw content blocks in transcript order (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[]; @@ -38,7 +37,7 @@ export interface ParsedSession { turns: Turn[]; } -export function rawToUsageSummary(raw: Record): UsageSummary { +function rawToUsageSummary(raw: Record): UsageSummary { return { input_tokens: raw['input_tokens'] ?? 0, output_tokens: raw['output_tokens'] ?? 0, @@ -47,15 +46,6 @@ export function rawToUsageSummary(raw: Record): UsageSummary { }; } -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 function parseSessionFile(filePath: string): ParsedSession | null { return parseSessionReader(() => fs.readFileSync(filePath, 'utf8')); } @@ -107,20 +97,19 @@ function buildSession(lines: unknown[]): ParsedSession { 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; + const { message, type, role: rawRole, timestamp } = readTranscriptLine(line); + const role = rawRole ?? type; if (role === 'assistant') { - currentAssistantLines.push({ line: entry, prevTimestamp }); + // `role === 'assistant'` implies the line is an object (it carried either + // a `message.role` or a top-level `type`), so the {} fallback is unreachable. + currentAssistantLines.push({ line: isObject(line) ? line : {}, prevTimestamp }); } 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'); + const hasText = typeof rawContent === 'string' + || (Array.isArray(rawContent) ? rawContent : []).some(isTextBlock); if (hasText && currentAssistantLines.length > 0) { turns.push(buildTurn(currentAssistantLines)); currentAssistantLines = []; @@ -138,17 +127,16 @@ function buildSession(lines: unknown[]): ParsedSession { } function buildTurn(assistantLines: AssistantLine[]): Turn { - const calls: AssistantCallDetail[] = assistantLines.map(({ line, prevTimestamp }) => { - const message = line['message'] as Record | undefined; + const calls: AssistantCallDetail[] = []; + for (const { line, prevTimestamp } of assistantLines) { + const { message } = readTranscriptLine(line); 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; 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`. + // A bare-string `content` is the legacy single-text format; synthesize a + // text block so downstream sees a uniform block list (missing/other → []). const contentBlocks: unknown[] = Array.isArray(rawContent) ? (rawContent as unknown[]) : typeof rawContent === 'string' @@ -158,29 +146,27 @@ function buildTurn(assistantLines: AssistantLine[]): Turn { const stopReason = (message?.['stop_reason'] ?? message?.['finish_reason']) as string | undefined; const timestamp = (line['timestamp'] as string | undefined) ?? ''; - return { - timestamp, - prevTimestamp, - model, - usage, - 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(); + // Fold split lines (shared message.id) into one call per API response. + // Split lines duplicate the response usage — keep the last line's, which + // accompanies stop_reason; keep the first line's prevTimestamp as start. + const prev = calls.at(-1); + if (responseId && prev?.responseId === responseId) { + prev.contentBlocks.push(...contentBlocks); + prev.timestamp = timestamp; + prev.usage = usage; + prev.reasoningTokens = reasoningTokens ?? prev.reasoningTokens; + prev.model ??= model; + prev.finishReason ??= stopReason; + continue; + } + calls.push({ timestamp, prevTimestamp, model, usage, reasoningTokens, contentBlocks, responseId, finishReason: stopReason }); + } + + const model = calls.filter(call => call.model).at(-1)?.model; const texts = calls.flatMap(call => extractAssistantTextBlocks(call.contentBlocks)); return { - totalUsage: () => totalUsageValue, primaryModel: () => model, textBlocks: () => texts, assistantCalls: () => calls, @@ -200,6 +186,31 @@ function isObject(v: unknown): v is Record { return typeof v === 'object' && v !== null; } +/** Structural shape of a single JSONL transcript line we care about. */ +type TranscriptLine = { + message?: Record; + type?: string; + role?: string; + timestamp?: string; +}; + +/** + * Decode one raw JSONL transcript line into the fields the parser reads, + * narrowing each with a runtime check instead of an `as` cast. `role` is the + * raw `message.role` (callers fall back to `type` for lines that carry only a + * top-level `type`). Fields absent or of the wrong type come back undefined. + */ +function readTranscriptLine(line: unknown): TranscriptLine { + if (!isObject(line)) return {}; + const message = isObject(line['message']) ? line['message'] : undefined; + return { + message, + type: typeof line['type'] === 'string' ? line['type'] : undefined, + role: typeof message?.['role'] === 'string' ? message['role'] : undefined, + timestamp: typeof line['timestamp'] === 'string' ? line['timestamp'] : undefined, + }; +} + export function isTextBlock(block: unknown): block is TextBlock { return isObject(block) && block['type'] === 'text' && typeof block['text'] === 'string'; } diff --git a/tests/interleave-handlers.test.ts b/tests/interleave-handlers.test.ts index 8282eeb..b4b42b0 100644 --- a/tests/interleave-handlers.test.ts +++ b/tests/interleave-handlers.test.ts @@ -12,6 +12,7 @@ import { ATTR } from '../src/genaiSpans.ts'; import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; const USAGE = { input_tokens: 100, output_tokens: 1508, cache_read_input_tokens: 400 }; +const TOOL_CALL_ID = 'gen_ai.tool.call.id'; function aLine(id: string, ts: string, block: Record, stop?: string) { return { @@ -86,7 +87,7 @@ test('handlers: Stop emits each chat once; text + tool output parts preserve int const turn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); assert.ok(turn, 'main-agent turn exported'); - const tool = spans.find(s => s.attributes[ATTR.TOOL_CALL_ID] === 'tool_1'); + const tool = spans.find(s => s.attributes[TOOL_CALL_ID] === 'tool_1'); assert.ok(tool, 'execute_tool span exported'); assert.ok(childrenOf(spans, turn).includes(tool), 'main-agent tool nests directly under the turn'); @@ -141,7 +142,7 @@ test('handlers: Stop emits multiple tool-calling responses once under the turn', assert.ok(turn, 'main-agent turn exported'); const toolIds = childrenOf(spans, turn) .filter(s => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool') - .map(s => s.attributes[ATTR.TOOL_CALL_ID]) + .map(s => s.attributes[TOOL_CALL_ID]) .sort(); assert.deepEqual(toolIds, ['tool_A', 'tool_B'], 'both main-agent tools nest directly under the turn'); } finally { From 41dc9b0341728f991ed5dc3d1a45c25a7ac953c2 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 23:48:48 -0700 Subject: [PATCH 09/11] fix(daemon): track recursive subagent dispatches An Agent dispatch from WITHIN a subagent (agent_id set) fell through to the generic tool branch: no tracker, so its SubagentStart became an orphan flattened under the turn with one ERROR log per spawn (the bug predates the SDK swap; reproduced live by a recursive depth test). Parent the marker under the spawning subagent's own marker instead, so recursive spawns keep their depth; prompt-hash correlation and PostToolUse settling work unchanged. Co-Authored-By: Claude Fable 5 --- src/daemon.ts | 10 +++--- tests/subagent-nesting.test.ts | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index b8ac0af..ebacec3 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -689,16 +689,14 @@ export class GlobalDaemon { const toolInput = (input.tool_input ?? {}) as Record; if (toolName === 'Agent' && toolInput['subagent_type']) { - const spawnParent: weave.Turn | weave.SubAgent | undefined = agentId - ? session.activeSubagents.get(agentId)?.subAgent ?? session.currentTurn - : session.currentTurn; - if (!spawnParent) { - this.log('ERROR', `PreToolUse(Agent): no parent for session=${sessionId}`); + const spawner = agentId ? session.activeSubagents.get(agentId)?.subAgent : session.currentTurn; + if (!spawner) { + this.log('ERROR', `PreToolUse(Agent): no parent for session=${sessionId}${agentId ? ` agent=${agentId}` : ''}`); return; } const subagentType = toolInput['subagent_type'] as string; const prompt = typeof toolInput['prompt'] === 'string' ? toolInput['prompt'] : ''; - const subAgent = spawnParent.startSubagent({ name: subagentType, agentVersion: VERSION, startTime: new Date() }); + const subAgent = spawner.startSubagent({ name: subagentType, agentVersion: VERSION, startTime: new Date() }); if (prompt) { subAgent.setAttributes({ [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]) }); } diff --git a/tests/subagent-nesting.test.ts b/tests/subagent-nesting.test.ts index 47d09f6..266294e 100644 --- a/tests/subagent-nesting.test.ts +++ b/tests/subagent-nesting.test.ts @@ -135,3 +135,67 @@ test('ambiguous correlation does not manufacture a duplicate subagent marker', a fs.rmSync(dir, { recursive: true, force: true }); } }); + +test('recursive dispatch: a subagent spawning a subagent nests the child under its own marker', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-nest-002'; + const outerPrompt = 'do the outer task'; + const innerPrompt = 'do the inner task'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest2-')); + const coordPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(coordPath, userLine('kick off') + '\n'); + for (const [agentId, prompt] of [['outer-1', outerPrompt], ['inner-1', innerPrompt]] as const) { + const transcript = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(transcript), { recursive: true }); + fs.writeFileSync(transcript, userLine(prompt) + '\n' + + assistantLine('done', { input_tokens: 10, output_tokens: 5 }) + '\n'); + } + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tu-outer', + tool_name: 'Agent', tool_input: { subagent_type: 'general-purpose', prompt: outerPrompt }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'outer-1', agent_type: 'general-purpose' }); + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'outer-1', tool_use_id: 'tu-inner', + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: innerPrompt }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'inner-1', agent_type: 'Explore' }); + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'inner-1', tool_use_id: 'tu-read', + tool_name: 'Read', tool_input: { file_path: '/f.ts' }, + }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'inner-1', tool_use_id: 'tu-read', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'inner-1', agent_transcript_path: path.join(dir, sid, 'subagents', 'agent-inner-1.jsonl'), agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'outer-1', tool_use_id: 'tu-inner', tool_response: 'inner done' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'outer-1', agent_transcript_path: path.join(dir, sid, 'subagents', 'agent-outer-1.jsonl'), agent_type: 'general-purpose' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-outer', tool_response: 'outer done' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const outer = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && s.attributes[ATTR.AGENT_NAME] === 'general-purpose'); + const inner = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(turn && outer && inner, 'turn + both markers exported'); + assert.equal(spanParentId(outer), turn.spanContext().spanId, 'outer marker nests under the turn'); + assert.equal(spanParentId(inner), outer.spanContext().spanId, 'inner marker nests under the outer marker'); + assert.equal(inner.attributes[ATTR.AGENT_ID], 'inner-1', 'inner marker matched'); + assert.equal(inner.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined, 'no orphan fallback'); + + const readTool = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool' + && s.attributes['gen_ai.tool.name'] === 'Read'); + assert.ok(readTool, 'inner tool exported'); + assert.equal(spanParentId(readTool), inner.spanContext().spanId, 'inner tool nests under inner marker'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 7397c87bec532410cc5609f56477652010eda762 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Sat, 18 Jul 2026 00:09:33 -0700 Subject: [PATCH 10/11] test: consolidate the suite into per-concern files 24 test files fold into 8 (chat-spans, turn-spans, subagents, daemon-lifecycle, config, install, plus status and stale-daemon-socket untouched). Every test case and assertion carries over verbatim; identical local fixtures/builders dedupe to one copy per file, and anecdotal comments in the merged files are trimmed. 85 tests, unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/daemon.ts | 7 +- tests/chat-spans.test.ts | 388 ++++++ tests/config-agent-name.test.ts | 37 - tests/config-set-masks-secrets.test.ts | 30 - .../{config-drift.test.ts => config.test.ts} | 114 +- tests/daemon-idle-inflight.test.ts | 75 -- tests/daemon-lifecycle.test.ts | 313 +++++ tests/daemon-session-reconstruction.test.ts | 68 - tests/daemon-shutdown-finalizes-turn.test.ts | 119 -- tests/daemon-startup-race.test.ts | 69 - tests/daemon-subagent-recovery.test.ts | 103 -- tests/genai-span-usage-tokens.test.ts | 73 -- tests/install-source-local.test.ts | 125 -- tests/install.test.ts | 229 ++++ tests/interleave-handlers.test.ts | 181 --- tests/interleave-split-lines.test.ts | 79 -- tests/interleaved-assistant-spans.test.ts | 51 - tests/interrupted-turn.test.ts | 73 -- tests/marketplace-ref-drift.test.ts | 110 -- tests/restart.test.ts | 117 -- tests/subagent-nesting.test.ts | 201 --- tests/subagents.test.ts | 1131 +++++++++++++++++ tests/system-instructions-integration.test.ts | 153 --- tests/teammate-idle.test.ts | 739 ----------- tests/trace-base-url.test.ts | 36 - tests/turn-span-agent-name.test.ts | 42 - tests/turn-span-integration.test.ts | 75 -- tests/turn-spans.test.ts | 365 ++++++ 28 files changed, 2536 insertions(+), 2567 deletions(-) create mode 100644 tests/chat-spans.test.ts delete mode 100644 tests/config-agent-name.test.ts delete mode 100644 tests/config-set-masks-secrets.test.ts rename tests/{config-drift.test.ts => config.test.ts} (60%) delete mode 100644 tests/daemon-idle-inflight.test.ts create mode 100644 tests/daemon-lifecycle.test.ts delete mode 100644 tests/daemon-session-reconstruction.test.ts delete mode 100644 tests/daemon-shutdown-finalizes-turn.test.ts delete mode 100644 tests/daemon-startup-race.test.ts delete mode 100644 tests/daemon-subagent-recovery.test.ts delete mode 100644 tests/genai-span-usage-tokens.test.ts delete mode 100644 tests/install-source-local.test.ts create mode 100644 tests/install.test.ts delete mode 100644 tests/interleave-handlers.test.ts delete mode 100644 tests/interleave-split-lines.test.ts delete mode 100644 tests/interleaved-assistant-spans.test.ts delete mode 100644 tests/interrupted-turn.test.ts delete mode 100644 tests/marketplace-ref-drift.test.ts delete mode 100644 tests/restart.test.ts delete mode 100644 tests/subagent-nesting.test.ts create mode 100644 tests/subagents.test.ts delete mode 100644 tests/system-instructions-integration.test.ts delete mode 100644 tests/teammate-idle.test.ts delete mode 100644 tests/trace-base-url.test.ts delete mode 100644 tests/turn-span-agent-name.test.ts delete mode 100644 tests/turn-span-integration.test.ts create mode 100644 tests/turn-spans.test.ts diff --git a/src/daemon.ts b/src/daemon.ts index ebacec3..e459d02 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -697,9 +697,10 @@ export class GlobalDaemon { const subagentType = toolInput['subagent_type'] as string; const prompt = typeof toolInput['prompt'] === 'string' ? toolInput['prompt'] : ''; const subAgent = spawner.startSubagent({ name: subagentType, agentVersion: VERSION, startTime: new Date() }); - if (prompt) { - subAgent.setAttributes({ [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]) }); - } + subAgent.setAttributes({ + [ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID]: toolUseId, + ...(prompt ? { [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: prompt }]) } : {}), + }); const call: PendingSubagentCall = { kind: 'subagent', subagentType, diff --git a/tests/chat-spans.test.ts b/tests/chat-spans.test.ts new file mode 100644 index 0000000..5b6d8c6 --- /dev/null +++ b/tests/chat-spans.test.ts @@ -0,0 +1,388 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// Chat-span behavior for assistant responses: each response is a single `chat` +// span whose ordered `gen_ai.output.messages` parts carry the response's blocks +// (thinking / text / tool_use) in transcript order. Tool executions are sibling +// spans under the owning agent turn. This file covers: +// +// - contentBlocksToParts: the pure formatting layer (block -> ordered part). +// - Handler integration (via routeEvent): Stop reconstructs response spans, +// SessionEnd recovers an interrupted response, and tools stay under the turn. +// - Split-line reconstruction: one API response written as multiple transcript +// lines (one per content block, shared message id) is reconstructed without +// dropping blocks and with usage counted once. +// - Usage tokens: `gen_ai.usage.input_tokens` includes cache read + creation +// (OTel semconv), fixing the >100% cache-hit-rate bug. + +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 type { InMemorySpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { ATTR, contentBlocksToParts } from '../src/genaiSpans.ts'; +import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; + +const USAGE = { input_tokens: 100, output_tokens: 1508, cache_read_input_tokens: 400 }; +const TOOL_CALL_ID = 'gen_ai.tool.call.id'; + +/** One assistant transcript line carrying a single content block, mirroring how + * Claude Code splits a response. `usage` is the FULL message usage, duplicated + * on every line of the same response (verified against real transcripts). */ +function aLine(id: string, ts: string, block: Record, stop?: string) { + return { + type: 'assistant', + timestamp: ts, + message: { + role: 'assistant', + id, + model: 'claude-opus-4-8', + content: [block], + usage: USAGE, + ...(stop ? { stop_reason: stop } : {}), + }, + }; +} + +/** One tool-less assistant transcript line carrying `text` and a custom `usage` + * (used by the usage-token tests, which vary usage per case). */ +function usageLine(id: string, ts: string, text: string, usage: Record) { + return { + type: 'assistant', + timestamp: ts, + message: { role: 'assistant', id, model: 'claude-opus-4-7', content: [{ type: 'text', text }], usage, stop_reason: 'end_turn' }, + }; +} + +function userText(ts: string, text: string) { + return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; +} + +/** Incrementally-appendable transcript. Appends after SessionStart are visible + * (getFd caches one fd, re-stat per read). Path must be inside $HOME. */ +function makeTranscript(sessionId: string): { file: string; append: (line: unknown) => void; dir: string } { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-itest-')); + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, ''); + return { + file, + dir, + append: (line: unknown) => fs.appendFileSync(file, JSON.stringify(line) + '\n'), + }; +} + +function chatByResponse(spans: ReadableSpan[], id: string): ReadableSpan[] { + return spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === 'chat' && s.attributes[ATTR.RESPONSE_ID] === id); +} +function partsOf(span: ReadableSpan): Array> { + const msgs = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string) as Array<{ parts?: Array> }>; + return msgs[0]?.parts ?? []; +} + +/** Drive one turn whose single tool-less assistant response carries `usage`, + * and return the exported `chat` span. */ +async function chatSpanForUsage(exporter: InMemorySpanExporter, sid: string, usage: Record): Promise { + exporter.reset(); + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-usage-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, [ + JSON.stringify(userText('2026-01-01T00:00:00Z', 'do it')), + JSON.stringify(usageLine('msgA', '2026-01-01T00:00:01Z', 'all done', usage)), + ].join('\n') + '\n'); + const d = makeGenaiDaemon(); + try { + 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 flushWeave(); + const chat = exporter.getFinishedSpans().find(s => s.name === 'chat'); + assert.ok(chat, 'chat span should be emitted'); + return chat; + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +// --- contentBlocksToParts: pure block -> ordered part mapping ---------------- +// End-to-end output-part interleave and turn-owned tool spans are covered by +// the handler and split-line tests below. + +test('contentBlocksToParts: interleaved text and tool_use map to ordered parts', () => { + const parts = contentBlocksToParts([ + { type: 'text', text: 'Now let me add the method' }, + { type: 'tool_use', id: 'toolu_01', name: 'Edit', input: { file_path: '/foo.ts' } }, + { type: 'text', text: 'Now let me add the test' }, + { type: 'tool_use', id: 'toolu_02', name: 'Edit', input: { file_path: '/foo.test.ts' } }, + { type: 'text', text: 'All done' }, + ]); + + assert.deepEqual(parts, [ + { type: 'text', content: 'Now let me add the method' }, + { type: 'tool_call', toolCallId: 'toolu_01', toolName: 'Edit', arguments: '{"file_path":"/foo.ts"}' }, + { type: 'text', content: 'Now let me add the test' }, + { type: 'tool_call', toolCallId: 'toolu_02', toolName: 'Edit', arguments: '{"file_path":"/foo.test.ts"}' }, + { type: 'text', content: 'All done' }, + ]); +}); + +test('contentBlocksToParts: thinking maps to a reasoning part; redacted_thinking to a placeholder', () => { + const parts = contentBlocksToParts([ + { type: 'thinking', thinking: 'Let me reason about this...' }, + { type: 'redacted_thinking', data: 'ENCRYPTED' }, + { type: 'text', text: 'answer' }, + ]); + + assert.deepEqual(parts, [ + { type: 'reasoning', content: 'Let me reason about this...' }, + { type: 'reasoning', content: '[redacted]' }, + { type: 'text', content: 'answer' }, + ]); +}); + +test('contentBlocksToParts: empty text and empty thinking are skipped', () => { + const parts = contentBlocksToParts([ + { type: 'text', text: ' ' }, + { type: 'thinking', thinking: '' }, + { type: 'text', text: 'kept' }, + ]); + assert.deepEqual(parts, [{ type: 'text', content: 'kept' }]); +}); + +// --- Handlers state machine (driven through routeEvent) ---------------------- + +test('handlers: Stop emits each chat once; text + tool output parts preserve interleave', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-A'; + const { file, append, dir } = makeTranscript(sid); + append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); + + const d = makeGenaiDaemon(); + try { + 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 the thing' }); + + // Response msgA: text then tool_use (split lines, shared id), flushed + // before the tool's PreToolUse fires. + append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' })); + append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); + + // msgB: text-only (no tool_use -> no PreToolUse; back-filled at Stop). + append(aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn')); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + + // One chat span per response. + assert.equal(chatByResponse(spans, 'msgA').length, 1, 'one chat span for msgA'); + assert.equal(chatByResponse(spans, 'msgB').length, 1, 'one chat span for msgB'); + + const chatA = chatByResponse(spans, 'msgA')[0]; + // msgA text + tool_use are ordered output parts on the chat span. + assert.deepEqual(partsOf(chatA), [ + { type: 'text', content: 'first I will edit' }, + { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, + ], 'msgA: text then tool_call, in transcript order, as output parts'); + assert.equal(childrenOf(spans, chatA).length, 0, 'chat span does not own tool execution'); + + const turn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.ok(turn, 'main-agent turn exported'); + const tool = spans.find(s => s.attributes[TOOL_CALL_ID] === 'tool_1'); + assert.ok(tool, 'execute_tool span exported'); + assert.ok(childrenOf(spans, turn).includes(tool), 'main-agent tool nests directly under the turn'); + + // Usage counted once for the split response. + assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); + assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); + + const chatB = chatByResponse(spans, 'msgB')[0]; + assert.deepEqual(partsOf(chatB), [{ type: 'text', content: 'all done' }]); + assert.equal(childrenOf(spans, chatB).length, 0, 'tool-less msgB has no execute_tool children'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('handlers: Stop emits multiple tool-calling responses once under the turn', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-B'; + const { file, append, dir } = makeTranscript(sid); + append(userText('2026-01-01T00:00:00.000Z', 'do two things')); + + const d = makeGenaiDaemon(); + try { + 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 two things' }); + + append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'editing A' })); + append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_A', name: 'Edit', input: {} }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_A', tool_name: 'Edit', tool_input: {} }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_A', tool_response: 'ok' }); + + // Second response with its own tool_use. + append(aLine('msgB', '2026-01-01T00:00:05.000Z', { type: 'text', text: 'editing B' })); + append(aLine('msgB', '2026-01-01T00:00:06.000Z', { type: 'tool_use', id: 'tool_B', name: 'Edit', input: {} }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_B', tool_name: 'Edit', tool_input: {} }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_B', tool_response: 'ok' }); + + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + assert.equal(chatByResponse(spans, 'msgA').length, 1, 'msgA emitted exactly once'); + assert.equal(chatByResponse(spans, 'msgB').length, 1, 'msgB emitted exactly once'); + + for (const id of ['msgA', 'msgB']) { + const chat = chatByResponse(spans, id)[0]; + const parts = partsOf(chat).map(p => p['type']); + assert.deepEqual(parts, ['text', 'tool_call'], `${id}: text + tool_call output parts`); + assert.equal(childrenOf(spans, chat).length, 0, `${id}: chat does not own tool execution`); + } + + const turn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.ok(turn, 'main-agent turn exported'); + const toolIds = childrenOf(spans, turn) + .filter(s => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool') + .map(s => s.attributes[TOOL_CALL_ID]) + .sort(); + assert.deepEqual(toolIds, ['tool_A', 'tool_B'], 'both main-agent tools nest directly under the turn'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('handlers: SessionEnd finalizes a still-open chat span with its output + usage (not an empty orphan)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-C'; + const { file, append, dir } = makeTranscript(sid); + append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); + + const d = makeGenaiDaemon(); + try { + 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 the thing' }); + + append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' })); + append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: {} }); + + // No Stop - session ends mid-turn with the chat span still open. + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const chatA = chatByResponse(spans, 'msgA')[0]; + assert.ok(chatA, 'chat span for msgA was finalized at SessionEnd (has a response id)'); + // Finalized, not an empty orphan: usage + text output part are present. + assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508, 'usage recovered at SessionEnd'); + const types = partsOf(chatA).map(p => p['type']); + assert.ok(types.includes('text'), 'assistant text output part recovered at SessionEnd'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- Split-line reconstruction ----------------------------------------------- +// Claude Code writes a single assistant API response as MULTIPLE transcript +// lines, one per content block (thinking / text / tool_use), all sharing one +// `message.id`. An earlier version walked `blockIdx` within a single call's +// contentBlocks and dropped the text/thinking blocks against real (split) +// transcripts. This drives the reconstruction through the Stop handler. + +test('reconstruction: split thinking/redacted_thinking/text/tool_use lines interleave as ordered parts, none dropped, usage once', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + // One turn: + // response msgA: thinking, redacted_thinking, text, tool_use (4 split lines, shared id) + // response msgB: text-only (no tool_use) + const sid = 'sess-split'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-splitlines-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, [ + userText('2026-01-01T00:00:00.000Z', 'do the thing'), + aLine('msgA', '2026-01-01T00:00:01.000Z', { type: 'thinking', thinking: 'let me think' }), + aLine('msgA', '2026-01-01T00:00:01.500Z', { type: 'redacted_thinking', data: 'ENCRYPTED' }), + aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' }), + aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use'), + aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn'), + ].map(l => JSON.stringify(l)).join('\n') + '\n'); + + const d = makeGenaiDaemon(); + try { + 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 the thing' }); + // No PreToolUse fires here; both responses are back-filled at Stop, which + // is the reconstruction path this test exercises. + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const chatA = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgA'); + assert.ok(chatA, 'chat span for msgA emitted'); + + // thinking, redacted_thinking (as a [redacted] reasoning part), text, and + // tool_use are NOT dropped and appear in transcript order as parts. + assert.deepEqual(partsOf(chatA), [ + { type: 'reasoning', content: 'let me think' }, + { type: 'reasoning', content: '[redacted]' }, + { type: 'text', content: 'first I will edit' }, + { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, + ], 'thinking, redacted placeholder, text, tool_call: all present, in order'); + + // Usage counted ONCE for the response (not 4x for the 4 split lines). + assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); + assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); + + // The tool-less final message still renders as its own chat span. + const chatB = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgB'); + assert.ok(chatB, 'chat span for tool-less msgB emitted'); + assert.deepEqual(partsOf(chatB), [{ type: 'text', content: 'all done' }]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// --- Usage tokens (cache-hit-rate bug: Weave UI showed >100%) ---------------- +// Anthropic splits prompt usage into three disjoint fields (input_tokens, +// cache_read_input_tokens, cache_creation_input_tokens). OTel GenAI semconv +// requires `gen_ai.usage.input_tokens` to be the TOTAL prompt size. Spec ref: +// https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md +// Driven end-to-end so the assertion is on the exported `chat` span's attributes. + +test('chat span: input_tokens includes cache_read + cache_creation (OTel semconv)', async () => { + const exporter = await initWeaveInMemory(); + const chatSpan = await chatSpanForUsage(exporter, 'sess-usage-1', { + input_tokens: 7600, + output_tokens: 528, + cache_read_input_tokens: 36500, + cache_creation_input_tokens: 4100, + }); + + // Total prompt = 7600 + 36500 + 4100 = 48200. + // Without this fix the value was 7600, making cache_read/input_tokens = 480%. + assert.equal( + chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], + 48200, + 'gen_ai.usage.input_tokens must include cache_read and cache_creation per OTel semconv', + ); + // Cache fields are reported separately and unchanged. + assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_READ_INPUT_TOKENS], 36500); + assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS], 4100); + assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 528); +}); + +test('chat span: input_tokens unchanged when no cache fields present', async () => { + const exporter = await initWeaveInMemory(); + const chatSpan = await chatSpanForUsage(exporter, 'sess-usage-2', { input_tokens: 1000, output_tokens: 200 }); + + assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1000); + assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_READ_INPUT_TOKENS], undefined); + assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS], undefined); +}); diff --git a/tests/config-agent-name.test.ts b/tests/config-agent-name.test.ts deleted file mode 100644 index 055c08f..0000000 --- a/tests/config-agent-name.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// `config` support for the customizable top-level agent name. -// The seed settings file deliberately OMITS agent_name to mirror an install -// from before the field existed; `get` must still resolve to the default -// rather than error with "Unknown key". - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import * as fs from 'node:fs'; -import { seedConfigHome, runCli } from './helpers.ts'; - -test('config agent_name: default, set, get/show, and env-var override', async () => { - const { home } = seedConfigHome('agentname'); - try { - // get on a file missing the key resolves to the default, not an error. - const def = await runCli(home, ['config', 'get', 'agent_name']); - assert.equal(def.code, 0); - assert.equal(def.stdout.trim(), 'claude-code'); - - // set, then get/show reflect the value (surrounding whitespace is trimmed - // at resolution time, so the effective value is clean). - const set = await runCli(home, ['config', 'set', 'agent_name', ' my-team-bot ']); - assert.equal(set.code, 0); - assert.equal((await runCli(home, ['config', 'get', 'agent_name'])).stdout.trim(), 'my-team-bot'); - assert.match((await runCli(home, ['config', 'show'])).stdout, /agent_name:\s+my-team-bot \[settings\.json\]/); - - // WEAVE_AGENT_NAME overrides the settings file. - const env = { WEAVE_AGENT_NAME: 'from-env' }; - assert.equal((await runCli(home, ['config', 'get', 'agent_name'], env)).stdout.trim(), 'from-env'); - assert.match((await runCli(home, ['config', 'show'], env)).stdout, /agent_name:\s+from-env \[WEAVE_AGENT_NAME env var\]/); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } -}); diff --git a/tests/config-set-masks-secrets.test.ts b/tests/config-set-masks-secrets.test.ts deleted file mode 100644 index eaac2c2..0000000 --- a/tests/config-set-masks-secrets.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// `config set` must mask wandb_api_key in stdout (it didn't — see #66) but -// must still echo non-sensitive keys in full and persist the full secret. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import * as fs from 'node:fs'; -import { seedConfigHome, runCli } from './helpers.ts'; - -const SECRET = 'wandb_v1_SUPERSECRETvalueDoNotLeak0123456789'; - -test('config set: masks wandb_api_key, echoes weave_project in full', async () => { - const { home, settingsFile } = seedConfigHome('cfgset-mask'); - try { - const apiKey = await runCli(home, ['config', 'set', 'wandb_api_key', SECRET]); - assert.equal(apiKey.code, 0); - assert.equal(apiKey.stdout.includes(SECRET), false, `stdout leaked the secret:\n${apiKey.stdout}`); - assert.match(apiKey.stdout, /wand…/); - assert.equal(JSON.parse(fs.readFileSync(settingsFile, 'utf8')).wandb_api_key, SECRET); - - const project = await runCli(home, ['config', 'set', 'weave_project', 'my-entity/my-project']); - assert.equal(project.code, 0); - assert.match(project.stdout, /my-entity\/my-project/); - } finally { - fs.rmSync(home, { recursive: true, force: true }); - } -}); diff --git a/tests/config-drift.test.ts b/tests/config.test.ts similarity index 60% rename from tests/config-drift.test.ts rename to tests/config.test.ts index 46ee18a..e87860a 100644 --- a/tests/config-drift.test.ts +++ b/tests/config.test.ts @@ -2,11 +2,13 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Config-drift detection over the socket: `status` asks the live daemon for the -// fingerprint of the config it loaded, and warns when settings.json now -// resolves to something different. Nothing is written to disk. -// -// Sockets live under /tmp (macOS 104-char path cap); see stale-daemon-socket.test.ts. +// Consolidated config-resolution / CLI tests. Covers: +// - agent_name resolution: default, set, get/show, WEAVE_AGENT_NAME override +// - `config set` masking of wandb_api_key vs. full echo of non-secret keys +// - config-drift detection over the socket (fingerprint, fake daemon, real daemon) +// - trace base URL resolution across WANDB_BASE_URL / WF_TRACE_SERVER_URL combos +// Each test keeps its original setup/teardown; the socket-drift and base-url +// helpers are distinct per concern and are not shared. import { test, suite, before, after } from 'node:test'; import assert from 'node:assert/strict'; @@ -17,6 +19,105 @@ import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { resolveDaemonConfig, daemonConfigFingerprint } from '../src/config.ts'; +import { seedConfigHome, runCli } from './helpers.ts'; + +// ───────────────────────────────────────────────────────────────────────────── +// agent_name resolution +// +// `config` support for the customizable top-level agent name. +// The seed settings file deliberately OMITS agent_name to mirror an install +// from before the field existed; `get` must still resolve to the default +// rather than error with "Unknown key". + +test('config agent_name: default, set, get/show, and env-var override', async () => { + const { home } = seedConfigHome('agentname'); + try { + // get on a file missing the key resolves to the default, not an error. + const def = await runCli(home, ['config', 'get', 'agent_name']); + assert.equal(def.code, 0); + assert.equal(def.stdout.trim(), 'claude-code'); + + // set, then get/show reflect the value (surrounding whitespace is trimmed + // at resolution time, so the effective value is clean). + const set = await runCli(home, ['config', 'set', 'agent_name', ' my-team-bot ']); + assert.equal(set.code, 0); + assert.equal((await runCli(home, ['config', 'get', 'agent_name'])).stdout.trim(), 'my-team-bot'); + assert.match((await runCli(home, ['config', 'show'])).stdout, /agent_name:\s+my-team-bot \[settings\.json\]/); + + // WEAVE_AGENT_NAME overrides the settings file. + const env = { WEAVE_AGENT_NAME: 'from-env' }; + assert.equal((await runCli(home, ['config', 'get', 'agent_name'], env)).stdout.trim(), 'from-env'); + assert.match((await runCli(home, ['config', 'show'], env)).stdout, /agent_name:\s+from-env \[WEAVE_AGENT_NAME env var\]/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// `config set` secret masking +// +// `config set` must mask wandb_api_key in stdout (it didn't, see #66) but +// must still echo non-sensitive keys in full and persist the full secret. + +const SECRET = 'wandb_v1_SUPERSECRETvalueDoNotLeak0123456789'; + +test('config set: masks wandb_api_key, echoes weave_project in full', async () => { + const { home, settingsFile } = seedConfigHome('cfgset-mask'); + try { + const apiKey = await runCli(home, ['config', 'set', 'wandb_api_key', SECRET]); + assert.equal(apiKey.code, 0); + assert.equal(apiKey.stdout.includes(SECRET), false, `stdout leaked the secret:\n${apiKey.stdout}`); + assert.match(apiKey.stdout, /wand…/); + assert.equal(JSON.parse(fs.readFileSync(settingsFile, 'utf8')).wandb_api_key, SECRET); + + const project = await runCli(home, ['config', 'set', 'weave_project', 'my-entity/my-project']); + assert.equal(project.code, 0); + assert.match(project.stdout, /my-entity\/my-project/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// trace base URL resolution +// +// The daemon exports OTLP spans to the Weave trace server, not the wandb API +// host. SaaS `api.wandb.ai` has no OTLP route, so setting `WANDB_BASE_URL` to +// it (the wandb SDK default) must not silently misroute traces. + +const SETTINGS = { weave_project: 'e/p', wandb_api_key: 'k' }; +const baseUrlFor = (env: Record): string => + resolveDaemonConfig(SETTINGS as never, env).baseUrl; + +test('trace base URL resolution across env combinations', () => { + // Unset → SaaS trace server default. + assert.equal(baseUrlFor({}), 'https://trace.wandb.ai'); + + // SaaS API host (and trailing-slash / scheme-case variants) remap to the + // trace server rather than the routeless api host. + assert.equal(baseUrlFor({ WANDB_BASE_URL: 'https://api.wandb.ai' }), 'https://trace.wandb.ai'); + assert.equal(baseUrlFor({ WANDB_BASE_URL: 'https://api.wandb.ai/' }), 'https://trace.wandb.ai'); + assert.equal(baseUrlFor({ WANDB_BASE_URL: 'HTTPS://API.WANDB.AI' }), 'https://trace.wandb.ai'); + + // Self-hosted / dedicated base URL passes through unchanged (trailing slash trimmed). + assert.equal(baseUrlFor({ WANDB_BASE_URL: 'https://my.wandb.io' }), 'https://my.wandb.io'); + assert.equal(baseUrlFor({ WANDB_BASE_URL: 'https://my.wandb.io/' }), 'https://my.wandb.io'); + + // Explicit trace server URL wins over WANDB_BASE_URL and is not remapped. + assert.equal( + baseUrlFor({ WF_TRACE_SERVER_URL: 'https://trace.example.io/', WANDB_BASE_URL: 'https://api.wandb.ai' }), + 'https://trace.example.io', + ); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// config-drift detection over the socket +// +// `status` asks the live daemon for the fingerprint of the config it loaded, +// and warns when settings.json now resolves to something different. Nothing is +// written to disk. +// +// Sockets live under /tmp (macOS 104-char path cap); see stale-daemon-socket.test.ts. const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, '..'); @@ -85,7 +186,6 @@ async function waitFor(predicate: () => boolean, timeoutMs = 8000): Promise { test('is stable for identical config and changes when agent_name changes', () => { const base = { weaveProject: 'e/p', apiKey: 'k', baseUrl: 'https://x', agentName: 'goober', debug: false }; @@ -100,7 +200,6 @@ suite('daemonConfigFingerprint', () => { }); }); -// ───────────────────────────────────────────────────────────────────────────── suite('status config-drift warning (socket query)', () => { test('warns when the daemon reports a different config than settings.json', async () => { const home = fs.mkdtempSync(path.join(scratch, 'drift-')); @@ -142,7 +241,6 @@ suite('status config-drift warning (socket query)', () => { }); }); -// ───────────────────────────────────────────────────────────────────────────── suite('status config-drift against a real daemon', () => { test('no drift initially, then drift after settings.json changes', async () => { const home = fs.mkdtempSync(path.join(scratch, 'real-')); diff --git a/tests/daemon-idle-inflight.test.ts b/tests/daemon-idle-inflight.test.ts deleted file mode 100644 index c52fbb7..0000000 --- a/tests/daemon-idle-inflight.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// 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. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -import { startTestDaemon } from './helpers.ts'; - -function writeTranscript(home: string, sessionId: string): string { - const dir = path.join(home, '.claude', 'projects', 'test', sessionId); - fs.mkdirSync(dir, { recursive: true }); - const file = path.join(dir, `${sessionId}.jsonl`); - fs.writeFileSync(file, [ - JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'do work' }] } }), - JSON.stringify({ - type: 'assistant', - message: { - role: 'assistant', model: 'claude-opus-4-8', id: 'm1', - usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - stop_reason: 'end_turn', content: [{ type: 'text', text: 'done' }], - }, - }), - ].join('\n') + '\n'); - return file; -} - -test('daemon stays up past the inactivity timeout while a turn span is open', async () => { - const d = await startTestDaemon({ env: { WEAVE_INACTIVITY_MS: '1000' } }); - try { - const sessionId = 'inflight-001'; - 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 long-running task' }); - - // Turn span is open and no further events arrive. Past the 1s timeout - // (checks fire every ~500ms) the daemon must log that it is holding open - // for in-flight work, and must NOT decide to shut down. - const stayedUp = await d.waitForLog(/work in flight — staying up/, 3000); - assert.ok(stayedUp, `daemon should hold open while a turn is in flight; log was:\n${d.readLog()}`); - assert.doesNotMatch(d.readLog(), /Inactivity timeout — shutting down/); - assert.equal(d.hasExited(), false, 'daemon should still be running'); - } finally { - await d.stop(); - } -}); - -test('daemon still idles out once the turn closes and nothing is in flight', async () => { - const d = await startTestDaemon({ env: { WEAVE_INACTIVITY_MS: '1000' } }); - try { - const sessionId = 'inflight-002'; - 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 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). - 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()}`); - } finally { - await d.stop(); - } -}); diff --git a/tests/daemon-lifecycle.test.ts b/tests/daemon-lifecycle.test.ts new file mode 100644 index 0000000..2cde30d --- /dev/null +++ b/tests/daemon-lifecycle.test.ts @@ -0,0 +1,313 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// Daemon lifecycle: idle/in-flight hold, session reconstruction across a +// restart, the `restart` CLI command, and startup herd-race safety. Each test +// spawns a REAL daemon subprocess (some via startTestDaemon, some manually) and +// tears it down in a finally/after so no daemon leaks. WEAVE_INACTIVITY_MS is +// set per spawned daemon via env, never process-global. + +import { test, suite, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { sendToSocket, probeUnixSocket, SocketState } from '../src/utils.ts'; +import { startTestDaemon, waitUntil } from './helpers.ts'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..'); +const CLI = path.join(REPO_ROOT, 'src', 'cli.ts'); + +// --------------------------------------------------------------------------- +// Idle / in-flight hold +// +// 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. +// --------------------------------------------------------------------------- + +function writeInflightTranscript(home: string, sessionId: string): string { + const dir = path.join(home, '.claude', 'projects', 'test', sessionId); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, [ + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'do work' }] } }), + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', model: 'claude-opus-4-8', id: 'm1', + usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: 'done' }], + }, + }), + ].join('\n') + '\n'); + return file; +} + +test('daemon stays up past the inactivity timeout while a turn span is open', async () => { + const d = await startTestDaemon({ env: { WEAVE_INACTIVITY_MS: '1000' } }); + try { + const sessionId = 'inflight-001'; + const transcript = writeInflightTranscript(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 long-running task' }); + + // Turn span is open and no further events arrive. Past the 1s timeout + // (checks fire every ~500ms) the daemon must log that it is holding open + // for in-flight work, and must NOT decide to shut down. + const stayedUp = await d.waitForLog(/work in flight — staying up/, 3000); + assert.ok(stayedUp, `daemon should hold open while a turn is in flight; log was:\n${d.readLog()}`); + assert.doesNotMatch(d.readLog(), /Inactivity timeout — shutting down/); + assert.equal(d.hasExited(), false, 'daemon should still be running'); + } finally { + await d.stop(); + } +}); + +test('daemon still idles out once the turn closes and nothing is in flight', async () => { + const d = await startTestDaemon({ env: { WEAVE_INACTIVITY_MS: '1000' } }); + try { + const sessionId = 'inflight-002'; + const transcript = writeInflightTranscript(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 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). + 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()}`); + } finally { + await d.stop(); + } +}); + +// --------------------------------------------------------------------------- +// Session reconstruction across a daemon restart +// +// The daemon shuts itself down after a short idle window and keeps all session +// state in memory, seeded only at SessionStart. A Claude Code session that +// outlives a daemon restart (e.g. the user steps away, the daemon idles +// out, then they resume the SAME session) sends its next UserPromptSubmit to a +// fresh daemon that never saw its SessionStart, producing "Unknown session" +// and silently dropping all tracing for the rest of that session. +// +// The fix: reconstruct the session from the `transcript_path` carried on the +// event, so the daemon is tolerant of its own restarts. +// --------------------------------------------------------------------------- + +/** Write a transcript with `turns` completed user+assistant pairs and return + * its path. Lives under the daemon's $HOME so TranscriptFile's within-home + * check passes. */ +function writeReconTranscript(home: string, sessionId: string, turns: number): string { + const dir = path.join(home, '.claude', 'projects', 'test', sessionId); + fs.mkdirSync(dir, { recursive: true }); + const lines: string[] = []; + for (let i = 0; i < turns; i++) { + lines.push(JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: `prompt ${i}` }] } })); + lines.push(JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', model: 'claude-opus-4-8', id: `m${i}`, + usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: `answer ${i}` }], + }, + })); + } + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, lines.join('\n') + '\n'); + return file; +} + +test('UserPromptSubmit for an unknown session reconstructs it from transcript_path and opens a turn span', async () => { + const d = await startTestDaemon(); + try { + const sessionId = 'recon-sess-001'; + const transcript = writeReconTranscript(d.home, sessionId, 1); + + // No SessionStart, this session predates this daemon instance. + await d.send({ + hook_event_name: 'UserPromptSubmit', + session_id: sessionId, + transcript_path: transcript, + prompt: 'continue the work', + }); + + const traced = await d.waitForLog(/Created turn span/, 3000); + assert.ok(traced, `expected a turn span for the reconstructed session; log was:\n${d.readLog()}`); + + const log = d.readLog(); + assert.match(log, /Session reconstructed after restart: recon-sess-001/); + assert.doesNotMatch(log, /Unknown session/); + } finally { + await d.stop(); + } +}); + +// --------------------------------------------------------------------------- +// `weave-claude-code restart`: stop a running daemon and start a fresh one, and +// refuse to spawn an unconfigured daemon. +// +// Sockets live under /tmp (macOS 104-char path cap); see stale-daemon-socket.test.ts. +// --------------------------------------------------------------------------- + +const homes: string[] = []; +const sockets: string[] = []; + +after(async () => { + // Best-effort: stop any daemon a test left running, then remove temp homes. + for (const s of sockets) { + if ((await probeUnixSocket(s)) === SocketState.Alive) { + try { await sendToSocket(s, JSON.stringify({ command: 'shutdown' })); } catch { /* gone */ } + } + } + for (const h of homes) fs.rmSync(h, { recursive: true, force: true }); +}); + +function newHome( + label: string, + cfg: { weave_project?: string | null; wandb_api_key?: string | null; agent_name?: string | null }, +): { home: string; socketPath: string } { + const home = fs.mkdtempSync(`/tmp/wcp-${label}-`); + homes.push(home); + const dir = path.join(home, '.weave-claude-code'); + fs.mkdirSync(path.join(dir, 'logs'), { recursive: true }); + const socketPath = path.join(dir, 'daemon.sock'); + sockets.push(socketPath); + fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ + log_file: path.join(dir, 'logs', 'daemon.log'), + daemon_socket: socketPath, + weave_project: cfg.weave_project ?? null, + wandb_api_key: cfg.wandb_api_key ?? null, + agent_name: cfg.agent_name ?? null, + debug: false, + installed_at: '2026-01-01T00:00:00Z', + version: '0.0.0-test', + }, null, 2)); + return { home, socketPath }; +} + +function runRestart(home: string): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve, reject) => { + const env = { ...process.env, HOME: home }; + delete env.WANDB_API_KEY; + delete env.WEAVE_PROJECT; + delete env.WEAVE_AGENT_NAME; + // Keep the OTel exporter from reaching real wandb.ai; refuse fast instead. + env.WANDB_BASE_URL = 'http://127.0.0.1:1'; + // Backstop: a daemon leaked by an assertion failure self-exits quickly. + env.WEAVE_INACTIVITY_MS = '20000'; + const child = spawn(process.execPath, ['--import', 'tsx', CLI, 'restart'], { cwd: REPO_ROOT, env }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (b) => { stdout += b.toString(); }); + child.stderr.on('data', (b) => { stderr += b.toString(); }); + child.on('error', reject); + child.on('exit', (code) => resolve({ stdout, stderr, code })); + }); +} + +async function waitForState(socketPath: string, want: (s: SocketState) => boolean, timeoutMs = 6000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (want(await probeUnixSocket(socketPath))) return; + await new Promise((r) => setTimeout(r, 50)); + } + throw new Error(`waitForState timeout after ${timeoutMs}ms (last=${await probeUnixSocket(socketPath)})`); +} + +suite('weave-claude-code restart', () => { + test('refuses to start a daemon and exits non-zero when unconfigured', async () => { + const { home, socketPath } = newHome('restart-unconfigured', {}); + const r = await runRestart(home); + assert.notEqual(r.code, 0, `expected non-zero exit; stdout=${r.stdout} stderr=${r.stderr}`); + assert.match(r.stdout + r.stderr, /missing configuration|weave_project/i); + assert.equal(fs.existsSync(socketPath), false, 'no daemon socket should be created when unconfigured'); + }); + + test('stops a running daemon and starts a fresh one', async () => { + const { home, socketPath } = newHome('restart-happy', { + weave_project: 'fake-entity/fake-project', + wandb_api_key: 'fake-api-key', + }); + + // Cold start: no daemon yet, so restart should bring one up. + const first = await runRestart(home); + assert.equal(first.code, 0, `cold restart should exit 0; stdout=${first.stdout} stderr=${first.stderr}`); + await waitForState(socketPath, (s) => s === SocketState.Alive); + + // Warm restart: a daemon is alive, so restart must stop it and start anew. + const second = await runRestart(home); + assert.equal(second.code, 0, `warm restart should exit 0; stdout=${second.stdout} stderr=${second.stderr}`); + await waitForState(socketPath, (s) => s === SocketState.Alive); + + // Cleanup so the detached daemon does not linger. + await sendToSocket(socketPath, JSON.stringify({ command: 'shutdown' })); + await waitForState(socketPath, (s) => s !== SocketState.Alive); + }); +}); + +// --------------------------------------------------------------------------- +// Startup herd race +// +// Herd safety. When several hooks fire at once and each cold-starts a daemon, +// only one can bind the socket; the losers must yield cleanly. The old start() +// guarded with existsSync -> probe -> unlink, then listen() and threw on error. +// Two daemons that both found no socket raced listen(): the loser crashed with +// EEXIST/EADDRINUSE ("Daemon failed to start", exit 1). Seven such crashes +// appeared in one local log over 14 days. +// +// The race is inherent, so a single run is probabilistic (measured on the old +// code: ~2 of 3 herds crash at least one daemon, the rest happen to serialize). +// The assertion here is therefore the POST-FIX invariant, which is +// deterministic once listen() errors are handled by re-probing instead of +// throwing: a herd crashes nobody and leaves exactly one listener. +// --------------------------------------------------------------------------- + +test('a herd of concurrent daemon starts crashes nobody and leaves exactly one listener', async () => { + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-herd-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.writeFileSync( + path.join(configDir, 'settings.json'), + JSON.stringify({ + weave_project: 'test/test', wandb_api_key: 'fake-key', + daemon_socket: socketPath, log_file: logPath, debug: true, + }), + ); + + const N = 12; + const procs = Array.from({ length: N }, () => + spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { + env: { ...process.env, HOME: home, WANDB_BASE_URL: 'http://127.0.0.1:1' }, + stdio: 'ignore', + }), + ); + try { + await waitUntil(() => fs.existsSync(socketPath), 5000); + await new Promise((r) => setTimeout(r, 2000)); // let every daemon resolve bind/yield + + const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; + const failures = (log.match(/Daemon failed to start/g) ?? []).length; + const started = (log.match(/Daemon started/g) ?? []).length; + + assert.equal(failures, 0, `herd must not crash any daemon; log:\n${log}`); + assert.equal(started, 1, `exactly one daemon should bind, got ${started}; log:\n${log}`); + assert.equal(await probeUnixSocket(socketPath), 'alive', 'a live listener should own the socket'); + } finally { + for (const p of procs) { try { p.kill('SIGKILL'); } catch { /* already gone */ } } + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/tests/daemon-session-reconstruction.test.ts b/tests/daemon-session-reconstruction.test.ts deleted file mode 100644 index 06c5518..0000000 --- a/tests/daemon-session-reconstruction.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// The daemon shuts itself down after a short idle window and keeps all session -// state in memory, seeded only at SessionStart. A Claude Code session that -// outlives a daemon restart (e.g. the user steps away, the daemon idles -// out, then they resume the SAME session) sends its next UserPromptSubmit to a -// fresh daemon that never saw its SessionStart — producing "Unknown session" -// and silently dropping all tracing for the rest of that session. -// -// The fix: reconstruct the session from the `transcript_path` carried on the -// event, so the daemon is tolerant of its own restarts. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -import { startTestDaemon } from './helpers.ts'; - -/** Write a transcript with `turns` completed user+assistant pairs and return - * its path. Lives under the daemon's $HOME so TranscriptFile's within-home - * check passes. */ -function writeTranscript(home: string, sessionId: string, turns: number): string { - const dir = path.join(home, '.claude', 'projects', 'test', sessionId); - fs.mkdirSync(dir, { recursive: true }); - const lines: string[] = []; - for (let i = 0; i < turns; i++) { - lines.push(JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: `prompt ${i}` }] } })); - lines.push(JSON.stringify({ - type: 'assistant', - message: { - role: 'assistant', model: 'claude-opus-4-8', id: `m${i}`, - usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - stop_reason: 'end_turn', content: [{ type: 'text', text: `answer ${i}` }], - }, - })); - } - const file = path.join(dir, `${sessionId}.jsonl`); - fs.writeFileSync(file, lines.join('\n') + '\n'); - return file; -} - -test('UserPromptSubmit for an unknown session reconstructs it from transcript_path and opens a turn span', async () => { - const d = await startTestDaemon(); - try { - const sessionId = 'recon-sess-001'; - const transcript = writeTranscript(d.home, sessionId, 1); - - // No SessionStart — this session predates this daemon instance. - await d.send({ - hook_event_name: 'UserPromptSubmit', - session_id: sessionId, - transcript_path: transcript, - prompt: 'continue the work', - }); - - const traced = await d.waitForLog(/Created turn span/, 3000); - assert.ok(traced, `expected a turn span for the reconstructed session; log was:\n${d.readLog()}`); - - const log = d.readLog(); - assert.match(log, /Session reconstructed after restart: recon-sess-001/); - assert.doesNotMatch(log, /Unknown session/); - } finally { - await d.stop(); - } -}); diff --git a/tests/daemon-shutdown-finalizes-turn.test.ts b/tests/daemon-shutdown-finalizes-turn.test.ts deleted file mode 100644 index 9178f91..0000000 --- a/tests/daemon-shutdown-finalizes-turn.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -// 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 { ATTR } from '../src/genaiSpans.ts'; -import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; - -const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; - -function aLine(id: string, ts: string, block: Record, stop?: string) { - return { - type: 'assistant', - timestamp: ts, - message: { role: 'assistant', id, model: 'claude-opus-4-8', content: [block], usage: USAGE, ...(stop ? { stop_reason: stop } : {}) }, - }; -} -function userText(ts: string, text: string) { - return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; -} - -function makeTranscript(sessionId: string): { file: string; append: (line: unknown) => void; dir: string } { - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-shutdown-itest-')); - const file = path.join(dir, `${sessionId}.jsonl`); - fs.writeFileSync(file, ''); - return { file, dir, append: (line: unknown) => fs.appendFileSync(file, JSON.stringify(line) + '\n') }; -} - -async function openTurnWithOneCompletedTool(d: Harness, sid: string, append: (l: unknown) => void, file: string) { - append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - 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 the thing' }); - append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'reading' })); - append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Read', input: {} }, 'tool_use')); - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Read', tool_input: { file_path: '/foo' } }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); -} - -test('daemon shutdown mid-turn exports the turn root span (children are not left rootless)', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-shutdown'; - const { file, append, dir } = makeTranscript(sid); - const d = makeGenaiDaemon(); - try { - await openTurnWithOneCompletedTool(d, sid, append, file); - - await d.drain('inactivity'); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const tool = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); - assert.ok(tool, 'the completed tool span exported as a child'); - - const root = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); - assert.ok(root, 'the turn root span must be exported on shutdown, not leaked'); - assert.equal(root!.attributes[ATTR.AGENT_NAME], 'claude-code'); - assert.equal(root!.attributes[ATTR.CONVERSATION_ID], sid); - assert.equal(root!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'daemon_shutdown'); - - assert.equal(tool!.spanContext().traceId, root!.spanContext().traceId, 'child and root share one trace'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('daemon shutdown ends an open subagent invoke_agent span under the same trace', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-shutdown-subagent'; - const { file, append, dir } = makeTranscript(sid); - const d = makeGenaiDaemon(); - try { - append(userText('2026-01-01T00:00:00.000Z', 'spawn a reviewer')); - 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: 'spawn a reviewer' }); - - append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'tool_use', id: 'agent_1', name: 'Agent', input: { subagent_type: 'code-reviewer', prompt: 'review' } }, 'tool_use')); - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'agent_1', tool_name: 'Agent', tool_input: { subagent_type: 'code-reviewer', prompt: 'review' } }); - - await d.drain('SIGTERM'); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const invokeAgents = spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); - const root = invokeAgents.find(s => s.attributes[ATTR.AGENT_NAME] === 'claude-code'); - const sub = invokeAgents.find(s => s.attributes[ATTR.AGENT_NAME] === 'code-reviewer'); - assert.ok(root, 'turn root exported'); - assert.ok(sub, 'open subagent invoke_agent span exported on shutdown'); - assert.equal(sub!.spanContext().traceId, root!.spanContext().traceId, 'subagent nests under the same trace as the root'); - assert.equal(spanParentId(sub!), root!.spanContext().spanId, 'subagent parents under the turn root'); - assert.equal(sub!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'daemon_shutdown'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('SessionEnd still exports the turn root span after the finalize refactor', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-sessionend'; - const { file, append, dir } = makeTranscript(sid); - const d = makeGenaiDaemon(); - try { - await openTurnWithOneCompletedTool(d, sid, append, file); - await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); - await flushWeave(); - - const root = exporter.getFinishedSpans().find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); - assert.ok(root, 'SessionEnd exports the turn root'); - assert.equal(root!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'session_ended'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/daemon-startup-race.test.ts b/tests/daemon-startup-race.test.ts deleted file mode 100644 index 5d415fd..0000000 --- a/tests/daemon-startup-race.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// Herd safety. When several hooks fire at once and each cold-starts a daemon, -// only one can bind the socket; the losers must yield cleanly. The old start() -// guarded with existsSync -> probe -> unlink, then listen() and threw on error. -// Two daemons that both found no socket raced listen(): the loser crashed with -// EEXIST/EADDRINUSE ("Daemon failed to start", exit 1). Seven such crashes -// appeared in one local log over 14 days. -// -// The race is inherent, so a single run is probabilistic (measured on the old -// code: ~2 of 3 herds crash at least one daemon, the rest happen to serialize). -// The assertion here is therefore the POST-FIX invariant, which is -// deterministic once listen() errors are handled by re-probing instead of -// throwing: a herd crashes nobody and leaves exactly one listener. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { probeUnixSocket } from '../src/utils.ts'; -import { waitUntil } from './helpers.ts'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(HERE, '..'); -const CLI = path.join(REPO_ROOT, 'src', 'cli.ts'); - -test('a herd of concurrent daemon starts crashes nobody and leaves exactly one listener', async () => { - const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-herd-')); - const configDir = path.join(home, '.weave-claude-code'); - const socketPath = path.join(configDir, 'daemon.sock'); - const logPath = path.join(configDir, 'logs', 'daemon.log'); - fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); - fs.writeFileSync( - path.join(configDir, 'settings.json'), - JSON.stringify({ - weave_project: 'test/test', wandb_api_key: 'fake-key', - daemon_socket: socketPath, log_file: logPath, debug: true, - }), - ); - - const N = 12; - const procs = Array.from({ length: N }, () => - spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { - env: { ...process.env, HOME: home, WANDB_BASE_URL: 'http://127.0.0.1:1' }, - stdio: 'ignore', - }), - ); - try { - await waitUntil(() => fs.existsSync(socketPath), 5000); - await new Promise((r) => setTimeout(r, 2000)); // let every daemon resolve bind/yield - - const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; - const failures = (log.match(/Daemon failed to start/g) ?? []).length; - const started = (log.match(/Daemon started/g) ?? []).length; - - assert.equal(failures, 0, `herd must not crash any daemon; log:\n${log}`); - assert.equal(started, 1, `exactly one daemon should bind, got ${started}; log:\n${log}`); - assert.equal(await probeUnixSocket(socketPath), 'alive', 'a live listener should own the socket'); - } finally { - for (const p of procs) { try { p.kill('SIGKILL'); } catch { /* already gone */ } } - fs.rmSync(home, { recursive: true, force: true }); - } -}); diff --git a/tests/daemon-subagent-recovery.test.ts b/tests/daemon-subagent-recovery.test.ts deleted file mode 100644 index 96a18d6..0000000 --- a/tests/daemon-subagent-recovery.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// 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 { - flushWeave, - initWeaveInMemory, - makeGenaiDaemon, - spanParentId, - transcriptAssistantLine, - transcriptUserLine, -} from './helpers.ts'; - -test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_agent + chat with tokens', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subrecover-')); - const sid = 'sub-recover-001'; - const agentId = 'a1234567890abcdef'; - - const mainPath = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(mainPath, transcriptUserLine('spawn a subagent') + '\n' + transcriptAssistantLine('working', { input_tokens: 10, output_tokens: 5 }) + '\n'); - - const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); - fs.mkdirSync(path.dirname(subPath), { recursive: true }); - fs.writeFileSync(subPath, transcriptUserLine('do the subtask') + '\n' + transcriptAssistantLine('subtask done', { input_tokens: 200, output_tokens: 40 }) + '\n'); - - const d = makeGenaiDaemon(); - try { - await d.routeEvent({ - hook_event_name: 'SubagentStop', - session_id: sid, - transcript_path: mainPath, - agent_id: agentId, - agent_transcript_path: subPath, - agent_type: 'general-purpose', - }); - await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const names = spans.map((s) => `${s.name}[${s.attributes['gen_ai.agent.name']}]`).join(', '); - - const subInvoke = spans.find( - (s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'general-purpose', - ); - assert.ok(subInvoke, `expected a recovered subagent invoke_agent span; got: ${names}`); - - const chat = spans.find( - (s) => s.attributes['gen_ai.operation.name'] === 'chat' && s.attributes['gen_ai.agent.name'] === 'general-purpose', - ); - assert.ok(chat, `expected the subagent chat span; got: ${names}`); - assert.ok(Number(chat.attributes['gen_ai.usage.output_tokens']) > 0, 'chat span carries the subagent token usage'); - assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the subagent invoke_agent span'); - - const turn = spans.find( - (s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude-code', - ); - assert.ok(turn, `expected a reconstructed turn span to parent the subagent; got: ${names}`); - assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'subagent invoke_agent nests under the reconstructed turn'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('recovery reuses an already-open turn span instead of creating a spurious second turn', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subrecover2-')); - const sid = 'sub-recover-002'; - const agentId = 'b1234567890abcdef'; - - const mainPath = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(mainPath, transcriptUserLine('start') + '\n'); - const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); - fs.mkdirSync(path.dirname(subPath), { recursive: true }); - fs.writeFileSync(subPath, transcriptUserLine('subtask') + '\n' + transcriptAssistantLine('done', { input_tokens: 50, output_tokens: 7 }) + '\n'); - - const d = makeGenaiDaemon(); - try { - await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, transcript_path: mainPath, prompt: 'go' }); - await d.routeEvent({ - hook_event_name: 'SubagentStop', session_id: sid, transcript_path: mainPath, - agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore', - }); - await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const turns = spans.filter((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude-code'); - assert.equal(turns.length, 1, `exactly one turn span expected, no spurious reconstructed turn; got ${turns.length}`); - const subInvoke = spans.find((s) => s.attributes['gen_ai.agent.name'] === 'Explore' && s.attributes['gen_ai.operation.name'] === 'invoke_agent'); - assert.ok(subInvoke, 'recovered subagent invoke_agent span present'); - assert.equal(spanParentId(subInvoke), turns[0].spanContext().spanId, 'subagent nests under the pre-existing turn'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/genai-span-usage-tokens.test.ts b/tests/genai-span-usage-tokens.test.ts deleted file mode 100644 index 505c14d..0000000 --- a/tests/genai-span-usage-tokens.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -// 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 type { InMemorySpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { ATTR } from '../src/genaiSpans.ts'; -import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; - -function aLine(id: string, ts: string, text: string, usage: Record) { - return { - type: 'assistant', - timestamp: ts, - message: { role: 'assistant', id, model: 'claude-opus-4-7', content: [{ type: 'text', text }], usage, stop_reason: 'end_turn' }, - }; -} -function userText(ts: string, text: string) { - return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; -} - -async function chatSpanForUsage(exporter: InMemorySpanExporter, sid: string, usage: Record): Promise { - exporter.reset(); - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-usage-')); - const file = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(file, [ - JSON.stringify(userText('2026-01-01T00:00:00Z', 'do it')), - JSON.stringify(aLine('msgA', '2026-01-01T00:00:01Z', 'all done', usage)), - ].join('\n') + '\n'); - const d = makeGenaiDaemon(); - try { - 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 flushWeave(); - const chat = exporter.getFinishedSpans().find(s => s.name === 'chat'); - assert.ok(chat, 'chat span should be emitted'); - return chat; - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -} - -test('chat span: input_tokens includes cache_read + cache_creation (OTel semconv)', async () => { - const exporter = await initWeaveInMemory(); - const chatSpan = await chatSpanForUsage(exporter, 'sess-usage-1', { - input_tokens: 7600, - output_tokens: 528, - cache_read_input_tokens: 36500, - cache_creation_input_tokens: 4100, - }); - - assert.equal( - chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], - 48200, - 'gen_ai.usage.input_tokens must include cache_read and cache_creation per OTel semconv', - ); - assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_READ_INPUT_TOKENS], 36500); - assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS], 4100); - assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 528); -}); - -test('chat span: input_tokens unchanged when no cache fields present', async () => { - const exporter = await initWeaveInMemory(); - const chatSpan = await chatSpanForUsage(exporter, 'sess-usage-2', { input_tokens: 1000, output_tokens: 200 }); - - assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1000); - assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_READ_INPUT_TOKENS], undefined); - assert.equal(chatSpan.attributes[ATTR.USAGE_CACHE_CREATION_INPUT_TOKENS], undefined); -}); diff --git a/tests/install-source-local.test.ts b/tests/install-source-local.test.ts deleted file mode 100644 index df494dd..0000000 --- a/tests/install-source-local.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// `registerPlugin` with InstallSource.Local must register the marketplace from -// the npm-installed package on disk (no git clone), so CI/sandbox environments -// without SSH access to GitHub can still install. - -import { test, suite, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { MARKETPLACE_NAME } from '../src/setup.ts'; -import { readFakeCalls } from './helpers.ts'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const FAKE_CLAUDE_BIN_DIR = path.join(HERE, 'fixtures', 'fake-claude-bin'); - -function seedLocalPluginTree(npmPrefix: string): string { - const pkgDir = path.join(npmPrefix, 'lib', 'node_modules', 'weave-claude-code'); - fs.mkdirSync(path.join(pkgDir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync( - path.join(pkgDir, '.claude-plugin', 'marketplace.json'), - JSON.stringify({ name: MARKETPLACE_NAME, plugins: [] }), - ); - return pkgDir; -} - -let tmpHome: string; -let tmpNpmPrefix: string; -let savedHome: string | undefined; -let savedPath: string | undefined; -let savedMktName: string | undefined; -let savedNpmPrefix: string | undefined; - -beforeEach(() => { - fs.chmodSync(path.join(FAKE_CLAUDE_BIN_DIR, 'claude'), 0o755); - tmpHome = fs.mkdtempSync('/tmp/wcp-install-source-test-'); - tmpNpmPrefix = fs.mkdtempSync('/tmp/wcp-install-source-npm-'); - savedHome = process.env.HOME; - savedPath = process.env.PATH; - savedMktName = process.env.FAKE_CLAUDE_MARKETPLACE_NAME; - savedNpmPrefix = process.env.npm_config_prefix; - process.env.HOME = tmpHome; - process.env.PATH = `${FAKE_CLAUDE_BIN_DIR}:${process.env.PATH}`; - process.env.FAKE_CLAUDE_MARKETPLACE_NAME = MARKETPLACE_NAME; - process.env.npm_config_prefix = tmpNpmPrefix; -}); - -afterEach(() => { - if (savedHome === undefined) delete process.env.HOME; - else process.env.HOME = savedHome; - if (savedPath === undefined) delete process.env.PATH; - else process.env.PATH = savedPath; - if (savedMktName === undefined) delete process.env.FAKE_CLAUDE_MARKETPLACE_NAME; - else process.env.FAKE_CLAUDE_MARKETPLACE_NAME = savedMktName; - if (savedNpmPrefix === undefined) delete process.env.npm_config_prefix; - else process.env.npm_config_prefix = savedNpmPrefix; - fs.rmSync(tmpHome, { recursive: true, force: true }); - fs.rmSync(tmpNpmPrefix, { recursive: true, force: true }); -}); - -suite('install --source=local', () => { - test('findLocalPluginPath: returns the seeded tree, null otherwise', async () => { - // Incremental setup: start with no install, then a half-install, then a - // full install. Each step asserts that findLocalPluginPath reflects the - // current on-disk state. - const { findLocalPluginPath } = await import('../src/setup.ts'); - - assert.equal(findLocalPluginPath(), null, 'no install: expected null'); - - const dir = path.join(tmpNpmPrefix, 'lib', 'node_modules', 'weave-claude-code'); - fs.mkdirSync(dir, { recursive: true }); - assert.equal(findLocalPluginPath(), null, 'dir without marketplace.json: expected null'); - - fs.mkdirSync(path.join(dir, '.claude-plugin'), { recursive: true }); - fs.writeFileSync( - path.join(dir, '.claude-plugin', 'marketplace.json'), - JSON.stringify({ name: MARKETPLACE_NAME, plugins: [] }), - ); - assert.equal(findLocalPluginPath(), dir, 'seeded: expected the seeded path'); - }); - - test('registerPlugin(Local): registers from the local path, installs the plugin, skips drift-update', async () => { - // Local source bypasses github cloning entirely: marketplace is registered - // from the npm-installed directory, plugin installs as normal, and the - // drift-detection update never fires (npm is the version-of-record, so the - // marketplace "ref" is a directory path with no meaningful comparison). - const { registerPlugin, InstallSource } = await import('../src/setup.ts'); - const pkgDir = seedLocalPluginTree(tmpNpmPrefix); - - const result = registerPlugin(path.join(tmpHome, 'log.txt'), InstallSource.Local); - - const calls = readFakeCalls(tmpHome); - const addCall = calls.find((c) => c.startsWith('plugin marketplace add')); - assert.ok(addCall, 'expected plugin marketplace add to be called'); - assert.ok(addCall.includes(pkgDir), `expected local path ${pkgDir} in: ${addCall}`); - assert.ok(!addCall.includes('wandb/weave-claude-code#'), `expected no github source in: ${addCall}`); - assert.ok(calls.some((c) => c.startsWith('plugin install'))); - assert.ok(!calls.some((c) => c.startsWith('plugin update'))); - assert.equal(result.pluginUpdated, false); - }); - - test('registerPlugin(Local): throws with a helpful error when no local plugin tree is found', async () => { - const { registerPlugin, InstallSource } = await import('../src/setup.ts'); - - assert.throws( - () => registerPlugin(path.join(tmpHome, 'log.txt'), InstallSource.Local), - /npm install -g weave-claude-code/, - ); - }); - - test('registerPlugin(): default source falls back to the github marketplace ref', async () => { - const { registerPlugin, MARKETPLACE_SOURCE } = await import('../src/setup.ts'); - - registerPlugin(path.join(tmpHome, 'log.txt')); - - const calls = readFakeCalls(tmpHome); - const addCall = calls.find((c) => c.startsWith('plugin marketplace add')); - assert.ok(addCall); - assert.ok(addCall.includes(MARKETPLACE_SOURCE), `expected github source ${MARKETPLACE_SOURCE}, got: ${addCall}`); - }); -}); diff --git a/tests/install.test.ts b/tests/install.test.ts new file mode 100644 index 0000000..5225d23 --- /dev/null +++ b/tests/install.test.ts @@ -0,0 +1,229 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// Plugin install/registration coverage: +// - install --source=local: registers the marketplace from the npm-installed +// package on disk (no git clone). +// - registerPlugin github ref-drift: a CLI upgrade that changes MARKETPLACE_REF +// follows `plugin install` with `plugin update`. +// - readRegisteredMarketplaceRef file/key/parse edge cases. +// The two suites keep separate beforeEach/afterEach because the local-source +// setup also manages `npm_config_prefix`; each suite scopes its own hooks. + +import { test, suite, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { MARKETPLACE_NAME, MARKETPLACE_REPO } from '../src/setup.ts'; +import { readFakeCalls, writeKnownMarketplace } from './helpers.ts'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const FAKE_CLAUDE_BIN_DIR = path.join(HERE, 'fixtures', 'fake-claude-bin'); +const PLUGIN_SPEC = `weave@${MARKETPLACE_NAME}`; +const KNOWN_MARKETPLACES_REL = path.join('.claude', 'plugins', 'known_marketplaces.json'); + +function seedLocalPluginTree(npmPrefix: string): string { + const pkgDir = path.join(npmPrefix, 'lib', 'node_modules', 'weave-claude-code'); + fs.mkdirSync(path.join(pkgDir, '.claude-plugin'), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ name: MARKETPLACE_NAME, plugins: [] }), + ); + return pkgDir; +} + +function seedInstalledPlugin(home: string): void { + fs.mkdirSync(path.join(home, '.claude'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.claude', 'fake-claude-installed-plugins.json'), + JSON.stringify({ [PLUGIN_SPEC]: { installedAt: '2026-01-01T00:00:00Z' } }), + ); +} + +// `registerPlugin` with InstallSource.Local must register the marketplace from +// the npm-installed package on disk (no git clone), so CI/sandbox environments +// without SSH access to GitHub can still install. +suite('install --source=local', () => { + let tmpHome: string; + let tmpNpmPrefix: string; + let savedHome: string | undefined; + let savedPath: string | undefined; + let savedMktName: string | undefined; + let savedNpmPrefix: string | undefined; + + beforeEach(() => { + fs.chmodSync(path.join(FAKE_CLAUDE_BIN_DIR, 'claude'), 0o755); + tmpHome = fs.mkdtempSync('/tmp/wcp-install-source-test-'); + tmpNpmPrefix = fs.mkdtempSync('/tmp/wcp-install-source-npm-'); + savedHome = process.env.HOME; + savedPath = process.env.PATH; + savedMktName = process.env.FAKE_CLAUDE_MARKETPLACE_NAME; + savedNpmPrefix = process.env.npm_config_prefix; + process.env.HOME = tmpHome; + process.env.PATH = `${FAKE_CLAUDE_BIN_DIR}:${process.env.PATH}`; + process.env.FAKE_CLAUDE_MARKETPLACE_NAME = MARKETPLACE_NAME; + process.env.npm_config_prefix = tmpNpmPrefix; + }); + + afterEach(() => { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedPath === undefined) delete process.env.PATH; + else process.env.PATH = savedPath; + if (savedMktName === undefined) delete process.env.FAKE_CLAUDE_MARKETPLACE_NAME; + else process.env.FAKE_CLAUDE_MARKETPLACE_NAME = savedMktName; + if (savedNpmPrefix === undefined) delete process.env.npm_config_prefix; + else process.env.npm_config_prefix = savedNpmPrefix; + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpNpmPrefix, { recursive: true, force: true }); + }); + + test('findLocalPluginPath: returns the seeded tree, null otherwise', async () => { + // Incremental setup: start with no install, then a half-install, then a + // full install. Each step asserts that findLocalPluginPath reflects the + // current on-disk state. + const { findLocalPluginPath } = await import('../src/setup.ts'); + + assert.equal(findLocalPluginPath(), null, 'no install: expected null'); + + const dir = path.join(tmpNpmPrefix, 'lib', 'node_modules', 'weave-claude-code'); + fs.mkdirSync(dir, { recursive: true }); + assert.equal(findLocalPluginPath(), null, 'dir without marketplace.json: expected null'); + + fs.mkdirSync(path.join(dir, '.claude-plugin'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ name: MARKETPLACE_NAME, plugins: [] }), + ); + assert.equal(findLocalPluginPath(), dir, 'seeded: expected the seeded path'); + }); + + test('registerPlugin(Local): registers from the local path, installs the plugin, skips drift-update', async () => { + // Local source bypasses github cloning entirely: marketplace is registered + // from the npm-installed directory, plugin installs as normal, and the + // drift-detection update never fires (npm is the version-of-record, so the + // marketplace "ref" is a directory path with no meaningful comparison). + const { registerPlugin, InstallSource } = await import('../src/setup.ts'); + const pkgDir = seedLocalPluginTree(tmpNpmPrefix); + + const result = registerPlugin(path.join(tmpHome, 'log.txt'), InstallSource.Local); + + const calls = readFakeCalls(tmpHome); + const addCall = calls.find((c) => c.startsWith('plugin marketplace add')); + assert.ok(addCall, 'expected plugin marketplace add to be called'); + assert.ok(addCall.includes(pkgDir), `expected local path ${pkgDir} in: ${addCall}`); + assert.ok(!addCall.includes('wandb/weave-claude-code#'), `expected no github source in: ${addCall}`); + assert.ok(calls.some((c) => c.startsWith('plugin install'))); + assert.ok(!calls.some((c) => c.startsWith('plugin update'))); + assert.equal(result.pluginUpdated, false); + }); + + test('registerPlugin(Local): throws with a helpful error when no local plugin tree is found', async () => { + const { registerPlugin, InstallSource } = await import('../src/setup.ts'); + + assert.throws( + () => registerPlugin(path.join(tmpHome, 'log.txt'), InstallSource.Local), + /npm install -g weave-claude-code/, + ); + }); + + test('registerPlugin(): default source falls back to the github marketplace ref', async () => { + const { registerPlugin, MARKETPLACE_SOURCE } = await import('../src/setup.ts'); + + registerPlugin(path.join(tmpHome, 'log.txt')); + + const calls = readFakeCalls(tmpHome); + const addCall = calls.find((c) => c.startsWith('plugin marketplace add')); + assert.ok(addCall); + assert.ok(addCall.includes(MARKETPLACE_SOURCE), `expected github source ${MARKETPLACE_SOURCE}, got: ${addCall}`); + }); +}); + +// registerPlugin ref-drift: a CLI upgrade that changes MARKETPLACE_REF must +// follow `plugin install` with `plugin update` to refresh the loaded plugin. +suite('marketplace ref-drift', () => { + let tmpHome: string; + let savedHome: string | undefined; + let savedPath: string | undefined; + let savedMktName: string | undefined; + + beforeEach(() => { + fs.chmodSync(path.join(FAKE_CLAUDE_BIN_DIR, 'claude'), 0o755); + tmpHome = fs.mkdtempSync('/tmp/wcp-marketplace-test-'); + savedHome = process.env.HOME; + savedPath = process.env.PATH; + savedMktName = process.env.FAKE_CLAUDE_MARKETPLACE_NAME; + process.env.HOME = tmpHome; + process.env.PATH = `${FAKE_CLAUDE_BIN_DIR}:${process.env.PATH}`; + process.env.FAKE_CLAUDE_MARKETPLACE_NAME = MARKETPLACE_NAME; + }); + + afterEach(() => { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + if (savedPath === undefined) delete process.env.PATH; + else process.env.PATH = savedPath; + if (savedMktName === undefined) delete process.env.FAKE_CLAUDE_MARKETPLACE_NAME; + else process.env.FAKE_CLAUDE_MARKETPLACE_NAME = savedMktName; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + suite('registerPlugin', () => { + test('fresh install: register + install, no update', async () => { + const { registerPlugin, MARKETPLACE_REF } = await import('../src/setup.ts'); + const result = registerPlugin(path.join(tmpHome, 'log.txt')); + + assert.equal(result.refBefore, null); + assert.equal(result.refAfter, MARKETPLACE_REF); + assert.equal(result.pluginUpdated, false); + const calls = readFakeCalls(tmpHome); + assert.ok(calls.some((c) => c.startsWith('plugin marketplace add'))); + assert.ok(calls.some((c) => c.startsWith('plugin install'))); + assert.ok(!calls.some((c) => c.startsWith('plugin update'))); + }); + + test('idempotent re-run: same ref → no update', async () => { + const { registerPlugin, MARKETPLACE_REF } = await import('../src/setup.ts'); + writeKnownMarketplace(tmpHome, { source: 'github', repo: MARKETPLACE_REPO, ref: MARKETPLACE_REF }); + seedInstalledPlugin(tmpHome); + + const result = registerPlugin(path.join(tmpHome, 'log.txt')); + + assert.equal(result.refBefore, MARKETPLACE_REF); + assert.equal(result.refAfter, MARKETPLACE_REF); + assert.equal(result.pluginUpdated, false); + assert.ok(!readFakeCalls(tmpHome).some((c) => c.startsWith('plugin update'))); + }); + + test('ref drift: refresh marketplace + plugin update', async () => { + const { registerPlugin, MARKETPLACE_REF } = await import('../src/setup.ts'); + const OLD_REF = 'v0.0.1'; + assert.notEqual(OLD_REF, MARKETPLACE_REF); + writeKnownMarketplace(tmpHome, { source: 'github', repo: MARKETPLACE_REPO, ref: OLD_REF }); + seedInstalledPlugin(tmpHome); + + const result = registerPlugin(path.join(tmpHome, 'log.txt')); + + assert.equal(result.refBefore, OLD_REF); + assert.equal(result.refAfter, MARKETPLACE_REF); + assert.equal(result.pluginUpdated, true); + assert.ok(readFakeCalls(tmpHome).some((c) => c.startsWith('plugin update'))); + }); + }); + + test('readRegisteredMarketplaceRef: file/key/parse edge cases', async () => { + const { readRegisteredMarketplaceRef } = await import('../src/setup.ts'); + + assert.equal(readRegisteredMarketplaceRef(MARKETPLACE_NAME), null); + + writeKnownMarketplace(tmpHome, { source: 'github', repo: MARKETPLACE_REPO, ref: 'v9.9.9' }); + assert.equal(readRegisteredMarketplaceRef(MARKETPLACE_NAME), 'v9.9.9'); + assert.equal(readRegisteredMarketplaceRef('some-other-marketplace'), null); + + fs.writeFileSync(path.join(tmpHome, KNOWN_MARKETPLACES_REL), '{ not valid json'); + assert.equal(readRegisteredMarketplaceRef(MARKETPLACE_NAME), null); + }); +}); diff --git a/tests/interleave-handlers.test.ts b/tests/interleave-handlers.test.ts deleted file mode 100644 index b4b42b0..0000000 --- a/tests/interleave-handlers.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -// 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 type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { ATTR } from '../src/genaiSpans.ts'; -import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; - -const USAGE = { input_tokens: 100, output_tokens: 1508, cache_read_input_tokens: 400 }; -const TOOL_CALL_ID = 'gen_ai.tool.call.id'; - -function aLine(id: string, ts: string, block: Record, stop?: string) { - return { - type: 'assistant', - timestamp: ts, - message: { - role: 'assistant', - id, - model: 'claude-opus-4-8', - content: [block], - usage: USAGE, - ...(stop ? { stop_reason: stop } : {}), - }, - }; -} - -function userText(ts: string, text: string) { - return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; -} - -function makeTranscript(sessionId: string): { file: string; append: (line: unknown) => void; dir: string } { - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-itest-')); - const file = path.join(dir, `${sessionId}.jsonl`); - fs.writeFileSync(file, ''); - return { - file, - dir, - append: (line: unknown) => fs.appendFileSync(file, JSON.stringify(line) + '\n'), - }; -} - -function chatByResponse(spans: ReadableSpan[], id: string): ReadableSpan[] { - return spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === 'chat' && s.attributes[ATTR.RESPONSE_ID] === id); -} -function partsOf(span: ReadableSpan): Array> { - const msgs = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string) as Array<{ parts?: Array> }>; - return msgs[0]?.parts ?? []; -} - -test('handlers: Stop emits each chat once; text + tool output parts preserve interleave', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-A'; - const { file, append, dir } = makeTranscript(sid); - append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - - const d = makeGenaiDaemon(); - try { - 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 the thing' }); - - append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' })); - append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')); - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); - - append(aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn')); - await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - - assert.equal(chatByResponse(spans, 'msgA').length, 1, 'one chat span for msgA'); - assert.equal(chatByResponse(spans, 'msgB').length, 1, 'one chat span for msgB'); - - const chatA = chatByResponse(spans, 'msgA')[0]; - assert.deepEqual(partsOf(chatA), [ - { type: 'text', content: 'first I will edit' }, - { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, - ], 'msgA: text then tool_call, in transcript order, as output parts'); - assert.equal(childrenOf(spans, chatA).length, 0, 'chat span does not own tool execution'); - - const turn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); - assert.ok(turn, 'main-agent turn exported'); - const tool = spans.find(s => s.attributes[TOOL_CALL_ID] === 'tool_1'); - assert.ok(tool, 'execute_tool span exported'); - assert.ok(childrenOf(spans, turn).includes(tool), 'main-agent tool nests directly under the turn'); - - assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); - assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); - - const chatB = chatByResponse(spans, 'msgB')[0]; - assert.deepEqual(partsOf(chatB), [{ type: 'text', content: 'all done' }]); - assert.equal(childrenOf(spans, chatB).length, 0, 'tool-less msgB has no execute_tool children'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('handlers: Stop emits multiple tool-calling responses once under the turn', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-B'; - const { file, append, dir } = makeTranscript(sid); - append(userText('2026-01-01T00:00:00.000Z', 'do two things')); - - const d = makeGenaiDaemon(); - try { - 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 two things' }); - - append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'editing A' })); - append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_A', name: 'Edit', input: {} }, 'tool_use')); - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_A', tool_name: 'Edit', tool_input: {} }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_A', tool_response: 'ok' }); - - append(aLine('msgB', '2026-01-01T00:00:05.000Z', { type: 'text', text: 'editing B' })); - append(aLine('msgB', '2026-01-01T00:00:06.000Z', { type: 'tool_use', id: 'tool_B', name: 'Edit', input: {} }, 'tool_use')); - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_B', tool_name: 'Edit', tool_input: {} }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_B', tool_response: 'ok' }); - - await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - assert.equal(chatByResponse(spans, 'msgA').length, 1, 'msgA emitted exactly once'); - assert.equal(chatByResponse(spans, 'msgB').length, 1, 'msgB emitted exactly once'); - - for (const id of ['msgA', 'msgB']) { - const chat = chatByResponse(spans, id)[0]; - const parts = partsOf(chat).map(p => p['type']); - assert.deepEqual(parts, ['text', 'tool_call'], `${id}: text + tool_call output parts`); - assert.equal(childrenOf(spans, chat).length, 0, `${id}: chat does not own tool execution`); - } - - const turn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); - assert.ok(turn, 'main-agent turn exported'); - const toolIds = childrenOf(spans, turn) - .filter(s => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool') - .map(s => s.attributes[TOOL_CALL_ID]) - .sort(); - assert.deepEqual(toolIds, ['tool_A', 'tool_B'], 'both main-agent tools nest directly under the turn'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('handlers: SessionEnd finalizes a still-open chat span with its output + usage (not an empty orphan)', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-C'; - const { file, append, dir } = makeTranscript(sid); - append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - - const d = makeGenaiDaemon(); - try { - 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 the thing' }); - - append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' })); - append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')); - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: {} }); - - await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const chatA = chatByResponse(spans, 'msgA')[0]; - assert.ok(chatA, 'chat span for msgA was finalized at SessionEnd (has a response id)'); - assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508, 'usage recovered at SessionEnd'); - const types = partsOf(chatA).map(p => p['type']); - assert.ok(types.includes('text'), 'assistant text output part recovered at SessionEnd'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/interleave-split-lines.test.ts b/tests/interleave-split-lines.test.ts deleted file mode 100644 index 9433fba..0000000 --- a/tests/interleave-split-lines.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -// 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 { ATTR } from '../src/genaiSpans.ts'; -import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; - -function aLine(id: string, ts: string, block: Record, stop?: string) { - return { - type: 'assistant', - timestamp: ts, - message: { - role: 'assistant', - id, - model: 'claude-opus-4-8', - content: [block], - usage: { input_tokens: 100, output_tokens: 1508, cache_read_input_tokens: 400 }, - ...(stop ? { stop_reason: stop } : {}), - }, - }; -} - -function userText(ts: string, text: string) { - return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; -} - -function partsOf(span: import('@opentelemetry/sdk-trace-base').ReadableSpan): Array> { - const msgs = JSON.parse(span.attributes[ATTR.OUTPUT_MESSAGES] as string) as Array<{ parts?: Array> }>; - return msgs[0]?.parts ?? []; -} - -test('reconstruction: split thinking/redacted_thinking/text/tool_use lines interleave as ordered parts, none dropped, usage once', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-split'; - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-splitlines-')); - const file = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(file, [ - userText('2026-01-01T00:00:00.000Z', 'do the thing'), - aLine('msgA', '2026-01-01T00:00:01.000Z', { type: 'thinking', thinking: 'let me think' }), - aLine('msgA', '2026-01-01T00:00:01.500Z', { type: 'redacted_thinking', data: 'ENCRYPTED' }), - aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'first I will edit' }), - aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use'), - aLine('msgB', '2026-01-01T00:00:10.000Z', { type: 'text', text: 'all done' }, 'end_turn'), - ].map(l => JSON.stringify(l)).join('\n') + '\n'); - - const d = makeGenaiDaemon(); - try { - 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 the thing' }); - await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const chatA = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgA'); - assert.ok(chatA, 'chat span for msgA emitted'); - - assert.deepEqual(partsOf(chatA), [ - { type: 'reasoning', content: 'let me think' }, - { type: 'reasoning', content: '[redacted]' }, - { type: 'text', content: 'first I will edit' }, - { type: 'tool_call', toolCallId: 'tool_1', toolName: 'Edit', arguments: '{}' }, - ], 'thinking, redacted placeholder, text, tool_call: all present, in order'); - - assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508); - assert.equal(chatA.attributes[ATTR.USAGE_INPUT_TOKENS], 100 + 400); - - const chatB = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgB'); - assert.ok(chatB, 'chat span for tool-less msgB emitted'); - assert.deepEqual(partsOf(chatB), [{ type: 'text', content: 'all done' }]); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/interleaved-assistant-spans.test.ts b/tests/interleaved-assistant-spans.test.ts deleted file mode 100644 index 8f9e7f6..0000000 --- a/tests/interleaved-assistant-spans.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// Pins `contentBlocksToParts`'s block-to-part mapping and order; end-to-end -// interleave coverage lives in interleave-handlers / interleave-split-lines. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { contentBlocksToParts } from '../src/genaiSpans.ts'; - -test('contentBlocksToParts: interleaved text and tool_use map to ordered parts', () => { - const parts = contentBlocksToParts([ - { type: 'text', text: 'Now let me add the method' }, - { type: 'tool_use', id: 'toolu_01', name: 'Edit', input: { file_path: '/foo.ts' } }, - { type: 'text', text: 'Now let me add the test' }, - { type: 'tool_use', id: 'toolu_02', name: 'Edit', input: { file_path: '/foo.test.ts' } }, - { type: 'text', text: 'All done' }, - ]); - - assert.deepEqual(parts, [ - { type: 'text', content: 'Now let me add the method' }, - { type: 'tool_call', toolCallId: 'toolu_01', toolName: 'Edit', arguments: '{"file_path":"/foo.ts"}' }, - { type: 'text', content: 'Now let me add the test' }, - { type: 'tool_call', toolCallId: 'toolu_02', toolName: 'Edit', arguments: '{"file_path":"/foo.test.ts"}' }, - { type: 'text', content: 'All done' }, - ]); -}); - -test('contentBlocksToParts: thinking maps to a reasoning part; redacted_thinking to a placeholder', () => { - const parts = contentBlocksToParts([ - { type: 'thinking', thinking: 'Let me reason about this...' }, - { type: 'redacted_thinking', data: 'ENCRYPTED' }, - { type: 'text', text: 'answer' }, - ]); - - assert.deepEqual(parts, [ - { type: 'reasoning', content: 'Let me reason about this...' }, - { type: 'reasoning', content: '[redacted]' }, - { type: 'text', content: 'answer' }, - ]); -}); - -test('contentBlocksToParts: empty text and empty thinking are skipped', () => { - const parts = contentBlocksToParts([ - { type: 'text', text: ' ' }, - { type: 'thinking', thinking: '' }, - { type: 'text', text: 'kept' }, - ]); - assert.deepEqual(parts, [{ type: 'text', content: 'kept' }]); -}); diff --git a/tests/interrupted-turn.test.ts b/tests/interrupted-turn.test.ts deleted file mode 100644 index b03f5e2..0000000 --- a/tests/interrupted-turn.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -// 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 { ATTR } from '../src/genaiSpans.ts'; -import { - flushWeave, - initWeaveInMemory, - makeGenaiDaemon, - transcriptUserLine, -} from './helpers.ts'; - -function assistantToolUseLine(msgId: string, toolUseId: string, ts: string): string { - return JSON.stringify({ - type: 'assistant', - timestamp: ts, - message: { - role: 'assistant', - id: msgId, - model: 'claude-opus-4-8', - usage: { input_tokens: 100, output_tokens: 10 }, - stop_reason: 'tool_use', - content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'sleep 999' } }], - }, - }); -} - -test('interrupted turn: next prompt closes the open turn and tool tracing survives', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-interrupt'; - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-interrupt-')); - const file = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(file, transcriptUserLine('turn one', { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }) + '\n'); - - const d = makeGenaiDaemon(); - try { - 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: 'turn one' }); - - fs.appendFileSync(file, assistantToolUseLine('msgA', 'tool_1', '2026-01-01T00:00:02.000Z') + '\n'); - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Bash', tool_input: { command: 'sleep 999' } }); - - fs.appendFileSync(file, transcriptUserLine('turn two', { timestamp: '2026-01-01T00:00:10.000Z' }) + '\n'); - await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'turn two' }); - - fs.appendFileSync(file, assistantToolUseLine('msgB', 'tool_2', '2026-01-01T00:00:12.000Z') + '\n'); - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_2', tool_name: 'Bash', tool_input: { command: 'sleep 999' } }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_2', tool_response: 'ok' }); - await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const turns = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); - assert.equal(turns.length, 2, 'both turn roots exported (interrupted turn not leaked)'); - - const superseded = turns.find((s) => s.attributes[ATTR.WEAVE_ORPHAN_REASON] === 'superseded_by_next_prompt'); - assert.ok(superseded, 'interrupted turn closed with the superseded orphan reason'); - - const tools = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); - assert.equal(tools.length, 2, 'tool spans from both turns exported (turn 2 tracing survived)'); - - const chats = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat'); - assert.ok(chats.some((c) => c.attributes[ATTR.RESPONSE_ID] === 'msgA'), 'interrupted chat span exported'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/marketplace-ref-drift.test.ts b/tests/marketplace-ref-drift.test.ts deleted file mode 100644 index b8c7387..0000000 --- a/tests/marketplace-ref-drift.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// registerPlugin ref-drift: a CLI upgrade that changes MARKETPLACE_REF must -// follow `plugin install` with `plugin update` to refresh the loaded plugin. - -import { test, suite, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { MARKETPLACE_NAME, MARKETPLACE_REPO } from '../src/setup.ts'; -import { readFakeCalls, writeKnownMarketplace } from './helpers.ts'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const FAKE_CLAUDE_BIN_DIR = path.join(HERE, 'fixtures', 'fake-claude-bin'); -const PLUGIN_SPEC = `weave@${MARKETPLACE_NAME}`; -const KNOWN_MARKETPLACES_REL = path.join('.claude', 'plugins', 'known_marketplaces.json'); - -function seedInstalledPlugin(home: string): void { - fs.mkdirSync(path.join(home, '.claude'), { recursive: true }); - fs.writeFileSync( - path.join(home, '.claude', 'fake-claude-installed-plugins.json'), - JSON.stringify({ [PLUGIN_SPEC]: { installedAt: '2026-01-01T00:00:00Z' } }), - ); -} - -let tmpHome: string; -let savedHome: string | undefined; -let savedPath: string | undefined; -let savedMktName: string | undefined; - -beforeEach(() => { - fs.chmodSync(path.join(FAKE_CLAUDE_BIN_DIR, 'claude'), 0o755); - tmpHome = fs.mkdtempSync('/tmp/wcp-marketplace-test-'); - savedHome = process.env.HOME; - savedPath = process.env.PATH; - savedMktName = process.env.FAKE_CLAUDE_MARKETPLACE_NAME; - process.env.HOME = tmpHome; - process.env.PATH = `${FAKE_CLAUDE_BIN_DIR}:${process.env.PATH}`; - process.env.FAKE_CLAUDE_MARKETPLACE_NAME = MARKETPLACE_NAME; -}); - -afterEach(() => { - if (savedHome === undefined) delete process.env.HOME; - else process.env.HOME = savedHome; - if (savedPath === undefined) delete process.env.PATH; - else process.env.PATH = savedPath; - if (savedMktName === undefined) delete process.env.FAKE_CLAUDE_MARKETPLACE_NAME; - else process.env.FAKE_CLAUDE_MARKETPLACE_NAME = savedMktName; - fs.rmSync(tmpHome, { recursive: true, force: true }); -}); - -suite('registerPlugin', () => { - test('fresh install: register + install, no update', async () => { - const { registerPlugin, MARKETPLACE_REF } = await import('../src/setup.ts'); - const result = registerPlugin(path.join(tmpHome, 'log.txt')); - - assert.equal(result.refBefore, null); - assert.equal(result.refAfter, MARKETPLACE_REF); - assert.equal(result.pluginUpdated, false); - const calls = readFakeCalls(tmpHome); - assert.ok(calls.some((c) => c.startsWith('plugin marketplace add'))); - assert.ok(calls.some((c) => c.startsWith('plugin install'))); - assert.ok(!calls.some((c) => c.startsWith('plugin update'))); - }); - - test('idempotent re-run: same ref → no update', async () => { - const { registerPlugin, MARKETPLACE_REF } = await import('../src/setup.ts'); - writeKnownMarketplace(tmpHome, { source: 'github', repo: MARKETPLACE_REPO, ref: MARKETPLACE_REF }); - seedInstalledPlugin(tmpHome); - - const result = registerPlugin(path.join(tmpHome, 'log.txt')); - - assert.equal(result.refBefore, MARKETPLACE_REF); - assert.equal(result.refAfter, MARKETPLACE_REF); - assert.equal(result.pluginUpdated, false); - assert.ok(!readFakeCalls(tmpHome).some((c) => c.startsWith('plugin update'))); - }); - - test('ref drift: refresh marketplace + plugin update', async () => { - const { registerPlugin, MARKETPLACE_REF } = await import('../src/setup.ts'); - const OLD_REF = 'v0.0.1'; - assert.notEqual(OLD_REF, MARKETPLACE_REF); - writeKnownMarketplace(tmpHome, { source: 'github', repo: MARKETPLACE_REPO, ref: OLD_REF }); - seedInstalledPlugin(tmpHome); - - const result = registerPlugin(path.join(tmpHome, 'log.txt')); - - assert.equal(result.refBefore, OLD_REF); - assert.equal(result.refAfter, MARKETPLACE_REF); - assert.equal(result.pluginUpdated, true); - assert.ok(readFakeCalls(tmpHome).some((c) => c.startsWith('plugin update'))); - }); -}); - -test('readRegisteredMarketplaceRef: file/key/parse edge cases', async () => { - const { readRegisteredMarketplaceRef } = await import('../src/setup.ts'); - - assert.equal(readRegisteredMarketplaceRef(MARKETPLACE_NAME), null); - - writeKnownMarketplace(tmpHome, { source: 'github', repo: MARKETPLACE_REPO, ref: 'v9.9.9' }); - assert.equal(readRegisteredMarketplaceRef(MARKETPLACE_NAME), 'v9.9.9'); - assert.equal(readRegisteredMarketplaceRef('some-other-marketplace'), null); - - fs.writeFileSync(path.join(tmpHome, KNOWN_MARKETPLACES_REL), '{ not valid json'); - assert.equal(readRegisteredMarketplaceRef(MARKETPLACE_NAME), null); -}); diff --git a/tests/restart.test.ts b/tests/restart.test.ts deleted file mode 100644 index b7725ba..0000000 --- a/tests/restart.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// Tests for `weave-claude-code restart`: stop a running daemon and start a -// fresh one, and refuse to spawn an unconfigured daemon. -// -// Sockets live under /tmp (macOS 104-char path cap); see stale-daemon-socket.test.ts. - -import { test, suite, after } from 'node:test'; -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { sendToSocket, probeUnixSocket, SocketState } from '../src/utils.ts'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(HERE, '..'); -const CLI = path.join(REPO_ROOT, 'src', 'cli.ts'); - -const homes: string[] = []; -const sockets: string[] = []; - -after(async () => { - // Best-effort: stop any daemon a test left running, then remove temp homes. - for (const s of sockets) { - if ((await probeUnixSocket(s)) === SocketState.Alive) { - try { await sendToSocket(s, JSON.stringify({ command: 'shutdown' })); } catch { /* gone */ } - } - } - for (const h of homes) fs.rmSync(h, { recursive: true, force: true }); -}); - -function newHome( - label: string, - cfg: { weave_project?: string | null; wandb_api_key?: string | null; agent_name?: string | null }, -): { home: string; socketPath: string } { - const home = fs.mkdtempSync(`/tmp/wcp-${label}-`); - homes.push(home); - const dir = path.join(home, '.weave-claude-code'); - fs.mkdirSync(path.join(dir, 'logs'), { recursive: true }); - const socketPath = path.join(dir, 'daemon.sock'); - sockets.push(socketPath); - fs.writeFileSync(path.join(dir, 'settings.json'), JSON.stringify({ - log_file: path.join(dir, 'logs', 'daemon.log'), - daemon_socket: socketPath, - weave_project: cfg.weave_project ?? null, - wandb_api_key: cfg.wandb_api_key ?? null, - agent_name: cfg.agent_name ?? null, - debug: false, - installed_at: '2026-01-01T00:00:00Z', - version: '0.0.0-test', - }, null, 2)); - return { home, socketPath }; -} - -function runRestart(home: string): Promise<{ stdout: string; stderr: string; code: number | null }> { - return new Promise((resolve, reject) => { - const env = { ...process.env, HOME: home }; - delete env.WANDB_API_KEY; - delete env.WEAVE_PROJECT; - delete env.WEAVE_AGENT_NAME; - // Keep the OTel exporter from reaching real wandb.ai; refuse fast instead. - env.WANDB_BASE_URL = 'http://127.0.0.1:1'; - // Backstop: a daemon leaked by an assertion failure self-exits quickly. - env.WEAVE_INACTIVITY_MS = '20000'; - const child = spawn(process.execPath, ['--import', 'tsx', CLI, 'restart'], { cwd: REPO_ROOT, env }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', (b) => { stdout += b.toString(); }); - child.stderr.on('data', (b) => { stderr += b.toString(); }); - child.on('error', reject); - child.on('exit', (code) => resolve({ stdout, stderr, code })); - }); -} - -async function waitForState(socketPath: string, want: (s: SocketState) => boolean, timeoutMs = 6000): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (want(await probeUnixSocket(socketPath))) return; - await new Promise((r) => setTimeout(r, 50)); - } - throw new Error(`waitForState timeout after ${timeoutMs}ms (last=${await probeUnixSocket(socketPath)})`); -} - -suite('weave-claude-code restart', () => { - test('refuses to start a daemon and exits non-zero when unconfigured', async () => { - const { home, socketPath } = newHome('restart-unconfigured', {}); - const r = await runRestart(home); - assert.notEqual(r.code, 0, `expected non-zero exit; stdout=${r.stdout} stderr=${r.stderr}`); - assert.match(r.stdout + r.stderr, /missing configuration|weave_project/i); - assert.equal(fs.existsSync(socketPath), false, 'no daemon socket should be created when unconfigured'); - }); - - test('stops a running daemon and starts a fresh one', async () => { - const { home, socketPath } = newHome('restart-happy', { - weave_project: 'fake-entity/fake-project', - wandb_api_key: 'fake-api-key', - }); - - // Cold start: no daemon yet, so restart should bring one up. - const first = await runRestart(home); - assert.equal(first.code, 0, `cold restart should exit 0; stdout=${first.stdout} stderr=${first.stderr}`); - await waitForState(socketPath, (s) => s === SocketState.Alive); - - // Warm restart: a daemon is alive, so restart must stop it and start anew. - const second = await runRestart(home); - assert.equal(second.code, 0, `warm restart should exit 0; stdout=${second.stdout} stderr=${second.stderr}`); - await waitForState(socketPath, (s) => s === SocketState.Alive); - - // Cleanup so the detached daemon does not linger. - await sendToSocket(socketPath, JSON.stringify({ command: 'shutdown' })); - await waitForState(socketPath, (s) => s !== SocketState.Alive); - }); -}); diff --git a/tests/subagent-nesting.test.ts b/tests/subagent-nesting.test.ts deleted file mode 100644 index 266294e..0000000 --- a/tests/subagent-nesting.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -// 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 { ATTR } from '../src/genaiSpans.ts'; -import { - flushWeave, - initWeaveInMemory, - makeGenaiDaemon, - spanParentId, - transcriptAssistantLine, - transcriptUserLine, -} from './helpers.ts'; - -const userLine = (text: string): string => - transcriptUserLine(text, { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }); -const assistantLine = (text: string, usage: Record): string => - transcriptAssistantLine(text, usage, { timestamp: '2026-01-01T00:00:05.000Z' }); - -test('matched subagent: tools and chats nest under its invoke_agent marker with full identity', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sub-nest-001'; - const agentId = 'nest-agent-1'; - const firingPrompt = 'find the flaky test'; - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest-')); - const coordPath = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(coordPath, userLine('kick off') + '\n'); - - const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); - fs.mkdirSync(path.dirname(subPath), { recursive: true }); - fs.writeFileSync(subPath, userLine(firingPrompt) + '\n' + assistantLine('found it', { input_tokens: 120, output_tokens: 30 }) + '\n'); - - const d = makeGenaiDaemon(); - try { - await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); - await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); - await d.routeEvent({ - hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tu-agent', - tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: firingPrompt, description: 'Find it' }, - }); - await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); - await d.routeEvent({ - hook_event_name: 'PreToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', - tool_name: 'Read', tool_input: { file_path: '/flaky.test.ts' }, - }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', tool_response: 'contents' }); - await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore' }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent', tool_response: 'found the flaky test' }); - await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); - assert.ok(turn, 'coordinator turn exported'); - const subInvoke = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); - assert.ok(subInvoke, 'subagent invoke_agent marker exported'); - assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'marker nests under the turn'); - assert.equal(subInvoke.attributes[ATTR.AGENT_ID], agentId, 'agent id recorded at SubagentStart'); - assert.equal( - subInvoke.attributes[ATTR.OUTPUT_MESSAGES], - JSON.stringify([{ role: 'assistant', content: 'found the flaky test' }]), - 'PostToolUse(Agent) closes the marker with the canonical tool return', - ); - - const readTool = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool' && s.attributes['gen_ai.tool.name'] === 'Read'); - assert.ok(readTool, 'subagent tool span exported'); - assert.equal(spanParentId(readTool), subInvoke.spanContext().spanId, 'subagent tool nests under the marker'); - assert.equal(readTool.attributes[ATTR.AGENT_NAME], 'Explore', 'subagent tool tagged with the subagent name'); - - const chat = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); - assert.ok(chat, 'subagent chat span exported'); - assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the marker'); - assert.equal(chat.attributes[ATTR.USAGE_INPUT_TOKENS], 120); - - for (const s of [subInvoke, readTool, chat]) { - assert.equal(s.attributes[ATTR.CONVERSATION_ID], sid, `${s.name}: conversation id`); - assert.equal(s.attributes['weave.integration.name'], 'weave-claude-code', `${s.name}: integration name`); - } - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('ambiguous correlation does not manufacture a duplicate subagent marker', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sub-nest-ambiguous'; - const agentId = 'ambiguous-agent'; - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest-ambiguous-')); - const coordPath = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(coordPath, userLine('dispatch two explorers') + '\n'); - - const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); - fs.mkdirSync(path.dirname(subPath), { recursive: true }); - fs.writeFileSync(subPath, userLine('prompt not present on either dispatch') + '\n' - + assistantLine('ambiguous result', { input_tokens: 20, output_tokens: 5 }) + '\n'); - - const d = makeGenaiDaemon(); - try { - await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); - await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'dispatch two explorers' }); - for (const [toolUseId, prompt] of [['tu-agent-a', 'first task'], ['tu-agent-b', 'second task']] as const) { - await d.routeEvent({ - hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: toolUseId, - tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt }, - }); - } - - await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); - await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore' }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent-a', tool_response: 'first result' }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent-b', tool_response: 'second result' }); - await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' - && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); - assert.ok(turn, 'coordinator turn exported'); - const subagents = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' - && s.attributes[ATTR.AGENT_NAME] === 'Explore'); - assert.equal(subagents.length, 2, 'only the two actual Agent dispatches produce markers'); - - const chat = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat' - && s.attributes[ATTR.AGENT_NAME] === 'Explore'); - assert.ok(chat, 'ambiguous subagent chat still exported'); - assert.equal(spanParentId(chat), turn.spanContext().spanId, 'ambiguous chat safely falls back to the turn'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('recursive dispatch: a subagent spawning a subagent nests the child under its own marker', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sub-nest-002'; - const outerPrompt = 'do the outer task'; - const innerPrompt = 'do the inner task'; - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest2-')); - const coordPath = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(coordPath, userLine('kick off') + '\n'); - for (const [agentId, prompt] of [['outer-1', outerPrompt], ['inner-1', innerPrompt]] as const) { - const transcript = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); - fs.mkdirSync(path.dirname(transcript), { recursive: true }); - fs.writeFileSync(transcript, userLine(prompt) + '\n' - + assistantLine('done', { input_tokens: 10, output_tokens: 5 }) + '\n'); - } - - const d = makeGenaiDaemon(); - try { - await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); - await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); - await d.routeEvent({ - hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tu-outer', - tool_name: 'Agent', tool_input: { subagent_type: 'general-purpose', prompt: outerPrompt }, - }); - await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'outer-1', agent_type: 'general-purpose' }); - await d.routeEvent({ - hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'outer-1', tool_use_id: 'tu-inner', - tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: innerPrompt }, - }); - await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'inner-1', agent_type: 'Explore' }); - await d.routeEvent({ - hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'inner-1', tool_use_id: 'tu-read', - tool_name: 'Read', tool_input: { file_path: '/f.ts' }, - }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'inner-1', tool_use_id: 'tu-read', tool_response: 'ok' }); - await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'inner-1', agent_transcript_path: path.join(dir, sid, 'subagents', 'agent-inner-1.jsonl'), agent_type: 'Explore' }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'outer-1', tool_use_id: 'tu-inner', tool_response: 'inner done' }); - await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'outer-1', agent_transcript_path: path.join(dir, sid, 'subagents', 'agent-outer-1.jsonl'), agent_type: 'general-purpose' }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-outer', tool_response: 'outer done' }); - await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' - && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); - const outer = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' - && s.attributes[ATTR.AGENT_NAME] === 'general-purpose'); - const inner = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' - && s.attributes[ATTR.AGENT_NAME] === 'Explore'); - assert.ok(turn && outer && inner, 'turn + both markers exported'); - assert.equal(spanParentId(outer), turn.spanContext().spanId, 'outer marker nests under the turn'); - assert.equal(spanParentId(inner), outer.spanContext().spanId, 'inner marker nests under the outer marker'); - assert.equal(inner.attributes[ATTR.AGENT_ID], 'inner-1', 'inner marker matched'); - assert.equal(inner.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined, 'no orphan fallback'); - - const readTool = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool' - && s.attributes['gen_ai.tool.name'] === 'Read'); - assert.ok(readTool, 'inner tool exported'); - assert.equal(spanParentId(readTool), inner.spanContext().spanId, 'inner tool nests under inner marker'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/subagents.test.ts b/tests/subagents.test.ts new file mode 100644 index 0000000..c1b5344 --- /dev/null +++ b/tests/subagents.test.ts @@ -0,0 +1,1131 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// Consolidated subagent + teammate span coverage. Merged from four suites: +// - subagent nesting: matched + recursive Agent dispatch nest the subagent's +// tools and chats under its invoke_agent marker, with identity flowing +// through the handle chain to every nested span. +// - daemon subagent recovery: a SubagentStop with no tracker (post-restart, +// reconstruction #92) still recovers the subagent invoke_agent + chat, and +// recovery reuses an already-open turn instead of creating a spurious one. +// - daemon shutdown finalize: a turn root (invoke_agent) opened at +// UserPromptSubmit is only ended at Stop/SessionEnd; the shutdown drain must +// finalize live turns (and open subagent markers) so their already-exported +// children aren't left rootless. +// - teammate idle: teammate transcript parsing (agent-setting head line, +// multi-turn) plus per-session and cross-session TeammateIdle tracing. The +// integration cases spawn a real daemon subprocess and assert its log lines +// (per-session, cross-session, FIFO re-spawn, inactivity-hold, +// duplicate-idle). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, type ChildProcess } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFirstTranscriptLine } from '../src/transcriptFile.ts'; +import { parseSessionFile } from '../src/parser.ts'; +import { ATTR } from '../src/genaiSpans.ts'; +import { + childrenOf, + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + spanParentId, + transcriptAssistantLine, + transcriptUserLine, + type DaemonDriver, +} from './helpers.ts'; + +// ── builders: subagent-nesting ──────────────────────────────────────────────── + +const userLine = (text: string): string => + transcriptUserLine(text, { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }); +const assistantLine = (text: string, usage: Record): string => + transcriptAssistantLine(text, usage, { timestamp: '2026-01-01T00:00:05.000Z' }); + +// ── builders: daemon-shutdown ───────────────────────────────────────────────── + +const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; + +function aLine(id: string, ts: string, block: Record, stop?: string) { + return { + type: 'assistant', + timestamp: ts, + message: { role: 'assistant', id, model: 'claude-opus-4-8', content: [block], usage: USAGE, ...(stop ? { stop_reason: stop } : {}) }, + }; +} +function userText(ts: string, text: string) { + return { type: 'user', timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; +} + +function makeTranscript(sessionId: string): { file: string; append: (line: unknown) => void; dir: string } { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-shutdown-itest-')); + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, ''); + return { file, dir, append: (line: unknown) => fs.appendFileSync(file, JSON.stringify(line) + '\n') }; +} + +/** Drive a session to a mid-turn state: turn open, one tool completed. */ +async function openTurnWithOneCompletedTool(d: DaemonDriver, sid: string, append: (l: unknown) => void, file: string) { + append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); + 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 the thing' }); + append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'reading' })); + append(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Read', input: {} }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Read', tool_input: { file_path: '/foo' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); +} + +// ── fixtures: teammate transcripts ──────────────────────────────────────────── + +/** Write a fake teammate transcript to a temp file and return its path. + * + * readFirstTranscriptLine requires the path to be within os.homedir() (security + * check). We use a subdir of the home directory rather than /tmp to satisfy it. + */ +function writeTeammateTranscript(lines: object[]): string { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-test-')); + const filePath = path.join(dir, 'abc123.jsonl'); + fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n') + '\n'); + return filePath; +} + +const AGENT_SETTING_LINE = { + type: 'agent-setting', + agentSetting: 'cks-specialist', + sessionId: 'abc123-session-id', +}; + +const MODE_LINE = { type: 'mode', mode: 'normal', sessionId: 'abc123-session-id' }; + +const USER_LINE = { + parentUuid: null, + isSidechain: false, + teamName: 'triage-supp-12345', + agentName: 'cks-specialist', + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: 'Investigate the CKS cluster health.' }], + }, + timestamp: '2026-06-05T10:00:00.000Z', +}; + +const ASSISTANT_LINE = { + type: 'assistant', + message: { + role: 'assistant', + model: 'claude-opus-4-8', + id: 'msg_test123', + usage: { + input_tokens: 1000, + output_tokens: 200, + cache_read_input_tokens: 500, + cache_creation_input_tokens: 0, + }, + stop_reason: 'end_turn', + content: [{ type: 'text', text: 'The cluster looks healthy. No anomalies detected.' }], + }, + timestamp: '2026-06-05T10:00:05.000Z', +}; + +// ── helpers: teammate integration (real daemon subprocess) ──────────────────── + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(HERE, '..'); +const CLI = path.join(REPO_ROOT, 'src', 'cli.ts'); + +const sleep = (ms: number): Promise => new Promise(r => setTimeout(r, ms)); + +function spawnDaemon(home: string, extraEnv: Record = {}): ChildProcess { + return spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { + env: { ...process.env, HOME: home, ...extraEnv }, + stdio: 'ignore', + }); +} + +const sendEvent = (socketPath: string, payload: object): Promise => new Promise((resolve, reject) => { + const s = net.createConnection(socketPath); + s.on('error', reject); + s.on('connect', () => { s.end(JSON.stringify(payload)); }); + s.on('close', () => resolve()); +}); + +const waitForSocket = (socketPath: string): Promise => new Promise((resolve) => { + const poll = setInterval(() => { + if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } + }, 50); +}); + +const isAlive = (socketPath: string): Promise => new Promise((resolve) => { + const s = net.createConnection(socketPath); + s.on('error', () => resolve(false)); + s.on('connect', () => { s.destroy(); resolve(true); }); +}); + +const readLog = (logPath: string): string => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; + +async function stopDaemon(daemon: ChildProcess, home: string): Promise { + daemon.kill(); + await new Promise(resolve => daemon.once('exit', () => resolve())); + fs.rmSync(home, { recursive: true, force: true }); +} + +// ── subagent nesting ────────────────────────────────────────────────────────── +// +// The matched subagent path end-to-end: PreToolUse(Agent) opens the +// invoke_agent marker under the turn, SubagentStart correlates the agent_id by +// firing-prompt hash, the subagent's own tools and chat spans nest under the +// marker (weave 0.16.3 Subagent parents children), and PostToolUse(Agent) +// closes the marker with the tool's canonical return. Conversation id and +// integration identity must reach every nested span through the handle chain. + +test('matched subagent: tools and chats nest under its invoke_agent marker with full identity', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-nest-001'; + const agentId = 'nest-agent-1'; + const firingPrompt = 'find the flaky test'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest-')); + const coordPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(coordPath, userLine('kick off') + '\n'); + + // Subagent transcript at the derived path; line 1 is the firing prompt + // (byte-identical to the Agent tool's prompt) for content-based correlation. + const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(subPath), { recursive: true }); + fs.writeFileSync(subPath, userLine(firingPrompt) + '\n' + assistantLine('found it', { input_tokens: 120, output_tokens: 30 }) + '\n'); + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tu-agent', + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: firingPrompt, description: 'Find it' }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); + // The subagent runs its own tool. + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', + tool_name: 'Read', tool_input: { file_path: '/flaky.test.ts' }, + }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: agentId, tool_use_id: 'tu-read', tool_response: 'contents' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent', tool_response: 'found the flaky test' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + assert.ok(turn, 'coordinator turn exported'); + const subInvoke = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(subInvoke, 'subagent invoke_agent marker exported'); + assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'marker nests under the turn'); + assert.equal(subInvoke.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID], 'tu-agent'); + assert.equal(subInvoke.attributes[ATTR.AGENT_ID], agentId, 'agent id recorded at SubagentStart'); + assert.equal( + subInvoke.attributes[ATTR.OUTPUT_MESSAGES], + JSON.stringify([{ role: 'assistant', content: 'found the flaky test' }]), + 'PostToolUse(Agent) closes the marker with the canonical tool return', + ); + + const readTool = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool' && s.attributes['gen_ai.tool.name'] === 'Read'); + assert.ok(readTool, 'subagent tool span exported'); + assert.equal(spanParentId(readTool), subInvoke.spanContext().spanId, 'subagent tool nests under the marker'); + assert.equal(readTool.attributes[ATTR.AGENT_NAME], 'Explore', 'subagent tool tagged with the subagent name'); + + const chat = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(chat, 'subagent chat span exported'); + assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the marker'); + assert.equal(chat.attributes[ATTR.USAGE_INPUT_TOKENS], 120); + + // Identity flows through the handle chain to every nested span. + for (const s of [subInvoke, readTool, chat]) { + assert.equal(s.attributes[ATTR.CONVERSATION_ID], sid, `${s.name}: conversation id`); + assert.equal(s.attributes['weave.integration.name'], 'weave-claude-code', `${s.name}: integration name`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('recursive dispatch: a subagent spawning a subagent nests the child under its own marker', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-nest-002'; + const outerPrompt = 'do the outer task'; + const innerPrompt = 'do the inner task'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest2-')); + const coordPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(coordPath, userLine('kick off') + '\n'); + for (const [agentId, prompt] of [['outer-1', outerPrompt], ['inner-1', innerPrompt]] as const) { + const p = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, userLine(prompt) + '\n' + assistantLine('done', { input_tokens: 10, output_tokens: 5 }) + '\n'); + } + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'kick off' }); + // Main agent dispatches the outer subagent. + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tu-outer', + tool_name: 'Agent', tool_input: { subagent_type: 'general-purpose', prompt: outerPrompt }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'outer-1', agent_type: 'general-purpose' }); + // The OUTER subagent dispatches the inner one (agent_id set on the event). + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'outer-1', tool_use_id: 'tu-inner', + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt: innerPrompt }, + }); + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'inner-1', agent_type: 'Explore' }); + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'inner-1', tool_use_id: 'tu-read', + tool_name: 'Read', tool_input: { file_path: '/f.ts' }, + }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'inner-1', tool_use_id: 'tu-read', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'inner-1', agent_transcript_path: path.join(dir, sid, 'subagents', 'agent-inner-1.jsonl'), agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'outer-1', tool_use_id: 'tu-inner', tool_response: 'inner done' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'outer-1', agent_transcript_path: path.join(dir, sid, 'subagents', 'agent-outer-1.jsonl'), agent_type: 'general-purpose' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-outer', tool_response: 'outer done' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const outer = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'general-purpose'); + const inner = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(turn && outer && inner, 'turn + both markers exported'); + assert.equal(spanParentId(outer), turn.spanContext().spanId, 'outer marker nests under the turn'); + assert.equal(spanParentId(inner), outer.spanContext().spanId, 'inner marker nests under the OUTER marker'); + assert.equal(inner.attributes[ATTR.AGENT_ID], 'inner-1', 'inner marker matched (not an orphan)'); + assert.equal(inner.attributes[ATTR.WEAVE_ORPHAN_REASON], undefined, 'no orphan fallback'); + + const readTool = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool' && s.attributes['gen_ai.tool.name'] === 'Read'); + assert.ok(readTool, 'inner tool exported'); + assert.equal(spanParentId(readTool), inner.spanContext().spanId, 'inner tool nests under the inner marker'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('ambiguous correlation does not manufacture a duplicate subagent marker', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sub-nest-ambiguous'; + const agentId = 'ambiguous-agent'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subnest-ambiguous-')); + const coordPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(coordPath, userLine('dispatch two explorers') + '\n'); + + const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(subPath), { recursive: true }); + fs.writeFileSync(subPath, userLine('prompt not present on either dispatch') + '\n' + + assistantLine('ambiguous result', { input_tokens: 20, output_tokens: 5 }) + '\n'); + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'dispatch two explorers' }); + for (const [toolUseId, prompt] of [['tu-agent-a', 'first task'], ['tu-agent-b', 'second task']] as const) { + await d.routeEvent({ + hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: toolUseId, + tool_name: 'Agent', tool_input: { subagent_type: 'Explore', prompt }, + }); + } + + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: agentId, agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent-a', tool_response: 'first result' }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tu-agent-b', tool_response: 'second result' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turn = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + assert.ok(turn, 'coordinator turn exported'); + const subagents = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' + && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.equal(subagents.length, 2, 'only the two actual Agent dispatches produce markers'); + + const chat = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat' + && s.attributes[ATTR.AGENT_NAME] === 'Explore'); + assert.ok(chat, 'ambiguous subagent chat still exported'); + assert.equal(spanParentId(chat), turn.spanContext().spanId, 'ambiguous chat safely falls back to the turn'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── daemon subagent recovery ────────────────────────────────────────────────── +// +// Regression for subagent spans dropped after a daemon restart: reconstruction +// (#92) rebuilds the session but not its subagent trackers, so handleSubagentStop +// found no tracker and dropped the subagent's spans. These drive the real +// routeEvent with an in-memory exporter and assert the recovered span tree. + +test('SubagentStop with no tracker (post-restart) recovers the subagent invoke_agent + chat with tokens', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subrecover-')); + const sid = 'sub-recover-001'; + const agentId = 'a1234567890abcdef'; + + // Main transcript: the in-progress turn the subagent ran under, already on disk. + const mainPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(mainPath, transcriptUserLine('spawn a subagent') + '\n' + transcriptAssistantLine('working', { input_tokens: 10, output_tokens: 5 }) + '\n'); + + // Subagent transcript where the daemon derives it (agentId-based sibling dir). + const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(subPath), { recursive: true }); + fs.writeFileSync(subPath, transcriptUserLine('do the subtask') + '\n' + transcriptAssistantLine('subtask done', { input_tokens: 200, output_tokens: 40 }) + '\n'); + + const d = makeGenaiDaemon(); + try { + // Fresh daemon that only sees the subagent's completion, not its start. + await d.routeEvent({ + hook_event_name: 'SubagentStop', + session_id: sid, + transcript_path: mainPath, + agent_id: agentId, + agent_transcript_path: subPath, + agent_type: 'general-purpose', + }); + // SessionEnd closes the reconstructed turn so it exports. + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const names = spans.map((s) => `${s.name}[${s.attributes['gen_ai.agent.name']}]`).join(', '); + + const subInvoke = spans.find( + (s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'general-purpose', + ); + assert.ok(subInvoke, `expected a recovered subagent invoke_agent span; got: ${names}`); + + // The subagent's chat spans nest under its invoke_agent marker, and carry + // its tokens. + const chat = spans.find( + (s) => s.attributes['gen_ai.operation.name'] === 'chat' && s.attributes['gen_ai.agent.name'] === 'general-purpose', + ); + assert.ok(chat, `expected the subagent chat span; got: ${names}`); + assert.ok(Number(chat.attributes['gen_ai.usage.output_tokens']) > 0, 'chat span carries the subagent token usage'); + assert.equal(spanParentId(chat), subInvoke.spanContext().spanId, 'subagent chat nests under the subagent invoke_agent span'); + + // Recovery reconstructs the turn; the subagent nests under it. + const turn = spans.find( + (s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude-code', + ); + assert.ok(turn, `expected a reconstructed turn span to parent the subagent; got: ${names}`); + assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'subagent invoke_agent nests under the reconstructed turn'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('recovery reuses an already-open turn span instead of creating a spurious second turn', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-subrecover2-')); + const sid = 'sub-recover-002'; + const agentId = 'b1234567890abcdef'; + + const mainPath = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(mainPath, transcriptUserLine('start') + '\n'); + const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); + fs.mkdirSync(path.dirname(subPath), { recursive: true }); + fs.writeFileSync(subPath, transcriptUserLine('subtask') + '\n' + transcriptAssistantLine('done', { input_tokens: 50, output_tokens: 7 }) + '\n'); + + const d = makeGenaiDaemon(); + try { + // UserPromptSubmit reconstructs the session and opens a turn first; recovery + // must nest under that existing turn, not create a second one. + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, transcript_path: mainPath, prompt: 'go' }); + await d.routeEvent({ + hook_event_name: 'SubagentStop', session_id: sid, transcript_path: mainPath, + agent_id: agentId, agent_transcript_path: subPath, agent_type: 'Explore', + }); + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turns = spans.filter((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent' && s.attributes['gen_ai.agent.name'] === 'claude-code'); + assert.equal(turns.length, 1, `exactly one turn span expected, no spurious reconstructed turn; got ${turns.length}`); + const subInvoke = spans.find((s) => s.attributes['gen_ai.agent.name'] === 'Explore' && s.attributes['gen_ai.operation.name'] === 'invoke_agent'); + assert.ok(subInvoke, 'recovered subagent invoke_agent span present'); + assert.equal(spanParentId(subInvoke), turns[0].spanContext().spanId, 'subagent nests under the pre-existing turn'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── daemon shutdown finalizes the turn root ─────────────────────────────────── +// +// A turn's root span (`invoke_agent`) is created at UserPromptSubmit and only +// ended at Stop or SessionEnd. When the daemon exits for any other reason +// (inactivity timeout, SIGTERM/SIGINT/SIGHUP, or a restart control message), its +// already-ended children (completed tool spans, finalized chat spans, closed +// subagent spans) have been exported, but the still-open root had not. The +// result was a rootless trace: tool spans with no user turn to attribute them +// to. The fix finalizes every live session (ending its turn root) inside the +// shutdown drain, before the exporter is flushed. + +test('daemon shutdown mid-turn exports the turn root span (children are not left rootless)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-shutdown'; + const { file, append, dir } = makeTranscript(sid); + const d = makeGenaiDaemon(); + try { + await openTurnWithOneCompletedTool(d, sid, append, file); + + // Neither Stop nor SessionEnd fired: the daemon exits (inactivity / signal + // / restart). The drain must finalize the open turn before flushing. + await d.drain('inactivity'); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const tool = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); + assert.ok(tool, 'the completed tool span exported as a child'); + + const root = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.ok(root, 'the turn root span must be exported on shutdown, not leaked'); + assert.equal(root!.attributes[ATTR.AGENT_NAME], 'claude-code'); + assert.equal(root!.attributes[ATTR.CONVERSATION_ID], sid); + assert.equal(root!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'daemon_shutdown'); + + // The trace is well-formed: the child shares the exported root's trace id. + assert.equal(tool!.spanContext().traceId, root!.spanContext().traceId, 'child and root share one trace'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('daemon shutdown ends an open subagent invoke_agent span under the same trace', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-shutdown-subagent'; + const { file, append, dir } = makeTranscript(sid); + const d = makeGenaiDaemon(); + try { + append(userText('2026-01-01T00:00:00.000Z', 'spawn a reviewer')); + 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: 'spawn a reviewer' }); + + // Agent tool with subagent_type opens a nested invoke_agent span (Subagent) + // that a mid-flight shutdown would otherwise leave open. + append(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'tool_use', id: 'agent_1', name: 'Agent', input: { subagent_type: 'code-reviewer', prompt: 'review' } }, 'tool_use')); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'agent_1', tool_name: 'Agent', tool_input: { subagent_type: 'code-reviewer', prompt: 'review' } }); + + await d.drain('SIGTERM'); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const invokeAgents = spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + const root = invokeAgents.find(s => s.attributes[ATTR.AGENT_NAME] === 'claude-code'); + const sub = invokeAgents.find(s => s.attributes[ATTR.AGENT_NAME] === 'code-reviewer'); + assert.ok(root, 'turn root exported'); + assert.ok(sub, 'open subagent invoke_agent span exported on shutdown'); + assert.equal(sub!.spanContext().traceId, root!.spanContext().traceId, 'subagent nests under the same trace as the root'); + assert.equal(spanParentId(sub!), root!.spanContext().spanId, 'subagent parents under the turn root'); + assert.equal(sub!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'daemon_shutdown'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('SessionEnd still exports the turn root span after the finalize refactor', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-sessionend'; + const { file, append, dir } = makeTranscript(sid); + const d = makeGenaiDaemon(); + try { + await openTurnWithOneCompletedTool(d, sid, append, file); + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); + + const root = exporter.getFinishedSpans().find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.ok(root, 'SessionEnd exports the turn root'); + assert.equal(root!.attributes[ATTR.WEAVE_ORPHAN_REASON], 'session_ended'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── teammate transcript parsing ─────────────────────────────────────────────── +// +// Teammate transcripts differ from subagent transcripts in two ways: +// 1. They live at /.jsonl (not under subagents/) +// 2. The first line is an agent-setting record, not a user message: +// {"type":"agent-setting","agentSetting":"cks-specialist","sessionId":"..."} +// +// TeammateIdle payload fields the handler reads: teammate_name (agent name), +// team_name, and transcript_path (the teammate's, not the coordinator's). + +test('readFirstTranscriptLine: returns agentSetting from teammate transcript', () => { + const filePath = writeTeammateTranscript([AGENT_SETTING_LINE, MODE_LINE, USER_LINE, ASSISTANT_LINE]); + try { + const firstLine = readFirstTranscriptLine(filePath); + assert.ok(firstLine, 'should read first line'); + assert.equal(firstLine['type'], 'agent-setting'); + assert.equal(firstLine['agentSetting'], 'cks-specialist'); + assert.equal(firstLine['sessionId'], 'abc123-session-id'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true }); + } +}); + +test('parseSessionFile: skips agent-setting lines, parses LLM calls from teammate transcript', () => { + const filePath = writeTeammateTranscript([AGENT_SETTING_LINE, MODE_LINE, USER_LINE, ASSISTANT_LINE]); + try { + const parsed = parseSessionFile(filePath); + assert.ok(parsed, 'parseSessionFile should return non-null'); + assert.equal(parsed.turns.length, 1, 'should produce exactly one turn'); + + const turn = parsed.turns[0]; + const calls = turn.assistantCalls(); + assert.equal(calls.length, 1, 'should have one assistant call'); + + const call = calls[0]; + assert.equal(call.model, 'claude-opus-4-8'); + assert.equal(call.usage.input_tokens, 1000); + assert.equal(call.usage.output_tokens, 200); + assert.equal(call.usage.cache_read_input_tokens, 500); + assert.equal(call.finishReason, 'end_turn'); + assert.equal(call.responseId, 'msg_test123'); + + assert.deepEqual(turn.textBlocks(), ['The cluster looks healthy. No anomalies detected.']); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true }); + } +}); + +test('TeammateIdle: multi-turn transcript emits chat spans from all turns', () => { + const turn2User = { + ...USER_LINE, + message: { ...USER_LINE.message, content: [{ type: 'text', text: 'Follow-up question.' }] }, + timestamp: '2026-06-05T10:01:00.000Z', + }; + const turn2Assistant = { + ...ASSISTANT_LINE, + message: { + ...ASSISTANT_LINE.message, + id: 'msg_turn2', + usage: { input_tokens: 800, output_tokens: 150, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + content: [{ type: 'text', text: 'Follow-up answer.' }], + }, + timestamp: '2026-06-05T10:01:05.000Z', + }; + + const filePath = writeTeammateTranscript([ + AGENT_SETTING_LINE, MODE_LINE, + USER_LINE, ASSISTANT_LINE, + turn2User, turn2Assistant, + ]); + try { + const parsed = parseSessionFile(filePath); + assert.ok(parsed); + assert.equal(parsed.turns.length, 2, 'should have 2 turns'); + + let totalCalls = 0; + for (const turn of parsed.turns) { + totalCalls += turn.assistantCalls().length; + } + assert.equal(totalCalls, 2, 'should have 2 assistant calls across both turns'); + } finally { + fs.rmSync(path.dirname(filePath), { recursive: true }); + } +}); + +// ── teammate idle span tree (in-process) ────────────────────────────────────── + +test('TeammateIdle span tree: teammate turn carries the teammate chat span, tagged by agent name', async () => { + // Drive the per-session teammate path end-to-end in-process: SubagentStart + // (orphan) creates the Subagent marker; SubagentStop keeps it open; + // TeammateIdle emits the teammate's chat spans under a fresh teammate turn + // (the Subagent is a leaf and can't parent them). Each teammate chat span is + // tagged with `gen_ai.agent.name` so the Agents view groups it. + const exporter = await initWeaveInMemory(); + exporter.reset(); + + const home = os.homedir(); + const coordSid = 'coord-span-001'; + const coordDir = fs.mkdtempSync(path.join(home, '.weave-tmspan-')); + const coordPath = path.join(coordDir, `${coordSid}.jsonl`); + fs.writeFileSync(coordPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + // Subagent transcript at the path the daemon derives: + // //subagents/agent-.jsonl + const agentId = 'agent-span-abc'; + const subDir = path.join(coordDir, coordSid, 'subagents'); + fs.mkdirSync(subDir, { recursive: true }); + fs.writeFileSync(path.join(subDir, `agent-${agentId}.jsonl`), + [USER_LINE, ASSISTANT_LINE].map(l => JSON.stringify(l)).join('\n') + '\n'); + + const d = makeGenaiDaemon(); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: coordSid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordSid, prompt: '/triage' }); + // Orphan SubagentStart (no matching PreToolUse tracker). + await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: coordSid, agent_id: agentId, agent_type: 'cks-specialist' }); + await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: coordSid, agent_id: agentId }); + await d.routeEvent({ hook_event_name: 'TeammateIdle', session_id: coordSid, teammate_name: 'cks-specialist', team_name: 'triage-span' }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + + // The teammate's own turn root (fresh trace), tagged with the teammate name. + const teammateTurn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'cks-specialist'); + assert.ok(teammateTurn, 'teammate turn span exists tagged with gen_ai.agent.name'); + + // The teammate chat span nests under the teammate turn and is tagged too. + const chatKids = childrenOf(spans, teammateTurn).filter(s => s.attributes[ATTR.OPERATION_NAME] === 'chat'); + assert.equal(chatKids.length, 1, 'one chat span under the teammate turn'); + const chatSpan = chatKids[0]; + assert.equal(chatSpan.attributes[ATTR.AGENT_NAME], 'cks-specialist', 'chat span tagged with the teammate name'); + + // Token counts are correct (cache-inclusive total for input). + assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1500, 'input_tokens = 1000 + 500 cache_read'); + assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 200); + } finally { + fs.rmSync(coordDir, { recursive: true, force: true }); + } +}); + +// ── teammate idle integration (real daemon subprocess) ──────────────────────── +// +// The integration cases send the real payload schema through a spawned daemon to +// catch regressions in field-name reading and cross-session correlation, and +// assert against the daemon log. Cross-session (agent-teams / TeamCreate): the +// teammate is an independent Claude session, SubagentStart does NOT fire, and a +// team registry bridges the coordinator to the teammate. + +test('TeammateIdle: full sequence (SubagentStart, SubagentStop, TeammateIdle) traces with all turns', async () => { + // Per-session teammate sequence (SubagentStart is the entry point; PreToolUse + // not tested here): + // 1. SubagentStart (orphan, no matching tracker): creates invoke_agent span, stores transcript path + // 2. SubagentStop: span kept open (pendingTeammateIdle=true), tracker stays in SubagentTracking + // 3. TeammateIdle: finds tracker, emits all-turns chat spans, closes span + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-inttest-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + const coordinatorSessionId = 'inttest-coord-001'; + + // Subagent transcript must live where the daemon expects it: + // /subagents/agent-.jsonl + const coordinatorTranscriptDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); + const subagentsDir = path.join(coordinatorTranscriptDir, 'subagents'); + const agentId = 'agent-abc123def456'; + const agentTranscriptPath = path.join(subagentsDir, `agent-${agentId}.jsonl`); + + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.mkdirSync(subagentsDir, { recursive: true }); + + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + weave_project: 'test/test', + wandb_api_key: 'fake-key-for-test', + daemon_socket: socketPath, + log_file: logPath, + debug: true, + })); + + // Multi-turn teammate transcript (two investigation turns) + fs.writeFileSync(agentTranscriptPath, [ + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Investigate CKS health' }] } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg1', + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: 'Phase 1: cluster looks healthy.' }] } }), + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Dig deeper' }] } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg2', + usage: { input_tokens: 200, output_tokens: 80, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: 'Phase 2: no anomalies detected.' }] } }), + ].join('\n') + '\n'); + + const coordinatorPath = path.join(coordinatorTranscriptDir, `${coordinatorSessionId}.jsonl`); + fs.mkdirSync(coordinatorTranscriptDir, { recursive: true }); + fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + const daemon = spawnDaemon(home); + + try { + await waitForSocket(socketPath); + await sleep(200); + + // Step 1: Coordinator session starts and submits prompt + await sendEvent(socketPath, { hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); + await sleep(100); + await sendEvent(socketPath, { hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-99999' }); + await sleep(100); + + // Step 2: SubagentStart (orphan, no matching PreToolUse) + await sendEvent(socketPath, { + hook_event_name: 'SubagentStart', + session_id: coordinatorSessionId, + agent_id: agentId, + agent_type: 'cks-specialist', + transcript_path: agentTranscriptPath, + }); + await sleep(100); + + // Step 3: SubagentStop, should keep span open (pendingTeammateIdle) + await sendEvent(socketPath, { + hook_event_name: 'SubagentStop', + session_id: coordinatorSessionId, + agent_id: agentId, + agent_transcript_path: agentTranscriptPath, + }); + await sleep(100); + + // Step 4: TeammateIdle, should close span with all-turns content + // CC sends coordinator's transcript_path (not the agent's), daemon uses stored path instead + await sendEvent(socketPath, { + hook_event_name: 'TeammateIdle', + session_id: coordinatorSessionId, + transcript_path: coordinatorPath, // coordinator's path (as CC sends it) + teammate_name: 'cks-specialist', + team_name: 'triage-inttest', + }); + await sleep(400); + + const log = readLog(logPath); + assert.match(log, /TeammateIdle: traced cks-specialist/, 'should trace cks-specialist'); + assert.doesNotMatch(log, /missing agent_id/, 'should not error on missing agent_id'); + assert.doesNotMatch(log, /no pending tracker for cks-specialist/, 'should find the pending tracker from SubagentStart'); + } finally { + await stopDaemon(daemon, home); + } +}); + +test('Cross-session: TeammateIdle from teammate session finds coordinator team member', async () => { + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-crosstest-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + const coordinatorSessionId = 'cross-coord-001'; + const teammateSessionId = 'cross-teammate-001'; + const teamName = 'triage-crosstest'; + const teammateName = 'cks-specialist'; + + // Coordinator transcript dir with subagents/ for transcript resolution + const coordinatorTranscriptDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); + const subagentsDir = path.join(coordinatorTranscriptDir, 'subagents'); + const agentId = 'agent-cross-abc123'; + const agentTranscriptPath = path.join(subagentsDir, `agent-${agentId}.jsonl`); + const agentMetaPath = path.join(subagentsDir, `agent-${agentId}.meta.json`); + + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.mkdirSync(subagentsDir, { recursive: true }); + + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + weave_project: 'test/crosstest', + wandb_api_key: 'fake-key-for-crosstest', + daemon_socket: socketPath, + log_file: logPath, + debug: true, + })); + + // Teammate transcript (the specialist's own investigation) + fs.writeFileSync(agentTranscriptPath, [ + JSON.stringify({ type: 'agent-setting', agentSetting: teammateName, sessionId: teammateSessionId }), + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Investigate CKS health' }] } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg-cross-1', + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: 'CKS cluster is healthy.' }] } }), + ].join('\n') + '\n'); + + // Meta file for transcript resolution (resolveTeammateTranscript reads this) + fs.writeFileSync(agentMetaPath, JSON.stringify({ agentType: teammateName })); + + // Coordinator and teammate transcript files + const coordinatorPath = path.join(coordinatorTranscriptDir, `${coordinatorSessionId}.jsonl`); + fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + const teammateTranscriptDir = path.join(home, '.claude', 'projects', 'test', teammateSessionId); + fs.mkdirSync(teammateTranscriptDir, { recursive: true }); + const teammatePath = path.join(teammateTranscriptDir, `${teammateSessionId}.jsonl`); + fs.writeFileSync(teammatePath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + const daemon = spawnDaemon(home); + + try { + await waitForSocket(socketPath); + await sleep(200); + + // Step 1: Coordinator starts and submits prompt + await sendEvent(socketPath, { hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); + await sleep(100); + await sendEvent(socketPath, { hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-crosstest' }); + await sleep(100); + + // Step 2: PreToolUse(Agent, team_name) in coordinator session + await sendEvent(socketPath, { + hook_event_name: 'PreToolUse', + session_id: coordinatorSessionId, + tool_use_id: 'toolu_cross_001', + tool_name: 'Agent', + tool_input: { + prompt: 'Investigate CKS health', + subagent_type: teammateName, + team_name: teamName, + name: teammateName, + }, + }); + await sleep(100); + + // Verify team member was registered + let log = readLog(logPath); + assert.match(log, /Team member registered/, 'coordinator PreToolUse should register team member'); + + // Step 3: PostToolUse(Agent), should NOT close the span (team mode) + await sendEvent(socketPath, { + hook_event_name: 'PostToolUse', + session_id: coordinatorSessionId, + tool_use_id: 'toolu_cross_001', + tool_name: 'Agent', + tool_response: 'Agent dispatched', + }); + await sleep(100); + + // Step 4: Teammate session starts (DIFFERENT session_id) + await sendEvent(socketPath, { hook_event_name: 'SessionStart', session_id: teammateSessionId, transcript_path: teammatePath }); + await sleep(100); + + // Step 5: TeammateIdle fires from TEAMMATE's session (the cross-session case) + await sendEvent(socketPath, { + hook_event_name: 'TeammateIdle', + session_id: teammateSessionId, + transcript_path: teammatePath, + teammate_name: teammateName, + team_name: teamName, + }); + await sleep(400); + + log = readLog(logPath); + assert.match(log, /TeammateIdle: traced cks-specialist team=triage-crosstest \(cross-session\)/, 'should trace via cross-session path'); + assert.doesNotMatch(log, /no pending tracker for cks-specialist/, 'should NOT fall through to per-session path'); + } finally { + await stopDaemon(daemon, home); + } +}); + +test('Cross-session: re-spawn of same team::name nests BOTH (FIFO queue, no overwrite)', async () => { + // Regression for the re-spawn bug: the same team::name is spawned twice in one + // run. A second PreToolUse(Agent) for the same `${team}::${name}` must append + // to the FIFO queue, not overwrite the first still-open span (which would leak + // it and mis-attribute the first teammate's transcript). + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-respawntest-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + const coordinatorSessionId = 'respawn-coord-001'; + const teamName = 'triage-respawn'; + const teammateName = 'cks-specialist'; + const tm1 = 'respawn-tm-001'; + const tm2 = 'respawn-tm-002'; + + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + weave_project: 'test/respawn', wandb_api_key: 'fake-key', daemon_socket: socketPath, log_file: logPath, debug: true, + })); + + const coordDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); + const subagentsDir = path.join(coordDir, 'subagents'); + fs.mkdirSync(subagentsDir, { recursive: true }); + const coordinatorPath = path.join(coordDir, `${coordinatorSessionId}.jsonl`); + fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + const mkTeammate = (agentId: string, sid: string, text: string): string => { + fs.writeFileSync(path.join(subagentsDir, `agent-${agentId}.jsonl`), [ + JSON.stringify({ type: 'agent-setting', agentSetting: teammateName, sessionId: sid }), + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: `msg-${agentId}`, + usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text }] } }), + ].join('\n') + '\n'); + fs.writeFileSync(path.join(subagentsDir, `agent-${agentId}.meta.json`), JSON.stringify({ agentType: teammateName })); + const tdir = path.join(home, '.claude', 'projects', 'test', sid); + fs.mkdirSync(tdir, { recursive: true }); + const tp = path.join(tdir, `${sid}.jsonl`); + fs.writeFileSync(tp, JSON.stringify({ type: 'system', content: [] }) + '\n'); + return tp; + }; + const tp1 = mkTeammate('respawn-a1', tm1, 'first cks investigation'); + const tp2 = mkTeammate('respawn-a2', tm2, 'second cks investigation'); + + const daemon = spawnDaemon(home); + + try { + await waitForSocket(socketPath); + await sleep(200); + await sendEvent(socketPath, { hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); + await sleep(100); + await sendEvent(socketPath, { hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-respawn' }); + await sleep(100); + + // FIRST spawn of cks-specialist + await sendEvent(socketPath, { hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_r1', + tool_name: 'Agent', tool_input: { prompt: 'first', subagent_type: teammateName, team_name: teamName, name: teammateName } }); + await sleep(80); + // SECOND spawn of the SAME team::name (the re-spawn) BEFORE the first idles + await sendEvent(socketPath, { hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_r2', + tool_name: 'Agent', tool_input: { prompt: 'second', subagent_type: teammateName, team_name: teamName, name: teammateName } }); + await sleep(120); + + let log = readLog(logPath); + assert.match(log, /queue depth 2/, 'second spawn of same key should APPEND to FIFO queue (depth 2), not overwrite'); + + // both teammate sessions start, then both idle + await sendEvent(socketPath, { hook_event_name: 'SessionStart', session_id: tm1, transcript_path: tp1 }); + await sendEvent(socketPath, { hook_event_name: 'SessionStart', session_id: tm2, transcript_path: tp2 }); + await sleep(100); + await sendEvent(socketPath, { hook_event_name: 'TeammateIdle', session_id: tm1, transcript_path: tp1, teammate_name: teammateName, team_name: teamName }); + await sleep(200); + await sendEvent(socketPath, { hook_event_name: 'TeammateIdle', session_id: tm2, transcript_path: tp2, teammate_name: teammateName, team_name: teamName }); + await sleep(400); + + log = readLog(logPath); + const traced = log.match(/TeammateIdle: traced cks-specialist team=triage-respawn \(cross-session\)/g) ?? []; + assert.equal(traced.length, 2, `BOTH re-spawned teammates should nest (no overwrite/leak) — got ${traced.length}`); + } finally { + await stopDaemon(daemon, home); + } +}); + +test('Inactivity guard: daemon stays up past timeout while team correlation is in flight', async () => { + // Regression for the daemon-restart-wipes-map failure: an agent-teams run has + // quiet windows after spawn (waiting on specialists). The daemon must NOT hit + // its inactivity timeout while team members are unemitted, or the restart wipes + // teamMembers and breaks nesting. Uses WEAVE_INACTIVITY_MS to make it fast. + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-inacttest-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + const coordinatorSessionId = 'inact-coord-001'; + const teamName = 'triage-inact'; + const teammateName = 'cks-specialist'; + + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + weave_project: 'test/inact', wandb_api_key: 'fake-key', daemon_socket: socketPath, log_file: logPath, debug: true, + })); + const coordDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); + fs.mkdirSync(coordDir, { recursive: true }); + const coordinatorPath = path.join(coordDir, `${coordinatorSessionId}.jsonl`); + fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + // 800ms inactivity timeout so the test runs in seconds (vs the 10-min default). + const daemon = spawnDaemon(home, { WEAVE_INACTIVITY_MS: '800' }); + + try { + await waitForSocket(socketPath); + await sleep(100); + await sendEvent(socketPath, { hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); + await sendEvent(socketPath, { hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-inact' }); + // Register a team member (unemitted), then go quiet, NO TeammateIdle. + await sendEvent(socketPath, { hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_inact_1', + tool_name: 'Agent', tool_input: { prompt: 'x', subagent_type: teammateName, team_name: teamName, name: teammateName } }); + + // Wait well past the 800ms timeout (multiple ~500ms check intervals) with no activity. + await sleep(2600); + + assert.equal(await isAlive(socketPath), true, 'daemon must stay UP past the inactivity timeout while a team member is unemitted'); + assert.match(readLog(logPath), /team correlation in flight — staying up/, 'should log that it stayed up for in-flight team work'); + } finally { + await stopDaemon(daemon, home); + } +}); + +test('Cross-session: duplicate TeammateIdle does not double-emit', async () => { + const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-duptest-')); + const configDir = path.join(home, '.weave-claude-code'); + const socketPath = path.join(configDir, 'daemon.sock'); + const logPath = path.join(configDir, 'logs', 'daemon.log'); + const coordinatorSessionId = 'dup-coord-001'; + const teammateSessionId = 'dup-teammate-001'; + + const coordinatorTranscriptDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); + const subagentsDir = path.join(coordinatorTranscriptDir, 'subagents'); + fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); + fs.mkdirSync(subagentsDir, { recursive: true }); + + fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ + weave_project: 'test/duptest', + wandb_api_key: 'fake-key-for-duptest', + daemon_socket: socketPath, + log_file: logPath, + debug: true, + })); + + const agentId = 'agent-dup-xyz'; + const agentTranscriptPath = path.join(subagentsDir, `agent-${agentId}.jsonl`); + fs.writeFileSync(agentTranscriptPath, [ + JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Check storage' }] } }), + JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg-dup', + usage: { input_tokens: 50, output_tokens: 30, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + stop_reason: 'end_turn', content: [{ type: 'text', text: 'Storage OK.' }] } }), + ].join('\n') + '\n'); + fs.writeFileSync(path.join(subagentsDir, `agent-${agentId}.meta.json`), JSON.stringify({ agentType: 'storage-specialist' })); + + const coordinatorPath = path.join(coordinatorTranscriptDir, `${coordinatorSessionId}.jsonl`); + fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + const teammateTranscriptDir = path.join(home, '.claude', 'projects', 'test', teammateSessionId); + fs.mkdirSync(teammateTranscriptDir, { recursive: true }); + const teammatePath = path.join(teammateTranscriptDir, `${teammateSessionId}.jsonl`); + fs.writeFileSync(teammatePath, JSON.stringify({ type: 'system', content: [] }) + '\n'); + + const daemon = spawnDaemon(home); + + try { + await waitForSocket(socketPath); + await sleep(200); + + await sendEvent(socketPath, { hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); + await sleep(100); + await sendEvent(socketPath, { hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage' }); + await sleep(100); + await sendEvent(socketPath, { + hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, + tool_use_id: 'toolu_dup_001', tool_name: 'Agent', + tool_input: { prompt: 'Check storage', subagent_type: 'storage-specialist', team_name: 'triage-duptest', name: 'storage-specialist' }, + }); + await sleep(100); + await sendEvent(socketPath, { hook_event_name: 'PostToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_dup_001', tool_name: 'Agent', tool_response: 'dispatched' }); + await sleep(100); + await sendEvent(socketPath, { hook_event_name: 'SessionStart', session_id: teammateSessionId, transcript_path: teammatePath }); + await sleep(100); + + // First TeammateIdle, should trace + await sendEvent(socketPath, { + hook_event_name: 'TeammateIdle', session_id: teammateSessionId, transcript_path: teammatePath, + teammate_name: 'storage-specialist', team_name: 'triage-duptest', + }); + await sleep(300); + + // Second TeammateIdle (duplicate), should skip + await sendEvent(socketPath, { + hook_event_name: 'TeammateIdle', session_id: teammateSessionId, transcript_path: teammatePath, + teammate_name: 'storage-specialist', team_name: 'triage-duptest', + }); + await sleep(300); + + const log = readLog(logPath); + const traceMatches = log.match(/TeammateIdle: traced storage-specialist/g) ?? []; + assert.equal(traceMatches.length, 1, 'should trace exactly once, not twice'); + + // The second one should either hit "already emitted" or "no pending tracker", not trace again + const skipOrFallthrough = log.includes('already emitted') || log.includes('no pending tracker'); + assert.ok(skipOrFallthrough, 'duplicate idle should be skipped'); + } finally { + await stopDaemon(daemon, home); + } +}); diff --git a/tests/system-instructions-integration.test.ts b/tests/system-instructions-integration.test.ts deleted file mode 100644 index 09c84e9..0000000 --- a/tests/system-instructions-integration.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// The daemon captures instruction files from the InstructionsLoaded hook and -// stamps them on every turn root as `gen_ai.system_instructions`. The hook -// carries only file_path (not the contents), so the daemon reads each file from -// disk — these tests write real files and let the daemon read them back. The -// hook fires per file, and its order relative to SessionStart is NOT guaranteed -// (verified in daemon logs: a file can load before SessionStart), so -// instructions arriving before the session exists are buffered and drained when -// the session is created. These tests drive the real routeEvent entry point (as -// production does) so buffering, draining, dedup, and per-turn stamping are all -// exercised end-to-end against the exported spans (the public contract). - -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 type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { ATTR } from '../src/genaiSpans.ts'; -import { flushWeave, initWeaveInMemory, makeGenaiDaemon, transcriptUserLine } from './helpers.ts'; - -/** Seed a transcript file with a single user line (the first line carries the - * CC CLI version, as real transcripts do) and return its path. */ -function seedTranscript(sid: string): { dir: string; file: string } { - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-sysinstr-')); - const file = path.join(dir, `${sid}.jsonl`); - fs.writeFileSync(file, transcriptUserLine('hi', { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }) + '\n'); - return { dir, file }; -} - -/** Build an InstructionsLoaded payload the way Claude Code does: content-free, - * carrying only the path. `content` is written to a real file under `dir` keyed - * by `logicalPath`, so re-loading the same logical path rewrites the same file - * (exercising dedup) and the daemon reads the content back from disk. */ -function makeInstructionsLoader(dir: string) { - return (sid: string, logicalPath: string, content: string, loadReason: string) => { - const filePath = path.join(dir, logicalPath.replace(/[/\\]/g, '_')); - fs.writeFileSync(filePath, content); - return { hook_event_name: 'InstructionsLoaded', session_id: sid, file_path: filePath, load_reason: loadReason }; - }; -} - -function turnRoots(spans: ReadableSpan[]): ReadableSpan[] { - return spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); -} - -test('buffers InstructionsLoaded fired before SessionStart, then accumulates in load order', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-order'; - const { dir, file } = seedTranscript(sid); - const d = makeGenaiDaemon(); - try { - const loadInstr = makeInstructionsLoader(dir); - // Global CLAUDE.md loads BEFORE SessionStart (the real, non-deterministic order). - await d.routeEvent(loadInstr(sid, '/home/u/.claude/CLAUDE.md', 'GLOBAL', 'session_start')); - await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); - // Project CLAUDE.md loads AFTER SessionStart. - 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 flushWeave(); - - const [turn] = turnRoots(exporter.getFinishedSpans()); - assert.ok(turn, 'turn root exported'); - assert.equal( - turn.attributes[ATTR.SYSTEM_INSTRUCTIONS], - JSON.stringify([ - { type: 'text', content: 'GLOBAL' }, - { type: 'text', content: 'PROJECT' }, - ]), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('re-loading the same file replaces its content rather than duplicating', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-dedup'; - const { dir, file } = seedTranscript(sid); - const d = makeGenaiDaemon(); - try { - const loadInstr = makeInstructionsLoader(dir); - await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); - await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'V1', 'session_start')); - // Same path reloads (e.g. after compaction) with new content. - 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 flushWeave(); - - const [turn] = turnRoots(exporter.getFinishedSpans()); - assert.ok(turn, 'turn root exported'); - assert.equal( - turn.attributes[ATTR.SYSTEM_INSTRUCTIONS], - JSON.stringify([{ type: 'text', content: 'V2' }]), - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('stamps system instructions on every turn root (no session span to hang them on)', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-multiturn'; - const { dir, file } = seedTranscript(sid); - const d = makeGenaiDaemon(); - try { - const loadInstr = makeInstructionsLoader(dir); - await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'PROJECT', 'session_start')); - 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: 'turn one' }); - 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 flushWeave(); - - const turns = turnRoots(exporter.getFinishedSpans()); - assert.equal(turns.length, 2, 'both turn roots exported'); - const expected = JSON.stringify([{ type: 'text', content: 'PROJECT' }]); - for (const turn of turns) { - assert.equal(turn.attributes[ATTR.SYSTEM_INSTRUCTIONS], expected); - } - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('omits gen_ai.system_instructions when no instructions were loaded', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-none'; - const { dir, file } = seedTranscript(sid); - const d = makeGenaiDaemon(); - try { - 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 flushWeave(); - - const [turn] = turnRoots(exporter.getFinishedSpans()); - assert.ok(turn, 'turn root exported'); - assert.equal(turn.attributes[ATTR.SYSTEM_INSTRUCTIONS], undefined); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/teammate-idle.test.ts b/tests/teammate-idle.test.ts deleted file mode 100644 index a93a91f..0000000 --- a/tests/teammate-idle.test.ts +++ /dev/null @@ -1,739 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// Tests for the TeammateIdle handler's transcript parsing behaviour. -// -// Teammate transcripts differ from subagent transcripts in two ways: -// 1. They live at /.jsonl (not under subagents/) -// 2. The first line is an agent-setting record, not a user message: -// {"type":"agent-setting","agentSetting":"cks-specialist","sessionId":"..."} -// -// TeammateIdle payload fields the handler reads: teammate_name (agent name), -// team_name, and transcript_path (the teammate's, not the coordinator's). The -// integration test below drives this payload through the daemon to catch any -// regression in field-name reading. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { spawn } from 'node:child_process'; -import * as fs from 'node:fs'; -import * as net from 'node:net'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { readFirstTranscriptLine } from '../src/transcriptFile.ts'; -import { parseSessionFile } from '../src/parser.ts'; -import { ATTR } from '../src/genaiSpans.ts'; -import { childrenOf, flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; - -// ── helpers ────────────────────────────────────────────────────────────────── - -/** Write a fake teammate transcript to a temp file and return its path. - * - * readFirstTranscriptLine requires the path to be within os.homedir() (security - * check). We use a subdir of the home directory rather than /tmp to satisfy it. - */ -function writeTeammateTranscript(lines: object[]): string { - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-test-')); - const filePath = path.join(dir, 'abc123.jsonl'); - fs.writeFileSync(filePath, lines.map(l => JSON.stringify(l)).join('\n') + '\n'); - return filePath; -} - -// ── test data ───────────────────────────────────────────────────────────────── - -const AGENT_SETTING_LINE = { - type: 'agent-setting', - agentSetting: 'cks-specialist', - sessionId: 'abc123-session-id', -}; - -const MODE_LINE = { type: 'mode', mode: 'normal', sessionId: 'abc123-session-id' }; - -const USER_LINE = { - parentUuid: null, - isSidechain: false, - teamName: 'triage-supp-12345', - agentName: 'cks-specialist', - type: 'user', - message: { - role: 'user', - content: [{ type: 'text', text: 'Investigate the CKS cluster health.' }], - }, - timestamp: '2026-06-05T10:00:00.000Z', -}; - -const ASSISTANT_LINE = { - type: 'assistant', - message: { - role: 'assistant', - model: 'claude-opus-4-8', - id: 'msg_test123', - usage: { - input_tokens: 1000, - output_tokens: 200, - cache_read_input_tokens: 500, - cache_creation_input_tokens: 0, - }, - stop_reason: 'end_turn', - content: [{ type: 'text', text: 'The cluster looks healthy. No anomalies detected.' }], - }, - timestamp: '2026-06-05T10:00:05.000Z', -}; - -// ── tests ───────────────────────────────────────────────────────────────────── - -test('readFirstTranscriptLine: returns agentSetting from teammate transcript', () => { - const filePath = writeTeammateTranscript([AGENT_SETTING_LINE, MODE_LINE, USER_LINE, ASSISTANT_LINE]); - try { - const firstLine = readFirstTranscriptLine(filePath); - assert.ok(firstLine, 'should read first line'); - assert.equal(firstLine['type'], 'agent-setting'); - assert.equal(firstLine['agentSetting'], 'cks-specialist'); - assert.equal(firstLine['sessionId'], 'abc123-session-id'); - } finally { - fs.rmSync(path.dirname(filePath), { recursive: true }); - } -}); - -test('parseSessionFile: skips agent-setting lines, parses LLM calls from teammate transcript', () => { - const filePath = writeTeammateTranscript([AGENT_SETTING_LINE, MODE_LINE, USER_LINE, ASSISTANT_LINE]); - try { - const parsed = parseSessionFile(filePath); - assert.ok(parsed, 'parseSessionFile should return non-null'); - assert.equal(parsed.turns.length, 1, 'should produce exactly one turn'); - - const turn = parsed.turns[0]; - const calls = turn.assistantCalls(); - assert.equal(calls.length, 1, 'should have one assistant call'); - - const call = calls[0]; - assert.equal(call.model, 'claude-opus-4-8'); - assert.equal(call.usage.input_tokens, 1000); - assert.equal(call.usage.output_tokens, 200); - assert.equal(call.usage.cache_read_input_tokens, 500); - assert.equal(call.finishReason, 'end_turn'); - assert.equal(call.responseId, 'msg_test123'); - - assert.deepEqual(turn.textBlocks(), ['The cluster looks healthy. No anomalies detected.']); - } finally { - fs.rmSync(path.dirname(filePath), { recursive: true }); - } -}); - -test('TeammateIdle span tree: teammate turn carries the teammate chat span, tagged by agent name', async () => { - // Drive the per-session teammate path end-to-end in-process: SubagentStart - // (orphan) creates the Subagent marker; SubagentStop keeps it open; - // TeammateIdle emits the teammate's chat spans under a fresh teammate turn - // (the Subagent is a leaf and can't parent them). Each teammate chat span is - // tagged with `gen_ai.agent.name` so the Agents view groups it. - const exporter = await initWeaveInMemory(); - exporter.reset(); - - const home = os.homedir(); - const coordSid = 'coord-span-001'; - const coordDir = fs.mkdtempSync(path.join(home, '.weave-tmspan-')); - const coordPath = path.join(coordDir, `${coordSid}.jsonl`); - fs.writeFileSync(coordPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); - - // Subagent transcript at the path the daemon derives: - // //subagents/agent-.jsonl - const agentId = 'agent-span-abc'; - const subDir = path.join(coordDir, coordSid, 'subagents'); - fs.mkdirSync(subDir, { recursive: true }); - fs.writeFileSync(path.join(subDir, `agent-${agentId}.jsonl`), - [USER_LINE, ASSISTANT_LINE].map(l => JSON.stringify(l)).join('\n') + '\n'); - - const d = makeGenaiDaemon(); - try { - await d.routeEvent({ hook_event_name: 'SessionStart', session_id: coordSid, transcript_path: coordPath, source: 'startup', cwd: '/x' }); - await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordSid, prompt: '/triage' }); - // Orphan SubagentStart (no matching PreToolUse tracker). - await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: coordSid, agent_id: agentId, agent_type: 'cks-specialist' }); - await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: coordSid, agent_id: agentId }); - await d.routeEvent({ hook_event_name: 'TeammateIdle', session_id: coordSid, teammate_name: 'cks-specialist', team_name: 'triage-span' }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - - // The teammate's own turn root (fresh trace), tagged with the teammate name. - const teammateTurn = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'cks-specialist'); - assert.ok(teammateTurn, 'teammate turn span exists tagged with gen_ai.agent.name'); - - // The teammate chat span nests under the teammate turn and is tagged too. - const chatKids = childrenOf(spans, teammateTurn).filter(s => s.attributes[ATTR.OPERATION_NAME] === 'chat'); - assert.equal(chatKids.length, 1, 'one chat span under the teammate turn'); - const chatSpan = chatKids[0]; - assert.equal(chatSpan.attributes[ATTR.AGENT_NAME], 'cks-specialist', 'chat span tagged with the teammate name'); - - // Token counts are correct (cache-inclusive total for input). - assert.equal(chatSpan.attributes[ATTR.USAGE_INPUT_TOKENS], 1500, 'input_tokens = 1000 + 500 cache_read'); - assert.equal(chatSpan.attributes[ATTR.USAGE_OUTPUT_TOKENS], 200); - } finally { - fs.rmSync(coordDir, { recursive: true, force: true }); - } -}); - -test('TeammateIdle: multi-turn transcript emits chat spans from all turns', () => { - const turn2User = { - ...USER_LINE, - message: { ...USER_LINE.message, content: [{ type: 'text', text: 'Follow-up question.' }] }, - timestamp: '2026-06-05T10:01:00.000Z', - }; - const turn2Assistant = { - ...ASSISTANT_LINE, - message: { - ...ASSISTANT_LINE.message, - id: 'msg_turn2', - usage: { input_tokens: 800, output_tokens: 150, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - content: [{ type: 'text', text: 'Follow-up answer.' }], - }, - timestamp: '2026-06-05T10:01:05.000Z', - }; - - const filePath = writeTeammateTranscript([ - AGENT_SETTING_LINE, MODE_LINE, - USER_LINE, ASSISTANT_LINE, - turn2User, turn2Assistant, - ]); - try { - const parsed = parseSessionFile(filePath); - assert.ok(parsed); - assert.equal(parsed.turns.length, 2, 'should have 2 turns'); - - let totalCalls = 0; - for (const turn of parsed.turns) { - totalCalls += turn.assistantCalls().length; - } - assert.equal(totalCalls, 2, 'should have 2 assistant calls across both turns'); - } finally { - fs.rmSync(path.dirname(filePath), { recursive: true }); - } -}); - -// ── integration: actual payload field names ─────────────────────────────────── - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const REPO_ROOT = path.resolve(HERE, '..'); -const CLI = path.join(REPO_ROOT, 'src', 'cli.ts'); - -test('TeammateIdle: full sequence SubagentStart -> SubagentStop -> TeammateIdle traces with all turns', async () => { - // Per-session teammate sequence (SubagentStart is the entry point; PreToolUse - // not tested here): - // 1. SubagentStart (orphan, no matching tracker): creates invoke_agent span, stores transcript path - // 2. SubagentStop: span kept open (pendingTeammateIdle=true), tracker stays in SubagentTracking - // 3. TeammateIdle: finds tracker, emits all-turns chat spans, closes span - const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-inttest-')); - const configDir = path.join(home, '.weave-claude-code'); - const socketPath = path.join(configDir, 'daemon.sock'); - const logPath = path.join(configDir, 'logs', 'daemon.log'); - const coordinatorSessionId = 'inttest-coord-001'; - - // Subagent transcript must live where the daemon expects it: - // /subagents/agent-.jsonl - const coordinatorTranscriptDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); - const subagentsDir = path.join(coordinatorTranscriptDir, 'subagents'); - const agentId = 'agent-abc123def456'; - const agentTranscriptPath = path.join(subagentsDir, `agent-${agentId}.jsonl`); - - fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); - fs.mkdirSync(subagentsDir, { recursive: true }); - - fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ - weave_project: 'test/test', - wandb_api_key: 'fake-key-for-test', - daemon_socket: socketPath, - log_file: logPath, - debug: true, - })); - - // Multi-turn teammate transcript (two investigation turns) - fs.writeFileSync(agentTranscriptPath, [ - JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Investigate CKS health' }] } }), - JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg1', - usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - stop_reason: 'end_turn', content: [{ type: 'text', text: 'Phase 1: cluster looks healthy.' }] } }), - JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Dig deeper' }] } }), - JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg2', - usage: { input_tokens: 200, output_tokens: 80, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - stop_reason: 'end_turn', content: [{ type: 'text', text: 'Phase 2: no anomalies detected.' }] } }), - ].join('\n') + '\n'); - - const coordinatorPath = path.join(coordinatorTranscriptDir, `${coordinatorSessionId}.jsonl`); - fs.mkdirSync(coordinatorTranscriptDir, { recursive: true }); - fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); - - const daemon = spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { - env: { ...process.env, HOME: home }, - stdio: 'ignore', - }); - - const sendEvent = (payload: object): Promise => new Promise((resolve, reject) => { - const s = net.createConnection(socketPath); - s.on('error', reject); - s.on('connect', () => { s.end(JSON.stringify(payload)); }); - s.on('close', () => resolve()); - }); - - const waitForSocket = (): Promise => new Promise((resolve) => { - const poll = setInterval(() => { - if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } - }, 50); - }); - - const readLog = () => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; - - try { - await waitForSocket(); - await new Promise(r => setTimeout(r, 200)); - - // Step 1: Coordinator session starts and submits prompt - await sendEvent({ hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); - await new Promise(r => setTimeout(r, 100)); - await sendEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-99999' }); - await new Promise(r => setTimeout(r, 100)); - - // Step 2: SubagentStart (orphan — no matching PreToolUse) - await sendEvent({ - hook_event_name: 'SubagentStart', - session_id: coordinatorSessionId, - agent_id: agentId, - agent_type: 'cks-specialist', - transcript_path: agentTranscriptPath, - }); - await new Promise(r => setTimeout(r, 100)); - - // Step 3: SubagentStop — should keep span open (pendingTeammateIdle) - await sendEvent({ - hook_event_name: 'SubagentStop', - session_id: coordinatorSessionId, - agent_id: agentId, - agent_transcript_path: agentTranscriptPath, - }); - await new Promise(r => setTimeout(r, 100)); - - // Step 4: TeammateIdle — should close span with all-turns content - // CC sends coordinator's transcript_path (not the agent's) — daemon uses stored path instead - await sendEvent({ - hook_event_name: 'TeammateIdle', - session_id: coordinatorSessionId, - transcript_path: coordinatorPath, // coordinator's path (as CC sends it) - teammate_name: 'cks-specialist', - team_name: 'triage-inttest', - }); - await new Promise(r => setTimeout(r, 400)); - - const log = readLog(); - assert.match(log, /TeammateIdle: traced cks-specialist/, 'should trace cks-specialist'); - assert.doesNotMatch(log, /missing agent_id/, 'should not error on missing agent_id'); - assert.doesNotMatch(log, /no pending tracker for cks-specialist/, 'should find the pending tracker from SubagentStart'); - } finally { - daemon.kill(); - await new Promise(resolve => daemon.once('exit', () => resolve())); - fs.rmSync(home, { recursive: true, force: true }); - } -}); - -// ── cross-session: agent-teams (TeamCreate) model ─────────────────────────── -// -// In agent-teams, the teammate is an independent Claude session. SubagentStart -// does NOT fire for teammates. The sequence is: -// 1. Coordinator: PreToolUse(Agent, team_name) → creates tracker + team member -// 2. Teammate: SessionStart (new session_id) -// 3. Teammate: TeammateIdle (from teammate's session, NOT coordinator's) -// The cross-session team registry bridges coordinator → teammate. - -test('Cross-session: TeammateIdle from teammate session finds coordinator team member', async () => { - const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-crosstest-')); - const configDir = path.join(home, '.weave-claude-code'); - const socketPath = path.join(configDir, 'daemon.sock'); - const logPath = path.join(configDir, 'logs', 'daemon.log'); - const coordinatorSessionId = 'cross-coord-001'; - const teammateSessionId = 'cross-teammate-001'; - const teamName = 'triage-crosstest'; - const teammateName = 'cks-specialist'; - - // Coordinator transcript dir with subagents/ for transcript resolution - const coordinatorTranscriptDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); - const subagentsDir = path.join(coordinatorTranscriptDir, 'subagents'); - const agentId = 'agent-cross-abc123'; - const agentTranscriptPath = path.join(subagentsDir, `agent-${agentId}.jsonl`); - const agentMetaPath = path.join(subagentsDir, `agent-${agentId}.meta.json`); - - fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); - fs.mkdirSync(subagentsDir, { recursive: true }); - - fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ - weave_project: 'test/crosstest', - wandb_api_key: 'fake-key-for-crosstest', - daemon_socket: socketPath, - log_file: logPath, - debug: true, - })); - - // Teammate transcript (the specialist's own investigation) - fs.writeFileSync(agentTranscriptPath, [ - JSON.stringify({ type: 'agent-setting', agentSetting: teammateName, sessionId: teammateSessionId }), - JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Investigate CKS health' }] } }), - JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg-cross-1', - usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - stop_reason: 'end_turn', content: [{ type: 'text', text: 'CKS cluster is healthy.' }] } }), - ].join('\n') + '\n'); - - // Meta file for transcript resolution (resolveTeammateTranscript reads this) - fs.writeFileSync(agentMetaPath, JSON.stringify({ agentType: teammateName })); - - // Coordinator and teammate transcript files - const coordinatorPath = path.join(coordinatorTranscriptDir, `${coordinatorSessionId}.jsonl`); - fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); - - const teammateTranscriptDir = path.join(home, '.claude', 'projects', 'test', teammateSessionId); - fs.mkdirSync(teammateTranscriptDir, { recursive: true }); - const teammatePath = path.join(teammateTranscriptDir, `${teammateSessionId}.jsonl`); - fs.writeFileSync(teammatePath, JSON.stringify({ type: 'system', content: [] }) + '\n'); - - const daemon = spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { - env: { ...process.env, HOME: home }, - stdio: 'ignore', - }); - - const sendEvent = (payload: object): Promise => new Promise((resolve, reject) => { - const s = net.createConnection(socketPath); - s.on('error', reject); - s.on('connect', () => { s.end(JSON.stringify(payload)); }); - s.on('close', () => resolve()); - }); - - const waitForSocket = (): Promise => new Promise((resolve) => { - const poll = setInterval(() => { - if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } - }, 50); - }); - - const readLog = () => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; - - try { - await waitForSocket(); - await new Promise(r => setTimeout(r, 200)); - - // Step 1: Coordinator starts and submits prompt - await sendEvent({ hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); - await new Promise(r => setTimeout(r, 100)); - await sendEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-crosstest' }); - await new Promise(r => setTimeout(r, 100)); - - // Step 2: PreToolUse(Agent, team_name) in coordinator session - await sendEvent({ - hook_event_name: 'PreToolUse', - session_id: coordinatorSessionId, - tool_use_id: 'toolu_cross_001', - tool_name: 'Agent', - tool_input: { - prompt: 'Investigate CKS health', - subagent_type: teammateName, - team_name: teamName, - name: teammateName, - }, - }); - await new Promise(r => setTimeout(r, 100)); - - // Verify team member was registered - let log = readLog(); - assert.match(log, /Team member registered/, 'coordinator PreToolUse should register team member'); - - // Step 3: PostToolUse(Agent) — should NOT close the span (team mode) - await sendEvent({ - hook_event_name: 'PostToolUse', - session_id: coordinatorSessionId, - tool_use_id: 'toolu_cross_001', - tool_name: 'Agent', - tool_response: 'Agent dispatched', - }); - await new Promise(r => setTimeout(r, 100)); - - // Step 4: Teammate session starts (DIFFERENT session_id) - await sendEvent({ hook_event_name: 'SessionStart', session_id: teammateSessionId, transcript_path: teammatePath }); - await new Promise(r => setTimeout(r, 100)); - - // Step 5: TeammateIdle fires from TEAMMATE's session (the cross-session case) - await sendEvent({ - hook_event_name: 'TeammateIdle', - session_id: teammateSessionId, - transcript_path: teammatePath, - teammate_name: teammateName, - team_name: teamName, - }); - await new Promise(r => setTimeout(r, 400)); - - log = readLog(); - assert.match(log, /TeammateIdle: traced cks-specialist team=triage-crosstest \(cross-session\)/, 'should trace via cross-session path'); - assert.doesNotMatch(log, /no pending tracker for cks-specialist/, 'should NOT fall through to per-session path'); - } finally { - daemon.kill(); - await new Promise(resolve => daemon.once('exit', () => resolve())); - fs.rmSync(home, { recursive: true, force: true }); - } -}); - -test('Cross-session: re-spawn of same team::name nests BOTH (FIFO queue, no overwrite)', async () => { - // Regression for the re-spawn bug: the same team::name is spawned twice in one - // run. A second PreToolUse(Agent) for the same `${team}::${name}` must append - // to the FIFO queue, not overwrite the first still-open span (which would leak - // it and mis-attribute the first teammate's transcript). - const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-respawntest-')); - const configDir = path.join(home, '.weave-claude-code'); - const socketPath = path.join(configDir, 'daemon.sock'); - const logPath = path.join(configDir, 'logs', 'daemon.log'); - const coordinatorSessionId = 'respawn-coord-001'; - const teamName = 'triage-respawn'; - const teammateName = 'cks-specialist'; - const tm1 = 'respawn-tm-001'; - const tm2 = 'respawn-tm-002'; - - fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); - fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ - weave_project: 'test/respawn', wandb_api_key: 'fake-key', daemon_socket: socketPath, log_file: logPath, debug: true, - })); - - const coordDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); - const subagentsDir = path.join(coordDir, 'subagents'); - fs.mkdirSync(subagentsDir, { recursive: true }); - const coordinatorPath = path.join(coordDir, `${coordinatorSessionId}.jsonl`); - fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); - - const mkTeammate = (agentId: string, sid: string, text: string): string => { - fs.writeFileSync(path.join(subagentsDir, `agent-${agentId}.jsonl`), [ - JSON.stringify({ type: 'agent-setting', agentSetting: teammateName, sessionId: sid }), - JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }), - JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: `msg-${agentId}`, - usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - stop_reason: 'end_turn', content: [{ type: 'text', text }] } }), - ].join('\n') + '\n'); - fs.writeFileSync(path.join(subagentsDir, `agent-${agentId}.meta.json`), JSON.stringify({ agentType: teammateName })); - const tdir = path.join(home, '.claude', 'projects', 'test', sid); - fs.mkdirSync(tdir, { recursive: true }); - const tp = path.join(tdir, `${sid}.jsonl`); - fs.writeFileSync(tp, JSON.stringify({ type: 'system', content: [] }) + '\n'); - return tp; - }; - const tp1 = mkTeammate('respawn-a1', tm1, 'first cks investigation'); - const tp2 = mkTeammate('respawn-a2', tm2, 'second cks investigation'); - - const daemon = spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { - env: { ...process.env, HOME: home }, stdio: 'ignore', - }); - const sendEvent = (payload: object): Promise => new Promise((resolve, reject) => { - const s = net.createConnection(socketPath); - s.on('error', reject); - s.on('connect', () => { s.end(JSON.stringify(payload)); }); - s.on('close', () => resolve()); - }); - const waitForSocket = (): Promise => new Promise((resolve) => { - const poll = setInterval(() => { if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } }, 50); - }); - const readLog = () => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; - - try { - await waitForSocket(); - await new Promise(r => setTimeout(r, 200)); - await sendEvent({ hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); - await new Promise(r => setTimeout(r, 100)); - await sendEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-respawn' }); - await new Promise(r => setTimeout(r, 100)); - - // FIRST spawn of cks-specialist - await sendEvent({ hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_r1', - tool_name: 'Agent', tool_input: { prompt: 'first', subagent_type: teammateName, team_name: teamName, name: teammateName } }); - await new Promise(r => setTimeout(r, 80)); - // SECOND spawn of the SAME team::name (the re-spawn) BEFORE the first idles - await sendEvent({ hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_r2', - tool_name: 'Agent', tool_input: { prompt: 'second', subagent_type: teammateName, team_name: teamName, name: teammateName } }); - await new Promise(r => setTimeout(r, 120)); - - let log = readLog(); - assert.match(log, /queue depth 2/, 'second spawn of same key should APPEND to FIFO queue (depth 2), not overwrite'); - - // both teammate sessions start, then both idle - await sendEvent({ hook_event_name: 'SessionStart', session_id: tm1, transcript_path: tp1 }); - await sendEvent({ hook_event_name: 'SessionStart', session_id: tm2, transcript_path: tp2 }); - await new Promise(r => setTimeout(r, 100)); - await sendEvent({ hook_event_name: 'TeammateIdle', session_id: tm1, transcript_path: tp1, teammate_name: teammateName, team_name: teamName }); - await new Promise(r => setTimeout(r, 200)); - await sendEvent({ hook_event_name: 'TeammateIdle', session_id: tm2, transcript_path: tp2, teammate_name: teammateName, team_name: teamName }); - await new Promise(r => setTimeout(r, 400)); - - log = readLog(); - const traced = log.match(/TeammateIdle: traced cks-specialist team=triage-respawn \(cross-session\)/g) ?? []; - assert.equal(traced.length, 2, `BOTH re-spawned teammates should nest (no overwrite/leak) — got ${traced.length}`); - } finally { - daemon.kill(); - await new Promise(resolve => daemon.once('exit', () => resolve())); - fs.rmSync(home, { recursive: true, force: true }); - } -}); - -test('Inactivity guard: daemon stays up past timeout while team correlation is in flight', async () => { - // Regression for the daemon-restart-wipes-map failure: an agent-teams run has - // quiet windows after spawn (waiting on specialists). The daemon must NOT hit - // its inactivity timeout while team members are unemitted, or the restart wipes - // teamMembers and breaks nesting. Uses WEAVE_INACTIVITY_MS to make it fast. - const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-inacttest-')); - const configDir = path.join(home, '.weave-claude-code'); - const socketPath = path.join(configDir, 'daemon.sock'); - const logPath = path.join(configDir, 'logs', 'daemon.log'); - const coordinatorSessionId = 'inact-coord-001'; - const teamName = 'triage-inact'; - const teammateName = 'cks-specialist'; - - fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); - fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ - weave_project: 'test/inact', wandb_api_key: 'fake-key', daemon_socket: socketPath, log_file: logPath, debug: true, - })); - const coordDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); - fs.mkdirSync(coordDir, { recursive: true }); - const coordinatorPath = path.join(coordDir, `${coordinatorSessionId}.jsonl`); - fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); - - // 800ms inactivity timeout so the test runs in seconds (vs the 10-min default). - const daemon = spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { - env: { ...process.env, HOME: home, WEAVE_INACTIVITY_MS: '800' }, stdio: 'ignore', - }); - const sendEvent = (payload: object): Promise => new Promise((resolve, reject) => { - const s = net.createConnection(socketPath); - s.on('error', reject); - s.on('connect', () => { s.end(JSON.stringify(payload)); }); - s.on('close', () => resolve()); - }); - const isAlive = (): Promise => new Promise((resolve) => { - const s = net.createConnection(socketPath); - s.on('error', () => resolve(false)); - s.on('connect', () => { s.destroy(); resolve(true); }); - }); - const waitForSocket = (): Promise => new Promise((resolve) => { - const poll = setInterval(() => { if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } }, 50); - }); - const readLog = () => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; - - try { - await waitForSocket(); - await new Promise(r => setTimeout(r, 100)); - await sendEvent({ hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); - await sendEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage supp-inact' }); - // Register a team member (unemitted), then go quiet — NO TeammateIdle. - await sendEvent({ hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_inact_1', - tool_name: 'Agent', tool_input: { prompt: 'x', subagent_type: teammateName, team_name: teamName, name: teammateName } }); - - // Wait well past the 800ms timeout (multiple ~500ms check intervals) with no activity. - await new Promise(r => setTimeout(r, 2600)); - - assert.equal(await isAlive(), true, 'daemon must stay UP past the inactivity timeout while a team member is unemitted'); - assert.match(readLog(), /team correlation in flight — staying up/, 'should log that it stayed up for in-flight team work'); - } finally { - daemon.kill(); - await new Promise(resolve => daemon.once('exit', () => resolve())); - fs.rmSync(home, { recursive: true, force: true }); - } -}); - -test('Cross-session: duplicate TeammateIdle does not double-emit', async () => { - const home = fs.mkdtempSync(path.join(os.homedir(), '.weave-duptest-')); - const configDir = path.join(home, '.weave-claude-code'); - const socketPath = path.join(configDir, 'daemon.sock'); - const logPath = path.join(configDir, 'logs', 'daemon.log'); - const coordinatorSessionId = 'dup-coord-001'; - const teammateSessionId = 'dup-teammate-001'; - - const coordinatorTranscriptDir = path.join(home, '.claude', 'projects', 'test', coordinatorSessionId); - const subagentsDir = path.join(coordinatorTranscriptDir, 'subagents'); - fs.mkdirSync(path.join(configDir, 'logs'), { recursive: true }); - fs.mkdirSync(subagentsDir, { recursive: true }); - - fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify({ - weave_project: 'test/duptest', - wandb_api_key: 'fake-key-for-duptest', - daemon_socket: socketPath, - log_file: logPath, - debug: true, - })); - - const agentId = 'agent-dup-xyz'; - const agentTranscriptPath = path.join(subagentsDir, `agent-${agentId}.jsonl`); - fs.writeFileSync(agentTranscriptPath, [ - JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'Check storage' }] } }), - JSON.stringify({ type: 'assistant', message: { role: 'assistant', model: 'claude-opus-4-8', id: 'msg-dup', - usage: { input_tokens: 50, output_tokens: 30, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - stop_reason: 'end_turn', content: [{ type: 'text', text: 'Storage OK.' }] } }), - ].join('\n') + '\n'); - fs.writeFileSync(path.join(subagentsDir, `agent-${agentId}.meta.json`), JSON.stringify({ agentType: 'storage-specialist' })); - - const coordinatorPath = path.join(coordinatorTranscriptDir, `${coordinatorSessionId}.jsonl`); - fs.writeFileSync(coordinatorPath, JSON.stringify({ type: 'system', content: [] }) + '\n'); - const teammateTranscriptDir = path.join(home, '.claude', 'projects', 'test', teammateSessionId); - fs.mkdirSync(teammateTranscriptDir, { recursive: true }); - const teammatePath = path.join(teammateTranscriptDir, `${teammateSessionId}.jsonl`); - fs.writeFileSync(teammatePath, JSON.stringify({ type: 'system', content: [] }) + '\n'); - - const daemon = spawn(process.execPath, ['--import', 'tsx', CLI, 'daemon'], { - env: { ...process.env, HOME: home }, - stdio: 'ignore', - }); - const sendEvent = (payload: object): Promise => new Promise((resolve, reject) => { - const s = net.createConnection(socketPath); - s.on('error', reject); - s.on('connect', () => { s.end(JSON.stringify(payload)); }); - s.on('close', () => resolve()); - }); - const waitForSocket = (): Promise => new Promise((resolve) => { - const poll = setInterval(() => { - if (fs.existsSync(socketPath)) { clearInterval(poll); resolve(); } - }, 50); - }); - const readLog = () => fs.existsSync(logPath) ? fs.readFileSync(logPath, 'utf8') : ''; - - try { - await waitForSocket(); - await new Promise(r => setTimeout(r, 200)); - - await sendEvent({ hook_event_name: 'SessionStart', session_id: coordinatorSessionId, transcript_path: coordinatorPath }); - await new Promise(r => setTimeout(r, 100)); - await sendEvent({ hook_event_name: 'UserPromptSubmit', session_id: coordinatorSessionId, transcript_path: coordinatorPath, prompt: '/triage' }); - await new Promise(r => setTimeout(r, 100)); - await sendEvent({ - hook_event_name: 'PreToolUse', session_id: coordinatorSessionId, - tool_use_id: 'toolu_dup_001', tool_name: 'Agent', - tool_input: { prompt: 'Check storage', subagent_type: 'storage-specialist', team_name: 'triage-duptest', name: 'storage-specialist' }, - }); - await new Promise(r => setTimeout(r, 100)); - await sendEvent({ hook_event_name: 'PostToolUse', session_id: coordinatorSessionId, tool_use_id: 'toolu_dup_001', tool_name: 'Agent', tool_response: 'dispatched' }); - await new Promise(r => setTimeout(r, 100)); - await sendEvent({ hook_event_name: 'SessionStart', session_id: teammateSessionId, transcript_path: teammatePath }); - await new Promise(r => setTimeout(r, 100)); - - // First TeammateIdle — should trace - await sendEvent({ - hook_event_name: 'TeammateIdle', session_id: teammateSessionId, transcript_path: teammatePath, - teammate_name: 'storage-specialist', team_name: 'triage-duptest', - }); - await new Promise(r => setTimeout(r, 300)); - - // Second TeammateIdle (duplicate) — should skip - await sendEvent({ - hook_event_name: 'TeammateIdle', session_id: teammateSessionId, transcript_path: teammatePath, - teammate_name: 'storage-specialist', team_name: 'triage-duptest', - }); - await new Promise(r => setTimeout(r, 300)); - - const log = readLog(); - const traceMatches = log.match(/TeammateIdle: traced storage-specialist/g) ?? []; - assert.equal(traceMatches.length, 1, 'should trace exactly once, not twice'); - - // The second one should either hit "already emitted" or "no pending tracker" — not trace again - const skipOrFallthrough = log.includes('already emitted') || log.includes('no pending tracker'); - assert.ok(skipOrFallthrough, 'duplicate idle should be skipped'); - } finally { - daemon.kill(); - await new Promise(resolve => daemon.once('exit', () => resolve())); - fs.rmSync(home, { recursive: true, force: true }); - } -}); diff --git a/tests/trace-base-url.test.ts b/tests/trace-base-url.test.ts deleted file mode 100644 index bd31b13..0000000 --- a/tests/trace-base-url.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// The daemon exports OTLP spans to the Weave trace server, not the wandb API -// host. SaaS `api.wandb.ai` has no OTLP route, so setting `WANDB_BASE_URL` to -// it (the wandb SDK default) must not silently misroute traces. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { resolveDaemonConfig } from '../src/config.ts'; - -const SETTINGS = { weave_project: 'e/p', wandb_api_key: 'k' }; -const baseUrlFor = (env: Record): string => - resolveDaemonConfig(SETTINGS as never, env).baseUrl; - -test('trace base URL resolution across env combinations', () => { - // Unset → SaaS trace server default. - assert.equal(baseUrlFor({}), 'https://trace.wandb.ai'); - - // SaaS API host (and trailing-slash / scheme-case variants) remap to the - // trace server rather than the routeless api host. - assert.equal(baseUrlFor({ WANDB_BASE_URL: 'https://api.wandb.ai' }), 'https://trace.wandb.ai'); - assert.equal(baseUrlFor({ WANDB_BASE_URL: 'https://api.wandb.ai/' }), 'https://trace.wandb.ai'); - assert.equal(baseUrlFor({ WANDB_BASE_URL: 'HTTPS://API.WANDB.AI' }), 'https://trace.wandb.ai'); - - // Self-hosted / dedicated base URL passes through unchanged (trailing slash trimmed). - assert.equal(baseUrlFor({ WANDB_BASE_URL: 'https://my.wandb.io' }), 'https://my.wandb.io'); - assert.equal(baseUrlFor({ WANDB_BASE_URL: 'https://my.wandb.io/' }), 'https://my.wandb.io'); - - // Explicit trace server URL wins over WANDB_BASE_URL and is not remapped. - assert.equal( - baseUrlFor({ WF_TRACE_SERVER_URL: 'https://trace.example.io/', WANDB_BASE_URL: 'https://api.wandb.ai' }), - 'https://trace.example.io', - ); -}); diff --git a/tests/turn-span-agent-name.test.ts b/tests/turn-span-agent-name.test.ts deleted file mode 100644 index aa03ee5..0000000 --- a/tests/turn-span-agent-name.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -// 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 { ATTR, DEFAULT_AGENT_NAME } from '../src/genaiSpans.ts'; -import { flushWeave, initWeaveInMemory, makeGenaiDaemon } from './helpers.ts'; - -function writeTranscript(sessionId: string, text: string): { file: string; dir: string } { - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-agentname-')); - const file = path.join(dir, `${sessionId}.jsonl`); - fs.writeFileSync(file, JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }) + '\n'); - return { file, dir }; -} - -test('turn span: agentName drives gen_ai.agent.name', async () => { - const exporter = await initWeaveInMemory(); - - // A custom name and the default both flow through identically. - for (const name of ['my-custom-agent', DEFAULT_AGENT_NAME]) { - exporter.reset(); - const sid = `sess-${name}`; - const { file, dir } = writeTranscript(sid, 'hello'); - const d = makeGenaiDaemon(name); - try { - await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/tmp' }); - await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'hello' }); - await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); - await flushWeave(); - - const turnSpans = exporter.getFinishedSpans().filter(s => s.name === 'invoke_agent'); - assert.equal(turnSpans.length, 1, 'exactly one turn span'); - assert.equal(turnSpans[0].attributes[ATTR.AGENT_NAME], name, `gen_ai.agent.name must be "${name}"`); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - } -}); diff --git a/tests/turn-span-integration.test.ts b/tests/turn-span-integration.test.ts deleted file mode 100644 index 4764f70..0000000 --- a/tests/turn-span-integration.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -// 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 { VERSION } from '../src/setup.ts'; -import { flushWeave, initWeaveInMemory, makeGenaiDaemon, spanParentId } from './helpers.ts'; - -const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; - -function userText(ts: string, text: string, version: string) { - return { type: 'user', version, timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; -} - -function aLine(id: string, ts: string, block: Record, stop?: string) { - return { - type: 'assistant', - timestamp: ts, - message: { - role: 'assistant', - id, - model: 'claude-opus-4-8', - content: [block], - usage: USAGE, - ...(stop ? { stop_reason: stop } : {}), - }, - }; -} - -test('integration identity propagates weave.integration.* to every span (turn, chat, tool)', async () => { - const exporter = await initWeaveInMemory(); - exporter.reset(); - const sid = 'sess-bag'; - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-integ-')); - const file = path.join(dir, `${sid}.jsonl`); - fs.appendFileSync(file, JSON.stringify(userText('2026-01-01T00:00:00.000Z', 'do it', '1.2.3')) + '\n'); - - const d = makeGenaiDaemon(); - try { - 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' }); - - fs.appendFileSync(file, JSON.stringify(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'editing' })) + '\n'); - fs.appendFileSync(file, JSON.stringify(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')) + '\n'); - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); - await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await flushWeave(); - - const spans = exporter.getFinishedSpans(); - const ops = new Set(spans.map((s) => s.attributes['gen_ai.operation.name'])); - assert.ok(ops.has('invoke_agent'), 'turn span present'); - assert.ok(ops.has('chat'), 'chat span present'); - assert.ok(ops.has('execute_tool'), 'tool span present'); - - const turn = spans.find((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent'); - assert.ok(turn, 'turn span present'); - assert.equal(spanParentId(turn), undefined, 'turn span is a trace root'); - for (const s of spans) { - assert.equal(s.spanContext().traceId, turn.spanContext().traceId, `${s.name} shares the turn trace`); - } - - for (const s of spans) { - assert.equal(s.attributes['weave.integration.name'], 'weave-claude-code', `${s.name}: integration name`); - assert.equal(s.attributes['weave.integration.version'], VERSION, `${s.name}: integration version`); - assert.equal(s.attributes['weave.integration.meta.claude_code_app_version'], '1.2.3', `${s.name}: cc app version`); - } - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/turn-spans.test.ts b/tests/turn-spans.test.ts new file mode 100644 index 0000000..446dc70 --- /dev/null +++ b/tests/turn-spans.test.ts @@ -0,0 +1,365 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// Turn-span behaviour across four areas, all exercised in-process against an +// in-memory OTLP exporter via the daemon's routeEvent entry point: +// 1. integration identity propagated to every span (turn, chat, tool) +// 2. the customizable top-level agent name driving gen_ai.agent.name +// 3. interrupted-turn recovery (open turn closed by the next prompt) +// 4. system-instructions capture: buffering, dedup, and per-turn propagation +// Merged from turn-span-integration, turn-span-agent-name, interrupted-turn, +// and system-instructions-integration. + +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 type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { VERSION } from '../src/setup.ts'; +import { ATTR, DEFAULT_AGENT_NAME } from '../src/genaiSpans.ts'; +import { + flushWeave, + initWeaveInMemory, + makeGenaiDaemon, + spanParentId, + transcriptUserLine, +} from './helpers.ts'; + +// ---- shared builders ---- + +const USAGE = { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 }; + +function userText(ts: string, text: string, version: string) { + return { type: 'user', version, timestamp: ts, message: { role: 'user', content: [{ type: 'text', text }] } }; +} + +function aLine(id: string, ts: string, block: Record, stop?: string) { + return { + type: 'assistant', + timestamp: ts, + message: { + role: 'assistant', + id, + model: 'claude-opus-4-8', + content: [block], + usage: USAGE, + ...(stop ? { stop_reason: stop } : {}), + }, + }; +} + +function writeTranscript(sessionId: string, text: string): { file: string; dir: string } { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-agentname-')); + const file = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(file, JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }) + '\n'); + return { file, dir }; +} + +function assistantToolUseLine(msgId: string, toolUseId: string, ts: string): string { + return JSON.stringify({ + type: 'assistant', + timestamp: ts, + message: { + role: 'assistant', + id: msgId, + model: 'claude-opus-4-8', + usage: { input_tokens: 100, output_tokens: 10 }, + stop_reason: 'tool_use', + content: [{ type: 'tool_use', id: toolUseId, name: 'Bash', input: { command: 'sleep 999' } }], + }, + }); +} + +/** Seed a transcript file with a single user line (the first line carries the + * CC CLI version, as real transcripts do) and return its path. */ +function seedTranscript(sid: string): { dir: string; file: string } { + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-sysinstr-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, transcriptUserLine('hi', { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }) + '\n'); + return { dir, file }; +} + +/** Build an InstructionsLoaded payload the way Claude Code does: content-free, + * carrying only the path. `content` is written to a real file under `dir` keyed + * by `logicalPath`, so re-loading the same logical path rewrites the same file + * (exercising dedup) and the daemon reads the content back from disk. */ +function makeInstructionsLoader(dir: string) { + return (sid: string, logicalPath: string, content: string, loadReason: string) => { + const filePath = path.join(dir, logicalPath.replace(/[/\\]/g, '_')); + fs.writeFileSync(filePath, content); + return { hook_event_name: 'InstructionsLoaded', session_id: sid, file_path: filePath, load_reason: loadReason }; + }; +} + +function turnRoots(spans: ReadableSpan[]): ReadableSpan[] { + return spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); +} + +// ---- integration identity ---- +// Integration identity rides onto EVERY span (turn root and all children), not +// just the turn root. The daemon builds per-session integration attributes at +// SessionStart and sets them on the session's Conversation; the SDK propagates +// them to every span it emits, and routeEvent re-activates the conversation for +// each event (each runIsolated frame starts with fresh ambient state). So a chat +// or execute_tool span deep in a turn is filterable by integration just like the +// root. Assertions use the literal wire keys, those strings are the contract +// the Weave backend reads into its queryable custom-attribute maps. +// +// Driven through the real routeEvent entry point so the per-event conversation +// re-activation is exercised. + +test('integration identity propagates weave.integration.* to every span (turn, chat, tool)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-bag'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-integ-')); + const file = path.join(dir, `${sid}.jsonl`); + // First transcript line carries the CC CLI version (real CC transcripts do). + fs.appendFileSync(file, JSON.stringify(userText('2026-01-01T00:00:00.000Z', 'do it', '1.2.3')) + '\n'); + + const d = makeGenaiDaemon(); + try { + 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' }); + + // Assistant response msgA: text then tool_use (shared id), flushed before PreToolUse. + fs.appendFileSync(file, JSON.stringify(aLine('msgA', '2026-01-01T00:00:02.000Z', { type: 'text', text: 'editing' })) + '\n'); + fs.appendFileSync(file, JSON.stringify(aLine('msgA', '2026-01-01T00:00:03.000Z', { type: 'tool_use', id: 'tool_1', name: 'Edit', input: {} }, 'tool_use')) + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const ops = new Set(spans.map((s) => s.attributes['gen_ai.operation.name'])); + assert.ok(ops.has('invoke_agent'), 'turn span present'); + assert.ok(ops.has('chat'), 'chat span present'); + assert.ok(ops.has('execute_tool'), 'tool span present'); + + // The per-event conversation re-activation must not disturb the trace tree: the + // turn is still the root (no parent) and every span lives in its trace. + const turn = spans.find((s) => s.attributes['gen_ai.operation.name'] === 'invoke_agent'); + assert.ok(turn, 'turn span present'); + assert.equal(spanParentId(turn), undefined, 'turn span is a trace root'); + for (const s of spans) { + assert.equal(s.spanContext().traceId, turn.spanContext().traceId, `${s.name} shares the turn trace`); + } + + // Every span, regardless of depth, must carry the integration identity. + for (const s of spans) { + assert.equal(s.attributes['weave.integration.name'], 'weave-claude-code', `${s.name}: integration name`); + assert.equal(s.attributes['weave.integration.version'], VERSION, `${s.name}: integration version`); + assert.equal(s.attributes['weave.integration.meta.claude_code_app_version'], '1.2.3', `${s.name}: cc app version`); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ---- agent name ---- +// The resolved agent name (settings `agent_name` / `WEAVE_AGENT_NAME`) is passed +// to `weave.startTurn`, which sets it as `gen_ai.agent.name` (the span name stays +// `invoke_agent`), driving Weave's Agents-view grouping. + +test('turn span: agentName drives gen_ai.agent.name', async () => { + const exporter = await initWeaveInMemory(); + + // A custom name and the default both flow through identically. + for (const name of ['my-custom-agent', DEFAULT_AGENT_NAME]) { + exporter.reset(); + const sid = `sess-${name}`; + const { file, dir } = writeTranscript(sid, 'hello'); + const d = makeGenaiDaemon(name); + try { + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/tmp' }); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'hello' }); + // The turn span only exports on end; SessionEnd finalizes an open turn. + await d.routeEvent({ hook_event_name: 'SessionEnd', session_id: sid, reason: 'clear' }); + await flushWeave(); + + const turnSpans = exporter.getFinishedSpans().filter(s => s.name === 'invoke_agent'); + assert.equal(turnSpans.length, 1, 'exactly one turn span'); + assert.equal(turnSpans[0].attributes[ATTR.AGENT_NAME], name, `gen_ai.agent.name must be "${name}"`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +// ---- interrupted turn ---- +// A user interrupt ends a turn WITHOUT a Stop hook, so the next +// UserPromptSubmit arrives with the previous turn (and possibly its chat span) +// still open. Regression coverage for two bugs in that window: +// 1. the open turn's handle was silently overwritten, leaking its root span +// un-exported (rootless trace); +// 2. the stale activeChat's response key, finalized against the NEXT turn's +// transcript, produced an empty call group and crashed recordChat — +// killing tool tracing for the rest of the session. + +test('interrupted turn: next prompt closes the open turn and tool tracing survives', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-interrupt'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-interrupt-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, transcriptUserLine('turn one', { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }) + '\n'); + + const d = makeGenaiDaemon(); + try { + 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: 'turn one' }); + + // Turn 1's response msgA starts a tool; the user interrupts before it + // completes, so neither PostToolUse nor Stop ever fires. + fs.appendFileSync(file, assistantToolUseLine('msgA', 'tool_1', '2026-01-01T00:00:02.000Z') + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Bash', tool_input: { command: 'sleep 999' } }); + + // Turn 2 begins: the transcript's new user message starts a new parsed turn, + // making turn 1's msgA key stale relative to the latest parse. + fs.appendFileSync(file, transcriptUserLine('turn two', { timestamp: '2026-01-01T00:00:10.000Z' }) + '\n'); + await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'turn two' }); + + // Tool tracing in turn 2 must still work (this crashed on the stale key). + fs.appendFileSync(file, assistantToolUseLine('msgB', 'tool_2', '2026-01-01T00:00:12.000Z') + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_2', tool_name: 'Bash', tool_input: { command: 'sleep 999' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_2', tool_response: 'ok' }); + await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); + await flushWeave(); + + const spans = exporter.getFinishedSpans(); + const turns = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent'); + assert.equal(turns.length, 2, 'both turn roots exported (interrupted turn not leaked)'); + + const superseded = turns.find((s) => s.attributes[ATTR.WEAVE_ORPHAN_REASON] === 'superseded_by_next_prompt'); + assert.ok(superseded, 'interrupted turn closed with the superseded orphan reason'); + + const tools = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'execute_tool'); + assert.equal(tools.length, 2, 'tool spans from both turns exported (turn 2 tracing survived)'); + + // The interrupted turn's chat span is finalized from the transcript with + // its real usage, not dropped. + const chats = spans.filter((s) => s.attributes[ATTR.OPERATION_NAME] === 'chat'); + assert.ok(chats.some((c) => c.attributes[ATTR.RESPONSE_ID] === 'msgA'), 'interrupted chat span exported'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// ---- system instructions ---- +// The daemon captures instruction files from the InstructionsLoaded hook and +// propagates them to every turn root as `gen_ai.system_instructions`. The hook +// carries only file_path (not the contents), so the daemon reads each file from +// disk, these tests write real files and let the daemon read them back. The +// hook fires per file, and its order relative to SessionStart is NOT guaranteed +// (verified in daemon logs: a file can load before SessionStart), so +// instructions arriving before the session exists are buffered and drained when +// the session is created. These tests drive the real routeEvent entry point (as +// production does) so buffering, draining, dedup, and per-turn propagation are all +// exercised end-to-end against the exported spans (the public contract). + +test('buffers InstructionsLoaded fired before SessionStart, then accumulates in load order', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-order'; + const { dir, file } = seedTranscript(sid); + const d = makeGenaiDaemon(); + try { + const loadInstr = makeInstructionsLoader(dir); + // Global CLAUDE.md loads BEFORE SessionStart (the real, non-deterministic order). + await d.routeEvent(loadInstr(sid, '/home/u/.claude/CLAUDE.md', 'GLOBAL', 'session_start')); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + // Project CLAUDE.md loads AFTER SessionStart. + 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 flushWeave(); + + const [turn] = turnRoots(exporter.getFinishedSpans()); + assert.ok(turn, 'turn root exported'); + assert.equal( + turn.attributes[ATTR.SYSTEM_INSTRUCTIONS], + JSON.stringify([ + { type: 'text', content: 'GLOBAL' }, + { type: 'text', content: 'PROJECT' }, + ]), + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('re-loading the same file replaces its content rather than duplicating', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-dedup'; + const { dir, file } = seedTranscript(sid); + const d = makeGenaiDaemon(); + try { + const loadInstr = makeInstructionsLoader(dir); + await d.routeEvent({ hook_event_name: 'SessionStart', session_id: sid, transcript_path: file, source: 'startup', cwd: '/x' }); + await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'V1', 'session_start')); + // Same path reloads (e.g. after compaction) with new content. + 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 flushWeave(); + + const [turn] = turnRoots(exporter.getFinishedSpans()); + assert.ok(turn, 'turn root exported'); + assert.equal( + turn.attributes[ATTR.SYSTEM_INSTRUCTIONS], + JSON.stringify([{ type: 'text', content: 'V2' }]), + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('propagates system instructions to every turn root (no session span to hang them on)', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-multiturn'; + const { dir, file } = seedTranscript(sid); + const d = makeGenaiDaemon(); + try { + const loadInstr = makeInstructionsLoader(dir); + await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'PROJECT', 'session_start')); + 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: 'turn one' }); + 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 flushWeave(); + + const turns = turnRoots(exporter.getFinishedSpans()); + assert.equal(turns.length, 2, 'both turn roots exported'); + const expected = JSON.stringify([{ type: 'text', content: 'PROJECT' }]); + for (const turn of turns) { + assert.equal(turn.attributes[ATTR.SYSTEM_INSTRUCTIONS], expected); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('omits gen_ai.system_instructions when no instructions were loaded', async () => { + const exporter = await initWeaveInMemory(); + exporter.reset(); + const sid = 'sess-none'; + const { dir, file } = seedTranscript(sid); + const d = makeGenaiDaemon(); + try { + 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 flushWeave(); + + const [turn] = turnRoots(exporter.getFinishedSpans()); + assert.ok(turn, 'turn root exported'); + assert.equal(turn.attributes[ATTR.SYSTEM_INSTRUCTIONS], undefined); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From ee9e8fc24d1d776775cf48a1c7068582f692a4b7 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Wed, 22 Jul 2026 10:25:12 -0700 Subject: [PATCH 11/11] test: remove stale subagent marker assertion --- tests/subagents.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/subagents.test.ts b/tests/subagents.test.ts index c1b5344..6de5468 100644 --- a/tests/subagents.test.ts +++ b/tests/subagents.test.ts @@ -227,7 +227,6 @@ test('matched subagent: tools and chats nest under its invoke_agent marker with const subInvoke = spans.find((s) => s.attributes[ATTR.OPERATION_NAME] === 'invoke_agent' && s.attributes[ATTR.AGENT_NAME] === 'Explore'); assert.ok(subInvoke, 'subagent invoke_agent marker exported'); assert.equal(spanParentId(subInvoke), turn.spanContext().spanId, 'marker nests under the turn'); - assert.equal(subInvoke.attributes[ATTR.WEAVE_SUBAGENT_SPAWNING_TOOL_CALL_ID], 'tu-agent'); assert.equal(subInvoke.attributes[ATTR.AGENT_ID], agentId, 'agent id recorded at SubagentStart'); assert.equal( subInvoke.attributes[ATTR.OUTPUT_MESSAGES],