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

View File

@@ -106,23 +106,23 @@ function MergedToolCallItem({ item }: { item: MergedToolCall }) {
{isExpanded && (
<View style={stylesheet.toolContent}>
{item.rawInput && (
{item.input && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Input:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.rawInput, null, 2)}
{JSON.stringify(item.input, null, 2)}
</Text>
</View>
)}
{item.rawOutput && (
{item.output && (
<View style={stylesheet.section}>
<Text style={stylesheet.sectionTitle}>Output:</Text>
<Text style={stylesheet.code}>
{JSON.stringify(item.rawOutput, null, 2)}
{JSON.stringify(item.output, null, 2)}
</Text>
</View>
)}
{!item.rawInput && !item.rawOutput && (
{!item.input && !item.output && (
<Text style={stylesheet.emptyText}>
No details available
</Text>

View File

@@ -1101,8 +1101,8 @@ function PermissionRequestCard({
<ToolCallDetailsContent
detail={{
type: "unknown",
rawInput: request.input ?? null,
rawOutput: null,
input: request.input ?? null,
output: null,
}}
maxHeight={200}
/>

View File

@@ -1198,8 +1198,8 @@ export const ToolCall = memo(function ToolCall({
if (args !== undefined || result !== undefined) {
return {
type: "unknown",
rawInput: args ?? null,
rawOutput: result ?? null,
input: args ?? null,
output: result ?? null,
};
}
return undefined;
@@ -1208,8 +1208,8 @@ export const ToolCall = memo(function ToolCall({
const displayDetail =
effectiveDetail ?? {
type: "unknown",
rawInput: null,
rawOutput: null,
input: null,
output: null,
};
const displayModel = useMemo(
@@ -1235,8 +1235,8 @@ export const ToolCall = memo(function ToolCall({
Boolean(error) ||
(effectiveDetail
? effectiveDetail.type !== "unknown" ||
effectiveDetail.rawInput !== null ||
effectiveDetail.rawOutput !== null
effectiveDetail.input !== null ||
effectiveDetail.output !== null
: false);
const handleToggle = useCallback(() => {

View File

@@ -139,8 +139,8 @@ export function ToolCallDetailsContent({
);
} else if (detail?.type === "unknown") {
const sectionsFromTopLevel = [
{ title: "Input", value: detail.rawInput },
{ title: "Output", value: detail.rawOutput },
{ title: "Input", value: detail.input },
{ title: "Output", value: detail.output },
].filter((entry) => entry.value !== null && entry.value !== undefined);
for (const section of sectionsFromTopLevel) {

View File

@@ -41,8 +41,8 @@ export interface ToolCall {
title: string;
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other';
rawInput?: Record<string, unknown>;
rawOutput?: Record<string, unknown>;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
content?: unknown[];
locations?: unknown[];
}
@@ -53,8 +53,8 @@ export interface ToolCallUpdate {
title?: string | null;
status?: 'pending' | 'in_progress' | 'completed' | 'failed' | null;
toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other' | null;
rawInput?: Record<string, unknown>;
rawOutput?: Record<string, unknown>;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
content?: unknown[] | null;
locations?: unknown[] | null;
}
@@ -109,8 +109,8 @@ export interface MergedToolCall {
title: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other';
rawInput?: Record<string, unknown>;
rawOutput?: Record<string, unknown>;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
content?: unknown[];
locations?: unknown[];
startTimestamp: Date;
@@ -137,8 +137,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
title: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other';
rawInput?: Record<string, unknown>;
rawOutput?: Record<string, unknown>;
input?: Record<string, unknown>;
output?: Record<string, unknown>;
content?: unknown[];
locations?: unknown[];
startTimestamp: Date;
@@ -210,8 +210,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
title: update.title,
status: update.status || 'pending',
toolKind: update.toolKind,
rawInput: update.rawInput,
rawOutput: update.rawOutput,
input: update.input,
output: update.output,
content: update.content,
locations: update.locations,
startTimestamp: activity.timestamp,
@@ -223,8 +223,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
existing.title = update.title;
if (update.status) existing.status = update.status;
if (update.toolKind) existing.toolKind = update.toolKind;
if (update.rawInput) existing.rawInput = update.rawInput;
if (update.rawOutput) existing.rawOutput = update.rawOutput;
if (update.input) existing.input = update.input;
if (update.output) existing.output = update.output;
if (update.content) existing.content = update.content;
if (update.locations) existing.locations = update.locations;
existing.endTimestamp = activity.timestamp;
@@ -236,8 +236,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
if (update.title) existing.title = update.title;
if (update.status) existing.status = update.status;
if (update.toolKind) existing.toolKind = update.toolKind;
if (update.rawInput) existing.rawInput = { ...existing.rawInput, ...update.rawInput };
if (update.rawOutput) existing.rawOutput = { ...existing.rawOutput, ...update.rawOutput };
if (update.input) existing.input = { ...existing.input, ...update.input };
if (update.output) existing.output = { ...existing.output, ...update.output };
if (update.content) existing.content = update.content;
if (update.locations) existing.locations = update.locations;
existing.endTimestamp = activity.timestamp;
@@ -251,8 +251,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
title: update.title || 'Tool Call',
status: update.status || 'pending',
toolKind: update.toolKind || undefined,
rawInput: update.rawInput,
rawOutput: update.rawOutput,
input: update.input,
output: update.output,
content: update.content || undefined,
locations: update.locations || undefined,
startTimestamp: activity.timestamp,
@@ -280,8 +280,8 @@ export function groupActivities(activities: AgentActivity[]): Array<GroupedTextM
title: toolCall.title,
status: toolCall.status,
toolKind: toolCall.toolKind,
rawInput: toolCall.rawInput,
rawOutput: toolCall.rawOutput,
input: toolCall.input,
output: toolCall.output,
content: toolCall.content,
locations: toolCall.locations,
startTimestamp: toolCall.startTimestamp,

View File

@@ -28,8 +28,8 @@ const toolCallEvent = (): AgentStreamEventPayload => ({
status: "running",
detail: {
type: "unknown",
rawInput: { command: "echo hi" },
rawOutput: null,
input: { command: "echo hi" },
output: null,
},
error: null,
},

View File

@@ -215,8 +215,8 @@ function buildToolEvent({
}): AgentStreamEventPayload {
const canonicalDetail: ToolCallDetail = detail ?? {
type: "unknown",
rawInput: input,
rawOutput: output,
input: input,
output: output,
};
const baseItem = {

View File

@@ -42,8 +42,8 @@ function canonicalToolTimeline(params: {
}): AgentStreamEventPayload {
const detail: ToolCallDetail = params.detail ?? {
type: "unknown",
rawInput: params.input ?? null,
rawOutput: params.output ?? null,
input: params.input ?? null,
output: params.output ?? null,
};
const baseItem = {
@@ -150,8 +150,8 @@ describe("stream reducer canonical tool calls", () => {
assert.strictEqual(tools[0].payload.data.status, "completed");
assert.deepStrictEqual(tools[0].payload.data.detail, {
type: "unknown",
rawInput: { command: "pwd" },
rawOutput: {
input: { command: "pwd" },
output: {
output: "/tmp/repo\n",
exitCode: 0,
},
@@ -282,8 +282,8 @@ describe("stream reducer canonical tool calls", () => {
assert.ok(tool);
assert.deepStrictEqual(tool.payload.data.detail, {
type: "unknown",
rawInput: { path: "README.md" },
rawOutput: { content: "hello" },
input: { path: "README.md" },
output: { content: "hello" },
});
assert.strictEqual(tool.payload.data.status, "completed");
});

View File

@@ -353,20 +353,20 @@ function mergeToolCallDetail(existing: ToolCallDetail, incoming: ToolCallDetail)
if (existing.type === "unknown" && incoming.type === "unknown") {
return {
type: "unknown",
rawInput: mergeUnknownValue(existing.rawInput, incoming.rawInput),
rawOutput: mergeUnknownValue(existing.rawOutput, incoming.rawOutput),
input: mergeUnknownValue(existing.input, incoming.input),
output: mergeUnknownValue(existing.output, incoming.output),
};
}
if (existing.type === incoming.type) {
return { ...existing, ...incoming };
return { ...existing, ...incoming } as ToolCallDetail;
}
return incoming;
}
function rawInputFromDetail(detail: ToolCallDetail): unknown | null {
return detail.type === "unknown" ? detail.rawInput : null;
function inputFromUnknownDetail(detail: ToolCallDetail): unknown | null {
return detail.type === "unknown" ? detail.input : null;
}
function mergeAgentToolCallStatus(
@@ -552,7 +552,7 @@ export function reduceStreamUpdate(
// as Tasks when possible and otherwise hide it to avoid a stuck loading tool call.
const tasks = extractTaskEntriesFromToolCall(
item.name,
rawInputFromDetail(item.detail)
inputFromUnknownDetail(item.detail)
);
if (tasks) {
nextState = appendTodoList(
@@ -570,7 +570,7 @@ export function reduceStreamUpdate(
const tasks = extractTaskEntriesFromToolCall(
item.name,
rawInputFromDetail(item.detail)
inputFromUnknownDetail(item.detail)
);
if (tasks) {
nextState = appendTodoList(

View File

@@ -45,8 +45,8 @@ describe("tool-call-display", () => {
error: null,
detail: {
type: "unknown",
rawInput: null,
rawOutput: null,
input: null,
output: null,
},
metadata: {
subAgentActivity: "Running tests",
@@ -66,8 +66,8 @@ describe("tool-call-display", () => {
error: null,
detail: {
type: "unknown",
rawInput: null,
rawOutput: null,
input: null,
output: null,
},
});
@@ -83,8 +83,8 @@ describe("tool-call-display", () => {
error: null,
detail: {
type: "unknown",
rawInput: { command: "npm run test" },
rawOutput: null,
input: { command: "npm run test" },
output: null,
},
});
@@ -100,8 +100,8 @@ describe("tool-call-display", () => {
error: { message: "boom" },
detail: {
type: "unknown",
rawInput: { command: "false" },
rawOutput: null,
input: { command: "false" },
output: null,
},
});

View File

@@ -344,8 +344,8 @@ describe("DaemonClient", () => {
status: "inProgress",
detail: {
type: "unknown",
rawInput: { command: "pwd" },
rawOutput: null,
input: { command: "pwd" },
output: null,
},
},
},

View File

@@ -16,8 +16,8 @@ function toolCallItem(params: {
const detail =
params.detail ?? {
type: "unknown" as const,
rawInput: params.input ?? null,
rawOutput: params.output ?? null,
input: params.input ?? null,
output: params.output ?? null,
};
return {
type: "tool_call",

View File

@@ -71,18 +71,18 @@ function mergeToolDetail(existing: ToolCallDetail, incoming: ToolCallDetail): To
if (existing.type === "unknown" && incoming.type === "unknown") {
return {
type: "unknown",
rawInput: mergeUnknownValue(existing.rawInput, incoming.rawInput),
rawOutput: mergeUnknownValue(existing.rawOutput, incoming.rawOutput),
input: mergeUnknownValue(existing.input, incoming.input),
output: mergeUnknownValue(existing.output, incoming.output),
};
}
if (existing.type === incoming.type) {
return { ...existing, ...incoming };
return { ...existing, ...incoming } as ToolCallDetail;
}
return incoming;
}
function rawInputFromDetail(detail: ToolCallDetail): unknown {
return detail.type === "unknown" ? detail.rawInput : null;
function inputFromUnknownDetail(detail: ToolCallDetail): unknown {
return detail.type === "unknown" ? detail.input : null;
}
/**
@@ -203,7 +203,7 @@ export function curateAgentActivity(
break;
case "tool_call": {
flushBuffers(lines, buffers);
const inputJson = formatToolInputJson(rawInputFromDetail(item.detail));
const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail));
const display = buildToolCallDisplayModel({
name: item.name,
status: item.status,

View File

@@ -136,8 +136,8 @@ export type ToolCallDetail =
}
| {
type: "unknown";
rawInput: unknown | null;
rawOutput: unknown | null;
input: unknown | null;
output: unknown | null;
};
type ToolCallBase = {

View File

@@ -97,7 +97,7 @@ function extractToolCommand(detail: unknown): string | null {
return detail.command;
}
if (detail.type === "unknown") {
return extractCommandText(detail.rawInput);
return extractCommandText(detail.input);
}
return null;
}
@@ -245,7 +245,6 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
let hydrateStreamState: (updates: unknown[]) => unknown = () => {
throw new Error("hydrateStreamState not initialized");
};
let isAgentToolCallItem: (item: unknown) => boolean = () => false;
let agentMcpServer: AgentMcpServerHandle;
let restoreClaudeConfigDir: (() => void) | null = null;
const buildConfig = (
@@ -271,7 +270,6 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
beforeAll(async () => {
const stream = await import("../../../../../app/src/types/stream.js");
hydrateStreamState = stream.hydrateStreamState as typeof hydrateStreamState;
isAgentToolCallItem = stream.isAgentToolCallItem as typeof isAgentToolCallItem;
});
beforeAll(async () => {
agentMcpServer = await startAgentMcpServer();
@@ -450,8 +448,8 @@ async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
}
if (item.detail.type === "unknown") {
return (
rawContainsText(item.detail.rawInput, "tool-test.txt") ||
rawContainsText(item.detail.rawOutput, "tool-test.txt")
rawContainsText(item.detail.input, "tool-test.txt") ||
rawContainsText(item.detail.output, "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[] {
return state.filter(isAgentToolCallItem).map((item) => ({
key: buildToolSnapshotKey(item.payload.data, item.id),
data: item.payload.data,
}));
return state
.filter(
(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 {

View File

@@ -54,14 +54,18 @@ export function deriveClaudeToolDetail(
name: string,
input: unknown,
output: unknown
): ToolCallDetail | undefined {
): ToolCallDetail {
const parsed = ClaudeKnownToolDetailSchema.safeParse({
name,
input,
output,
});
if (!parsed.success) {
return undefined;
if (parsed.success && parsed.data) {
return parsed.data;
}
return parsed.data;
return {
type: "unknown",
input: input ?? null,
output: output ?? null,
};
}

View File

@@ -187,8 +187,8 @@ describe("claude tool-call mapper", () => {
expect(item.error).toBeNull();
expect(item.detail).toEqual({
type: "unknown",
rawInput: { foo: "bar" },
rawOutput: { ok: true },
input: { foo: "bar" },
output: { ok: true },
});
});
});

View File

@@ -1,6 +1,6 @@
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 { 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): {
callId: string;
name: string;
input: unknown | null;
output: unknown | null;
detail?: ToolCallDetail;
detail: Extract<ToolCallTimelineItem, { type: "tool_call" }>["detail"];
metadata?: Record<string, unknown>;
} {
const parsedParams = ClaudeMapperParamsSchema.parse(params);
@@ -51,9 +49,7 @@ function buildBase(params: MapperParams): {
return {
callId: coerceCallId(parsedParams.callId, parsedParams.name, input),
name: parsedParams.name,
input,
output,
...(detail ? { detail } : {}),
detail,
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
}

View File

@@ -48,35 +48,43 @@ function useTempCodexSessionDir(): () => void {
}
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") {
return item.detail.command.includes(commandFragment);
}
const unknownInput =
item.detail.type === "unknown" && typeof item.detail.rawInput === "object" && item.detail.rawInput
? (item.detail.rawInput as { command?: string })
item.detail.type === "unknown" && typeof item.detail.input === "object" && item.detail.input
? (item.detail.input as { command?: string | string[]; cmd?: string | string[] })
: 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);
}
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") {
return item.detail.filePath === fileName || (item.detail.unifiedDiff?.includes(fileName) ?? false);
}
const unknownInput =
item.detail.type === "unknown" && typeof item.detail.rawInput === "object" && item.detail.rawInput
? (item.detail.rawInput as { files?: Array<{ path?: string }> })
item.detail.type === "unknown" && typeof item.detail.input === "object" && item.detail.input
? (item.detail.input as { path?: string; file_path?: string; filePath?: string; files?: Array<{ path?: string }> })
: undefined;
const unknownOutput =
item.detail.type === "unknown" && typeof item.detail.rawOutput === "object" && item.detail.rawOutput
? (item.detail.rawOutput as { files?: Array<{ path?: string; patch?: string }>; diff?: string })
item.detail.type === "unknown" && typeof item.detail.output === "object" && item.detail.output
? (item.detail.output as { path?: string; file_path?: string; filePath?: string; files?: Array<{ path?: string; patch?: string }>; diff?: string })
: 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 inOutput = (unknownOutput?.files ?? []).some((file) => file?.path === 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(
@@ -363,7 +371,7 @@ describe("Codex app-server provider (integration)", () => {
if (call.name !== "paseo_test.echo") {
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);
if (!existing || call.status === "completed") {
distinctMcpCalls.set(key, call);
@@ -382,8 +390,9 @@ describe("Codex app-server provider (integration)", () => {
expect(toolNames.some((name) => name.toLowerCase().includes("shell"))).toBe(false);
// Hard assertion: roundtrip token must be present in the MCP tool I/O.
expect(JSON.stringify(mcpToolCall.input ?? {})).toContain(token);
expect(JSON.stringify(mcpToolCall.output ?? {})).toContain(`ECHO:${token}`);
const mcpDetail = mcpToolCall.detail.type === "unknown" ? mcpToolCall.detail : null;
expect(JSON.stringify(mcpDetail?.input ?? {})).toContain(token);
expect(JSON.stringify(mcpDetail?.output ?? {})).toContain(`ECHO:${token}`);
expect(result.finalText).toContain(`ECHO:${token}`);
} finally {
cleanup();
@@ -496,7 +505,7 @@ describe("Codex app-server provider (integration)", () => {
}
expect(sawPermission || timelineItems.length > 0).toBe(true);
expect(
timelineItems.some((item) => item.type === "tool_call" && item.name === "shell")
timelineItems.some((item) => hasShellCommand(item, "printf"))
).toBe(true);
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8")).toContain("ok");
@@ -629,12 +638,10 @@ describe("Codex app-server provider (integration)", () => {
expect(shellItem?.type).toBe("tool_call");
expect(patchItem?.type).toBe("tool_call");
if (shellItem?.type === "tool_call") {
expect(shellItem.name).toBe("shell");
expect(shellItem.input).toBeTruthy();
expect(hasShellCommand(shellItem, "printf")).toBe(true);
}
if (patchItem?.type === "tool_call") {
expect(patchItem.name).toBe("apply_patch");
expect(patchItem.input || patchItem.output).toBeTruthy();
expect(hasApplyPatchFile(patchItem, "patch.txt")).toBe(true);
}
} finally {
cleanup();
@@ -698,7 +705,6 @@ describe("Codex app-server provider (integration)", () => {
if (
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "shell" &&
hasShellCommand(event.item, "sleep 60")
) {
sawSleepCommand = true;

View File

@@ -134,15 +134,19 @@ export function deriveCodexToolDetail(params: {
input: unknown;
output: unknown;
cwd?: string | null;
}): ToolCallDetail | undefined {
}): ToolCallDetail {
const parsed = CodexKnownToolDetailSchema.safeParse({
name: params.name,
input: params.input,
output: params.output,
cwd: params.cwd ?? null,
});
if (!parsed.success) {
return undefined;
if (parsed.success && parsed.data) {
return parsed.data;
}
return parsed.data;
return {
type: "unknown",
input: params.input ?? null,
output: params.output ?? null,
};
}

View File

@@ -235,8 +235,8 @@ describe("codex tool-call mapper", () => {
expect(item.error).toBeNull();
expect(item.detail).toEqual({
type: "unknown",
rawInput: { foo: "bar" },
rawOutput: { ok: true },
input: { foo: "bar" },
output: { ok: true },
});
expect(item.callId).toBe("codex-call-4");
});

View File

@@ -140,10 +140,8 @@ function buildToolCall(params: {
callId: string;
name: string;
status: ToolCallTimelineItem["status"];
input: unknown | null;
output: unknown | null;
error: unknown | null;
detail?: ToolCallDetail;
detail: ToolCallDetail;
metadata?: Record<string, unknown>;
}): ToolCallTimelineItem {
if (params.status === "failed") {
@@ -152,10 +150,8 @@ function buildToolCall(params: {
callId: params.callId,
name: params.name,
status: "failed",
input: params.input,
output: params.output,
error: params.error ?? { message: "Tool call failed" },
...(params.detail ? { detail: params.detail } : {}),
detail: params.detail,
...(params.metadata ? { metadata: params.metadata } : {}),
};
}
@@ -165,10 +161,8 @@ function buildToolCall(params: {
callId: params.callId,
name: params.name,
status: params.status,
input: params.input,
output: params.output,
error: null,
...(params.detail ? { detail: params.detail } : {}),
detail: params.detail,
...(params.metadata ? { metadata: params.metadata } : {}),
};
}
@@ -221,7 +215,11 @@ function mapCommandExecutionItem(
...(item.aggregatedOutput ? { output: item.aggregatedOutput } : {}),
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
}
: undefined;
: {
type: "unknown" as const,
input,
output,
};
const name = "shell";
const callId = coerceCallId(item.id, name, input);
@@ -232,10 +230,8 @@ function mapCommandExecutionItem(
callId,
name,
status,
input,
output,
error,
...(detail ? { detail } : {}),
detail,
});
}
@@ -290,7 +286,11 @@ function mapFileChangeItem(
filePath: firstFile.path,
...(firstFile.diff !== undefined ? { unifiedDiff: truncateDiffText(firstFile.diff) } : {}),
}
: undefined;
: {
type: "unknown" as const,
input,
output,
};
const name = "apply_patch";
const callId = coerceCallId(item.id, name, input);
@@ -301,10 +301,8 @@ function mapFileChangeItem(
callId,
name,
status,
input,
output,
error,
...(detail ? { detail } : {}),
detail,
});
}
@@ -330,10 +328,8 @@ function mapMcpToolCallItem(
callId,
name,
status,
input,
output,
error,
...(detail ? { detail } : {}),
detail,
});
}
@@ -349,16 +345,18 @@ function mapWebSearchItem(item: z.infer<typeof CodexWebSearchItemSchema>): ToolC
type: "search" as const,
query: item.query,
}
: undefined;
: {
type: "unknown" as const,
input,
output,
};
return buildToolCall({
callId,
name,
status,
input,
output,
error,
...(detail ? { detail } : {}),
detail,
});
}
@@ -420,9 +418,7 @@ export function mapCodexRolloutToolCall(params: {
callId,
name: parsed.name,
status,
input,
output,
error,
...(detail ? { detail } : {}),
detail,
});
}

View File

@@ -43,14 +43,18 @@ export function deriveOpencodeToolDetail(
toolName: string,
input: unknown,
output: unknown
): ToolCallDetail | undefined {
): ToolCallDetail {
const parsed = OpencodeKnownToolDetailSchema.safeParse({
toolName,
input,
output,
});
if (!parsed.success) {
return undefined;
if (parsed.success && parsed.data) {
return parsed.data;
}
return parsed.data;
return {
type: "unknown",
input: input ?? null,
output: output ?? null,
};
}

View File

@@ -194,8 +194,8 @@ describe("opencode tool-call mapper", () => {
expect(item.error).toBeNull();
expect(item.detail).toEqual({
type: "unknown",
rawInput: { foo: "bar" },
rawOutput: { ok: true },
input: { foo: "bar" },
output: { ok: true },
});
});
});

View File

@@ -81,10 +81,8 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim
callId,
name: parsedParams.toolName,
status: "failed",
input,
output,
detail,
error: parsedParams.error ?? { message: "Tool call failed" },
...(detail ? { detail } : {}),
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
}
@@ -94,10 +92,8 @@ export function mapOpencodeToolCall(params: OpencodeToolCallParams): ToolCallTim
callId,
name: parsedParams.toolName,
status,
input,
output,
detail,
error: null,
...(detail ? { detail } : {}),
...(parsedParams.metadata ? { metadata: parsedParams.metadata } : {}),
};
}

View File

@@ -50,6 +50,36 @@ describe("serializeAgentStreamEvent", () => {
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", () => {
const event = {
type: "timeline",
@@ -61,8 +91,8 @@ describe("serializeAgentStreamEvent", () => {
status: "inProgress",
detail: {
type: "unknown",
rawInput: { command: "pwd" },
rawOutput: null,
input: { command: "pwd" },
output: null,
},
},
} satisfies unknown;

View File

@@ -2092,11 +2092,11 @@ export class Session {
status: "running",
detail: {
type: "unknown",
rawInput: {
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
rawOutput: null,
output: null,
},
error: null,
});
@@ -2117,11 +2117,11 @@ export class Session {
status: "completed",
detail: {
type: "unknown",
rawInput: {
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
rawOutput: {
output: {
worktreePath: worktree.worktreePath,
commands: results.map((result) => ({
command: result.command,
@@ -2145,11 +2145,11 @@ export class Session {
status: "failed",
detail: {
type: "unknown",
rawInput: {
input: {
worktreePath: worktree.worktreePath,
branchName: worktree.branchName,
},
rawOutput: {
output: {
worktreePath: worktree.worktreePath,
commands: results.map((result) => ({
command: result.command,

View File

@@ -248,8 +248,8 @@ class FakeAgentSession implements AgentSession {
status: "running",
detail: {
type: "unknown",
rawInput: tool.input ?? null,
rawOutput: null,
input: tool.input ?? null,
output: null,
},
error: null,
},
@@ -332,8 +332,8 @@ class FakeAgentSession implements AgentSession {
status: "completed",
detail: {
type: "unknown",
rawInput: tool.input ?? null,
rawOutput: toolOutput ?? { ok: true },
input: tool.input ?? null,
output: toolOutput ?? { ok: true },
},
error: null,
},

View File

@@ -64,8 +64,17 @@ describe("shared messages tool_call schema", () => {
error: null,
});
const withTopLevelInputOutput = AgentTimelineItemPayloadSchema.safeParse({
...canonicalBase(),
status: "running",
error: null,
input: { command: "pwd" },
output: { exitCode: 0 },
});
expect(missingCallId.success).toBe(false);
expect(unknownStatus.success).toBe(false);
expect(withTopLevelInputOutput.success).toBe(false);
});
it("rejects legacy status/error combinations without normalization", () => {

View File

@@ -190,8 +190,8 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUn
}),
z.object({
type: z.literal("unknown"),
rawInput: UnknownValueSchema,
rawOutput: UnknownValueSchema,
input: UnknownValueSchema,
output: UnknownValueSchema,
}),
]);
@@ -201,7 +201,7 @@ const ToolCallBasePayloadSchema = z.object({
name: z.string(),
detail: ToolCallDetailPayloadSchema,
metadata: z.record(z.unknown()).optional(),
});
}).strict();
const ToolCallRunningPayloadSchema = ToolCallBasePayloadSchema.extend({
status: z.literal("running"),

View File

@@ -28,8 +28,8 @@ describe("shared tool-call display mapping", () => {
error: null,
detail: {
type: "unknown",
rawInput: { command: "npm test" },
rawOutput: null,
input: { command: "npm test" },
output: null,
},
});
@@ -45,8 +45,8 @@ describe("shared tool-call display mapping", () => {
error: null,
detail: {
type: "unknown",
rawInput: null,
rawOutput: null,
input: null,
output: null,
},
metadata: {
subAgentActivity: "Running tests",
@@ -66,8 +66,8 @@ describe("shared tool-call display mapping", () => {
error: { message: "boom" },
detail: {
type: "unknown",
rawInput: null,
rawOutput: null,
input: null,
output: null,
},
});