Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 69 additions & 3 deletions src/event-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,14 @@ export class EventProcessor {
userMessage: string,
model?: { providerID?: string; modelID?: string },
): Promise<void> {
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
Expand Down Expand Up @@ -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<SessionState> {
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
*/
Expand Down Expand Up @@ -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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to do some kind of merge in this case?

this.log(
"Session state already exists (lazy-initialized), skipping session.created init",
{ sessionKey },
)
return
}

const rootSpanId = generateUUID()
const state: SessionState = {
rootSpanId,
Expand Down
156 changes: 156 additions & 0 deletions src/tracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
59 changes: 56 additions & 3 deletions src/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down