diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 0bdbc15812d..d7aa4690d8b 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -1709,6 +1709,168 @@ describe("runCodexAppServerAttempt", () => { ); }); + it("releases the session when a real completed agent message omits text", async () => { + let notify: (notification: CodexServerNotification) => Promise = async () => undefined; + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-1"); + } + if (method === "turn/start") { + return turnStartResult("turn-1", "inProgress"); + } + return {}; + }); + __testing.setCodexAppServerClientFactoryForTests( + async () => + ({ + request, + addNotificationHandler: (handler: typeof notify) => { + notify = handler; + return () => undefined; + }, + addRequestHandler: () => () => undefined, + }) as never, + ); + const params = createParams( + path.join(tempDir, "session.jsonl"), + path.join(tempDir, "workspace"), + ); + params.timeoutMs = 200; + + const run = runCodexAppServerAttempt(params, { + turnAssistantCompletionIdleTimeoutMs: 5, + }); + await vi.waitFor( + () => + expect(request).toHaveBeenCalledWith("turn/start", expect.anything(), expect.anything()), + { interval: 1 }, + ); + await notify({ + method: "item/agentMessage/delta", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "msg-final-1", + delta: "Done.", + }, + }); + await notify({ + method: "item/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "agentMessage", + id: "msg-final-1", + }, + }, + }); + + await expect(run).resolves.toMatchObject({ + aborted: false, + timedOut: false, + promptError: null, + assistantTexts: ["Done."], + }); + await vi.waitFor( + () => + expect(request).toHaveBeenCalledWith( + "turn/interrupt", + { + threadId: "thread-1", + turnId: "turn-1", + }, + { timeoutMs: 5_000 }, + ), + { interval: 1 }, + ); + }); + + it("keeps the completed assistant release armed across bookkeeping notifications", async () => { + let notify: (notification: CodexServerNotification) => Promise = async () => undefined; + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-1"); + } + if (method === "turn/start") { + return turnStartResult("turn-1", "inProgress"); + } + return {}; + }); + __testing.setCodexAppServerClientFactoryForTests( + async () => + ({ + request, + addNotificationHandler: (handler: typeof notify) => { + notify = handler; + return () => undefined; + }, + addRequestHandler: () => () => undefined, + }) as never, + ); + const params = createParams( + path.join(tempDir, "session.jsonl"), + path.join(tempDir, "workspace"), + ); + params.timeoutMs = 200; + + const run = runCodexAppServerAttempt(params, { + turnAssistantCompletionIdleTimeoutMs: 5, + }); + await vi.waitFor( + () => + expect(request).toHaveBeenCalledWith("turn/start", expect.anything(), expect.anything()), + { interval: 1 }, + ); + await notify({ + method: "item/agentMessage/delta", + params: { + threadId: "thread-1", + turnId: "turn-1", + itemId: "msg-final-1", + delta: "Done.", + }, + }); + await notify({ + method: "item/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "agentMessage", + id: "msg-final-1", + }, + }, + }); + await notify({ + method: "turn/plan/updated", + params: { + threadId: "thread-1", + turnId: "turn-1", + plan: [], + }, + }); + + await expect(run).resolves.toMatchObject({ + aborted: false, + timedOut: false, + promptError: null, + assistantTexts: ["Done."], + }); + await vi.waitFor( + () => + expect(request).toHaveBeenCalledWith( + "turn/interrupt", + { + threadId: "thread-1", + turnId: "turn-1", + }, + { timeoutMs: 5_000 }, + ), + { interval: 1 }, + ); + }); + it("does not release the session after only a raw assistant response item", async () => { let notify: (notification: CodexServerNotification) => Promise = async () => undefined; const request = vi.fn(async (method: string) => { diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 9884f9abe35..64de5a185a3 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1258,7 +1258,10 @@ export async function runCodexAppServerAttempt( armTurnAssistantCompletionIdleWatch(describeNotificationActivity(notification)); } else if (unblockedAssistantCompletionRelease) { armTurnAssistantCompletionIdleWatch(describeNotificationActivity(notification)); - } else if (isCurrentTurnNotification) { + } else if ( + isCurrentTurnNotification && + shouldDisarmAssistantCompletionIdleWatch(notification) + ) { disarmTurnAssistantCompletionIdleWatch(); } if ( @@ -2635,7 +2638,20 @@ function isCompletedAssistantNotification(notification: CodexServerNotification) return false; } const item = isJsonObject(notification.params.item) ? notification.params.item : undefined; - return Boolean(item && readString(item, "type") === "agentMessage" && readString(item, "text")); + return Boolean(item && readString(item, "type") === "agentMessage"); +} + +function shouldDisarmAssistantCompletionIdleWatch(notification: CodexServerNotification): boolean { + if (!isJsonObject(notification.params)) { + return false; + } + if (notification.method === "item/started") { + return true; + } + if (notification.method === "item/agentMessage/delta") { + return true; + } + return false; } function readNotificationItemId(notification: CodexServerNotification): string | undefined {