refactor tool-call detail contract for unknown tools

This commit is contained in:
Mohamed Boudra
2026-02-09 16:50:04 +07:00
parent ef29319baf
commit 7ec3f2d3cf
31 changed files with 228 additions and 177 deletions
@@ -106,23 +106,23 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
{isExpanded && ( {isExpanded && (
<View style={stylesheet.toolContent}> <View style={stylesheet.toolContent}>
{item.rawInput && ( {item.input && (
<View style={stylesheet.section}> <View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text> <Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}> <Text style={stylesheet.code}>
{JSON.stringify(item.rawInput, null, 2)} {JSON.stringify(item.input, null, 2)}
</Text> </Text>
</View> </View>
)} )}
{item.rawOutput && ( {item.output && (
<View style={stylesheet.section}> <View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text> <Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}> <Text style={stylesheet.code}>
{JSON.stringify(item.rawOutput, null, 2)} {JSON.stringify(item.output, null, 2)}
</Text> </Text>
</View> </View>
)} )}
{!item.rawInput && !item.rawOutput && ( {!item.input && !item.output && (
<Text style={stylesheet.emptyText}> <Text style={stylesheet.emptyText}>
No details available No details available
</Text> </Text>
@@ -1101,8 +1101,8 @@ function PermissionRequestCard({
<ToolCallDetailsContent <ToolCallDetailsContent
detail={{ detail={{
type: "unknown", type: "unknown",
rawInput: request.input ?? null, input: request.input ?? null,
rawOutput: null, output: null,
}} }}
maxHeight={200} maxHeight={200}
/> />
+6 -6
View File
@@ -1198,8 +1198,8 @@ export const ToolCall = memo(function ToolCall({
if (args !== undefined || result !== undefined) { if (args !== undefined || result !== undefined) {
return { return {
type: "unknown", type: "unknown",
rawInput: args ?? null, input: args ?? null,
rawOutput: result ?? null, output: result ?? null,
}; };
} }
return undefined; return undefined;
@@ -1208,8 +1208,8 @@ export const ToolCall = memo(function ToolCall({
const displayDetail = const displayDetail =
effectiveDetail ?? { effectiveDetail ?? {
type: "unknown", type: "unknown",
rawInput: null, input: null,
rawOutput: null, output: null,
}; };
const displayModel = useMemo( const displayModel = useMemo(
@@ -1235,8 +1235,8 @@ export const ToolCall = memo(function ToolCall({
Boolean(error) || Boolean(error) ||
(effectiveDetail (effectiveDetail
? effectiveDetail.type !== "unknown" || ? effectiveDetail.type !== "unknown" ||
effectiveDetail.rawInput !== null || effectiveDetail.input !== null ||
effectiveDetail.rawOutput !== null effectiveDetail.output !== null
: false); : false);
const handleToggle = useCallback(() => { const handleToggle = useCallback(() => {
@@ -139,8 +139,8 @@ export function ToolCallDetailsContent({
); );
} else if (detail?.type === "unknown") { } else if (detail?.type === "unknown") {
const sectionsFromTopLevel = [ const sectionsFromTopLevel = [
{ title: "Input", value: detail.rawInput }, { title: "Input", value: detail.input },
{ title: "Output", value: detail.rawOutput }, { title: "Output", value: detail.output },
].filter((entry) => entry.value !== null && entry.value !== undefined); ].filter((entry) => entry.value !== null && entry.value !== undefined);
for (const section of sectionsFromTopLevel) { for (const section of sectionsFromTopLevel) {
+18 -18
View File
@@ -41,8 +41,8 @@ export interface ToolCall {
title: string; title: string;
status?: 'pending' | 'in_progress' | 'completed' | 'failed'; status?: 'pending' | 'in_progress' | 'completed' | 'failed';
toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other'; toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other';
rawInput?: Record<string, unknown>; input?: Record<string, unknown>;
rawOutput?: Record<string, unknown>; output?: Record<string, unknown>;
content?: unknown[]; content?: unknown[];
locations?: unknown[]; locations?: unknown[];
} }
@@ -53,8 +53,8 @@ export interface ToolCallUpdate {
title?: string | null; title?: string | null;
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | null; status?: 'pending' | 'in_progress' | 'completed' | 'failed' | null;
toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other' | null; toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other' | null;
rawInput?: Record<string, unknown>; input?: Record<string, unknown>;
rawOutput?: Record<string, unknown>; output?: Record<string, unknown>;
content?: unknown[] | null; content?: unknown[] | null;
locations?: unknown[] | null; locations?: unknown[] | null;
} }
@@ -109,8 +109,8 @@ export interface MergedToolCall {
title: string; title: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed'; status: 'pending' | 'in_progress' | 'completed' | 'failed';
toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other'; toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other';
rawInput?: Record<string, unknown>; input?: Record<string, unknown>;
rawOutput?: Record<string, unknown>; output?: Record<string, unknown>;
content?: unknown[]; content?: unknown[];
locations?: unknown[]; locations?: unknown[];
startTimestamp: Date; startTimestamp: Date;
@@ -137,8 +137,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
title: string; title: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed'; status: 'pending' | 'in_progress' | 'completed' | 'failed';
toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other'; toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other';
rawInput?: Record<string, unknown>; input?: Record<string, unknown>;
rawOutput?: Record<string, unknown>; output?: Record<string, unknown>;
content?: unknown[]; content?: unknown[];
locations?: unknown[]; locations?: unknown[];
startTimestamp: Date; startTimestamp: Date;
@@ -210,8 +210,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
title: update.title, title: update.title,
status: update.status || 'pending', status: update.status || 'pending',
toolKind: update.toolKind, toolKind: update.toolKind,
rawInput: update.rawInput, input: update.input,
rawOutput: update.rawOutput, output: update.output,
content: update.content, content: update.content,
locations: update.locations, locations: update.locations,
startTimestamp: activity.timestamp, startTimestamp: activity.timestamp,
@@ -223,8 +223,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
existing.title = update.title; existing.title = update.title;
if (update.status) existing.status = update.status; if (update.status) existing.status = update.status;
if (update.toolKind) existing.toolKind = update.toolKind; if (update.toolKind) existing.toolKind = update.toolKind;
if (update.rawInput) existing.rawInput = update.rawInput; if (update.input) existing.input = update.input;
if (update.rawOutput) existing.rawOutput = update.rawOutput; if (update.output) existing.output = update.output;
if (update.content) existing.content = update.content; if (update.content) existing.content = update.content;
if (update.locations) existing.locations = update.locations; if (update.locations) existing.locations = update.locations;
existing.endTimestamp = activity.timestamp; existing.endTimestamp = activity.timestamp;
@@ -236,8 +236,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
if (update.title) existing.title = update.title; if (update.title) existing.title = update.title;
if (update.status) existing.status = update.status; if (update.status) existing.status = update.status;
if (update.toolKind) existing.toolKind = update.toolKind; if (update.toolKind) existing.toolKind = update.toolKind;
if (update.rawInput) existing.rawInput = { ...existing.rawInput, ...update.rawInput }; if (update.input) existing.input = { ...existing.input, ...update.input };
if (update.rawOutput) existing.rawOutput = { ...existing.rawOutput, ...update.rawOutput }; if (update.output) existing.output = { ...existing.output, ...update.output };
if (update.content) existing.content = update.content; if (update.content) existing.content = update.content;
if (update.locations) existing.locations = update.locations; if (update.locations) existing.locations = update.locations;
existing.endTimestamp = activity.timestamp; existing.endTimestamp = activity.timestamp;
@@ -251,8 +251,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
title: update.title || 'Tool Call', title: update.title || 'Tool Call',
status: update.status || 'pending', status: update.status || 'pending',
toolKind: update.toolKind || undefined, toolKind: update.toolKind || undefined,
rawInput: update.rawInput, input: update.input,
rawOutput: update.rawOutput, output: update.output,
content: update.content || undefined, content: update.content || undefined,
locations: update.locations || undefined, locations: update.locations || undefined,
startTimestamp: activity.timestamp, startTimestamp: activity.timestamp,
@@ -280,8 +280,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
title: toolCall.title, title: toolCall.title,
status: toolCall.status, status: toolCall.status,
toolKind: toolCall.toolKind, toolKind: toolCall.toolKind,
rawInput: toolCall.rawInput, input: toolCall.input,
rawOutput: toolCall.rawOutput, output: toolCall.output,
content: toolCall.content, content: toolCall.content,
locations: toolCall.locations, locations: toolCall.locations,
startTimestamp: toolCall.startTimestamp, startTimestamp: toolCall.startTimestamp,
+2 -2
View File
@@ -28,8 +28,8 @@ const toolCallEvent = (): AgentStreamEventPayload => ({
status: "running", status: "running",
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: { command: "echo hi" }, input: { command: "echo hi" },
rawOutput: null, output: null,
}, },
error: null, error: null,
}, },
@@ -215,8 +215,8 @@ function buildToolEvent({
}): AgentStreamEventPayload { }): AgentStreamEventPayload {
const canonicalDetail: ToolCallDetail = detail ?? { const canonicalDetail: ToolCallDetail = detail ?? {
type: "unknown", type: "unknown",
rawInput: input, input: input,
rawOutput: output, output: output,
}; };
const baseItem = { const baseItem = {
+6 -6
View File
@@ -42,8 +42,8 @@ function canonicalToolTimeline(params: {
}): AgentStreamEventPayload { }): AgentStreamEventPayload {
const detail: ToolCallDetail = params.detail ?? { const detail: ToolCallDetail = params.detail ?? {
type: "unknown", type: "unknown",
rawInput: params.input ?? null, input: params.input ?? null,
rawOutput: params.output ?? null, output: params.output ?? null,
}; };
const baseItem = { const baseItem = {
@@ -150,8 +150,8 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(tools[0].payload.data.status, "completed"); assert.strictEqual(tools[0].payload.data.status, "completed");
assert.deepStrictEqual(tools[0].payload.data.detail, { assert.deepStrictEqual(tools[0].payload.data.detail, {
type: "unknown", type: "unknown",
rawInput: { command: "pwd" }, input: { command: "pwd" },
rawOutput: { output: {
output: "/tmp/repo\n", output: "/tmp/repo\n",
exitCode: 0, exitCode: 0,
}, },
@@ -282,8 +282,8 @@ describe("stream reducer canonical tool calls", () => {
assert.ok(tool); assert.ok(tool);
assert.deepStrictEqual(tool.payload.data.detail, { assert.deepStrictEqual(tool.payload.data.detail, {
type: "unknown", type: "unknown",
rawInput: { path: "README.md" }, input: { path: "README.md" },
rawOutput: { content: "hello" }, output: { content: "hello" },
}); });
assert.strictEqual(tool.payload.data.status, "completed"); assert.strictEqual(tool.payload.data.status, "completed");
}); });
+7 -7
View File
@@ -353,20 +353,20 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail)
if (existing.type === "unknown" && incoming.type === "unknown") { if (existing.type === "unknown" && incoming.type === "unknown") {
return { return {
type: "unknown", type: "unknown",
rawInput: mergeUnknownValue(existing.rawInput, incoming.rawInput), input: mergeUnknownValue(existing.input, incoming.input),
rawOutput: mergeUnknownValue(existing.rawOutput, incoming.rawOutput), output: mergeUnknownValue(existing.output, incoming.output),
}; };
} }
if (existing.type === incoming.type) { if (existing.type === incoming.type) {
return { ...existing, ...incoming }; return { ...existing, ...incoming } as ToolCallDetail;
} }
return incoming; return incoming;
} }
function rawInputFromDetail(detail: ToolCallDetail): unknown | null { function inputFromUnknownDetail(detail: ToolCallDetail): unknown | null {
return detail.type === "unknown" ? detail.rawInput : null; return detail.type === "unknown" ? detail.input : null;
} }
function mergeAgentToolCallStatus( function mergeAgentToolCallStatus(
@@ -552,7 +552,7 @@ export function reduceStreamUpdate(
// as Tasks when possible and otherwise hide it to avoid a stuck loading tool call. // as Tasks when possible and otherwise hide it to avoid a stuck loading tool call.
const tasks = extractTaskEntriesFromToolCall( const tasks = extractTaskEntriesFromToolCall(
item.name, item.name,
rawInputFromDetail(item.detail) inputFromUnknownDetail(item.detail)
); );
if (tasks) { if (tasks) {
nextState = appendTodoList( nextState = appendTodoList(
@@ -570,7 +570,7 @@ export function reduceStreamUpdate(
const tasks = extractTaskEntriesFromToolCall( const tasks = extractTaskEntriesFromToolCall(
item.name, item.name,
rawInputFromDetail(item.detail) inputFromUnknownDetail(item.detail)
); );
if (tasks) { if (tasks) {
nextState = appendTodoList( nextState = appendTodoList(
@@ -45,8 +45,8 @@ describe("tool-call-display", () => {
error: null, error: null,
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: null, input: null,
rawOutput: null, output: null,
}, },
metadata: { metadata: {
subAgentActivity: "Running tests", subAgentActivity: "Running tests",
@@ -66,8 +66,8 @@ describe("tool-call-display", () => {
error: null, error: null,
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: null, input: null,
rawOutput: null, output: null,
}, },
}); });
@@ -83,8 +83,8 @@ describe("tool-call-display", () => {
error: null, error: null,
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: { command: "npm run test" }, input: { command: "npm run test" },
rawOutput: null, output: null,
}, },
}); });
@@ -100,8 +100,8 @@ describe("tool-call-display", () => {
error: { message: "boom" }, error: { message: "boom" },
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: { command: "false" }, input: { command: "false" },
rawOutput: null, output: null,
}, },
}); });
@@ -344,8 +344,8 @@ describe("DaemonClient", () => {
status: "inProgress", status: "inProgress",
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: { command: "pwd" }, input: { command: "pwd" },
rawOutput: null, output: null,
}, },
}, },
}, },
@@ -16,8 +16,8 @@ function toolCallItem(params: {
const detail = const detail =
params.detail ?? { params.detail ?? {
type: "unknown" as const, type: "unknown" as const,
rawInput: params.input ?? null, input: params.input ?? null,
rawOutput: params.output ?? null, output: params.output ?? null,
}; };
return { return {
type: "tool_call", type: "tool_call",
@@ -71,18 +71,18 @@ function mergeToolDetail(existing: ToolCallDetail, incoming: ToolCallDetail): To
if (existing.type === "unknown" && incoming.type === "unknown") { if (existing.type === "unknown" && incoming.type === "unknown") {
return { return {
type: "unknown", type: "unknown",
rawInput: mergeUnknownValue(existing.rawInput, incoming.rawInput), input: mergeUnknownValue(existing.input, incoming.input),
rawOutput: mergeUnknownValue(existing.rawOutput, incoming.rawOutput), output: mergeUnknownValue(existing.output, incoming.output),
}; };
} }
if (existing.type === incoming.type) { if (existing.type === incoming.type) {
return { ...existing, ...incoming }; return { ...existing, ...incoming } as ToolCallDetail;
} }
return incoming; return incoming;
} }
function rawInputFromDetail(detail: ToolCallDetail): unknown { function inputFromUnknownDetail(detail: ToolCallDetail): unknown {
return detail.type === "unknown" ? detail.rawInput : null; return detail.type === "unknown" ? detail.input : null;
} }
/** /**
@@ -203,7 +203,7 @@ export function curateAgentActivity(
break; break;
case "tool_call": { case "tool_call": {
flushBuffers(lines, buffers); flushBuffers(lines, buffers);
const inputJson = formatToolInputJson(rawInputFromDetail(item.detail)); const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail));
const display = buildToolCallDisplayModel({ const display = buildToolCallDisplayModel({
name: item.name, name: item.name,
status: item.status, status: item.status,
@@ -136,8 +136,8 @@ export type ToolCallDetail =
} }
| { | {
type: "unknown"; type: "unknown";
rawInput: unknown | null; input: unknown | null;
rawOutput: unknown | null; output: unknown | null;
}; };
type ToolCallBase = { type ToolCallBase = {
@@ -97,7 +97,7 @@ function extractToolCommand(detail: unknown): string | null {
return detail.command; return detail.command;
} }
if (detail.type === "unknown") { if (detail.type === "unknown") {
return extractCommandText(detail.rawInput); return extractCommandText(detail.input);
} }
return null; return null;
} }
@@ -245,7 +245,6 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
let hydrateStreamState: (updates: unknown[]) => unknown = () => { let hydrateStreamState: (updates: unknown[]) => unknown = () => {
throw new Error("hydrateStreamState not initialized"); throw new Error("hydrateStreamState not initialized");
}; };
let isAgentToolCallItem: (item: unknown) => boolean = () => false;
let agentMcpServer: AgentMcpServerHandle; let agentMcpServer: AgentMcpServerHandle;
let restoreClaudeConfigDir: (() => void) | null = null; let restoreClaudeConfigDir: (() => void) | null = null;
const buildConfig = ( const buildConfig = (
@@ -271,7 +270,6 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
beforeAll(async () => { beforeAll(async () => {
const stream = await import("../../../../../app/src/types/stream.js"); const stream = await import("../../../../../app/src/types/stream.js");
hydrateStreamState = stream.hydrateStreamState as typeof hydrateStreamState; hydrateStreamState = stream.hydrateStreamState as typeof hydrateStreamState;
isAgentToolCallItem = stream.isAgentToolCallItem as typeof isAgentToolCallItem;
}); });
beforeAll(async () => { beforeAll(async () => {
agentMcpServer = await startAgentMcpServer(); agentMcpServer = await startAgentMcpServer();
@@ -450,8 +448,8 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
} }
if (item.detail.type === "unknown") { if (item.detail.type === "unknown") {
return ( return (
rawContainsText(item.detail.rawInput, "tool-test.txt") || rawContainsText(item.detail.input, "tool-test.txt") ||
rawContainsText(item.detail.rawOutput, "tool-test.txt") rawContainsText(item.detail.output, "tool-test.txt")
); );
} }
return rawContainsText(item.detail, "tool-test.txt"); return rawContainsText(item.detail, "tool-test.txt");
@@ -1330,10 +1328,18 @@ function stateIncludesUserMessage(state: StreamItem[], marker: string): boolean
} }
function extractAgentToolSnapshots(state: StreamItem[]): ToolSnapshot[] { function extractAgentToolSnapshots(state: StreamItem[]): ToolSnapshot[] {
return state.filter(isAgentToolCallItem).map((item) => ({ return state
key: buildToolSnapshotKey(item.payload.data, item.id), .filter(
data: item.payload.data, (item): item is { kind: "tool_call"; id: string; payload: { source: "agent"; data: AgentToolCallData } } =>
})); Boolean(item) &&
item.kind === "tool_call" &&
item.payload?.source === "agent" &&
item.payload?.data
)
.map((item) => ({
key: buildToolSnapshotKey(item.payload.data, item.id),
data: item.payload.data,
}));
} }
function buildToolSnapshotKey(data: AgentToolCallData, fallbackId: string): string { function buildToolSnapshotKey(data: AgentToolCallData, fallbackId: string): string {
@@ -54,14 +54,18 @@ export function deriveClaudeToolDetail(
name: string, name: string,
input: unknown, input: unknown,
output: unknown output: unknown
): ToolCallDetail | undefined { ): ToolCallDetail {
const parsed = ClaudeKnownToolDetailSchema.safeParse({ const parsed = ClaudeKnownToolDetailSchema.safeParse({
name, name,
input, input,
output, output,
}); });
if (!parsed.success) { if (parsed.success && parsed.data) {
return undefined; return parsed.data;
} }
return parsed.data; return {
type: "unknown",
input: input ?? null,
output: output ?? null,
};
} }
@@ -187,8 +187,8 @@ describe("claude tool-call mapper", () => {
expect(item.error).toBeNull(); expect(item.error).toBeNull();
expect(item.detail).toEqual({ expect(item.detail).toEqual({
type: "unknown", type: "unknown",
rawInput: { foo: "bar" }, input: { foo: "bar" },
rawOutput: { ok: true }, output: { ok: true },
}); });
}); });
}); });
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import type { ToolCallDetail, ToolCallTimelineItem } from "../../agent-sdk-types.js"; import type { ToolCallTimelineItem } from "../../agent-sdk-types.js";
import { coerceToolCallId } from "../tool-call-mapper-utils.js"; import { coerceToolCallId } from "../tool-call-mapper-utils.js";
import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js"; import { deriveClaudeToolDetail } from "./tool-call-detail-parser.js";
@@ -38,9 +38,7 @@ function coerceCallId(callId: string | null | undefined, name: string, input: un
function buildBase(params: MapperParams): { function buildBase(params: MapperParams): {
callId: string; callId: string;
name: string; name: string;
input: unknown | null; detail: Extract<ToolCallTimelineItem, { type: "tool_call" }>["detail"];
output: unknown | null;
detail?: ToolCallDetail;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
} { } {
const parsedParams = ClaudeMapperParamsSchema.parse(params); const parsedParams = ClaudeMapperParamsSchema.parse(params);
@@ -51,9 +49,7 @@ function buildBase(params: MapperParams): {
return { return {
callId: coerceCallId(parsedParams.callId, parsedParams.name, input), callId: coerceCallId(parsedParams.callId, parsedParams.name, input),
name: parsedParams.name, name: parsedParams.name,
input, detail,
output,
...(detail ? { detail } : {}),
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}), ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
}; };
} }
@@ -48,35 +48,43 @@ function useTempCodexSessionDir(): () => void {
} }
function hasShellCommand(item: AgentTimelineItem, commandFragment: string): boolean { function hasShellCommand(item: AgentTimelineItem, commandFragment: string): boolean {
if (item.type !== "tool_call" || item.name !== "shell") return false; if (item.type !== "tool_call") return false;
if (item.detail.type === "shell") { if (item.detail.type === "shell") {
return item.detail.command.includes(commandFragment); return item.detail.command.includes(commandFragment);
} }
const unknownInput = const unknownInput =
item.detail.type === "unknown" && typeof item.detail.rawInput === "object" && item.detail.rawInput item.detail.type === "unknown" && typeof item.detail.input === "object" && item.detail.input
? (item.detail.rawInput as { command?: string }) ? (item.detail.input as { command?: string | string[]; cmd?: string | string[] })
: undefined; : undefined;
const command = unknownInput?.command ?? ""; const commandValue = unknownInput?.command ?? unknownInput?.cmd;
const command =
typeof commandValue === "string"
? commandValue
: Array.isArray(commandValue)
? commandValue.filter((value): value is string => typeof value === "string").join(" ")
: "";
return command.includes(commandFragment); return command.includes(commandFragment);
} }
function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean { function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean {
if (item.type !== "tool_call" || item.name !== "apply_patch") return false; if (item.type !== "tool_call") return false;
if (item.detail.type === "edit") { if (item.detail.type === "edit") {
return item.detail.filePath === fileName || (item.detail.unifiedDiff?.includes(fileName) ?? false); return item.detail.filePath === fileName || (item.detail.unifiedDiff?.includes(fileName) ?? false);
} }
const unknownInput = const unknownInput =
item.detail.type === "unknown" && typeof item.detail.rawInput === "object" && item.detail.rawInput item.detail.type === "unknown" && typeof item.detail.input === "object" && item.detail.input
? (item.detail.rawInput as { files?: Array<{ path?: string }> }) ? (item.detail.input as { path?: string; file_path?: string; filePath?: string; files?: Array<{ path?: string }> })
: undefined; : undefined;
const unknownOutput = const unknownOutput =
item.detail.type === "unknown" && typeof item.detail.rawOutput === "object" && item.detail.rawOutput item.detail.type === "unknown" && typeof item.detail.output === "object" && item.detail.output
? (item.detail.rawOutput as { files?: Array<{ path?: string; patch?: string }>; diff?: string }) ? (item.detail.output as { path?: string; file_path?: string; filePath?: string; files?: Array<{ path?: string; patch?: string }>; diff?: string })
: undefined; : undefined;
const inputPath = unknownInput?.path ?? unknownInput?.file_path ?? unknownInput?.filePath;
const outputPath = unknownOutput?.path ?? unknownOutput?.file_path ?? unknownOutput?.filePath;
const inInput = (unknownInput?.files ?? []).some((file) => file?.path === fileName); const inInput = (unknownInput?.files ?? []).some((file) => file?.path === fileName);
const inOutput = (unknownOutput?.files ?? []).some((file) => file?.path === fileName); const inOutput = (unknownOutput?.files ?? []).some((file) => file?.path === fileName);
const inDiff = typeof unknownOutput?.diff === "string" && unknownOutput.diff.includes(fileName); const inDiff = typeof unknownOutput?.diff === "string" && unknownOutput.diff.includes(fileName);
return inInput || inOutput || inDiff; return inInput || inOutput || inDiff || inputPath === fileName || outputPath === fileName;
} }
async function waitForFileToContainText( async function waitForFileToContainText(
@@ -363,7 +371,7 @@ describe("Codex app-server provider (integration)", () => {
if (call.name !== "paseo_test.echo") { if (call.name !== "paseo_test.echo") {
continue; continue;
} }
const key = String(call.callId ?? `${call.name}:${JSON.stringify(call.input ?? {})}`); const key = String(call.callId ?? `${call.name}:${JSON.stringify(call.detail)}`);
const existing = distinctMcpCalls.get(key); const existing = distinctMcpCalls.get(key);
if (!existing || call.status === "completed") { if (!existing || call.status === "completed") {
distinctMcpCalls.set(key, call); distinctMcpCalls.set(key, call);
@@ -382,8 +390,9 @@ describe("Codex app-server provider (integration)", () => {
expect(toolNames.some((name) => name.toLowerCase().includes("shell"))).toBe(false); expect(toolNames.some((name) => name.toLowerCase().includes("shell"))).toBe(false);
// Hard assertion: roundtrip token must be present in the MCP tool I/O. // Hard assertion: roundtrip token must be present in the MCP tool I/O.
expect(JSON.stringify(mcpToolCall.input ?? {})).toContain(token); const mcpDetail = mcpToolCall.detail.type === "unknown" ? mcpToolCall.detail : null;
expect(JSON.stringify(mcpToolCall.output ?? {})).toContain(`ECHO:${token}`); expect(JSON.stringify(mcpDetail?.input ?? {})).toContain(token);
expect(JSON.stringify(mcpDetail?.output ?? {})).toContain(`ECHO:${token}`);
expect(result.finalText).toContain(`ECHO:${token}`); expect(result.finalText).toContain(`ECHO:${token}`);
} finally { } finally {
cleanup(); cleanup();
@@ -496,7 +505,7 @@ describe("Codex app-server provider (integration)", () => {
} }
expect(sawPermission || timelineItems.length > 0).toBe(true); expect(sawPermission || timelineItems.length > 0).toBe(true);
expect( expect(
timelineItems.some((item) => item.type === "tool_call" && item.name === "shell") timelineItems.some((item) => hasShellCommand(item, "printf"))
).toBe(true); ).toBe(true);
expect(existsSync(filePath)).toBe(true); expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8")).toContain("ok"); expect(readFileSync(filePath, "utf8")).toContain("ok");
@@ -629,12 +638,10 @@ describe("Codex app-server provider (integration)", () => {
expect(shellItem?.type).toBe("tool_call"); expect(shellItem?.type).toBe("tool_call");
expect(patchItem?.type).toBe("tool_call"); expect(patchItem?.type).toBe("tool_call");
if (shellItem?.type === "tool_call") { if (shellItem?.type === "tool_call") {
expect(shellItem.name).toBe("shell"); expect(hasShellCommand(shellItem, "printf")).toBe(true);
expect(shellItem.input).toBeTruthy();
} }
if (patchItem?.type === "tool_call") { if (patchItem?.type === "tool_call") {
expect(patchItem.name).toBe("apply_patch"); expect(hasApplyPatchFile(patchItem, "patch.txt")).toBe(true);
expect(patchItem.input || patchItem.output).toBeTruthy();
} }
} finally { } finally {
cleanup(); cleanup();
@@ -698,7 +705,6 @@ describe("Codex app-server provider (integration)", () => {
if ( if (
event.type === "timeline" && event.type === "timeline" &&
event.item.type === "tool_call" && event.item.type === "tool_call" &&
event.item.name === "shell" &&
hasShellCommand(event.item, "sleep 60") hasShellCommand(event.item, "sleep 60")
) { ) {
sawSleepCommand = true; sawSleepCommand = true;
@@ -134,15 +134,19 @@ export function deriveCodexToolDetail(params: {
input: unknown; input: unknown;
output: unknown; output: unknown;
cwd?: string | null; cwd?: string | null;
}): ToolCallDetail | undefined { }): ToolCallDetail {
const parsed = CodexKnownToolDetailSchema.safeParse({ const parsed = CodexKnownToolDetailSchema.safeParse({
name: params.name, name: params.name,
input: params.input, input: params.input,
output: params.output, output: params.output,
cwd: params.cwd ?? null, cwd: params.cwd ?? null,
}); });
if (!parsed.success) { if (parsed.success && parsed.data) {
return undefined; return parsed.data;
} }
return parsed.data; return {
type: "unknown",
input: params.input ?? null,
output: params.output ?? null,
};
} }
@@ -235,8 +235,8 @@ describe("codex tool-call mapper", () => {
expect(item.error).toBeNull(); expect(item.error).toBeNull();
expect(item.detail).toEqual({ expect(item.detail).toEqual({
type: "unknown", type: "unknown",
rawInput: { foo: "bar" }, input: { foo: "bar" },
rawOutput: { ok: true }, output: { ok: true },
}); });
expect(item.callId).toBe("codex-call-4"); expect(item.callId).toBe("codex-call-4");
}); });
@@ -140,10 +140,8 @@ function buildToolCall(params: {
callId: string; callId: string;
name: string; name: string;
status: ToolCallTimelineItem["status"]; status: ToolCallTimelineItem["status"];
input: unknown | null;
output: unknown | null;
error: unknown | null; error: unknown | null;
detail?: ToolCallDetail; detail: ToolCallDetail;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}): ToolCallTimelineItem { }): ToolCallTimelineItem {
if (params.status === "failed") { if (params.status === "failed") {
@@ -152,10 +150,8 @@ function buildToolCall(params: {
callId: params.callId, callId: params.callId,
name: params.name, name: params.name,
status: "failed", status: "failed",
input: params.input,
output: params.output,
error: params.error ?? { message: "Tool call failed" }, error: params.error ?? { message: "Tool call failed" },
...(params.detail ? { detail: params.detail } : {}), detail: params.detail,
...(params.metadata ? { metadata: params.metadata } : {}), ...(params.metadata ? { metadata: params.metadata } : {}),
}; };
} }
@@ -165,10 +161,8 @@ function buildToolCall(params: {
callId: params.callId, callId: params.callId,
name: params.name, name: params.name,
status: params.status, status: params.status,
input: params.input,
output: params.output,
error: null, error: null,
...(params.detail ? { detail: params.detail } : {}), detail: params.detail,
...(params.metadata ? { metadata: params.metadata } : {}), ...(params.metadata ? { metadata: params.metadata } : {}),
}; };
} }
@@ -221,7 +215,11 @@ function mapCommandExecutionItem(
...(item.aggregatedOutput ? { output: item.aggregatedOutput } : {}), ...(item.aggregatedOutput ? { output: item.aggregatedOutput } : {}),
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}), ...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
} }
: undefined; : {
type: "unknown" as const,
input,
output,
};
const name = "shell"; const name = "shell";
const callId = coerceCallId(item.id, name, input); const callId = coerceCallId(item.id, name, input);
@@ -232,10 +230,8 @@ function mapCommandExecutionItem(
callId, callId,
name, name,
status, status,
input,
output,
error, error,
...(detail ? { detail } : {}), detail,
}); });
} }
@@ -290,7 +286,11 @@ function mapFileChangeItem(
filePath: firstFile.path, filePath: firstFile.path,
...(firstFile.diff !== undefined ? { unifiedDiff: truncateDiffText(firstFile.diff) } : {}), ...(firstFile.diff !== undefined ? { unifiedDiff: truncateDiffText(firstFile.diff) } : {}),
} }
: undefined; : {
type: "unknown" as const,
input,
output,
};
const name = "apply_patch"; const name = "apply_patch";
const callId = coerceCallId(item.id, name, input); const callId = coerceCallId(item.id, name, input);
@@ -301,10 +301,8 @@ function mapFileChangeItem(
callId, callId,
name, name,
status, status,
input,
output,
error, error,
...(detail ? { detail } : {}), detail,
}); });
} }
@@ -330,10 +328,8 @@ function mapMcpToolCallItem(
callId, callId,
name, name,
status, status,
input,
output,
error, error,
...(detail ? { detail } : {}), detail,
}); });
} }
@@ -349,16 +345,18 @@ function mapWebSearchItem(item: z.infer<typeof CodexWebSearchItemSchema>): ToolC
type: "search" as const, type: "search" as const,
query: item.query, query: item.query,
} }
: undefined; : {
type: "unknown" as const,
input,
output,
};
return buildToolCall({ return buildToolCall({
callId, callId,
name, name,
status, status,
input,
output,
error, error,
...(detail ? { detail } : {}), detail,
}); });
} }
@@ -420,9 +418,7 @@ export function mapCodexRolloutToolCall(params: {
callId, callId,
name: parsed.name, name: parsed.name,
status, status,
input,
output,
error, error,
...(detail ? { detail } : {}), detail,
}); });
} }
@@ -43,14 +43,18 @@ export function deriveOpencodeToolDetail(
toolName: string, toolName: string,
input: unknown, input: unknown,
output: unknown output: unknown
): ToolCallDetail | undefined { ): ToolCallDetail {
const parsed = OpencodeKnownToolDetailSchema.safeParse({ const parsed = OpencodeKnownToolDetailSchema.safeParse({
toolName, toolName,
input, input,
output, output,
}); });
if (!parsed.success) { if (parsed.success && parsed.data) {
return undefined; return parsed.data;
} }
return parsed.data; return {
type: "unknown",
input: input ?? null,
output: output ?? null,
};
} }
@@ -194,8 +194,8 @@ describe("opencode tool-call mapper", () => {
expect(item.error).toBeNull(); expect(item.error).toBeNull();
expect(item.detail).toEqual({ expect(item.detail).toEqual({
type: "unknown", type: "unknown",
rawInput: { foo: "bar" }, input: { foo: "bar" },
rawOutput: { ok: true }, output: { ok: true },
}); });
}); });
}); });
@@ -81,10 +81,8 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim
callId, callId,
name: parsedParams.toolName, name: parsedParams.toolName,
status: "failed", status: "failed",
input, detail,
output,
error: parsedParams.error ?? { message: "Tool call failed" }, error: parsedParams.error ?? { message: "Tool call failed" },
...(detail ? { detail } : {}),
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}), ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
}; };
} }
@@ -94,10 +92,8 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim
callId, callId,
name: parsedParams.toolName, name: parsedParams.toolName,
status, status,
input, detail,
output,
error: null, error: null,
...(detail ? { detail } : {}),
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}), ...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
}; };
} }
+32 -2
View File
@@ -50,6 +50,36 @@ describe("serializeAgentStreamEvent", () => {
expect(serialized.item.error).toBeNull(); expect(serialized.item.error).toBeNull();
}); });
test("passes unknown-detail tool_call payloads through unchanged", () => {
const event: AgentStreamEvent = {
type: "timeline",
provider: "codex",
item: {
type: "tool_call",
callId: "call_unknown",
name: "paseo_voice.speak",
status: "completed",
detail: {
type: "unknown",
input: { text: "hello" },
output: { ok: true },
},
error: null,
},
};
const serialized = serializeAgentStreamEvent(event);
expect(serialized).not.toBeNull();
if (!serialized || serialized.type !== "timeline" || serialized.item.type !== "tool_call") {
throw new Error("Expected timeline.tool_call event");
}
expect(serialized.item.detail).toEqual({
type: "unknown",
input: { text: "hello" },
output: { ok: true },
});
});
test("drops invalid legacy tool_call items", () => { test("drops invalid legacy tool_call items", () => {
const event = { const event = {
type: "timeline", type: "timeline",
@@ -61,8 +91,8 @@ describe("serializeAgentStreamEvent", () => {
status: "inProgress", status: "inProgress",
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: { command: "pwd" }, input: { command: "pwd" },
rawOutput: null, output: null,
}, },
}, },
} satisfies unknown; } satisfies unknown;
+6 -6
View File
@@ -2092,11 +2092,11 @@ export class Session {
status: "running", status: "running",
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: { input: {
worktreePath: worktree.worktreePath, worktreePath: worktree.worktreePath,
branchName: worktree.branchName, branchName: worktree.branchName,
}, },
rawOutput: null, output: null,
}, },
error: null, error: null,
}); });
@@ -2117,11 +2117,11 @@ export class Session {
status: "completed", status: "completed",
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: { input: {
worktreePath: worktree.worktreePath, worktreePath: worktree.worktreePath,
branchName: worktree.branchName, branchName: worktree.branchName,
}, },
rawOutput: { output: {
worktreePath: worktree.worktreePath, worktreePath: worktree.worktreePath,
commands: results.map((result) => ({ commands: results.map((result) => ({
command: result.command, command: result.command,
@@ -2145,11 +2145,11 @@ export class Session {
status: "failed", status: "failed",
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: { input: {
worktreePath: worktree.worktreePath, worktreePath: worktree.worktreePath,
branchName: worktree.branchName, branchName: worktree.branchName,
}, },
rawOutput: { output: {
worktreePath: worktree.worktreePath, worktreePath: worktree.worktreePath,
commands: results.map((result) => ({ commands: results.map((result) => ({
command: result.command, command: result.command,
@@ -248,8 +248,8 @@ class FakeAgentSession implements AgentSession {
status: "running", status: "running",
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: tool.input ?? null, input: tool.input ?? null,
rawOutput: null, output: null,
}, },
error: null, error: null,
}, },
@@ -332,8 +332,8 @@ class FakeAgentSession implements AgentSession {
status: "completed", status: "completed",
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: tool.input ?? null, input: tool.input ?? null,
rawOutput: toolOutput ?? { ok: true }, output: toolOutput ?? { ok: true },
}, },
error: null, error: null,
}, },
@@ -64,8 +64,17 @@ describe("shared messages tool_call schema", () => {
error: null, error: null,
}); });
const withTopLevelInputOutput = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "running",
error: null,
input: { command: "pwd" },
output: { exitCode: 0 },
});
expect(missingCallId.success).toBe(false); expect(missingCallId.success).toBe(false);
expect(unknownStatus.success).toBe(false); expect(unknownStatus.success).toBe(false);
expect(withTopLevelInputOutput.success).toBe(false);
}); });
it("rejects legacy status/error combinations without normalization", () => { it("rejects legacy status/error combinations without normalization", () => {
+3 -3
View File
@@ -190,8 +190,8 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
}), }),
z.object({ z.object({
type: z.literal("unknown"), type: z.literal("unknown"),
rawInput: UnknownValueSchema, input: UnknownValueSchema,
rawOutput: UnknownValueSchema, output: UnknownValueSchema,
}), }),
]); ]);
@@ -201,7 +201,7 @@ const ToolCallBasePayloadSchema = z.object({
name: z.string(), name: z.string(),
detail: ToolCallDetailPayloadSchema, detail: ToolCallDetailPayloadSchema,
metadata: z.record(z.unknown()).optional(), metadata: z.record(z.unknown()).optional(),
}); }).strict();
const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({ const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({
status: z.literal("running"), status: z.literal("running"),
@@ -28,8 +28,8 @@ describe("shared tool-call display mapping", () => {
error: null, error: null,
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: { command: "npm test" }, input: { command: "npm test" },
rawOutput: null, output: null,
}, },
}); });
@@ -45,8 +45,8 @@ describe("shared tool-call display mapping", () => {
error: null, error: null,
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: null, input: null,
rawOutput: null, output: null,
}, },
metadata: { metadata: {
subAgentActivity: "Running tests", subAgentActivity: "Running tests",
@@ -66,8 +66,8 @@ describe("shared tool-call display mapping", () => {
error: { message: "boom" }, error: { message: "boom" },
detail: { detail: {
type: "unknown", type: "unknown",
rawInput: null, input: null,
rawOutput: null, output: null,
}, },
}); });