From 20a3ce1258532468c828fffac6cfe59eabc83da7 Mon Sep 17 00:00:00 2001 From: Evan Burrell Date: Fri, 12 Jun 2026 13:59:34 +0100 Subject: [PATCH] fix(trace-opencode): support opencode 1.14.x delta event stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #9. opencode 1.14.x stopped delivering message.updated and message.part.updated to plugins; it now streams via message.part.delta and signals turn end via session.idle. LLM-span emission was gated on the old events, so no LLM spans landed in Braintrust against 1.14.x — root and turn spans appeared, but every turn showed "LLM calls: 0". Add a delta accumulator (per messageID, text-field only) and synthesize an LLM span on session.idle when the existing path didn't already emit one. The old path remains intact for opencode <1.14, and a per-turn guard prevents double-emit when both paths fire. Provider/model captured from chat.message resets unconditionally so a turn without a model object doesn't inherit the previous turn's identity. Shared synthesis lives in src/delta-synthesis.ts so the test-driving EventProcessor and the production hook can't drift. --- src/delta-synthesis.ts | 84 ++++++++++++++++ src/event-processor.ts | 77 +++++++++++++- src/test-helpers.ts | 22 ++++ src/tracing.test.ts | 222 +++++++++++++++++++++++++++++++++++++++++ src/tracing.ts | 73 +++++++++++++- 5 files changed, 475 insertions(+), 3 deletions(-) create mode 100644 src/delta-synthesis.ts diff --git a/src/delta-synthesis.ts b/src/delta-synthesis.ts new file mode 100644 index 0000000..1c0f755 --- /dev/null +++ b/src/delta-synthesis.ts @@ -0,0 +1,84 @@ +/** + * Shared synthesis of an LLM span from streamed deltas (opencode 1.14.x). + * + * Used when `message.updated` is not delivered to plugins, so the existing + * LLM-span path never fires. The fallback accumulates `message.part.delta` + * payloads into a per-message buffer; on `session.idle` we synthesize one + * LLM span per turn from the accumulated text. + * + * Both event-processor.ts (test-driving EventProcessor) and tracing.ts + * (production hooks) need this logic — extracted here so they can't drift. + */ + +import type { SpanData } from "./client" +import { msToSeconds } from "./clock" + +/** + * Structural type matching the fields the helper reads. Both SessionState + * shapes (in event-processor.ts and tracing.ts) satisfy this — no nominal + * import needed. + */ +export interface DeltaSynthesizableState { + currentTurnSpanId?: string + effectiveRootSpanId: string + systemPrompt?: string + currentInput?: string + currentTurnStartTime?: number + currentProviderID?: string + currentModelID?: string + deltaAccumulatedOutput: Map +} + +export function joinAccumulatedDeltas(state: DeltaSynthesizableState): string { + return Array.from(state.deltaAccumulatedOutput.values()).join("") +} + +/** + * Build a synthesized LLM span for the current turn, or return `undefined` + * if there's nothing to synthesize (no accumulated text, or no turn span to + * attach to). Tokens are intentionally omitted (not zeroed) so the Braintrust + * dashboard doesn't render misleading "0 tokens" — that information is + * unrecoverable from the delta stream. + */ +export function buildSynthesizedLlmSpan( + state: DeltaSynthesizableState, + endSeconds: number, +): SpanData | undefined { + const accumulated = joinAccumulatedDeltas(state) + if (!accumulated) return undefined + if (!state.currentTurnSpanId) return undefined + + const providerID = state.currentProviderID ?? "unknown" + const modelID = state.currentModelID ?? "unknown" + const modelName = `${providerID}/${modelID}` + + const llmInput: Array> = [] + if (state.systemPrompt) llmInput.push({ role: "system", content: state.systemPrompt }) + if (state.currentInput) llmInput.push({ role: "user", content: state.currentInput }) + + const id = crypto.randomUUID() + return { + id, + span_id: id, + root_span_id: state.effectiveRootSpanId, + span_parents: [state.currentTurnSpanId], + input: llmInput.length > 0 ? llmInput : undefined, + output: [{ role: "assistant", content: accumulated }], + metrics: { + start: + state.currentTurnStartTime !== undefined + ? msToSeconds(state.currentTurnStartTime) + : undefined, + end: endSeconds, + }, + metadata: { + model: modelID, + provider: providerID, + synthesized_from: "session.idle", + }, + span_attributes: { + name: modelName, + type: "llm", + }, + } +} diff --git a/src/event-processor.ts b/src/event-processor.ts index a94b9e3..0585954 100644 --- a/src/event-processor.ts +++ b/src/event-processor.ts @@ -9,6 +9,7 @@ import type { Event } from "@opencode-ai/sdk" import type { SpanData } from "./client" import type { Clock } from "./clock" import { msToSeconds, wallClock } from "./clock" +import { buildSynthesizedLlmSpan, joinAccumulatedDeltas } from "./delta-synthesis" import type { SpanSink } from "./span-sink" // Generate a UUID @@ -44,6 +45,13 @@ interface SessionState { > llmReasoningParts: Map // messageId -> reasoning/thinking text processedLlmMessages: Set + // opencode 1.14.x fallback: model captured from chat.message hook (used when + // message.updated is never delivered), accumulated delta text per messageID, + // and a per-turn guard so we don't double-emit when both event paths fire. + currentProviderID?: string + currentModelID?: string + llmSpanEmittedForCurrentTurn?: boolean + deltaAccumulatedOutput: Map // Tool span tracking toolStartTimes: Map toolCallMessageIds: Map // callID -> messageId (to look up reasoning) @@ -94,6 +102,9 @@ export class EventProcessor { await this.handleSessionCreated(info) } else if (event.type === "message.part.updated") { await this.handleMessagePartUpdated(props) + } else if ((event.type as string) === "message.part.delta") { + // opencode 1.14+ event, not yet in the SDK's typed Event union. + await this.handleMessagePartDelta(props) } else if (event.type === "message.updated") { await this.handleMessageUpdated(props) } else if (event.type === "session.idle") { @@ -143,6 +154,13 @@ export class EventProcessor { state.currentTurnSpanId = generateUUID() state.currentOutput = undefined state.currentInput = userMessage + state.deltaAccumulatedOutput.clear() + state.llmSpanEmittedForCurrentTurn = false + + // Reset unconditionally so a turn without a model object doesn't inherit + // the previous turn's identity in the synthesized LLM span fallback. + state.currentProviderID = model?.providerID + state.currentModelID = model?.modelID const now = this.clock.now() const nowSeconds = msToSeconds(now) @@ -225,6 +243,7 @@ export class EventProcessor { llmToolCalls: new Map(), llmReasoningParts: new Map(), processedLlmMessages: new Set(), + deltaAccumulatedOutput: new Map(), toolStartTimes: new Map(), toolCallMessageIds: new Map(), toolCallArgs: new Map(), @@ -385,6 +404,7 @@ export class EventProcessor { llmToolCalls: new Map(), llmReasoningParts: new Map(), processedLlmMessages: new Set(), + deltaAccumulatedOutput: new Map(), toolStartTimes: new Map(), toolCallMessageIds: new Map(), toolCallArgs: new Map(), @@ -447,6 +467,7 @@ export class EventProcessor { llmToolCalls: new Map(), llmReasoningParts: new Map(), processedLlmMessages: new Set(), + deltaAccumulatedOutput: new Map(), toolStartTimes: new Map(), toolCallMessageIds: new Map(), toolCallArgs: new Map(), @@ -560,6 +581,31 @@ export class EventProcessor { } } + /** + * Accumulate streamed deltas from opencode 1.14.x. The event payload is + * { sessionID, messageID, partID, field, delta }. The `field` is always + * "text" for both text and reasoning parts, so we cannot distinguish them + * here; we concatenate every delta into one buffer keyed by messageID. + * The synthesized LLM span on session.idle reads from this buffer. + */ + private async handleMessagePartDelta(props: Record): Promise { + const sessionID = props.sessionID as string + const messageID = props.messageID as string + const field = props.field as string + const delta = props.delta as string + + if (!sessionID || !messageID || !delta) return + // Only accumulate text-field deltas. Other fields (e.g., a future + // `tool_input` for streamed tool-call args) must not corrupt assistant text. + if (field !== "text") return + + const state = this.sessionStates.get(sessionID) + if (!state) return + + const prev = state.deltaAccumulatedOutput.get(messageID) ?? "" + state.deltaAccumulatedOutput.set(messageID, prev + delta) + } + private async handleMessageUpdated(props: Record): Promise { const messageInfo = props.info as Record | undefined if (!messageInfo) { @@ -678,10 +724,27 @@ export class EventProcessor { }, } + // Set the guard BEFORE awaiting so a failed insert doesn't leave the flag + // false and let session.idle synthesize a duplicate span. + state.llmSpanEmittedForCurrentTurn = true await this.spanSink.insertSpan(llmSpan) this.log("Created LLM span", { messageId, modelName, tokens: totalTokens }) } + private async synthesizeLlmSpanFromDeltas( + state: SessionState, + endSeconds: number, + ): Promise { + const span = buildSynthesizedLlmSpan(state, endSeconds) + if (!span) return + // Set the guard BEFORE awaiting (see handleMessageUpdated for rationale). + state.llmSpanEmittedForCurrentTurn = true + await this.spanSink.insertSpan(span) + this.log("Synthesized LLM span from deltas", { + modelName: span.span_attributes?.name, + }) + } + private async handleSessionIdle(sessionID: string | undefined): Promise { if (!sessionID) { this.log("session.idle but no session ID found") @@ -695,13 +758,23 @@ export class EventProcessor { const now = this.clock.nowSeconds() const isChildSession = !!state.parentSessionId + // opencode 1.14.x fallback: if no message.updated landed for this turn + // but we accumulated streamed deltas, synthesize an LLM span before + // closing the turn so the trace isn't empty. + if (state.currentTurnSpanId && !state.llmSpanEmittedForCurrentTurn) { + await this.synthesizeLlmSpanFromDeltas(state, now) + } + // Close current turn span if exists if (state.currentTurnSpanId) { + // currentOutput may still be empty when only deltas arrived; fall back to + // the accumulated delta buffer so the turn span shows the assistant text. + const fallbackOutput = state.currentOutput || joinAccumulatedDeltas(state) || undefined const turnSpan: SpanData = { id: state.currentTurnSpanId, span_id: state.currentTurnSpanId, root_span_id: state.effectiveRootSpanId, - output: state.currentOutput || undefined, + output: fallbackOutput, metrics: { end: now, }, @@ -712,6 +785,8 @@ export class EventProcessor { state.currentInput = undefined state.currentOutput = undefined state.currentTurnStartTime = undefined + state.deltaAccumulatedOutput.clear() + state.llmSpanEmittedForCurrentTurn = false this.log("Turn span closed", { sessionKey, turnNumber: state.turnNumber }) } diff --git a/src/test-helpers.ts b/src/test-helpers.ts index d367149..1896558 100644 --- a/src/test-helpers.ts +++ b/src/test-helpers.ts @@ -290,6 +290,28 @@ export function toolCallCompletedPart( } } +/** + * Create a message.part.delta event (opencode 1.14.x event stream). + * Not yet in @opencode-ai/sdk's typed Event union, so we cast through unknown. + */ +export function messagePartDelta( + sessionID: string, + messageID: string, + delta: string, + options?: { partID?: string; field?: string }, +): Event { + return { + type: "message.part.delta", + properties: { + sessionID, + messageID, + partID: options?.partID ?? `prt_text_${messageID}`, + field: options?.field ?? "text", + delta, + }, + } as unknown as Event +} + /** * Create message.updated event for assistant message completion */ diff --git a/src/tracing.test.ts b/src/tracing.test.ts index d75a759..07b4d5a 100644 --- a/src/tracing.test.ts +++ b/src/tracing.test.ts @@ -19,6 +19,7 @@ import { childSessionCreated, eventsToTree, messageCompleted, + messagePartDelta, reasoningPart, session, sessionCreated, @@ -1669,3 +1670,224 @@ describe("MCP tool output fixture replay", () => { expect(toolSpan?.output as string).toContain("Getting started") }) }) + +describe("opencode 1.14.x event stream (delta-only)", () => { + it("synthesizes an LLM span on session.idle from accumulated deltas", async () => { + const sessionId = "ses_1_14" + const messageId = "msg_1_14" + + const tree = await eventsToTree( + session( + sessionId, + sessionCreated(sessionId), + chatMessage("Hi", { providerID: "anthropic", modelID: "claude-sonnet-4-6" }), + messagePartDelta(sessionId, messageId, "Hello"), + messagePartDelta(sessionId, messageId, " there"), + messagePartDelta(sessionId, messageId, "!"), + sessionIdle(sessionId), + ), + ) + + expect(tree).not.toBeNull() + expect(tree?.name).toBe("OpenCode: test-project") + + const turn = tree?.children[0] + expect(turn?.name).toBe("Turn 1") + + const llm = turn?.children.find((c) => c.type === "llm") + expect(llm).toBeDefined() + expect(llm?.name).toBe("anthropic/claude-sonnet-4-6") + + const output = llm?.output as Array<{ role: string; content: string }> + expect(output[0].role).toBe("assistant") + expect(output[0].content).toBe("Hello there!") + + // Tokens are intentionally omitted (not zeroed) — unrecoverable from deltas. + expect(llm?.metrics?.tokens).toBeUndefined() + expect(llm?.metrics?.prompt_tokens).toBeUndefined() + expect(llm?.metrics?.completion_tokens).toBeUndefined() + + expect(llm?.metadata?.synthesized_from).toBe("session.idle") + }) + + it("falls back to delta buffer for the turn-span output when no message.updated arrives", async () => { + const sessionId = "ses_1_14_turn_output" + const messageId = "msg_1_14_turn_output" + + const tree = await eventsToTree( + session( + sessionId, + sessionCreated(sessionId), + chatMessage("Tell me a joke"), + messagePartDelta(sessionId, messageId, "Why did the chicken"), + messagePartDelta(sessionId, messageId, " cross the road?"), + sessionIdle(sessionId), + ), + ) + + const turn = tree?.children[0] + expect(turn?.output).toBe("Why did the chicken cross the road?") + }) + + it("prefers message.updated over delta synthesis when both arrive (transition case)", async () => { + const sessionId = "ses_transition" + const messageId = "msg_transition" + + const tree = await eventsToTree( + session( + sessionId, + sessionCreated(sessionId), + chatMessage("Hi", { providerID: "anthropic", modelID: "claude-sonnet-4-6" }), + messagePartDelta(sessionId, messageId, "Hello from deltas"), + // Old-style events still arrive — these should win, and the synthesized + // span must NOT also fire (no double-emit). + textPart(sessionId, messageId, "Hello from message.updated"), + messageCompleted(sessionId, messageId, { tokens: { input: 5, output: 3 } }), + sessionIdle(sessionId), + ), + ) + + const turn = tree?.children[0] + const llmSpans = turn?.children.filter((c) => c.type === "llm") ?? [] + expect(llmSpans.length).toBe(1) + + // The single LLM span comes from message.updated (has token counts, no synthesized_from marker). + const llm = llmSpans[0] + expect(llm?.metrics?.tokens).toBe(8) + expect(llm?.metadata?.synthesized_from).toBeUndefined() + }) + + it("prepends system prompt from experimental.chat.system.transform to the synthesized LLM span", async () => { + const sessionId = "ses_1_14_sys" + const messageId = "msg_1_14_sys" + + const tree = await eventsToTree( + session( + sessionId, + sessionCreated(sessionId), + systemTransform(["You are helpful."]), + chatMessage("Hi"), + messagePartDelta(sessionId, messageId, "Hi back"), + sessionIdle(sessionId), + ), + ) + + const turn = tree?.children[0] + const llm = turn?.children.find((c) => c.type === "llm") + const llmInput = llm?.input as Array<{ role: string; content: string }> + expect(llmInput[0]).toEqual({ role: "system", content: "You are helpful." }) + expect(llmInput[1]).toEqual({ role: "user", content: "Hi" }) + }) + + it("does not leak provider/model from the previous turn when Turn 2's chat.message has no model object", async () => { + // Drive the EventProcessor directly so we can pass `undefined` as the + // chat.message model param — the eventsToTree harness defaults it. + const sessionId = "ses_model_leak" + const clock = new TestClock() + const collector = new TestSpanCollector() + const processor = new EventProcessor(collector, { projectName: "test" }, { clock }) + + clock.tick() + await processor.processEvent(sessionCreated(sessionId)) + + // Turn 1: explicit model. + clock.tick() + await processor.processChatMessage(sessionId, "Turn 1", { + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + }) + clock.tick() + await processor.processEvent(messagePartDelta(sessionId, "msg_1", "first")) + clock.tick() + await processor.processEvent(sessionIdle(sessionId)) + + // Turn 2: no model object — mirrors a production chat.message where + // messageInput.model is undefined or a non-object string. + clock.tick() + await processor.processChatMessage(sessionId, "Turn 2", undefined) + clock.tick() + await processor.processEvent(messagePartDelta(sessionId, "msg_2", "second")) + clock.tick() + await processor.processEvent(sessionIdle(sessionId)) + + const llmSpans = collector + .getSpans() + .filter((s) => s.span_attributes?.type === "llm" && !s._is_merge) + expect(llmSpans.length).toBe(2) + + // Turn 2's synthesized span must NOT inherit Turn 1's model identity. + const turn2Span = llmSpans[1] + expect(turn2Span.metadata?.provider).toBe("unknown") + expect(turn2Span.metadata?.model).toBe("unknown") + expect(turn2Span.span_attributes?.name).toBe("unknown/unknown") + }) + + it("does not synthesize an LLM span when no deltas arrived (model never responded)", async () => { + const sessionId = "ses_no_deltas" + + const tree = await eventsToTree( + session( + sessionId, + sessionCreated(sessionId), + chatMessage("Hi"), + // No message.part.delta events at all — model never streamed a response. + sessionIdle(sessionId), + ), + ) + + const turn = tree?.children[0] + expect(turn?.name).toBe("Turn 1") + // Turn still closes, but no LLM child span — the absence is meaningful. + const llmSpans = turn?.children.filter((c) => c.type === "llm") ?? [] + expect(llmSpans.length).toBe(0) + }) + + it("ignores message.part.delta events with non-text fields (e.g. tool_input)", async () => { + const sessionId = "ses_field_filter" + const messageId = "msg_field_filter" + + const tree = await eventsToTree( + session( + sessionId, + sessionCreated(sessionId), + chatMessage("Hi"), + messagePartDelta(sessionId, messageId, "real text"), + // Future opencode could stream tool-call input deltas under a + // different field. We must NOT concatenate them into the assistant + // response. + messagePartDelta(sessionId, messageId, '{"path":"/tmp"}', { field: "tool_input" }), + messagePartDelta(sessionId, messageId, " more text"), + sessionIdle(sessionId), + ), + ) + + const turn = tree?.children[0] + const llm = turn?.children.find((c) => c.type === "llm") + const output = llm?.output as Array<{ role: string; content: string }> + expect(output[0].content).toBe("real text more text") + }) + + it("preserves metrics.start when the turn began at clock time 0 (epoch)", async () => { + // Drive the EventProcessor with a clock starting at 0 — currentTurnStartTime + // becomes 0, which a `value ? msToSeconds(value) : undefined` ternary would + // collapse to undefined. We want the start metric preserved as 0. + const sessionId = "ses_epoch_zero" + const clock = new TestClock(0) + const collector = new TestSpanCollector() + const processor = new EventProcessor(collector, { projectName: "test" }, { clock }) + + await processor.processEvent(sessionCreated(sessionId)) + await processor.processChatMessage(sessionId, "Hi", { + providerID: "anthropic", + modelID: "claude", + }) + await processor.processEvent(messagePartDelta(sessionId, "msg_epoch", "hello")) + clock.advance(1000) + await processor.processEvent(sessionIdle(sessionId)) + + const llmSpan = collector + .getSpans() + .find((s) => s.span_attributes?.type === "llm" && !s._is_merge) + expect(llmSpan?.metrics?.start).toBe(0) + }) +}) diff --git a/src/tracing.ts b/src/tracing.ts index 7d33c40..be72698 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -12,6 +12,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Event } from "@opencode-ai/sdk" import type { BraintrustConfig, SpanData } from "./client" import { msToSeconds, wallClock } from "./clock" +import { buildSynthesizedLlmSpan, joinAccumulatedDeltas } from "./delta-synthesis" import { extractToolOutput } from "./event-processor" import type { FileLogger } from "./file-logger" import type { SpanQueue } from "./span-queue" @@ -50,6 +51,13 @@ interface SessionState { > // messageId -> tool_calls llmReasoningParts: Map // messageId -> reasoning/thinking text processedLlmMessages: Set // track which assistant messages we've created spans for + // opencode 1.14.x fallback: model captured from chat.message hook (used when + // message.updated is never delivered), accumulated delta text per messageID, + // and a per-turn guard so we don't double-emit when both event paths fire. + currentProviderID?: string + currentModelID?: string + llmSpanEmittedForCurrentTurn?: boolean + deltaAccumulatedOutput: Map // Tool span tracking toolStartTimes: Map // callID -> start timestamp toolCallMessageIds: Map // callID -> messageId (to look up reasoning) @@ -198,6 +206,7 @@ export function createTracingHooks( llmToolCalls: new Map(), llmReasoningParts: new Map(), processedLlmMessages: new Set(), + deltaAccumulatedOutput: new Map(), toolStartTimes: new Map(), toolCallMessageIds: new Map(), toolCallArgs: new Map(), @@ -275,6 +284,7 @@ export function createTracingHooks( llmToolCalls: new Map(), llmReasoningParts: new Map(), processedLlmMessages: new Set(), + deltaAccumulatedOutput: new Map(), toolStartTimes: new Map(), toolCallMessageIds: new Map(), toolCallArgs: new Map(), @@ -407,6 +417,29 @@ export function createTracingHooks( } } } + // opencode 1.14.x: streamed deltas (not yet in the SDK's typed Event union). + // Payload: { sessionID, messageID, partID, field, delta }. The `field` + // is always "text" for both text and reasoning, so we accumulate + // everything into one buffer keyed by messageID. The session.idle + // handler synthesizes an LLM span from this buffer when message.updated + // never arrives. + else if ((event.type as string) === "message.part.delta") { + const deltaSessionID = props.sessionID as string + const deltaMessageID = props.messageID as string + const deltaField = props.field as string + const deltaText = props.delta as string + + if (!deltaSessionID || !deltaMessageID || !deltaText) return + // Only accumulate text-field deltas. Other fields (e.g., a future + // `tool_input` for streamed tool-call args) must not corrupt assistant text. + if (deltaField !== "text") return + + const state = sessionStates.get(deltaSessionID) + if (!state) return + + const prev = state.deltaAccumulatedOutput.get(deltaMessageID) ?? "" + state.deltaAccumulatedOutput.set(deltaMessageID, prev + deltaText) + } // Handle assistant message completion - create LLM span else if (event.type === "message.updated") { const messageInfo = props.info as Record | undefined @@ -540,6 +573,9 @@ export function createTracingHooks( }, } + // Set the guard BEFORE enqueue so a failed enqueue doesn't leave the + // flag false and let session.idle synthesize a duplicate span. + state.llmSpanEmittedForCurrentTurn = true enqueue(llmSpan) log("Created LLM span", { messageId, @@ -567,13 +603,30 @@ export function createTracingHooks( const now = wallClock.nowSeconds() const isChildSession = !!state.parentSessionId + // opencode 1.14.x fallback: if message.updated never landed but + // we accumulated streamed deltas, synthesize an LLM span before + // closing the turn. + if (state.currentTurnSpanId && !state.llmSpanEmittedForCurrentTurn) { + const synthSpan = buildSynthesizedLlmSpan(state, now) + if (synthSpan) { + // Set the guard BEFORE enqueue (see handleMessageUpdated rationale). + state.llmSpanEmittedForCurrentTurn = true + enqueue(synthSpan) + log("Synthesized LLM span from deltas", { + modelName: synthSpan.span_attributes?.name, + }) + } + } + // Close current turn span if exists if (state.currentTurnSpanId) { + const fallbackOutput = + state.currentOutput || joinAccumulatedDeltas(state) || undefined log("Closing turn span on idle", { sessionKey, turnNumber: state.turnNumber, input: state.currentInput?.substring(0, 100), - output: state.currentOutput?.substring(0, 100), + output: fallbackOutput?.substring(0, 100), isChildSession, }) @@ -581,7 +634,7 @@ export function createTracingHooks( id: state.currentTurnSpanId, span_id: state.currentTurnSpanId, root_span_id: state.effectiveRootSpanId, - output: state.currentOutput || undefined, + output: fallbackOutput, metrics: { end: now, }, @@ -592,6 +645,8 @@ export function createTracingHooks( state.currentInput = undefined state.currentOutput = undefined state.currentTurnStartTime = undefined + state.deltaAccumulatedOutput.clear() + state.llmSpanEmittedForCurrentTurn = false log("Turn span closed", { sessionKey, turnNumber: state.turnNumber }) } @@ -788,6 +843,7 @@ export function createTracingHooks( llmToolCalls: new Map(), llmReasoningParts: new Map(), processedLlmMessages: new Set(), + deltaAccumulatedOutput: new Map(), toolStartTimes: new Map(), toolCallMessageIds: new Map(), toolCallArgs: new Map(), @@ -840,6 +896,19 @@ export function createTracingHooks( state.turnNumber++ state.currentTurnSpanId = generateUUID() state.currentOutput = undefined + state.deltaAccumulatedOutput.clear() + state.llmSpanEmittedForCurrentTurn = false + + // Capture model identity for the synthesized LLM span fallback (opencode 1.14.x). + // The chat.message hook receives `model` as `{ providerID, modelID }` or as a string. + // Reset unconditionally so a turn without a model object doesn't inherit the + // previous turn's identity in the synthesized LLM span. + const modelObj = + typeof messageInput.model === "object" && messageInput.model + ? (messageInput.model as { providerID?: string; modelID?: string }) + : undefined + state.currentProviderID = modelObj?.providerID + state.currentModelID = modelObj?.modelID // Extract user message from parts const userMessage =