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 <vincentkoc@ieee.org>
This commit is contained in:
Krzysztof Probola
2026-05-22 12:22:20 +02:00
committed by GitHub
parent 6bd430ee35
commit 8523e0930e
5 changed files with 182 additions and 6 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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({

View File

@@ -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(

View File

@@ -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",