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
@@ -344,8 +344,8 @@ describe("DaemonClient", () => {
status: "inProgress",
detail: {
type: "unknown",
rawInput: { command: "pwd" },
rawOutput: null,
input: { command: "pwd" },
output: null,
},
},
},
@@ -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",
@@ -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,
@@ -136,8 +136,8 @@ export type ToolCallDetail =
}
| {
type: "unknown";
rawInput: unknown | null;
rawOutput: unknown | null;
input: unknown | null;
output: unknown | null;
};
type ToolCallBase = {
@@ -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 {
@@ -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,
};
}
@@ -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 },
});
});
});
@@ -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 } : {}),
};
}
@@ -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;
@@ -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,
};
}
@@ -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");
});
@@ -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,
});
}
@@ -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,
};
}
@@ -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 },
});
});
});
@@ -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 } : {}),
};
}
+32 -2
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;
+6 -6
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,
@@ -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,
},
@@ -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", () => {
+3 -3
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"),
@@ -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,
},
});