mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-28 03:00:34 +00:00
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:
committed by
GitHub
parent
6bd430ee35
commit
8523e0930e
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user