diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 9986357cf4..a73ff0c490 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -227,6 +227,67 @@ function deferred() { return { promise, resolve }; } +describe("Codex file-change approvals", () => { + test("file-change approval includes the matching started patch", async () => { + const appServer = createFakeCodexAppServer(); + const { session } = await startPublicSteeringSession(appServer); + try { + for (const [id, filePath, diff] of [ + ["file-alpha", "/workspace/project/alpha.txt", "@@ -1 +1 @@\n-blue\n+green\n"], + ["file-beta", "/workspace/project/beta.txt", "@@ -1 +1 @@\n-small\n+large\n"], + ]) { + const started = waitForTimelineToolCall(session, id); + appServer.child.stdout.write( + `${JSON.stringify({ + method: "item/started", + params: { + threadId: "thread-1", + turnId: "native-A", + item: { + type: "fileChange", + id, + status: "inProgress", + changes: [{ path: filePath, kind: { type: "update", move_path: null }, diff }], + }, + }, + })}\n`, + ); + await started; + } + const permission = waitForNextPermission(session); + appServer.requestFileChangeApproval({ + itemId: "file-alpha", + threadId: "thread-1", + turnId: "native-A", + reason: "Apply the patch", + }); + + expect((await permission).request).toEqual({ + id: "permission-file-alpha", + provider: "codex", + name: "CodexFileChange", + kind: "tool", + title: "Apply file changes", + description: "Apply the patch", + detail: { + type: "edit", + filePath: "alpha.txt", + unifiedDiff: "@@ -1 +1 @@\n-blue\n+green\n", + }, + metadata: { itemId: "file-alpha", threadId: "thread-1", turnId: "native-A" }, + }); + + await session.respondToPermission("permission-file-alpha", { behavior: "allow" }); + await expect(appServer.waitForApprovalDecision("file-alpha")).resolves.toEqual({ + decision: "accept", + }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); +}); + describe("Codex active-turn steering admission", () => { test("steers an accepted turn whose native id arrives after startTurn returns", async () => { const appServer = createFakeCodexAppServer({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index cc667b2494..86f7eb073b 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3483,6 +3483,7 @@ export class CodexAppServerAgentSession implements AgentSession { private pendingReasoning = new Map(); private pendingCommandOutputDeltas = new Map(); private pendingFileChangeOutputDeltas = new Map(); + private pendingToolCallDetails = new Map(); private pendingAssistantMessageBoundary = false; private terminalCommandByProcessId = new Map(); // The root thread's still-running command process ids, keyed by the native turn that @@ -5251,6 +5252,7 @@ export class CodexAppServerAgentSession implements AgentSession { async close(): Promise { this.closed = true; this.clearPendingPermissions(); + this.pendingToolCallDetails.clear(); this.pendingSubAgentNotificationsByThreadId.clear(); this.subscribers.clear(); this.startupEvents = null; @@ -6416,6 +6418,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingReasoning.clear(); this.pendingCommandOutputDeltas.clear(); this.pendingFileChangeOutputDeltas.clear(); + this.pendingToolCallDetails.clear(); this.pendingAssistantMessageBoundary = false; this.warnedIncompleteEditToolCallIds.clear(); this.pendingRootCompactionItemIds.clear(); @@ -6789,6 +6792,9 @@ export class CodexAppServerAgentSession implements AgentSession { if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } + if (parsed.item.id) { + this.pendingToolCallDetails.delete(parsed.item.id); + } this.receiveAsyncQuestion(parsed.threadId, parsed.item); if (this.isUserMessageItem(parsed.item)) { await this.handleUserMessageItem(parsed); @@ -6984,6 +6990,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (!timelineItem || timelineItem.type !== "tool_call") { return; } + this.pendingToolCallDetails.set(timelineItem.callId, timelineItem.detail); const registeredChildThreadIds = this.registerSubAgentToolCall({ timelineItem, rawItem: parsed.item, @@ -7270,7 +7277,7 @@ export class CodexAppServerAgentSession implements AgentSession { kind: "tool", title: "Apply file changes", description: parsed.reason ?? undefined, - detail: { + detail: this.pendingToolCallDetails.get(parsed.itemId) ?? { type: "unknown", input: { reason: parsed.reason ?? null,