diff --git a/packages/server/src/server/agent/providers/codex-agent.test.ts b/packages/server/src/server/agent/providers/codex-agent.test.ts index a4d8654a5..b6a234835 100644 --- a/packages/server/src/server/agent/providers/codex-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-agent.test.ts @@ -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> | 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(); + const callIdStatuses = new Map>(); + 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 () => { diff --git a/packages/server/src/server/agent/providers/codex-agent.ts b/packages/server/src/server/agent/providers/codex-agent.ts index 5a50fdedc..43aedd1bc 100644 --- a/packages/server/src/server/agent/providers/codex-agent.ts +++ b/packages/server/src/server/agent/providers/codex-agent.ts @@ -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; +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 },