From 02c53cc322cdd5308eee6c6edfc5b9d5a9084604 Mon Sep 17 00:00:00 2001 From: ekeith <55766816+evanmkeith@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:55:31 -0700 Subject: [PATCH] fix(trace-opencode): lazy-init SessionState in chat.message for API-created sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Sessions created via the OpenCode HTTP API (`POST /sessions`) were silently dropping all trace data. OpenCode publishes `session.created` internally but does not deliver it to plugins for API-created sessions. Since the plugin initializes `SessionState` exclusively in the `session.created` handler, every subsequent hook (`chat.message`, `message.part.updated`, `message.updated`, `tool.execute.*`) found no state and returned early — resulting in complete, silent data loss. ## Changes - **`src/tracing.ts`** — `chat.message` hook: change `const state` → `let state`; replace the `if (!state) { return }` early-exit with lazy initialization that creates a root span and `SessionState` on demand, identical to the `session.created` root-session path - **`src/tracing.ts`** — `session.created` handler: add a guard (`if (sessionStates.has(sessionKey)) return`) so a late-arriving `session.created` event doesn't overwrite an already lazy-initialized state and orphan the root span ## Testing - [ ] API-created session: `POST /sessions` → send message via API → spans appear in Braintrust - [ ] Interactive session (regression): open OpenCode normally → send message → traces unchanged - [ ] Bump version to `0.0.6` in `package.json` ## Notes `startTime` for lazily-initialized sessions is set at the first `chat.message` rather than true session creation. This is acceptable — API-created sessions have no meaningful "open" period before the first message. Fixes BT-4649. --- src/event-processor.ts | 72 ++++++++++++++++++- src/tracing.test.ts | 156 +++++++++++++++++++++++++++++++++++++++++ src/tracing.ts | 59 +++++++++++++++- 3 files changed, 281 insertions(+), 6 deletions(-) diff --git a/src/event-processor.ts b/src/event-processor.ts index ca8bfb3..0840595 100644 --- a/src/event-processor.ts +++ b/src/event-processor.ts @@ -111,10 +111,14 @@ export class EventProcessor { userMessage: string, model?: { providerID?: string; modelID?: string }, ): Promise { - const state = this.sessionStates.get(sessionID) + let state = this.sessionStates.get(sessionID) if (!state) { - this.log("No state found for session", { sessionID }) - return + // session.created is not delivered to plugins for API-created sessions. + // Initialize state lazily so API-created sessions are traced correctly. + this.log("No state found for session, initializing lazily (API-created session)", { + sessionID, + }) + state = await this.initSessionStateLazily(sessionID) } // Finalize previous turn if exists @@ -183,6 +187,57 @@ export class EventProcessor { return this.processChatMessage(sessionID, userMessage, model) } + /** + * Lazily initialize session state and emit a root span for API-created sessions. + * Called from processChatMessage when no state exists for a session — this happens + * because OpenCode does not deliver session.created to plugins for sessions created + * via the HTTP API (POST /sessions). + */ + private async initSessionStateLazily(sessionID: string): Promise { + const rootSpanId = generateUUID() + const now = this.clock.now() + const state: SessionState = { + rootSpanId, + effectiveRootSpanId: rootSpanId, + turnNumber: 0, + toolCallCount: 0, + startTime: now, + llmOutputParts: new Map(), + llmToolCalls: new Map(), + llmReasoningParts: new Map(), + processedLlmMessages: new Set(), + toolStartTimes: new Map(), + toolCallMessageIds: new Map(), + toolCallArgs: new Map(), + toolCallOutputs: new Map(), + } + this.sessionStates.set(sessionID, state) + + const root_span: SpanData = { + id: rootSpanId, + span_id: rootSpanId, + root_span_id: rootSpanId, + created: new Date(now).toISOString(), + metadata: { + ...this.config.additionalMetadata, + session_id: sessionID, + workspace: this.config.worktree, + directory: this.config.directory, + }, + metrics: { + start: now, + }, + span_attributes: { + name: `OpenCode: ${this.config.projectName}`, + type: "task", + }, + } + + await this.spanSink.insertSpan(root_span) + this.log("Created root span via lazy init", { rootSpanId, sessionID }) + return state + } + /** * Process a tool.execute.before hook call */ @@ -352,6 +407,17 @@ export class EventProcessor { } const sessionKey = String(sessionID) + + // Guard: skip if state already exists (lazy-initialized when chat.message arrived + // before session.created for API-created sessions). + if (this.sessionStates.has(sessionKey)) { + this.log( + "Session state already exists (lazy-initialized), skipping session.created init", + { sessionKey }, + ) + return + } + const rootSpanId = generateUUID() const state: SessionState = { rootSpanId, diff --git a/src/tracing.test.ts b/src/tracing.test.ts index b861714..02711ff 100644 --- a/src/tracing.test.ts +++ b/src/tracing.test.ts @@ -533,6 +533,162 @@ describe("Reasoning/Thinking Content", () => { }) }) +describe("API-created sessions (lazy init)", () => { + it("session without session.created still produces a complete trace", async () => { + const sessionId = "ses_api_1" + const messageId = "msg_api_1" + + // No sessionCreated event — simulates an API-created session + await assertEventsProduceTree( + session( + sessionId, + chatMessage("Hello from API session"), + textPart(sessionId, messageId, "Hi there!"), + messageCompleted(sessionId, messageId, { tokens: { input: 10, output: 5 } }), + sessionIdle(sessionId), + ), + { + span_attributes: { name: "OpenCode: test-project", type: "task" }, + children: [ + { + span_attributes: { name: "Turn 1", type: "task" }, + children: [ + { + span_attributes: { name: "anthropic/claude-3-haiku", type: "llm" }, + metrics: { prompt_tokens: 10, completion_tokens: 5, tokens: 15 }, + }, + ], + }, + ], + }, + ) + }) + + it("session without session.created supports multiple turns", async () => { + const sessionId = "ses_api_multi" + + await assertEventsProduceTree( + session( + sessionId, + chatMessage("First message"), + textPart(sessionId, "msg_1", "First response"), + messageCompleted(sessionId, "msg_1", { tokens: { input: 5, output: 3 } }), + sessionIdle(sessionId), + chatMessage("Second message"), + textPart(sessionId, "msg_2", "Second response"), + messageCompleted(sessionId, "msg_2", { tokens: { input: 8, output: 4 } }), + sessionIdle(sessionId), + ), + { + span_attributes: { name: "OpenCode: test-project", type: "task" }, + children: [ + { + span_attributes: { name: "Turn 1", type: "task" }, + children: [ + { + span_attributes: { name: "anthropic/claude-3-haiku", type: "llm" }, + metrics: { prompt_tokens: 5, completion_tokens: 3 }, + }, + ], + }, + { + span_attributes: { name: "Turn 2", type: "task" }, + children: [ + { + span_attributes: { name: "anthropic/claude-3-haiku", type: "llm" }, + metrics: { prompt_tokens: 8, completion_tokens: 4 }, + }, + ], + }, + ], + }, + ) + }) + + it("session without session.created supports tool calls", async () => { + const sessionId = "ses_api_tool" + const messageId = "msg_api_tool" + + await assertEventsProduceTree( + session( + sessionId, + chatMessage("Read a file"), + toolCallPart(sessionId, messageId, "call_1", "read", { filePath: "/config.ts" }), + toolExecute("call_1", "read", "/config.ts", { filePath: "/config.ts" }, "file contents"), + textPart(sessionId, messageId, "I read the file."), + messageCompleted(sessionId, messageId, { tokens: { input: 15, output: 8 } }), + sessionIdle(sessionId), + ), + { + span_attributes: { name: "OpenCode: test-project", type: "task" }, + children: [ + { + span_attributes: { name: "Turn 1", type: "task" }, + children: [ + { span_attributes: { name: "read: config.ts", type: "tool" } }, + { + span_attributes: { name: "anthropic/claude-3-haiku", type: "llm" }, + metrics: { prompt_tokens: 15, completion_tokens: 8 }, + }, + ], + }, + ], + }, + ) + }) + + it("session.created after lazy init is idempotent (no duplicate root spans)", async () => { + const clock = new TestClock() + const collector = new TestSpanCollector() + const processor = new EventProcessor(collector, { projectName: "test-project" }, { clock }) + + const sessionId = "ses_api_idempotent" + + // chat.message fires first (API-created session — no session.created yet) + clock.tick() + await processor.processChatMessage(sessionId, "Hello", { + providerID: "anthropic", + modelID: "claude-3-haiku", + }) + + // session.created fires late (should be a no-op — state already exists) + clock.tick() + await processor.processEvent({ + type: "session.created", + properties: { + info: { + id: sessionId, + projectID: "test-project", + directory: "/test", + version: "1.0.0", + title: "Test", + time: { created: Date.now(), updated: Date.now() }, + }, + }, + }) + + clock.tick() + await processor.processEvent({ + type: "session.idle", + properties: { sessionID: sessionId }, + }) + + const spans = collector.getSpans() + const { spansToTree } = await import("./span-sink") + const tree = spansToTree(spans) + + // Root span should exist and have exactly one child turn (no duplicate root spans) + expect(tree).not.toBeNull() + expect(tree?.name).toBe("OpenCode: test-project") + expect(tree?.children.length).toBe(1) + expect(tree?.children[0]?.name).toBe("Turn 1") + + // Verify there is only one root span (no duplicate from session.created) + const rootSpans = spans.filter((s) => !s._is_merge && !s.span_parents?.length) + expect(rootSpans.length).toBe(1) + }) +}) + describe("Fail-open: missing or non-string tool output", () => { it("tool with undefined output creates span without crashing", async () => { const sessionId = "ses_undef_output" diff --git a/src/tracing.ts b/src/tracing.ts index 82042a5..6b353d9 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -244,6 +244,15 @@ export function createTracingHooks( const sessionKey = String(sessionID) + // Guard: skip if state already exists (lazy-initialized when chat.message arrived + // before session.created for API-created sessions). + if (sessionStates.has(sessionKey)) { + log("Session state already exists (lazy-initialized), skipping session.created init", { + sessionKey, + }) + return + } + // Create root span for session const rootSpanId = generateUUID() const state: SessionState = { @@ -747,10 +756,54 @@ export function createTracingHooks( const { sessionID } = messageInput log("Chat message", { sessionID, parts: output?.parts }) - const state = sessionStates.get(sessionID) + let state = sessionStates.get(sessionID) if (!state) { - log("No state found for session", { sessionID }) - return + // session.created is not delivered to plugins for API-created sessions. + // Initialize state lazily so API-created sessions are traced correctly. + log("No state found for session, initializing lazily (API-created session)", { sessionID }) + const rootSpanId = generateUUID() + const now = wallClock.now() + state = { + rootSpanId, + effectiveRootSpanId: rootSpanId, + turnNumber: 0, + toolCallCount: 0, + startTime: now, + llmOutputParts: new Map(), + llmToolCalls: new Map(), + llmReasoningParts: new Map(), + processedLlmMessages: new Set(), + toolStartTimes: new Map(), + toolCallMessageIds: new Map(), + toolCallArgs: new Map(), + toolCallOutputs: new Map(), + } + sessionStates.set(sessionID, state) + + const root_span: SpanData = { + id: rootSpanId, + span_id: rootSpanId, + root_span_id: rootSpanId, + created: new Date(now).toISOString(), + metadata: { + ...config.additionalMetadata, + session_id: sessionID, + workspace: input.worktree, + directory: input.directory, + hostname: getHostname(), + username: getUsername(), + os: getOS(), + }, + metrics: { + start: now, + }, + span_attributes: { + name: `OpenCode: ${getProjectName(input.worktree)}`, + type: "task", + }, + } + enqueue(root_span) + log("Created root span via lazy init", { rootSpanId, sessionID }) } // Finalize previous turn if exists (using merge to only update end time)