Ensure Codex tool call IDs unique
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user