mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -235,6 +235,7 @@ export type ToolCallDetail =
|
||||
type: "sub_agent";
|
||||
subAgentType?: string;
|
||||
description?: string;
|
||||
childSessionId?: string;
|
||||
log: string;
|
||||
actions: Array<{
|
||||
index: number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user