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)