Skip to content

Commit 5fdef4c

Browse files
authored
fix(codex): ignore account updates for turn liveness (#79667)
* fix codex app-server completion liveness * docs changelog codex liveness fix
1 parent bb95031 commit 5fdef4c

5 files changed

Lines changed: 103 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,7 @@ Docs: https://docs.openclaw.ai
430430
- WhatsApp/onboarding: canonicalize setup and pairing allowlist entries to WhatsApp's digit-only phone ids while still accepting E.164, JID, and `whatsapp:` inputs, so personal-phone allowlists match WhatsApp Web sender ids after setup. Thanks @vincentkoc.
431431
- WhatsApp/login: route login success and failure messages through the injected runtime, so setup/onboarding surfaces capture all login output instead of only the QR. Thanks @vincentkoc.
432432
- Channels/WhatsApp: apply the shared group/channel visible-reply mode during inbound dispatch so group replies stay message-tool-only by default without overriding direct-chat harness defaults. Refs #75178 and #67394. Thanks @scoootscooob.
433+
- Codex app-server: ignore account and rate-limit notifications when measuring active-turn liveness and suppress duplicate generic timeout replies after a visible messaging-tool delivery, so lost completion signals no longer keep Telegram/Discord turns active behind a delivered reply. (#79667) Thanks @joshavant.
433434
- Telegram/media: derive no-caption inbound media placeholders from saved MIME metadata instead of the Telegram `photo` shape, so non-image and mixed attachments no longer reach the model as `<media:image>`. Fixes #69793. Thanks @aspalagin.
434435
- Telegram/streaming: reuse the active preview as the first chunk for long text finals, so multi-chunk replies no longer create a transient extra bubble that appears and then disappears. Thanks @vincentkoc.
435436
- Telegram/streaming: sanitize tool-progress draft preview backticks before shared compaction, so long backtick-heavy progress text still renders inside the safe code-formatted preview instead of collapsing to an ellipsis.

extensions/codex/src/app-server/run-attempt.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -979,6 +979,83 @@ describe("runCodexAppServerAttempt", () => {
979979
expect(queueAgentHarnessMessage("session-1", "after timeout")).toBe(false);
980980
});
981981

982+
it("does not count account rate-limit updates as turn completion activity", async () => {
983+
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
984+
let handleRequest:
985+
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
986+
| undefined;
987+
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
988+
const request = vi.fn(async (method: string) => {
989+
if (method === "thread/start") {
990+
return threadStartResult("thread-1");
991+
}
992+
if (method === "turn/start") {
993+
return turnStartResult("turn-1", "inProgress");
994+
}
995+
return {};
996+
});
997+
__testing.setCodexAppServerClientFactoryForTests(
998+
async () =>
999+
({
1000+
request,
1001+
addNotificationHandler: (handler: typeof notify) => {
1002+
notify = handler;
1003+
return () => undefined;
1004+
},
1005+
addRequestHandler: (
1006+
handler: (request: {
1007+
id: string;
1008+
method: string;
1009+
params?: unknown;
1010+
}) => Promise<unknown>,
1011+
) => {
1012+
handleRequest = handler;
1013+
return () => undefined;
1014+
},
1015+
}) as never,
1016+
);
1017+
const params = createParams(
1018+
path.join(tempDir, "session.jsonl"),
1019+
path.join(tempDir, "workspace"),
1020+
);
1021+
params.timeoutMs = 60_000;
1022+
1023+
const run = runCodexAppServerAttempt(params, {
1024+
turnCompletionIdleTimeoutMs: 5,
1025+
turnTerminalIdleTimeoutMs: 60_000,
1026+
});
1027+
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), { interval: 1 });
1028+
1029+
await expect(
1030+
handleRequest?.({
1031+
id: "request-tool-1",
1032+
method: "item/tool/call",
1033+
params: {
1034+
threadId: "thread-1",
1035+
turnId: "turn-1",
1036+
callId: "call-1",
1037+
namespace: null,
1038+
tool: "message",
1039+
arguments: { action: "send", text: "already sent" },
1040+
},
1041+
}),
1042+
).resolves.toMatchObject({ success: false });
1043+
await notify(rateLimitsUpdated(Math.ceil(Date.now() / 1000) + 120));
1044+
1045+
await expect(run).resolves.toMatchObject({
1046+
aborted: true,
1047+
timedOut: true,
1048+
promptError: "codex app-server turn idle timed out waiting for turn/completed",
1049+
});
1050+
expect(warn).toHaveBeenCalledWith(
1051+
"codex app-server turn idle timed out waiting for completion",
1052+
expect.objectContaining({
1053+
timeoutMs: 5,
1054+
lastActivityReason: "request:item/tool/call:response",
1055+
}),
1056+
);
1057+
});
1058+
9821059
it("keeps waiting when Codex emits a raw assistant item after a dynamic tool response", async () => {
9831060
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
9841061
let handleRequest:

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,9 +1039,6 @@ export async function runCodexAppServerAttempt(
10391039
};
10401040

10411041
const handleNotification = async (notification: CodexServerNotification) => {
1042-
touchTurnCompletionActivity(`notification:${notification.method}`, {
1043-
details: describeNotificationActivity(notification),
1044-
});
10451042
userInputBridge?.handleNotification(notification);
10461043
if (!projector || !turnId) {
10471044
pendingNotifications.push(notification);
@@ -1052,6 +1049,11 @@ export async function runCodexAppServerAttempt(
10521049
thread.threadId,
10531050
turnId,
10541051
);
1052+
if (isCurrentTurnNotification) {
1053+
touchTurnCompletionActivity(`notification:${notification.method}`, {
1054+
details: describeNotificationActivity(notification),
1055+
});
1056+
}
10551057
if (isCurrentTurnNotification && notification.method === "error") {
10561058
if (isRetryableErrorNotification(notification.params)) {
10571059
disarmTurnCompletionIdleWatch();

src/agents/pi-embedded-runner/run.overflow-compaction.loop.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,25 @@ describe("overflow compaction in run loop", () => {
635635
expect(result.payloads?.[0]?.text).toContain("timed out");
636636
});
637637

638+
it("does not emit a generic timeout payload after messaging-tool delivery", async () => {
639+
mockedRunEmbeddedAttempt.mockResolvedValue(
640+
makeAttemptResult({
641+
aborted: true,
642+
timedOut: true,
643+
timedOutDuringCompaction: false,
644+
assistantTexts: [],
645+
didSendViaMessagingTool: true,
646+
messagingToolSentTexts: ["already delivered"],
647+
}),
648+
);
649+
650+
const result = await runEmbeddedPiAgent(baseParams);
651+
652+
expect(result.payloads).toBeUndefined();
653+
expect(result.didSendViaMessagingTool).toBe(true);
654+
expect(result.messagingToolSentTexts).toEqual(["already delivered"]);
655+
});
656+
638657
it("returns a timeout payload instead of a partial assistant fragment after stream timeout", async () => {
639658
mockedRunEmbeddedAttempt.mockResolvedValue(
640659
makeAttemptResult({

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2391,6 +2391,7 @@ export async function runEmbeddedPiAgent(
23912391
// partial assistant fragment. Emit an explicit timeout error instead.
23922392
if (
23932393
timedOutDuringPrompt &&
2394+
!hasMessagingToolDeliveryEvidence(attempt) &&
23942395
(!payloadsWithToolMedia?.length || hasPartialAssistantTextAfterPromptTimeout)
23952396
) {
23962397
const timeoutText = idleTimedOut

0 commit comments

Comments
 (0)