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 <ryan@mlh.io>
This commit is contained in:
Mohamed Boudra
2026-05-02 13:19:42 +08:00
committed by GitHub
parent a64be66353
commit fdc510d3a0
8 changed files with 718 additions and 16 deletions

View File

@@ -244,15 +244,95 @@ function resolveSubAgentFallbackHeader(
interface SubAgentDetailProps {
log: string;
actions: Extract<ToolCallDetail, { type: "sub_agent" }>["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 (
<View style={styles.subAgentActionRow}>
<Text selectable style={styles.subAgentActionTool}>
{formatSubAgentToolName(action.toolName)}
</Text>
{action.summary ? (
<Text selectable style={styles.subAgentActionSummary}>
{action.summary}
</Text>
) : null}
</View>
);
}
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 (
<Text selectable style={styles.scrollText}>
{activityLog}
</Text>
);
}
if (!hasActions) {
return (
<Text selectable style={styles.scrollText}>
{fallbackHeader}
</Text>
);
}
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 (
<View style={ds.sectionFillStyle}>
<View style={ds.codeBlockFillStyle}>
@@ -270,9 +350,23 @@ function SubAgentDetailSection({ log, subAgentType, description, ds }: SubAgentD
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<Text selectable style={styles.scrollText}>
{hasLog ? activityLog : fallbackHeader}
</Text>
{childSessionId ? (
<Text selectable style={styles.subAgentSessionText}>
session {childSessionId}
</Text>
) : null}
{hasActions ? (
<View style={styles.subAgentActions}>
{actions.map((action) => (
<SubAgentActionRow key={action.index} action={action} />
))}
</View>
) : null}
<SubAgentLogText
activityLog={activityLog}
fallbackHeader={fallbackHeader}
hasActions={hasActions}
/>
</View>
</ScrollView>
</ScrollView>
@@ -530,6 +624,8 @@ function buildDetailSections(
<SubAgentDetailSection
key="sub-agent"
log={detail.log}
actions={detail.actions}
childSessionId={detail.childSessionId}
subAgentType={detail.subAgentType}
description={detail.description}
ds={ds}
@@ -731,6 +827,35 @@ const styles = StyleSheet.create((theme) => {
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,

View File

@@ -235,6 +235,7 @@ export type ToolCallDetail =
type: "sub_agent";
subAgentType?: string;
description?: string;
childSessionId?: string;
log: string;
actions: Array<{
index: number;

View File

@@ -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<string, OpenCodeMessageRole>;
accumulatedUsage: AgentUsage;
streamedPartKeys: Set<string>;
emittedStructuredMessageIds: Set<string>;
/** Tracks the type of each part by ID, learned from message.part.updated events. */
partTypes: Map<string, string>;
subAgentsByCallId?: Map<string, OpenCodeSubAgentActivityState>;
subAgentCallIdByChildSessionId?: Map<string, string>;
pendingChildToolPartsBySessionId?: Map<string, OpenCodeToolPartEventPart[]>;
modelContextWindowsByModelKey?: ReadonlyMap<string, number>;
onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void;
}
type OpenCodeToolPartEventPart = Extract<
Extract<OpenCodeEvent, { type: "message.part.updated" }>["properties"]["part"],
{ type: "tool" }
>;
interface OpenCodeSubAgentActionEntry {
index: number;
key: string;
toolName: string;
summary?: string;
}
interface OpenCodeSubAgentActivityState {
toolCall: ToolCallTimelineItem;
actions: OpenCodeSubAgentActionEntry[];
actionIndexByKey: Map<string, number>;
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<string, OpenCodeSubAgentActivityState>;
callIdByChildSessionId: Map<string, string>;
pendingChildToolPartsBySessionId: Map<string, OpenCodeToolPartEventPart[]>;
} {
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<ToolCallDetail, { type: "sub_agent" }>,
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<OpenCodeEvent, { type: "session.created" | "session.updated" }>,
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<string, ToolCallTimelineItem>();
private subAgentsByCallId = new Map<string, OpenCodeSubAgentActivityState>();
private subAgentCallIdByChildSessionId = new Map<string, string>();
private pendingChildToolPartsBySessionId = new Map<string, OpenCodeToolPartEventPart[]>();
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<AgentStreamEvent[]> {
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;

View File

@@ -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<string, unknown> {
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,

View File

@@ -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\n<task_result>done</task_result>",
}),
);
expect(item.detail).toEqual({
type: "sub_agent",
subAgentType: "explore",
description: "Explore current directory",
childSessionId: "ses_2268db431ffe299vL1bbot8R7Z",
log: "task_id: ses_2268db431ffe299vL1bbot8R7Z\n\n<task_result>done</task_result>",
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({

View File

@@ -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 {

View File

@@ -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",

View File

@@ -412,14 +412,18 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, z.ZodTypeDef, unkno
type: z.literal("sub_agent"),
subAgentType: z.string().optional(),
description: z.string().optional(),
childSessionId: z.string().optional(),
log: z.string(),
actions: z.array(
z.object({
index: z.number().int().positive(),
toolName: z.string(),
summary: z.string().optional(),
}),
),
actions: z
.array(
z.object({
index: z.number().int().positive(),
toolName: z.string(),
summary: z.string().optional(),
}),
)
.optional()
.default([]),
}),
z.object({
type: z.literal("plain_text"),