Skip to content

Commit a4c81c6

Browse files
authored
fix(codex): recover final text after prompt timeout (#84993)
1 parent b8e9ab9 commit a4c81c6

3 files changed

Lines changed: 82 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
3939
- QA-Lab: keep explicitly searchable/deferred OpenClaw dynamic tool rows report-only by default so tool-coverage gates do not treat mock discovery gaps as hard product failures. (#80319) Thanks @100yenadmin.
4040
- Agents/config: keep non-Google provider model refs from being rewritten by Google Gemini preview-id normalization. (#84762) Thanks @zhangguiping-xydt.
4141
- Installer: require a real controlling terminal before launching onboarding so headless `curl | bash` installs finish cleanly after installing the CLI.
42+
- 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.
4243
- 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.
4344
- 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.
4445
- Agents/Pi: keep embedded session transcript writes from tripping false takeover detection after packaged npm onboarding agent turns.

src/agents/pi-embedded-runner/run.incomplete-turn.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,46 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
318318
expect(result.meta.replayInvalid).toBe(false);
319319
});
320320

321+
it("promotes successful final assistant text when a prompt timeout races completion", async () => {
322+
mockedClassifyFailoverReason.mockReturnValue(null);
323+
const finalText =
324+
"1. Verdict: the answer completed cleanly. 2. Evidence: the runner captured final text.";
325+
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
326+
makeAttemptResult({
327+
assistantTexts: [],
328+
timedOut: true,
329+
lastAssistant: {
330+
role: "assistant",
331+
stopReason: "stop",
332+
provider: "openai-codex",
333+
model: "gpt-5.5",
334+
content: [{ type: "text", text: finalText }],
335+
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
336+
}),
337+
);
338+
339+
const result = await runEmbeddedPiAgent({
340+
...overflowBaseRunParams,
341+
provider: "openai-codex",
342+
model: "gpt-5.5",
343+
runId: "run-prompt-timeout-final-assistant-recovered",
344+
});
345+
346+
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
347+
expect(result.payloads).toEqual([{ text: finalText }]);
348+
expect(result.meta.finalAssistantVisibleText).toBe(finalText);
349+
expect(result.meta.finalAssistantRawText).toBe(finalText);
350+
expect(result.meta.livenessState).toBe("working");
351+
expect(result.meta.completion).toEqual({
352+
stopReason: "stop",
353+
finishReason: "stop",
354+
});
355+
expect(result.meta.executionTrace?.attempts?.at(-1)).toMatchObject({
356+
result: "success",
357+
stage: "assistant",
358+
});
359+
});
360+
321361
it("auto-activates strict-agentic for unconfigured GPT-5 openai runs and surfaces the blocked state", async () => {
322362
// Criterion 1 of the GPT-5.4 parity gate ("no stalls after planning") must
323363
// cover out-of-the-box installs, not only users who opted in. An

src/agents/pi-embedded-runner/run.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2669,6 +2669,35 @@ export async function runEmbeddedPiAgent(
26692669
});
26702670
const timedOutDuringPrompt =
26712671
timedOut && !timedOutDuringCompaction && !timedOutDuringToolExecution;
2672+
const finalAssistantStopReason = (sessionLastAssistant?.stopReason ?? "")
2673+
.trim()
2674+
.toLowerCase();
2675+
const recoveredFinalAssistantTextAfterPromptTimeout =
2676+
timedOutDuringPrompt &&
2677+
["completed", "end_turn", "stop"].includes(finalAssistantStopReason)
2678+
? (finalAssistantVisibleText ?? finalAssistantRawText)?.trim()
2679+
: undefined;
2680+
const payloadAlreadyContainsRecoveredFinalAssistant =
2681+
recoveredFinalAssistantTextAfterPromptTimeout
2682+
? (payloadsWithToolMedia ?? []).some(
2683+
(payload) =>
2684+
payload?.isError !== true &&
2685+
payload?.isReasoning !== true &&
2686+
typeof payload.text === "string" &&
2687+
payload.text.trim() === recoveredFinalAssistantTextAfterPromptTimeout,
2688+
)
2689+
: false;
2690+
const recoveredFinalAssistantPayloadsAfterPromptTimeout =
2691+
recoveredFinalAssistantTextAfterPromptTimeout &&
2692+
!payloadAlreadyContainsRecoveredFinalAssistant
2693+
? [{ text: recoveredFinalAssistantTextAfterPromptTimeout }]
2694+
: undefined;
2695+
const hasSuccessfulFinalAssistantAfterPromptTimeout =
2696+
timedOutDuringPrompt &&
2697+
Boolean(
2698+
payloadAlreadyContainsRecoveredFinalAssistant ||
2699+
recoveredFinalAssistantPayloadsAfterPromptTimeout?.length,
2700+
);
26722701
const hasPartialAssistantTextAfterPromptTimeout =
26732702
timedOutDuringPrompt &&
26742703
(attempt.assistantTexts ?? []).some((text) => text.trim().length > 0) &&
@@ -2690,7 +2719,11 @@ export async function runEmbeddedPiAgent(
26902719
// Timeout aborts can leave the run without payloads or with only a
26912720
// partial assistant fragment. Emit an explicit timeout error instead,
26922721
// preserving any tool payloads that succeeded before the timeout.
2693-
if (timedOutDuringPrompt && !hasMessagingToolDeliveryEvidence(attempt)) {
2722+
if (
2723+
timedOutDuringPrompt &&
2724+
!hasSuccessfulFinalAssistantAfterPromptTimeout &&
2725+
!hasMessagingToolDeliveryEvidence(attempt)
2726+
) {
26942727
const defaultTimeoutText = idleTimedOut
26952728
? "The model did not produce a response before the model idle timeout. " +
26962729
"Please try again, or increase `models.providers.<id>.timeoutSeconds` for slow local or self-hosted providers. " +
@@ -2755,11 +2788,13 @@ export async function runEmbeddedPiAgent(
27552788
timedOut,
27562789
attempt,
27572790
});
2758-
const payloadsForTerminalPath = payloadsWithToolMedia?.length
2759-
? payloadsWithToolMedia
2760-
: silentToolResultReplyPayload
2761-
? [silentToolResultReplyPayload]
2762-
: payloadsWithToolMedia;
2791+
const payloadsForTerminalPath = recoveredFinalAssistantPayloadsAfterPromptTimeout
2792+
? recoveredFinalAssistantPayloadsAfterPromptTimeout
2793+
: payloadsWithToolMedia?.length
2794+
? payloadsWithToolMedia
2795+
: silentToolResultReplyPayload
2796+
? [silentToolResultReplyPayload]
2797+
: payloadsWithToolMedia;
27632798
const payloadCount = payloadsForTerminalPath?.length ?? 0;
27642799
const emptyAssistantReplyIsSilent = shouldTreatEmptyAssistantReplyAsSilent({
27652800
allowEmptyAssistantReplyAsSilent: params.allowEmptyAssistantReplyAsSilent,

0 commit comments

Comments
 (0)