From 8523e0930ee2c8ad36f1442e58edebe80cef25d7 Mon Sep 17 00:00:00 2001 From: Krzysztof Probola <32790662+rozmiarD@users.noreply.github.com> Date: Fri, 22 May 2026 12:22:20 +0200 Subject: [PATCH] fix(codex): keep interrupted turns visible-answer eligible (#84494) * fix(codex): keep interrupted turns visible-answer eligible * docs(changelog): note codex interrupted recovery --------- Co-authored-by: Vincent Koc --- CHANGELOG.md | 1 + .../src/app-server/event-projector.test.ts | 79 ++++++++++++++++++- .../codex/src/app-server/event-projector.ts | 6 +- .../codex/src/app-server/run-attempt.test.ts | 27 +++++++ .../run.incomplete-turn.test.ts | 75 ++++++++++++++++++ 5 files changed, 182 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ecc028ed7b..a7a42e1dbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai - Agents/config: keep non-Google provider model refs from being rewritten by Google Gemini preview-id normalization. (#84762) Thanks @zhangguiping-xydt. - Installer: require a real controlling terminal before launching onboarding so headless `curl | bash` installs finish cleanly after installing the CLI. - Agents/Codex: promote a completed final assistant response when a prompt timeout races Codex app-server completion instead of returning an empty timeout envelope. Refs #84516. +- Codex app-server: keep interrupted turn statuses from being treated as OpenClaw aborts by themselves, so tool-only turns remain eligible for no-visible-answer recovery. Fixes #84492. - Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has. - Control UI/Web Push: use `https://openclaw.ai` as the generated default VAPID subject instead of the old localhost mailbox so iOS PWA push setup uses an Apple-acceptable subject when `OPENCLAW_VAPID_SUBJECT` is unset. Fixes #83134. (#83317) Thanks @IWhatsskill. - Control UI: distinguish inherited thinking-off settings from explicit Off selections so the thinking selector no longer shows two identical Off rows. (#85223) Thanks @amknight. diff --git a/extensions/codex/src/app-server/event-projector.test.ts b/extensions/codex/src/app-server/event-projector.test.ts index 92c4ba30d0e..f362c65b6f8 100644 --- a/extensions/codex/src/app-server/event-projector.test.ts +++ b/extensions/codex/src/app-server/event-projector.test.ts @@ -252,11 +252,15 @@ function rateLimitsUpdated(resetsAt: number): ProjectorNotification { } function turnCompleted(items: unknown[] = []): ProjectorNotification { + return turnWithStatus("completed", items); +} + +function turnWithStatus(status: string, items: unknown[] = []): ProjectorNotification { return { method: "turn/completed", params: { threadId: THREAD_ID, - turn: { id: TURN_ID, status: "completed", items }, + turn: { id: TURN_ID, status, items }, }, } as ProjectorNotification; } @@ -588,6 +592,79 @@ describe("CodexAppServerEventProjector", () => { expect(result.lastAssistant).toBeUndefined(); }); + it("does not treat app-server interrupted status as a user cancellation by itself", async () => { + const projector = await createProjector(); + + await projector.handleNotification(turnWithStatus("interrupted")); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expect(result.aborted).toBe(false); + expect(result.externalAbort).toBe(false); + expect(result.timedOut).toBe(false); + expect(result.promptError).toBeNull(); + expect(result.assistantTexts).toEqual([]); + expect(result.lastAssistant).toBeUndefined(); + }); + + it("keeps sparse successful bash output eligible for the no-visible-answer guard", async () => { + const projector = await createProjector(); + + await projector.handleNotification( + turnWithStatus("interrupted", [ + { + type: "commandExecution", + id: "cmd-empty-output", + command: + "ps -eo pid,ppid,stat,cmd | rg 'venv-roadmap|pytest|run_security_contract_validation|validate_public_install|git push|apply_patch' || true", + cwd: "/workspace", + processId: null, + source: "agent", + status: "completed", + commandActions: [], + aggregatedOutput: "", + exitCode: 0, + durationMs: 42, + }, + ]), + ); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + + expect(result.aborted).toBe(false); + expect(result.assistantTexts).toEqual([]); + expect(result.toolMetas).toEqual([ + expect.objectContaining({ toolName: "bash", meta: expect.stringContaining("workspace") }), + ]); + }); + + it("keeps explicit cancellation marked aborted for interrupted tool-only turns", async () => { + const projector = await createProjector(); + projector.markAborted(); + + await projector.handleNotification( + turnWithStatus("interrupted", [ + { + type: "commandExecution", + id: "cmd-cancelled", + command: "/bin/bash -lc true", + cwd: "/workspace", + processId: null, + source: "agent", + status: "completed", + commandActions: [], + aggregatedOutput: "", + exitCode: 0, + durationMs: 12, + }, + ]), + ); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + expect(result.aborted).toBe(true); + expect(result.assistantTexts).toEqual([]); + }); + it("does not fail a completed reply after a retryable app-server error notification", async () => { const projector = await createProjector(); diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index 185a39b3b4c..e05c338ee34 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -313,7 +313,6 @@ export class CodexAppServerEventProjector { messagesSnapshot.push(attachCodexMirrorIdentity(lastAssistant, `${turnId}:assistant`)); } const turnFailed = this.completedTurn?.status === "failed"; - const turnInterrupted = this.completedTurn?.status === "interrupted"; const promptError = this.promptError ?? (turnFailed ? (this.completedTurn?.error?.message ?? "codex app-server turn failed") : null); @@ -331,7 +330,7 @@ export class CodexAppServerEventProjector { this.sideEffectingToolItemIds.size > 0 || this.sideEffectingDynamicToolCallIds.size > 0; return { - aborted: this.aborted || turnInterrupted, + aborted: this.aborted, externalAbort: false, timedOut: false, idleTimedOut: false, @@ -660,9 +659,6 @@ export class CodexAppServerEventProjector { return; } this.completedTurn = turn; - if (turn.status === "interrupted") { - this.aborted = true; - } if (turn.status === "failed") { this.promptError = formatCodexUsageLimitErrorMessage({ diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index be92797aead..0aa50249c33 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -7811,6 +7811,33 @@ describe("runCodexAppServerAttempt", () => { expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false); }); + it("keeps upstream cancellation aborted when Codex completes the turn as interrupted", async () => { + const harness = createStartedThreadHarness(); + const abortController = new AbortController(); + const params = createParams( + path.join(tempDir, "session.jsonl"), + path.join(tempDir, "workspace"), + ); + params.abortSignal = abortController.signal; + const run = runCodexAppServerAttempt(params, { turnTerminalIdleTimeoutMs: 60_000 }); + + await harness.waitForMethod("turn/start"); + abortController.abort("user_cancelled"); + await harness.notify({ + method: "turn/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + turn: { id: "turn-1", status: "interrupted" }, + }, + }); + + const result = await run; + expect(result.aborted).toBe(true); + expect(result.timedOut).toBe(false); + expect(result.promptError).toBeNull(); + }); + it("releases completion when the app-server client closes during an active turn", async () => { const harness = createStartedThreadHarness(); const run = runCodexAppServerAttempt( diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts index 2c2ba8f09c8..8df05e73e94 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -1235,6 +1235,45 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { ).toBe(false); }); + it("surfaces no-visible-answer recovery for app-server interrupted tool-only output", () => { + const interruptedToolOnlyAttempt = makeAttemptResult({ + assistantTexts: [], + toolMetas: [{ toolName: "bash", meta: "workspace" }], + messagesSnapshot: [ + { + role: "user", + content: "check running processes", + timestamp: 1, + }, + { + role: "toolResult", + content: "", + isError: false, + details: { aggregated: "" }, + timestamp: 2, + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + ], + }); + + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount: interruptedToolOnlyAttempt.assistantTexts.length, + aborted: false, + timedOut: false, + attempt: interruptedToolOnlyAttempt, + }); + + expect(incompleteTurnText).toContain("couldn't generate a response"); + + const explicitCancellationText = resolveIncompleteTurnPayloadText({ + payloadCount: interruptedToolOnlyAttempt.assistantTexts.length, + aborted: true, + timedOut: false, + attempt: interruptedToolOnlyAttempt, + }); + + expect(explicitCancellationText).toBeNull(); + }); + it("detects tool-use terminal turn with pre-tool text as incomplete (#76477)", () => { // When the last assistant message ended with stopReason=toolUse, pre-tool // text alone must not suppress the incomplete-turn guard. The model @@ -2098,6 +2137,42 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(DEFAULT_EMPTY_RESPONSE_RETRY_LIMIT).toBe(1); }); + it("surfaces empty Codex app-server replies after successful sparse bash output", () => { + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + toolMetas: [{ toolName: "bash", meta: "exit=0" }], + messagesSnapshot: [ + { + role: "toolResult", + content: [{ type: "text", text: "" }], + details: { aggregated: "" }, + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + { + role: "assistant", + stopReason: "stop", + provider: "openai-codex", + model: "gpt-5.5", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["messagesSnapshot"][number], + ], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "openai-codex", + model: "gpt-5.5", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(incompleteTurnText).toContain("couldn't generate a response"); + expect(incompleteTurnText).toContain("verify before retrying"); + }); + it("retries generic empty Bedrock Converse turns without visible text", () => { const retryInstruction = resolveEmptyResponseRetryInstruction({ provider: "amazon-bedrock",