Ensure Codex tool call IDs unique

This commit is contained in:
Mohamed Boudra
2025-12-02 04:00:30 +00:00
parent b36f870ee9
commit 9079695f46
2 changed files with 98 additions and 4 deletions
@@ -226,6 +226,85 @@ describe("CodexAgentClient (SDK integration)", () => {
180_000
);
test(
"emits distinct non-empty call ids for sequential Codex tool calls",
async () => {
const cwd = tmpCwd();
const restoreSessionDir = useTempCodexSessionDir();
const client = new CodexAgentClient();
const config: AgentSessionConfig = { provider: "codex", cwd, modeId: "full-access" };
let session: Awaited<ReturnType<typeof client.createSession>> | null = null;
try {
session = await client.createSession(config);
log("Streaming Codex run for tool call id uniqueness test");
const prompt = [
"1. Run the command `pwd` using your shell tool and wait for it to finish.",
"2. Use apply_patch (not the shell) to create a file codex-call-id.txt containing only the text 'ok'.",
"3. After the patch completes, run `cat codex-call-id.txt` with the shell tool to read the file back.",
"4. Respond DONE and stop.",
].join("\n");
const stream = session.stream(prompt);
const observedToolCalls: ToolCallItem[] = [];
for await (const event of stream) {
if (
event.type === "timeline" &&
event.provider === "codex" &&
event.item.type === "tool_call" &&
event.item.server !== "permission"
) {
observedToolCalls.push(event.item);
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
break;
}
}
expect(observedToolCalls.length).toBeGreaterThanOrEqual(2);
for (const toolCall of observedToolCalls) {
expect(typeof toolCall.callId).toBe("string");
expect((toolCall.callId ?? "").trim().length).toBeGreaterThan(0);
}
const uniqueIds = new Set(observedToolCalls.map((item) => item.callId));
expect(uniqueIds.size).toBeGreaterThanOrEqual(2);
const callIdCounts = new Map<string, number>();
const callIdStatuses = new Map<string, Set<string>>();
for (const toolCall of observedToolCalls) {
const callId = toolCall.callId!;
callIdCounts.set(callId, (callIdCounts.get(callId) ?? 0) + 1);
if (!callIdStatuses.has(callId)) {
callIdStatuses.set(callId, new Set());
}
if (typeof toolCall.status === "string") {
callIdStatuses.get(callId)!.add(toolCall.status);
}
}
const commandCallGroups = Array.from(callIdCounts.entries())
.filter(([callId]) => observedToolCalls.some((call) => call.callId === callId && call.server === "command"));
const multiEventCommandIds = commandCallGroups.filter(([, count]) => count >= 2).map(([callId]) => callId);
expect(multiEventCommandIds.length).toBeGreaterThan(0);
const hasCompletedCommandLifecycle = multiEventCommandIds.some((callId) => {
const statuses = callIdStatuses.get(callId);
return statuses?.has("completed") && (statuses?.size ?? 0) >= 2;
});
expect(hasCompletedCommandLifecycle).toBe(true);
} finally {
await session?.close();
rmSync(cwd, { recursive: true, force: true });
restoreSessionDir();
}
},
180_000
);
test(
"hydrates persisted shell_command tool calls with completed status",
async () => {
@@ -101,6 +101,7 @@ const MAX_ROLLOUT_SEARCH_DEPTH = 5;
const PERSISTED_TIMELINE_LIMIT = 20;
const SHELL_FUNCTION_NAMES = new Set(["shell", "shell_command"]);
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
type ThreadItemWithOptionalCallId = ThreadItem & { call_id?: string };
type ExecPermissionRequestPayload = {
call_id?: string;
@@ -126,6 +127,19 @@ function createToolCallTimelineItem(
return { type: "tool_call", ...data };
}
function normalizeCallId(value?: string | null): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length ? trimmed : undefined;
}
function extractThreadItemCallId(item: ThreadItem): string | undefined {
const withCallId = item as ThreadItemWithOptionalCallId;
return normalizeCallId(withCallId.call_id) ?? normalizeCallId((item as { id?: string }).id);
}
function buildCommandDisplayName(command?: unknown): string {
if (typeof command === "string") {
const trimmed = command.trim();
@@ -811,6 +825,7 @@ class CodexAgentSession implements AgentSession {
}
private threadItemToTimeline(item: ThreadItem): AgentTimelineItem | null {
const callId = extractThreadItemCallId(item);
switch (item.type) {
case "agent_message":
return { type: "assistant_message", text: item.text };
@@ -838,7 +853,7 @@ class CodexAgentSession implements AgentSession {
server: "command",
tool: "shell",
status: item.status,
callId: (item as any)?.call_id,
callId,
displayName: buildCommandDisplayName(item.command),
kind: "execute",
input: { command: item.command, cwd },
@@ -852,7 +867,7 @@ class CodexAgentSession implements AgentSession {
server: "file_change",
tool: "apply_patch",
status: "completed",
callId: (item as any)?.call_id,
callId,
displayName: buildFileChangeSummary(files),
kind: "edit",
output: { files },
@@ -863,7 +878,7 @@ class CodexAgentSession implements AgentSession {
server: item.server,
tool: item.tool,
status: item.status,
callId: (item as any)?.call_id,
callId,
displayName: `${item.server}.${item.tool}`,
kind: "tool",
input: (item as any)?.input,
@@ -874,7 +889,7 @@ class CodexAgentSession implements AgentSession {
server: "web_search",
tool: "web_search",
status: (item as any)?.status ?? "completed",
callId: (item as any)?.call_id,
callId,
displayName: item.query ? `Web search: ${item.query}` : "Web search",
kind: "search",
input: { query: item.query },