diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index de83deca38c..d8293645bc0 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -5471,6 +5471,111 @@ describe("runCodexAppServerAttempt", () => { ).toBe(false); }); + it("arms completion idle watch after non-assistant rawResponseItem/completed with no active items", async () => { + let notify: (notification: CodexServerNotification) => Promise = async () => undefined; + let handleRequest: + | ((request: { id: string; method: string; params?: unknown }) => Promise) + | undefined; + const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => 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 {}; + }); + setCodexAppServerClientFactoryForTest( + async () => + ({ + request, + addNotificationHandler: (handler: typeof notify) => { + notify = handler; + return () => undefined; + }, + addRequestHandler: ( + handler: (request: { + id: string; + method: string; + params?: unknown; + }) => Promise, + ) => { + handleRequest = handler; + return () => undefined; + }, + }) as never, + ); + const params = createParams( + path.join(tempDir, "session.jsonl"), + path.join(tempDir, "workspace"), + ); + params.timeoutMs = 60_000; + + const run = runCodexAppServerAttempt(params, { + turnCompletionIdleTimeoutMs: 5, + turnAssistantCompletionIdleTimeoutMs: 500, + turnTerminalIdleTimeoutMs: 500, + }); + await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait); + + const toolResult = (await handleRequest?.({ + id: "request-tool-1", + method: "item/tool/call", + params: { + threadId: "thread-1", + turnId: "turn-1", + callId: "call-1", + namespace: null, + tool: "message", + arguments: { action: "send", text: "already sent" }, + }, + })) as { success?: boolean }; + expect(toolResult.success).toBe(false); + // Send a rawResponseItem/completed with type "reasoning" — this does NOT + // qualify as postToolRawAssistantCompletionNeedsTerminalGuard (which + // requires type=message + role=assistant + text preview). Before the fix, + // this would disarm the completion idle watch via the catch-all disarm + // block, leaving only the 30-minute terminal timeout. After the fix, + // rawResponseItemCompletedWithNoActiveItems keeps the 60s (here 5ms) + // completion idle watch armed. + await notify({ + method: "rawResponseItem/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + item: { + type: "reasoning", + id: "raw-reasoning-1", + }, + }, + }); + + const result = await run; + expect(result.aborted).toBe(true); + expect(result.timedOut).toBe(true); + expect(result.promptError).toBe( + "codex app-server turn idle timed out waiting for turn/completed", + ); + const completionWarnCall = warn.mock.calls.find( + ([message]) => message === "codex app-server turn idle timed out waiting for completion", + ); + expect(completionWarnCall).toBeDefined(); + const completionWarnData = completionWarnCall?.[1] as + | { lastActivityReason?: string; timeoutMs?: number } + | undefined; + expect(completionWarnData?.timeoutMs).toBe(5); + expect(completionWarnData?.lastActivityReason).toBe("notification:rawResponseItem/completed"); + // The terminal idle watch (500ms) should NOT have fired — the shorter + // completion idle watch (5ms) should catch the stall first. + expect( + warn.mock.calls.some( + ([message]) => + message === "codex app-server turn idle timed out waiting for terminal event", + ), + ).toBe(false); + }); + it("releases the session when Codex accepts a turn but never sends progress", async () => { const harness = createStartedThreadHarness(); const params = createParams(