From 2f5fd67932474c64e150597c83520d123505d0aa Mon Sep 17 00:00:00 2001 From: Bill Zuo Date: Mon, 14 Sep 2026 01:08:22 -0700 Subject: [PATCH] Stamp root product session id on nested OpenCode spans. Sub-agent turns keep parent_span_id nesting under the task tool while sp.session.id / gen_ai.conversation.id always use the root OpenCode session, so thelake and Explorer see one holistic Session. Co-authored-by: Cursor --- contracts/fixtures/expected-nested-spans.json | 30 ++++++--- packages/opencode-plugin/README.md | 21 +++--- packages/opencode-plugin/src/session-graph.ts | 13 ++++ packages/opencode-plugin/src/softprobe.ts | 5 +- .../tests/session-graph.test.ts | 2 + .../opencode-plugin/tests/subagent.test.ts | 64 +++++++++++++------ packages/tracing/src/attributes.ts | 5 +- 7 files changed, 104 insertions(+), 36 deletions(-) diff --git a/contracts/fixtures/expected-nested-spans.json b/contracts/fixtures/expected-nested-spans.json index d2337cf..2b3cde7 100644 --- a/contracts/fixtures/expected-nested-spans.json +++ b/contracts/fixtures/expected-nested-spans.json @@ -7,8 +7,12 @@ "sp.observation.type": "agent", "sp.session.id": "sess-contract-1", "sp.user.id": "user-contract-1", - "sp.tags": ["contract", "nested"], - "sp.input": "{\"goal\":\"answer question\"}" + "sp.tags": [ + "contract", + "nested" + ], + "sp.input": "{\"goal\":\"answer question\"}", + "gen_ai.conversation.id": "sess-contract-1" }, "events": [], "status_code": "OK", @@ -20,7 +24,8 @@ "parent_name": "agent.run", "attributes": { "sp.observation.type": "chain", - "sp.session.id": "sess-contract-1" + "sp.session.id": "sess-contract-1", + "gen_ai.conversation.id": "sess-contract-1" }, "events": [], "status_code": "OK", @@ -67,7 +72,9 @@ "gen_ai.request.temperature": 0.2, "gen_ai.request.max_tokens": 128, "gen_ai.response.id": "chatcmpl-contract-1", - "gen_ai.response.finish_reasons": ["tool_calls"], + "gen_ai.response.finish_reasons": [ + "tool_calls" + ], "gen_ai.usage.input_tokens": 100, "gen_ai.usage.output_tokens": 25, "gen_ai.usage.total_tokens": 125, @@ -76,13 +83,20 @@ "sp.cost.total": 0.0003, "sp.prompt.name": "answer", "sp.prompt.version": 1, - "sp.tool.available_names": ["lookup"], + "sp.tool.available_names": [ + "lookup" + ], "sp.tool.available_count": 1, - "sp.tool.call_names": ["lookup"], + "sp.tool.call_names": [ + "lookup" + ], "sp.tool.call_count": 1, - "sp.tool.call_ids": ["call_lookup_1"], + "sp.tool.call_ids": [ + "call_lookup_1" + ], "sp.input": "{\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}", - "sp.output": "{\"content\":null,\"tool_calls\":[{\"id\":\"call_lookup_1\",\"name\":\"lookup\",\"arguments\":\"{\\\"name\\\":\\\"lookup\\\"}\"}]}" + "sp.output": "{\"content\":null,\"tool_calls\":[{\"id\":\"call_lookup_1\",\"name\":\"lookup\",\"arguments\":\"{\\\"name\\\":\\\"lookup\\\"}\"}]}", + "gen_ai.conversation.id": "sess-contract-1" }, "events": [ { diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 4f3f9e0..45bbe61 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -8,6 +8,9 @@ and used to improve agents. ## Install +Full guide: [Agent QA · OpenCode](https://docs.softprobe.ai/en/agent-qa/opencode) +(Explorer can also give you a short pasteable OpenCode chat prompt). + Enable OpenTelemetry and add the plugin in `opencode.json` / `opencode.jsonc`: ```json @@ -69,16 +72,18 @@ credentials when `SPCODE_MODE` is on (see softprobe-code ### Sub-agent sessions -OpenCode runs sub-agents (the `task` tool) in child sessions. The plugin nests -each child session's turn under the dispatching task span, so a whole agent -run — root session plus any sub-agents, recursively — forms a single trace -instead of several disconnected ones. The link comes from the task part's +OpenCode runs sub-agents (the `task` tool) in child OpenCode sessions. The +plugin nests each child turn under the dispatching task span (`parent_span_id`) +and stamps the **root** OpenCode session id as `sp.session.id` / +`gen_ai.conversation.id` on every nested span — one product Session for the +whole chat. Child `ses_*` ids stay internal to the plugin graph and are never +the product session key. Linkage comes from the task part's `state.metadata.sessionId` (authoritative), with `task_id` resume and parentID + task-call inference as fallbacks; ambiguous dispatches (e.g. -parallel identical task calls) are left unnested rather than guessed. Each -span keeps its own `sp.session.id`; the parent side is recorded as -`sp.metadata.opencode.parentSessionID` / `sp.metadata.opencode.parentTaskCallID` -on the child turn and `sp.child.session.id` on the task span. +parallel identical task calls) are left unnested rather than guessed. Nested +turns also carry `sp.metadata.opencode.parentSessionID` / +`sp.metadata.opencode.parentTaskCallID`, and the task span records +`sp.child.session.id` for the OpenCode child session id. Session lifecycle is isolated per session: a sub-agent session going idle finalizes only its own spans, never the parent session's in-flight work. diff --git a/packages/opencode-plugin/src/session-graph.ts b/packages/opencode-plugin/src/session-graph.ts index 96f1385..031dd10 100644 --- a/packages/opencode-plugin/src/session-graph.ts +++ b/packages/opencode-plugin/src/session-graph.ts @@ -92,6 +92,19 @@ export class SessionGraph { return this.parents.get(sessionID); } + /** Walk parent links to the product (root) session id. */ + rootSessionId(sessionID: string): string { + let current = sessionID; + const seen = new Set(); + while (true) { + if (seen.has(current)) return current; + seen.add(current); + const parent = this.parents.get(current); + if (!parent) return current; + current = parent; + } + } + registerTaskCall(callID: string, sessionID: string, args: TaskCallArgs): void { const existing = this.taskCalls.get(callID); if (existing) { diff --git a/packages/opencode-plugin/src/softprobe.ts b/packages/opencode-plugin/src/softprobe.ts index d0ff960..5a016f9 100644 --- a/packages/opencode-plugin/src/softprobe.ts +++ b/packages/opencode-plugin/src/softprobe.ts @@ -481,8 +481,11 @@ export class SoftprobeSessionTracer { } private sessionAttrs(sessionID: string) { + // Product session is always the root OpenCode session; child ses_* scopes + // keep their own bookkeeping keys but stamp the root on every span. + const productSessionId = this.sessionGraph.rootSessionId(sessionID); return { - sessionId: sessionID, + sessionId: productSessionId, ...(this.userId ? { userId: this.userId } : {}), }; } diff --git a/packages/opencode-plugin/tests/session-graph.test.ts b/packages/opencode-plugin/tests/session-graph.test.ts index 834c3b5..e8c7d4b 100644 --- a/packages/opencode-plugin/tests/session-graph.test.ts +++ b/packages/opencode-plugin/tests/session-graph.test.ts @@ -8,6 +8,8 @@ describe("SessionGraph genealogy", () => { graph.markRoot("root"); expect(graph.parentOf("child")).toBe("parent"); + expect(graph.rootSessionId("child")).toBe("parent"); + expect(graph.rootSessionId("parent")).toBe("parent"); expect(graph.isRoot("root")).toBe(true); expect(graph.isRoot("child")).toBe(false); diff --git a/packages/opencode-plugin/tests/subagent.test.ts b/packages/opencode-plugin/tests/subagent.test.ts index ba90324..600615a 100644 --- a/packages/opencode-plugin/tests/subagent.test.ts +++ b/packages/opencode-plugin/tests/subagent.test.ts @@ -38,9 +38,31 @@ function attr( | undefined; } -function turnSpan(spans: ReadableSpan[], sessionID: string) { +function turnSpan(spans: ReadableSpan[], productSessionID: string) { return spans.find( - (s) => s.name === "opencode.turn" && attr(s, "sp.session.id") === sessionID, + (s) => + s.name === "opencode.turn" && + attr(s, "sp.session.id") === productSessionID && + !attr(s, "sp.metadata.opencode.parentSessionID"), + ); +} + +/** Nested sub-agent turn under the product session (stamps root sp.session.id). */ +function childTurnSpan( + spans: ReadableSpan[], + productSessionID: string, + parentTaskCallID?: string, + openCodeParentSessionID: string = productSessionID, +) { + return spans.find( + (s) => + s.name === "opencode.turn" && + attr(s, "sp.session.id") === productSessionID && + attr(s, "sp.metadata.opencode.parentSessionID") === + openCodeParentSessionID && + (parentTaskCallID + ? attr(s, "sp.metadata.opencode.parentTaskCallID") === parentTaskCallID + : true), ); } @@ -119,14 +141,15 @@ describe("sub-agent nesting", () => { const raw = exporter.getFinishedSpans(); const parentTurn = turnSpan(raw, PARENT)!; const task = taskSpan(raw, "call-t1")!; - const childTurn = turnSpan(raw, CHILD)!; + const childTurn = childTurnSpan(raw, PARENT, "call-t1")!; expect(childTurn.parentSpanId).toBe(task.spanContext().spanId); expect(childTurn.spanContext().traceId).toBe( parentTurn.spanContext().traceId, ); - // Sessions keep their own ids; the link is expressed via metadata. - expect(attr(childTurn, "sp.session.id")).toBe(CHILD); + // Product session is the root; nesting is parent_span_id under the task. + expect(attr(childTurn, "sp.session.id")).toBe(PARENT); + expect(attr(childTurn, "gen_ai.conversation.id")).toBe(PARENT); expect(attr(childTurn, "sp.metadata.opencode.parentSessionID")).toBe( PARENT, ); @@ -261,7 +284,8 @@ describe("sub-agent nesting", () => { const resumed = raw.find( (s) => s.name === "opencode.turn" && - attr(s, "sp.session.id") === CHILD && + attr(s, "sp.session.id") === PARENT && + attr(s, "sp.metadata.opencode.parentSessionID") === PARENT && String(attr(s, "sp.input")).includes("keep going"), )!; const second = taskSpan(raw, "call-t2")!; @@ -292,7 +316,7 @@ describe("sub-agent nesting", () => { await client.forceFlush(); const raw = exporter.getFinishedSpans(); - expect(turnSpan(raw, CHILD)!.parentSpanId).toBe( + expect(childTurnSpan(raw, PARENT)!.parentSpanId).toBe( taskSpan(raw, "call-t1")!.spanContext().spanId, ); @@ -318,7 +342,7 @@ describe("sub-agent nesting", () => { await client.forceFlush(); const raw = exporter.getFinishedSpans(); - expect(turnSpan(raw, CHILD)!.parentSpanId).toBe( + expect(childTurnSpan(raw, PARENT)!.parentSpanId).toBe( taskSpan(raw, "call-t1")!.spanContext().spanId, ); @@ -348,7 +372,7 @@ describe("sub-agent nesting", () => { await client.forceFlush(); const raw = exporter.getFinishedSpans(); - const childTurn = turnSpan(raw, CHILD)!; + const childTurn = childTurnSpan(raw, PARENT)!; // Ambiguous: falls back to the parent turn, never to a guessed task span. expect(childTurn.parentSpanId).toBe( turnSpan(raw, PARENT)!.spanContext().spanId, @@ -388,7 +412,7 @@ describe("sub-agent nesting", () => { await client.forceFlush(); const raw = exporter.getFinishedSpans(); - expect(turnSpan(raw, CHILD)!.parentSpanId).toBe( + expect(childTurnSpan(raw, PARENT)!.parentSpanId).toBe( taskSpan(raw, "call-t1")!.spanContext().spanId, ); @@ -418,10 +442,11 @@ describe("sub-agent nesting", () => { await client.forceFlush(); const raw = exporter.getFinishedSpans(); - const grandchildTurn = turnSpan(raw, GRANDCHILD)!; + const grandchildTurn = childTurnSpan(raw, PARENT, "call-t2", CHILD)!; expect(grandchildTurn.parentSpanId).toBe( taskSpan(raw, "call-t2")!.spanContext().spanId, ); + expect(attr(grandchildTurn, "sp.session.id")).toBe(PARENT); expect(grandchildTurn.spanContext().traceId).toBe( turnSpan(raw, PARENT)!.spanContext().traceId, ); @@ -489,7 +514,7 @@ describe("sub-agent nesting via OpenCode hooks", () => { await client.forceFlush(); const raw = exporter.getFinishedSpans(); - expect(turnSpan(raw, CHILD)!.parentSpanId).toBe( + expect(childTurnSpan(raw, PARENT)!.parentSpanId).toBe( taskSpan(raw, "call-t1")!.spanContext().spanId, ); expect(attr(taskSpan(raw, "call-t1"), "sp.output")).toContain("verified"); @@ -524,7 +549,7 @@ describe("binding and lifecycle edge cases", () => { await client.forceFlush(); const raw = exporter.getFinishedSpans(); - const childTurn = turnSpan(raw, CHILD)!; + const childTurn = childTurnSpan(raw, PARENT)!; // Task A's span does not exist; task B is somebody else's sub-agent. // Fall back to the parent turn rather than mis-parenting under B. expect(childTurn.parentSpanId).not.toBe( @@ -602,7 +627,7 @@ describe("binding and lifecycle edge cases", () => { const raw = exporter.getFinishedSpans(); expect(attr(taskSpan(raw, "call-t1"), "sp.child.session.id")).toBeUndefined(); // The reverse edge still records the relationship. - expect(attr(turnSpan(raw, CHILD), "sp.metadata.opencode.parentTaskCallID")).toBe( + expect(attr(childTurnSpan(raw, PARENT), "sp.metadata.opencode.parentTaskCallID")).toBe( "call-t1", ); @@ -649,7 +674,8 @@ describe("binding and lifecycle edge cases", () => { const resumed = raw.find( (s) => s.name === "opencode.turn" && - attr(s, "sp.session.id") === CHILD && + attr(s, "sp.session.id") === PARENT && + attr(s, "sp.metadata.opencode.parentSessionID") === PARENT && String(attr(s, "sp.input")).includes("keep going"), )!; expect(resumed.parentSpanId).toBe( @@ -725,7 +751,7 @@ describe("binding and lifecycle edge cases", () => { await client.forceFlush(); const raw = exporter.getFinishedSpans(); - expect(turnSpan(raw, CHILD)!.parentSpanId).toBe( + expect(childTurnSpan(raw, PARENT)!.parentSpanId).toBe( taskSpan(raw, "call-t1")!.spanContext().spanId, ); @@ -745,8 +771,10 @@ describe("binding and lifecycle edge cases", () => { await client.forceFlush(); const raw = exporter.getFinishedSpans(); - const childTurn = turnSpan(raw, CHILD)!; - expect(childTurn.parentSpanId).toBeUndefined(); + // Lookup failed → treated as its own product root (no parent metadata). + const orphanTurn = turnSpan(raw, CHILD)!; + expect(orphanTurn.parentSpanId).toBeUndefined(); + expect(attr(orphanTurn, "sp.session.id")).toBe(CHILD); await client.shutdown(); }); diff --git a/packages/tracing/src/attributes.ts b/packages/tracing/src/attributes.ts index c7aae01..151e2c5 100644 --- a/packages/tracing/src/attributes.ts +++ b/packages/tracing/src/attributes.ts @@ -70,7 +70,10 @@ export function buildObservationAttributes( const metadata = ("metadata" in options ? options.metadata : undefined) ?? defaults.metadata; - if (sessionId) attrs["sp.session.id"] = sessionId; + if (sessionId) { + attrs["sp.session.id"] = sessionId; + attrs["gen_ai.conversation.id"] = sessionId; + } if (userId) attrs["sp.user.id"] = userId; if (release) attrs["sp.release"] = release; if (tags) attrs["sp.tags"] = tags;