From fdc510d3a06e0c3ef761320023d40d365bba4f5c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 2 May 2026 13:19:42 +0800 Subject: [PATCH] feat(opencode): add subagent timeline support (#658) Map OpenCode `task` tool events into the existing sub_agent timeline shape, enabling live activity tracking for OpenCode subagent sessions. - Add `childSessionId` (optional) to sub_agent schema - Make `actions` optional with default [] for backward compatibility - Parse task tool input/output into sub_agent detail (tool-call-detail-parser) - Track child session linkage and buffer/flush child tool parts - Render sub_agent actions and session IDs in the app component - Add glob tool detail mapping - Include backward-compat schema test Co-authored-by: Ryan Swift --- .../app/src/components/tool-call-details.tsx | 137 +++++++- .../src/server/agent/agent-sdk-types.ts | 1 + .../server/agent/providers/opencode-agent.ts | 325 +++++++++++++++++- .../opencode/tool-call-detail-parser.ts | 112 ++++++ .../opencode/tool-call-mapper.test.ts | 115 +++++++ .../providers/opencode/tool-call-mapper.ts | 2 +- .../shared/messages.tool-call-schema.test.ts | 24 ++ packages/server/src/shared/messages.ts | 18 +- 8 files changed, 718 insertions(+), 16 deletions(-) diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 7af089cd4..d763de01c 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -244,15 +244,95 @@ function resolveSubAgentFallbackHeader( interface SubAgentDetailProps { log: string; + actions: Extract["actions"]; + childSessionId: string | null | undefined; subAgentType: string | null | undefined; description: string | null | undefined; ds: DetailStyles; } -function SubAgentDetailSection({ log, subAgentType, description, ds }: SubAgentDetailProps) { - const activityLog = log.replace(/^\n+/, ""); - const hasLog = activityLog.length > 0; +function buildSubAgentActionLog(actions: SubAgentDetailProps["actions"]): string { + return actions + .map((action) => + action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`, + ) + .join("\n"); +} + +function stripSubAgentActionLog(log: string, actions: SubAgentDetailProps["actions"]): string { + const actionLog = buildSubAgentActionLog(actions); + const trimmedLog = log.replace(/^\n+/, ""); + if (!actionLog || !trimmedLog.startsWith(actionLog)) { + return trimmedLog; + } + return trimmedLog.slice(actionLog.length).replace(/^\n+/, ""); +} + +function SubAgentActionRow({ action }: { action: SubAgentDetailProps["actions"][number] }) { + return ( + + + {formatSubAgentToolName(action.toolName)} + + {action.summary ? ( + + {action.summary} + + ) : null} + + ); +} + +function formatSubAgentToolName(toolName: string): string { + const trimmed = toolName.trim(); + if (!trimmed) { + return toolName; + } + return trimmed + .replace(/[._-]+/g, " ") + .split(" ") + .filter((segment) => segment.length > 0) + .map((segment) => `${segment[0]?.toUpperCase() ?? ""}${segment.slice(1)}`) + .join(" "); +} + +function SubAgentLogText({ + activityLog, + fallbackHeader, + hasActions, +}: { + activityLog: string; + fallbackHeader: string; + hasActions: boolean; +}) { + if (activityLog.length > 0) { + return ( + + {activityLog} + + ); + } + if (!hasActions) { + return ( + + {fallbackHeader} + + ); + } + return null; +} + +function SubAgentDetailSection({ + log, + actions, + childSessionId, + subAgentType, + description, + ds, +}: SubAgentDetailProps) { + const activityLog = stripSubAgentActionLog(log, actions); const fallbackHeader = resolveSubAgentFallbackHeader(subAgentType, description); + const hasActions = actions.length > 0; return ( @@ -270,9 +350,23 @@ function SubAgentDetailSection({ log, subAgentType, description, ds }: SubAgentD contentContainerStyle={styles.codeHorizontalContent} > - - {hasLog ? activityLog : fallbackHeader} - + {childSessionId ? ( + + session {childSessionId} + + ) : null} + {hasActions ? ( + + {actions.map((action) => ( + + ))} + + ) : null} + @@ -530,6 +624,8 @@ function buildDetailSections( { shellPrompt: { color: theme.colors.foregroundMuted, }, + subAgentSessionText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + lineHeight: 18, + marginBottom: theme.spacing[2], + }, + subAgentActions: { + gap: theme.spacing[1], + marginBottom: theme.spacing[2], + }, + subAgentActionRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + subAgentActionTool: { + minWidth: 56, + fontFamily: Fonts.mono, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + lineHeight: 18, + }, + subAgentActionSummary: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + lineHeight: 18, + }, jsonScroll: { borderWidth: theme.borderWidth[1], borderColor: theme.colors.border, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index fffe65a24..90844327e 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -235,6 +235,7 @@ export type ToolCallDetail = type: "sub_agent"; subAgentType?: string; description?: string; + childSessionId?: string; log: string; actions: Array<{ index: number; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 1ff7ecf79..d6050ba89 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -48,6 +48,7 @@ import { import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; import { withTimeout } from "../../../utils/promise-timeout.js"; import { spawnProcess } from "../../../utils/spawn.js"; +import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { formatDiagnosticStatus, @@ -1312,16 +1313,43 @@ export class OpenCodeAgentClient implements AgentClient { export interface OpenCodeEventTranslationState { sessionId: string; + cwd?: string; messageRoles: Map; accumulatedUsage: AgentUsage; streamedPartKeys: Set; emittedStructuredMessageIds: Set; /** Tracks the type of each part by ID, learned from message.part.updated events. */ partTypes: Map; + subAgentsByCallId?: Map; + subAgentCallIdByChildSessionId?: Map; + pendingChildToolPartsBySessionId?: Map; modelContextWindowsByModelKey?: ReadonlyMap; onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void; } +type OpenCodeToolPartEventPart = Extract< + Extract["properties"]["part"], + { type: "tool" } +>; + +interface OpenCodeSubAgentActionEntry { + index: number; + key: string; + toolName: string; + summary?: string; +} + +interface OpenCodeSubAgentActivityState { + toolCall: ToolCallTimelineItem; + actions: OpenCodeSubAgentActionEntry[]; + actionIndexByKey: Map; + nextActionIndex: number; + childSessionId?: string; +} + +const MAX_OPENCODE_SUB_AGENT_ACTIONS = 200; +const MAX_OPENCODE_PENDING_CHILD_TOOL_PARTS = 200; + function stringifyStructuredAssistantMessage(value: unknown): string | null { if (value === undefined) { return null; @@ -1564,6 +1592,269 @@ function resetOpenCodeTurnTrackingState(state: OpenCodeEventTranslationState): v state.partTypes.clear(); } +function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): { + byCallId: Map; + callIdByChildSessionId: Map; + pendingChildToolPartsBySessionId: Map; +} { + state.subAgentsByCallId ??= new Map(); + state.subAgentCallIdByChildSessionId ??= new Map(); + state.pendingChildToolPartsBySessionId ??= new Map(); + return { + byCallId: state.subAgentsByCallId, + callIdByChildSessionId: state.subAgentCallIdByChildSessionId, + pendingChildToolPartsBySessionId: state.pendingChildToolPartsBySessionId, + }; +} + +function getOpenCodeSubAgentState( + callId: string, + state: OpenCodeEventTranslationState, + toolCall: ToolCallTimelineItem, +): OpenCodeSubAgentActivityState { + const maps = getOpenCodeSubAgentMaps(state); + const existing = maps.byCallId.get(callId); + if (existing) { + existing.toolCall = toolCall; + return existing; + } + + const created: OpenCodeSubAgentActivityState = { + toolCall, + actions: [], + actionIndexByKey: new Map(), + nextActionIndex: 1, + }; + maps.byCallId.set(callId, created); + return created; +} + +function linkOpenCodeSubAgentChildSession( + activity: OpenCodeSubAgentActivityState, + childSessionId: string, + state: OpenCodeEventTranslationState, +): void { + activity.childSessionId = childSessionId; + const maps = getOpenCodeSubAgentMaps(state); + maps.callIdByChildSessionId.set(childSessionId, activity.toolCall.callId); +} + +function buildOpenCodeSubAgentLog( + detail: Extract, + activity: OpenCodeSubAgentActivityState, +): string { + const actionLog = activity.actions + .map((action) => + action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`, + ) + .join("\n"); + const parts = [actionLog, detail.log].filter((part) => part.trim().length > 0); + return parts.join("\n\n"); +} + +function buildOpenCodeSubAgentTimelineItem( + activity: OpenCodeSubAgentActivityState, +): ToolCallTimelineItem { + const toolCall = activity.toolCall; + if (toolCall.detail.type !== "sub_agent") { + return toolCall; + } + const childSessionId = activity.childSessionId ?? toolCall.detail.childSessionId; + return { + ...toolCall, + detail: { + ...toolCall.detail, + ...(childSessionId ? { childSessionId } : {}), + log: buildOpenCodeSubAgentLog(toolCall.detail, activity), + actions: activity.actions.map((action) => ({ + index: action.index, + toolName: action.toolName, + ...(action.summary ? { summary: action.summary } : {}), + })), + }, + }; +} + +function registerOpenCodeSubAgentToolCall( + item: ToolCallTimelineItem, + state: OpenCodeEventTranslationState, +): 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); + } + return buildOpenCodeSubAgentTimelineItem(activity); +} + +function bufferOpenCodeSubAgentChildToolPart( + part: OpenCodeToolPartEventPart, + state: OpenCodeEventTranslationState, +): void { + const maps = getOpenCodeSubAgentMaps(state); + if (maps.byCallId.size === 0) { + return; + } + const totalPending = [...maps.pendingChildToolPartsBySessionId.values()].reduce( + (total, parts) => total + parts.length, + 0, + ); + if (totalPending >= MAX_OPENCODE_PENDING_CHILD_TOOL_PARTS) { + return; + } + const pending = maps.pendingChildToolPartsBySessionId.get(part.sessionID) ?? []; + pending.push(part); + maps.pendingChildToolPartsBySessionId.set(part.sessionID, pending); +} + +function flushOpenCodeSubAgentChildToolParts( + childSessionId: string, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + const maps = getOpenCodeSubAgentMaps(state); + const pending = maps.pendingChildToolPartsBySessionId.get(childSessionId); + if (!pending || pending.length === 0) { + return; + } + maps.pendingChildToolPartsBySessionId.delete(childSessionId); + for (const part of pending) { + appendOpenCodeSubAgentChildToolPart(part, state, events); + } +} + +function findOnlyOpenCodeSubAgentWaitingForChild( + state: OpenCodeEventTranslationState, +): OpenCodeSubAgentActivityState | null { + const maps = getOpenCodeSubAgentMaps(state); + const candidates = [...maps.byCallId.values()].filter( + (activity) => + activity.toolCall.status === "running" && + activity.toolCall.detail.type === "sub_agent" && + !activity.childSessionId, + ); + return candidates.length === 1 ? (candidates[0] ?? null) : null; +} + +function summarizeOpenCodeSubAgentAction( + item: ToolCallTimelineItem, + cwd: string | undefined, +): string | undefined { + const display = buildToolCallDisplayModel({ + name: item.name, + status: item.status, + error: item.error, + metadata: item.metadata, + detail: item.detail, + cwd, + }); + return display.summary ?? display.errorText; +} + +function appendOpenCodeSubAgentAction( + activity: OpenCodeSubAgentActivityState, + item: ToolCallTimelineItem, + cwd: string | undefined, +): boolean { + const key = item.callId || `${item.name}:${activity.actions.length}`; + const existingIndex = activity.actionIndexByKey.get(key); + const summary = summarizeOpenCodeSubAgentAction(item, cwd); + + if (existingIndex !== undefined) { + const action = activity.actions[existingIndex]; + if (!action) { + return false; + } + const changed = action.toolName !== item.name || action.summary !== summary; + action.toolName = item.name; + if (summary) { + action.summary = summary; + } else { + delete action.summary; + } + return changed; + } + + if (activity.actions.length >= MAX_OPENCODE_SUB_AGENT_ACTIONS) { + return false; + } + + activity.actionIndexByKey.set(key, activity.actions.length); + activity.actions.push({ + index: activity.nextActionIndex, + key, + toolName: item.name, + ...(summary ? { summary } : {}), + }); + activity.nextActionIndex += 1; + return true; +} + +function appendOpenCodeToolCallTimelineItem( + item: ToolCallTimelineItem, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + const timelineItem = registerOpenCodeSubAgentToolCall(item, state); + events.push({ + type: "timeline", + provider: "opencode", + item: timelineItem, + }); + if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) { + flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events); + } +} + +function appendOpenCodeSubAgentChildSessionLinked( + childSessionId: string, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + const activity = findOnlyOpenCodeSubAgentWaitingForChild(state); + if (!activity) { + return; + } + linkOpenCodeSubAgentChildSession(activity, childSessionId, state); + events.push({ + type: "timeline", + provider: "opencode", + item: buildOpenCodeSubAgentTimelineItem(activity), + }); + flushOpenCodeSubAgentChildToolParts(childSessionId, state, events); +} + +function appendOpenCodeSubAgentChildToolPart( + part: OpenCodeToolPartEventPart, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + const maps = getOpenCodeSubAgentMaps(state); + const parentCallId = maps.callIdByChildSessionId.get(part.sessionID); + if (!parentCallId) { + bufferOpenCodeSubAgentChildToolPart(part, state); + return; + } + const activity = maps.byCallId.get(parentCallId); + if (!activity) { + return; + } + const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); + if (!parsedToolPart.success || !parsedToolPart.data) { + return; + } + if (!appendOpenCodeSubAgentAction(activity, parsedToolPart.data, state.cwd)) { + return; + } + events.push({ + type: "timeline", + provider: "opencode", + item: buildOpenCodeSubAgentTimelineItem(activity), + }); +} + function appendOpenCodeSessionCreatedOrUpdated( event: Extract, state: OpenCodeEventTranslationState, @@ -1575,6 +1866,13 @@ function appendOpenCodeSessionCreatedOrUpdated( sessionId: state.sessionId, provider: "opencode", }); + return; + } + + const info = readOpenCodeRecord(event.properties.info); + const parentSessionId = readNonEmptyString(info?.parentID) ?? readNonEmptyString(info?.parentId); + if (parentSessionId === state.sessionId) { + appendOpenCodeSubAgentChildSessionLinked(event.properties.info.id, state, events); } } @@ -1620,6 +1918,9 @@ function appendOpenCodeMessagePartUpdated( ): void { const part = event.properties.part; if (part.sessionID !== state.sessionId) { + if (part.type === "tool") { + appendOpenCodeSubAgentChildToolPart(part, state, events); + } return; } const messageRole = state.messageRoles.get(part.messageID); @@ -1636,7 +1937,7 @@ function appendOpenCodeMessagePartUpdated( if (part.type === "tool") { const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); if (parsedToolPart.success && parsedToolPart.data) { - events.push({ type: "timeline", provider: "opencode", item: parsedToolPart.data }); + appendOpenCodeToolCallTimelineItem(parsedToolPart.data, state, events); } return; } @@ -1914,6 +2215,9 @@ class OpenCodeAgentSession implements AgentSession { private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; private readonly runningToolCalls = new Map(); + private subAgentsByCallId = new Map(); + private subAgentCallIdByChildSessionId = new Map(); + private pendingChildToolPartsBySessionId = new Map(); private selectedModelContextWindowMaxTokens: number | undefined; private releaseServer: (() => void) | null; constructor( @@ -2066,6 +2370,9 @@ class OpenCodeAgentSession implements AgentSession { } this.runningToolCalls.clear(); + this.subAgentsByCallId.clear(); + this.subAgentCallIdByChildSessionId.clear(); + this.pendingChildToolPartsBySessionId.clear(); const turnAbortController = new AbortController(); this.abortController = turnAbortController; await this.ensureMcpServersConfigured(); @@ -2352,6 +2659,7 @@ class OpenCodeAgentSession implements AgentSession { private synthesizeInterruptedToolCalls(turnId: string): void { for (const item of this.runningToolCalls.values()) { + const error = { message: "Tool execution aborted" }; this.notifySubscribers( { type: "timeline", @@ -2359,7 +2667,16 @@ class OpenCodeAgentSession implements AgentSession { item: { ...item, status: "failed", - error: { message: "Tool execution aborted" }, + error, + detail: + item.detail.type === "sub_agent" + ? { + ...item.detail, + log: [item.detail.log, error.message] + .filter((entry) => entry.trim().length > 0) + .join("\n"), + } + : item.detail, }, }, turnId, @@ -2710,11 +3027,15 @@ class OpenCodeAgentSession implements AgentSession { private async translateEvent(event: OpenCodeEvent): Promise { const translated = translateOpenCodeEvent(event, { sessionId: this.sessionId, + cwd: this.config.cwd, messageRoles: this.messageRoles, accumulatedUsage: this.accumulatedUsage, streamedPartKeys: this.streamedPartKeys, emittedStructuredMessageIds: this.emittedStructuredMessageIds, partTypes: this.partTypes, + subAgentsByCallId: this.subAgentsByCallId, + subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId, + pendingChildToolPartsBySessionId: this.pendingChildToolPartsBySessionId, modelContextWindowsByModelKey: this.modelContextWindowsByModelKey, onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => { this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens; 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 804fbbab2..9c5a5bbcc 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 @@ -1,9 +1,11 @@ import { z } from "zod"; import type { ToolCallDetail } from "../../agent-sdk-types.js"; +import { nonEmptyString } from "../tool-call-mapper-utils.js"; import { ToolEditInputSchema, ToolEditOutputSchema, + ToolGlobOutputSchema, ToolReadInputSchema, ToolReadOutputSchema, ToolSearchInputSchema, @@ -19,6 +21,100 @@ import { toolDetailBranchByToolName, } from "../tool-call-detail-primitives.js"; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function normalizeSubAgentText(value: unknown): string | undefined { + return nonEmptyString(value)?.trim().replace(/\s+/g, " "); +} + +function readOutputText(value: unknown): string | undefined { + if (typeof value === "string") { + return nonEmptyString(value.trim()); + } + if (!isRecord(value)) { + return undefined; + } + + const directText = + readOutputText(value.output) ?? + readOutputText(value.text) ?? + readOutputText(value.content) ?? + readOutputText(value.result); + if (directText) { + return directText; + } + + return undefined; +} + +function formatLogEntry(value: unknown): string | undefined { + const outputText = readOutputText(value); + if (outputText) { + return outputText; + } + if (value === null || value === undefined) { + return undefined; + } + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function extractOpenCodeTaskSessionId(value: unknown): string | undefined { + const text = readOutputText(value); + if (text) { + const match = text.match(/\btask_id:\s*(ses_[A-Za-z0-9]+)/i); + if (match?.[1]) { + return match[1]; + } + } + if (!isRecord(value)) { + return undefined; + } + + return ( + normalizeSubAgentText(value.task_id) ?? + normalizeSubAgentText(value.taskId) ?? + normalizeSubAgentText(value.sessionID) ?? + normalizeSubAgentText(value.sessionId) ?? + extractOpenCodeTaskSessionId(value.output) ?? + extractOpenCodeTaskSessionId(value.text) ?? + extractOpenCodeTaskSessionId(value.content) ?? + extractOpenCodeTaskSessionId(value.result) + ); +} + +function deriveOpencodeTaskDetail( + input: unknown, + output: unknown, + error: unknown, +): ToolCallDetail | null { + if (!isRecord(input)) { + return null; + } + + const subAgentType = normalizeSubAgentText(input.subagent_type ?? input.subAgentType); + const description = normalizeSubAgentText(input.description); + if (!subAgentType && !description) { + return null; + } + + const log = [formatLogEntry(output), formatLogEntry(error)].filter((entry) => entry).join("\n"); + const childSessionId = extractOpenCodeTaskSessionId(output); + return { + type: "sub_agent", + ...(subAgentType ? { subAgentType } : {}), + ...(description ? { description } : {}), + ...(childSessionId ? { childSessionId } : {}), + log, + actions: [], + }; +} + const OpencodeKnownToolDetailSchema = z.union([ toolDetailBranchByToolName( "shell", @@ -80,6 +176,14 @@ const OpencodeKnownToolDetailSchema = z.union([ toolDetailBranchByToolName("search", ToolSearchInputSchema, z.unknown(), (input) => toSearchToolDetail({ input, toolName: "search" }), ), + toolDetailBranchByToolName("glob", ToolSearchInputSchema, z.unknown(), (input, output) => { + const parsedOutput = ToolGlobOutputSchema.safeParse(output); + return toSearchToolDetail({ + input, + output: parsedOutput.success ? parsedOutput.data : null, + toolName: "glob", + }); + }), toolDetailBranchByToolName("web_search", ToolSearchInputSchema, z.unknown(), (input) => toSearchToolDetail({ input, toolName: "web_search" }), ), @@ -89,7 +193,15 @@ export function deriveOpencodeToolDetail( toolName: string, input: unknown, output: unknown, + error: unknown = null, ): ToolCallDetail { + if (toolName.trim().toLowerCase() === "task") { + const taskDetail = deriveOpencodeTaskDetail(input, output, error); + if (taskDetail) { + return taskDetail; + } + } + const parsed = OpencodeKnownToolDetailSchema.safeParse({ toolName, input, 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 23dbe6442..a0db0272d 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 @@ -88,6 +88,21 @@ describe("opencode tool-call mapper", () => { query: "opencode mapper", toolName: "web_search", }); + + const globItem = expectMapped( + mapOpencodeToolCall({ + toolName: "glob", + callId: "opencode-running-glob", + status: "running", + input: { pattern: "**/*.md" }, + output: null, + }), + ); + expect(globItem.detail).toEqual({ + type: "search", + query: "**/*.md", + toolName: "glob", + }); }); it("maps completed read calls", () => { @@ -235,6 +250,106 @@ describe("opencode tool-call mapper", () => { }); }); + it("maps running task calls with subagent input to sub_agent detail", () => { + const item = expectMapped( + mapOpencodeToolCall({ + toolName: "task", + callId: "opencode-task-running", + status: "running", + input: { + subagent_type: "explore", + description: "Explore agent-tools codebase", + }, + output: null, + }), + ); + + expect(item.status).toBe("running"); + expect(item.error).toBeNull(); + expect(item.detail).toEqual({ + type: "sub_agent", + subAgentType: "explore", + description: "Explore agent-tools codebase", + log: "", + actions: [], + }); + }); + + it("maps completed task calls with final output log to sub_agent detail", () => { + const item = expectMapped( + mapOpencodeToolCall({ + toolName: "task", + callId: "opencode-task-completed", + status: "completed", + input: { + subagent_type: "explore", + description: "Explore agent-tools codebase", + }, + output: { result: "Found the CLI entrypoint and provider registry." }, + }), + ); + + expect(item.status).toBe("completed"); + expect(item.error).toBeNull(); + expect(item.detail).toEqual({ + type: "sub_agent", + subAgentType: "explore", + description: "Explore agent-tools codebase", + log: "Found the CLI entrypoint and provider registry.", + actions: [], + }); + }); + + it("extracts child session ids from completed task output", () => { + const item = expectMapped( + mapOpencodeToolCall({ + toolName: "task", + callId: "opencode-task-completed-with-id", + status: "completed", + input: { + subagent_type: "explore", + description: "Explore current directory", + }, + output: "task_id: ses_2268db431ffe299vL1bbot8R7Z\n\ndone", + }), + ); + + expect(item.detail).toEqual({ + type: "sub_agent", + subAgentType: "explore", + description: "Explore current directory", + childSessionId: "ses_2268db431ffe299vL1bbot8R7Z", + log: "task_id: ses_2268db431ffe299vL1bbot8R7Z\n\ndone", + actions: [], + }); + }); + + it("maps aborted task calls with preserved error log to sub_agent detail", () => { + const item = expectMapped( + mapOpencodeToolCall({ + toolName: "task", + callId: "opencode-task-aborted", + status: "aborted", + input: { + subagent_type: "explore", + description: "Explore agent-tools codebase", + }, + output: null, + error: "Tool execution aborted", + }), + ); + + expect(item.status).toBe("failed"); + expect(item.error).toBe("Tool execution aborted"); + expect(item.detail).toEqual({ + type: "sub_agent", + subAgentType: "explore", + description: "Explore agent-tools codebase", + log: "Tool execution aborted", + actions: [], + }); + }); + it("does not apply cross-provider speak normalization in opencode mapper", () => { const item = expectMapped( mapOpencodeToolCall({ 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 e66b787ca..6d6051f87 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); + const detail = deriveOpencodeToolDetail(name, input, output, error); if (status === "failed") { return { diff --git a/packages/server/src/shared/messages.tool-call-schema.test.ts b/packages/server/src/shared/messages.tool-call-schema.test.ts index dac060bce..b31804eb3 100644 --- a/packages/server/src/shared/messages.tool-call-schema.test.ts +++ b/packages/server/src/shared/messages.tool-call-schema.test.ts @@ -147,6 +147,30 @@ describe("shared messages tool_call schema", () => { } }); + it("defaults missing sub_agent actions for older payloads", () => { + const parsed = AgentTimelineItemPayloadSchema.parse({ + type: "tool_call", + callId: "call_sub_agent_legacy", + name: "Task", + status: "running", + error: null, + detail: { + type: "sub_agent", + subAgentType: "Explore", + description: "Inspect repository structure", + log: "[Read] README.md", + }, + }); + + expect(parsed.type).toBe("tool_call"); + if (parsed.type === "tool_call") { + expect(parsed.detail.type).toBe("sub_agent"); + if (parsed.detail.type === "sub_agent") { + expect(parsed.detail.actions).toEqual([]); + } + } + }); + it("parses plain_text detail with icon and rejects unknown icon names", () => { const parsed = AgentTimelineItemPayloadSchema.parse({ type: "tool_call", diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index a28d09d52..274c8cb92 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -412,14 +412,18 @@ const ToolCallDetailPayloadSchema: z.ZodType