From 4b0bbd8df2a92afe59ed1cb1a86621911f6f4208 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Sat, 18 Jul 2026 23:03:37 -0700 Subject: [PATCH 1/4] feat(daemon): emit GenAI spans via the weave SDK Swap the daemon's conversation/turn core onto the weave SDK genai primitives: - weave.init() replaces the hand-rolled tracer provider/OTLP exporter wiring; OTel diag warnings/errors land in the daemon log (exporter failures were silent). - One Conversation handle per session (sessionState starts it) seeds gen_ai.conversation.id, agent identity, and the integration attributes onto every span through the handle chain, so there is no ambient per-event state. runIsolated per event keeps the SDK's single-active guards clear of concurrent sessions. - Turns via conversation.startTurn: turn input rides TurnInit.userMessage, instruction files ride TurnInit.systemInstructions. Turns that end without a Stop hook (user interrupt) close as superseded_by_next_prompt instead of leaking the root span. - sessionState moves wholesale to weave-handle types; the tracker/team fields it declares are consumed by the subagent PR later in this stack. Chat (per-response LLM), tool, and subagent span emission is parked: the handlers count tool calls for the turn attrs and log, and their tests leave with them; both return rewritten in the next PRs of this stack. The plugin is not fully functional until the stack lands; each PR builds green (npm run check). Co-Authored-By: Claude Fable 5 --- src/daemon.ts | 1455 +++-------------- src/genaiSpans.ts | 38 +- src/sessionState.ts | 272 ++- tests/daemon-shutdown-finalizes-turn.test.ts | 160 -- tests/daemon-subagent-recovery.test.ts | 144 -- tests/genai-span-usage-tokens.test.ts | 98 -- tests/helpers.ts | 114 +- tests/interleave-handlers.test.ts | 208 --- tests/interleave-split-lines.test.ts | 166 -- tests/nested-subagent-nesting.test.ts | 115 -- tests/system-instructions-integration.test.ts | 61 +- tests/teammate-idle.test.ts | 760 --------- tests/tool-span-conversation-id.test.ts | 59 - tests/turn-span-agent-name.test.ts | 71 +- tests/turn-span-integration.test.ts | 119 -- tests/turn-span-system-instructions.test.ts | 72 - 16 files changed, 497 insertions(+), 3415 deletions(-) delete mode 100644 tests/daemon-shutdown-finalizes-turn.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/interleave-handlers.test.ts delete mode 100644 tests/interleave-split-lines.test.ts delete mode 100644 tests/nested-subagent-nesting.test.ts delete mode 100644 tests/teammate-idle.test.ts delete mode 100644 tests/tool-span-conversation-id.test.ts delete mode 100644 tests/turn-span-integration.test.ts delete mode 100644 tests/turn-span-system-instructions.test.ts diff --git a/src/daemon.ts b/src/daemon.ts index bcb0839..b14b994 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -5,21 +5,8 @@ import * as net from 'net'; import * as fs from 'fs'; import * as path from 'path'; -import { - Baggage, - Span, - SpanStatusCode, - Tracer, - context as otelContext, - propagation, - diag, - DiagConsoleLogger, - DiagLogLevel, -} from '@opentelemetry/api'; -import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; -import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'; -import { resourceFromAttributes } from '@opentelemetry/resources'; -import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'; +import { diag, DiagLogLevel } from '@opentelemetry/api'; +import type { Attributes } from '@opentelemetry/api'; import type { HookInput, SessionStartHookInput, @@ -36,56 +23,29 @@ import type { StopHookInput, SessionEndHookInput, } from '@anthropic-ai/claude-agent-sdk'; +import * as weave from 'weave'; import { loadSettings, VERSION } from './setup.js'; +import { appendToLog } from './utils.js'; +import { parseSessionFd } from './parser.js'; +import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; +import { + ATTR, + CompactionAttrs, + setCompactionAttrs, + assistantOutputMessages, + snippet, +} from './genaiSpans.js'; import { resolveDaemonConfig, daemonConfigFingerprint, missingConfig } from './config.js'; +import type { DaemonConfig } from './config.js'; import { - resolvePermissionIfPending, - hashPrompt, - computeSubagentTranscriptPath, - extractUserMessageContent, lastAssistantTextEndsWith, - readSubagentFirstLineWithRetry, newSessionState, upsertInstruction, } from './sessionState.js'; import type { - PendingToolCall, - SubagentTracker, - TeamMember, SessionState, LoadedInstruction, } from './sessionState.js'; -import { appendToLog, deepEqual } from './utils.js'; -import { - parseSessionFd, - extractAssistantTextBlocks, - isTextBlock, - isThinkingBlock, - isRedactedThinkingBlock, -} from './parser.js'; -import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; -import { - ATTR, - CompactionAttrs, - IntegrationBaggageSpanProcessor, - createIntegrationBaggage, - startTurnSpan, - startToolSpan, - startInvokeAgentSpan, - startChatSpan, - finalizeChatSpan, - emitAssistantTextSpan, - emitThinkingSpan, - emitChatSpansFromAssistantCalls, - addPermissionRequestEvent, - addPermissionResolvedEvent, - setCompactionAttrs, - toolDisplayName, - promptSnippet, - jsonStr, - parseTimestamp, -} from './genaiSpans.js'; -import type { AssistantCallDetail, ParsedSession } from './parser.js'; // ───────────────────────────────────────────────────────────────────────────── // Types @@ -108,75 +68,6 @@ function isControlMessage(payload: unknown): payload is ControlMessage { return cmd === 'shutdown' || cmd === 'config-hash'; } -/** Stable identity for an assistant API call within a turn. Anthropic returns - * a `message.id` on every response; that's the primary key. When it's - * missing (legacy transcripts), fall back to the index, which is stable - * within a single parse + turn. */ -function chatMessageKey(call: AssistantCallDetail, callIdx: number): string { - return call.responseId ?? `idx:${callIdx}`; -} - -/** All calls belonging to one assistant API response, in transcript order. - * Claude Code splits a single response's thinking / text / tool_use blocks - * across separate transcript lines that share a `message.id`; the parser maps - * each line to its own `AssistantCallDetail`, so this regroups them by key. */ -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; -} - -/** Find the response key of the assistant call whose content contains a - * `tool_use` block with `toolUseId`, or undefined if not found (transcript - * not flushed yet, or unknown id). */ -function findToolUseResponseKey( - calls: AssistantCallDetail[], - toolUseId: string, -): string | undefined { - for (let ci = 0; ci < calls.length; ci++) { - for (const raw of calls[ci].contentBlocks) { - const b = raw as Record | undefined; - if (b && typeof b === 'object' && b['type'] === 'tool_use' && b['id'] === toolUseId) { - return chatMessageKey(calls[ci], ci); - } - } - } - return undefined; -} - -function parseIsoOrNow(ts: string | undefined): Date { - return parseTimestamp(ts) ?? new Date(); -} - -// ───────────────────────────────────────────────────────────────────────────── -// GlobalDaemon -// ───────────────────────────────────────────────────────────────────────────── - -// How long the daemon stays alive with no hook events before self-reaping. It -// only fires when nothing is in flight (the INFLIGHT_HOLD_MAX_MS guards keep an -// active turn/tool/team alive regardless), so this window purely governs how -// long an idle daemon stays warm for the next prompt. Set to 120 min so gaps in -// a working session (a long build, a meeting, lunch) don't reap the daemon and -// strand the resumed session on a fresh, amnesiac one, the dominant source of -// "Unknown session" drops. Longer idle gaps still reap; session reconstruction -// then recovers those. Override with WEAVE_INACTIVITY_MS. -const INACTIVITY_TIMEOUT_MS = 120 * 60 * 1_000; // 120 minutes -// Absolute ceiling for holding the daemon open past the normal inactivity -// timeout while work is still in flight — either cross-session team -// correlation (hasUnemittedTeamMembers) or an ordinary open turn / pending -// tool / tracked subagent (hasInFlightWork); see checkInactivity. Bounds the -// pathological case (a teammate that never emits TeammateIdle, or a stuck -// session) so an in-flight entry can't pin the daemon forever. -const INFLIGHT_HOLD_MAX_MS = 60 * 60 * 1_000; // 60 minutes -const CONNECTION_TIMEOUT_MS = 5_000; // 5 seconds per connection - -const MAX_SOCKET_PAYLOAD_BYTES = 4 * 1024 * 1024; // 4 MiB per message - /** 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 @@ -190,6 +81,20 @@ function daemonEntryPath(): string { } } +// ───────────────────────────────────────────────────────────────────────────── +// GlobalDaemon +// ───────────────────────────────────────────────────────────────────────────── + +// Idle window before self-reap; fires only with nothing in flight. Long enough +// that mid-session gaps don't strand a resumed session. WEAVE_INACTIVITY_MS overrides. +const INACTIVITY_TIMEOUT_MS = 120 * 60 * 1_000; // 120 minutes +// Ceiling on holding past the idle timeout while work is in flight, so a stuck +// session or silent teammate can't pin the daemon forever (see checkInactivity). +const INFLIGHT_HOLD_MAX_MS = 60 * 60 * 1_000; // 60 minutes +const CONNECTION_TIMEOUT_MS = 5_000; // 5 seconds per connection + +const MAX_SOCKET_PAYLOAD_BYTES = 4 * 1024 * 1024; // 4 MiB per message + export class GlobalDaemon { private server?: net.Server; private running = false; @@ -203,35 +108,24 @@ export class GlobalDaemon { * hook can fire before SessionStart). Keyed by session_id; drained into the * session at SessionStart / reconstruction and cleared (also on SessionEnd). */ private pendingInstructions = new Map(); - private provider: NodeTracerProvider | null = null; - private tracer: Tracer | null = null; - /** Cross-session team correlation, keyed by `${team_name}::${name}`. Bridges - * the coordinator's PreToolUse(Agent) to each teammate's TeammateIdle. The - * value is a FIFO queue: a re-spawned `${team}::${name}` appends rather than - * overwriting, so two live spans for the same name never collide. */ - private teamMembers = new Map(); + /** True once `weave.init` has completed. All span emission is gated on it. */ + private tracingEnabled = false; constructor( private readonly socketPath: string, private readonly logFile: string, - private readonly weaveProject: string | null, - private readonly apiKey: string | null, - private readonly baseUrl: string, - private readonly debugEnabled: boolean, - private readonly agentName: string, + private readonly config: DaemonConfig, ) {} async start(): Promise { - // Initialize the OTel tracer if Weave is configured - if (this.weaveProject && this.apiKey) { + if (this.config.weaveProject && this.config.apiKey) { try { - this.initTracer(); - this.log('INFO', `OTel tracer initialized — project=${this.weaveProject}, endpoint=${this.baseUrl}/agents/otel/v1/traces`); - this.log('INFO', `View traces: https://wandb.ai/${this.weaveProject}/weave/agents`); + await this.initWeave(); + this.log('INFO', `OTel tracer initialized — project=${this.config.weaveProject}, endpoint=${this.config.baseUrl}/agents/otel/v1/traces`); + this.log('INFO', `View traces: https://wandb.ai/${this.config.weaveProject}/weave/agents`); } catch (err) { this.log('ERROR', `Failed to initialize OTel tracer: ${err} — continuing without tracing`); - this.provider = null; - this.tracer = null; + this.tracingEnabled = false; } } else { this.log('INFO', 'No weave_project / API key configured — tracing disabled'); @@ -248,13 +142,12 @@ export class GlobalDaemon { process.on('SIGTERM', () => void this.shutdown('SIGTERM')); process.on('SIGINT', () => void this.shutdown('SIGINT')); - // Without SIGHUP, Node terminates the process on terminal close with no JS - // handler — leaving the socket inode behind for the next hook event to - // mistake for a live daemon. Routing SIGHUP through shutdown() unlinks it. + // Without SIGHUP, Node exits on terminal close with no JS handler, leaving + // the socket inode behind for the next hook to mistake for a live daemon. + // Routing it through shutdown() unlinks it. process.on('SIGHUP', () => void this.shutdown('SIGHUP')); - // Belt-and-suspenders: catch any non-signal exit (uncaught exception, - // process.exit from elsewhere) and remove the inode. Does NOT cover SIGKILL - // or OOM — the hook handler's probe handles those at next event. + // Remove the inode on any non-signal exit; SIGKILL/OOM aren't coverable and + // are handled by the hook handler's probe at the next event. process.on('exit', () => { try { if (fs.existsSync(this.socketPath)) fs.unlinkSync(this.socketPath); } catch { /* nothing more we can do */ } }); @@ -326,43 +219,32 @@ export class GlobalDaemon { // ── tracer initialization ─────────────────────────────────────────────── - private initTracer(): void { - if (!this.weaveProject) throw new Error('weaveProject required to init tracer'); - if (!this.apiKey) throw new Error('apiKey required to init tracer'); + private async initWeave(): Promise { + if (!this.config.weaveProject) throw new Error('weaveProject required to init tracer'); + if (!this.config.apiKey) throw new Error('apiKey required to init tracer'); - const [entity, project] = this.weaveProject.split('/', 2); + const [entity, project] = this.config.weaveProject.split('/', 2); if (!entity || !project) { - throw new Error(`Invalid weave_project format: '${this.weaveProject}' (expected entity/project)`); - } - - // Route OTel diagnostics into the daemon log so exporter errors surface. - if (this.debugEnabled) { - diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN); - } - - const resource = resourceFromAttributes({ - // service.name has always mirrored the agent name; keep that coupling - // so a custom agent_name renames the OTel service too. - 'service.name': this.agentName, - 'service.version': VERSION, - 'wandb.entity': entity, - 'wandb.project': project, - }); - - const exporter = new OTLPTraceExporter({ - url: `${this.baseUrl}/agents/otel/v1/traces`, - headers: { 'wandb-api-key': this.apiKey }, - }); + throw new Error(`Invalid weave_project format: '${this.config.weaveProject}' (expected entity/project)`); + } + + // The SDK reads apiKey/host from env only (weave login() writes netrc; wrong + // for a daemon): WF_TRACE_SERVER_URL aims the OTLP exporter, WANDB_API_KEY + // auths. WANDB_BASE_URL must stay unset or a wrong trace URL is derived. + process.env['WF_TRACE_SERVER_URL'] = this.config.baseUrl; + process.env['WANDB_API_KEY'] = this.config.apiKey; + + // Route OTel diagnostics into the daemon log; the batch exporter otherwise + // fails silently (a bad key or unreachable host drops every span unlogged). + const otelDiag = (message: string, ...args: unknown[]) => + this.log('ERROR', `otel: ${message}${args.length ? ` ${args.map(String).join(' ')}` : ''}`); + diag.setLogger( + { verbose: otelDiag, debug: otelDiag, info: otelDiag, warn: otelDiag, error: otelDiag }, + DiagLogLevel.WARN, + ); - this.provider = new NodeTracerProvider({ - resource, - // IntegrationBaggageSpanProcessor runs first so it stamps the integration - // identity (from the active session baggage) onto every span before the - // batch processor exports it. - spanProcessors: [new IntegrationBaggageSpanProcessor(), new BatchSpanProcessor(exporter)], - }); - this.provider.register(); - this.tracer = this.provider.getTracer('weave-claude-code', VERSION); + await weave.init(this.config.weaveProject); + this.tracingEnabled = true; } // ── connection handling ─────────────────────────────────────────────────── @@ -415,12 +297,8 @@ export class GlobalDaemon { if (isControlMessage(payload)) { if (payload.command === 'config-hash') { - // Reply carries the config fingerprint (for drift detection) plus the - // daemon's runtime identity, so `status` can show which build is - // actually running (pid + version + resolved entry script) rather than - // just where the CLI symlink currently points. socket.end(JSON.stringify({ - config_hash: this.configFingerprint(), + config_hash: daemonConfigFingerprint(this.config), pid: process.pid, version: VERSION, path: daemonEntryPath(), @@ -453,8 +331,8 @@ export class GlobalDaemon { // ── event routing ───────────────────────────────────────────────────────── private async routeEvent(payload: HookPayload): Promise { - // Trust the raw hook JSON against the SDK's schema once here so dispatch - // and handlers work with typed, discriminated inputs. + // Validate the socket's raw hook JSON against the SDK schema once, so the + // handlers get typed, discriminated inputs instead of re-casting per field. const input = payload as HookInput; const sessionId = input.session_id; if (!sessionId) { @@ -464,22 +342,14 @@ export class GlobalDaemon { this.log('INFO', `${input.hook_event_name} session=${sessionId}${input.agent_id ? ` agent=${input.agent_id}` : ''}`); - // Activate the session's integration baggage for the whole event so every - // span created while handling it inherits the integration identity (copied - // on by IntegrationBaggageSpanProcessor). The session and its baggage don't - // exist until SessionStart runs, so that one event dispatches unwrapped — it - // creates no spans anyway. - const session = this.sessions.get(sessionId); - const eventContext = session - ? propagation.setBaggage(otelContext.active(), session.integrationBaggage) - : otelContext.active(); - await otelContext.with(eventContext, () => - this.dispatchEvent(input, sessionId), - ); + // Each event runs in its own isolated frame so the SDK's single-active + // guards (one Conversation/Turn/LLM per frame) never trip across concurrent + // sessions; identity flows through the held handles, not the frame. + await weave.runIsolated(() => this.dispatchEvent(input, sessionId)); } - /** Narrow `input` via the discriminant and run its handler (inside the - * session's baggage context installed by `routeEvent`). */ + /** Run one hook event's handler, narrowing `input` via the discriminant; split + * from `routeEvent` so it runs inside the isolated per-event context. */ private async dispatchEvent(input: HookInput, sessionId: string): Promise { try { switch (input.hook_event_name) { @@ -564,6 +434,8 @@ export class GlobalDaemon { source, initialRequestModel, turnNumber: 0, + agentName: this.config.agentName, + tracingEnabled: this.tracingEnabled, }); this.sessions.set(sessionId, session); this.drainPendingInstructions(session); @@ -576,22 +448,9 @@ export class GlobalDaemon { ); } - /** - * Resolve the canonical `gen_ai.conversation.id` for a session by walking - * the `forkedFrom.sessionId` chain to the root. Returns `sessionId` itself - * for fresh (non-forked) sessions, or when the chain can't be resolved. - * - * `claude --continue` / `claude --resume ` produce a new process-level - * session_id but stamp every transcript line with `forkedFrom.sessionId` - * pointing at the immediate parent. Each transcript file is named after - * its session id and lives in the same project directory, so walking the - * chain is just sibling-file reads. - * - * SessionStart fires roughly when Claude Code flushes the first transcript - * line, so we retry briefly if the file isn't readable yet. The hard cap - * on chain depth is a sanity guard against pathological forking, not a - * real limit. - */ + /** Canonical `gen_ai.conversation.id`: root of the `forkedFrom.sessionId` chain, + * so `--continue`/`--resume` sessions stitch to the original. Sibling-file walk; + * the first read retries (SessionStart races the flush); depth-capped. */ private async resolveConversationId( sessionId: string, transcriptPath: string, @@ -651,15 +510,9 @@ export class GlobalDaemon { return current; } - /** - * Return the tracked session, reconstructing it from the event's - * `transcript_path` when this daemon never saw its SessionStart. The daemon - * idles out after a short quiet window and keeps all session state in memory; - * Claude Code only emits SessionStart on startup/resume/clear/compact, so a - * session that outlives a daemon restart would otherwise be permanently - * untraced (the "Unknown session" errors). Every hook event carries - * `transcript_path`, which is enough to rebuild state and resume tracing. - */ + /** Return the tracked session, reconstructing it from the event's + * `transcript_path` when this daemon never saw its SessionStart (a session + * outliving a daemon restart would otherwise go untraced). */ private async getOrReconstructSession( sessionId: string, input: HookInput, @@ -703,6 +556,8 @@ export class GlobalDaemon { source, initialRequestModel, turnNumber: priorTurns, + agentName: this.config.agentName, + tracingEnabled: this.tracingEnabled, }); this.sessions.set(sessionId, session); this.drainPendingInstructions(session); @@ -713,25 +568,12 @@ export class GlobalDaemon { return session; } - /** - * Capture one instruction file (global/project CLAUDE.md, .claude/rules, - * @-import) from InstructionsLoaded for `gen_ai.system_instructions`. - * - * The hook is observability-only and gives `file_path`, not contents, so we - * read the file ourselves. The read is synchronous to keep a session-start - * burst in load order (async reads could reorder); the files are small and - * local, off Claude Code's hot path. - * - * The hook's order vs SessionStart is not guaranteed (a file can load first), - * so instructions arriving before the session exists are buffered and drained - * on creation. We do not reconstruct here: handleSessionStart is idempotent, - * so a premature reconstruct would no-op the real one and lose its source/model. - * - * Only files loaded while this daemon runs are captured; a session - * reconstructed after a restart starts empty and picks up only files that - * (re)load afterward (e.g. load_reason=compact). - */ + /** Capture one instruction file for `gen_ai.system_instructions`. Only + * `file_path` arrives, so read it here (sync preserves load order). Early + * files buffer; reconstructing would no-op the real SessionStart. */ private handleInstructionsLoaded(sessionId: string, input: InstructionsLoadedHookInput): void { + // No tracing means no turn to set these on; skip reads nothing will consume. + if (!this.tracingEnabled) return; const filePath = input.file_path; let content: string; try { @@ -767,877 +609,123 @@ export class GlobalDaemon { this.log('DEBUG', `Drained ${pending.length} buffered instruction file(s) into session ${session.sessionId}`); } + /** Open a turn under the session's conversation, with per-turn session metadata + * (queryable without a session-level span). Each turn roots its own trace; the + * conversation handle seeds conversation.id + identity onto the whole subtree. */ + private startSessionTurn(session: SessionState, userMessage?: string): weave.Turn | undefined { + if (!session.conversation) return undefined; + const turn = session.conversation.startTurn({ + agentVersion: VERSION, + model: session.initialRequestModel, + userMessage, + systemInstructions: session.systemInstructions.map((i) => i.content), + startTime: new Date(), + }); + turn.setAttributes({ + [ATTR.WEAVE_CWD]: session.cwd, + [ATTR.WEAVE_SOURCE]: session.source, + [ATTR.WEAVE_PLUGIN_VERSION]: VERSION, + [ATTR.WEAVE_TURN_NUMBER]: session.turnNumber, + }); + session.currentTurn = turn; + return turn; + } + private async handleUserPromptSubmit(sessionId: string, input: UserPromptSubmitHookInput): Promise { - // Reconstruct the session if this daemon never saw its SessionStart (e.g. it - // idled out mid-session and a fresh daemon took over) so the rest of the - // session stays traced instead of dropping with "Unknown session". + // Reconstruct if this daemon never saw SessionStart (e.g. a fresh daemon + // took over mid-session) so the rest of the session stays traced. const session = await this.getOrReconstructSession(sessionId, input); if (!session) { this.log('ERROR', `Unknown session (no transcript_path to reconstruct): ${sessionId}`); return; } - if (!this.tracer) return; + if (!this.tracingEnabled) return; const prompt = input.prompt; this.log( 'DEBUG', - `UserPromptSubmit: session=${sessionId} current_turn_span=${session.currentTurnSpan ? 'open' : 'none'} turn_number=${session.turnNumber} prompt=${promptSnippet(prompt, 120)}`, + `UserPromptSubmit: session=${sessionId} current_turn=${session.currentTurn ? 'open' : 'none'} turn_number=${session.turnNumber} prompt=${snippet(prompt, 120)}`, ); + // A user interrupt ends a turn with no Stop hook; close any still-open turn + // as superseded before the new one overwrites the handle and leaks the root. + this.finalizeOpenTurn(session, 'superseded_by_next_prompt'); + session.turnNumber += 1; session.turnToolCalls = 0; - session.emittedChatSpanResponseKeys.clear(); - const turnSpan = startTurnSpan(this.tracer, { - sessionId: session.sessionId, - conversationId: session.conversationId, - turnNumber: session.turnNumber, - prompt, - cwd: session.cwd, - source: session.source, - pluginVersion: VERSION, - agentName: this.agentName, - requestModel: session.initialRequestModel, - displayName: `Turn ${session.turnNumber}: ${promptSnippet(prompt)}`, - systemInstructions: session.systemInstructions.map((i) => i.content), - }); - session.currentTurnSpan = turnSpan; + const turn = this.startSessionTurn(session, prompt); + if (!turn) return; // Drain compaction attrs buffered while no turn was open. if (session.pendingCompaction) { - setCompactionAttrs(turnSpan, session.pendingCompaction); + setCompactionAttrs(turn, session.pendingCompaction); session.pendingCompaction = undefined; } - - this.log( - 'INFO', - `Created turn span (turn ${session.turnNumber}) trace_id=${turnSpan.spanContext().traceId}`, - ); + this.log('INFO', `Created turn span (turn ${session.turnNumber})`); } + /** Parked: chat/tool spans (and the Agent-dispatch marker) land later in this + * stack; until then tool calls only feed the turn's tool-count attribute. */ private async handlePreToolUse(sessionId: string, input: PreToolUseHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session || !this.tracer) return; - - const agentId = input.agent_id; - const toolUseId = input.tool_use_id; - const toolName = input.tool_name; - if (!toolUseId || !toolName) return; - - // tool_input is per-tool JSON the SDK types as `unknown`; narrow to index it. - const toolInput = (input.tool_input ?? {}) as Record; - - // Parent: subagent's invoke_agent span if this PreToolUse comes from inside - // a subagent, else the current turn span. - const parentSpan = agentId - ? session.subagents.byAgentId(agentId)?.invokeAgentSpan ?? session.currentTurnSpan - : session.currentTurnSpan; - if (!parentSpan) { - this.log('ERROR', `PreToolUse: no parent span for session=${sessionId} tool=${toolName}`); - return; - } - - // For the main agent, parent the tool span under its assistant response's - // chat span; subagents keep flat parenting under their invoke_agent span. - const toolParent = this.resolveMainAgentToolParent(session, agentId, toolUseId, parentSpan); - - // Agent tool with subagent_type → emit a nested `invoke_agent ` - // span, NOT an `execute_tool Agent` span. The Weave Agents chat view renders - // nested invoke_agent spans as their own `agent_start` lifecycle marker; an - // execute_tool wrapper here would mis-render the subagent dispatch as a - // generic tool call. The Agent tool's PostToolUse closes this span with the - // subagent's final return as `gen_ai.output.messages`. - // - // Fires for the main agent AND a subagent that spawns its own subagent - // (`agentId` set): `toolParent` already resolves to the spawner's - // invoke_agent span, so the grandchild nests instead of orphaning. - // - // `promptHash` lets SubagentStart correlate this tracker to the right - // subagent deterministically by reading the subagent transcript's line 1 - // (the firing prompt) and matching by sha256 + subagent_type. - if (toolName === 'Agent' && toolInput['subagent_type']) { - const subagentType = toolInput['subagent_type'] as string; - const prompt = typeof toolInput['prompt'] === 'string' ? (toolInput['prompt'] as string) : ''; - const invokeAgentSpan = startInvokeAgentSpan(this.tracer, toolParent, { - agentType: subagentType, - conversationId: session.conversationId, - pluginVersion: VERSION, - inputMessages: prompt ? [{ role: 'user', content: prompt }] : undefined, - spawningToolCallId: toolUseId, - displayName: toolDisplayName(toolName, toolInput), - }); - // Agent-teams: when the Agent tool carries a `team_name`, the teammate - // runs as its own session and TeammateIdle fires under the teammate's - // session_id. Register the invoke_agent span in the cross-session team - // map so TeammateIdle can find it regardless of which session fires it. - const teamName = typeof toolInput['team_name'] === 'string' ? (toolInput['team_name'] as string) : undefined; - const memberName = (typeof toolInput['name'] === 'string' && toolInput['name']) ? (toolInput['name'] as string) : subagentType; - session.subagents.add({ - toolUseId, - subagentType, - detectedAt: new Date(), - invokeAgentSpan, - promptHash: hashPrompt(prompt), - teamName, - }); - if (teamName) { - // Append to the per-key FIFO queue (do NOT overwrite): the same - // `${team}::${name}` may be spawned again later in the run (e.g. TARS - // re-spawns a specialist Sonnet→Opus). Overwriting would orphan the - // first, still-open span and mis-route its teammate's transcript. - const key = `${teamName}::${memberName}`; - const queue = this.teamMembers.get(key) ?? []; - queue.push({ - invokeAgentSpan, - conversationId: session.conversationId, - coordinatorTranscriptPath: session.transcript.resolvedPath, - emitted: false, - }); - this.teamMembers.set(key, queue); - this.log('INFO', `Team member registered: ${key} (cross-session nesting, queue depth ${queue.length})`); - } - return; - } - - const toolSpan = startToolSpan(this.tracer, toolParent, { - toolName, - toolUseId, - toolInput, - conversationId: session.conversationId, - displayName: toolDisplayName(toolName, toolInput), - }); - session.pendingToolCalls.set(toolUseId, { span: toolSpan, toolName, toolInput }); - } - - /** - * Open (or reuse) the chat span for the assistant API response that produced - * `toolUseId`, so the tool span can parent under it. Reads the transcript to - * find which response the tool_use belongs to; on a transition to a new - * response, finalizes the previous chat span first. Returns the chat span, - * or `undefined` if the transcript can't be located / parsed yet (caller - * falls back to the turn span). - * - * Text/thinking children are NOT emitted here; they're emitted when the - * chat span is finalized (next transition or Stop), once all of the - * response's split transcript lines are present and can be stamped with - * their real timestamps. (Claude Code writes each content block as its own - * transcript line sharing a `message.id`; at PreToolUse the trailing lines - * may not be flushed yet.) - */ - /** - * Parent span for a tool span emitted from PreToolUse. For the main agent, - * advance the chat-span machine — find the assistant response containing this - * tool_use, ensure its `chat` span is open, and return it so the tool span - * nests under it. (The response's text/thinking children are emitted when the chat - * span is finalized, not here.) Falls back to `fallback` (the turn span) when - * the machine can't advance yet, e.g. the transcript writer hasn't flushed - * the assistant message. Subagents keep flat parenting under their - * invoke_agent span — their chat spans are emitted at SubagentStop / - * TeammateIdle, where the full transcript is available — so they return - * `fallback` unchanged. - */ - private resolveMainAgentToolParent( - session: SessionState, - agentId: string | undefined, - toolUseId: string, - fallback: Span, - ): Span { - if (agentId) return fallback; - return this.advanceMainAgentChatSpan(session, toolUseId) ?? fallback; - } - - private advanceMainAgentChatSpan(session: SessionState, toolUseId: string): Span | undefined { - if (!this.tracer || !session.currentTurnSpan) return undefined; - - // Re-parses the whole transcript per main-agent PreToolUse: O(size) per - // tool call. Off CC's critical path (async daemon), so no editor latency; - // parse the current turn's tail instead if it shows up in profiling. - let fd: number; - try { - fd = session.transcript.getFd(); - } catch { - return undefined; - } - const parsed = parseSessionFd(fd); - if (!parsed) return undefined; - const lastTurn = parsed.turns[parsed.turns.length - 1]; - if (!lastTurn) return undefined; - const calls = lastTurn.assistantCalls(); - const key = findToolUseResponseKey(calls, toolUseId); - if (!key) { - // Transcript writer hasn't flushed the assistant message yet. Fall back - // to the turn span. - return undefined; - } - - // Transition to a new API response: finalize the previous chat span first. - if (session.activeChatSpan && session.activeChatSpan.responseKey !== key) { - this.finalizeActiveChatSpan(session, calls); - } - - if (!session.activeChatSpan) { - // key came from findToolUseResponseKey above, so the group is non-empty. - const group = callsForResponseKey(calls, key); - const first = group[0]; - const span = startChatSpan(this.tracer, session.currentTurnSpan, { - conversationId: session.conversationId, - model: group.map(c => c.model).find(Boolean), - startedAt: parseIsoOrNow(first.prevTimestamp ?? first.timestamp), - }); - session.activeChatSpan = { responseKey: key, span }; - session.emittedChatSpanResponseKeys.add(key); - } - - return session.activeChatSpan.span; - } - - /** Finalize `session.activeChatSpan` from the current transcript and clear it. */ - private finalizeActiveChatSpan(session: SessionState, calls: AssistantCallDetail[]): void { - const active = session.activeChatSpan; - if (!active) return; - this.emitChatSpanForResponse(session, calls, active.responseKey, active.span); - session.activeChatSpan = undefined; - } - - /** - * Emit a complete chat span for one assistant API response `key`. Emits each - * of the response's split transcript lines' text/thinking blocks as children - * stamped with that line's timestamp, so they sort into transcript order - * among the sibling `execute_tool` spans, which carry live PreToolUse times - * on the same wall clock. Usage is taken once from the response (the split - * lines duplicate the message usage, so it must not be summed), then the span - * is ended. Reuses `existingSpan` when the span was already opened during - * PreToolUse; otherwise opens a fresh one under the turn span. - */ - private emitChatSpanForResponse( - session: SessionState, - calls: AssistantCallDetail[], - key: string, - existingSpan?: Span, - ): void { - if (!this.tracer || !session.currentTurnSpan) return; - // `key` always comes from a real call (findToolUseResponseKey / - // chatMessageKey) and transcripts are append-only, so the group is never - // empty. - const group = callsForResponseKey(calls, key); - - const model = group.map(c => c.model).find(Boolean); - const span = existingSpan ?? startChatSpan(this.tracer, session.currentTurnSpan, { - conversationId: session.conversationId, - model, - startedAt: parseIsoOrNow(group[0].prevTimestamp ?? group[0].timestamp), - }); - - for (const call of group) { - this.emitContentBlocks(span, call.contentBlocks, parseIsoOrNow(call.timestamp), session.conversationId); - } - - // Usage is identical across the response's split lines (each carries the - // full message usage), so take it from the last line, which also carries - // the stop_reason, rather than summing. - const last = group[group.length - 1]; - const finishReason = group.map(c => c.finishReason).find(Boolean); - const parts = group.flatMap(c => c.contentBlocks); - finalizeChatSpan(span, { - usage: last.usage, - reasoningTokens: last.reasoningTokens, - responseId: last.responseId, - finishReasons: finishReason ? [finishReason] : undefined, - model, - outputMessages: parts.length ? [{ role: 'assistant', parts }] : undefined, - endedAt: parseIsoOrNow(last.timestamp), - }); - session.emittedChatSpanResponseKeys.add(key); + if (!session || !this.tracingEnabled) return; + this.log('DEBUG', `PreToolUse (not yet traced): session=${sessionId} tool=${input.tool_name}`); } - /** Emit `assistant_text` / `thinking` spans for the text/thinking blocks in - * `blocks`, each stamped at `ts`. tool_use blocks are skipped; they render - * as their own live `execute_tool` spans. */ - private emitContentBlocks( - parent: Span, - blocks: unknown[], - ts: Date, - conversationId: string, - ): void { - if (!this.tracer) return; - for (const block of blocks) { - if (isTextBlock(block) && block.text.trim()) { - emitAssistantTextSpan(this.tracer, parent, { conversationId, text: block.text, startedAt: ts, endedAt: ts }); - } else if (isThinkingBlock(block) && block.thinking.trim()) { - emitThinkingSpan(this.tracer, parent, { conversationId, text: block.thinking, startedAt: ts, endedAt: ts }); - } else if (isRedactedThinkingBlock(block)) { - // Reasoning withheld by safety filtering: the `data` blob is encrypted, - // so surface a placeholder thinking span to keep it in transcript order. - emitThinkingSpan(this.tracer, parent, { conversationId, text: '[redacted]', startedAt: ts, endedAt: ts }); - } - } + private countToolCall(session: SessionState, toolName: string): void { + session.totalToolCalls += 1; + session.turnToolCalls += 1; + session.toolCounts[toolName] = (session.toolCounts[toolName] ?? 0) + 1; } + /** Parked: the permission span event lands with tool spans later in this stack. */ private async handlePermissionRequest(sessionId: string, input: PermissionRequestHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; - - const toolName = input.tool_name; - if (!toolName) return; - - // Correlate to a pending tool call by tool_name + tool_input. Record the - // permission state; the actual span event is added at PostToolUse[Failure] - // once we know whether it was approved. - 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.span, { - suggestions: input.permission_suggestions, - timestamp: new Date(), - }); - - this.log('DEBUG', `Permission request recorded for ${toolName}`); + this.log('DEBUG', `PermissionRequest (not yet traced): session=${sessionId} tool=${input.tool_name}`); } 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; - - // Subagent dispatch: the matching span is the subagent's invoke_agent - // span (not a pendingToolCall), so we close it here with the subagent's - // final assistant text as `gen_ai.output.messages`. - const subagentTracker = session.subagents.byToolUseId(toolUseId); - if (subagentTracker?.invokeAgentSpan) { - if (subagentTracker.teamName) { - // Agent-teams: the Agent tool returns immediately (teammate runs async - // in its own session). Do NOT close the invoke_agent span — it would - // end empty before the teammate works. The team map owns it now. - session.subagents.remove(subagentTracker); - } else { - this.closeSubagentInvokeAgentSpan(subagentTracker, input.tool_response, /*failure*/ false); - session.subagents.remove(subagentTracker); - } - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts['Agent'] = (session.toolCounts['Agent'] ?? 0) + 1; - return; - } - - const pending = session.pendingToolCalls.get(toolUseId); - if (!pending) return; - - resolvePermissionIfPending(pending, true); - - pending.span.setAttribute(ATTR.TOOL_CALL_RESULT, jsonStr(input.tool_response)); - pending.span.end(); - - session.pendingToolCalls.delete(toolUseId); - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts[pending.toolName] = (session.toolCounts[pending.toolName] ?? 0) + 1; + if (!session || !input.tool_name) return; + // Parked: tool spans land later in this stack; count for the turn attrs. + this.countToolCall(session, input.tool_name); } 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; - - // Subagent dispatch failed (rare). Close the invoke_agent span with ERROR - // status; subagent chat spans, if any reached SubagentStop, are already - // attached as children. - const subagentTracker = session.subagents.byToolUseId(toolUseId); - if (subagentTracker?.invokeAgentSpan) { - if (subagentTracker.teamName) { - // Agent-teams: the team map owns this span (closed at the teammate's - // TeammateIdle, cross-session). Closing it here would end it early and - // then double-end when TeammateIdle fires. Mirror handlePostToolUse: - // just drop the per-session tracker; the queue entry lives on. - session.subagents.remove(subagentTracker); - } else { - this.closeSubagentInvokeAgentSpan(subagentTracker, error, /*failure*/ true); - session.subagents.remove(subagentTracker); - } - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts['Agent'] = (session.toolCounts['Agent'] ?? 0) + 1; - return; - } - - const pending = session.pendingToolCalls.get(toolUseId); - if (!pending) return; - - resolvePermissionIfPending(pending, false); - - pending.span.setAttribute(ATTR.TOOL_CALL_RESULT, jsonStr(error)); - pending.span.setAttribute(ATTR.ERROR_TYPE, this.errorTypeFor(error)); - pending.span.setStatus({ code: SpanStatusCode.ERROR, message: typeof error === 'string' ? error : 'tool failed' }); - pending.span.end(); - - session.pendingToolCalls.delete(toolUseId); - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts[pending.toolName] = (session.toolCounts[pending.toolName] ?? 0) + 1; - } - - /** - * Close a subagent's `invoke_agent` span. Idempotent — guarded by - * `tracker.ended` so PostToolUse and SubagentStop can both safely call this - * regardless of order. Sets `gen_ai.output.messages` from the canonical - * tool return string when available; marks the span ERROR on failure. - */ - private closeSubagentInvokeAgentSpan( - tracker: SubagentTracker, - output: unknown, - failure: boolean, - ): void { - const span = tracker.invokeAgentSpan; - if (!span || tracker.ended) return; - - if (output !== undefined && output !== null && output !== '') { - const outputText = typeof output === 'string' ? output : jsonStr(output); - span.setAttribute( - ATTR.OUTPUT_MESSAGES, - jsonStr([{ role: 'assistant', content: outputText }]), - ); - } - if (failure) { - span.setAttribute(ATTR.ERROR_TYPE, this.errorTypeFor(output)); - span.setStatus({ - code: SpanStatusCode.ERROR, - message: typeof output === 'string' ? output : 'subagent failed', - }); - } - span.end(); - tracker.ended = true; + if (!session || !input.tool_name) return; + // Parked: tool spans land later in this stack; count for the turn attrs. + this.countToolCall(session, input.tool_name); } + /** Parked: subagent `invoke_agent` markers land later in this stack. */ private async handleSubagentStart(sessionId: string, input: SubagentStartHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session || !this.tracer) return; - - 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 — either the parent's PreToolUse never - // fired, or the firing prompt couldn't be read from the subagent - // transcript. Create an orphan tracker AND an orphan `invoke_agent` - // span so the subagent still produces a valid nested agent invocation - // in the chat view. The span parents under the current turn (no - // spawning tool_use_id) and has no input_messages (firing prompt - // unavailable). Closed at SubagentStop since there will be no - // PostToolUse 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, - pendingTeammateIdle: true, - }; - if (session.currentTurnSpan) { - bestTracker.invokeAgentSpan = startInvokeAgentSpan(this.tracer, session.currentTurnSpan, { - agentType, - conversationId: session.conversationId, - pluginVersion: VERSION, - displayName: `Agent: ${agentType}`, - }); - bestTracker.invokeAgentSpan.setAttribute(ATTR.WEAVE_ORPHAN_REASON, reason); - } - session.subagents.add(bestTracker); - } - - bestTracker.agentId = agentId; - if (bestTracker.invokeAgentSpan) { - // Stamp the runtime agent_id on the subagent's invoke_agent span — the - // chat view uses `gen_ai.agent.id` to label the subagent's subtree. - bestTracker.invokeAgentSpan.setAttribute(ATTR.AGENT_ID, agentId); - } - - this.log('INFO', `Subagent started: agentId=${agentId} type=${agentType} matched=${matched}`); - } - - /** The session's open turn span, opening a fresh one if a restart left the - * session without a turn, so a subagent recovered at SubagentStop has a parent. */ - private getOrReconstructTurnSpan(session: SessionState): Span | undefined { - if (session.currentTurnSpan) return session.currentTurnSpan; - if (!this.tracer) return undefined; - const turnNumber = session.turnNumber || 1; - const turnSpan = startTurnSpan(this.tracer, { - sessionId: session.sessionId, - conversationId: session.conversationId, - turnNumber, - prompt: '', - cwd: session.cwd, - source: session.source, - pluginVersion: VERSION, - agentName: this.agentName, - requestModel: session.initialRequestModel, - displayName: `Turn ${turnNumber} (reconstructed)`, - }); - session.currentTurnSpan = turnSpan; - this.log( - 'INFO', - `Reconstructed turn span (turn ${turnNumber}) after restart trace_id=${turnSpan.spanContext().traceId}`, - ); - return turnSpan; - } - - /** 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 { - if (!this.tracer) return undefined; - const turnSpan = this.getOrReconstructTurnSpan(session); - if (!turnSpan) return undefined; - const invokeAgentSpan = startInvokeAgentSpan(this.tracer, turnSpan, { - agentType, - conversationId: session.conversationId, - pluginVersion: VERSION, - displayName: `Agent: ${agentType}`, - }); - invokeAgentSpan.setAttribute(ATTR.AGENT_ID, agentId); - invokeAgentSpan.setAttribute( - ATTR.WEAVE_ORPHAN_REASON, - 'recovered at SubagentStop after daemon restart (no tracker)', - ); - const tracker: SubagentTracker = { - subagentType: agentType, - detectedAt: new Date(), - agentId, - invokeAgentSpan, - transcriptPath: computeSubagentTranscriptPath(session.transcript.resolvedPath, agentId), - }; - session.subagents.add(tracker); - this.log('INFO', `SubagentStop: recovered subagent agentId=${agentId} type=${agentType} after restart`); - return tracker; + if (!session || !this.tracingEnabled) return; + this.log('DEBUG', `SubagentStart (not yet traced): session=${sessionId} agent=${input.agent_id}`); } + /** Parked: subagent `invoke_agent` markers land later in this stack. */ 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.tracer) 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) { - this.log('ERROR', `SubagentStop: no tracker for agentId=${agentId} and none recoverable`); - return; - } - - // Chat spans for the subagent's LLM calls parent under the subagent's - // own invoke_agent span. For orphan trackers without an invoke_agent - // span (no current turn at SubagentStart), fall back to the turn span. - const chatParent = tracker.invokeAgentSpan ?? session.currentTurnSpan; - - // 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()); - // Use the last turn only. Subagent transcripts are almost always - // single-turn; the rare 2-turn case occurs when the parent agent's - // prior assistant message is carried in as pre-context on line 0 - // and the user prompt that fires the subagent appears on line 1. - // Emitting chat spans from earlier turns would mis-attribute the - // parent's LLM call to this subagent invocation. - const lastTurn = parsed?.turns[parsed.turns.length - 1]; - model = lastTurn?.primaryModel(); - lastAssistantText = lastTurn?.textBlocks().join('\n'); - - if (lastTurn) { - emitChatSpansFromAssistantCalls( - this.tracer, - chatParent, - session.conversationId, - lastTurn.assistantCalls(), - ); - } - } catch (err) { - this.log('DEBUG', `SubagentStop: could not parse transcript: ${err}`); - } finally { - agentTranscript?.close(); - } - } - - if (tracker.invokeAgentSpan) { - // Stamp the model the subagent actually ran on (Claude Code's - // SubagentStart payload doesn't carry the model; the transcript does). - if (model) { - tracker.invokeAgentSpan.setAttribute(ATTR.RESPONSE_MODEL, model); - } - // Orphan path: no PostToolUse will fire, so close the invoke_agent - // span here — unless TeammateIdle is expected to follow (FleetView/ - // Teammate pattern). In that case, keep the span open so TeammateIdle - // can emit all-turns content and close it correctly. - // Matched path: leave the span open for PostToolUse to close with the - // canonical tool_response and remove the tracker; if we removed the - // tracker here, byToolUseId at PostToolUse would miss it. - if (!tracker.ended && !tracker.toolUseId && !tracker.pendingTeammateIdle) { - this.closeSubagentInvokeAgentSpan(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. - // Orphans awaiting TeammateIdle also stay — TeammateIdle will close and remove. - if (!tracker.toolUseId && !tracker.pendingTeammateIdle) { - session.subagents.remove(tracker); - } + if (!session || !this.tracingEnabled) return; + this.log('DEBUG', `SubagentStop (not yet traced): session=${sessionId} agent=${input.agent_id}`); } + /** Parked: teammate tracing lands later in this stack. */ private async handleTeammateIdle(sessionId: string, input: TeammateIdleHookInput): Promise { - if (!this.tracer) return; - // FAIL-OPEN on a missing session: in the agent-teams model this hook fires - // under the TEAMMATE's session_id, which may not be registered with this - // daemon (only the coordinator is). `session` is therefore OPTIONAL for the - // cross-session team path and only REQUIRED for the per-session fallback. - // Do NOT early-return on a missing session — that would silently drop - // cross-session nesting, the whole point of this handler. - const session = this.sessions.get(sessionId); - - // TeammateIdle payload (actual schema, confirmed from live TARS triage): - // session_id — the teammate's session UUID (NOT the coordinator's) - // teammate_name — agent name, e.g. "cks-specialist". INVARIANT: must equal - // the `name` the coordinator passed to the Agent tool (in - // TARS, name === subagent_type), else the lookup misses. - // team_name — team name, e.g. "triage-supp-25017" - // transcript_path — CC sets this to the coordinator's transcript (not the - // teammate's), so we ignore it and use the path stored - // at SubagentStart instead. - // - // Note: CC docs incorrectly listed agent_id / agent_type — those fields do - // not appear in practice. - const agentType = input.teammate_name; - const teamName = input.team_name; - - // ── Cross-session team path (agent-teams / TeamCreate model) ───────── - // The coordinator's PreToolUse(Agent, team_name) registered the invoke_agent - // span in teamMembers under `${team_name}::${name}` (a FIFO queue). Consume - // the OLDEST not-yet-emitted entry, so re-spawns of the same name match in - // dispatch order instead of overwriting each other. - const key = `${teamName}::${agentType}`; - const queue = this.teamMembers.get(key); - if (queue && queue.length) { - const member = queue.find(m => !m.emitted); - if (!member) { - // All queued spans for this key already emitted — duplicate (repeat) - // TeammateIdle. Expected; nothing to do. - this.log('DEBUG', `TeammateIdle: ${key} all ${queue.length} entries already emitted — skipping duplicate idle`); - return; - } - member.emitted = true; - const idleTranscript = session?.transcript.resolvedPath ?? input.transcript_path; - const teammateTranscriptPath = this.resolveTeammateTranscript(member.coordinatorTranscriptPath, agentType, idleTranscript); - this.emitTeammateTranscript(member.invokeAgentSpan, member.conversationId, teammateTranscriptPath); - // Remove the consumed entry; drop the key once its queue drains. - const idx = queue.indexOf(member); - if (idx >= 0) queue.splice(idx, 1); - if (!queue.length) this.teamMembers.delete(key); - this.log('INFO', `TeammateIdle: traced ${agentType} team=${teamName} (cross-session) transcript=${teammateTranscriptPath ?? '(none)'} (queue depth now ${queue.length})`); - return; - } - - // No team entry for this key. If OTHER team keys ARE registered, this most - // likely means the teammate_name ≠ Agent.name invariant was violated — log - // it loudly (not silently) so it is debuggable, then try the per-session path. - if (this.teamMembers.size > 0) { - this.log('INFO', `TeammateIdle: no team entry for ${key} (registered: ${[...this.teamMembers.keys()].join(', ')}) — check teammate_name === Agent.name`); - } - - // ── Per-session path (individual Agent calls without team_name) ────── - // Requires the firing session to be known to this daemon. Find the orphan - // tracker created at SubagentStart; SubagentStop left its invoke_agent span - // open specifically so we can close it here with full all-turns content. - if (!session) { - this.log('DEBUG', `TeammateIdle: session ${sessionId} unknown and no team entry for ${key} — skipping`); - return; - } - const tracker = session.subagents.findPendingTeammateIdle(agentType); - - if (!tracker?.invokeAgentSpan) { - this.log('DEBUG', `TeammateIdle: no pending tracker for ${agentType} team=${teamName} — skipping`); - return; - } - - // Use the transcript path stored at SubagentStart — more reliable than - // the payload's transcript_path which CC sets to the coordinator's path. - const transcriptPath = tracker.transcriptPath; - - this.log('DEBUG', `TeammateIdle: agent=${agentType} team=${teamName} transcript=${transcriptPath ?? '(none)'}`); - - let model: string | undefined; - let lastAssistantText: string | undefined; - let agentTranscript: TranscriptFile | undefined; - try { - if (!transcriptPath) throw new Error('no transcript path stored at SubagentStart'); - agentTranscript = new TranscriptFile(transcriptPath); - const parsed = parseSessionFd(agentTranscript.getFd()); - if (parsed) { - // Emit chat spans for ALL turns. Teammates are independent top-level - // sessions — every turn is their own work. SubagentStop only emitted - // the last turn; we replace that with full coverage here. - for (const turn of parsed.turns) { - emitChatSpansFromAssistantCalls( - this.tracer, - tracker.invokeAgentSpan, - session.conversationId, - turn.assistantCalls(), - ); - } - const lastTurn = parsed.turns[parsed.turns.length - 1]; - model = lastTurn?.primaryModel(); - lastAssistantText = lastTurn?.textBlocks().join('\n'); - } - } catch (err) { - this.log('DEBUG', `TeammateIdle: could not parse transcript ${transcriptPath}: ${err}`); - } finally { - agentTranscript?.close(); - } - - if (model) tracker.invokeAgentSpan.setAttribute(ATTR.RESPONSE_MODEL, model); - if (lastAssistantText) { - tracker.invokeAgentSpan.setAttribute( - ATTR.OUTPUT_MESSAGES, - JSON.stringify([{ role: 'assistant', content: lastAssistantText }]), - ); - } - - this.closeSubagentInvokeAgentSpan(tracker, lastAssistantText, /*failure*/ false); - session.subagents.remove(tracker); - - this.log('INFO', `TeammateIdle: traced ${agentType} model=${model ?? 'unknown'} path=${transcriptPath ?? '(no transcript)'}`); - } - - /** Resolve a teammate's OWN transcript. TeammateIdle.session_id is unreliable - * (sometimes the teammate's, sometimes the coordinator's), so the idle - * session's transcript may be the coordinator's. The authoritative source is - * `/subagents/agent-.jsonl` paired with a sibling - * `agent-.meta.json` carrying `{"agentType": }`. Match by - * agentType === teammateName; pick the most-recently-modified if re-spawned. */ - private resolveTeammateTranscript( - coordinatorTranscriptPath: string, - teammateName: string, - idleTranscriptPath: string | undefined, - ): string | undefined { - try { - const projectDir = path.dirname(coordinatorTranscriptPath); - const sessionDirName = path.basename(coordinatorTranscriptPath, '.jsonl'); - const subagentsDir = path.join(projectDir, sessionDirName, 'subagents'); - if (fs.existsSync(subagentsDir)) { - let best: { p: 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 transcript = path.join(subagentsDir, meta.replace(/\.meta\.json$/, '.jsonl')); - if (!fs.existsSync(transcript)) continue; - const mtime = fs.statSync(transcript).mtimeMs; - if (!best || mtime > best.mtime) best = { p: transcript, mtime }; - } catch { /* skip malformed meta */ } - } - if (best) return best.p; - } - } catch (err) { - this.log('DEBUG', `resolveTeammateTranscript(${teammateName}): ${err}`); - } - return idleTranscriptPath; - } - - /** Parse a teammate's transcript and emit its chat spans under the given - * invoke_agent span, then end it. Used by the cross-session team path. */ - private emitTeammateTranscript( - invokeAgentSpan: Span, - conversationId: string, - transcriptPath: string | undefined, - ): void { - let model: string | undefined; - let lastAssistantText: string | undefined; - let t: TranscriptFile | undefined; - try { - if (!transcriptPath) throw new Error('no teammate transcript path'); - t = new TranscriptFile(transcriptPath); - const parsed = parseSessionFd(t.getFd()); - if (parsed && this.tracer) { - for (const turn of parsed.turns) { - emitChatSpansFromAssistantCalls(this.tracer, invokeAgentSpan, conversationId, turn.assistantCalls()); - } - const lastTurn = parsed.turns[parsed.turns.length - 1]; - model = lastTurn?.primaryModel(); - lastAssistantText = lastTurn?.textBlocks().join('\n'); - } - } catch (err) { - this.log('DEBUG', `emitTeammateTranscript: could not parse ${transcriptPath}: ${err}`); - } finally { - t?.close(); - } - if (model) invokeAgentSpan.setAttribute(ATTR.RESPONSE_MODEL, model); - if (lastAssistantText) { - invokeAgentSpan.setAttribute( - ATTR.OUTPUT_MESSAGES, - JSON.stringify([{ role: 'assistant', content: lastAssistantText }]), - ); - } - invokeAgentSpan.end(); + if (!this.tracingEnabled) return; + this.log('DEBUG', `TeammateIdle (not yet traced): session=${sessionId} teammate=${input.teammate_name}`); } private async handlePreCompact(sessionId: string, input: PreCompactHookInput): Promise { const session = this.sessions.get(sessionId); if (!session) return; - // Live CC payloads carry a summary + item counts the SDK type doesn't - // declare — read them off the raw record. + // Live CC payloads carry the compaction summary + item counts the backend + // wants, but the SDK type doesn't declare them; read them off the raw record. const raw = input as Record; const summary = raw['summary'] ?? raw['compaction_summary']; const itemsBefore = raw['items_before']; @@ -1648,8 +736,8 @@ export class GlobalDaemon { itemsAfter: typeof itemsAfter === 'number' ? itemsAfter : undefined, }; - if (session.currentTurnSpan) { - setCompactionAttrs(session.currentTurnSpan, attrs); + if (session.currentTurn) { + setCompactionAttrs(session.currentTurn, attrs); this.log('INFO', `PreCompact attached to active turn ${session.turnNumber} (session ${sessionId})`); } else { // Buffer until the next UserPromptSubmit opens a turn span. @@ -1660,67 +748,46 @@ export class GlobalDaemon { private async handleStop(sessionId: string, input: StopHookInput): Promise { const session = this.sessions.get(sessionId); - if (!session?.currentTurnSpan || !this.tracer) return; + if (!session?.currentTurn) return; // Pass last_assistant_message so the retry waits for the synthesis to - // flush — otherwise the final chat span drops when the read races the writer. + // flush; otherwise the final chat span drops when the read races the writer. const finalAssistantMessage = input.last_assistant_message; const parsedSession = await this.parseSessionFileWithRetry( session.transcript, finalAssistantMessage, ); - const currentTurn = parsedSession?.turns[parsedSession.turns.length - 1]; + const currentTurn = parsedSession?.turns.at(-1); const model = currentTurn?.primaryModel(); const transcriptTurns = parsedSession?.turns.length ?? 0; this.log( 'DEBUG', - `Stop: session=${sessionId} trace_id=${session.currentTurnSpan.spanContext().traceId} transcript_path=${session.transcript.resolvedPath} transcript_turns=${transcriptTurns} parsed_model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(input.last_assistant_message)}`, + `Stop: session=${sessionId} transcript_path=${session.transcript.resolvedPath} transcript_turns=${transcriptTurns} parsed_model=${model ?? 'unknown'} last_assistant_message_present=${Boolean(input.last_assistant_message)}`, ); - // Finalize the chat-span state machine for this turn. - // - The active chat span (open during PreToolUse) gets its trailing - // text/thinking children plus its usage attrs, then ends. - // - Assistant calls that never triggered a PreToolUse (final text-only - // message, or any other tool-less call) get a fresh chat span emitted - // here with their full content as children. - if (currentTurn) { - const calls = currentTurn.assistantCalls(); - if (session.activeChatSpan) { - this.finalizeActiveChatSpan(session, calls); - } - // Emit a chat span for every response that never opened one during - // PreToolUse (tool-less responses, or any not yet emitted). - 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); - } - } + // Parked: per-response chat spans land later in this stack; the turn root + // carries the parsed output/model until then. const parsedTexts = currentTurn?.textBlocks() ?? []; const lastMessage = input.last_assistant_message ?? ''; const assistantMessages = parsedTexts.length > 0 ? parsedTexts : (lastMessage ? [lastMessage] : []); + const turnAttrs: Attributes = { [ATTR.WEAVE_TURN_TOOL_COUNT]: session.turnToolCalls }; if (assistantMessages.length) { - session.currentTurnSpan.setAttribute( - ATTR.OUTPUT_MESSAGES, - jsonStr(assistantMessages.map((m) => ({ role: 'assistant', content: m }))), - ); + turnAttrs[ATTR.OUTPUT_MESSAGES] = assistantOutputMessages(assistantMessages); } - - // Aggregate finish reasons from per-call detail const finishReasons = currentTurn?.assistantCalls().map(c => c.finishReason).filter((r): r is string => !!r); if (finishReasons?.length) { - session.currentTurnSpan.setAttribute(ATTR.RESPONSE_FINISH_REASONS, finishReasons); + turnAttrs[ATTR.RESPONSE_FINISH_REASONS] = finishReasons; } - + session.currentTurn.setAttributes(turnAttrs); + // record(), not setAttributes: Turn.end() re-emits its internal request + // model, which would clobber a raw attribute write of the parsed model. if (model) { - session.currentTurnSpan.setAttribute(ATTR.REQUEST_MODEL, model); + session.currentTurn.record({ model }); } - - session.currentTurnSpan.setAttribute(ATTR.WEAVE_TURN_TOOL_COUNT, session.turnToolCalls); - session.currentTurnSpan.end(); - session.currentTurnSpan = undefined; + session.currentTurn.end(); + session.currentTurn = undefined; this.log('INFO', `Finished turn ${session.turnNumber} (${session.turnToolCalls} tools)`); } @@ -1746,75 +813,22 @@ export class GlobalDaemon { session.transcript.close(); } - /** - * End every span still open on a session — pending tool calls, the active - * chat span, the current turn (root) span, and any tracked subagent - * `invoke_agent` spans — stamping `weave.claude_code.orphan_reason` so the - * trace records why each closed outside its normal path. The active chat - * span is finalized from the transcript (recovering its text + usage) like - * Stop does; only a failed parse falls back to a bare orphan close. - * - * Called from SessionEnd and from `drain` (daemon shutdown). Finalizing at - * shutdown is what keeps a turn's root span exported: without it, a turn - * interrupted by an inactivity/signal/restart shutdown leaks its still-open - * root, leaving its already-exported tool/chat children rootless. Idempotent - * per span — each builder ends at most once. - */ + /** End every still-open span on the session, setting `orphan_reason`; finalizing + * at shutdown keeps an interrupted turn's root exported. Idempotent. (Only the + * turn root exists at this point in the stack.) */ private finalizeSession(session: SessionState, orphanReason: string): void { - // Close any pending tool calls that were never completed - for (const [toolUseId, pending] of session.pendingToolCalls) { - resolvePermissionIfPending(pending, false); - pending.span.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - pending.span.setStatus({ code: SpanStatusCode.ERROR, message: 'tool did not complete before shutdown' }); - pending.span.end(); - this.log('DEBUG', `Closed orphaned tool span: ${toolUseId} (${pending.toolName})`); - } - session.pendingToolCalls.clear(); - - // Finalize a chat span left open mid-turn (Stop never fired) from the - // now-flushed transcript, like Stop does, so its text + usage aren't lost. - // Bare orphan close only if the parse fails or the turn span is gone. - if (session.activeChatSpan) { - let finalized = false; - if (session.currentTurnSpan) { - let parsed: ParsedSession | null = null; - try { - parsed = parseSessionFd(session.transcript.getFd()); - } catch { - parsed = null; - } - const lastTurn = parsed?.turns[parsed.turns.length - 1]; - if (lastTurn) { - this.finalizeActiveChatSpan(session, lastTurn.assistantCalls()); - finalized = true; - } - } - if (session.activeChatSpan) { - session.activeChatSpan.span.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - session.activeChatSpan.span.end(); - session.activeChatSpan = undefined; - } - this.log('DEBUG', finalized ? `Finalized active chat span` : `Closed orphaned chat span`); - } - - // Close the current turn (root) span if still open - if (session.currentTurnSpan) { - session.currentTurnSpan.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - session.currentTurnSpan.end(); - session.currentTurnSpan = undefined; - this.log('DEBUG', `Closed orphaned turn span`); - } + 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.invokeAgentSpan && !tracker.ended) { - tracker.invokeAgentSpan.setAttribute(ATTR.WEAVE_ORPHAN_REASON, orphanReason); - tracker.invokeAgentSpan.setStatus({ code: SpanStatusCode.ERROR, message: 'subagent did not complete before shutdown' }); - tracker.invokeAgentSpan.end(); - tracker.ended = true; - } - this.log('DEBUG', `Subagent tracker not stopped: ${tracker.agentId ?? '(unmatched)'} type=${tracker.subagentType}`); + /** Close the still-open turn (root) span, setting `orphanReason`. Also called at + * UserPromptSubmit when an interrupt ended the turn with no Stop hook, else the + * next turn overwrites the handle and leaks the root unexported. */ + private finalizeOpenTurn(session: SessionState, orphanReason: string): void { + if (session.currentTurn) { + session.currentTurn.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); + session.currentTurn.end(); + session.currentTurn = undefined; + this.log('DEBUG', `Closed orphaned turn span (${orphanReason})`); } } @@ -1823,23 +837,8 @@ export class GlobalDaemon { private checkInactivity(): void { const idle = Date.now() - this.lastActivity; if (idle <= this.inactivityMs) return; - // Do NOT shut down while cross-session team correlation is in flight: a - // shutdown wipes the in-memory teamMembers map and breaks nesting for every - // still-open specialist span. Agent-teams runs have quiet windows (engineer - // think-time; gaps between spawn and first teammate report) that would - // otherwise trip the 10-min timeout mid-triage. Hold open until the team - // work drains, bounded by INFLIGHT_HOLD_MAX_MS so a crashed teammate that - // never emits TeammateIdle can't pin the daemon indefinitely. - if (idle < INFLIGHT_HOLD_MAX_MS && this.hasUnemittedTeamMembers()) { - this.log('DEBUG', 'Inactivity timeout reached but team correlation in flight — staying up'); - return; - } - // Also hold open while ordinary work is in flight: an open turn span, a - // pending tool call, or a tracked subagent. A long-running tool or turn - // (longer than the timeout, with no other session active) would otherwise - // trip the timeout mid-flight — dropping the still-open spans and forcing - // the resumed work onto a fresh, amnesiac daemon. Same INFLIGHT_HOLD_MAX_MS - // ceiling so a stuck session can't pin the daemon indefinitely. + // Hold open while work is in flight (open turn/tool/subagent) so it isn't cut + // off; the INFLIGHT_HOLD_MAX_MS ceiling stops a stuck session pinning us. if (idle < INFLIGHT_HOLD_MAX_MS && this.hasInFlightWork()) { this.log('DEBUG', 'Inactivity timeout reached but work in flight — staying up'); return; @@ -1848,21 +847,12 @@ export class GlobalDaemon { void this.shutdown('inactivity'); } - /** True if any registered team member still awaits its TeammateIdle. Used to - * keep the daemon alive across an agent-teams run's quiet windows. */ - private hasUnemittedTeamMembers(): boolean { - for (const queue of this.teamMembers.values()) { - if (queue.some(m => !m.emitted)) return true; - } - return false; - } - /** True if any session has work in flight: an open turn span, a pending tool * call, or a tracked subagent. Keeps the daemon alive across the inactivity * timeout so in-flight work isn't cut off mid-flight (see checkInactivity). */ private hasInFlightWork(): boolean { for (const s of this.sessions.values()) { - if (s.currentTurnSpan) return true; + if (s.currentTurn) return true; if (s.pendingToolCalls.size > 0) return true; if (s.subagents.size() > 0) return true; } @@ -1876,38 +866,25 @@ export class GlobalDaemon { process.exit(0); } - /** - * Everything a shutdown does except the final `process.exit`: end in-flight - * spans, flush the exporter, and release the socket. Split out from - * `shutdown` so it can be exercised in tests without terminating the process. - * - * Order matters: open sessions are finalized (their root turn spans ended) - * BEFORE `provider.shutdown()` flushes, so those roots make the final export - * batch instead of being dropped — the fix for rootless traces left behind - * when the daemon exits mid-turn. - */ + /** Shutdown minus the final `process.exit` (split out for tests). Sessions are + * finalized before `weave.flushOTel()` so the just-ended roots make the final + * export batch instead of being left rootless. */ private async drain(reason: string): Promise { this.log('INFO', `Shutdown: ${reason}`); this.server?.close(); - // Backstop: close any queued team-member invoke_agent spans whose teammate - // never emitted a TeammateIdle (e.g. teammate crashed, or daemon exits - // mid-triage) so they flush as ended spans instead of leaking. - for (const [, queue] of this.teamMembers) { - for (const m of queue) { - if (!m.emitted) { try { m.invokeAgentSpan.end(); } catch { /* best effort */ } } - } - } - this.teamMembers.clear(); - // Finalize every live session's still-open spans (turn root, active chat, - // pending tools, subagents) so an interrupted turn keeps its exported root. + // Per-session try: one bad session must not abort the flush below. for (const session of this.sessions.values()) { - this.finalizeSession(session, 'daemon_shutdown'); + try { + this.finalizeSession(session, 'daemon_shutdown'); + } catch (err) { + this.log('ERROR', `Error finalizing session ${session.sessionId} at shutdown: ${err}`); + } } - if (this.provider) { + if (this.tracingEnabled) { try { - await this.provider.shutdown(); + await weave.flushOTel(); } catch (err) { - this.log('ERROR', `Error shutting down OTel provider: ${err}`); + this.log('ERROR', `Error flushing Weave SDK: ${err}`); } } for (const session of this.sessions.values()) { @@ -1958,40 +935,13 @@ export class GlobalDaemon { this.sessionQueues.set(sessionId, next); } - /** Categorize an error value into a short identifier for `error.type`. */ - private errorTypeFor(error: unknown): string { - if (typeof error === 'string') { - const trimmed = error.trim(); - if (!trimmed) return 'tool_error'; - // Take the first word that looks like a category label - 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'; - } - - /** Fingerprint of the config this daemon loaded at startup. Held in memory; - * replied over the socket for the `config-hash` control message. */ - private configFingerprint(): string { - return daemonConfigFingerprint({ - weaveProject: this.weaveProject, - apiKey: this.apiKey, - baseUrl: this.baseUrl, - agentName: this.agentName, - debug: this.debugEnabled, - }); - } - private log(level: 'DEBUG' | 'INFO' | 'ERROR', msg: string): void { - if (level === 'DEBUG' && !this.debugEnabled) return; + if (level === 'DEBUG' && !this.config.debug) return; appendToLog(this.logFile, level, msg); } } + // ───────────────────────────────────────────────────────────────────────────── // Entry point (invoked by `weave-claude-code daemon`) // ───────────────────────────────────────────────────────────────────────────── @@ -2002,18 +952,15 @@ export async function runDaemon(): Promise { fs.mkdirSync(path.dirname(logFile), { recursive: true }); - const { weaveProject, apiKey, baseUrl, agentName, debug } = resolveDaemonConfig(settings, process.env); + const config = resolveDaemonConfig(settings, process.env); - if (!weaveProject || !apiKey) { - const missing = missingConfig(!!weaveProject, !!apiKey, 'WANDB_API_KEY'); + if (!config.weaveProject || !config.apiKey) { + const missing = missingConfig(!!config.weaveProject, !!config.apiKey, 'WANDB_API_KEY'); appendToLog(logFile, 'INFO', `Daemon not started — missing configuration: ${missing}`); process.exit(0); } - // Ensure downstream tooling (e.g. wandb settings) still sees the API key. - process.env['WANDB_API_KEY'] = apiKey; - - const daemon = new GlobalDaemon(socketPath, logFile, weaveProject, apiKey, baseUrl, debug, agentName); + const daemon = new GlobalDaemon(socketPath, logFile, config); try { await daemon.start(); diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index 54a3c67..d86219f 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -17,7 +17,7 @@ import { trace, } from '@opentelemetry/api'; import type { ReadableSpan, Span as SdkSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base'; -import type { MessagePart, Usage } from 'weave'; +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'; @@ -102,11 +102,11 @@ export const ATTR = { * `agent_name` / `WEAVE_AGENT_NAME`. */ export const DEFAULT_AGENT_NAME = 'claude-code'; -export const INTEGRATION_NAME = 'weave-claude-code'; +const INTEGRATION_NAME = 'weave-claude-code'; /** Free-form integration metadata prefix: new fields (e.g. * `claude_code_app_version`) need no new attribute constant. */ -export const WEAVE_INTEGRATION_META_PREFIX = 'weave.integration.meta.'; +const WEAVE_INTEGRATION_META_PREFIX = 'weave.integration.meta.'; // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -214,28 +214,18 @@ export function buildUsage(usage: UsageSummary, reasoningTokens?: number): Usage // Span events // ───────────────────────────────────────────────────────────────────────────── -export interface PermissionRequestEventArgs { - suggestions?: unknown; - timestamp: Date; -} - /** Added at PermissionRequest time. */ -export function addPermissionRequestEvent(toolSpan: Span, args: PermissionRequestEventArgs): void { +export function addPermissionRequestEvent(tool: Tool, args: { suggestions?: unknown; timestamp: Date }): void { const attrs: Attributes = {}; if (args.suggestions !== undefined) { attrs[ATTR.EVT_PERMISSION_SUGGESTIONS] = jsonStr(args.suggestions); } - toolSpan.addEvent(ATTR.EVT_PERMISSION_REQUEST, attrs, args.timestamp); -} - -export interface PermissionResolvedEventArgs { - approved: boolean; - timestamp: Date; + tool.addEvent(ATTR.EVT_PERMISSION_REQUEST, attrs, args.timestamp); } /** Added at PostToolUse[Failure] with the request outcome. */ -export function addPermissionResolvedEvent(toolSpan: Span, args: PermissionResolvedEventArgs): void { - toolSpan.addEvent( +export function addPermissionResolvedEvent(tool: Tool, args: { approved: boolean; timestamp: Date }): void { + tool.addEvent( ATTR.EVT_PERMISSION_RESOLVED, { [ATTR.EVT_PERMISSION_APPROVED]: args.approved }, args.timestamp, @@ -250,10 +240,12 @@ export interface CompactionAttrs { /** Set `weave.compaction.*` on a turn (backend renders a context_compacted card). * Session-level, but with no session span it rides the open (or next) turn. */ -export function setCompactionAttrs(turnSpan: Span, attrs: CompactionAttrs): void { - if (attrs.summary !== undefined) turnSpan.setAttribute(ATTR.COMPACTION_SUMMARY, attrs.summary); - if (attrs.itemsBefore !== undefined) turnSpan.setAttribute(ATTR.COMPACTION_ITEMS_BEFORE, attrs.itemsBefore); - if (attrs.itemsAfter !== undefined) turnSpan.setAttribute(ATTR.COMPACTION_ITEMS_AFTER, attrs.itemsAfter); +export function setCompactionAttrs(turn: Turn, attrs: CompactionAttrs): void { + const out: Attributes = {}; + if (attrs.summary !== undefined) out[ATTR.COMPACTION_SUMMARY] = attrs.summary; + if (attrs.itemsBefore !== undefined) out[ATTR.COMPACTION_ITEMS_BEFORE] = attrs.itemsBefore; + if (attrs.itemsAfter !== undefined) out[ATTR.COMPACTION_ITEMS_AFTER] = attrs.itemsAfter; + if (Object.keys(out).length) turn.setAttributes(out); } // ───────────────────────────────────────────────────────────────────────────── @@ -292,8 +284,8 @@ export function toolDisplayName(toolName: string, input: Record } // ───────────────────────────────────────────────────────────────────────────── -// LEGACY: baggage plumbing and hand-rolled span builders, still used by -// daemon.ts; deleted once the SDK swap (next PRs in this stack) lands. +// LEGACY: baggage plumbing and hand-rolled span builders. Unreferenced after +// the SDK swap in daemon.ts; kept out of this diff and deleted in the next PR. // ───────────────────────────────────────────────────────────────────────────── /** Common prefix for all integration-identity attributes. The span processor diff --git a/src/sessionState.ts b/src/sessionState.ts index eb31d89..8c605f1 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -2,60 +2,125 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Session-scoped helpers shared by the daemon's hook handlers. Moved verbatim -// from daemon.ts; no behavior change. - import * as path from 'path'; -import type { Baggage } from '@opentelemetry/api'; -import type { Span } from '@opentelemetry/api'; +import * as weave from 'weave'; import { VERSION } from './setup.js'; -import { parseSessionFd, extractAssistantTextBlocks } from './parser.js'; +import { parseSessionFd, extractAssistantTextBlocks, isTextBlock } from './parser.js'; import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; -import { addPermissionResolvedEvent, createIntegrationBaggage } from './genaiSpans.js'; -import type { CompactionAttrs } from './genaiSpans.js'; import { sha256Hex } from './utils.js'; +import { buildIntegrationAttrs, addPermissionResolvedEvent } from './genaiSpans.js'; +import type { CompactionAttrs } from './genaiSpans.js'; /** Stores the tool span opened at PreToolUse so PostToolUse can close it. */ export type PendingToolCall = { - span: Span; + tool: weave.Tool; toolName: string; toolInput: Record; /** True once a PermissionRequest event has been emitted for this tool. */ permissionRequested?: boolean; } -/** The chat span open for the in-flight assistant response; its tool spans - * parent here. Content lands when it is finalized (next response transition, - * or Stop), once all its transcript lines are flushed. */ -type ActiveChatSpan = { +/** The chat span (LLM) open for one assistant response; its tool spans parent + * here. Ordered `gen_ai.output.messages` parts land at finalize (next response + * or Stop), once all the response's split transcript lines are present. */ +type ActiveChat = { /** Response key (Anthropic `message.id`, or index fallback) this chat span * represents; see `chatMessageKey`. */ responseKey: string; - span: Span; + llm: weave.LLM; } /** Emit `weave.permission_resolved` on a pending tool call's span, if one was requested. */ export function resolvePermissionIfPending(pending: PendingToolCall, approved: boolean): void { if (!pending.permissionRequested) return; - addPermissionResolvedEvent(pending.span, { + addPermissionResolvedEvent(pending.tool, { approved, timestamp: new Date(), }); } -/** - * Tracks a subagent across hook events. Matched trackers are created at - * PreToolUse(Agent) and correlated to an agent_id at SubagentStart by - * sha256(firing prompt) + type; orphans are created at SubagentStart when - * nothing matches. Either way the subagent is its own `invoke_agent` span - * under the turn (why a marker and not `execute_tool`: see the daemon's - * Agent-dispatch branch). - */ +/** sha256 of the firing prompt, used to correlate an `Agent` PreToolUse with + * the subagent's SubagentStart by matching transcript content. */ +export function hashPrompt(prompt: string): string { + return sha256Hex(prompt); +} + +/** A session's subagent-transcript directory, sibling of the session transcript: + * //subagents/. */ +export function subagentsDirFor(sessionTranscriptPath: string): string { + const projectDir = path.dirname(sessionTranscriptPath); + const sessionDirName = path.basename(sessionTranscriptPath, '.jsonl'); + return path.join(projectDir, sessionDirName, 'subagents'); +} + +/** Map a parent transcript path + subagent agent_id to the subagent's transcript file. */ +export function computeSubagentTranscriptPath(parentTranscriptPath: string, agentId: string): string { + return path.join(subagentsDirFor(parentTranscriptPath), `agent-${agentId}.jsonl`); +} + +/** User-message content of a `{type: 'user'}` transcript line, else undefined; + * array-form content joins across text blocks. */ +export function extractUserMessageContent(line: Record | undefined): string | undefined { + if (!line || line['type'] !== 'user') return undefined; + const msg = line['message']; + if (!msg || typeof msg !== 'object') return undefined; + const content = (msg as Record)['content']; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + // Join text blocks verbatim, keeping empties (unlike extractAssistantTextBlocks). + const parts = content.filter(isTextBlock).map(block => block.text); + return parts.length > 0 ? parts.join('') : undefined; + } + return undefined; +} + +/** True if the last assistant call's joined text ends with `suffix`, + * ignoring trailing whitespace on either side. */ +export function lastAssistantTextEndsWith( + result: NonNullable>, + suffix: string, +): boolean { + const call = result.turns.at(-1)?.assistantCalls().at(-1); + // Turn exists but parser saw no assistant calls (writer mid-flush). + if (!call) return false; + return extractAssistantTextBlocks(call.contentBlocks).join('\n').trimEnd().endsWith(suffix); +} + +/** One instruction file from the `InstructionsLoaded` hook, deduped by path; + * propagated to every turn root as `gen_ai.system_instructions`. */ +export type LoadedInstruction = { filePath: string; content: string }; + +/** Append `item`, or replace the entry with the same filePath (a reload updates + * in place), preserving first-seen order. */ +export function upsertInstruction(list: LoadedInstruction[], item: LoadedInstruction): void { + const idx = list.findIndex((i) => i.filePath === item.filePath); + if (idx >= 0) list[idx] = item; + else list.push(item); +} + +/** First line of the subagent transcript, retrying briefly (Claude Code may not + * have flushed it yet when SubagentStart fires). */ +const SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS = [0, 50, 100, 150]; +export async function readSubagentFirstLineWithRetry( + transcriptPath: string, +): Promise | undefined> { + for (const delay of SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS) { + if (delay > 0) await new Promise(r => setTimeout(r, delay)); + const line = readFirstTranscriptLine(transcriptPath); + if (line && line['type'] === 'user') return line; + } + return undefined; +} + +/** Tracks a subagent (its own `invoke_agent` span under the turn). Matched: + * created at PreToolUse, `agentId` filled at SubagentStart by sha256(prompt) + + * type. Orphan: created at SubagentStart when nothing matches. (Marker, not + * execute_tool: see handlePreToolUse's Agent-dispatch branch.) */ export type SubagentTracker = { subagentType: string; detectedAt: Date; toolUseId?: string; // tool_use_id of the spawning Agent tool (matched path only) - invokeAgentSpan?: Span; // subagent's `invoke_agent` span; subagent chat/tool spans parent here + subAgent?: weave.SubAgent; // subagent's `invoke_agent` marker span; its chat/tool spans nest here agentId?: string; /** sha256 of the prompt passed to the Agent tool; matched against the * subagent's transcript line-1 user message at SubagentStart. */ @@ -63,62 +128,46 @@ export type SubagentTracker = { /** True once the invoke_agent span has been ended. Guards against * double-end when PostToolUse and SubagentStop both try to close it. */ ended?: boolean; - /** Stored at SubagentStart; TeammateIdle's own transcript_path is the - * coordinator's, so this is the reliable copy. */ + /** Stored at SubagentStart; the TeammateIdle payload carries the coordinator's + * transcript_path, so this is the reliable copy. */ transcriptPath?: string; /** Orphan awaiting TeammateIdle: SubagentStop leaves the span open so * TeammateIdle can close it with full all-turns content. */ pendingTeammateIdle?: boolean; - /** Set for `team_name` spawns: the span is owned by - * GlobalDaemon.teamMembers and closed at the teammate's TeammateIdle, - * NOT at the coordinator's PostToolUse(Agent). */ + /** Set for `team_name` spawns: the marker is owned by GlobalDaemon.teamMembers + * and closed at the teammate's TeammateIdle, not at PostToolUse(Agent). */ teamName?: string; } -/** One queued team-member spawn. A teammate is an independent session whose - * TeammateIdle fires under its OWN session_id, so the coordinator's - * PreToolUse(Agent, team_name) is the only reliable anchor: it queues the - * span in GlobalDaemon.teamMembers (FIFO per `${team}::${name}`; the same - * name can be re-spawned, and overwriting would leak the first, still-open - * span). */ +/** Cross-session team correlation: a teammate runs as its own session, so its + * TeammateIdle fires under a different session_id and the per-session lookup + * misses; the coordinator's PreToolUse(Agent, team_name) is the anchor. FIFO + * per `${team_name}::${name}` so re-spawns don't overwrite a live span. */ export type TeamMember = { - invokeAgentSpan: Span; - conversationId: string; + subAgent: weave.SubAgent; + /** Coordinator's Conversation handle; seeds conversation.id + integration + * identity (which don't inherit cross-session) onto the teammate's subtree. */ + conversation: weave.Conversation; coordinatorTranscriptPath: string; emitted: boolean; } -/** One instruction file surfaced by the `InstructionsLoaded` hook. Accumulated - * per session (deduped by path) and stamped as `gen_ai.system_instructions` on - * each turn root. */ -export type LoadedInstruction = { filePath: string; content: string }; - -/** Append `item` to `list` in place, replacing any existing entry with the same - * filePath so a reloaded file (e.g. `load_reason=compact`) updates rather than - * duplicates. Preserves each file's first-seen position. */ -export function upsertInstruction(list: LoadedInstruction[], item: LoadedInstruction): void { - const idx = list.findIndex((i) => i.filePath === item.filePath); - if (idx >= 0) list[idx] = item; - else list.push(item); -} - export type SessionState = { sessionId: string; - /** Root ancestor's session id — used as `gen_ai.conversation.id` so resumed - * turns stitch with their pre-resume turns server-side. Equals `sessionId` - * for fresh (non-forked) sessions. Resolved once at SessionStart by - * walking `forkedFrom.sessionId` pointers across transcript files. */ + /** Root ancestor's session id (= `gen_ai.conversation.id`) so resumed turns + * stitch with their pre-resume turns; equals `sessionId` for fresh sessions. */ conversationId: string; transcript: TranscriptFile; cwd: string; source: string; initialRequestModel?: string; - /** Integration identity (name, version, meta.*) as OTel Baggage, built once - * at SessionStart. Activated for every event in `routeEvent` so each span - * inherits it via `IntegrationBaggageSpanProcessor`. */ - integrationBaggage: Baggage; - currentTurnSpan?: Span; + /** Conversation handle; seeds conversation.id, agent identity, and integration + * attrs onto every turn and (via the handle chain, no ambient state) all child + * spans, even across `runIsolated` frames. Unset when tracing is disabled. */ + conversation?: weave.Conversation; + + currentTurn?: weave.Turn; turnNumber: number; totalToolCalls: number; @@ -128,19 +177,18 @@ export type SessionState = { pendingToolCalls: Map; subagents: SubagentTracking; - /** Chat span currently open for an in-progress assistant API call. Tool - * spans from PreToolUse parent here; finalized at Stop or on transition - * to the next API call. Cleared at Stop. */ - activeChatSpan?: ActiveChatSpan; - /** Response keys with a chat span already opened this turn; Stop emits - * fresh spans for the rest (tool-less responses never hit PreToolUse). */ + /** Chat span (LLM) open for the in-progress assistant call; tool spans parent + * here. Finalized (and cleared) at Stop, or on transition to the next call. */ + activeChat?: ActiveChat; + /** Response keys already given a chat span this turn; Stop emits spans for the + * rest (responses with no tool_use never hit PreToolUse). Reset per turn. */ emittedChatSpanResponseKeys: Set; /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ pendingCompaction?: CompactionAttrs; - /** Instruction files from InstructionsLoaded, in load order, deduped by - * path; stamped on every turn root as `gen_ai.system_instructions`. */ + /** Instruction files from InstructionsLoaded, in load order, deduped by path; + * propagated to every turn root as `gen_ai.system_instructions`. */ systemInstructions: LoadedInstruction[]; } @@ -157,7 +205,7 @@ export class SubagentTracking { this.trackers.push(tracker); } - /** Oldest unmatched tracker (no agent_id yet) for `(promptHash, type)`; + /** Oldest unmatched tracker (no agent_id yet) for (promptHash, subagentType); * FIFO so back-to-back identical Agent calls correlate in dispatch order. */ findUnmatchedByContent(promptHash: string, subagentType: string): SubagentTracker | undefined { let best: SubagentTracker | undefined; @@ -174,7 +222,7 @@ export class SubagentTracking { return this.trackers.find(t => t.agentId === agentId); } - /** Oldest tracker awaiting TeammateIdle for this subagentType (FIFO). */ + /** Oldest tracker awaiting TeammateIdle with this subagentType (FIFO). */ findPendingTeammateIdle(subagentType: string): SubagentTracker | undefined { let best: SubagentTracker | undefined; for (const t of this.trackers) { @@ -185,7 +233,8 @@ export class SubagentTracking { return best; } - /** Lookup by the spawning Agent tool's tool_use_id (PostToolUse settle). */ + /** Lookup by the spawning Agent call's tool_use_id; at PostToolUse the Agent + * call has an invoke_agent marker, not a pendingToolCalls entry. */ byToolUseId(toolUseId: string): SubagentTracker | undefined { return this.trackers.find(t => t.toolUseId === toolUseId); } @@ -216,9 +265,13 @@ type NewSessionStateOptions = { source: string; initialRequestModel: string | undefined; turnNumber: number; + /** The top-level agent name the conversation (and thus every turn) carries. */ + agentName: string; + /** When false (tracing disabled), no Conversation handle is created. */ + tracingEnabled: boolean; }; -/** Build a fresh SessionState. */ +/** Build a fresh SessionState, starting its Conversation when tracing is on. */ export function newSessionState(options: NewSessionStateOptions): SessionState { const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = options; @@ -227,10 +280,13 @@ export function newSessionState(options: NewSessionStateOptions): SessionState { const headLine = readFirstTranscriptLine(transcript.resolvedPath); const version = headLine?.['version']; const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; - const integrationBaggage = createIntegrationBaggage({ + const integrationAttrs = buildIntegrationAttrs({ version: VERSION, meta: { claude_code_app_version: claudeCodeAppVersion }, }); + const conversation = options.tracingEnabled + ? weave.startConversation({ conversationId, agentName: options.agentName, attributes: integrationAttrs }) + : undefined; return { sessionId, @@ -239,7 +295,7 @@ export function newSessionState(options: NewSessionStateOptions): SessionState { cwd, source, initialRequestModel, - integrationBaggage, + conversation, turnNumber, totalToolCalls: 0, turnToolCalls: 0, @@ -250,71 +306,3 @@ export function newSessionState(options: NewSessionStateOptions): SessionState { systemInstructions: [], }; } - -/** sha256 of the firing prompt — used to correlate an `Agent` PreToolUse with - * the subagent's SubagentStart by matching transcript content. */ -export function hashPrompt(prompt: string): string { - return sha256Hex(prompt); -} - -/** - * Map a parent transcript path + subagent agent_id to the subagent's transcript - * file. Claude Code writes subagent transcripts as siblings of the parent in a - * `/subagents/` subdirectory: - * parent: /.jsonl - * subagent: //subagents/agent-.jsonl - */ -export function computeSubagentTranscriptPath(parentTranscriptPath: string, agentId: string): string { - const projectDir = path.dirname(parentTranscriptPath); - const sessionDirName = path.basename(parentTranscriptPath, '.jsonl'); - return path.join(projectDir, sessionDirName, 'subagents', `agent-${agentId}.jsonl`); -} - -/** Pull the user-message content out of a transcript line. Returns the prompt - * string for `{type: 'user', message: {content: string|Array}}` lines, else - * undefined. Array-form content is joined across text blocks. */ -export function extractUserMessageContent(line: Record | undefined): string | undefined { - if (!line || line['type'] !== 'user') return undefined; - const msg = line['message']; - if (!msg || typeof msg !== 'object') return undefined; - const content = (msg as Record)['content']; - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - const parts: string[] = []; - for (const block of content) { - if (block && typeof block === 'object' && (block as Record)['type'] === 'text') { - const t = (block as Record)['text']; - if (typeof t === 'string') parts.push(t); - } - } - return parts.length > 0 ? parts.join('') : undefined; - } - return undefined; -} - -/** True if the last assistant call's joined text ends with `suffix`, - * ignoring trailing whitespace on either side. */ -export function lastAssistantTextEndsWith( - result: NonNullable>, - suffix: string, -): boolean { - const call = result.turns.at(-1)?.assistantCalls().at(-1); - // Turn exists but parser saw no assistant calls (writer mid-flush). - if (!call) return false; - return extractAssistantTextBlocks(call.contentBlocks).join('\n').trimEnd().endsWith(suffix); -} - -/** Read the subagent transcript's first line, retrying briefly because Claude - * Code may not have flushed it yet when SubagentStart fires. Total wait - * bounded by the sum of `RETRY_DELAYS_MS`. */ -const SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS = [0, 50, 100, 150]; -export async function readSubagentFirstLineWithRetry( - transcriptPath: string, -): Promise | undefined> { - for (const delay of SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS) { - if (delay > 0) await new Promise(r => setTimeout(r, delay)); - const line = readFirstTranscriptLine(transcriptPath); - if (line && line['type'] === 'user') return line; - } - return undefined; -} diff --git a/tests/daemon-shutdown-finalizes-turn.test.ts b/tests/daemon-shutdown-finalizes-turn.test.ts deleted file mode 100644 index 44f48ea..0000000 --- a/tests/daemon-shutdown-finalizes-turn.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// A turn's root span (`invoke_agent claude-code`) 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 { - 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 }; -} - -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') }; -} - -interface Harness { - handleSessionStart(s: string, p: Record): Promise; - handleUserPromptSubmit(s: string, p: Record): Promise; - handlePreToolUse(s: string, p: Record): Promise; - handlePostToolUse(s: string, p: Record): Promise; - handleSessionEnd(s: string, p: Record): Promise; - drain(reason: string): Promise; - tracer: unknown; -} - -function makeDaemon(tracer: ReturnType['tracer']): Harness { - const logFile = path.join(os.tmpdir(), `wcp-shutdown-itest-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-shutdown.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude-code'); - (d as unknown as { tracer: unknown }).tracer = tracer; - return d as unknown as Harness; -} - -/** 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.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(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.handlePreToolUse(sid, { tool_use_id: 'tool_1', tool_name: 'Read', tool_input: { file_path: '/foo' } }); - await d.handlePostToolUse(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 sid = 'sess-shutdown'; - const { file, append, dir } = makeTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); - 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 provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const tool = spans.find(s => s.attributes[ATTR.OPERATION_NAME] === OP.EXECUTE_TOOL); - assert.ok(tool, 'the completed tool span exported as a child'); - - const root = spans.find(s => s.name === `${OP.INVOKE_AGENT} claude-code`); - 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 sid = 'sess-shutdown-subagent'; - const { file, append, dir } = makeTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); - try { - append(userText('2026-01-01T00:00:00.000Z', 'spawn a reviewer')); - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(sid, { prompt: 'spawn a reviewer' }); - - // Agent tool with subagent_type opens a nested invoke_agent span 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.handlePreToolUse(sid, { tool_use_id: 'agent_1', tool_name: 'Agent', tool_input: { subagent_type: 'code-reviewer', prompt: 'review' } }); - - await d.drain('SIGTERM'); - await provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const root = spans.find(s => s.name === `${OP.INVOKE_AGENT} claude-code`); - const sub = spans.find(s => s.name === `${OP.INVOKE_AGENT} 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(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 sid = 'sess-sessionend'; - const { file, append, dir } = makeTranscript(sid); - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); - try { - await openTurnWithOneCompletedTool(d, sid, append, file); - await d.handleSessionEnd(sid, { reason: 'clear' }); - await provider.forceFlush(); - - const root = exporter.getFinishedSpans().find(s => s.name === `${OP.INVOKE_AGENT} claude-code`); - 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 deleted file mode 100644 index 7980d67..0000000 --- a/tests/daemon-subagent-recovery.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -// 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 { context } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { IntegrationBaggageSpanProcessor } from '../src/genaiSpans.ts'; - -// Production registers this via NodeTracerProvider.register(); the test injects -// a BasicTracerProvider, so set it up here or context.with won't propagate. -context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new IntegrationBaggageSpanProcessor(), new SimpleSpanProcessor(exporter)], - }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} - -function makeDaemon(tracer: unknown) { - const logFile = path.join(os.tmpdir(), `wcp-subrecover-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-subrecover.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 }; -} - -function userLine(text: string): string { - return JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } }); -} -function assistantLine(text: string, usage: Record): string { - return JSON.stringify({ - type: 'assistant', - message: { role: 'assistant', model: 'claude-opus-4-8', id: 'm1', usage, stop_reason: 'end_turn', content: [{ type: 'text', text }] }, - }); -} - -test('SubagentStop with no tracker (post-restart) recovers the subagent: turn -> invoke_agent -> chat with tokens', async () => { - 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, userLine('spawn a subagent') + '\n' + assistantLine('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, userLine('do the subtask') + '\n' + assistantLine('subtask done', { input_tokens: 200, output_tokens: 40 }) + '\n'); - - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); - 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 provider.forceFlush(); - - 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.parentSpanContext?.spanId === subInvoke.spanContext().spanId, - ); - assert.ok(chat, `expected the subagent chat span nested under the subagent invoke_agent span; got: ${names}`); - assert.ok(Number(chat.attributes['gen_ai.usage.output_tokens']) > 0, 'chat span carries the subagent token usage'); - - // 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(subInvoke.parentSpanContext?.spanId, turn.spanContext().spanId, 'subagent invoke_agent nests under the reconstructed turn'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - await provider.shutdown(); - } -}); - -test('recovery reuses an already-open turn span instead of creating a spurious second turn', async () => { - 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, userLine('start') + '\n'); - const subPath = path.join(dir, sid, 'subagents', `agent-${agentId}.jsonl`); - fs.mkdirSync(path.dirname(subPath), { recursive: true }); - fs.writeFileSync(subPath, userLine('subtask') + '\n' + assistantLine('done', { input_tokens: 50, output_tokens: 7 }) + '\n'); - - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); - 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 provider.forceFlush(); - - 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'); - assert.ok(subInvoke, 'recovered subagent invoke_agent span present'); - assert.equal(subInvoke.parentSpanContext?.spanId, turns[0].spanContext().spanId, 'subagent nests under the pre-existing turn'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - await provider.shutdown(); - } -}); diff --git a/tests/genai-span-usage-tokens.test.ts b/tests/genai-span-usage-tokens.test.ts deleted file mode 100644 index 2b9c188..0000000 --- a/tests/genai-span-usage-tokens.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// Regression test for the cache-hit-rate bug (Weave UI showed >100%). -// -// Anthropic's API splits prompt usage into three disjoint fields: -// input_tokens — new (uncached) prompt tokens -// cache_read_input_tokens — tokens served from prompt cache -// cache_creation_input_tokens — tokens written to prompt cache -// -// OTel GenAI semconv requires `gen_ai.usage.input_tokens` to be the TOTAL -// prompt size (including cache reads and writes). When the plugin forwarded -// Anthropic's `input_tokens` verbatim, downstream consumers computing -// `cache_read / input_tokens` produced rates greater than 100% (the cache -// portion was larger than the uncached portion). Spec ref: -// https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/anthropic.md - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { emitChatSpan, ATTR } from '../src/genaiSpans.ts'; - -function setupTracer(): { tracer: ReturnType; exporter: InMemorySpanExporter; provider: BasicTracerProvider } { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; -} - -test('emitChatSpan: input_tokens includes cache_read + cache_creation (OTel semconv)', async () => { - const { tracer, exporter, provider } = setupTracer(); - const parent = tracer.startSpan('parent'); - - const startedAt = new Date('2026-01-01T00:00:00Z'); - const endedAt = new Date('2026-01-01T00:00:01Z'); - - emitChatSpan(tracer, parent, { - conversationId: 'conv-1', - model: 'claude-opus-4-7', - startedAt, - endedAt, - usage: { - input_tokens: 7600, - output_tokens: 528, - cache_read_input_tokens: 36500, - cache_creation_input_tokens: 4100, - }, - }); - - parent.end(); - await provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const chatSpan = spans.find(s => s.name === 'chat claude-opus-4-7'); - assert.ok(chatSpan, 'chat span should be emitted'); - - // 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('emitChatSpan: input_tokens unchanged when no cache fields present', async () => { - const { tracer, exporter, provider } = setupTracer(); - const parent = tracer.startSpan('parent'); - - emitChatSpan(tracer, parent, { - conversationId: 'conv-2', - model: 'claude-haiku-4-5', - startedAt: new Date(), - endedAt: new Date(), - usage: { input_tokens: 1000, output_tokens: 200 }, - }); - - parent.end(); - await provider.forceFlush(); - - const chatSpan = exporter.getFinishedSpans().find(s => s.name === 'chat claude-haiku-4-5'); - assert.ok(chatSpan); - 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/helpers.ts b/tests/helpers.ts index 2e36691..e79e36e 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -2,18 +2,18 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// Shared test helpers. The first occurrence lived inline in -// marketplace-ref-drift.test.ts; extracted here once a second test -// (install-source-local.test.ts) needed the same helper. - 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 { spawn, type ChildProcess } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +import { InMemorySpanExporter, SimpleSpanProcessor, type ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import * as weave from 'weave'; -import { MARKETPLACE_NAME } from '../src/setup.ts'; +import { MARKETPLACE_NAME, type Settings } from '../src/setup.ts'; +import { GlobalDaemon } from '../src/daemon.ts'; +import { resolveApiKey, resolveProject } from '../src/config.ts'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(HERE, '..'); @@ -94,14 +94,102 @@ export function writeKnownMarketplace(home: string, source: Record { + if (!genaiExporter) { + // weave.init() requires a key (WANDB_API_KEY/~/.netrc) even offline; resolve + // fake creds the way the daemon does so the bridge stays hermetic on CI. + const settings: Settings = { + log_file: '', daemon_socket: '', weave_project: 'e/p', wandb_api_key: 'fake-key-for-test', + agent_name: null, debug: false, installed_at: '', version: '0.0.0-test', + }; + process.env.WANDB_API_KEY = resolveApiKey(settings).value ?? ''; + genaiExporter = new InMemorySpanExporter(); + await weave.init(resolveProject(settings).value ?? 'e/p', { + genai: { spanProcessor: new SimpleSpanProcessor(genaiExporter) }, + }); + } + return genaiExporter; +} + +/** The daemon surface the genai tests drive: the (private) routeEvent entry + * point production feeds from the socket, plus drain for shutdown tests. */ +export type DaemonDriver = { + routeEvent(p: Record): Promise; + drain(reason: string): Promise; +}; + +/** GlobalDaemon with tracing marked enabled (SDK inited via `initWeaveInMemory`), + * skipping the real socket/`start()`; viewed through the DaemonDriver seam. */ +export function makeGenaiDaemon(agentName = 'claude-code'): DaemonDriver { + const logFile = path.join(os.tmpdir(), `wcp-genai-${process.pid}.log`); + const d = new GlobalDaemon('/tmp/unused.sock', logFile, { + weaveProject: 'e/p', apiKey: 'k', baseUrl: 'https://x', agentName, debug: false, + }); + (d as unknown as { tracingEnabled: boolean }).tracingEnabled = true; + return d as unknown as DaemonDriver; +} + +/** One JSONL transcript line for a user text message. `version` mirrors the + * CC CLI version field real transcripts carry on their head line. */ +export function transcriptUserLine(text: string, opts: { version?: string; timestamp?: string } = {}): string { + return JSON.stringify({ + type: 'user', + ...(opts.version ? { version: opts.version } : {}), + ...(opts.timestamp ? { timestamp: opts.timestamp } : {}), + message: { role: 'user', content: [{ type: 'text', text }] }, + }); +} + +/** One JSONL transcript line for a single-text assistant response. */ +export function transcriptAssistantLine( + text: string, + usage: Record, + opts: { id?: string; model?: string; timestamp?: string } = {}, +): string { + return JSON.stringify({ + type: 'assistant', + ...(opts.timestamp ? { timestamp: opts.timestamp } : {}), + message: { + role: 'assistant', + id: opts.id ?? 'm1', + model: opts.model ?? 'claude-opus-4-8', + usage, + stop_reason: 'end_turn', + content: [{ type: 'text', text }], + }, + }); +} + +/** Flush any spans buffered in the SDK so the in-memory exporter has them. */ +export function flushWeave(): Promise { + return weave.flushOTel(); +} + +/** Parent span id: weave's provider exposes `parentSpanId` (older providers used + * `parentSpanContext`), so read whichever is present. */ +export function spanParentId(s: ReadableSpan): string | undefined { + return (s as unknown as { parentSpanId?: string }).parentSpanId ?? s.parentSpanContext?.spanId; +} + +/** Direct children of `parent`, ordered by span start time. */ +export function childrenOf(spans: ReadableSpan[], parent: ReadableSpan): ReadableSpan[] { + const parentId = parent.spanContext().spanId; + return spans + .filter(s => spanParentId(s) === parentId) + .sort((a, b) => hrToNs(a.startTime) - hrToNs(b.startTime)); +} + +function hrToNs(t: [number, number]): number { + return t[0] * 1e9 + t[1]; +} function delay(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); diff --git a/tests/interleave-handlers.test.ts b/tests/interleave-handlers.test.ts deleted file mode 100644 index 9b93f0b..0000000 --- a/tests/interleave-handlers.test.ts +++ /dev/null @@ -1,208 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// Chat-span state machine, driven through the real hook handlers (not by -// calling emitChatSpanForResponse directly): chat span opened at PreToolUse + -// tool parenting, response transitions, the dedup (no double chat span at -// Stop), and SessionEnd finalizing a still-open span. The other interleave -// tests cover only the helpers in isolation. - -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, - type ReadableSpan, -} 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 }; -} - -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 }] } }; -} - -/** 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'), - }; -} - -interface Handlers { - handleSessionStart(s: string, p: Record): Promise; - handleUserPromptSubmit(s: string, p: Record): Promise; - handlePreToolUse(s: string, p: Record): Promise; - handlePostToolUse(s: string, p: Record): Promise; - handleStop(s: string, p: Record): Promise; - handleSessionEnd(s: string, p: Record): Promise; - tracer: unknown; -} - -function makeDaemon(tracer: ReturnType['tracer']): Handlers { - const logFile = path.join(os.tmpdir(), `wcp-itest-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused.sock', logFile, 'e/p', 'k', 'https://x', false, 'claude-code'); - (d as unknown as { tracer: unknown }).tracer = tracer; - return d as unknown as Handlers; -} - -function childrenOf(spans: ReadableSpan[], parent: ReadableSpan): ReadableSpan[] { - return spans - .filter(s => s.parentSpanContext?.spanId === parent.spanContext().spanId) - .sort((a, b) => hrToNs(a.startTime) - hrToNs(b.startTime)); -} -function chatByResponse(spans: ReadableSpan[], id: string): ReadableSpan[] { - return spans.filter(s => s.attributes[ATTR.OPERATION_NAME] === OP.CHAT && s.attributes[ATTR.RESPONSE_ID] === id); -} - -test('handlers: PreToolUse opens the chat span, Stop finalizes; text + tool interleave, usage once, no double-emit', async () => { - const sid = 'sess-A'; - const { file, append, dir } = makeTranscript(sid); - append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); - try { - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(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.handlePreToolUse(sid, { tool_use_id: 'tool_1', tool_name: 'Edit', tool_input: { file_path: '/foo.ts' } }); - await d.handlePostToolUse(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.handleStop(sid, {}); - await provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - - // Dedup: one chat span per response (Stop back-fill skips already-final msgA). - 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]; - const aKids = childrenOf(spans, chatA).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.deepEqual(aKids, [OP.ASSISTANT_TEXT, OP.EXECUTE_TOOL], 'msgA: text then tool, both parented under the chat span'); - - // 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(childrenOf(spans, chatB).map(s => s.attributes[ATTR.OPERATION_NAME]), [OP.ASSISTANT_TEXT]); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('handlers: a new response transitions and finalizes the previous chat span', async () => { - const sid = 'sess-B'; - const { file, append, dir } = makeTranscript(sid); - append(userText('2026-01-01T00:00:00.000Z', 'do two things')); - - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); - try { - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(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.handlePreToolUse(sid, { tool_use_id: 'tool_A', tool_name: 'Edit', tool_input: {} }); - await d.handlePostToolUse(sid, { tool_use_id: 'tool_A', tool_response: 'ok' }); - - // Second response with its own tool_use → PreToolUse(tool_B) must finalize - // msgA's chat span (transition) before opening msgB's. - 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.handlePreToolUse(sid, { tool_use_id: 'tool_B', tool_name: 'Edit', tool_input: {} }); - await d.handlePostToolUse(sid, { tool_use_id: 'tool_B', tool_response: 'ok' }); - - await d.handleStop(sid, {}); - await provider.forceFlush(); - - 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 kids = childrenOf(spans, chat).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.deepEqual(kids, [OP.ASSISTANT_TEXT, OP.EXECUTE_TOOL], `${id}: text + tool under its chat span`); - } - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -test('handlers: SessionEnd finalizes a still-open chat span with its text + usage (not an empty orphan)', async () => { - const sid = 'sess-C'; - const { file, append, dir } = makeTranscript(sid); - append(userText('2026-01-01T00:00:00.000Z', 'do the thing')); - - const { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); - try { - await d.handleSessionStart(sid, { transcript_path: file, source: 'startup', cwd: '/x' }); - await d.handleUserPromptSubmit(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.handlePreToolUse(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.handleSessionEnd(sid, { reason: 'clear' }); - await provider.forceFlush(); - - 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 child are present. - assert.equal(chatA.attributes[ATTR.USAGE_OUTPUT_TOKENS], 1508, 'usage recovered at SessionEnd'); - const kids = childrenOf(spans, chatA).map(s => s.attributes[ATTR.OPERATION_NAME]); - assert.ok(kids.includes(OP.ASSISTANT_TEXT), 'assistant_text child recovered at SessionEnd'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); - -function hrToNs(t: [number, number]): number { - return t[0] * 1e9 + t[1]; -} diff --git a/tests/interleave-split-lines.test.ts b/tests/interleave-split-lines.test.ts deleted file mode 100644 index 1165e0d..0000000 --- a/tests/interleave-split-lines.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// Regression test for the main-agent chat-span 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`, and the parser maps each line to its own AssistantCallDetail. -// An earlier version walked `blockIdx` within a single call's contentBlocks, -// assuming all blocks lived together; against real (split) transcripts that -// emitted nothing for the text/thinking blocks (they were dropped) and lumped -// any surviving text at the end with emission-time timestamps. -// -// This drives the actual reconstruction (GlobalDaemon.emitChatSpanForResponse, -// reached the same way the Stop handler reaches it) against a realistic -// split-line transcript and asserts: text/thinking are NOT dropped, each lands -// on a span stamped with its transcript timestamp (so it sorts into order -// among the live tool spans), and the duplicated per-line usage is counted -// once per response. - -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, - type ReadableSpan, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { parseSessionFile } from '../src/parser.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 }; -} - -/** 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: { 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 writeTranscript(lines: unknown[]): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wcp-interleave-')); - const file = path.join(dir, 'session.jsonl'); - fs.writeFileSync(file, lines.map(l => JSON.stringify(l)).join('\n') + '\n'); - return file; -} - -function makeDaemon(tracer: ReturnType['tracer']): GlobalDaemon { - const d = new GlobalDaemon('/tmp/unused.sock', '/tmp/unused.log', 'e/p', 'k', 'https://x', false); - // Inject the in-memory tracer (normally created by initTracer at startup). - (d as unknown as { tracer: unknown }).tracer = tracer; - return d; -} - -test('reconstruction: split thinking/redacted_thinking/text/tool_use lines interleave, none dropped, usage counted once', async () => { - // One turn: - // response msgA: thinking, redacted_thinking, text, tool_use (4 split lines, shared id) - // response msgB: text-only (no tool_use) - const file = writeTranscript([ - 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'), - ]); - - const { tracer, exporter, provider } = setupTracer(); - try { - const parsed = parseSessionFile(file); - assert.ok(parsed); - const calls = parsed.turns[parsed.turns.length - 1].assistantCalls(); - // Sanity: the parser really does split one response across lines. - assert.equal(calls.filter(c => c.responseId === 'msgA').length, 4, 'msgA is 4 split lines'); - - const daemon = makeDaemon(tracer); - const turn = tracer.startSpan('invoke_agent claude-code'); - const session = { - conversationId: 'conv-1', - currentTurnSpan: turn, - emittedChatSpanResponseKeys: new Set(), - activeChatSpan: undefined, - }; - - // Reach the real reconstruction the same way the Stop handler does. - const emit = (key: string) => - (daemon as unknown as { emitChatSpanForResponse: (s: unknown, c: unknown, k: string) => void }) - .emitChatSpanForResponse(session, calls, key); - emit('msgA'); - emit('msgB'); - - turn.end(); - await provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const chatA = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgA'); - assert.ok(chatA, 'chat span for msgA emitted'); - - const childrenOf = (parent: ReadableSpan) => - spans - .filter(s => s.parentSpanContext?.spanId === parent.spanContext().spanId) - .sort((a, b) => hrToNs(a.startTime) - hrToNs(b.startTime)); - - const aChildren = childrenOf(chatA); - // thinking, redacted_thinking, and text are NOT dropped, and appear in - // transcript order. redacted_thinking has no readable content, so it - // surfaces as a placeholder thinking span rather than being dropped. - assert.deepEqual( - aChildren.map(s => s.attributes[ATTR.OPERATION_NAME]), - [OP.THINKING, OP.THINKING, OP.ASSISTANT_TEXT], - 'thinking, redacted placeholder, text — all present, in order', - ); - assert.deepEqual( - JSON.parse(aChildren[1].attributes[ATTR.OUTPUT_MESSAGES] as string), - [{ role: 'assistant', parts: [{ type: 'thinking', content: '[redacted]' }] }], - 'redacted_thinking renders a [redacted] placeholder', - ); - // Each child is stamped with its transcript line timestamp (so it sorts - // before the tool_use of the same response, whose live span starts later). - assert.equal(isoOf(aChildren[0].startTime), '2026-01-01T00:00:01.000Z'); - assert.equal(isoOf(aChildren[1].startTime), '2026-01-01T00:00:01.500Z'); - assert.equal(isoOf(aChildren[2].startTime), '2026-01-01T00:00:02.000Z'); - - // Usage counted ONCE for the response (not 3x for the 3 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, after msgA. - const chatB = spans.find(s => s.attributes[ATTR.RESPONSE_ID] === 'msgB'); - assert.ok(chatB, 'chat span for tool-less msgB emitted'); - assert.equal(childrenOf(chatB).map(s => s.attributes[ATTR.OPERATION_NAME])[0], OP.ASSISTANT_TEXT); - } finally { - fs.rmSync(path.dirname(file), { recursive: true, force: true }); - } -}); - -function hrToNs(t: [number, number]): number { - return t[0] * 1e9 + t[1]; -} -function isoOf(t: [number, number]): string { - return new Date(t[0] * 1000 + t[1] / 1e6).toISOString(); -} diff --git a/tests/nested-subagent-nesting.test.ts b/tests/nested-subagent-nesting.test.ts deleted file mode 100644 index a6dc35c..0000000 --- a/tests/nested-subagent-nesting.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// A subagent can itself spawn a subagent (the Agent tool is available inside a -// general-purpose subagent). The grandchild's invoke_agent span must nest under -// its spawning subagent, not orphan onto the turn. Regression test: the daemon -// used to create the invoke_agent tracker only for Agent calls from the MAIN -// agent, so a subagent-initiated dispatch was untracked and the grandchild -// orphaned ("no tracker matches ...; creating orphan"). - -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 { context } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { IntegrationBaggageSpanProcessor } from '../src/genaiSpans.ts'; - -// Production installs this via NodeTracerProvider.register(); the test injects a -// BasicTracerProvider, so set it up here or context.with won't propagate. -context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new IntegrationBaggageSpanProcessor(), new SimpleSpanProcessor(exporter)], - }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} - -/** A subagent transcript's line 1: the firing user prompt, byte-identical to the - * spawning Agent tool's `tool_input.prompt` (how SubagentStart correlates). */ -function userLine(text: string): string { - return JSON.stringify({ - type: 'user', - version: '1.2.3', - timestamp: '2026-01-01T00:00:00.000Z', - message: { role: 'user', content: [{ type: 'text', text }] }, - }) + '\n'; -} - -function makeDaemon(tracer: unknown) { - const logFile = path.join(os.tmpdir(), `wcp-nest-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-nest.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('a subagent spawned by a subagent nests under its parent, not orphaned onto the turn', async () => { - const sid = 'sess-nest'; - const dir = fs.mkdtempSync(path.join(os.homedir(), '.weave-nest-')); - const file = path.join(dir, `${sid}.jsonl`); - fs.appendFileSync(file, userLine('do it')); - - // Subagent transcripts live at //subagents/agent-.jsonl - // (see computeSubagentTranscriptPath). - const subDir = path.join(dir, sid, 'subagents'); - fs.mkdirSync(subDir, { recursive: true }); - const childPrompt = 'child subagent prompt'; - const grandchildPrompt = 'grandchild subagent prompt'; - - 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: 'do it' }); - - // Main agent dispatches child subagent A1. - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, tool_use_id: 'agent_1', tool_name: 'Agent', tool_input: { subagent_type: 'general-purpose', prompt: childPrompt } }); - fs.writeFileSync(path.join(subDir, 'agent-A1.jsonl'), userLine(childPrompt)); - await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'A1', agent_type: 'general-purpose' }); - - // Child subagent A1 dispatches its OWN subagent A2 (PreToolUse carries A1's agent_id). - await d.routeEvent({ hook_event_name: 'PreToolUse', session_id: sid, agent_id: 'A1', tool_use_id: 'agent_2', tool_name: 'Agent', tool_input: { subagent_type: 'general-purpose', prompt: grandchildPrompt } }); - fs.writeFileSync(path.join(subDir, 'agent-A2.jsonl'), userLine(grandchildPrompt)); - await d.routeEvent({ hook_event_name: 'SubagentStart', session_id: sid, agent_id: 'A2', agent_type: 'general-purpose' }); - - // Tear down inner-to-outer, closing each invoke_agent span at its Agent PostToolUse. - await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'A2' }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, agent_id: 'A1', tool_use_id: 'agent_2', tool_response: 'ok' }); - await d.routeEvent({ hook_event_name: 'SubagentStop', session_id: sid, agent_id: 'A1' }); - await d.routeEvent({ hook_event_name: 'PostToolUse', session_id: sid, tool_use_id: 'agent_1', tool_response: 'ok' }); - await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const byAgentId = (id: string) => spans.find((s) => s.attributes['gen_ai.agent.id'] === id); - const a1 = byAgentId('A1'); - const a2 = byAgentId('A2'); - assert.ok(a1, 'child subagent A1 invoke_agent span present'); - assert.ok(a2, 'grandchild subagent A2 invoke_agent span present'); - - // The grandchild must nest under its spawning subagent, not orphan onto the turn. - assert.equal( - a2.parentSpanContext?.spanId, - a1.spanContext().spanId, - 'A2 (grandchild) parents under A1 (its spawning subagent)', - ); - assert.equal( - a2.attributes['weave.claude_code.orphan_reason'], - undefined, - 'A2 is not emitted as an orphan', - ); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/system-instructions-integration.test.ts b/tests/system-instructions-integration.test.ts index cc72f81..09c84e9 100644 --- a/tests/system-instructions-integration.test.ts +++ b/tests/system-instructions-integration.test.ts @@ -18,43 +18,16 @@ 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 { context } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { ATTR, IntegrationBaggageSpanProcessor } from '../src/genaiSpans.ts'; - -// Production installs this via NodeTracerProvider.register(); the test injects a -// BasicTracerProvider, so set it up here or context.with won't propagate. -context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new IntegrationBaggageSpanProcessor(), new SimpleSpanProcessor(exporter)], - }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} - -function makeDaemon(tracer: unknown) { - const logFile = path.join(os.tmpdir(), `wcp-sysinstr-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-sysinstr.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 }; -} +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`); - const userLine = { type: 'user', version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'hi' }] } }; - fs.writeFileSync(file, JSON.stringify(userLine) + '\n'); + fs.writeFileSync(file, transcriptUserLine('hi', { version: '1.2.3', timestamp: '2026-01-01T00:00:00.000Z' }) + '\n'); return { dir, file }; } @@ -75,10 +48,11 @@ function turnRoots(spans: ReadableSpan[]): ReadableSpan[] { } 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 { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { const loadInstr = makeInstructionsLoader(dir); // Global CLAUDE.md loads BEFORE SessionStart (the real, non-deterministic order). @@ -88,7 +62,7 @@ test('buffers InstructionsLoaded fired before SessionStart, then accumulates in await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'PROJECT', 'session_start')); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const [turn] = turnRoots(exporter.getFinishedSpans()); assert.ok(turn, 'turn root exported'); @@ -105,10 +79,11 @@ test('buffers InstructionsLoaded fired before SessionStart, then accumulates in }); 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 { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + 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' }); @@ -117,7 +92,7 @@ test('re-loading the same file replaces its content rather than duplicating', as await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'V2', 'compact')); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'do it' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const [turn] = turnRoots(exporter.getFinishedSpans()); assert.ok(turn, 'turn root exported'); @@ -131,10 +106,11 @@ test('re-loading the same file replaces its content rather than duplicating', as }); 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 { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + const d = makeGenaiDaemon(); try { const loadInstr = makeInstructionsLoader(dir); await d.routeEvent(loadInstr(sid, '/x/CLAUDE.md', 'PROJECT', 'session_start')); @@ -143,7 +119,7 @@ test('stamps system instructions on every turn root (no session span to hang the await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); await d.routeEvent({ hook_event_name: 'UserPromptSubmit', session_id: sid, prompt: 'turn two' }); await d.routeEvent({ hook_event_name: 'Stop', session_id: sid }); - await provider.forceFlush(); + await flushWeave(); const turns = turnRoots(exporter.getFinishedSpans()); assert.equal(turns.length, 2, 'both turn roots exported'); @@ -157,15 +133,16 @@ test('stamps system instructions on every turn root (no session span to hang the }); 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 { tracer, exporter, provider } = setupTracer(); - const d = makeDaemon(tracer); + 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 provider.forceFlush(); + await flushWeave(); const [turn] = turnRoots(exporter.getFinishedSpans()); assert.ok(turn, 'turn root exported'); diff --git a/tests/teammate-idle.test.ts b/tests/teammate-idle.test.ts deleted file mode 100644 index 0203390..0000000 --- a/tests/teammate-idle.test.ts +++ /dev/null @@ -1,760 +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":"..."} -// -// Actual TeammateIdle payload schema (confirmed from live TARS triage, NOT CC docs): -// teammate_name — agent name, e.g. "cks-specialist" (docs said: agent_type) -// team_name — team name, e.g. "triage-supp-25017" (docs said: agent_id) -// transcript_path — teammate's transcript path (docs said: coordinator's) -// -// The integration test below sends this real payload schema 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 { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { readFirstTranscriptLine } from '../src/transcriptFile.ts'; -import { parseSessionFile } from '../src/parser.ts'; -import { startInvokeAgentSpan, emitChatSpansFromAssistantCalls, ATTR } from '../src/genaiSpans.ts'; - -// ── helpers ────────────────────────────────────────────────────────────────── - -function setupTracer(): { - tracer: ReturnType; - exporter: InMemorySpanExporter; - provider: BasicTracerProvider; -} { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; -} - -/** 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: invoke_agent span contains chat child', async () => { - const filePath = writeTeammateTranscript([AGENT_SETTING_LINE, MODE_LINE, USER_LINE, ASSISTANT_LINE]); - const { tracer, exporter, provider } = setupTracer(); - - try { - const parsed = parseSessionFile(filePath); - assert.ok(parsed); - - // Mimic what handleTeammateIdle does: agent type comes from payload['teammate_name'], - // transcript path comes from payload['transcript_path']. - const agentType = 'cks-specialist'; // from payload['teammate_name'] - const parentSpan = tracer.startSpan('turn'); - const invokeSpan = startInvokeAgentSpan(tracer, parentSpan, { - agentType, - conversationId: 'conv-1', - pluginVersion: '0.2.6', - displayName: `Agent: ${agentType}`, - }); - - for (const turn of parsed.turns) { - emitChatSpansFromAssistantCalls(tracer, invokeSpan, 'conv-1', turn.assistantCalls()); - } - invokeSpan.end(); - parentSpan.end(); - - await provider.forceFlush(); - - const spans = exporter.getFinishedSpans(); - const invokeAgentSpan = spans.find(s => s.name === 'invoke_agent cks-specialist'); - assert.ok(invokeAgentSpan, 'invoke_agent span should exist'); - assert.equal( - invokeAgentSpan.attributes[ATTR.AGENT_NAME], - 'cks-specialist', - 'gen_ai.agent.name should be set', - ); - - const chatSpan = spans.find(s => s.name === 'chat claude-opus-4-8'); - assert.ok(chatSpan, 'chat span should exist'); - - // Chat span should be a child of the invoke_agent span. - assert.equal( - chatSpan.parentSpanContext?.spanId, - invokeAgentSpan.spanContext().spanId, - 'chat span should be child of invoke_agent span', - ); - - // Token counts should be 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(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 }); - } -}); - -// ── 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 TARS sequence — SubagentStart → SubagentStop → TeammateIdle traces with all turns', async () => { - // Replicate the real TARS triage sequence: - // 1. Coordinator dispatches specialist via Agent tool (PreToolUse not tested here — SubagentStart is the entry point) - // 2. SubagentStart fires (orphan — no matching PreToolUse tracker) → creates invoke_agent span, stores transcript path - // 3. SubagentStop fires → span kept open (pendingTeammateIdle=true), tracker stays in SubagentTracking - // 4. TeammateIdle fires → 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: TARS re-spawns a specialist (Sonnet→Opus) - // within one run. A second PreToolUse(Agent) for the same `${team}::${name}` - // must APPEND to a 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/tool-span-conversation-id.test.ts b/tests/tool-span-conversation-id.test.ts deleted file mode 100644 index 4bc4fa6..0000000 --- a/tests/tool-span-conversation-id.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -// 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 }); - } -}); diff --git a/tests/turn-span-agent-name.test.ts b/tests/turn-span-agent-name.test.ts index 2bdcb60..d93a00c 100644 --- a/tests/turn-span-agent-name.test.ts +++ b/tests/turn-span-agent-name.test.ts @@ -2,55 +2,46 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// The top-level agent name is user-customizable (settings `agent_name` / -// `WEAVE_AGENT_NAME`). The daemon resolves the effective value and passes it -// to startTurnSpan, which must stamp it on BOTH the span name (`invoke_agent -// `, which drives Weave's Agents-view grouping) and the -// `gen_ai.agent.name` attribute. +// 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. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { startTurnSpan, ATTR, DEFAULT_AGENT_NAME } from '../src/genaiSpans.ts'; +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 setupTracer(): { tracer: ReturnType; exporter: InMemorySpanExporter; provider: BasicTracerProvider } { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; +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 baseArgs(agentName: string) { - return { - sessionId: 'sess-1', - conversationId: 'conv-1', - turnNumber: 1, - prompt: 'hello', - cwd: '/tmp', - source: 'startup', - pluginVersion: '0.0.0-test', - agentName, - }; -} - -test('startTurnSpan: agentName drives the span name and gen_ai.agent.name', async () => { - const { tracer, exporter, provider } = setupTracer(); +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]) { - startTurnSpan(tracer, baseArgs(name)).end(); - } - await provider.forceFlush(); + 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(); - for (const name of ['my-custom-agent', DEFAULT_AGENT_NAME]) { - const span = exporter.getFinishedSpans().find(s => s.name === `invoke_agent ${name}`); - assert.ok(span, `span name must embed the agent name "${name}"`); - assert.equal(span.attributes[ATTR.AGENT_NAME], name); + 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 3c49d22..0000000 --- a/tests/turn-span-integration.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// Integration identity rides OTel Baggage onto EVERY span, not just the turn -// root. The daemon stashes per-session baggage at SessionStart and activates it -// for each event (in routeEvent); IntegrationBaggageSpanProcessor copies the -// `weave.integration.*` entries onto every span at onStart. 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. -// -// This drives the real routeEvent entry point (not the handlers directly) so -// the baggage context.with wrapping is exercised, and registers an -// AsyncLocalStorage context manager the way production's provider.register() -// does, so context.active() propagates across the handlers' awaits. - -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 { context } from '@opentelemetry/api'; -import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { GlobalDaemon } from '../src/daemon.ts'; -import { IntegrationBaggageSpanProcessor } from '../src/genaiSpans.ts'; -import { VERSION } from '../src/setup.ts'; - -// Production installs this via NodeTracerProvider.register(); the test injects a -// BasicTracerProvider, so set it up here or context.with won't propagate. -context.setGlobalContextManager(new AsyncLocalStorageContextManager().enable()); - -function setupTracer() { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new IntegrationBaggageSpanProcessor(), new SimpleSpanProcessor(exporter)], - }); - return { tracer: provider.getTracer('test'), exporter, provider }; -} - -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 makeDaemon(tracer: unknown) { - const logFile = path.join(os.tmpdir(), `wcp-integ-${process.pid}.log`); - const d = new GlobalDaemon('/tmp/unused-integ.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('integration baggage stamps weave.integration.* on every span (turn, chat, tool, text)', async () => { - 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 { 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: '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 provider.forceFlush(); - - 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 baggage context.with wrapping 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(turn.parentSpanContext, 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 }); - } -}); diff --git a/tests/turn-span-system-instructions.test.ts b/tests/turn-span-system-instructions.test.ts deleted file mode 100644 index fe6d6a0..0000000 --- a/tests/turn-span-system-instructions.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: 2026 CoreWeave, Inc. -// SPDX-License-Identifier: MIT -// SPDX-PackageName: weave-claude-code - -// The loaded instruction files (global/project CLAUDE.md, .claude/rules, -// @-imports) surfaced by the InstructionsLoaded hook are stamped on every turn -// root as `gen_ai.system_instructions` — one OTel text part per file, in load -// order. The base Claude Code system prompt is never exposed to hooks, so this -// captures only the user/project instructions appended to it. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { - BasicTracerProvider, - InMemorySpanExporter, - SimpleSpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { startTurnSpan, ATTR } from '../src/genaiSpans.ts'; - -function setupTracer(): { tracer: ReturnType; exporter: InMemorySpanExporter; provider: BasicTracerProvider } { - const exporter = new InMemorySpanExporter(); - const provider = new BasicTracerProvider({ - spanProcessors: [new SimpleSpanProcessor(exporter)], - }); - const tracer = provider.getTracer('test'); - return { tracer, exporter, provider }; -} - -function baseArgs(systemInstructions?: string[]) { - return { - sessionId: 'sess-1', - conversationId: 'conv-1', - turnNumber: 1, - prompt: 'hello', - cwd: '/tmp', - source: 'startup', - pluginVersion: '0.0.0-test', - agentName: 'claude-code', - systemInstructions, - }; -} - -test('startTurnSpan: stamps gen_ai.system_instructions as ordered text parts', async () => { - const { tracer, exporter, provider } = setupTracer(); - - startTurnSpan(tracer, baseArgs(['GLOBAL', 'PROJECT'])).end(); - await provider.forceFlush(); - - const span = exporter.getFinishedSpans()[0]; - assert.ok(span, 'turn span exported'); - assert.equal( - span.attributes[ATTR.SYSTEM_INSTRUCTIONS], - JSON.stringify([ - { type: 'text', content: 'GLOBAL' }, - { type: 'text', content: 'PROJECT' }, - ]), - ); -}); - -test('startTurnSpan: omits gen_ai.system_instructions when there are none', async () => { - const { tracer, exporter, provider } = setupTracer(); - - // Both undefined and empty-array (the daemon passes [] for a session with no - // loaded instructions) must leave the attribute unset — not "[]". - startTurnSpan(tracer, baseArgs(undefined)).end(); - startTurnSpan(tracer, baseArgs([])).end(); - await provider.forceFlush(); - - for (const span of exporter.getFinishedSpans()) { - assert.equal(span.attributes[ATTR.SYSTEM_INSTRUCTIONS], undefined); - } -}); From 25abd7a9aec5000c00c6e6b5bf93e68ae7a92ae5 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Tue, 21 Jul 2026 10:03:49 -0700 Subject: [PATCH 2/4] refactor(daemon): drop the turn-number span attribute The turn's ordinal (weave.claude_code.turn.number) is derivable from the conversation's ordered turns server-side; stop setting it on the span. Co-Authored-By: Claude Fable 5 --- src/daemon.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/daemon.ts b/src/daemon.ts index b14b994..2354a29 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -625,7 +625,6 @@ export class GlobalDaemon { [ATTR.WEAVE_CWD]: session.cwd, [ATTR.WEAVE_SOURCE]: session.source, [ATTR.WEAVE_PLUGIN_VERSION]: VERSION, - [ATTR.WEAVE_TURN_NUMBER]: session.turnNumber, }); session.currentTurn = turn; return turn; From 55fceee7c8c178c142db2e09a37b1d7e9ad75318 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Tue, 21 Jul 2026 10:05:10 -0700 Subject: [PATCH 3/4] refactor(daemon): set plugin version on the conversation, not each turn weave.claude_code.plugin.version is session-level, so set it once on the Conversation; it propagates to every turn and child span like the other integration attributes. Co-Authored-By: Claude Fable 5 --- src/daemon.ts | 1 - src/sessionState.ts | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 2354a29..09c4983 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -624,7 +624,6 @@ export class GlobalDaemon { turn.setAttributes({ [ATTR.WEAVE_CWD]: session.cwd, [ATTR.WEAVE_SOURCE]: session.source, - [ATTR.WEAVE_PLUGIN_VERSION]: VERSION, }); session.currentTurn = turn; return turn; diff --git a/src/sessionState.ts b/src/sessionState.ts index 8c605f1..e30a76b 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -8,7 +8,7 @@ import { VERSION } from './setup.js'; import { parseSessionFd, extractAssistantTextBlocks, isTextBlock } from './parser.js'; import { TranscriptFile, readFirstTranscriptLine } from './transcriptFile.js'; import { sha256Hex } from './utils.js'; -import { buildIntegrationAttrs, addPermissionResolvedEvent } from './genaiSpans.js'; +import { ATTR, buildIntegrationAttrs, addPermissionResolvedEvent } from './genaiSpans.js'; import type { CompactionAttrs } from './genaiSpans.js'; /** Stores the tool span opened at PreToolUse so PostToolUse can close it. */ @@ -285,7 +285,11 @@ export function newSessionState(options: NewSessionStateOptions): SessionState { meta: { claude_code_app_version: claudeCodeAppVersion }, }); const conversation = options.tracingEnabled - ? weave.startConversation({ conversationId, agentName: options.agentName, attributes: integrationAttrs }) + ? weave.startConversation({ + conversationId, + agentName: options.agentName, + attributes: { ...integrationAttrs, [ATTR.WEAVE_PLUGIN_VERSION]: VERSION }, + }) : undefined; return { From 0b3bea6285e0d237a2342fb08639c76cf1e6b2fe Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Tue, 21 Jul 2026 20:20:44 -0700 Subject: [PATCH 4/4] refactor(daemon): remove obsolete turn bookkeeping --- src/daemon.ts | 136 +++----------------- src/genaiSpans.ts | 9 -- src/sessionState.ts | 89 +------------ tests/daemon-session-reconstruction.test.ts | 21 --- tests/helpers.ts | 21 +-- tests/turn-span-agent-name.test.ts | 5 - 6 files changed, 26 insertions(+), 255 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 09c4983..ad14044 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -13,8 +13,6 @@ import type { InstructionsLoadedHookInput, UserPromptSubmitHookInput, PreToolUseHookInput, - PostToolUseHookInput, - PostToolUseFailureHookInput, PermissionRequestHookInput, SubagentStartHookInput, SubagentStopHookInput, @@ -81,15 +79,9 @@ function daemonEntryPath(): string { } } -// ───────────────────────────────────────────────────────────────────────────── -// GlobalDaemon -// ───────────────────────────────────────────────────────────────────────────── - -// Idle window before self-reap; fires only with nothing in flight. Long enough -// that mid-session gaps don't strand a resumed session. WEAVE_INACTIVITY_MS overrides. +// Keep resumed sessions warm across long idle gaps. const INACTIVITY_TIMEOUT_MS = 120 * 60 * 1_000; // 120 minutes -// Ceiling on holding past the idle timeout while work is in flight, so a stuck -// session or silent teammate can't pin the daemon forever (see checkInactivity). +// Bound how long stuck in-flight work can keep the daemon alive. const INFLIGHT_HOLD_MAX_MS = 60 * 60 * 1_000; // 60 minutes const CONNECTION_TIMEOUT_MS = 5_000; // 5 seconds per connection @@ -108,7 +100,6 @@ export class GlobalDaemon { * hook can fire before SessionStart). Keyed by session_id; drained into the * session at SessionStart / reconstruction and cleared (also on SessionEnd). */ private pendingInstructions = new Map(); - /** True once `weave.init` has completed. All span emission is gated on it. */ private tracingEnabled = false; constructor( @@ -142,12 +133,9 @@ export class GlobalDaemon { process.on('SIGTERM', () => void this.shutdown('SIGTERM')); process.on('SIGINT', () => void this.shutdown('SIGINT')); - // Without SIGHUP, Node exits on terminal close with no JS handler, leaving - // the socket inode behind for the next hook to mistake for a live daemon. - // Routing it through shutdown() unlinks it. + // Route terminal-close cleanup through shutdown to remove the socket inode. process.on('SIGHUP', () => void this.shutdown('SIGHUP')); - // Remove the inode on any non-signal exit; SIGKILL/OOM aren't coverable and - // are handled by the hook handler's probe at the next event. + // The next hook's socket probe handles cleanup after SIGKILL or OOM. process.on('exit', () => { try { if (fs.existsSync(this.socketPath)) fs.unlinkSync(this.socketPath); } catch { /* nothing more we can do */ } }); @@ -228,14 +216,11 @@ export class GlobalDaemon { throw new Error(`Invalid weave_project format: '${this.config.weaveProject}' (expected entity/project)`); } - // The SDK reads apiKey/host from env only (weave login() writes netrc; wrong - // for a daemon): WF_TRACE_SERVER_URL aims the OTLP exporter, WANDB_API_KEY - // auths. WANDB_BASE_URL must stay unset or a wrong trace URL is derived. + // weave.init reads the exporter endpoint and API key from the environment. process.env['WF_TRACE_SERVER_URL'] = this.config.baseUrl; process.env['WANDB_API_KEY'] = this.config.apiKey; - // Route OTel diagnostics into the daemon log; the batch exporter otherwise - // fails silently (a bad key or unreachable host drops every span unlogged). + // Surface exporter failures in the daemon log. const otelDiag = (message: string, ...args: unknown[]) => this.log('ERROR', `otel: ${message}${args.length ? ` ${args.map(String).join(' ')}` : ''}`); diag.setLogger( @@ -331,8 +316,6 @@ export class GlobalDaemon { // ── event routing ───────────────────────────────────────────────────────── private async routeEvent(payload: HookPayload): Promise { - // Validate the socket's raw hook JSON against the SDK schema once, so the - // handlers get typed, discriminated inputs instead of re-casting per field. const input = payload as HookInput; const sessionId = input.session_id; if (!sessionId) { @@ -342,14 +325,10 @@ export class GlobalDaemon { this.log('INFO', `${input.hook_event_name} session=${sessionId}${input.agent_id ? ` agent=${input.agent_id}` : ''}`); - // Each event runs in its own isolated frame so the SDK's single-active - // guards (one Conversation/Turn/LLM per frame) never trip across concurrent - // sessions; identity flows through the held handles, not the frame. + // Isolate SDK active-span state across concurrent sessions. await weave.runIsolated(() => this.dispatchEvent(input, sessionId)); } - /** Run one hook event's handler, narrowing `input` via the discriminant; split - * from `routeEvent` so it runs inside the isolated per-event context. */ private async dispatchEvent(input: HookInput, sessionId: string): Promise { try { switch (input.hook_event_name) { @@ -370,10 +349,7 @@ 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); @@ -433,7 +409,6 @@ export class GlobalDaemon { cwd, source, initialRequestModel, - turnNumber: 0, agentName: this.config.agentName, tracingEnabled: this.tracingEnabled, }); @@ -448,9 +423,6 @@ export class GlobalDaemon { ); } - /** Canonical `gen_ai.conversation.id`: root of the `forkedFrom.sessionId` chain, - * so `--continue`/`--resume` sessions stitch to the original. Sibling-file walk; - * the first read retries (SessionStart races the flush); depth-capped. */ private async resolveConversationId( sessionId: string, transcriptPath: string, @@ -510,9 +482,6 @@ export class GlobalDaemon { return current; } - /** Return the tracked session, reconstructing it from the event's - * `transcript_path` when this daemon never saw its SessionStart (a session - * outliving a daemon restart would otherwise go untraced). */ private async getOrReconstructSession( sessionId: string, input: HookInput, @@ -539,15 +508,6 @@ export class GlobalDaemon { const initialRequestModel = raw['model'] as string | undefined; const conversationId = await this.resolveConversationId(sessionId, transcript.resolvedPath, source); - // Seed the turn counter from the turns already on disk so numbering - // continues across the restart instead of resetting to 1. - let priorTurns = 0; - try { - priorTurns = parseSessionFd(transcript.getFd())?.turns.length ?? 0; - } catch (err) { - this.log('DEBUG', `Reconstruct ${sessionId}: could not count prior turns: ${err}`); - } - const session = newSessionState({ sessionId, conversationId, @@ -555,7 +515,6 @@ export class GlobalDaemon { cwd, source, initialRequestModel, - turnNumber: priorTurns, agentName: this.config.agentName, tracingEnabled: this.tracingEnabled, }); @@ -563,16 +522,12 @@ export class GlobalDaemon { this.drainPendingInstructions(session); this.log( 'INFO', - `Session reconstructed after restart: ${sessionId} (conversation=${conversationId}, prior_turns=${priorTurns})`, + `Session reconstructed after restart: ${sessionId} (conversation=${conversationId})`, ); return session; } - /** Capture one instruction file for `gen_ai.system_instructions`. Only - * `file_path` arrives, so read it here (sync preserves load order). Early - * files buffer; reconstructing would no-op the real SessionStart. */ private handleInstructionsLoaded(sessionId: string, input: InstructionsLoadedHookInput): void { - // No tracing means no turn to set these on; skip reads nothing will consume. if (!this.tracingEnabled) return; const filePath = input.file_path; let content: string; @@ -609,9 +564,6 @@ export class GlobalDaemon { this.log('DEBUG', `Drained ${pending.length} buffered instruction file(s) into session ${session.sessionId}`); } - /** Open a turn under the session's conversation, with per-turn session metadata - * (queryable without a session-level span). Each turn roots its own trace; the - * conversation handle seeds conversation.id + identity onto the whole subtree. */ private startSessionTurn(session: SessionState, userMessage?: string): weave.Turn | undefined { if (!session.conversation) return undefined; const turn = session.conversation.startTurn({ @@ -630,8 +582,6 @@ export class GlobalDaemon { } private async handleUserPromptSubmit(sessionId: string, input: UserPromptSubmitHookInput): Promise { - // Reconstruct if this daemon never saw SessionStart (e.g. a fresh daemon - // took over mid-session) so the rest of the session stays traced. const session = await this.getOrReconstructSession(sessionId, input); if (!session) { this.log('ERROR', `Unknown session (no transcript_path to reconstruct): ${sessionId}`); @@ -642,15 +592,12 @@ export class GlobalDaemon { const prompt = input.prompt; this.log( 'DEBUG', - `UserPromptSubmit: session=${sessionId} current_turn=${session.currentTurn ? 'open' : 'none'} turn_number=${session.turnNumber} prompt=${snippet(prompt, 120)}`, + `UserPromptSubmit: session=${sessionId} current_turn=${session.currentTurn ? 'open' : 'none'} prompt=${snippet(prompt, 120)}`, ); - // A user interrupt ends a turn with no Stop hook; close any still-open turn - // as superseded before the new one overwrites the handle and leaks the root. + // Close interrupted turns that never received a Stop hook. this.finalizeOpenTurn(session, 'superseded_by_next_prompt'); - session.turnNumber += 1; - session.turnToolCalls = 0; const turn = this.startSessionTurn(session, prompt); if (!turn) return; @@ -660,59 +607,33 @@ export class GlobalDaemon { session.pendingCompaction = undefined; } - this.log('INFO', `Created turn span (turn ${session.turnNumber})`); + this.log('INFO', 'Created turn span'); } - /** Parked: chat/tool spans (and the Agent-dispatch marker) land later in this - * stack; until then tool calls only feed the turn's tool-count attribute. */ 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}`); } - private countToolCall(session: SessionState, toolName: string): void { - session.totalToolCalls += 1; - session.turnToolCalls += 1; - session.toolCounts[toolName] = (session.toolCounts[toolName] ?? 0) + 1; - } - - /** Parked: the permission span event lands with tool spans later in this stack. */ 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}`); } - private async handlePostToolUse(sessionId: string, input: PostToolUseHookInput): Promise { - const session = this.sessions.get(sessionId); - if (!session || !input.tool_name) return; - // Parked: tool spans land later in this stack; count for the turn attrs. - this.countToolCall(session, input.tool_name); - } - - private async handlePostToolUseFailure(sessionId: string, input: PostToolUseFailureHookInput): Promise { - const session = this.sessions.get(sessionId); - if (!session || !input.tool_name) return; - // Parked: tool spans land later in this stack; count for the turn attrs. - this.countToolCall(session, input.tool_name); - } - - /** Parked: subagent `invoke_agent` markers land later in this stack. */ 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}`); } - /** Parked: subagent `invoke_agent` markers land later in this stack. */ private async handleSubagentStop(sessionId: string, input: SubagentStopHookInput): Promise { const session = await this.getOrReconstructSession(sessionId, input); if (!session || !this.tracingEnabled) return; this.log('DEBUG', `SubagentStop (not yet traced): session=${sessionId} agent=${input.agent_id}`); } - /** Parked: teammate tracing lands later in this stack. */ 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}`); @@ -722,8 +643,7 @@ export class GlobalDaemon { const session = this.sessions.get(sessionId); if (!session) return; - // Live CC payloads carry the compaction summary + item counts the backend - // wants, but the SDK type doesn't declare them; read them off the raw record. + // Claude Code sends compaction fields that are absent from the SDK type. const raw = input as Record; const summary = raw['summary'] ?? raw['compaction_summary']; const itemsBefore = raw['items_before']; @@ -736,7 +656,7 @@ export class GlobalDaemon { if (session.currentTurn) { setCompactionAttrs(session.currentTurn, attrs); - this.log('INFO', `PreCompact attached to active turn ${session.turnNumber} (session ${sessionId})`); + this.log('INFO', `PreCompact attached to active turn (session ${sessionId})`); } else { // Buffer until the next UserPromptSubmit opens a turn span. session.pendingCompaction = attrs; @@ -748,8 +668,7 @@ export class GlobalDaemon { const session = this.sessions.get(sessionId); if (!session?.currentTurn) return; - // Pass last_assistant_message so the retry waits for the synthesis to - // flush; otherwise the final chat span drops when the read races the writer. + // Wait for transcript synthesis to flush before reading the final response. const finalAssistantMessage = input.last_assistant_message; const parsedSession = await this.parseSessionFileWithRetry( session.transcript, @@ -763,14 +682,11 @@ 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)}`, ); - // Parked: per-response chat spans land later in this stack; the turn root - // carries the parsed output/model until then. - const parsedTexts = currentTurn?.textBlocks() ?? []; const lastMessage = input.last_assistant_message ?? ''; const assistantMessages = parsedTexts.length > 0 ? parsedTexts : (lastMessage ? [lastMessage] : []); - const turnAttrs: Attributes = { [ATTR.WEAVE_TURN_TOOL_COUNT]: session.turnToolCalls }; + const turnAttrs: Attributes = {}; if (assistantMessages.length) { turnAttrs[ATTR.OUTPUT_MESSAGES] = assistantOutputMessages(assistantMessages); } @@ -778,16 +694,15 @@ export class GlobalDaemon { if (finishReasons?.length) { turnAttrs[ATTR.RESPONSE_FINISH_REASONS] = finishReasons; } - session.currentTurn.setAttributes(turnAttrs); - // record(), not setAttributes: Turn.end() re-emits its internal request - // model, which would clobber a raw attribute write of the parsed model. + if (Object.keys(turnAttrs).length) session.currentTurn.setAttributes(turnAttrs); + // Turn.end() re-emits its request model, so update it through record(). if (model) { session.currentTurn.record({ model }); } session.currentTurn.end(); session.currentTurn = undefined; - this.log('INFO', `Finished turn ${session.turnNumber} (${session.turnToolCalls} tools)`); + this.log('INFO', 'Finished turn'); } private async handleSessionEnd(sessionId: string, input: SessionEndHookInput): Promise { @@ -799,7 +714,7 @@ export class GlobalDaemon { this.log( 'DEBUG', - `SessionEnd: session=${sessionId} reason=${input.reason} transcript_path=${session.transcript.resolvedPath} turns=${session.turnNumber} total_tools=${session.totalToolCalls} pending_tools=${session.pendingToolCalls.size} open_subagents=${session.subagents.size()}`, + `SessionEnd: session=${sessionId} reason=${input.reason} transcript_path=${session.transcript.resolvedPath} pending_tools=${session.pendingToolCalls.size} open_subagents=${session.subagents.size()}`, ); this.finalizeSession(session, 'session_ended'); @@ -811,16 +726,10 @@ export class GlobalDaemon { session.transcript.close(); } - /** End every still-open span on the session, setting `orphan_reason`; finalizing - * at shutdown keeps an interrupted turn's root exported. Idempotent. (Only the - * turn root exists at this point in the stack.) */ private finalizeSession(session: SessionState, orphanReason: string): void { this.finalizeOpenTurn(session, orphanReason); } - /** Close the still-open turn (root) span, setting `orphanReason`. Also called at - * UserPromptSubmit when an interrupt ended the turn with no Stop hook, else the - * next turn overwrites the handle and leaks the root unexported. */ private finalizeOpenTurn(session: SessionState, orphanReason: string): void { if (session.currentTurn) { session.currentTurn.setAttributes({ [ATTR.WEAVE_ORPHAN_REASON]: orphanReason }); @@ -835,8 +744,7 @@ export class GlobalDaemon { private checkInactivity(): void { const idle = Date.now() - this.lastActivity; if (idle <= this.inactivityMs) return; - // Hold open while work is in flight (open turn/tool/subagent) so it isn't cut - // off; the INFLIGHT_HOLD_MAX_MS ceiling stops a stuck session pinning us. + // 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; @@ -864,13 +772,9 @@ export class GlobalDaemon { process.exit(0); } - /** Shutdown minus the final `process.exit` (split out for tests). Sessions are - * finalized before `weave.flushOTel()` so the just-ended roots make the final - * export batch instead of being left rootless. */ private async drain(reason: string): Promise { this.log('INFO', `Shutdown: ${reason}`); this.server?.close(); - // Per-session try: one bad session must not abort the flush below. for (const session of this.sessions.values()) { try { this.finalizeSession(session, 'daemon_shutdown'); diff --git a/src/genaiSpans.ts b/src/genaiSpans.ts index d86219f..f818192 100644 --- a/src/genaiSpans.ts +++ b/src/genaiSpans.ts @@ -61,8 +61,6 @@ export const ATTR = { WEAVE_CWD: 'weave.claude_code.cwd', WEAVE_SOURCE: 'weave.claude_code.source', WEAVE_PLUGIN_VERSION: 'weave.claude_code.plugin.version', - WEAVE_TURN_NUMBER: 'weave.claude_code.turn.number', - WEAVE_TURN_TOOL_COUNT: 'weave.claude_code.turn.tool_count', WEAVE_ORPHAN_REASON: 'weave.claude_code.orphan_reason', WEAVE_DISPLAY_NAME: 'weave.claude_code.display_name', @@ -283,11 +281,6 @@ export function toolDisplayName(toolName: string, input: Record } } -// ───────────────────────────────────────────────────────────────────────────── -// LEGACY: baggage plumbing and hand-rolled span builders. Unreferenced after -// the SDK swap in daemon.ts; kept out of this diff and deleted in the next PR. -// ───────────────────────────────────────────────────────────────────────────── - /** 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.'; @@ -381,7 +374,6 @@ type TurnSpanArgs = { * resume share `gen_ai.conversation.id`). For fresh sessions, equals * `sessionId`. */ conversationId: string; - turnNumber: number; prompt: string; cwd: string; source: string; @@ -414,7 +406,6 @@ export function startTurnSpan(tracer: Tracer, args: TurnSpanArgs): Span { [ATTR.WEAVE_CWD]: args.cwd, [ATTR.WEAVE_SOURCE]: args.source, [ATTR.WEAVE_PLUGIN_VERSION]: args.pluginVersion, - [ATTR.WEAVE_TURN_NUMBER]: args.turnNumber, [ATTR.INPUT_MESSAGES]: jsonStr([{ role: 'user', content: args.prompt }]), }; if (args.requestModel) attrs[ATTR.REQUEST_MODEL] = args.requestModel; diff --git a/src/sessionState.ts b/src/sessionState.ts index e30a76b..288a677 100644 --- a/src/sessionState.ts +++ b/src/sessionState.ts @@ -20,12 +20,7 @@ export type PendingToolCall = { permissionRequested?: boolean; } -/** The chat span (LLM) open for one assistant response; its tool spans parent - * here. Ordered `gen_ai.output.messages` parts land at finalize (next response - * or Stop), once all the response's split transcript lines are present. */ type ActiveChat = { - /** Response key (Anthropic `message.id`, or index fallback) this chat span - * represents; see `chatMessageKey`. */ responseKey: string; llm: weave.LLM; } @@ -39,27 +34,20 @@ export function resolvePermissionIfPending(pending: PendingToolCall, approved: b }); } -/** sha256 of the firing prompt, used to correlate an `Agent` PreToolUse with - * the subagent's SubagentStart by matching transcript content. */ export function hashPrompt(prompt: string): string { return sha256Hex(prompt); } -/** A session's subagent-transcript directory, sibling of the session transcript: - * //subagents/. */ export function subagentsDirFor(sessionTranscriptPath: string): string { const projectDir = path.dirname(sessionTranscriptPath); const sessionDirName = path.basename(sessionTranscriptPath, '.jsonl'); return path.join(projectDir, sessionDirName, 'subagents'); } -/** Map a parent transcript path + subagent agent_id to the subagent's transcript file. */ export function computeSubagentTranscriptPath(parentTranscriptPath: string, agentId: string): string { return path.join(subagentsDirFor(parentTranscriptPath), `agent-${agentId}.jsonl`); } -/** User-message content of a `{type: 'user'}` transcript line, else undefined; - * array-form content joins across text blocks. */ export function extractUserMessageContent(line: Record | undefined): string | undefined { if (!line || line['type'] !== 'user') return undefined; const msg = line['message']; @@ -67,39 +55,29 @@ export function extractUserMessageContent(line: Record | undefi const content = (msg as Record)['content']; if (typeof content === 'string') return content; if (Array.isArray(content)) { - // Join text blocks verbatim, keeping empties (unlike extractAssistantTextBlocks). const parts = content.filter(isTextBlock).map(block => block.text); return parts.length > 0 ? parts.join('') : undefined; } return undefined; } -/** True if the last assistant call's joined text ends with `suffix`, - * ignoring trailing whitespace on either side. */ export function lastAssistantTextEndsWith( result: NonNullable>, suffix: string, ): boolean { const call = result.turns.at(-1)?.assistantCalls().at(-1); - // Turn exists but parser saw no assistant calls (writer mid-flush). if (!call) return false; return extractAssistantTextBlocks(call.contentBlocks).join('\n').trimEnd().endsWith(suffix); } -/** One instruction file from the `InstructionsLoaded` hook, deduped by path; - * propagated to every turn root as `gen_ai.system_instructions`. */ export type LoadedInstruction = { filePath: string; content: string }; -/** Append `item`, or replace the entry with the same filePath (a reload updates - * in place), preserving first-seen order. */ export function upsertInstruction(list: LoadedInstruction[], item: LoadedInstruction): void { const idx = list.findIndex((i) => i.filePath === item.filePath); if (idx >= 0) list[idx] = item; else list.push(item); } -/** First line of the subagent transcript, retrying briefly (Claude Code may not - * have flushed it yet when SubagentStart fires). */ const SUBAGENT_TRANSCRIPT_RETRY_DELAYS_MS = [0, 50, 100, 150]; export async function readSubagentFirstLineWithRetry( transcriptPath: string, @@ -112,41 +90,21 @@ export async function readSubagentFirstLineWithRetry( return undefined; } -/** Tracks a subagent (its own `invoke_agent` span under the turn). Matched: - * created at PreToolUse, `agentId` filled at SubagentStart by sha256(prompt) + - * type. Orphan: created at SubagentStart when nothing matches. (Marker, not - * execute_tool: see handlePreToolUse's Agent-dispatch branch.) */ export type SubagentTracker = { subagentType: string; detectedAt: Date; - toolUseId?: string; // tool_use_id of the spawning Agent tool (matched path only) - subAgent?: weave.SubAgent; // subagent's `invoke_agent` marker span; its chat/tool spans nest here + toolUseId?: string; + subAgent?: weave.SubAgent; agentId?: string; - /** sha256 of the prompt passed to the Agent tool; matched against the - * subagent's transcript line-1 user message at SubagentStart. */ promptHash?: string; - /** True once the invoke_agent span has been ended. Guards against - * double-end when PostToolUse and SubagentStop both try to close it. */ ended?: boolean; - /** Stored at SubagentStart; the TeammateIdle payload carries the coordinator's - * transcript_path, so this is the reliable copy. */ transcriptPath?: string; - /** Orphan awaiting TeammateIdle: SubagentStop leaves the span open so - * TeammateIdle can close it with full all-turns content. */ pendingTeammateIdle?: boolean; - /** Set for `team_name` spawns: the marker is owned by GlobalDaemon.teamMembers - * and closed at the teammate's TeammateIdle, not at PostToolUse(Agent). */ teamName?: string; } -/** Cross-session team correlation: a teammate runs as its own session, so its - * TeammateIdle fires under a different session_id and the per-session lookup - * misses; the coordinator's PreToolUse(Agent, team_name) is the anchor. FIFO - * per `${team_name}::${name}` so re-spawns don't overwrite a live span. */ export type TeamMember = { subAgent: weave.SubAgent; - /** Coordinator's Conversation handle; seeds conversation.id + integration - * identity (which don't inherit cross-session) onto the teammate's subtree. */ conversation: weave.Conversation; coordinatorTranscriptPath: string; emitted: boolean; @@ -154,49 +112,28 @@ export type TeamMember = { export type SessionState = { sessionId: string; - /** Root ancestor's session id (= `gen_ai.conversation.id`) so resumed turns - * stitch with their pre-resume turns; equals `sessionId` for fresh sessions. */ conversationId: string; transcript: TranscriptFile; cwd: string; source: string; initialRequestModel?: string; - /** Conversation handle; seeds conversation.id, agent identity, and integration - * attrs onto every turn and (via the handle chain, no ambient state) all child - * spans, even across `runIsolated` frames. Unset when tracing is disabled. */ conversation?: weave.Conversation; currentTurn?: weave.Turn; - turnNumber: number; - totalToolCalls: number; - turnToolCalls: number; - toolCounts: Record; - pendingToolCalls: Map; subagents: SubagentTracking; - /** Chat span (LLM) open for the in-progress assistant call; tool spans parent - * here. Finalized (and cleared) at Stop, or on transition to the next call. */ activeChat?: ActiveChat; - /** Response keys already given a chat span this turn; Stop emits spans for the - * rest (responses with no tool_use never hit PreToolUse). Reset per turn. */ emittedChatSpanResponseKeys: Set; /** Compaction attrs buffered while no turn span is open. Drained on next UserPromptSubmit. */ pendingCompaction?: CompactionAttrs; - /** Instruction files from InstructionsLoaded, in load order, deduped by path; - * propagated to every turn root as `gen_ai.system_instructions`. */ systemInstructions: LoadedInstruction[]; } -/** - * Per-session container that tracks subagents from PreToolUse (when an Agent - * tool with subagent_type is detected) through SubagentStop. Single source of - * truth for the tracker list, with intent-revealing lookup methods. - */ export class SubagentTracking { private trackers: SubagentTracker[] = []; @@ -205,8 +142,6 @@ export class SubagentTracking { this.trackers.push(tracker); } - /** Oldest unmatched tracker (no agent_id yet) for (promptHash, subagentType); - * FIFO so back-to-back identical Agent calls correlate in dispatch order. */ findUnmatchedByContent(promptHash: string, subagentType: string): SubagentTracker | undefined { let best: SubagentTracker | undefined; for (const t of this.trackers) { @@ -222,7 +157,6 @@ export class SubagentTracking { return this.trackers.find(t => t.agentId === agentId); } - /** Oldest tracker awaiting TeammateIdle with this subagentType (FIFO). */ findPendingTeammateIdle(subagentType: string): SubagentTracker | undefined { let best: SubagentTracker | undefined; for (const t of this.trackers) { @@ -233,8 +167,6 @@ export class SubagentTracking { return best; } - /** Lookup by the spawning Agent call's tool_use_id; at PostToolUse the Agent - * call has an invoke_agent marker, not a pendingToolCalls entry. */ byToolUseId(toolUseId: string): SubagentTracker | undefined { return this.trackers.find(t => t.toolUseId === toolUseId); } @@ -253,10 +185,6 @@ export class SubagentTracking { } } -/** Options for {@link newSessionState}. `turnNumber` seeds the turn counter: 0 - * for a brand-new session, or the number of turns already on disk when - * reconstructing a session lost across a daemon restart (so the resumed turn - * keeps counting up instead of resetting to 1). */ type NewSessionStateOptions = { sessionId: string; conversationId: string; @@ -264,19 +192,14 @@ type NewSessionStateOptions = { cwd: string; source: string; initialRequestModel: string | undefined; - turnNumber: number; - /** The top-level agent name the conversation (and thus every turn) carries. */ agentName: string; - /** When false (tracing disabled), no Conversation handle is created. */ tracingEnabled: boolean; }; -/** Build a fresh SessionState, starting its Conversation when tracing is on. */ export function newSessionState(options: NewSessionStateOptions): SessionState { - const { sessionId, conversationId, transcript, cwd, source, initialRequestModel, turnNumber } = + const { sessionId, conversationId, transcript, cwd, source, initialRequestModel } = options; - // Best-effort CC CLI version from the transcript head line; built here so a - // reconstructed session carries the same integration identity. + // Preserve the Claude Code version when reconstructing a session. const headLine = readFirstTranscriptLine(transcript.resolvedPath); const version = headLine?.['version']; const claudeCodeAppVersion = typeof version === 'string' ? version : undefined; @@ -300,10 +223,6 @@ export function newSessionState(options: NewSessionStateOptions): SessionState { source, initialRequestModel, conversation, - turnNumber, - totalToolCalls: 0, - turnToolCalls: 0, - toolCounts: {}, pendingToolCalls: new Map(), subagents: new SubagentTracking(), emittedChatSpanResponseKeys: new Set(), diff --git a/tests/daemon-session-reconstruction.test.ts b/tests/daemon-session-reconstruction.test.ts index 1697553..06c5518 100644 --- a/tests/daemon-session-reconstruction.test.ts +++ b/tests/daemon-session-reconstruction.test.ts @@ -66,24 +66,3 @@ test('UserPromptSubmit for an unknown session reconstructs it from transcript_pa await d.stop(); } }); - -test('reconstructed session continues turn numbering from the transcript', async () => { - const d = await startTestDaemon(); - try { - const sessionId = 'recon-sess-002'; - // Three completed turns already on disk → the resumed turn is turn 4. - const transcript = writeTranscript(d.home, sessionId, 3); - - await d.send({ - hook_event_name: 'UserPromptSubmit', - session_id: sessionId, - transcript_path: transcript, - prompt: 'fourth prompt', - }); - - const ok = await d.waitForLog(/Created turn span \(turn 4\)/, 3000); - assert.ok(ok, `expected the reconstructed turn to be numbered 4; log was:\n${d.readLog()}`); - } finally { - await d.stop(); - } -}); diff --git a/tests/helpers.ts b/tests/helpers.ts index e79e36e..8541f43 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -94,18 +94,11 @@ export function writeKnownMarketplace(home: string, source: Record { if (!genaiExporter) { - // weave.init() requires a key (WANDB_API_KEY/~/.netrc) even offline; resolve - // fake creds the way the daemon does so the bridge stays hermetic on CI. + // weave.init requires credentials even with an in-memory exporter. const settings: Settings = { log_file: '', daemon_socket: '', weave_project: 'e/p', wandb_api_key: 'fake-key-for-test', agent_name: null, debug: false, installed_at: '', version: '0.0.0-test', @@ -119,15 +112,11 @@ export async function initWeaveInMemory(): Promise { return genaiExporter; } -/** The daemon surface the genai tests drive: the (private) routeEvent entry - * point production feeds from the socket, plus drain for shutdown tests. */ export type DaemonDriver = { routeEvent(p: Record): Promise; drain(reason: string): Promise; }; -/** GlobalDaemon with tracing marked enabled (SDK inited via `initWeaveInMemory`), - * skipping the real socket/`start()`; viewed through the DaemonDriver seam. */ export function makeGenaiDaemon(agentName = 'claude-code'): DaemonDriver { const logFile = path.join(os.tmpdir(), `wcp-genai-${process.pid}.log`); const d = new GlobalDaemon('/tmp/unused.sock', logFile, { @@ -137,8 +126,6 @@ export function makeGenaiDaemon(agentName = 'claude-code'): DaemonDriver { return d as unknown as DaemonDriver; } -/** One JSONL transcript line for a user text message. `version` mirrors the - * CC CLI version field real transcripts carry on their head line. */ export function transcriptUserLine(text: string, opts: { version?: string; timestamp?: string } = {}): string { return JSON.stringify({ type: 'user', @@ -148,7 +135,6 @@ export function transcriptUserLine(text: string, opts: { version?: string; times }); } -/** One JSONL transcript line for a single-text assistant response. */ export function transcriptAssistantLine( text: string, usage: Record, @@ -168,18 +154,15 @@ export function transcriptAssistantLine( }); } -/** Flush any spans buffered in the SDK so the in-memory exporter has them. */ export function flushWeave(): Promise { return weave.flushOTel(); } -/** Parent span id: weave's provider exposes `parentSpanId` (older providers used - * `parentSpanContext`), so read whichever is present. */ +/** Support both current and older OTel parent-span fields. */ export function spanParentId(s: ReadableSpan): string | undefined { return (s as unknown as { parentSpanId?: string }).parentSpanId ?? s.parentSpanContext?.spanId; } -/** Direct children of `parent`, ordered by span start time. */ export function childrenOf(spans: ReadableSpan[], parent: ReadableSpan): ReadableSpan[] { const parentId = parent.spanContext().spanId; return spans diff --git a/tests/turn-span-agent-name.test.ts b/tests/turn-span-agent-name.test.ts index d93a00c..aa03ee5 100644 --- a/tests/turn-span-agent-name.test.ts +++ b/tests/turn-span-agent-name.test.ts @@ -2,10 +2,6 @@ // SPDX-License-Identifier: MIT // SPDX-PackageName: weave-claude-code -// 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. - import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs'; @@ -33,7 +29,6 @@ test('turn span: agentName drives gen_ai.agent.name', async () => { 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();