diff --git a/packages/app/e2e/browser/provider-subagents.real.spec.ts b/packages/app/e2e/browser/provider-subagents.real.spec.ts index 054e0fabf..c65810756 100644 --- a/packages/app/e2e/browser/provider-subagents.real.spec.ts +++ b/packages/app/e2e/browser/provider-subagents.real.spec.ts @@ -15,6 +15,8 @@ interface ProviderSubagentCase { provider: RewindFlowProvider; sentinel: string; expectedName: string; + expectedSubtitle?: RegExp; + expectsUserMessage?: boolean; prompt: string; providerConfig?: Parameters[0]["providerConfig"]; } @@ -39,9 +41,12 @@ const cases: ProviderSubagentCase[] = [ { provider: "opencode", sentinel: "OPENCODE_CHILD_SENTINEL", - expectedName: "Explore", + expectedName: "Verify OpenCode descriptor", + expectedSubtitle: /explore · gpt-5\.4(?: · [^\n·]+)? · \d+(?:\.\d+)?k? tokens/i, + expectsUserMessage: false, + providerConfig: { model: "openai/gpt-5.4" }, prompt: - "Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", + 'Use the task tool exactly once with description "Verify OpenCode descriptor" and the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.', }, ]; @@ -51,7 +56,7 @@ test.describe("real provider subagent timelines", () => { for (const scenario of cases) { test(`${scenario.provider} exposes native child output from the subagent track`, async ({ page, - }) => { + }, testInfo) => { const cwd = realpathSync( mkdtempSync(path.join(tmpdir(), `paseo-provider-subagent-${scenario.provider}-`)), ); @@ -71,13 +76,52 @@ test.describe("real provider subagent timelines", () => { const rows = page.locator('[data-testid^="subagents-track-row-"]'); await expect(rows).toHaveCount(1, { timeout: 60_000 }); await expect(rows.first()).toContainText(scenario.expectedName); + if (scenario.expectedSubtitle) { + await expect(rows.first()).toContainText(scenario.expectedSubtitle); + const desktopTrackScreenshot = testInfo.outputPath( + `${scenario.provider}-subagent-track-desktop.png`, + ); + await page.screenshot({ path: desktopTrackScreenshot }); + await testInfo.attach(`${scenario.provider} subagent track desktop`, { + path: desktopTrackScreenshot, + contentType: "image/png", + }); + } await rows.first().click(); const panel = page.getByTestId("provider-subagent-panel"); await expect(panel).toBeVisible({ timeout: 30_000 }); - await expect( - panel.getByTestId("user-message").filter({ hasText: scenario.sentinel }), - ).toBeVisible({ timeout: 30_000 }); + if (scenario.expectedSubtitle) { + await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText( + scenario.expectedSubtitle, + ); + const desktopScreenshot = testInfo.outputPath( + `${scenario.provider}-subagent-desktop.png`, + ); + await page.screenshot({ path: desktopScreenshot }); + await testInfo.attach(`${scenario.provider} subagent desktop`, { + path: desktopScreenshot, + contentType: "image/png", + }); + await page.setViewportSize({ width: 494, height: 862 }); + await expect(page.getByTestId("provider-subagent-pane-subtitle")).toHaveText( + scenario.expectedSubtitle, + ); + const compactScreenshot = testInfo.outputPath( + `${scenario.provider}-subagent-compact.png`, + ); + await page.screenshot({ path: compactScreenshot }); + await testInfo.attach(`${scenario.provider} subagent compact`, { + path: compactScreenshot, + contentType: "image/png", + }); + await page.setViewportSize({ width: 1280, height: 720 }); + } + if (scenario.expectsUserMessage !== false) { + await expect( + panel.getByTestId("user-message").filter({ hasText: scenario.sentinel }), + ).toBeVisible({ timeout: 30_000 }); + } await expect( panel.getByTestId("assistant-message").filter({ hasText: scenario.sentinel }), ).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index cf09cfe40..9aa6170a7 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -141,6 +141,7 @@ function ProviderSubagentPanel() { const firstTimelineSeq = timeline?.rows.size ? Math.min(...timeline.rows.keys()) : null; const progressKey = timeline?.epoch && firstTimelineSeq !== null ? `${timeline.epoch}:${firstTimelineSeq}` : null; + const subtitle = descriptor?.subtitle?.trim(); const streamContext = useMemo( () => ({ @@ -183,6 +184,17 @@ function ProviderSubagentPanel() { return ( + {subtitle ? ( + + + {subtitle} + + + ) : null} ({ - container: { flex: 1 }, + container: { flex: 1, minHeight: 0 }, + subtitleHeader: { + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[1], + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + subtitleText: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + }, unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, })); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index a1f2959ad..7c6ee6eef 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -3249,7 +3249,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_registry", - title: "Live child", + description: "Live child", status: "running", }, }); @@ -4046,7 +4046,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_background", - title: "Plugin child", + description: "Plugin child", status: "running", }, }); @@ -4094,13 +4094,539 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_plugin", - title: "Background plugin child", + description: "Background plugin child", status: "running", }, }, ]); }); + test("folds child assistant facts into deduped presentation upserts without status", async () => { + const releaseEvents = createTestDeferred(); + const eventsConsumed = createTestDeferred(); + const fakeClient = { + global: { + event: vi.fn().mockResolvedValue({ + stream: (async function* () { + yield { type: "server.connected", properties: {} }; + await releaseEvents.promise; + yield { + type: "session.created", + properties: { + info: { id: "ses_child_facts", parentID: "ses_parent", title: "Fact child" }, + }, + }; + // Streaming frame: model facts, no completion yet. + yield { + type: "message.updated", + properties: { + info: { + id: "msg_child_facts", + sessionID: "ses_child_facts", + role: "assistant", + agent: "general", + providerID: "anthropic", + modelID: "claude-sonnet-5", + variant: "high", + time: { created: 1 }, + }, + }, + }; + // Same facts again: must not re-emit an identical subtitle upsert. + yield { + type: "message.updated", + properties: { + info: { + id: "msg_child_facts", + sessionID: "ses_child_facts", + role: "assistant", + agent: "general", + providerID: "anthropic", + modelID: "claude-sonnet-5", + variant: "high", + time: { created: 1 }, + }, + }, + }; + // Completion carries the token sums. + yield { + type: "message.updated", + properties: { + info: { + id: "msg_child_facts", + sessionID: "ses_child_facts", + role: "assistant", + agent: "general", + providerID: "anthropic", + modelID: "claude-sonnet-5", + variant: "high", + time: { created: 1, completed: 2 }, + tokens: { + input: 10_000, + output: 5_000, + reasoning: 1_000, + cache: { read: 400, write: 100 }, + }, + }, + }, + }; + yield { type: "session.idle", properties: { sessionID: "ses_child_facts" } }; + eventsConsumed.resolve(); + })(), + }), + }, + session: { + abort: vi.fn().mockResolvedValue({ error: null }), + update: vi.fn().mockResolvedValue({ error: null }), + }, + } as never; + const session = new __openCodeInternals.OpenCodeAgentSession( + { provider: "opencode", cwd: "/tmp/test" }, + fakeClient, + "ses_parent", + createTestLogger(), + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + releaseEvents.resolve(); + await eventsConsumed.promise; + await session.close(); + + const subtitleUpserts = events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.subtitle !== undefined + ? [event.event] + : [], + ); + expect(subtitleUpserts).toEqual([ + { + type: "upsert", + id: "ses_child_facts", + title: "general", + subtitle: "general · claude-sonnet-5 · High", + }, + { + type: "upsert", + id: "ses_child_facts", + subtitle: "general · claude-sonnet-5 · High · 16.5k tokens", + }, + ]); + // Presentation upserts must not carry status: they can never revert a finished child. + for (const upsert of subtitleUpserts) { + expect(upsert).not.toHaveProperty("status"); + } + expect(subtitleUpserts.filter((upsert) => upsert.title !== undefined)).toHaveLength(1); + expect(events.at(-1)).toEqual({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: "ses_child_facts", status: "completed" }, + }); + }); + + test("does not overwrite a link-set title with the assistant-frame agent", async () => { + const { parent, openCode } = await createParentSession("ses_parent_link_title"); + const events: AgentStreamEvent[] = []; + parent.subscribe((event) => events.push(event)); + + openCode.emitEvent({ + type: "message.part.updated", + properties: { + part: { + id: "prt_link_title_task", + sessionID: "ses_parent_link_title", + messageID: "msg_parent_link_title", + type: "tool", + tool: "task", + callID: "call_link_title", + state: { + status: "running", + input: { subagent_type: "explore", description: "Inspect title precedence" }, + }, + }, + }, + }); + openCode.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_link_title", + parentID: "ses_parent_link_title", + title: "Inspect title precedence", + }, + }, + }); + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: expect.objectContaining({ + type: "upsert", + id: "ses_child_link_title", + title: "explore", + toolCallId: "call_link_title", + }), + }), + ); + + openCode.emitEvent({ + type: "message.updated", + properties: { + info: { + id: "msg_child_link_title", + sessionID: "ses_child_link_title", + role: "assistant", + agent: "general", + providerID: "anthropic", + modelID: "claude-sonnet-5", + time: { created: 1 }, + }, + }, + }); + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_link_title", + subtitle: "general · claude-sonnet-5", + }, + }), + ); + + const childTitles = events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "upsert" && + event.event.id === "ses_child_link_title" && + event.event.title !== undefined + ? [event.event.title] + : [], + ); + expect(childTitles).toEqual(["explore"]); + await parent.close(); + }); + + test("maps child detection facts onto title, description, and subtitle", () => { + const state = createOpenCodeTranslationState("ses_parent"); + + const events = translateOpenCodeEvent( + { + type: "session.updated", + properties: { + info: { + id: "ses_child_rich", + parentID: "ses_parent", + title: "Investigate flaky test", + agent: "explore", + model: { providerID: "anthropic", id: "claude-sonnet-5", variant: "high" }, + }, + }, + } as OpenCodeEvent, + state, + ); + + expect(events).toEqual([ + { + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_rich", + title: "explore", + description: "Investigate flaky test", + status: "running", + subtitle: "explore · claude-sonnet-5 · High", + }, + }, + ]); + }); + + test("leaves title and description unset when the child session carries no facts", () => { + const state = createOpenCodeTranslationState("ses_parent"); + + const events = translateOpenCodeEvent( + { + type: "session.created", + properties: { + info: { id: "ses_child_bare", parentID: "ses_parent" }, + }, + } as OpenCodeEvent, + state, + ); + + expect(events).toEqual([ + { + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: "ses_child_bare", status: "running" }, + }, + ]); + }); + + test("links a waiting task tool call to a child detected afterwards", () => { + const state = createOpenCodeTranslationState("ses_parent"); + const events: AgentStreamEvent[] = []; + + events.push( + ...translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "prt_parent_task", + sessionID: "ses_parent", + messageID: "msg_parent", + type: "tool", + tool: "task", + callID: "call_task", + state: { + status: "running", + input: { subagent_type: "explore", description: "Inspect repo" }, + }, + }, + }, + } as OpenCodeEvent, + state, + ), + ...translateOpenCodeEvent( + { + type: "session.created", + properties: { + info: { id: "ses_child_linked", parentID: "ses_parent", title: "Inspect repo" }, + }, + } as OpenCodeEvent, + state, + ), + ); + + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_linked", + toolCallId: "call_task", + title: "explore", + description: "Inspect repo", + subtitle: "explore", + }, + }); + }); + + test("links a task tool call whose metadata resolves the child session id after detection", () => { + const state = createOpenCodeTranslationState("ses_parent"); + const events: AgentStreamEvent[] = []; + + events.push( + ...translateOpenCodeEvent( + { + type: "session.created", + properties: { + info: { id: "ses_childlatelink", parentID: "ses_parent", title: "Late link" }, + }, + } as OpenCodeEvent, + state, + ), + // No task call was waiting at detection time; the link arrives via the tool output. + ...translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "prt_parent_task_late", + sessionID: "ses_parent", + messageID: "msg_parent", + type: "tool", + tool: "task", + callID: "call_task_late", + state: { + status: "completed", + input: { subagent_type: "plan", description: "Draft the plan" }, + output: 'Done.', + metadata: { sessionId: "ses_childlatelink" }, + }, + }, + }, + } as OpenCodeEvent, + state, + ), + ); + + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_childlatelink", + toolCallId: "call_task_late", + title: "plan", + description: "Draft the plan", + subtitle: "plan", + }, + }); + }); + + test("links parallel task calls from their canonical child session metadata", () => { + const state = createOpenCodeTranslationState("ses_parent"); + const events: AgentStreamEvent[] = []; + + for (const task of [ + { callId: "call_alpha", description: "Inspect alpha" }, + { callId: "call_beta", description: "Inspect beta" }, + ]) { + events.push( + ...translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: `prt_${task.callId}`, + sessionID: "ses_parent", + messageID: "msg_parent", + type: "tool", + tool: "task", + callID: task.callId, + state: { + status: "running", + input: { subagent_type: "explore", description: task.description }, + }, + }, + }, + } as OpenCodeEvent, + state, + ), + ); + } + + for (const childSessionId of ["ses_child_alpha", "ses_child_beta"]) { + events.push( + ...translateOpenCodeEvent( + { + type: "session.created", + properties: { + info: { id: childSessionId, parentID: "ses_parent", title: "Child session" }, + }, + } as OpenCodeEvent, + state, + ), + ); + } + + for (const task of [ + { callId: "call_alpha", childSessionId: "ses_child_alpha", description: "Inspect alpha" }, + { callId: "call_beta", childSessionId: "ses_child_beta", description: "Inspect beta" }, + ]) { + events.push( + ...translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: `prt_${task.callId}`, + sessionID: "ses_parent", + messageID: "msg_parent", + type: "tool", + tool: "task", + callID: task.callId, + state: { + status: "completed", + input: { subagent_type: "explore", description: task.description }, + output: `Done.`, + metadata: { sessionId: task.childSessionId }, + }, + }, + }, + } as OpenCodeEvent, + state, + ), + ); + } + + expect(events).toEqual( + expect.arrayContaining([ + { + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_alpha", + toolCallId: "call_alpha", + title: "explore", + description: "Inspect alpha", + subtitle: "explore", + }, + }, + { + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_beta", + toolCallId: "call_beta", + title: "explore", + description: "Inspect beta", + subtitle: "explore", + }, + }, + ]), + ); + }); + + test("linking after detection keeps the task description over the session title", () => { + const state = createOpenCodeTranslationState("ses_parent"); + + const detection = translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "prt_parent_task", + sessionID: "ses_parent", + messageID: "msg_parent", + type: "tool", + tool: "task", + callID: "call_task", + state: { + status: "running", + input: { subagent_type: "explore", description: "Audit configs" }, + }, + }, + }, + } as OpenCodeEvent, + state, + ); + const linked = translateOpenCodeEvent( + { + type: "session.created", + properties: { + info: { + id: "ses_child_dup", + parentID: "ses_parent", + title: "OpenCode session title", + }, + }, + } as OpenCodeEvent, + state, + ); + + const upserts = [...detection, ...linked].flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], + ); + // Detection precedes the link inside the same translation batch; the link's description + // (task input) lands last, so the sticky store keeps the task as the row label. + expect(upserts.at(-1)).toMatchObject({ + id: "ses_child_dup", + toolCallId: "call_task", + title: "explore", + description: "Audit configs", + }); + for (const upsert of upserts) { + expect(upsert.title).not.toBe("OpenCode session title"); + } + }); + test("translates provider deletion of a known child session", () => { const state = createOpenCodeTranslationState("ses_parent"); state.subAgentCallIdByChildSessionId?.set("ses_child_deleted", "call_task"); @@ -4159,12 +4685,12 @@ describe("OpenCode provider subagent contract", () => { { type: "provider_subagent", provider: "opencode", - event: { type: "upsert", id: "ses_child_a", title: "Child A", status: "completed" }, + event: { type: "upsert", id: "ses_child_a", description: "Child A", status: "completed" }, }, { type: "provider_subagent", provider: "opencode", - event: { type: "upsert", id: "ses_child_b", title: "Child B", status: "completed" }, + event: { type: "upsert", id: "ses_child_b", description: "Child B", status: "completed" }, }, { type: "provider_subagent", @@ -4172,7 +4698,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_grandchild_a", - title: "Grandchild A", + description: "Grandchild A", status: "completed", }, }, @@ -4234,7 +4760,7 @@ describe("OpenCode provider subagent contract", () => { event: { type: "upsert", id: "ses_child_with_history", - title: "Historical child", + description: "Historical child", status: "completed", cwd: "/workspace/child", }, @@ -4261,6 +4787,86 @@ describe("OpenCode provider subagent contract", () => { await session.close(); }); + test("derives hydrated child presentation facts from the session record and last assistant message", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_facts" } }; + openCodeClient.sessionChildrenResponses = [ + { + data: [ + { + id: "ses_child_hydrated_facts", + parentID: "ses_parent_facts", + title: "Chase the regression", + model: { providerID: "anthropic", id: "claude-sonnet-5", variant: "max" }, + }, + ], + }, + { data: [] }, + ]; + openCodeClient.sessionMessagesResponse = { + data: [ + { + info: { + id: "msg_child_hydrated", + sessionID: "ses_child_hydrated_facts", + role: "assistant", + agent: "explore", + providerID: "anthropic", + modelID: "claude-sonnet-5", + variant: "max", + time: { created: 2, completed: 3 }, + tokens: { input: 800, output: 150, reasoning: 30, cache: { read: 15, write: 5 } }, + }, + parts: [ + { + id: "prt_child_hydrated", + sessionID: "ses_child_hydrated_facts", + messageID: "msg_child_hydrated", + type: "text", + text: "Found it.", + time: { start: 2, end: 3 }, + }, + ], + }, + ], + }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(2)); + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_hydrated_facts", + description: "Chase the regression", + status: "completed", + subtitle: "claude-sonnet-5 · Max", + }, + }); + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_hydrated_facts", + title: "explore", + subtitle: "explore · claude-sonnet-5 · Max · 1k tokens", + }, + }), + ); + await session.close(); + }); + test("preserves child events that arrive while existing children hydrate", async () => { const hydration = createTestDeferred<{ data: Array<{ id: string; parentID: string; title: string }>; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 672c510dc..22ae7288b 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -83,6 +83,12 @@ import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { composeSystemPromptParts } from "../system-prompt.js"; import { normalizeProviderReplayTimestamp } from "../provider-history-timestamps.js"; import { revertOpenCodeConversationAndFiles } from "./opencode/rewind.js"; +import { + claimOpenCodeSubagentFallbackTitle, + foldOpenCodeSubagentPresentation, + type OpenCodeSubagentPresentationFacts, + type OpenCodeSubagentPresentationState, +} from "./opencode/subagent-presentation.js"; import type { ManagedProcessRegistry } from "../../managed-processes/managed-processes.js"; const OPENCODE_CAPABILITIES: AgentCapabilityFlags = { @@ -312,6 +318,7 @@ const OpencodeToolStateSchema = z input: z.unknown().optional(), output: z.unknown().optional(), error: z.unknown().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), }) .passthrough(); @@ -332,6 +339,7 @@ const OpencodeToolPartWithCallIdSchema = OpencodeToolPartBaseSchema.extend({ input: part.state?.input, output: part.state?.output, error: part.state?.error, + metadata: part.state?.metadata, })); const OpencodeToolPartWithIdSchema = OpencodeToolPartBaseSchema.extend({ @@ -344,6 +352,7 @@ const OpencodeToolPartWithIdSchema = OpencodeToolPartBaseSchema.extend({ input: part.state?.input, output: part.state?.output, error: part.state?.error, + metadata: part.state?.metadata, })); const OpencodeToolPartWithoutIdSchema = OpencodeToolPartBaseSchema.extend({ @@ -356,6 +365,7 @@ const OpencodeToolPartWithoutIdSchema = OpencodeToolPartBaseSchema.extend({ input: part.state?.input, output: part.state?.output, error: part.state?.error, + metadata: part.state?.metadata, })); const OpencodeToolPartSchema = z.union([ @@ -371,6 +381,7 @@ const OpencodeToolPartTimelineEnvelopeSchema = OpencodeToolPartSchema.transform( input: part.input, output: part.output, error: part.error, + metadata: part.metadata, })); const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSchema.transform( @@ -382,6 +393,7 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch input: part.input, output: part.output, error: part.error, + metadata: part.metadata, }), ); @@ -1724,6 +1736,7 @@ export interface OpenCodeEventTranslationState { subAgentsByCallId?: Map; subAgentCallIdByChildSessionId?: Map; knownChildSessionIds?: Set; + subagentPresentationByChildId?: Map; modelContextWindowsByModelKey?: ReadonlyMap; onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void; } @@ -1761,6 +1774,8 @@ interface OpenCodeChildSessionInfo { title?: string; directory?: string; revert?: OpenCodePersistedSession["revert"]; + agent?: string; + model?: { id: string; variant?: string }; } interface OpenCodeSubAgentActivityState { @@ -2102,6 +2117,62 @@ function getOpenCodeKnownChildSessionIds(state: OpenCodeEventTranslationState): return state.knownChildSessionIds; } +function getOpenCodeSubagentPresentationState( + childSessionId: string, + state: OpenCodeEventTranslationState, +): OpenCodeSubagentPresentationState { + state.subagentPresentationByChildId ??= new Map(); + const existing = state.subagentPresentationByChildId.get(childSessionId); + if (existing) { + return existing; + } + const created: OpenCodeSubagentPresentationState = { facts: {} }; + state.subagentPresentationByChildId.set(childSessionId, created); + return created; +} + +function sumOpenCodeAssistantMessageTokens( + tokens: OpenCodeAssistantMessage["tokens"] | undefined, +): number { + if (!tokens) { + return 0; + } + return ( + (readPositiveFiniteNumber(tokens.input) ?? 0) + + (readPositiveFiniteNumber(tokens.output) ?? 0) + + (readPositiveFiniteNumber(tokens.reasoning) ?? 0) + + (readPositiveFiniteNumber(tokens.cache?.read) ?? 0) + + (readPositiveFiniteNumber(tokens.cache?.write) ?? 0) + ); +} + +/** Presentation facts observable on a child assistant message. Token sums only count once the + * message completes, so partial frames don't publish a shrinking total. */ +function readOpenCodeAssistantPresentationFacts( + info: OpenCodeAssistantMessage, +): OpenCodeSubagentPresentationFacts | null { + const facts: OpenCodeSubagentPresentationFacts = {}; + const agentName = readNonEmptyString(info.agent); + if (agentName) { + facts.agentName = agentName; + } + const modelId = readNonEmptyString(info.modelID); + if (modelId) { + facts.modelId = modelId; + } + const variant = readNonEmptyString(info.variant); + if (variant) { + facts.variant = variant; + } + if (info.time?.completed !== undefined) { + const totalTokens = sumOpenCodeAssistantMessageTokens(info.tokens); + if (totalTokens > 0) { + facts.totalTokens = totalTokens; + } + } + return Object.keys(facts).length > 0 ? facts : null; +} + function isOpenCodeSessionTrackedByParent( sessionId: string, state: OpenCodeEventTranslationState, @@ -2127,20 +2198,34 @@ function appendOpenCodeChildSessionDetected( } const knownChildSessionIds = getOpenCodeKnownChildSessionIds(state); + // Known limitation: detection runs once per child, so a session record that gains `agent` + // only in a later session.updated is not refreshed here. Assistant-frame facts recover the + // descriptor title and subtitle via appendChildAssistantPresentationUpsert. if (knownChildSessionIds.has(child.id)) { return false; } knownChildSessionIds.add(child.id); + const presentation = getOpenCodeSubagentPresentationState(child.id, state); + const subtitle = foldOpenCodeSubagentPresentation(presentation, { + ...(child.agent ? { agentName: child.agent } : {}), + ...(child.model?.id ? { modelId: child.model.id } : {}), + ...(child.model?.variant ? { variant: child.model.variant } : {}), + }); + const title = claimOpenCodeSubagentFallbackTitle(presentation, child.agent); + // The row label contract: `description` carries the task (session title fallback), `title` + // carries the subagent type. Neither gets a placeholder — absent facts render as nothing. events.push({ type: "provider_subagent", provider: "opencode", event: { type: "upsert", id: child.id, - title: child.title ?? "OpenCode subagent", + ...(title ? { title } : {}), + ...(child.title && !presentation.descriptionFromLink ? { description: child.title } : {}), status, ...(child.directory ? { cwd: child.directory } : {}), + ...(subtitle ? { subtitle } : {}), }, }); return true; @@ -2169,10 +2254,59 @@ function linkOpenCodeSubAgentChildSession( activity: OpenCodeSubAgentActivityState, childSessionId: string, state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], ): void { activity.childSessionId = childSessionId; const maps = getOpenCodeSubAgentMaps(state); maps.callIdByChildSessionId.set(childSessionId, activity.toolCall.callId); + appendOpenCodeSubAgentLinkPresentation(activity, childSessionId, state, events); +} + +/** + * When a child session ties to a parent `task` tool call, publish the task's identity onto the + * descriptor: `description` (task input), `title` (subagent type), `toolCallId`. Presentation + * only — no `status`, so it can never revert a finished child. + */ +function appendOpenCodeSubAgentLinkPresentation( + activity: OpenCodeSubAgentActivityState, + childSessionId: string, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + const detail = activity.toolCall.detail; + if (detail.type !== "sub_agent") { + return; + } + const presentation = getOpenCodeSubagentPresentationState(childSessionId, state); + if (presentation.linkedToolCallId === activity.toolCall.callId) { + return; + } + presentation.linkedToolCallId = activity.toolCall.callId; + const subAgentType = readNonEmptyString(detail.subAgentType); + const description = readNonEmptyString(detail.description); + if (subAgentType) { + presentation.titleFromLink = true; + presentation.titleEmitted = true; + } + if (description) { + presentation.descriptionFromLink = true; + } + const subtitle = foldOpenCodeSubagentPresentation( + presentation, + subAgentType ? { agentName: subAgentType } : {}, + ); + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: childSessionId, + toolCallId: activity.toolCall.callId, + ...(subAgentType ? { title: subAgentType } : {}), + ...(description ? { description } : {}), + ...(subtitle ? { subtitle } : {}), + }, + }); } function buildOpenCodeSubAgentTimelineItem( @@ -2195,13 +2329,14 @@ function buildOpenCodeSubAgentTimelineItem( function registerOpenCodeSubAgentToolCall( item: ToolCallTimelineItem, state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], ): ToolCallTimelineItem { if (item.detail.type !== "sub_agent") { return item; } const activity = getOpenCodeSubAgentState(item.callId, state, item); if (item.detail.childSessionId) { - linkOpenCodeSubAgentChildSession(activity, item.detail.childSessionId, state); + linkOpenCodeSubAgentChildSession(activity, item.detail.childSessionId, state, events); } return buildOpenCodeSubAgentTimelineItem(activity); } @@ -2224,7 +2359,7 @@ function appendOpenCodeToolCallTimelineItem( state: OpenCodeEventTranslationState, events: AgentStreamEvent[], ): void { - const timelineItem = registerOpenCodeSubAgentToolCall(item, state); + const timelineItem = registerOpenCodeSubAgentToolCall(item, state, events); events.push({ type: "timeline", provider: "opencode", @@ -2241,7 +2376,7 @@ function appendOpenCodeSubAgentChildSessionLinked( if (!activity) { return; } - linkOpenCodeSubAgentChildSession(activity, childSessionId, state); + linkOpenCodeSubAgentChildSession(activity, childSessionId, state, events); events.push({ type: "timeline", provider: "opencode", @@ -2271,20 +2406,14 @@ function appendOpenCodeSessionCreatedOrUpdated( const parentSessionId = readNonEmptyString(info?.parentID) ?? readNonEmptyString(info?.parentId); if (parentSessionId) { - appendOpenCodeChildSessionDetected( - { - id: event.properties.info.id, - parentSessionId, - ...(readNonEmptyString(info?.title) - ? { title: readNonEmptyString(info?.title) ?? undefined } - : {}), - ...(readNonEmptyString(info?.directory) - ? { directory: readNonEmptyString(info?.directory) ?? undefined } - : {}), - }, - state, - events, - ); + const child = readOpenCodeChildSessionInfo({ + ...info, + id: event.properties.info.id, + parentID: parentSessionId, + }); + if (child) { + appendOpenCodeChildSessionDetected(child, state, events); + } } if (parentSessionId === state.sessionId) { appendOpenCodeSubAgentChildSessionLinked(event.properties.info.id, state, events); @@ -2302,6 +2431,7 @@ function appendOpenCodeSessionDeleted( } state.knownChildSessionIds?.delete(sessionId); state.subAgentCallIdByChildSessionId?.delete(sessionId); + state.subagentPresentationByChildId?.delete(sessionId); events.push({ type: "provider_subagent", provider: "opencode", @@ -2857,12 +2987,29 @@ function readOpenCodeChildSessionInfo(value: unknown): OpenCodeChildSessionInfo const title = readNonEmptyString(record.title); const directory = readNonEmptyString(record.directory); const revert = readOpenCodeRecord(record.revert) as OpenCodePersistedSession["revert"] | null; + const agent = readNonEmptyString(record.agent); + const model = readOpenCodeChildSessionModel(record.model); return { id, parentSessionId, ...(title ? { title } : {}), ...(directory ? { directory } : {}), ...(revert ? { revert } : {}), + ...(agent ? { agent } : {}), + ...(model ? { model } : {}), + }; +} + +function readOpenCodeChildSessionModel(value: unknown): OpenCodeChildSessionInfo["model"] | null { + const record = readOpenCodeRecord(value); + const id = readNonEmptyString(record?.id); + if (!record || !id) { + return null; + } + const variant = readNonEmptyString(record.variant); + return { + id, + ...(variant ? { variant } : {}), }; } @@ -2956,6 +3103,10 @@ class OpenCodeAgentSession implements AgentSession { private subAgentsByCallId = new Map(); private subAgentCallIdByChildSessionId = new Map(); private knownChildSessionIds = new Set(); + private readonly subagentPresentationByChildId = new Map< + string, + OpenCodeSubagentPresentationState + >(); private readonly childTranslationStates = new Map(); private readonly childSessionCwds = new Map(); private readonly pendingPermissionDirectories = new Map(); @@ -3544,6 +3695,50 @@ class OpenCodeAgentSession implements AgentSession { translationState.hydratedPartFingerprints?.set(part.id, JSON.stringify(part)); } } + this.emitHydratedChildPresentation(child, messages); + } + + /** + * After replaying a historical child, derive presentation facts from the last assistant + * message (the session record's agent/model were already folded at detection) and publish + * any missing title plus the updated subtitle once. Presentation-only: no `status`. + */ + private emitHydratedChildPresentation( + child: OpenCodeChildSessionInfo, + messages: ReadonlyArray, + ): void { + const lastAssistant = messages.findLast( + (message): message is OpenCodeSessionMessage & { info: OpenCodeAssistantMessage } => + message.info.role === "assistant", + ); + if (!lastAssistant) { + return; + } + const facts = readOpenCodeAssistantPresentationFacts(lastAssistant.info); + if (!facts) { + return; + } + const presentation = getOpenCodeSubagentPresentationState( + child.id, + this.getChildTranslationState(child.id), + ); + const subtitle = foldOpenCodeSubagentPresentation(presentation, facts); + const title = claimOpenCodeSubagentFallbackTitle(presentation, facts.agentName); + if (!subtitle && !title) { + return; + } + const event: AgentStreamEvent = { + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: child.id, + ...(title ? { title } : {}), + ...(subtitle ? { subtitle } : {}), + }, + }; + this.recordProviderInternalEvent(event); + this.notifySubscribers(event, null); } private recordProviderInternalEvent(event: AgentStreamEvent): void { @@ -3562,6 +3757,7 @@ class OpenCodeAgentSession implements AgentSession { unregisterOpenCodeChildSessionServerUrl(event.event.id); this.childTranslationStates.delete(event.event.id); this.childSessionCwds.delete(event.event.id); + this.subagentPresentationByChildId.delete(event.event.id); } } @@ -4335,6 +4531,7 @@ class OpenCodeAgentSession implements AgentSession { subAgentsByCallId: this.subAgentsByCallId, subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId, knownChildSessionIds: this.knownChildSessionIds, + subagentPresentationByChildId: this.subagentPresentationByChildId, modelContextWindowsByModelKey: this.modelContextWindowsByModelKey, onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => { this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens; @@ -4367,6 +4564,7 @@ class OpenCodeAgentSession implements AgentSession { subAgentsByCallId: new Map(), subAgentCallIdByChildSessionId: new Map(), knownChildSessionIds: new Set(), + subagentPresentationByChildId: this.subagentPresentationByChildId, modelContextWindowsByModelKey: this.modelContextWindowsByModelKey, }; this.childTranslationStates.set(sessionId, state); @@ -4401,6 +4599,7 @@ class OpenCodeAgentSession implements AgentSession { if (event.type === "session.status" && event.properties.status.type === "busy") { markRunning(); } + this.appendChildAssistantPresentationUpsert(sessionId, event, events); for (const childEvent of translated) { if (childEvent.type === "timeline") { markRunning(); @@ -4444,6 +4643,48 @@ class OpenCodeAgentSession implements AgentSession { return events; } + /** + * Fold presentation facts (agent, model, variant, completed-message tokens) off a child + * assistant `message.updated` frame and emit the missing title and/or changed subtitle. + * Never carries `status`: a presentation upsert must not revert a finished child. + */ + private appendChildAssistantPresentationUpsert( + sessionId: string, + event: OpenCodeEvent, + events: AgentStreamEvent[], + ): void { + if (event.type !== "message.updated") { + return; + } + const info = event.properties.info; + if (info.sessionID !== sessionId || info.role !== "assistant") { + return; + } + const facts = readOpenCodeAssistantPresentationFacts(info); + if (!facts) { + return; + } + const presentation = getOpenCodeSubagentPresentationState( + sessionId, + this.getChildTranslationState(sessionId), + ); + const subtitle = foldOpenCodeSubagentPresentation(presentation, facts); + const title = claimOpenCodeSubagentFallbackTitle(presentation, facts.agentName); + if (!subtitle && !title) { + return; + } + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: sessionId, + ...(title ? { title } : {}), + ...(subtitle ? { subtitle } : {}), + }, + }); + } + private async translateEvent(event: OpenCodeEvent): Promise { const eventSessionId = getOpenCodeEventSessionId(event); if ( diff --git a/packages/server/src/server/agent/providers/opencode/subagent-presentation.test.ts b/packages/server/src/server/agent/providers/opencode/subagent-presentation.test.ts new file mode 100644 index 000000000..5c8f4d4ba --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/subagent-presentation.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; + +import { + buildOpenCodeSubagentSubtitle, + claimOpenCodeSubagentFallbackTitle, + foldOpenCodeSubagentPresentation, + type OpenCodeSubagentPresentationState, +} from "./subagent-presentation.js"; + +describe("claimOpenCodeSubagentFallbackTitle", () => { + it("claims the fallback title once", () => { + const state: OpenCodeSubagentPresentationState = { facts: {} }; + + expect(claimOpenCodeSubagentFallbackTitle(state, "explore")).toBe("explore"); + expect(claimOpenCodeSubagentFallbackTitle(state, "general")).toBeUndefined(); + }); + + it("respects a title set by the tool-call link", () => { + const state: OpenCodeSubagentPresentationState = { facts: {}, titleFromLink: true }; + + expect(claimOpenCodeSubagentFallbackTitle(state, "general")).toBeUndefined(); + }); + + it("rejects blank agent names", () => { + const state: OpenCodeSubagentPresentationState = { facts: {} }; + + expect(claimOpenCodeSubagentFallbackTitle(state, " ")).toBeUndefined(); + expect(state.titleEmitted).toBeUndefined(); + }); +}); + +describe("buildOpenCodeSubagentSubtitle", () => { + it("formats OpenCode facts into one compact provider-owned label", () => { + expect( + buildOpenCodeSubagentSubtitle({ + agentName: "general", + modelId: "claude-sonnet-5", + variant: "high", + totalTokens: 16_484, + }), + ).toBe("general · claude-sonnet-5 · High · 16.5k tokens"); + }); + + it("keeps raw model ids visible without a label table", () => { + expect(buildOpenCodeSubagentSubtitle({ modelId: "big-pickle" })).toBe("big-pickle"); + }); + + it("capitalizes multi-word variants and maps xhigh", () => { + expect(buildOpenCodeSubagentSubtitle({ variant: "ultra-think" })).toBe("Ultra Think"); + expect(buildOpenCodeSubagentSubtitle({ variant: "xhigh" })).toBe("Extra High"); + }); + + it("formats token counts below one thousand without abbreviation", () => { + expect(buildOpenCodeSubagentSubtitle({ totalTokens: 999 })).toBe("999 tokens"); + }); + + it("omits facts that were not observed", () => { + expect(buildOpenCodeSubagentSubtitle({ agentName: "explore", totalTokens: 0 })).toBe("explore"); + expect(buildOpenCodeSubagentSubtitle({})).toBeUndefined(); + expect(buildOpenCodeSubagentSubtitle({ agentName: " " })).toBeUndefined(); + }); +}); + +describe("foldOpenCodeSubagentPresentation", () => { + it("returns the subtitle only when it changes", () => { + const state: OpenCodeSubagentPresentationState = { facts: {} }; + expect(foldOpenCodeSubagentPresentation(state, { agentName: "general" })).toBe("general"); + expect(foldOpenCodeSubagentPresentation(state, { agentName: "general" })).toBeUndefined(); + expect( + foldOpenCodeSubagentPresentation(state, { modelId: "claude-sonnet-5", totalTokens: 1_200 }), + ).toBe("general · claude-sonnet-5 · 1.2k tokens"); + expect(foldOpenCodeSubagentPresentation(state, { totalTokens: 1_200 })).toBeUndefined(); + }); + + it("returns undefined while no facts are known", () => { + const state: OpenCodeSubagentPresentationState = { facts: {} }; + expect(foldOpenCodeSubagentPresentation(state, {})).toBeUndefined(); + expect(state.lastSubtitle).toBeUndefined(); + }); +}); diff --git a/packages/server/src/server/agent/providers/opencode/subagent-presentation.ts b/packages/server/src/server/agent/providers/opencode/subagent-presentation.ts new file mode 100644 index 000000000..12468e50d --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/subagent-presentation.ts @@ -0,0 +1,92 @@ +export interface OpenCodeSubagentPresentationFacts { + /** OpenCode agent name running the child, e.g. "general", "explore". */ + agentName?: string; + /** Raw model id, e.g. "claude-sonnet-5". OpenCode has no label table at translation time. */ + modelId?: string; + /** Thinking level, e.g. "high", "max". */ + variant?: string; + /** Latest assistant message token sum (input+output+reasoning+cache). */ + totalTokens?: number; +} + +/** + * Per-child presentation tracking: raw facts plus the last subtitle emitted, so + * repeated folds of the same facts never re-emit an identical upsert. + */ +export interface OpenCodeSubagentPresentationState { + facts: OpenCodeSubagentPresentationFacts; + lastSubtitle?: string; + /** A descriptor title has been emitted from detection, linking, or assistant facts. */ + titleEmitted?: boolean; + /** Set once the parent `task` tool call has been linked to this child. */ + linkedToolCallId?: string; + /** The link upsert carried a title (subAgentType); detection must not overwrite it. */ + titleFromLink?: boolean; + /** The link upsert carried a description (task input); detection must not overwrite it. */ + descriptionFromLink?: boolean; +} + +/** Claim the assistant/session agent as the descriptor title when no stronger title exists. */ +export function claimOpenCodeSubagentFallbackTitle( + state: OpenCodeSubagentPresentationState, + agentName: string | undefined, +): string | undefined { + const title = readPart(agentName); + if (!title || state.titleFromLink || state.titleEmitted) return undefined; + // Title means subagent type, so claim it once rather than refreshing on a mid-session handoff. + state.titleEmitted = true; + return title; +} + +/** Build the complete compact subtitle OpenCode exposes to provider-neutral clients. */ +export function buildOpenCodeSubagentSubtitle( + facts: OpenCodeSubagentPresentationFacts, +): string | undefined { + const parts = [ + readPart(facts.agentName), + readPart(facts.modelId), + formatVariant(facts.variant), + formatTokens(facts.totalTokens), + ].filter((part): part is string => part !== undefined); + return parts.length > 0 ? parts.join(" · ") : undefined; +} + +/** + * Merge observed facts into the tracked state and return the folded subtitle only + * when it differs from the last one emitted; otherwise return undefined. + */ +export function foldOpenCodeSubagentPresentation( + state: OpenCodeSubagentPresentationState, + patch: OpenCodeSubagentPresentationFacts, +): string | undefined { + if (patch.agentName !== undefined) state.facts.agentName = patch.agentName; + if (patch.modelId !== undefined) state.facts.modelId = patch.modelId; + if (patch.variant !== undefined) state.facts.variant = patch.variant; + if (patch.totalTokens !== undefined) state.facts.totalTokens = patch.totalTokens; + const subtitle = buildOpenCodeSubagentSubtitle(state.facts); + if (!subtitle || subtitle === state.lastSubtitle) return undefined; + state.lastSubtitle = subtitle; + return subtitle; +} + +function readPart(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +function formatVariant(variant: string | undefined): string | undefined { + const normalized = readPart(variant); + if (!normalized) return undefined; + if (normalized === "xhigh") return "Extra High"; + return normalized + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function formatTokens(totalTokens: number | undefined): string | undefined { + if (typeof totalTokens !== "number" || totalTokens <= 0) return undefined; + if (totalTokens < 1000) return `${Math.round(totalTokens)} tokens`; + return `${Math.round(totalTokens / 100) / 10}k tokens`; +} diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts index 992a0a4e6..afe89b85b 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-detail-parser.ts @@ -196,6 +196,7 @@ function deriveOpencodeTaskDetail( input: unknown, output: unknown, error: unknown, + metadata: Record | undefined, ): ToolCallDetail | null { if (!isRecord(input)) { return null; @@ -208,7 +209,8 @@ function deriveOpencodeTaskDetail( } const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n"); - const childSessionId = extractOpenCodeTaskSessionId(output); + const childSessionId = + normalizeSubAgentText(metadata?.sessionId) ?? extractOpenCodeTaskSessionId(output); return { type: "sub_agent", ...(subAgentType ? { subAgentType } : {}), @@ -359,9 +361,10 @@ export function deriveOpencodeToolDetail( input: unknown, output: unknown, error: unknown = null, + metadata?: Record, ): ToolCallDetail { if (toolName.trim().toLowerCase() === "task") { - const taskDetail = deriveOpencodeTaskDetail(input, output, error); + const taskDetail = deriveOpencodeTaskDetail(input, output, error, metadata); if (taskDetail) { return taskDetail; } diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts index fd6725f3f..54f3faa51 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.test.ts @@ -457,7 +457,7 @@ describe("opencode tool-call mapper", () => { }); }); - it("extracts child session ids from completed task output", () => { + it("extracts child session ids from current task metadata", () => { const item = expectMapped( mapOpencodeToolCall({ toolName: "task", @@ -467,7 +467,8 @@ describe("opencode tool-call mapper", () => { subagent_type: "explore", description: "Explore current directory", }, - output: "task_id: ses_2268db431ffe299vL1bbot8R7Z\n\ndone", + output: 'done', + metadata: { sessionId: "ses_2268db431ffe299vL1bbot8R7Z" }, }), ); @@ -476,7 +477,7 @@ describe("opencode tool-call mapper", () => { subAgentType: "explore", description: "Explore current directory", childSessionId: "ses_2268db431ffe299vL1bbot8R7Z", - log: "task_id: ses_2268db431ffe299vL1bbot8R7Z\n\ndone", + log: 'done', actions: [], }); }); diff --git a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts index 6d6051f87..8fbc861a8 100644 --- a/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/opencode/tool-call-mapper.ts @@ -43,7 +43,7 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim const error = raw.error ?? null; const rawStatus = typeof raw.status === "string" ? raw.status : undefined; const status = normalizeToolCallStatus(rawStatus, error, output); - const detail = deriveOpencodeToolDetail(name, input, output, error); + const detail = deriveOpencodeToolDetail(name, input, output, error, raw.metadata); if (status === "failed") { return {