Skip to content

Commit f2e8d98

Browse files
committed
Fix replay-invalid session recovery
1 parent 4dd3ba1 commit f2e8d98

6 files changed

Lines changed: 145 additions & 1 deletion

File tree

src/agents/embedded-agent-helpers.isbillingerrormessage.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1559,6 +1559,11 @@ describe("classifyProviderRuntimeFailureKind", () => {
15591559
expect(
15601560
classifyProviderRuntimeFailureKind("401 input item ID does not belong to this connection"),
15611561
).toBe("replay_invalid");
1562+
expect(
1563+
classifyProviderRuntimeFailureKind(
1564+
"400 code=invalid_encrypted_content Encrypted content could not be decrypted or parsed.",
1565+
),
1566+
).toBe("replay_invalid");
15621567
});
15631568

15641569
it("splits ambiguous provider runtime failures instead of collapsing to unknown", () => {

src/agents/embedded-agent-helpers/errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ const DNS_ERROR_RE = /\benotfound\b|\beai_again\b|\bgetaddrinfo\b|\bno such host
353353
const INTERRUPTED_NETWORK_ERROR_RE =
354354
/\beconnrefused\b|\beconnreset\b|\beconnaborted\b|\benetreset\b|\behostunreach\b|\behostdown\b|\benetunreach\b|\bepipe\b|\bsocket hang up\b|\bconnection refused\b|\bconnection reset\b|\bconnection aborted\b|\bnetwork is unreachable\b|\bhost is unreachable\b|\bfetch failed\b|\bconnection error\b|\bnetwork request failed\b/i;
355355
const REPLAY_INVALID_RE =
356-
/\bprevious_response_id\b.*\b(?:invalid|unknown|not found|does not exist|expired|mismatch)\b|\btool_(?:use|call)\.(?:input|arguments)\b.*\b(?:missing|required)\b|\bincorrect role information\b|\broles must alternate\b|\binput item id does not belong to this connection\b/i;
356+
/\bprevious_response_id\b.*\b(?:invalid|unknown|not found|does not exist|expired|mismatch)\b|\btool_(?:use|call)\.(?:input|arguments)\b.*\b(?:missing|required)\b|\bincorrect role information\b|\broles must alternate\b|\binput item id does not belong to this connection\b|\binvalid_encrypted_content\b|\bencrypted content could not be decrypted or parsed\b/i;
357357
const SANDBOX_BLOCKED_RE =
358358
/\bapproval is required\b|\bapproval timed out\b|\bapproval was denied\b|\bblocked by sandbox\b|\bsandbox\b.*\b(?:blocked|denied|forbidden|disabled|not allowed)\b|\bexec denied\s*\(/i;
359359
const NO_BODY_HTTP_WRAPPER_RE =

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ vi.mock("../../agents/bootstrap-budget.js", () => ({
9696

9797
vi.mock("../../agents/embedded-agent-helpers.js", () => ({
9898
BILLING_ERROR_USER_MESSAGE: "billing",
99+
classifyProviderRuntimeFailureKind: (message: string) =>
100+
/invalid_encrypted_content|encrypted content could not be decrypted or parsed|tool_(?:use|call)\.(?:input|arguments)|input item id does not belong to this connection/i.test(
101+
message,
102+
)
103+
? "replay_invalid"
104+
: "unclassified",
99105
formatRateLimitOrOverloadedErrorCopy: (message: string) => {
100106
if (/model\s+(?:is\s+)?at capacity/i.test(message)) {
101107
return "⚠️ Selected model is at capacity. Try a different model, or wait and retry.";
@@ -5476,6 +5482,50 @@ describe("runAgentTurnWithFallback", () => {
54765482
}
54775483
});
54785484

5485+
it("returns a session reset hint for OpenAI encrypted replay failures", async () => {
5486+
const resetSessionAfterReplayInvalid = vi.fn(async () => true);
5487+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
5488+
new Error(
5489+
"400 code=invalid_encrypted_content Encrypted content could not be decrypted or parsed.",
5490+
),
5491+
);
5492+
5493+
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
5494+
const result = await runAgentTurnWithFallback({
5495+
commandBody: "hello",
5496+
followupRun: createFollowupRun(),
5497+
sessionCtx: {
5498+
Provider: "telegram",
5499+
MessageSid: "msg",
5500+
} as unknown as TemplateContext,
5501+
opts: {},
5502+
typingSignals: createMockTypingSignaler(),
5503+
blockReplyPipeline: null,
5504+
blockStreamingEnabled: false,
5505+
resolvedBlockStreamingBreak: "message_end",
5506+
applyReplyToMode: (payload) => payload,
5507+
shouldEmitToolResult: () => true,
5508+
shouldEmitToolOutput: () => false,
5509+
pendingToolTasks: new Set(),
5510+
resetSessionAfterRoleOrderingConflict: async () => false,
5511+
resetSessionAfterReplayInvalid,
5512+
isHeartbeat: false,
5513+
sessionKey: "main",
5514+
getActiveSessionEntry: () => undefined,
5515+
resolvedVerboseLevel: "off",
5516+
});
5517+
5518+
expect(resetSessionAfterReplayInvalid).toHaveBeenCalledWith(
5519+
"400 code=invalid_encrypted_content Encrypted content could not be decrypted or parsed.",
5520+
);
5521+
expect(result.kind).toBe("final");
5522+
if (result.kind === "final") {
5523+
expect(result.payload.text).toBe(
5524+
"⚠️ Session history got out of sync. Please try again, or use /new to start a fresh session.",
5525+
);
5526+
}
5527+
});
5528+
54795529
it("keeps raw generic errors on internal control surfaces", async () => {
54805530
state.isInternalMessageChannelMock.mockReturnValue(true);
54815531
state.runEmbeddedAgentMock.mockRejectedValueOnce(

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
isOverloadedErrorMessage,
2828
isRateLimitErrorMessage,
2929
isTransientHttpError,
30+
classifyProviderRuntimeFailureKind,
3031
} from "../../agents/embedded-agent-helpers.js";
3132
import { sanitizeUserFacingText } from "../../agents/embedded-agent-helpers/sanitize-user-facing-text.js";
3233
import { isMessagingToolSendAction } from "../../agents/embedded-agent-messaging.js";
@@ -607,6 +608,13 @@ function hasBillingAttemptSummary(err: unknown): boolean {
607608
);
608609
}
609610

611+
const REPLAY_INVALID_SESSION_USER_MESSAGE =
612+
"⚠️ Session history got out of sync. Please try again, or use /new to start a fresh session.";
613+
614+
function isReplayInvalidRunFailureError(message: string): boolean {
615+
return classifyProviderRuntimeFailureKind(message) === "replay_invalid";
616+
}
617+
610618
function collapseRepeatedFailureDetail(message: string): string {
611619
const parts = message
612620
.split(/\s+\|\s+/u)
@@ -753,6 +761,12 @@ function buildExternalRunFailureReply(
753761
isGenericRunnerFailure: false,
754762
};
755763
}
764+
if (isReplayInvalidRunFailureError(normalizedMessage)) {
765+
return {
766+
text: REPLAY_INVALID_SESSION_USER_MESSAGE,
767+
isGenericRunnerFailure: false,
768+
};
769+
}
756770
const missingApiKeyFailure = buildMissingApiKeyFailureText(normalizedMessage);
757771
if (missingApiKeyFailure) {
758772
return { text: missingApiKeyFailure, isGenericRunnerFailure: false };
@@ -1400,6 +1414,7 @@ export async function runAgentTurnWithFallback(params: {
14001414
shouldEmitToolOutput: () => boolean;
14011415
pendingToolTasks: Set<Promise<void>>;
14021416
resetSessionAfterRoleOrderingConflict: (reason: string) => Promise<boolean>;
1417+
resetSessionAfterReplayInvalid?: (reason: string) => Promise<boolean>;
14031418
isHeartbeat: boolean;
14041419
sessionKey?: string;
14051420
runtimePolicySessionKey?: string;
@@ -2746,6 +2761,16 @@ export async function runAgentTurnWithFallback(params: {
27462761
};
27472762
}
27482763

2764+
if (isReplayInvalidRunFailureError(message)) {
2765+
try {
2766+
await params.resetSessionAfterReplayInvalid?.(message);
2767+
} catch (resetErr) {
2768+
defaultRuntime.error(
2769+
`Failed to reset replay-invalid session ${params.sessionKey}: ${String(resetErr)}`,
2770+
);
2771+
}
2772+
}
2773+
27492774
if (isTransientHttp && !didRetryTransientHttpError) {
27502775
didRetryTransientHttpError = true;
27512776
// Retry the full runWithModelFallback() cycle — transient errors

src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ function createMinimalRun(params?: {
200200
return {
201201
typing,
202202
opts,
203+
followupRun,
203204
run: async () => {
204205
const runReplyAgent = await getRunReplyAgent();
205206
return runReplyAgent({
@@ -426,6 +427,61 @@ describe("runReplyAgent pending final delivery capture", () => {
426427
return JSON.parse(raw).main as SessionEntry;
427428
}
428429

430+
it("rotates poisoned replay-invalid sessions before returning reset guidance", async () => {
431+
const sessionEntry: SessionEntry = {
432+
sessionId: "poisoned-session",
433+
sessionFile: "/tmp/poisoned-session.jsonl",
434+
updatedAt: Date.now(),
435+
sessionStartedAt: Date.now() - 10_000,
436+
systemSent: true,
437+
status: "running",
438+
contextTokens: 272_000,
439+
inputTokens: 877_883,
440+
outputTokens: 5_500,
441+
modelProvider: "openai-codex",
442+
model: "gpt-5.5",
443+
cliSessionBindings: { "codex-cli": { sessionId: "codex-session" } },
444+
};
445+
const sessionStore = { main: sessionEntry };
446+
const storePath = await createSessionStoreFile(sessionEntry);
447+
state.runEmbeddedAgentMock.mockRejectedValueOnce(
448+
new Error(
449+
"400 code=invalid_encrypted_content Encrypted content could not be decrypted or parsed.",
450+
),
451+
);
452+
453+
const { followupRun, run } = createMinimalRun({
454+
sessionEntry,
455+
sessionStore,
456+
sessionKey: "main",
457+
storePath,
458+
runOverrides: {
459+
provider: "openai-codex",
460+
model: "gpt-5.5",
461+
},
462+
});
463+
464+
const result = await run();
465+
const stored = await readStoredMainSession(storePath);
466+
467+
expect(result).toMatchObject({
468+
text: "⚠️ Session history got out of sync. Please try again, or use /new to start a fresh session.",
469+
});
470+
expect(stored.sessionId).not.toBe("poisoned-session");
471+
expect(stored.sessionFile).toEqual(expect.any(String));
472+
expect(stored.sessionFile).not.toBe("/tmp/poisoned-session.jsonl");
473+
expect(followupRun.run.sessionId).toBe(stored.sessionId);
474+
expect(followupRun.run.sessionFile).toBe(stored.sessionFile);
475+
expect(vi.mocked(refreshQueuedFollowupSession)).toHaveBeenCalledWith({
476+
key: "main",
477+
previousSessionId: "poisoned-session",
478+
nextSessionId: stored.sessionId,
479+
nextSessionFile: stored.sessionFile,
480+
});
481+
expect(stored.systemSent).toBe(false);
482+
expect(stored.contextTokens).toBeUndefined();
483+
});
484+
429485
it("does not persist message-tool-only final replies for heartbeat replay", async () => {
430486
const sessionEntry: SessionEntry = {
431487
sessionId: "session",

src/auto-reply/reply/agent-runner.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1460,6 +1460,13 @@ export async function runReplyAgent(params: {
14601460
`Role ordering conflict (${reason}). Restarting session ${sessionKey} -> ${nextSessionId}.`,
14611461
cleanupTranscripts: true,
14621462
});
1463+
const resetSessionAfterReplayInvalid = async (reason: string): Promise<boolean> =>
1464+
resetSession({
1465+
failureLabel: "replay-invalid session state",
1466+
buildLogMessage: (nextSessionId) =>
1467+
`Replay-invalid session state (${reason}). Restarting session ${sessionKey} -> ${nextSessionId}.`,
1468+
cleanupTranscripts: true,
1469+
});
14631470

14641471
replyOperation.setPhase("running");
14651472
const runStartedAt = Date.now();
@@ -1482,6 +1489,7 @@ export async function runReplyAgent(params: {
14821489
shouldEmitToolOutput,
14831490
pendingToolTasks,
14841491
resetSessionAfterRoleOrderingConflict,
1492+
resetSessionAfterReplayInvalid,
14851493
isHeartbeat,
14861494
sessionKey,
14871495
runtimePolicySessionKey,

0 commit comments

Comments
 (0)