From 845dbdf917337b8d423c247b686d2930f2b8f1a4 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Tue, 30 Jun 2026 19:31:29 -0700 Subject: [PATCH 1/2] fix(spans): propagate gen_ai.conversation.id to execute_tool spans startToolSpan was the only span builder not propagating gen_ai.conversation.id; tool spans inherited it from their root turn span at query time. If the root is lost to a hard crash (SIGKILL/OOM), the exported tool spans can't be stitched to a conversation. Propagate it directly so the tool span survives a lost root. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/daemon.ts | 1 + src/genaiSpans.ts | 4 ++ tests/interleaved-assistant-spans.test.ts | 2 + tests/tool-span-conversation-id.test.ts | 62 +++++++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 tests/tool-span-conversation-id.test.ts 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..7848b7f 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -368,6 +368,9 @@ type ToolSpanArgs = { toolName: string; toolUseId: string; toolInput: Record; + /** Stitching key: same value as the enclosing turn's `gen_ai.conversation.id`, + * propagated directly so the tool span stays attributable if its root is lost. */ + conversationId: string; displayName?: string; }; @@ -377,6 +380,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..3affb6f --- /dev/null +++ b/tests/tool-span-conversation-id.test.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. +// SPDX-License-Identifier: MIT +// SPDX-PackageName: weave-claude-code + +// An execute_tool span carries no conversation id of its own; it inherits +// gen_ai.conversation.id from its root turn span at query time. If that root is +// lost to a hard crash (SIGKILL/OOM, no graceful cleanup), the exported tool span +// can't be stitched to a conversation. Every other span builder already propagates +// the id; this drives a real PreToolUse and asserts the tool span carries it too. + +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 }); + } +}); From 6b5434978f8746fdccfb4874496fb32c01c37694 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Thu, 16 Jul 2026 22:47:38 -0700 Subject: [PATCH 2/2] chore(spans): trim redundant comments on tool span conversation id Comments duplicated the PR description verbatim; kept a one-line why. --- src/genaiSpans.ts | 3 +-- tests/tool-span-conversation-id.test.ts | 7 ++----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index 7848b7f..1678310 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -368,8 +368,7 @@ type ToolSpanArgs = { toolName: string; toolUseId: string; toolInput: Record; - /** Stitching key: same value as the enclosing turn's `gen_ai.conversation.id`, - * propagated directly so the tool span stays attributable if its root is lost. */ + /** Stitching key — same as the enclosing turn's `gen_ai.conversation.id`. */ conversationId: string; displayName?: string; }; diff --git a/tests/tool-span-conversation-id.test.ts b/tests/tool-span-conversation-id.test.ts index 3affb6f..4bc4fa6 100644 --- a/tests/tool-span-conversation-id.test.ts +++ b/tests/tool-span-conversation-id.test.ts @@ -2,11 +2,8 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// An execute_tool span carries no conversation id of its own; it inherits -// gen_ai.conversation.id from its root turn span at query time. If that root is -// lost to a hard crash (SIGKILL/OOM, no graceful cleanup), the exported tool span -// can't be stitched to a conversation. Every other span builder already propagates -// the id; this drives a real PreToolUse and asserts the tool span carries it too. +// 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';