Ensure Codex tool call IDs unique
This commit is contained in:
@@ -226,6 +226,85 @@ describe("CodexAgentClient (SDK integration)", () => {
|
|||||||
180_000
|
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(
|
test(
|
||||||
"hydrates persisted shell_command tool calls with completed status",
|
"hydrates persisted shell_command tool calls with completed status",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ const MAX_ROLLOUT_SEARCH_DEPTH = 5;
|
|||||||
const PERSISTED_TIMELINE_LIMIT = 20;
|
const PERSISTED_TIMELINE_LIMIT = 20;
|
||||||
const SHELL_FUNCTION_NAMES = new Set(["shell", "shell_command"]);
|
const SHELL_FUNCTION_NAMES = new Set(["shell", "shell_command"]);
|
||||||
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
type ToolCallTimelineItem = Extract<AgentTimelineItem, { type: "tool_call" }>;
|
||||||
|
type ThreadItemWithOptionalCallId = ThreadItem & { call_id?: string };
|
||||||
|
|
||||||
type ExecPermissionRequestPayload = {
|
type ExecPermissionRequestPayload = {
|
||||||
call_id?: string;
|
call_id?: string;
|
||||||
@@ -126,6 +127,19 @@ function createToolCallTimelineItem(
|
|||||||
return { type: "tool_call", ...data };
|
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 {
|
function buildCommandDisplayName(command?: unknown): string {
|
||||||
if (typeof command === "string") {
|
if (typeof command === "string") {
|
||||||
const trimmed = command.trim();
|
const trimmed = command.trim();
|
||||||
@@ -811,6 +825,7 @@ class CodexAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private threadItemToTimeline(item: ThreadItem): AgentTimelineItem | null {
|
private threadItemToTimeline(item: ThreadItem): AgentTimelineItem | null {
|
||||||
|
const callId = extractThreadItemCallId(item);
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case "agent_message":
|
case "agent_message":
|
||||||
return { type: "assistant_message", text: item.text };
|
return { type: "assistant_message", text: item.text };
|
||||||
@@ -838,7 +853,7 @@ class CodexAgentSession implements AgentSession {
|
|||||||
server: "command",
|
server: "command",
|
||||||
tool: "shell",
|
tool: "shell",
|
||||||
status: item.status,
|
status: item.status,
|
||||||
callId: (item as any)?.call_id,
|
callId,
|
||||||
displayName: buildCommandDisplayName(item.command),
|
displayName: buildCommandDisplayName(item.command),
|
||||||
kind: "execute",
|
kind: "execute",
|
||||||
input: { command: item.command, cwd },
|
input: { command: item.command, cwd },
|
||||||
@@ -852,7 +867,7 @@ class CodexAgentSession implements AgentSession {
|
|||||||
server: "file_change",
|
server: "file_change",
|
||||||
tool: "apply_patch",
|
tool: "apply_patch",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
callId: (item as any)?.call_id,
|
callId,
|
||||||
displayName: buildFileChangeSummary(files),
|
displayName: buildFileChangeSummary(files),
|
||||||
kind: "edit",
|
kind: "edit",
|
||||||
output: { files },
|
output: { files },
|
||||||
@@ -863,7 +878,7 @@ class CodexAgentSession implements AgentSession {
|
|||||||
server: item.server,
|
server: item.server,
|
||||||
tool: item.tool,
|
tool: item.tool,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
callId: (item as any)?.call_id,
|
callId,
|
||||||
displayName: `${item.server}.${item.tool}`,
|
displayName: `${item.server}.${item.tool}`,
|
||||||
kind: "tool",
|
kind: "tool",
|
||||||
input: (item as any)?.input,
|
input: (item as any)?.input,
|
||||||
@@ -874,7 +889,7 @@ class CodexAgentSession implements AgentSession {
|
|||||||
server: "web_search",
|
server: "web_search",
|
||||||
tool: "web_search",
|
tool: "web_search",
|
||||||
status: (item as any)?.status ?? "completed",
|
status: (item as any)?.status ?? "completed",
|
||||||
callId: (item as any)?.call_id,
|
callId,
|
||||||
displayName: item.query ? `Web search: ${item.query}` : "Web search",
|
displayName: item.query ? `Web search: ${item.query}` : "Web search",
|
||||||
kind: "search",
|
kind: "search",
|
||||||
input: { query: item.query },
|
input: { query: item.query },
|
||||||
|
|||||||
Reference in New Issue
Block a user