diff --git a/src/daemon.ts b/src/daemon.ts index f2c7d25..122cfe4 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1222,6 +1222,7 @@ export class GlobalDaemon { toolName, toolUseId, toolInput, + conversationId: session.conversationId, displayName: toolDisplayName(toolName, toolInput), }); session.pendingToolCalls.set(toolUseId, { span: toolSpan, toolName, toolInput }); diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index 9d4813d..1678310 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -368,6 +368,8 @@ type ToolSpanArgs = { toolName: string; toolUseId: string; toolInput: Record; + /** Stitching key — same as the enclosing turn's `gen_ai.conversation.id`. */ + conversationId: string; displayName?: string; }; @@ -377,6 +379,7 @@ export function startToolSpan(tracer: Tracer, parentSpan: Span, args: ToolSpanAr [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; diff --git a/tests/interleaved-assistant-spans.test.ts b/tests/interleaved-assistant-spans.test.ts index fb9cf95..eb40369 100644 --- a/tests/interleaved-assistant-spans.test.ts +++ b/tests/interleaved-assistant-spans.test.ts @@ -74,6 +74,7 @@ test('chat span parents per-block assistant_text and execute_tool children in tr toolName: 'Edit', toolUseId: 'toolu_01', toolInput: { file_path: '/foo.ts' }, + conversationId: 'conv-1', }); t1.end(); emitAssistantTextSpan(tracer, chat, { @@ -84,6 +85,7 @@ test('chat span parents per-block assistant_text and execute_tool children in tr toolName: 'Edit', toolUseId: 'toolu_02', toolInput: { file_path: '/foo.test.ts' }, + conversationId: 'conv-1', }); t2.end(); emitAssistantTextSpan(tracer, chat, { diff --git a/tests/tool-span-conversation-id.test.ts b/tests/tool-span-conversation-id.test.ts new file mode 100644 index 0000000..4bc4fa6 --- /dev/null +++ b/tests/tool-span-conversation-id.test.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// Regression test: a lost root span (hard crash) orphaned already-exported +// tool spans that had no conversation id of their own. + +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 { + BasicTracerProvider, + InMemorySpanExporter, + SimpleSpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { GlobalDaemon } from '../src/daemon.ts'; +import { ATTR, OP } from '../src/genaiSpans.ts'; + +function setupTracer() { + const exporter = new InMemorySpanExporter(); + const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] }); + return { tracer: provider.getTracer('test'), exporter, provider }; +} + +function makeDaemon(tracer: unknown) { + const logFile = path.join(os.tmpdir(), `wcp-toolconv-${process.pid}.log`); + const d = new GlobalDaemon('/tmp/unused-toolconv.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude-code'); + (d as unknown as { tracer: unknown }).tracer = tracer; + return d as unknown as { routeEvent(p: Record): Promise }; +} + +test('execute_tool spans carry gen_ai.conversation.id so they stitch even without their root', async () => { + const sid = 'sess-tool-conv'; + const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-toolconv-itest-')); + const file = path.join(dir, `${sid}.jsonl`); + fs.writeFileSync(file, JSON.stringify({ type: 'user', timestamp: '2026-01-01T00:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'go' }] } }) + '\n'); + + const { tracer, exporter, provider } = setupTracer(); + const d = makeDaemon(tracer); + 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: 'go' }); + // The assistant response the tool_use belongs to must be in the transcript before + // PreToolUse, so the daemon can parent the tool span under the right chat span. + fs.appendFileSync(file, JSON.stringify({ type: 'assistant', timestamp: '2026-01-01T00:00:02.000Z', message: { role: 'assistant', id: 'msgA', model: 'claude-opus-4-8', usage: { input_tokens: 1, output_tokens: 1 }, content: [{ type: 'tool_use', id: 'tool_1', name: 'Bash', input: { command: 'ls' } }], stop_reason: 'tool_use' } }) + '\n'); + await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'tool_1', tool_name: 'Bash', tool_input: { command: 'ls' } }); + await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'tool_1', tool_response: 'ok' }); + await provider.forceFlush(); + + const tool = exporter.getFinishedSpans().find((s) => s.attributes[ATTR.OPERATION_NAME] === OP.EXECUTE_TOOL); + assert.ok(tool, 'tool span exported'); + // conversationId === sessionId for a fresh (non-resumed) session. + assert.equal(tool.attributes[ATTR.CONVERSATION_ID], sid, 'tool span carries the conversation id'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +});