diff --git a/src/agents/agent-command.compaction-rotation.test.ts b/src/agents/agent-command.compaction-rotation.test.ts index 6b055f9afe28..04e51d20d455 100644 --- a/src/agents/agent-command.compaction-rotation.test.ts +++ b/src/agents/agent-command.compaction-rotation.test.ts @@ -447,17 +447,18 @@ describe("agentCommand compaction transcript rotation", () => { }); expect(compactionSessionEntry).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, - pendingFinalDeliveryContext: { - channel: "discord", - to: "discord:dm:123", - accountId: "main", + pendingFinalDelivery: { + kind: "replayable", + text, + context: { + channel: "discord", + to: "discord:dm:123", + accountId: "main", + }, }, }); expect(storedEntryBeforeCompaction).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, + pendingFinalDelivery: { kind: "replayable", text }, }); expect(result).toMatchObject({ deliverySucceeded: true }); expect(state.deliverAgentCommandResultMock).toHaveBeenCalledOnce(); @@ -468,7 +469,6 @@ describe("agentCommand compaction transcript rotation", () => { expect(readLifecyclePhases()).not.toContain("error"); const storedEntryAfterDelivery = findStoredSessionEntry(sessionKey); expect(storedEntryAfterDelivery?.pendingFinalDelivery).toBeUndefined(); - expect(storedEntryAfterDelivery?.pendingFinalDeliveryText).toBeUndefined(); }, ); @@ -486,7 +486,10 @@ describe("agentCommand compaction transcript rotation", () => { }), ); state.runCliTurnCompactionLifecycleMock.mockImplementationOnce(async (params) => { - pendingTextSeenByCompaction = params.sessionEntry?.pendingFinalDeliveryText ?? undefined; + pendingTextSeenByCompaction = + params.sessionEntry?.pendingFinalDelivery?.kind === "replayable" + ? params.sessionEntry.pendingFinalDelivery.text + : undefined; throw new Error(COMPACTION_ERROR); }); @@ -507,7 +510,6 @@ describe("agentCommand compaction transcript rotation", () => { expect(state.deliverAgentCommandResultMock).toHaveBeenCalledOnce(); const storedEntry = findStoredSessionEntry(sessionKey); expect(storedEntry?.pendingFinalDelivery).toBeUndefined(); - expect(storedEntry?.pendingFinalDeliveryText).toBeUndefined(); }); it("preserves media directives in the pending final persisted before compaction", async () => { @@ -517,7 +519,10 @@ describe("agentCommand compaction transcript rotation", () => { let pendingTextSeenByCompaction: string | undefined; state.runAgentAttemptMock.mockResolvedValueOnce(makeResult({ sessionId, text })); state.runCliTurnCompactionLifecycleMock.mockImplementationOnce(async (params) => { - pendingTextSeenByCompaction = params.sessionEntry?.pendingFinalDeliveryText ?? undefined; + pendingTextSeenByCompaction = + params.sessionEntry?.pendingFinalDelivery?.kind === "replayable" + ? params.sessionEntry.pendingFinalDelivery.text + : undefined; throw new Error(COMPACTION_ERROR); }); @@ -539,7 +544,6 @@ describe("agentCommand compaction transcript rotation", () => { ); const storedEntry = findStoredSessionEntry(sessionKey); expect(storedEntry?.pendingFinalDelivery).toBeUndefined(); - expect(storedEntry?.pendingFinalDeliveryText).toBeUndefined(); }); it("adopts a successful compaction successor for delivery and marker cleanup", async () => { @@ -582,21 +586,18 @@ describe("agentCommand compaction transcript rotation", () => { expect(compactionSetupError).toBeUndefined(); expect(successorBeforeCleanup).toMatchObject({ sessionId: successorSessionId, - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, + pendingFinalDelivery: { kind: "replayable", text }, }); expect(result).toMatchObject({ deliverySucceeded: true }); expect(state.deliveryFreshEntries.at(-1)).toMatchObject({ sessionId: successorSessionId, - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, + pendingFinalDelivery: { kind: "replayable", text }, }); const storedSuccessor = findStoredSessionEntry(sessionKey); expect(storedSuccessor).toMatchObject({ sessionId: successorSessionId, }); expect(storedSuccessor?.pendingFinalDelivery).toBeUndefined(); - expect(storedSuccessor?.pendingFinalDeliveryText).toBeUndefined(); expect(storedSuccessor?.restartRecoveryDeliveryContext).toBeUndefined(); expect(storedSuccessor?.restartRecoveryDeliveryRunId).toBeUndefined(); }); @@ -623,12 +624,14 @@ describe("agentCommand compaction transcript rotation", () => { expect(result).toMatchObject({ deliverySucceeded: false }); expect(state.deliverAgentCommandResultMock).toHaveBeenCalledOnce(); expect(findStoredSessionEntry(sessionKey)).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, - pendingFinalDeliveryContext: { - channel: "discord", - to: "discord:dm:123", - accountId: "main", + pendingFinalDelivery: { + kind: "replayable", + text, + context: { + channel: "discord", + to: "discord:dm:123", + accountId: "main", + }, }, }); }); @@ -641,8 +644,7 @@ describe("agentCommand compaction transcript rotation", () => { state.runAgentAttemptMock.mockResolvedValueOnce(makeResult({ sessionId, text })); state.runCliTurnCompactionLifecycleMock.mockImplementationOnce(async (params) => { expect(params.sessionEntry).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, + pendingFinalDelivery: { kind: "replayable", text }, }); abortController.abort(createAgentRunRestartAbortError()); throw new Error(COMPACTION_ERROR); @@ -664,8 +666,7 @@ describe("agentCommand compaction transcript rotation", () => { expect(state.deliverAgentCommandResultMock).not.toHaveBeenCalled(); expect(findStoredSessionEntry(sessionKey)).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, + pendingFinalDelivery: { kind: "replayable", text }, }); }); @@ -677,8 +678,7 @@ describe("agentCommand compaction transcript rotation", () => { state.runAgentAttemptMock.mockResolvedValueOnce(makeResult({ sessionId, text })); state.runCliTurnCompactionLifecycleMock.mockImplementationOnce(async (params) => { expect(params.sessionEntry).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, + pendingFinalDelivery: { kind: "replayable", text }, }); abortController.abort(createAgentRunRestartAbortError()); return params.sessionEntry; @@ -700,8 +700,7 @@ describe("agentCommand compaction transcript rotation", () => { expect(state.deliverAgentCommandResultMock).not.toHaveBeenCalled(); expect(findStoredSessionEntry(sessionKey)).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, + pendingFinalDelivery: { kind: "replayable", text }, }); }); @@ -712,8 +711,7 @@ describe("agentCommand compaction transcript rotation", () => { state.runAgentAttemptMock.mockResolvedValueOnce(makeResult({ sessionId, text })); state.runCliTurnCompactionLifecycleMock.mockImplementationOnce(async (params) => { expect(params.sessionEntry).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, + pendingFinalDelivery: { kind: "replayable", text }, }); rotateAgentEventLifecycleGeneration(); throw new Error(COMPACTION_ERROR); @@ -734,8 +732,7 @@ describe("agentCommand compaction transcript rotation", () => { expect(state.deliverAgentCommandResultMock).not.toHaveBeenCalled(); expect(findStoredSessionEntry(sessionKey)).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: text, + pendingFinalDelivery: { kind: "replayable", text }, }); }); @@ -787,7 +784,6 @@ describe("agentCommand compaction transcript rotation", () => { expect(state.deliverAgentCommandResultMock).not.toHaveBeenCalled(); const storedEntry = findStoredSessionEntry(sessionKey); expect(storedEntry?.pendingFinalDelivery).toBeUndefined(); - expect(storedEntry?.pendingFinalDeliveryText).toBeUndefined(); expect(readLifecyclePhases()).toContain("error"); }, ); @@ -817,7 +813,6 @@ describe("agentCommand compaction transcript rotation", () => { expect(result).toMatchObject({ deliverySucceeded: true }); const storedEntry = findStoredSessionEntry(sessionKey); expect(storedEntry?.pendingFinalDelivery).toBeUndefined(); - expect(storedEntry?.pendingFinalDeliveryText).toBeUndefined(); }); it("skips post-turn compaction when a recoverable final cannot persist a pending marker", async () => { @@ -845,7 +840,6 @@ describe("agentCommand compaction transcript rotation", () => { expect(result).toMatchObject({ deliverySucceeded: true }); const storedEntry = findStoredSessionEntry(sessionKey); expect(storedEntry?.pendingFinalDelivery).toBeUndefined(); - expect(storedEntry?.pendingFinalDeliveryText).toBeUndefined(); }); it("keeps post-turn compaction for no-delivery runs with unrecoverable sendable finals", async () => { diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 0bca57463935..a5c4cec0fc0a 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -2877,15 +2877,18 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { const pendingEntries = state.persistSessionEntryMock.mock.calls .map((call) => (call[0] as { entry?: SessionEntry }).entry) - .filter((entry): entry is SessionEntry => entry?.pendingFinalDelivery === true); + .filter((entry): entry is SessionEntry => entry?.pendingFinalDelivery !== undefined); expect(pendingEntries).toContainEqual( expect.objectContaining({ - pendingFinalDeliveryText: "ok", - pendingFinalDeliveryContext: { - channel: "discord", - to: "channel:1524410080953634829", - accountId: "main", - }, + pendingFinalDelivery: expect.objectContaining({ + kind: "replayable", + text: "ok", + context: { + channel: "discord", + to: "channel:1524410080953634829", + accountId: "main", + }, + }), }), ); expect(state.deliverAgentCommandResultMock).toHaveBeenCalledWith( @@ -2951,18 +2954,16 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { ); }); - it("clears stale flag-only pending final delivery when there is no final payload", async () => { + it("clears a pre-existing transport-only pending delivery after an empty delivered run", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeEmptyResult("openai", "gpt-5.4")); - setupBareStoredSession({ - pendingFinalDelivery: true, - pendingFinalDeliveryCreatedAt: 2, - pendingFinalDeliveryLastAttemptAt: 3, - pendingFinalDeliveryAttemptCount: 4, - pendingFinalDeliveryLastError: "previous failure", - pendingFinalDeliveryContext: { channel: "tui" }, - pendingFinalDeliveryIntentId: "intent-1", + pendingFinalDelivery: { + kind: "transport-only", + createdAt: 2, + context: { channel: "tui" }, + intentId: "intent-1", + }, }); state.deliverAgentCommandResultMock.mockResolvedValue(undefined); @@ -2975,16 +2976,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { expect(state.persistSessionEntryMock).toHaveBeenCalledWith( expect.objectContaining({ - entry: expect.objectContaining({ - pendingFinalDelivery: undefined, - pendingFinalDeliveryText: undefined, - pendingFinalDeliveryCreatedAt: undefined, - pendingFinalDeliveryLastAttemptAt: undefined, - pendingFinalDeliveryAttemptCount: undefined, - pendingFinalDeliveryLastError: undefined, - pendingFinalDeliveryContext: undefined, - pendingFinalDeliveryIntentId: undefined, - }), + entry: expect.objectContaining({ pendingFinalDelivery: undefined }), }), ); }); diff --git a/src/agents/agent-scope.test.ts b/src/agents/agent-scope.test.ts index 82eb53f06525..468e934efcae 100644 --- a/src/agents/agent-scope.test.ts +++ b/src/agents/agent-scope.test.ts @@ -705,9 +705,12 @@ describe("resolveAgentConfig", () => { authProfileOverride: "fallback-key", authProfileOverrideSource: "auto", authProfileOverrideCompactionCount: 1, - fallbackNoticeSelectedModel: "google/gemini-3-pro", - fallbackNoticeActiveModel: "google/gemini-3-pro", - fallbackNoticeReason: "rate_limit", + fallbackNotice: { + kind: "active", + selectedModel: "google/gemini-3-pro", + activeModel: "google/gemini-3-pro", + reason: "rate_limit", + }, }; clearAutoFallbackPrimaryProbeSelection(entry, 2); diff --git a/src/agents/agent-scope.ts b/src/agents/agent-scope.ts index b682f2c53a9f..4f34ccf21bd5 100644 --- a/src/agents/agent-scope.ts +++ b/src/agents/agent-scope.ts @@ -295,9 +295,7 @@ export function clearAutoFallbackPrimaryProbeSelection( delete entry.authProfileOverrideSource; delete entry.authProfileOverrideCompactionCount; } - delete entry.fallbackNoticeSelectedModel; - delete entry.fallbackNoticeActiveModel; - delete entry.fallbackNoticeReason; + delete entry.fallbackNotice; entry.updatedAt = now; } diff --git a/src/agents/command/post-run.ts b/src/agents/command/post-run.ts index 80cb0289836e..f4d34854eef5 100644 --- a/src/agents/command/post-run.ts +++ b/src/agents/command/post-run.ts @@ -24,7 +24,7 @@ import { loadDeliveryRuntime, loadSessionStoreRuntime, } from "./runtime-loaders.js"; -import { clearPendingFinalDeliveryFields, persistSessionEntry } from "./session-helpers.js"; +import { clearPendingFinalDelivery, persistSessionEntry } from "./session-helpers.js"; import type { EmbeddedSessionState } from "./session-preparation.js"; import type { AgentCommandOpts } from "./types.js"; @@ -343,18 +343,18 @@ export async function finalizeEmbeddedAgentCommand(params: { if (!entry) { throw new Error("Cannot clear pending delivery without a session entry"); } - const noPendingTextForThisRun = + // This command only creates replayable markers, so transport-only is stale from an earlier run. + const clearStaleTransportOnly = params.opts.deliver === true && - pendingFinalDeliveryMarker.pendingFinalDeliveryTextForThisRun === undefined && - entry.pendingFinalDelivery === true && - !entry.pendingFinalDeliveryText; - if (deliveryResult?.deliverySucceeded === true || noPendingTextForThisRun) { + !pendingFinalDeliveryMarker.hasSendableFinalPayload && + entry.pendingFinalDelivery?.kind === "transport-only"; + if (deliveryResult?.deliverySucceeded === true || clearStaleTransportOnly) { sessionEntry = await persistSessionEntry({ sessionStore, sessionKey, storePath, initialEntry: entry, - entry: clearPendingFinalDeliveryFields(entry, Date.now()), + entry: clearPendingFinalDelivery(entry, Date.now()), shouldPersist: (current) => shouldPersistCurrentRunSessionCleanup(current, runOwnedSessionId), }); diff --git a/src/agents/command/session-helpers.ts b/src/agents/command/session-helpers.ts index eacc8864b224..45a1d1bc4c59 100644 --- a/src/agents/command/session-helpers.ts +++ b/src/agents/command/session-helpers.ts @@ -35,20 +35,10 @@ export async function persistSessionEntry( return await persistSessionEntryBase(params); } -export function clearPendingFinalDeliveryFields( - entry: SessionEntry, - updatedAt: number, -): SessionEntry { +export function clearPendingFinalDelivery(entry: SessionEntry, updatedAt: number): SessionEntry { return { ...entry, pendingFinalDelivery: undefined, - pendingFinalDeliveryText: undefined, - pendingFinalDeliveryCreatedAt: undefined, - pendingFinalDeliveryLastAttemptAt: undefined, - pendingFinalDeliveryAttemptCount: undefined, - pendingFinalDeliveryLastError: undefined, - pendingFinalDeliveryContext: undefined, - pendingFinalDeliveryIntentId: undefined, restartRecoveryForceSafeTools: undefined, restartRecoveryDeliveryMediaUrls: undefined, restartRecoveryDisableMessageTool: undefined, diff --git a/src/agents/main-session-recovery-state.test.ts b/src/agents/main-session-recovery-state.test.ts index 31b1bc47262e..861b93f7d8ac 100644 --- a/src/agents/main-session-recovery-state.test.ts +++ b/src/agents/main-session-recovery-state.test.ts @@ -419,8 +419,7 @@ describe("main session recovery state", () => { it("moves a reservation into the lifecycle fence during Gateway admission", () => { const entry = interruptedEntry({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: " captured reply ", + pendingFinalDelivery: { kind: "replayable", text: " captured reply ", createdAt: 1 }, restartRecoveryDeliveryRunId: "recovery-1", restartRecoveryDeliverySourceRunId: "source-1", mainRestartRecovery: recoveryState({ @@ -455,8 +454,7 @@ describe("main session recovery state", () => { ).toEqual({ kind: "admitted_recovery" }); expect(entry).toMatchObject({ abortedLastRun: false, - pendingFinalDeliveryAttemptCount: 1, - pendingFinalDeliveryLastAttemptAt: 300, + pendingFinalDelivery: { kind: "replayable", text: "captured reply", createdAt: 1 }, restartRecoveryRuns: [{ runId: "recovery-1", lifecycleGeneration: "generation-1" }], mainRestartRecovery: { revision: 3, diff --git a/src/agents/main-session-recovery-state.ts b/src/agents/main-session-recovery-state.ts index c78ea5e9761d..35d80dda3d6c 100644 --- a/src/agents/main-session-recovery-state.ts +++ b/src/agents/main-session-recovery-state.ts @@ -424,14 +424,10 @@ export function transitionMainSessionRecovery( runId: command.runId, lifecycleGeneration: command.lifecycleGeneration, }); - if (entry.pendingFinalDelivery || entry.pendingFinalDeliveryText) { - const pendingText = sanitizePendingFinalDeliveryText(entry.pendingFinalDeliveryText ?? ""); + if (entry.pendingFinalDelivery?.kind === "replayable") { + const pendingText = sanitizePendingFinalDeliveryText(entry.pendingFinalDelivery.text); if (pendingText) { - entry.pendingFinalDeliveryLastAttemptAt = command.now; - entry.pendingFinalDeliveryAttemptCount = - (entry.pendingFinalDeliveryAttemptCount ?? 0) + 1; - entry.pendingFinalDeliveryLastError = null; - entry.pendingFinalDeliveryText = pendingText; + entry.pendingFinalDelivery = { ...entry.pendingFinalDelivery, text: pendingText }; } else { Object.assign(entry, PENDING_FINAL_DELIVERY_CLEAR_PATCH); } diff --git a/src/agents/main-session-restart-dispatch.ts b/src/agents/main-session-restart-dispatch.ts index 6e6f221cd49b..3266f7da0454 100644 --- a/src/agents/main-session-restart-dispatch.ts +++ b/src/agents/main-session-restart-dispatch.ts @@ -143,7 +143,7 @@ export function resolveRestartRecoveryDeliveryContext(params: { const hasActiveRunDeliveryClaim = normalizeOptionalString(params.entry.restartRecoveryDeliveryRunId) !== undefined; const deliveryContext = - normalizeDeliveryContext(params.entry.pendingFinalDeliveryContext) ?? + normalizeDeliveryContext(params.entry.pendingFinalDelivery?.context) ?? activeRunDeliveryContext ?? (params.includeSessionDeliveryFallback && !hasActiveRunDeliveryClaim ? deliveryContextFromSession(params.entry) diff --git a/src/agents/main-session-restart-recovery-checkpoint.ts b/src/agents/main-session-restart-recovery-checkpoint.ts index b65c56e55c82..5718cd6c2794 100644 --- a/src/agents/main-session-restart-recovery-checkpoint.ts +++ b/src/agents/main-session-restart-recovery-checkpoint.ts @@ -317,13 +317,6 @@ export async function markSessionCompletedAfterRecoveryCheckpoint(params: { abortedLastRun: false, endedAt, pendingFinalDelivery: undefined, - pendingFinalDeliveryText: undefined, - pendingFinalDeliveryCreatedAt: undefined, - pendingFinalDeliveryLastAttemptAt: undefined, - pendingFinalDeliveryAttemptCount: undefined, - pendingFinalDeliveryLastError: undefined, - pendingFinalDeliveryContext: undefined, - pendingFinalDeliveryIntentId: undefined, restartRecoveryForceSafeTools: undefined, restartRecoveryRuns: undefined, runtimeMs: diff --git a/src/agents/main-session-restart-recovery-store.ts b/src/agents/main-session-restart-recovery-store.ts index ed82a4a25894..11100e5409d3 100644 --- a/src/agents/main-session-restart-recovery-store.ts +++ b/src/agents/main-session-restart-recovery-store.ts @@ -310,8 +310,7 @@ export async function recoverStore(params: { }; if ( - entry.pendingFinalDelivery === true && - entry.pendingFinalDeliveryText && + entry.pendingFinalDelivery?.kind === "replayable" && entry.restartRecoveryForceSafeTools === true ) { if (await failBlockedResume()) { @@ -325,7 +324,7 @@ export async function recoverStore(params: { recoveryAttempt: recoveryView.nextAttempt, storePath: params.storePath, sessionKey, - pendingFinalDeliveryText: entry.pendingFinalDeliveryText, + pendingFinalDeliveryText: entry.pendingFinalDelivery.text, forceRestartSafeTools: true, sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, gatewayRuntime: params.gatewayRuntime, @@ -351,7 +350,7 @@ export async function recoverStore(params: { }, ); } catch (err) { - if (entry.pendingFinalDelivery === true && entry.pendingFinalDeliveryText) { + if (entry.pendingFinalDelivery?.kind === "replayable") { if (await failBlockedResume()) { continue; } @@ -366,7 +365,7 @@ export async function recoverStore(params: { recoveryAttempt: recoveryView.nextAttempt, storePath: params.storePath, sessionKey, - pendingFinalDeliveryText: entry.pendingFinalDeliveryText, + pendingFinalDeliveryText: entry.pendingFinalDelivery.text, sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, gatewayRuntime: params.gatewayRuntime, }); @@ -378,7 +377,7 @@ export async function recoverStore(params: { continue; } - if (entry.pendingFinalDelivery === true && entry.pendingFinalDeliveryText) { + if (entry.pendingFinalDelivery?.kind === "replayable") { if (await failBlockedResume()) { continue; } @@ -390,7 +389,7 @@ export async function recoverStore(params: { recoveryAttempt: recoveryView.nextAttempt, storePath: params.storePath, sessionKey, - pendingFinalDeliveryText: entry.pendingFinalDeliveryText, + pendingFinalDeliveryText: entry.pendingFinalDelivery.text, forceRestartSafeTools: hasReplaySafeCodeModeCheckpointInCurrentTurn(messages), sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, gatewayRuntime: params.gatewayRuntime, @@ -496,7 +495,6 @@ export async function recoverStore(params: { recoveryAttempt: recoveryView.nextAttempt, storePath: params.storePath, sessionKey, - pendingFinalDeliveryText: entry.pendingFinalDeliveryText, forceRestartSafeTools: entry.restartRecoveryForceSafeTools === true || resumePolicy.forceRestartSafeTools, sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 7b595d911f1c..9733a6bae64b 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -1547,18 +1547,20 @@ describe("main-session-restart-recovery", () => { await writeMainSession({ sessionsDir, restartRecoveryForceSafeTools: true, - pendingFinalDelivery: true, - pendingFinalDeliveryText: pendingPayload, + pendingFinalDelivery: { + kind: "replayable", + text: pendingPayload, + createdAt: Date.now() - 5_000, + context: { + channel: "discord", + to: "discord:dm:final", + accountId: "main", + }, + }, restartRecoveryBeforeAgentReplyState: "handled-reply", restartRecoveryDeliveryRunId: "discord-message-1", restartRecoveryDeliverySourceRunId: "discord-message-1", restartRecoverySourceIngress: "channel", - pendingFinalDeliveryContext: { - channel: "discord", - to: "discord:dm:final", - accountId: "main", - }, - pendingFinalDeliveryCreatedAt: Date.now() - 5_000, restartRecoveryDeliveryContext: { channel: "discord", to: "discord:dm:stale", @@ -1588,16 +1590,12 @@ describe("main-session-restart-recovery", () => { const store = readStore(path.join(sessionsDir, "sessions.json")); const entry = store["agent:main:main"]; expect(entry?.abortedLastRun).toBe(false); - expect(entry?.pendingFinalDelivery).toBe(true); - expect(entry?.pendingFinalDeliveryText).toBe(pendingPayload); - expect(entry?.pendingFinalDeliveryAttemptCount).toBe(1); - expect(entry?.pendingFinalDeliveryLastError).toBeNull(); + expect(entry?.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: pendingPayload, + }); expect(entry?.restartRecoveryForceSafeTools).toBe(true); - expect(entry?.pendingFinalDeliveryCreatedAt).toBeLessThanOrEqual(beforeStoreRead); - expect(entry?.pendingFinalDeliveryLastAttemptAt).toBeLessThanOrEqual(beforeStoreRead); - expect(entry?.pendingFinalDeliveryLastAttemptAt ?? 0).toBeGreaterThanOrEqual( - entry?.pendingFinalDeliveryCreatedAt ?? Number.POSITIVE_INFINITY, - ); + expect(entry?.pendingFinalDelivery?.createdAt).toBeLessThanOrEqual(beforeStoreRead); }); it("keeps a hook-owned pending final behind the unsafe-hook gate after claim cleanup", async () => { @@ -1608,11 +1606,14 @@ describe("main-session-restart-recovery", () => { await writeMainSession({ sessionsDir, sessionKey, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "hook reply", - pendingFinalDeliveryContext: { - channel: "discord", - to: "discord:dm:123", + pendingFinalDelivery: { + kind: "replayable", + text: "hook reply", + createdAt: Date.now(), + context: { + channel: "discord", + to: "discord:dm:123", + }, }, restartRecoveryBeforeAgentReplyState: "handled-reply", restartRecoveryForceSafeTools: true, @@ -1634,9 +1635,11 @@ describe("main-session-restart-recovery", () => { const sessionsDir = await makeSessionsDir(); await writeMainSession({ sessionsDir, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "Safe work finished.", - pendingFinalDeliveryCreatedAt: Date.now() - 5_000, + pendingFinalDelivery: { + kind: "replayable", + text: "Safe work finished.", + createdAt: Date.now() - 5_000, + }, }); await writeTranscript(sessionsDir, "main-session", [ { role: "user", content: "do the thing" }, @@ -1674,9 +1677,11 @@ describe("main-session-restart-recovery", () => { ].join("\n"); await writeMainSession({ sessionsDir, - pendingFinalDelivery: true, - pendingFinalDeliveryText: pendingPayload, - pendingFinalDeliveryCreatedAt: Date.now() - 5_000, + pendingFinalDelivery: { + kind: "replayable", + text: pendingPayload, + createdAt: Date.now() - 5_000, + }, }); await writeTranscript(sessionsDir, "main-session", [ { role: "user", content: "calculate the answer" }, @@ -1690,7 +1695,10 @@ describe("main-session-restart-recovery", () => { expect(gatewayParams().message).not.toContain("Conversation info"); const store = readStore(path.join(sessionsDir, "sessions.json")); - expect(store["agent:main:main"]?.pendingFinalDeliveryText).toBe("The final answer is 42."); + expect(store["agent:main:main"]?.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: "The final answer is 42.", + }); }); it("resumes an unguarded pending final delivery without a transcript", async () => { @@ -1699,9 +1707,11 @@ describe("main-session-restart-recovery", () => { "agent:main:main": { ...runningSessionEntry("missing-transcript-session"), abortedLastRun: true, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "The durable final answer.", - pendingFinalDeliveryCreatedAt: Date.now() - 5_000, + pendingFinalDelivery: { + kind: "replayable", + text: "The durable final answer.", + createdAt: Date.now() - 5_000, + }, }, }); @@ -1714,9 +1724,11 @@ describe("main-session-restart-recovery", () => { const sessionsDir = await makeSessionsDir(); await writeMainSession({ sessionsDir, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "assistant final was already captured", - pendingFinalDeliveryCreatedAt: Date.now() - 5_000, + pendingFinalDelivery: { + kind: "replayable", + text: "assistant final was already captured", + createdAt: Date.now() - 5_000, + }, }); await writeTranscript(sessionsDir, "main-session", [ { role: "user", content: "finish" }, @@ -1728,10 +1740,10 @@ describe("main-session-restart-recovery", () => { expect(gatewayParams().message).toContain("assistant final was already captured"); const store = readStore(path.join(sessionsDir, "sessions.json")); expect(store["agent:main:main"]?.status).toBe("running"); - expect(store["agent:main:main"]?.pendingFinalDelivery).toBe(true); - expect(store["agent:main:main"]?.pendingFinalDeliveryText).toBe( - "assistant final was already captured", - ); + expect(store["agent:main:main"]?.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: "assistant final was already captured", + }); }); it("does not scan ordinary running sessions without the restart-aborted marker", async () => { @@ -2137,8 +2149,11 @@ describe("main-session-restart-recovery", () => { const sessionsDir = await makeSessionsDir(); await writeMainSession({ sessionsDir, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "interrupted response", + pendingFinalDelivery: { + kind: "replayable", + text: "interrupted response", + createdAt: Date.now(), + }, }); const suspensionRef: { @@ -2535,8 +2550,11 @@ describe("main-session-restart-recovery", () => { revision: 1, chargedAttempts: 2, }, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "interrupted response", + pendingFinalDelivery: { + kind: "replayable", + text: "interrupted response", + createdAt: Date.now(), + }, }); vi.mocked(callGateway) .mockImplementationOnce(async () => { @@ -2887,7 +2905,6 @@ describe("main-session-restart-recovery", () => { status: "running", abortedLastRun: true, restartRecoveryBeforeAgentReplyState: "pending", - pendingFinalDeliveryIntentId: "pending-1", restartRecoveryDeliveryRunId: "recovery-1", restartRecoveryDeliverySourceRunId: "discord-message-1", restartRecoveryDeliveryContext: discordDeliveryContext, @@ -2936,8 +2953,6 @@ describe("main-session-restart-recovery", () => { expect(completed?.restartRecoveryDeliveryContext).toBeUndefined(); expect(completed?.restartRecoveryBeforeAgentReplyState).toBeUndefined(); expect(completed?.pendingFinalDelivery).toBeUndefined(); - expect(completed?.pendingFinalDeliveryText).toBeUndefined(); - expect(completed?.pendingFinalDeliveryIntentId).toBeUndefined(); }); it("resumes after an unhandled before_agent_reply hook checkpoint", async () => { diff --git a/src/agents/pending-final-delivery-marker.ts b/src/agents/pending-final-delivery-marker.ts index 2b804d9d9869..dffd2a99feb5 100644 --- a/src/agents/pending-final-delivery-marker.ts +++ b/src/agents/pending-final-delivery-marker.ts @@ -25,7 +25,6 @@ type PersistPendingFinalDeliveryMarkerParams = { type PendingFinalDeliveryMarkerResult = { sessionEntry?: SessionEntry; - pendingFinalDeliveryTextForThisRun?: string; pendingFinalDeliveryMarkerPersisted: boolean; hasSendableFinalPayload: boolean; }; @@ -72,22 +71,23 @@ export async function persistPendingFinalDeliveryMarker( initialEntry: entry, entry: { ...entry, - pendingFinalDelivery: true, - pendingFinalDeliveryText: recoverableText, - pendingFinalDeliveryContext: params.deliveryContext, - pendingFinalDeliveryCreatedAt: now, + pendingFinalDelivery: { + kind: "replayable", + text: recoverableText, + createdAt: now, + ...(params.deliveryContext ? { context: params.deliveryContext } : {}), + }, updatedAt: now, }, shouldPersist: (current) => current?.sessionId === params.runOwnedSessionId && current.abortedLastRun !== true, }); const markerPersisted = - persisted?.pendingFinalDelivery === true && - persisted.pendingFinalDeliveryText === recoverableText; + persisted?.pendingFinalDelivery?.kind === "replayable" && + persisted.pendingFinalDelivery.text === recoverableText; return { sessionEntry: persisted, - pendingFinalDeliveryTextForThisRun: markerPersisted ? recoverableText : undefined, pendingFinalDeliveryMarkerPersisted: markerPersisted, hasSendableFinalPayload, }; diff --git a/src/auto-reply/fallback-state.test.ts b/src/auto-reply/fallback-state.test.ts index 56e086be67f8..9d048135361f 100644 --- a/src/auto-reply/fallback-state.test.ts +++ b/src/auto-reply/fallback-state.test.ts @@ -15,9 +15,12 @@ const baseAttempt = { }; const activeFallbackState: FallbackNoticeState = { - fallbackNoticeSelectedModel: "demo-primary/model-a", - fallbackNoticeActiveModel: "demo-fallback/model-b", - fallbackNoticeReason: "rate limit", + fallbackNotice: { + kind: "active", + selectedModel: "demo-primary/model-a", + activeModel: "demo-fallback/model-b", + reason: "rate limit", + }, }; function registerAnthropicCliBackendForTest(): void { @@ -62,9 +65,12 @@ describe("fallback-state", () => { { name: "does not treat runtime drift as fallback when persisted state does not match", state: { - fallbackNoticeSelectedModel: "other-provider/other-model", - fallbackNoticeActiveModel: "demo-fallback/model-b", - fallbackNoticeReason: "rate limit", + fallbackNotice: { + kind: "active", + selectedModel: "other-provider/other-model", + activeModel: "demo-fallback/model-b", + reason: "rate limit", + }, } satisfies FallbackNoticeState, expected: { active: false, reason: undefined }, }, @@ -191,9 +197,12 @@ describe("fallback-state", () => { activeModel: "claude-opus-4-7", attempts: [], state: { - fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7", - fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7", - fallbackNoticeReason: "selected model unavailable", + fallbackNotice: { + kind: "active", + selectedModel: "anthropic/claude-opus-4-7", + activeModel: "claude-cli/claude-opus-4-7", + reason: "selected model unavailable", + }, }, cfg: {}, }); @@ -235,9 +244,12 @@ describe("fallback-state", () => { activeModel: "claude-opus-4-7", attempts: [], state: { - fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7", - fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7", - fallbackNoticeReason: "selected model unavailable", + fallbackNotice: { + kind: "active", + selectedModel: "anthropic/claude-opus-4-7", + activeModel: "claude-cli/claude-opus-4-7", + reason: "selected model unavailable", + }, }, cfg: {}, }); diff --git a/src/auto-reply/fallback-state.ts b/src/auto-reply/fallback-state.ts index 5349cd55f368..753c6ca0ea61 100644 --- a/src/auto-reply/fallback-state.ts +++ b/src/auto-reply/fallback-state.ts @@ -154,9 +154,9 @@ export function resolveFallbackTransition(params: { const selectedModelRef = formatProviderModelRef(params.selectedProvider, params.selectedModel); const activeModelRef = formatProviderModelRef(params.activeProvider, params.activeModel); const previousState = { - selectedModel: normalizeOptionalString(params.state?.fallbackNoticeSelectedModel), - activeModel: normalizeOptionalString(params.state?.fallbackNoticeActiveModel), - reason: normalizeOptionalString(params.state?.fallbackNoticeReason), + selectedModel: normalizeOptionalString(params.state?.fallbackNotice?.selectedModel), + activeModel: normalizeOptionalString(params.state?.fallbackNotice?.activeModel), + reason: normalizeOptionalString(params.state?.fallbackNotice?.reason), }; const comparisonOptions = { config: params.cfg }; const fallbackActive = !areRuntimeModelRefsEquivalent( diff --git a/src/auto-reply/reply/agent-runner-auto-fallback.ts b/src/auto-reply/reply/agent-runner-auto-fallback.ts index e38408c3b2e3..5a01cf7f4192 100644 --- a/src/auto-reply/reply/agent-runner-auto-fallback.ts +++ b/src/auto-reply/reply/agent-runner-auto-fallback.ts @@ -159,9 +159,7 @@ export async function clearRecoveredAutoFallbackPrimaryProbeSelection(params: { authProfileOverrideCompactionCount: undefined, } : {}), - fallbackNoticeSelectedModel: undefined, - fallbackNoticeActiveModel: undefined, - fallbackNoticeReason: undefined, + fallbackNotice: undefined, updatedAt: persistedEntry.updatedAt, }; }, diff --git a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts index fc648e06b6ae..86ddf841c75d 100644 --- a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { replaceSessionEntry } from "../../config/sessions/session-accessor.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import { withTempDir } from "../../test-helpers/temp-dir.js"; import { getReplyPayloadMetadata } from "../reply-payload.js"; import type { TemplateContext } from "../templating.js"; @@ -368,11 +369,11 @@ describe("runReplyAgent runtime config", () => { isActive: false, }); const sessionKey = "agent:main:telegram:default:direct:test"; - const sessionEntry = { + const sessionEntry: SessionEntry = { sessionId: "session-1", updatedAt: 1, compactionCount: 4, - memoryFlushFailureCount: 2, + memoryFlush: { kind: "failed", failureCount: 2 }, }; const sessionStore = { [sessionKey]: sessionEntry }; const storePath = join(tempDir, "sessions.json"); @@ -394,7 +395,7 @@ describe("runReplyAgent runtime config", () => { ); runMemoryFlushIfNeededMock.mockImplementation( async (params: { - sessionEntry?: typeof sessionEntry; + sessionEntry?: SessionEntry; onVisibleErrorPayloads?: (payloads: Array<{ text?: string; isError?: boolean }>) => void; }) => { params.onVisibleErrorPayloads?.([ @@ -406,8 +407,7 @@ describe("runReplyAgent runtime config", () => { return { sessionEntry: { ...params.sessionEntry, - memoryFlushFailureCount: 3, - memoryFlushCompactionCount: 4, + memoryFlush: { kind: "failed", compactionCount: 4, failureCount: 3 }, }, outcome: "exhausted", }; @@ -415,10 +415,10 @@ describe("runReplyAgent runtime config", () => { ); resetReplyRunSessionMock.mockImplementation(async (params: unknown) => { const resetParams = params as { - activeSessionEntry?: typeof sessionEntry; - activeSessionStore?: Record; + activeSessionEntry?: SessionEntry; + activeSessionStore?: Record; followupRun: typeof followupRun; - onActiveSessionEntry: (entry: typeof sessionEntry) => void; + onActiveSessionEntry: (entry: SessionEntry) => void; onNewSession: (sessionId: string, sessionFile: string) => void; }; const sessionFile = "/tmp/session-rotated.jsonl"; @@ -426,7 +426,6 @@ describe("runReplyAgent runtime config", () => { ...resetParams.activeSessionEntry, sessionId: "session-rotated", updatedAt: 1, - memoryFlushFailureCount: 0, compactionCount: 0, }; if (resetParams.activeSessionStore) { diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts index f6a846c577c0..d85f35f974f8 100644 --- a/src/auto-reply/reply/agent-runner-memory.test.ts +++ b/src/auto-reply/reply/agent-runner-memory.test.ts @@ -434,8 +434,7 @@ describe("runMemoryFlushIfNeeded", () => { const persisted = loadMainSessionEntry(storePath); expect(persisted.sessionId).toBe("session-rotated"); expect(persisted.compactionCount).toBe(2); - expect(persisted.memoryFlushCompactionCount).toBe(1); - expect(persisted.memoryFlushAt).toBe(1_700_000_000_000); + expect(persisted.memoryFlush).toEqual({ kind: "succeeded", compactionCount: 1 }); }); it("records the least-trusted provenance across a multi-write flush", async () => { @@ -670,8 +669,7 @@ describe("runMemoryFlushIfNeeded", () => { const persisted = loadMainSessionEntry(storePath); expect(persisted.sessionId).toBe("session-rotated"); expect(persisted.compactionCount).toBe(2); - expect(persisted.memoryFlushFailureCount).toBe(1); - expect(persisted.memoryFlushAt).toBeUndefined(); + expect(persisted.memoryFlush).toEqual({ kind: "failed", failureCount: 1 }); }); it("reports restricted memory-flush write failures for visible delivery", async () => { @@ -849,9 +847,7 @@ describe("runMemoryFlushIfNeeded", () => { const persisted = loadMainSessionEntry(storePath); expect(result.outcome).toBe("failed"); - expect(persisted.memoryFlushFailureCount).toBe(1); - expect(persisted.memoryFlushLastFailedAt).toBe(1_700_000_000_000); - expect(persisted.memoryFlushLastFailureError).toBe(`${"a".repeat(198)}…`); + expect(persisted.memoryFlush).toEqual({ kind: "failed", failureCount: 1 }); expect(emitAgentEventMock).toHaveBeenCalledWith( expect.objectContaining({ stream: "lifecycle", @@ -871,7 +867,6 @@ describe("runMemoryFlushIfNeeded", () => { updatedAt: Date.now(), totalTokens: 80_000, compactionCount: 1, - memoryFlushFailureCount: 0, }; await writeTestSessionStore(storePath, "main", sessionEntry); const abortErr = new Error("operation aborted by user"); @@ -895,9 +890,7 @@ describe("runMemoryFlushIfNeeded", () => { const persisted = loadMainSessionEntry(storePath); expect(result.outcome).toBe("failed"); - expect(persisted.memoryFlushFailureCount).toBe(0); - expect(persisted.memoryFlushLastFailedAt).toBeUndefined(); - expect(persisted.memoryFlushLastFailureError).toBeUndefined(); + expect(persisted.memoryFlush).toBeUndefined(); }); it("clears failure counters on successful flush", async () => { @@ -907,9 +900,7 @@ describe("runMemoryFlushIfNeeded", () => { updatedAt: Date.now(), totalTokens: 80_000, compactionCount: 1, - memoryFlushFailureCount: 2, - memoryFlushLastFailedAt: 1_699_999_999_000, - memoryFlushLastFailureError: "provider crashed during flush", + memoryFlush: { kind: "failed", failureCount: 2 }, }; await writeTestSessionStore(storePath, "main", sessionEntry); @@ -930,9 +921,7 @@ describe("runMemoryFlushIfNeeded", () => { const persisted = loadMainSessionEntry(storePath); expect(result.outcome).toBe("completed"); - expect(persisted.memoryFlushFailureCount).toBe(0); - expect(persisted.memoryFlushLastFailedAt).toBeUndefined(); - expect(persisted.memoryFlushLastFailureError).toBeUndefined(); + expect(persisted.memoryFlush).toEqual({ kind: "succeeded", compactionCount: 1 }); }); it("marks flush as completed after MAX_FLUSH_FAILURES to break retry loop", async () => { @@ -942,7 +931,7 @@ describe("runMemoryFlushIfNeeded", () => { updatedAt: Date.now(), totalTokens: 80_000, compactionCount: 1, - memoryFlushFailureCount: TEST_MAX_FLUSH_FAILURES - 1, + memoryFlush: { kind: "failed", failureCount: TEST_MAX_FLUSH_FAILURES - 1 }, }; await writeTestSessionStore(storePath, "main", sessionEntry); runWithModelFallbackMock.mockRejectedValueOnce(new Error("provider crashed during flush")); @@ -968,8 +957,7 @@ describe("runMemoryFlushIfNeeded", () => { const persisted = loadMainSessionEntry(storePath); expect(result.outcome).toBe("exhausted"); - expect(persisted.memoryFlushCompactionCount).toBe(1); - expect(persisted.memoryFlushFailureCount).toBe(TEST_MAX_FLUSH_FAILURES); + expect(persisted.memoryFlush).toEqual({ kind: "succeeded", compactionCount: 1 }); expect(emitAgentEventMock).toHaveBeenCalledWith( expect.objectContaining({ stream: "lifecycle", @@ -1020,7 +1008,7 @@ describe("runMemoryFlushIfNeeded", () => { expect(runWithModelFallbackMock).toHaveBeenCalledTimes(2); const persisted = loadMainSessionEntry(storePath); - expect(persisted.memoryFlushFailureCount).toBe(2); + expect(persisted.memoryFlush).toEqual({ kind: "failed", failureCount: 2 }); }); it("next message retries flush after failure", async () => { diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 295b2e284979..f8e5d02fcfed 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -1315,7 +1315,7 @@ export async function runMemoryFlushIfNeeded(params: { `tokenCount=${tokenCountForFlush ?? "undefined"} ` + `contextWindow=${contextWindowTokens} threshold=${flushThreshold} ` + `isHeartbeat=${params.isHeartbeat} isCli=${isCli} memoryFlushWritable=${memoryFlushWritable} ` + - `compactionCount=${entry?.compactionCount ?? 0} memoryFlushCompactionCount=${entry?.memoryFlushCompactionCount ?? "undefined"} ` + + `compactionCount=${entry?.compactionCount ?? 0} memoryFlushCompactionCount=${entry?.memoryFlush?.compactionCount ?? "undefined"} ` + `persistedPromptTokens=${persistedPromptTokens ?? "undefined"} persistedFresh=${entry?.totalTokensFresh === true} ` + `promptTokensEst=${promptTokenEstimate ?? "undefined"} transcriptPromptTokens=${transcriptPromptTokens ?? "undefined"} transcriptOutputTokens=${transcriptOutputTokens ?? "undefined"} ` + `projectedTokenCount=${projectedTokenCount ?? "undefined"} transcriptBytes=${transcriptByteSize ?? "undefined"} ` + @@ -1563,11 +1563,7 @@ export async function runMemoryFlushIfNeeded(params: { skipMaintenance: true, takeCacheOwnership: true, update: async () => ({ - memoryFlushAt: memoryDeps.now(), - memoryFlushCompactionCount: flushedCompactionCount, - memoryFlushFailureCount: 0, - memoryFlushLastFailedAt: undefined, - memoryFlushLastFailureError: undefined, + memoryFlush: { kind: "succeeded", compactionCount: flushedCompactionCount }, }), }); if (updatedEntry) { @@ -1588,16 +1584,22 @@ export async function runMemoryFlushIfNeeded(params: { const truncatedError = truncateMemoryFlushErrorMessage(err); if (!isAbortError(err) && params.storePath && params.sessionKey) { try { - const failedAt = memoryDeps.now(); const failedEntry = await memoryDeps.updateSessionEntry({ storePath: params.storePath, sessionKey: params.sessionKey, skipMaintenance: true, takeCacheOwnership: true, update: async (sessionEntry) => ({ - memoryFlushFailureCount: Math.max(0, sessionEntry.memoryFlushFailureCount ?? 0) + 1, - memoryFlushLastFailedAt: failedAt, - memoryFlushLastFailureError: truncatedError, + memoryFlush: { + kind: "failed", + ...(sessionEntry.memoryFlush?.compactionCount !== undefined + ? { compactionCount: sessionEntry.memoryFlush.compactionCount } + : {}), + failureCount: + (sessionEntry.memoryFlush?.kind === "failed" + ? sessionEntry.memoryFlush.failureCount + : 0) + 1, + }, }), }); if (failedEntry) { @@ -1606,7 +1608,8 @@ export async function runMemoryFlushIfNeeded(params: { activeSessionStore[params.sessionKey] = failedEntry; } } - const failureCount = Math.max(0, failedEntry?.memoryFlushFailureCount ?? 0); + const failureCount = + failedEntry?.memoryFlush?.kind === "failed" ? failedEntry.memoryFlush.failureCount : 0; logVerbose( `memory flush failed (attempt ${failureCount}/${MAX_FLUSH_FAILURES}): ${truncatedError}`, ); @@ -1644,8 +1647,10 @@ export async function runMemoryFlushIfNeeded(params: { skipMaintenance: true, takeCacheOwnership: true, update: async (sessionEntry) => ({ - memoryFlushAt: memoryDeps.now(), - memoryFlushCompactionCount: sessionEntry.compactionCount ?? 0, + memoryFlush: { + kind: "succeeded", + compactionCount: sessionEntry.compactionCount ?? 0, + }, }), }); if (exhaustedEntry) { diff --git a/src/auto-reply/reply/agent-runner-result-accounting.ts b/src/auto-reply/reply/agent-runner-result-accounting.ts index 84123152dcaf..cf4a772af1a3 100644 --- a/src/auto-reply/reply/agent-runner-result-accounting.ts +++ b/src/auto-reply/reply/agent-runner-result-accounting.ts @@ -191,10 +191,18 @@ export async function accountAgentTurn(context: AgentTurnAccountingContext) { cfg, }); if (fallbackTransition.stateChanged && !fallbackExhausted && !preserveUserFacingSessionState) { + const fallbackNotice = fallbackTransition.nextState.selectedModel + ? { + kind: "active" as const, + selectedModel: fallbackTransition.nextState.selectedModel, + activeModel: fallbackTransition.nextState.activeModel!, + ...(fallbackTransition.nextState.reason + ? { reason: fallbackTransition.nextState.reason } + : {}), + } + : undefined; if (fallbackStateEntry) { - fallbackStateEntry.fallbackNoticeSelectedModel = fallbackTransition.nextState.selectedModel; - fallbackStateEntry.fallbackNoticeActiveModel = fallbackTransition.nextState.activeModel; - fallbackStateEntry.fallbackNoticeReason = fallbackTransition.nextState.reason; + fallbackStateEntry.fallbackNotice = fallbackNotice; fallbackStateEntry.updatedAt = Date.now(); activeSessionEntry = fallbackStateEntry; } @@ -202,18 +210,10 @@ export async function accountAgentTurn(context: AgentTurnAccountingContext) { activeSessionStore[sessionKey] = fallbackStateEntry; } if (sessionKey && storePath) { - await updateSessionEntry( - { storePath, sessionKey }, - () => ({ - fallbackNoticeSelectedModel: fallbackTransition.nextState.selectedModel, - fallbackNoticeActiveModel: fallbackTransition.nextState.activeModel, - fallbackNoticeReason: fallbackTransition.nextState.reason, - }), - { - skipMaintenance: true, - takeCacheOwnership: true, - }, - ); + await updateSessionEntry({ storePath, sessionKey }, () => ({ fallbackNotice }), { + skipMaintenance: true, + takeCacheOwnership: true, + }); } } const usedCliProvider = isCliProvider(providerUsed, cfg); diff --git a/src/auto-reply/reply/agent-runner-result-complete.ts b/src/auto-reply/reply/agent-runner-result-complete.ts index c6d5f782db30..2b6ee70a4e07 100644 --- a/src/auto-reply/reply/agent-runner-result-complete.ts +++ b/src/auto-reply/reply/agent-runner-result-complete.ts @@ -395,11 +395,13 @@ export async function completeReplyAgentRun(input: { (entry) => entry.sessionId === expectedSessionId ? { - pendingFinalDelivery: true, - pendingFinalDeliveryText: resolvedPendingText, - pendingFinalDeliveryIntentId, - pendingFinalDeliveryContext, - pendingFinalDeliveryCreatedAt: Date.now(), + pendingFinalDelivery: { + kind: "replayable" as const, + text: resolvedPendingText, + intentId: pendingFinalDeliveryIntentId, + context: pendingFinalDeliveryContext, + createdAt: Date.now(), + }, updatedAt: Date.now(), } : null, @@ -410,7 +412,8 @@ export async function completeReplyAgentRun(input: { ); if ( persistedPendingFinalDelivery?.sessionId !== expectedSessionId || - persistedPendingFinalDelivery.pendingFinalDeliveryIntentId !== pendingFinalDeliveryIntentId + persistedPendingFinalDelivery.pendingFinalDelivery?.intentId !== + pendingFinalDeliveryIntentId ) { throw new Error("pending final delivery session changed or was deleted"); } diff --git a/src/auto-reply/reply/agent-runner-session-reset.test.ts b/src/auto-reply/reply/agent-runner-session-reset.test.ts index 90369053cba0..09f21fdacc9c 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.test.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.test.ts @@ -102,16 +102,14 @@ describe("resetReplyRunSession", () => { unwindowedMessageCount: 10, sessionId: "session", }, - fallbackNoticeSelectedModel: "anthropic/claude", - fallbackNoticeActiveModel: "openai/gpt", - fallbackNoticeReason: "rate limit", + fallbackNotice: { + kind: "active", + selectedModel: "anthropic/claude", + activeModel: "openai/gpt", + reason: "rate limit", + }, compactionCount: 4, - memoryFlushAt: 50, - memoryFlushCompactionCount: 3, - memoryFlushContextHash: "context-hash", - memoryFlushFailureCount: 2, - memoryFlushLastFailedAt: 60, - memoryFlushLastFailureError: "memory failed", + memoryFlush: { kind: "failed", compactionCount: 3, failureCount: 2 }, systemPromptReport: { source: "run", generatedAt: 1, @@ -158,24 +156,12 @@ describe("resetReplyRunSession", () => { expect(activeSessionEntry?.model).toBeUndefined(); expect(activeSessionEntry?.contextTokens).toBeUndefined(); expect(activeSessionEntry?.contextBudgetStatus).toBeUndefined(); - expect(activeSessionEntry?.fallbackNoticeSelectedModel).toBeUndefined(); - expect(activeSessionEntry?.fallbackNoticeActiveModel).toBeUndefined(); - expect(activeSessionEntry?.fallbackNoticeReason).toBeUndefined(); + expect(activeSessionEntry?.fallbackNotice).toBeUndefined(); expect(activeSessionEntry?.compactionCount).toBe(0); - expect(activeSessionEntry?.memoryFlushAt).toBeUndefined(); - expect(activeSessionEntry?.memoryFlushCompactionCount).toBeUndefined(); - expect(activeSessionEntry?.memoryFlushContextHash).toBeUndefined(); - expect(activeSessionEntry?.memoryFlushFailureCount).toBeUndefined(); - expect(activeSessionEntry?.memoryFlushLastFailedAt).toBeUndefined(); - expect(activeSessionEntry?.memoryFlushLastFailureError).toBeUndefined(); + expect(activeSessionEntry?.memoryFlush).toBeUndefined(); expect(activeSessionEntry?.systemPromptReport).toBeUndefined(); expect(activeSessionEntry?.compactionCount).toBe(0); - expect(activeSessionEntry?.memoryFlushAt).toBeUndefined(); - expect(activeSessionEntry?.memoryFlushCompactionCount).toBeUndefined(); - expect(activeSessionEntry?.memoryFlushContextHash).toBeUndefined(); - expect(activeSessionEntry?.memoryFlushFailureCount).toBeUndefined(); - expect(activeSessionEntry?.memoryFlushLastFailedAt).toBeUndefined(); - expect(activeSessionEntry?.memoryFlushLastFailureError).toBeUndefined(); + expect(activeSessionEntry?.memoryFlush).toBeUndefined(); expect(refreshQueuedFollowupSessionMock).toHaveBeenCalledWith({ key: "main", previousSessionId: "session", @@ -194,12 +180,9 @@ describe("resetReplyRunSession", () => { const persisted = loadSessionEntry({ storePath, sessionKey: "main" }); expect(persisted?.sessionId).toBe(activeSessionEntry?.sessionId); expect(persisted?.contextBudgetStatus).toBeUndefined(); - expect(persisted?.fallbackNoticeReason).toBeUndefined(); + expect(persisted?.fallbackNotice).toBeUndefined(); expect(persisted?.compactionCount).toBe(0); - expect(persisted?.memoryFlushAt).toBeUndefined(); - expect(persisted?.memoryFlushFailureCount).toBeUndefined(); - expect(persisted?.memoryFlushLastFailedAt).toBeUndefined(); - expect(persisted?.memoryFlushLastFailureError).toBeUndefined(); + expect(persisted?.memoryFlush).toBeUndefined(); }); it("rejects automatic recovery rotation for a model-locked session", async () => { diff --git a/src/auto-reply/reply/agent-runner-session-reset.ts b/src/auto-reply/reply/agent-runner-session-reset.ts index bfc91538c148..9587db3b0be4 100644 --- a/src/auto-reply/reply/agent-runner-session-reset.ts +++ b/src/auto-reply/reply/agent-runner-session-reset.ts @@ -91,16 +91,9 @@ export async function resetReplyRunSession(params: { contextTokens: undefined, contextBudgetStatus: undefined, systemPromptReport: undefined, - fallbackNoticeSelectedModel: undefined, - fallbackNoticeActiveModel: undefined, - fallbackNoticeReason: undefined, + fallbackNotice: undefined, compactionCount: 0, - memoryFlushAt: undefined, - memoryFlushCompactionCount: undefined, - memoryFlushContextHash: undefined, - memoryFlushFailureCount: undefined, - memoryFlushLastFailedAt: undefined, - memoryFlushLastFailureError: undefined, + memoryFlush: undefined, }; clearAllCliSessions(nextEntry); nextEntry.agentHarnessId = undefined; diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index e6fa0d0886db..e2400b6245ba 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -1101,7 +1101,6 @@ describe("runReplyAgent pending final delivery capture", () => { const stored = await readStoredMainSession(storePath); expect(stored.pendingFinalDelivery).toBeUndefined(); - expect(stored.pendingFinalDeliveryText).toBeUndefined(); }); it("does not persist sendPolicy-denied final replies for heartbeat replay", async () => { @@ -1128,7 +1127,6 @@ describe("runReplyAgent pending final delivery capture", () => { const stored = await readStoredMainSession(storePath); expect(stored.pendingFinalDelivery).toBeUndefined(); - expect(stored.pendingFinalDeliveryText).toBeUndefined(); }); it("persists only visible non-reasoning final reply text", async () => { @@ -1153,14 +1151,16 @@ describe("runReplyAgent pending final delivery capture", () => { const result = await run(); const stored = await readStoredMainSession(storePath); - expect(stored.pendingFinalDelivery).toBe(true); - expect(stored.pendingFinalDeliveryText).toBe("visible final"); - expect(stored.pendingFinalDeliveryIntentId).toEqual(expect.any(String)); + expect(stored.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: "visible final", + intentId: expect.any(String), + }); const visiblePayload = (Array.isArray(result) ? result : [result]).find( (payload) => payload?.text === "visible final", ); expect(getReplyPayloadMetadata(visiblePayload ?? {})).toMatchObject({ - pendingFinalDeliveryIntentId: stored.pendingFinalDeliveryIntentId, + pendingFinalDeliveryIntentId: stored.pendingFinalDelivery?.intentId, pendingFinalDeliveryRetryText: "visible final", }); }); @@ -1188,14 +1188,16 @@ describe("runReplyAgent pending final delivery capture", () => { const result = await run(); const stored = loadSessionEntry({ sessionKey, storePath }); expect(stored).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryIntentId: expect.any(String), - pendingFinalDeliveryText: "visible canonical final", + pendingFinalDelivery: { + kind: "replayable", + intentId: expect.any(String), + text: "visible canonical final", + }, sessionId: "session", }); const visiblePayload = Array.isArray(result) ? result[0] : result; expect(getReplyPayloadMetadata(visiblePayload ?? {})).toMatchObject({ - pendingFinalDeliveryIntentId: stored?.pendingFinalDeliveryIntentId, + pendingFinalDeliveryIntentId: stored?.pendingFinalDelivery?.intentId, pendingFinalDeliveryRetryText: "visible canonical final", }); expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce(); @@ -1323,13 +1325,15 @@ describe("runReplyAgent pending final delivery capture", () => { await run(); const stored = await readStoredMainSession(storePath); - expect(stored.pendingFinalDelivery).toBe(true); - expect(stored.pendingFinalDeliveryText).toBe("visible final"); - expect(stored.pendingFinalDeliveryContext).toEqual({ - channel: "discord", - to: "channel:24680", - accountId: "work", - threadId: "1503645939964055592", + expect(stored.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: "visible final", + context: { + channel: "discord", + to: "channel:24680", + accountId: "work", + threadId: "1503645939964055592", + }, }); expect(stored.restartRecoverySourceIngress).toBe("channel"); expect(stored.restartRecoveryDeliveryContext).toBeUndefined(); @@ -1397,7 +1401,6 @@ describe("runReplyAgent pending final delivery capture", () => { }); const stored = await readStoredMainSession(storePath); expect(stored.pendingFinalDelivery).toBeUndefined(); - expect(stored.pendingFinalDeliveryText).toBeUndefined(); expect(stored.restartRecoveryDeliveryReceiptState).toBeUndefined(); expect(stored.restartRecoveryDeliveryToolCallId).toBeUndefined(); expect(stored.restartRecoveryDeliveryRunId).toBeUndefined(); @@ -2320,8 +2323,7 @@ describe("runReplyAgent pending final delivery capture", () => { expect(state.beforeAgentReplyRunMock).toHaveBeenCalledOnce(); expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce(); expect(await readStoredMainSession(storePath)).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: "hook reply", + pendingFinalDelivery: { kind: "replayable", text: "hook reply" }, restartRecoveryBeforeAgentReplyState: "handled-reply", restartRecoveryForceSafeTools: true, restartRecoverySourceIngress: "channel", @@ -2420,7 +2422,7 @@ describe("runReplyAgent pending final delivery capture", () => { expect(state.beforeAgentReplyRunMock).toHaveBeenCalledOnce(); expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce(); expect(await readStoredMainSession(storePath)).toMatchObject({ - pendingFinalDelivery: true, + pendingFinalDelivery: { kind: "transport-only" }, restartRecoveryBeforeAgentReplyState: "continue", restartRecoverySourceIngress: "channel", }); @@ -2498,8 +2500,10 @@ describe("runReplyAgent pending final delivery capture", () => { await run(); const stored = await readStoredMainSession(storePath); - expect(stored.pendingFinalDelivery).toBe(true); - expect(stored.pendingFinalDeliveryText).toBe("Sent daily summary to channel."); + expect(stored.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: "Sent daily summary to channel.", + }); }); it("persists heartbeat reply remainder as pending delivery when remainder exceeds ackMaxChars", async () => { @@ -2529,11 +2533,13 @@ describe("runReplyAgent pending final delivery capture", () => { const result = await run(); const stored = await readStoredMainSession(storePath); - expect(stored.pendingFinalDelivery).toBe(true); - expect(stored.pendingFinalDeliveryText).toBe(longRemainder); + expect(stored.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: longRemainder, + }); const payload = Array.isArray(result) ? result[0] : result; expect(getReplyPayloadMetadata(payload ?? {})).toMatchObject({ - pendingFinalDeliveryIntentId: stored.pendingFinalDeliveryIntentId, + pendingFinalDeliveryIntentId: stored.pendingFinalDelivery?.intentId, pendingFinalDeliveryRetryText: longRemainder, }); }); @@ -2597,7 +2603,6 @@ describe("runReplyAgent typing (heartbeat)", () => { const stored = requireStoredSessionEntry(storePath); expect(stored.pendingFinalDelivery).toBeUndefined(); - expect(stored.pendingFinalDeliveryText).toBeUndefined(); } finally { await rm(dir, { recursive: true, force: true }); } @@ -3087,11 +3092,11 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(stored.modelOverrideSource, testCase.name).toBe("user"); expect(stored.modelProvider, testCase.name).toBe("deepinfra"); expect(stored.model, testCase.name).toBe("moonshotai/Kimi-K2.5"); - expect(stored.fallbackNoticeSelectedModel, testCase.name).toBe("openai/gpt-5.6-luna"); - expect(stored.fallbackNoticeActiveModel, testCase.name).toBe( + expect(stored.fallbackNotice?.selectedModel, testCase.name).toBe("openai/gpt-5.6-luna"); + expect(stored.fallbackNotice?.activeModel, testCase.name).toBe( "deepinfra/moonshotai/Kimi-K2.5", ); - expect(stored.fallbackNoticeReason, testCase.name).toBe("rate limit"); + expect(stored.fallbackNotice?.reason, testCase.name).toBe("rate limit"); expect( phases.filter((phase) => phase === "fallback"), testCase.name, @@ -3477,17 +3482,14 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(sessionEntry.providerOverride).toBeUndefined(); expect(sessionEntry.modelOverride).toBeUndefined(); expect(sessionEntry.modelOverrideSource).toBeUndefined(); - expect(sessionEntry.fallbackNoticeSelectedModel).toBeUndefined(); - expect(sessionEntry.fallbackNoticeActiveModel).toBeUndefined(); - expect(sessionEntry.fallbackNoticeReason).toBeUndefined(); + expect(sessionEntry.fallbackNotice).toBeUndefined(); const persistedSession = requireStoredSessionEntry(storePath); expect(persistedSession.modelProvider).toBe("openai"); expect(persistedSession.model).toBe("gpt-5.5"); expect(persistedSession.providerOverride).toBeUndefined(); expect(persistedSession.modelOverride).toBeUndefined(); expect(persistedSession.modelOverrideSource).toBeUndefined(); - expect(persistedSession.fallbackNoticeSelectedModel).toBeUndefined(); - expect(persistedSession.fallbackNoticeActiveModel).toBeUndefined(); + expect(persistedSession.fallbackNotice).toBeUndefined(); const payloads = Array.isArray(res) ? res : res ? [res] : []; expect(payloads.some((payload) => payload.text?.includes("Model Fallback:"))).toBe(false); expect(payloads.some((payload) => payload.text?.includes("Usage:"))).toBe(false); @@ -3556,9 +3558,7 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(sessionEntry.providerOverride).toBeUndefined(); expect(sessionEntry.modelOverride).toBeUndefined(); expect(sessionEntry.modelOverrideSource).toBeUndefined(); - expect(sessionEntry.fallbackNoticeSelectedModel).toBeUndefined(); - expect(sessionEntry.fallbackNoticeActiveModel).toBeUndefined(); - expect(sessionEntry.fallbackNoticeReason).toBeUndefined(); + expect(sessionEntry.fallbackNotice).toBeUndefined(); }); it("keeps fallback transition notices when block streaming has no final text", async () => { @@ -4504,9 +4504,12 @@ describe("runReplyAgent typing (heartbeat)", () => { const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now(), - fallbackNoticeSelectedModel: "anthropic/claude", - fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - ...(testCase.existingReason ? { fallbackNoticeReason: testCase.existingReason } : {}), + fallbackNotice: { + kind: "active" as const, + selectedModel: "anthropic/claude", + activeModel: "deepinfra/moonshotai/Kimi-K2.5", + ...(testCase.existingReason ? { reason: testCase.existingReason } : {}), + }, modelProvider: "deepinfra", model: "moonshotai/Kimi-K2.5", }; @@ -4544,7 +4547,7 @@ describe("runReplyAgent typing (heartbeat)", () => { const res = await run(); const firstText = Array.isArray(res) ? res[0]?.text : res?.text; expect(firstText).not.toContain("Model Fallback:"); - expect(sessionEntry.fallbackNoticeReason).toBe(testCase.expectedReason); + expect(sessionEntry.fallbackNotice?.reason).toBe(testCase.expectedReason); } finally { fallbackSpy.mockRestore(); } @@ -4555,9 +4558,12 @@ describe("runReplyAgent typing (heartbeat)", () => { const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now(), - fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7", - fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7", - fallbackNoticeReason: "selected model unavailable", + fallbackNotice: { + kind: "active", + selectedModel: "anthropic/claude-opus-4-7", + activeModel: "claude-cli/claude-opus-4-7", + reason: "selected model unavailable", + }, }; const sessionStore = { main: sessionEntry }; const dir = await mkdtemp(join(tmpdir(), "openclaw-agent-runner-cli-alias-")); @@ -4589,10 +4595,8 @@ describe("runReplyAgent typing (heartbeat)", () => { await run(); const stored = requireStoredSessionEntry(storePath); - expect(sessionEntry.fallbackNoticeSelectedModel).toBeUndefined(); - expect(sessionEntry.fallbackNoticeActiveModel).toBeUndefined(); - expect(stored.fallbackNoticeSelectedModel).toBeUndefined(); - expect(stored.fallbackNoticeActiveModel).toBeUndefined(); + expect(sessionEntry.fallbackNotice).toBeUndefined(); + expect(stored.fallbackNotice).toBeUndefined(); expect(stored.modelProvider).toBe("claude-cli"); expect(stored.model).toBe("claude-opus-4-7"); expect(stored.totalTokens).toBe(36_000); diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts index d97cd4ddcf10..fbc9967a5d66 100644 --- a/src/auto-reply/reply/commands-status.test.ts +++ b/src/auto-reply/reply/commands-status.test.ts @@ -1462,9 +1462,12 @@ describe("buildStatusReply subagent summary", () => { modelOverride: "mimo-v2-flash", modelProvider: "minimax-portal", model: "MiniMax-M2.7", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "minimax-portal/MiniMax-M2.7", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, contextTokens: 1_048_576, @@ -1525,9 +1528,12 @@ describe("buildStatusReply subagent summary", () => { modelOverride: "mimo-v2-flash", modelProvider: "custom-runtime", model: "unknown-fallback-model", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "custom-runtime/unknown-fallback-model", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "custom-runtime/unknown-fallback-model", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, contextTokens: 1_048_576, @@ -2028,9 +2034,12 @@ describe("buildStatusReply subagent summary", () => { modelOverride: "claude-opus-4-7", modelProvider: "claude-cli", model: "claude-opus-4-7", - fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7", - fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7", - fallbackNoticeReason: "selected model unavailable", + fallbackNotice: { + kind: "active", + selectedModel: "anthropic/claude-opus-4-7", + activeModel: "claude-cli/claude-opus-4-7", + reason: "selected model unavailable", + }, }, sessionKey: "agent:main:main", parentSessionKey: "agent:main:main", diff --git a/src/auto-reply/reply/dispatch-from-config.pending-final.test.ts b/src/auto-reply/reply/dispatch-from-config.pending-final.test.ts index d08170b0e19e..452621c828fd 100644 --- a/src/auto-reply/reply/dispatch-from-config.pending-final.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.pending-final.test.ts @@ -34,10 +34,12 @@ describe("pending final delivery restart proof", () => { status: "running", startedAt: 10, updatedAt: Date.now(), - pendingFinalDelivery: true, - pendingFinalDeliveryText: "hook reply", - pendingFinalDeliveryCreatedAt: 1, - pendingFinalDeliveryIntentId: "intent-1", + pendingFinalDelivery: { + kind: "replayable", + text: "hook reply", + createdAt: 1, + intentId: "intent-1", + }, restartRecoveryBeforeAgentReplyState: beforeAgentReplyState, restartRecoveryForceSafeTools: beforeAgentReplyState === "handled-reply" ? true : undefined, restartRecoverySourceIngress: "channel", @@ -58,8 +60,6 @@ describe("pending final delivery restart proof", () => { const entry = loadSessionEntry({ sessionKey, storePath }); expect(entry?.pendingFinalDelivery).toBeUndefined(); - expect(entry?.pendingFinalDeliveryText).toBeUndefined(); - expect(entry?.pendingFinalDeliveryIntentId).toBeUndefined(); expect(entry?.restartRecoveryBeforeAgentReplyState).toBeUndefined(); expect(entry?.restartRecoveryForceSafeTools).toBeUndefined(); expect(entry?.restartRecoverySourceIngress).toBeUndefined(); @@ -79,8 +79,11 @@ describe("pending final delivery restart proof", () => { status: "running", startedAt: 10, updatedAt: Date.now(), - pendingFinalDelivery: true, - pendingFinalDeliveryIntentId: "intent-media", + pendingFinalDelivery: { + kind: "transport-only", + createdAt: Date.now(), + intentId: "intent-media", + }, restartRecoveryBeforeAgentReplyState: "handled-unrecoverable", restartRecoverySourceIngress: "channel", }, @@ -117,9 +120,11 @@ describe("pending final delivery restart proof", () => { }); expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: "hook reply", - pendingFinalDeliveryIntentId: "intent-1", + pendingFinalDelivery: { + kind: "replayable", + text: "hook reply", + intentId: "intent-1", + }, restartRecoveryBeforeAgentReplyState: "continue", restartRecoverySourceIngress: "channel", }); diff --git a/src/auto-reply/reply/dispatch-from-config.pending-final.ts b/src/auto-reply/reply/dispatch-from-config.pending-final.ts index 1bf9091c5cea..19703e2e4302 100644 --- a/src/auto-reply/reply/dispatch-from-config.pending-final.ts +++ b/src/auto-reply/reply/dispatch-from-config.pending-final.ts @@ -36,13 +36,6 @@ function buildPendingFinalDeliveryCleanupPatch(entry: SessionEntry): Partial pendingPayloadSet.has(delivery.payload)) @@ -240,10 +230,9 @@ export async function reconcilePendingFinalDeliveryAfterSettlement(params: { const retryText = buildPendingFinalDeliveryRetryText( failedBeforeDeliver.map((delivery) => delivery.payload), ); - if (retryText) { + if (retryText && pending.kind === "replayable") { return { - pendingFinalDelivery: true, - pendingFinalDeliveryText: retryText, + pendingFinalDelivery: { ...pending, text: retryText }, updatedAt: Date.now(), }; } diff --git a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts index 0076636a4a0f..8eda9ee68f63 100644 --- a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts @@ -65,6 +65,13 @@ function createDeferred() { return { promise, resolve }; } +function pendingFinalDelivery( + text: string, + overrides: { createdAt?: number; context?: Record; intentId?: string } = {}, +) { + return { kind: "replayable" as const, text, createdAt: 1, ...overrides }; +} + describe("dispatchReplyFromConfig reply_dispatch hook", () => { beforeAll(async () => { ({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js")); @@ -217,13 +224,9 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "durable reply", - pendingFinalDeliveryCreatedAt: 1, - pendingFinalDeliveryLastAttemptAt: 2, - pendingFinalDeliveryAttemptCount: 3, - pendingFinalDeliveryLastError: "previous failure", - pendingFinalDeliveryContext: { source: "heartbeat" }, + pendingFinalDelivery: pendingFinalDelivery("durable reply", { + context: { source: "heartbeat" }, + }), }; sessionStoreMocks.loadSessionStore.mockClear(); mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" }); @@ -252,12 +255,6 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { expect(sessionStoreMocks.loadSessionStore).not.toHaveBeenCalled(); expect(deliver).toHaveBeenCalledOnce(); expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastAttemptAt).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryAttemptCount).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastError).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryContext).toBeUndefined(); }); it("clears pending final delivery when abort fires after a successful final send (#89115)", async () => { @@ -268,14 +265,10 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "durable reply", - pendingFinalDeliveryCreatedAt: 1, - pendingFinalDeliveryLastAttemptAt: 2, - pendingFinalDeliveryAttemptCount: 3, - pendingFinalDeliveryLastError: "previous failure", - pendingFinalDeliveryContext: { source: "heartbeat" }, - pendingFinalDeliveryIntentId: "intent-89115", + pendingFinalDelivery: pendingFinalDelivery("durable reply", { + context: { source: "heartbeat" }, + intentId: "intent-89115", + }), }; sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, @@ -309,22 +302,13 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { expect(result.queuedFinal).toBe(false); expect(sessionStoreMocks.updateSessionEntry).toHaveBeenCalledOnce(); expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastAttemptAt).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryAttemptCount).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryLastError).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryContext).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryIntentId).toBeUndefined(); }); it("preserves pending final delivery when final dispatch fails", async () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "durable reply", - pendingFinalDeliveryCreatedAt: 1, + pendingFinalDelivery: pendingFinalDelivery("durable reply"), }; sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, @@ -341,9 +325,9 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { expect(result.queuedFinal).toBe(false); expect(sessionStoreMocks.updateSessionEntry).not.toHaveBeenCalled(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBe(true); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBe("durable reply"); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBe(1); + expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toEqual( + pendingFinalDelivery("durable reply"), + ); }); it("preserves pending final delivery when beforeDeliver times out", async () => { @@ -352,10 +336,9 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "durable reply", - pendingFinalDeliveryCreatedAt: 1, - pendingFinalDeliveryContext: { channel: "whatsapp", to: "+1000" }, + pendingFinalDelivery: pendingFinalDelivery("durable reply", { + context: { channel: "whatsapp", to: "+1000" }, + }), }; sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, @@ -390,11 +373,13 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { // attempt follows the timed-out final. expect(dispatcher.getFailedCounts?.()).toEqual({ tool: 0, block: 0, final: 1 }); expect(sessionStoreMocks.updateSessionEntry).toHaveBeenCalledOnce(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBe(true); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBe("durable reply"); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryContext).toEqual({ - channel: "whatsapp", - to: "+1000", + expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: "durable reply", + context: { + channel: "whatsapp", + to: "+1000", + }, }); expect(vi.getTimerCount()).toBe(0); } finally { @@ -408,9 +393,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "durable reply", - pendingFinalDeliveryCreatedAt: 1, + pendingFinalDelivery: pendingFinalDelivery("durable reply"), }; sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, @@ -451,7 +434,6 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { ); expect(dispatcher.getFailedCounts?.()).toEqual({ tool: 0, block: 0, final: 1 }); expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBeUndefined(); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); @@ -464,9 +446,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "durable reply", - pendingFinalDeliveryCreatedAt: 1, + pendingFinalDelivery: pendingFinalDelivery("durable reply"), }; sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, @@ -505,8 +485,9 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { expect.objectContaining({ text: "auxiliary" }), expect.objectContaining({ kind: "final" }), ); - expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBe(true); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBe("durable reply"); + expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toEqual( + pendingFinalDelivery("durable reply"), + ); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); @@ -519,9 +500,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "auxiliary\n\ndurable reply", - pendingFinalDeliveryCreatedAt: 1, + pendingFinalDelivery: pendingFinalDelivery("auxiliary\n\ndurable reply"), }; sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, @@ -554,9 +533,9 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { await vi.advanceTimersByTimeAsync(15_000); await resultPromise; - expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBe(true); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBe("durable reply"); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBe(1); + expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toEqual( + pendingFinalDelivery("durable reply"), + ); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); @@ -569,10 +548,9 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "auxiliary durable reply", - pendingFinalDeliveryCreatedAt: 1, - pendingFinalDeliveryIntentId: "heartbeat-intent", + pendingFinalDelivery: pendingFinalDelivery("auxiliary durable reply", { + intentId: "heartbeat-intent", + }), }; sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, @@ -620,10 +598,9 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { await vi.advanceTimersByTimeAsync(15_000); await resultPromise; - expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBe(true); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBe("durable reply"); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBe(1); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryIntentId).toBe("heartbeat-intent"); + expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toEqual( + pendingFinalDelivery("durable reply", { intentId: "heartbeat-intent" }), + ); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); @@ -636,10 +613,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "older reply", - pendingFinalDeliveryCreatedAt: 1, - pendingFinalDeliveryIntentId: "older-intent", + pendingFinalDelivery: pendingFinalDelivery("older reply", { intentId: "older-intent" }), }; sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, @@ -670,17 +644,17 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { await hookStarted.promise; sessionStoreMocks.currentEntry = { ...sessionStoreMocks.currentEntry, - pendingFinalDeliveryText: "newer reply", - pendingFinalDeliveryCreatedAt: 2, - pendingFinalDeliveryIntentId: "newer-intent", + pendingFinalDelivery: pendingFinalDelivery("newer reply", { + createdAt: 2, + intentId: "newer-intent", + }), }; await vi.advanceTimersByTimeAsync(15_000); await resultPromise; - expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBe(true); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBe("newer reply"); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryCreatedAt).toBe(2); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryIntentId).toBe("newer-intent"); + expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toEqual( + pendingFinalDelivery("newer reply", { createdAt: 2, intentId: "newer-intent" }), + ); expect(vi.getTimerCount()).toBe(0); } finally { vi.useRealTimers(); @@ -691,9 +665,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "possibly visible reply", - pendingFinalDeliveryCreatedAt: 1, + pendingFinalDelivery: pendingFinalDelivery("possibly visible reply"), }; sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, @@ -720,16 +692,13 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { expect(dispatcher.getFailedCounts?.()).toEqual({ tool: 0, block: 0, final: 1 }); expect(sessionStoreMocks.updateSessionEntry).toHaveBeenCalledOnce(); expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBeUndefined(); }); it("clears pending final delivery after intentional pre-delivery cancellation", async () => { hookMocks.runner.hasHooks.mockReturnValue(false); sessionStoreMocks.currentEntry = { sessionKey: "agent:test:session", - pendingFinalDelivery: true, - pendingFinalDeliveryText: "policy-suppressed reply", - pendingFinalDeliveryCreatedAt: 1, + pendingFinalDelivery: pendingFinalDelivery("policy-suppressed reply"), }; sessionStoreMocks.resolveSessionStoreEntry.mockReturnValue({ existing: sessionStoreMocks.currentEntry, @@ -758,7 +727,6 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { expect(dispatcher.getCancelledCounts?.()).toEqual({ tool: 0, block: 0, final: 1 }); expect(dispatcher.getFailedCounts?.()).toEqual({ tool: 0, block: 0, final: 0 }); expect(sessionStoreMocks.currentEntry?.pendingFinalDelivery).toBeUndefined(); - expect(sessionStoreMocks.currentEntry?.pendingFinalDeliveryText).toBeUndefined(); }); it("delivers a generated final reply before queued follow-up admission", async () => { diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 23510e0a390b..743f905fd0e6 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -336,12 +336,12 @@ describe("getReplyFromConfig fast test bootstrap", () => { [sessionKey]: { sessionId: "pending-ack", updatedAt: Date.now(), - pendingFinalDelivery: true, - pendingFinalDeliveryText: "HEARTBEAT_OK", - pendingFinalDeliveryCreatedAt: 1, - pendingFinalDeliveryAttemptCount: 4, - pendingFinalDeliveryLastError: null, - pendingFinalDeliveryIntentId: "stale-heartbeat-intent", + pendingFinalDelivery: { + kind: "replayable", + text: "HEARTBEAT_OK", + createdAt: 1, + intentId: "stale-heartbeat-intent", + }, }, }); const cfg = withFastReplyConfig({ @@ -361,9 +361,6 @@ describe("getReplyFromConfig fast test bootstrap", () => { const stored = readFastPathSessionEntry(storePath, sessionKey); expect(stored.pendingFinalDelivery).toBeUndefined(); - expect(stored.pendingFinalDeliveryText).toBeUndefined(); - expect(stored.pendingFinalDeliveryAttemptCount).toBeUndefined(); - expect(stored.pendingFinalDeliveryIntentId).toBeUndefined(); }); it("clears short heartbeat pending delivery under the fixed ack policy", async () => { @@ -374,8 +371,11 @@ describe("getReplyFromConfig fast test bootstrap", () => { [sessionKey]: { sessionId: "pending-ack-with-remainder", updatedAt: Date.now(), - pendingFinalDelivery: true, - pendingFinalDeliveryText: "HEARTBEAT_OK short", + pendingFinalDelivery: { + kind: "replayable", + text: "HEARTBEAT_OK short", + createdAt: 1, + }, }, }); const cfg = withFastReplyConfig({ @@ -395,8 +395,6 @@ describe("getReplyFromConfig fast test bootstrap", () => { const stored = readFastPathSessionEntry(storePath, sessionKey); expect(stored.pendingFinalDelivery).toBeUndefined(); - expect(stored.pendingFinalDeliveryText).toBeUndefined(); - expect(stored.pendingFinalDeliveryAttemptCount).toBeUndefined(); }); it("does not replay stale heartbeat pending delivery", async () => { @@ -407,9 +405,11 @@ describe("getReplyFromConfig fast test bootstrap", () => { [sessionKey]: { sessionId: "pending-user-final", updatedAt: Date.now() - 60_000, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "private prior user answer", - pendingFinalDeliveryCreatedAt: 1, + pendingFinalDelivery: { + kind: "replayable", + text: "private prior user answer", + createdAt: 1, + }, }, }); const cfg = withFastReplyConfig({ @@ -430,9 +430,10 @@ describe("getReplyFromConfig fast test bootstrap", () => { }); const stored = readFastPathSessionEntry(storePath, sessionKey); - expect(stored.pendingFinalDelivery).toBe(true); - expect(stored.pendingFinalDeliveryText).toBe("private prior user answer"); - expect(stored.pendingFinalDeliveryAttemptCount).toBeUndefined(); + expect(stored.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: "private prior user answer", + }); }); it("handles native /status before workspace bootstrap", async () => { diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 719dde122018..ff2069e91f46 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -546,8 +546,8 @@ export async function getReplyFromConfig( storePath, }); - if (sessionEntry?.pendingFinalDelivery && sessionEntry.pendingFinalDeliveryText) { - const text = sanitizePendingFinalDeliveryText(sessionEntry.pendingFinalDeliveryText); + if (sessionEntry?.pendingFinalDelivery?.kind === "replayable") { + const text = sanitizePendingFinalDeliveryText(sessionEntry.pendingFinalDelivery.text); // Heartbeats may safely clear ack-only pending state, but must not replay // user-facing pending finals through a different delivery target. diff --git a/src/auto-reply/reply/memory-flush.ts b/src/auto-reply/reply/memory-flush.ts index d9fd0446a77c..5c5a52f54dcc 100644 --- a/src/auto-reply/reply/memory-flush.ts +++ b/src/auto-reply/reply/memory-flush.ts @@ -129,7 +129,7 @@ function resolveMemoryFlushGateState< export function shouldRunMemoryFlush(params: { entry?: Pick< SessionEntry, - "totalTokens" | "totalTokensFresh" | "compactionCount" | "memoryFlushCompactionCount" + "totalTokens" | "totalTokensFresh" | "compactionCount" | "memoryFlush" >; /** * Optional token count override for flush gating. When provided, this value is @@ -176,9 +176,9 @@ export function shouldRunPreflightCompaction(params: { * important for both the token-based and transcript-size–based trigger paths. */ export function hasAlreadyFlushedForCurrentCompaction( - entry: Pick, + entry: Pick, ): boolean { const compactionCount = entry.compactionCount ?? 0; - const lastFlushAt = entry.memoryFlushCompactionCount; + const lastFlushAt = entry.memoryFlush?.compactionCount; return typeof lastFlushAt === "number" && lastFlushAt === compactionCount; } diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index acf6c32e4494..ccb4bb99aae1 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -1555,7 +1555,13 @@ describe("createModelSelectionState auto-failover overrides", () => { modelOverrideRouteResolution: params.modelOverrideRouteResolution, modelOverrideFallbackOriginProvider: params.modelOverrideFallbackOriginProvider, modelOverrideFallbackOriginModel: params.modelOverrideFallbackOriginModel, - fallbackNoticeSelectedModel: params.fallbackNoticeSelectedModel, + fallbackNotice: params.fallbackNoticeSelectedModel + ? { + kind: "active", + selectedModel: params.fallbackNoticeSelectedModel, + activeModel: `${params.providerOverride}/${params.modelOverride}`, + } + : undefined, authProfileOverride: params.authProfileOverride, authProfileOverrideSource: params.authProfileOverrideSource, }); diff --git a/src/auto-reply/reply/pending-final-delivery.ts b/src/auto-reply/reply/pending-final-delivery.ts index e134b617595b..9fecd19c5ddf 100644 --- a/src/auto-reply/reply/pending-final-delivery.ts +++ b/src/auto-reply/reply/pending-final-delivery.ts @@ -91,13 +91,6 @@ export function buildPendingFinalDeliveryText(payloads: ReplyPayload[]): string // centralized prevents new ownership fields from leaving a phantom pending delivery. export const PENDING_FINAL_DELIVERY_CLEAR_PATCH = { pendingFinalDelivery: undefined, - pendingFinalDeliveryText: undefined, - pendingFinalDeliveryCreatedAt: undefined, - pendingFinalDeliveryLastAttemptAt: undefined, - pendingFinalDeliveryAttemptCount: undefined, - pendingFinalDeliveryLastError: undefined, - pendingFinalDeliveryContext: undefined, - pendingFinalDeliveryIntentId: undefined, } as const satisfies Partial; function collectDurableMediaDirectives(payload: ReplyPayload): string[] { diff --git a/src/auto-reply/reply/reply-state.test.ts b/src/auto-reply/reply/reply-state.test.ts index e1981e91cf8b..f051bc919e12 100644 --- a/src/auto-reply/reply/reply-state.test.ts +++ b/src/auto-reply/reply/reply-state.test.ts @@ -309,7 +309,7 @@ describe("shouldRunMemoryFlush", () => { entry: { totalTokens: 90_000, compactionCount: 2, - memoryFlushCompactionCount: 2, + memoryFlush: { kind: "succeeded", compactionCount: 2 }, }, contextWindowTokens: 100_000, reserveTokensFloor: 5_000, @@ -338,8 +338,16 @@ describe("shouldRunMemoryFlush", () => { for (const entry of [ { totalTokens: 95_000, compactionCount: 1 }, - { totalTokens: 95_000, compactionCount: 2, memoryFlushCompactionCount: 1 }, - { totalTokens: 95_000, compactionCount: 3, memoryFlushCompactionCount: 2 }, + { + totalTokens: 95_000, + compactionCount: 2, + memoryFlush: { kind: "succeeded" as const, compactionCount: 1 }, + }, + { + totalTokens: 95_000, + compactionCount: 3, + memoryFlush: { kind: "succeeded" as const, compactionCount: 2 }, + }, ]) { expect(shouldRunMemoryFlush({ entry, ...params })).toBe(true); } @@ -387,7 +395,7 @@ describe("hasAlreadyFlushedForCurrentCompaction", () => { expect( hasAlreadyFlushedForCurrentCompaction({ compactionCount: 3, - memoryFlushCompactionCount: 3, + memoryFlush: { kind: "succeeded", compactionCount: 3 }, }), ).toBe(true); }); @@ -396,7 +404,7 @@ describe("hasAlreadyFlushedForCurrentCompaction", () => { expect( hasAlreadyFlushedForCurrentCompaction({ compactionCount: 3, - memoryFlushCompactionCount: 2, + memoryFlush: { kind: "succeeded", compactionCount: 2 }, }), ).toBe(false); }); @@ -412,7 +420,7 @@ describe("hasAlreadyFlushedForCurrentCompaction", () => { it("treats missing compactionCount as 0", () => { expect( hasAlreadyFlushedForCurrentCompaction({ - memoryFlushCompactionCount: 0, + memoryFlush: { kind: "succeeded", compactionCount: 0 }, }), ).toBe(true); }); diff --git a/src/auto-reply/reply/restart-recovery-claim.ts b/src/auto-reply/reply/restart-recovery-claim.ts index b678c6f8e3b0..9582cce4b302 100644 --- a/src/auto-reply/reply/restart-recovery-claim.ts +++ b/src/auto-reply/reply/restart-recovery-claim.ts @@ -395,11 +395,18 @@ export function createReplyRestartRecoveryClaimController(params: { restartRecoveryBeforeAgentReplyState: state, ...(pendingFinalDelivery ? { - pendingFinalDelivery: true, - pendingFinalDeliveryText: pendingFinalDelivery.text, - pendingFinalDeliveryIntentId: pendingFinalDelivery.intentId, - pendingFinalDeliveryContext: pendingFinalDelivery.context, - pendingFinalDeliveryCreatedAt: updatedAt, + pendingFinalDelivery: { + ...(pendingFinalDelivery.text + ? { kind: "replayable" as const, text: pendingFinalDelivery.text } + : { kind: "transport-only" as const }), + createdAt: updatedAt, + ...(pendingFinalDelivery.intentId + ? { intentId: pendingFinalDelivery.intentId } + : {}), + ...(pendingFinalDelivery.context + ? { context: pendingFinalDelivery.context } + : {}), + }, // Hook-owned replies are already terminal. A restart may only deliver this // checkpoint; it must never resume the model or broader tool surface. restartRecoveryForceSafeTools: true, @@ -483,13 +490,6 @@ export function createReplyRestartRecoveryClaimController(params: { abortedLastRun: true, endedAt, pendingFinalDelivery: undefined, - pendingFinalDeliveryText: undefined, - pendingFinalDeliveryCreatedAt: undefined, - pendingFinalDeliveryLastAttemptAt: undefined, - pendingFinalDeliveryAttemptCount: undefined, - pendingFinalDeliveryLastError: undefined, - pendingFinalDeliveryContext: undefined, - pendingFinalDeliveryIntentId: undefined, runtimeMs: typeof current.startedAt === "number" ? Math.max(0, endedAt - current.startedAt) @@ -498,9 +498,7 @@ export function createReplyRestartRecoveryClaimController(params: { updatedAt: endedAt, }; } - const preservesPendingFinal = - current.pendingFinalDelivery === true || - normalizeOptionalString(current.pendingFinalDeliveryText) !== undefined; + const preservesPendingFinal = current.pendingFinalDelivery !== undefined; const completesHandledSilent = current.restartRecoveryBeforeAgentReplyState === "handled-silent" && !preservesPendingFinal; diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index f43c329e0f26..edd4169b2fca 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -1047,12 +1047,7 @@ describe("initSessionState RawBody", () => { unwindowedMessageCount: 8, }, compactionCount: 3, - memoryFlushAt: 123, - memoryFlushCompactionCount: 2, - memoryFlushContextHash: "stale-context", - memoryFlushFailureCount: 3, - memoryFlushLastFailedAt: 456, - memoryFlushLastFailureError: "provider crashed", + memoryFlush: { kind: "failed", compactionCount: 2, failureCount: 3 }, skillsSnapshot: { prompt: "stale", skills: [{ name: "stale" }], @@ -1087,12 +1082,7 @@ describe("initSessionState RawBody", () => { expect(result.sessionEntry.contextTokens).toBeUndefined(); expect(result.sessionEntry.contextBudgetStatus).toBeUndefined(); expect(result.sessionEntry.compactionCount).toBe(0); - expect(result.sessionEntry.memoryFlushAt).toBeUndefined(); - expect(result.sessionEntry.memoryFlushCompactionCount).toBeUndefined(); - expect(result.sessionEntry.memoryFlushContextHash).toBeUndefined(); - expect(result.sessionEntry.memoryFlushFailureCount).toBeUndefined(); - expect(result.sessionEntry.memoryFlushLastFailedAt).toBeUndefined(); - expect(result.sessionEntry.memoryFlushLastFailureError).toBeUndefined(); + expect(result.sessionEntry.memoryFlush).toBeUndefined(); const store = readSessionStoreFast(storePath) as Record< string, @@ -1103,12 +1093,7 @@ describe("initSessionState RawBody", () => { contextTokens?: number; contextBudgetStatus?: unknown; compactionCount?: number; - memoryFlushAt?: number; - memoryFlushCompactionCount?: number; - memoryFlushContextHash?: string; - memoryFlushFailureCount?: number; - memoryFlushLastFailedAt?: number; - memoryFlushLastFailureError?: string; + memoryFlush?: unknown; } >; expect(store[sessionKey]?.skillsSnapshot).toBeUndefined(); @@ -1117,12 +1102,7 @@ describe("initSessionState RawBody", () => { expect(store[sessionKey]?.contextTokens).toBeUndefined(); expect(store[sessionKey]?.contextBudgetStatus).toBeUndefined(); expect(store[sessionKey]?.compactionCount).toBe(0); - expect(store[sessionKey]?.memoryFlushAt).toBeUndefined(); - expect(store[sessionKey]?.memoryFlushCompactionCount).toBeUndefined(); - expect(store[sessionKey]?.memoryFlushContextHash).toBeUndefined(); - expect(store[sessionKey]?.memoryFlushFailureCount).toBeUndefined(); - expect(store[sessionKey]?.memoryFlushLastFailedAt).toBeUndefined(); - expect(store[sessionKey]?.memoryFlushLastFailureError).toBeUndefined(); + expect(store[sessionKey]?.memoryFlush).toBeUndefined(); }); it("drains stale system events when /new rotates an existing session", async () => { @@ -3173,9 +3153,12 @@ describe("initSessionState preserves behavior overrides across /new and /reset", contextTokens: 400_000, cacheRead: 1_000, cacheWrite: 2_000, - fallbackNoticeSelectedModel: "openai/gpt-5.4-mini", - fallbackNoticeActiveModel: "minimax/m2.7", - fallbackNoticeReason: "rate limit", + fallbackNotice: { + kind: "active", + selectedModel: "openai/gpt-5.4-mini", + activeModel: "minimax/m2.7", + reason: "rate limit", + }, systemPromptReport: { source: "run", generatedAt: 1, @@ -3208,9 +3191,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset", expect(result.sessionEntry.model, name).toBeUndefined(); expect(result.sessionEntry.cacheRead, name).toBeUndefined(); expect(result.sessionEntry.cacheWrite, name).toBeUndefined(); - expect(result.sessionEntry.fallbackNoticeSelectedModel, name).toBeUndefined(); - expect(result.sessionEntry.fallbackNoticeActiveModel, name).toBeUndefined(); - expect(result.sessionEntry.fallbackNoticeReason, name).toBeUndefined(); + expect(result.sessionEntry.fallbackNotice, name).toBeUndefined(); expect(result.sessionEntry.systemPromptReport, name).toBeUndefined(); expect(result.sessionEntry.providerOverride, name).toBe( explicitUserOverride.providerOverride, @@ -3224,9 +3205,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset", expect(stored[sessionKey]?.model, name).toBeUndefined(); expect(stored[sessionKey]?.cacheRead, name).toBeUndefined(); expect(stored[sessionKey]?.cacheWrite, name).toBeUndefined(); - expect(stored[sessionKey]?.fallbackNoticeSelectedModel, name).toBeUndefined(); - expect(stored[sessionKey]?.fallbackNoticeActiveModel, name).toBeUndefined(); - expect(stored[sessionKey]?.fallbackNoticeReason, name).toBeUndefined(); + expect(stored[sessionKey]?.fallbackNotice, name).toBeUndefined(); expect(stored[sessionKey]?.systemPromptReport, name).toBeUndefined(); expect(stored[sessionKey]?.providerOverride, name).toBe( explicitUserOverride.providerOverride, diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 3b467b6dbcbf..c8c9b770436d 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -1030,23 +1030,14 @@ async function initSessionStateAttemptLocked( } if (isNewSession) { sessionEntry.compactionCount = 0; - sessionEntry.memoryFlushCompactionCount = undefined; - sessionEntry.memoryFlushAt = undefined; + sessionEntry.memoryFlush = undefined; // Runtime model fields are persisted last-run cache, not user selection. // Reset must drop them so the next turn resolves current defaults or the // explicit providerOverride/modelOverride values preserved above. sessionEntry.modelProvider = undefined; sessionEntry.model = undefined; - sessionEntry.fallbackNoticeSelectedModel = undefined; - sessionEntry.fallbackNoticeActiveModel = undefined; - sessionEntry.fallbackNoticeReason = undefined; + sessionEntry.fallbackNotice = undefined; sessionEntry.systemPromptReport = undefined; - sessionEntry.memoryFlushFailureCount = undefined; - sessionEntry.memoryFlushLastFailedAt = undefined; - sessionEntry.memoryFlushLastFailureError = undefined; - // Clear stale context hash so the first flush in the new session is not - // incorrectly skipped due to a hash match with the old transcript (#30115). - sessionEntry.memoryFlushContextHash = undefined; sessionEntry.startedAt = undefined; sessionEntry.endedAt = undefined; sessionEntry.runtimeMs = undefined; diff --git a/src/auto-reply/reply/stored-model-override.ts b/src/auto-reply/reply/stored-model-override.ts index 7683131e74dc..46b47aa41734 100644 --- a/src/auto-reply/reply/stored-model-override.ts +++ b/src/auto-reply/reply/stored-model-override.ts @@ -198,7 +198,7 @@ export function isStaleHeartbeatAutoFallbackOverride(params: { const noticeSelectedKey = resolveModelRefKey({ defaultProvider: params.defaultProvider, - overrideModel: normalizeOptionalString(entry.fallbackNoticeSelectedModel), + overrideModel: normalizeOptionalString(entry.fallbackNotice?.selectedModel), }); if (noticeSelectedKey) { return noticeSelectedKey !== primaryKey; diff --git a/src/auto-reply/status.test.ts b/src/auto-reply/status.test.ts index a0b3111eb1be..4032032cd9c9 100644 --- a/src/auto-reply/status.test.ts +++ b/src/auto-reply/status.test.ts @@ -1075,9 +1075,12 @@ describe("buildStatusMessage", () => { modelOverride: "mimo-v2-flash", modelProvider: "minimax-portal", model: "MiniMax-M2.7", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "minimax-portal/MiniMax-M2.7", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, contextTokens: 1_048_576, @@ -1109,9 +1112,12 @@ describe("buildStatusMessage", () => { modelOverride: "claude-opus-4-7", modelProvider: "claude-cli", model: "claude-opus-4-7", - fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7", - fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7", - fallbackNoticeReason: "selected model unavailable", + fallbackNotice: { + kind: "active", + selectedModel: "anthropic/claude-opus-4-7", + activeModel: "claude-cli/claude-opus-4-7", + reason: "selected model unavailable", + }, inputTokens: 29, outputTokens: 19_000, cacheRead: 3_000_000, @@ -1162,9 +1168,12 @@ describe("buildStatusMessage", () => { modelOverride: "claude-opus-4-7", modelProvider: "claude-cli", model: "claude-opus-4-7", - fallbackNoticeSelectedModel: "anthropic/claude-opus-4-7", - fallbackNoticeActiveModel: "claude-cli/claude-opus-4-7", - fallbackNoticeReason: "selected model unavailable", + fallbackNotice: { + kind: "active", + selectedModel: "anthropic/claude-opus-4-7", + activeModel: "claude-cli/claude-opus-4-7", + reason: "selected model unavailable", + }, inputTokens: 29, outputTokens: 19_000, }, @@ -1208,9 +1217,12 @@ describe("buildStatusMessage", () => { modelOverride: "mimo-v2-flash", modelProvider: "minimax-portal", model: "MiniMax-M2.7", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "minimax-portal/MiniMax-M2.7", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, contextTokens: 1_048_576, @@ -1253,9 +1265,12 @@ describe("buildStatusMessage", () => { modelOverride: "mimo-v2-flash", modelProvider: "minimax-portal", model: "MiniMax-M2.7", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "minimax-portal/MiniMax-M2.7", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, contextTokens: 123_456, @@ -1300,9 +1315,12 @@ describe("buildStatusMessage", () => { modelOverride: "mimo-v2-flash", modelProvider: "minimax-portal", model: "MiniMax-M2.7", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "minimax-portal/MiniMax-M2.7", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, }, @@ -1346,9 +1364,12 @@ describe("buildStatusMessage", () => { modelOverride: "mimo-v2-flash", modelProvider: "minimax-portal", model: "MiniMax-M2.7", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "minimax-portal/MiniMax-M2.7", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, }, @@ -1391,9 +1412,12 @@ describe("buildStatusMessage", () => { modelOverride: "mimo-v2-flash", modelProvider: "minimax-portal", model: "MiniMax-M2.7", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "minimax-portal/MiniMax-M2.7", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "minimax-portal/MiniMax-M2.7", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, }, @@ -1433,9 +1457,12 @@ describe("buildStatusMessage", () => { modelOverride: "mimo-v2-flash", modelProvider: "custom-runtime", model: "unknown-fallback-model", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "custom-runtime/unknown-fallback-model", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "custom-runtime/unknown-fallback-model", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, contextTokens: 128_000, @@ -1636,9 +1663,12 @@ describe("buildStatusMessage", () => { modelOverride: "gpt-4.1-mini", modelProvider: "anthropic", model: "claude-haiku-4-5", - fallbackNoticeSelectedModel: "openai/gpt-4.1-mini", - fallbackNoticeActiveModel: "anthropic/claude-haiku-4-5", - fallbackNoticeReason: "rate limit", + fallbackNotice: { + kind: "active", + selectedModel: "openai/gpt-4.1-mini", + activeModel: "anthropic/claude-haiku-4-5", + reason: "rate limit", + }, contextTokens: 32_000, }, sessionKey: "agent:main:main", @@ -1668,9 +1698,12 @@ describe("buildStatusMessage", () => { updatedAt: 0, modelProvider: "anthropic", model: "claude-haiku-4-5", - fallbackNoticeSelectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo", - fallbackNoticeActiveModel: "deepinfra/moonshotai/Kimi-K2.5", - fallbackNoticeReason: "rate limit", + fallbackNotice: { + kind: "active", + selectedModel: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo", + activeModel: "deepinfra/moonshotai/Kimi-K2.5", + reason: "rate limit", + }, }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -1696,7 +1729,6 @@ describe("buildStatusMessage", () => { updatedAt: 0, modelProvider: "openai", model: "gpt-4.1-mini", - fallbackNoticeReason: "unknown", }, sessionKey: "agent:main:main", sessionScope: "per-sender", @@ -2429,9 +2461,12 @@ describe("buildStatusMessage", () => { providerOverride: "xiaomi", modelOverride: "mimo-v2-flash", model: "fake-minimax/FakeMiniMax-M2.5", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "fake-minimax/FakeMiniMax-M2.5", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "fake-minimax/FakeMiniMax-M2.5", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, }, @@ -2630,9 +2665,12 @@ describe("buildStatusMessage", () => { modelOverride: "mimo-v2-flash", modelProvider: "custom-runtime", model: "unknown-fallback-model", - fallbackNoticeSelectedModel: "xiaomi/mimo-v2-flash", - fallbackNoticeActiveModel: "custom-runtime/unknown-fallback-model", - fallbackNoticeReason: "model not allowed", + fallbackNotice: { + kind: "active", + selectedModel: "xiaomi/mimo-v2-flash", + activeModel: "custom-runtime/unknown-fallback-model", + reason: "model not allowed", + }, totalTokens: 49_000, totalTokensFresh: true, contextTokens: 128_000, diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 7c72bb4d6ff6..4421e0867f2e 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -1502,9 +1502,12 @@ describe("agentCommand", () => { authProfileOverride: "profile-legacy", authProfileOverrideSource: "user", authProfileOverrideCompactionCount: 2, - fallbackNoticeSelectedModel: "anthropic/claude-opus-4-6", - fallbackNoticeActiveModel: "openai/gpt-4.1-mini", - fallbackNoticeReason: "fallback", + fallbackNotice: { + kind: "active", + selectedModel: "anthropic/claude-opus-4-6", + activeModel: "openai/gpt-4.1-mini", + reason: "fallback", + }, }, }); @@ -1534,9 +1537,7 @@ describe("agentCommand", () => { authProfileOverride?: string; authProfileOverrideSource?: string; authProfileOverrideCompactionCount?: number; - fallbackNoticeSelectedModel?: string; - fallbackNoticeActiveModel?: string; - fallbackNoticeReason?: string; + fallbackNotice?: unknown; }>(clearStore); const entry = cleared["agent:main:subagent:clear-overrides"]; expect(entry?.providerOverride).toBeUndefined(); @@ -1544,9 +1545,7 @@ describe("agentCommand", () => { expect(entry?.authProfileOverride).toBeUndefined(); expect(entry?.authProfileOverrideSource).toBeUndefined(); expect(entry?.authProfileOverrideCompactionCount).toBeUndefined(); - expect(entry?.fallbackNoticeSelectedModel).toBeUndefined(); - expect(entry?.fallbackNoticeActiveModel).toBeUndefined(); - expect(entry?.fallbackNoticeReason).toBeUndefined(); + expect(entry?.fallbackNotice).toBeUndefined(); }); }); diff --git a/src/commands/doctor-session-state-providers.ts b/src/commands/doctor-session-state-providers.ts index fbca3e77e0b4..ba5992ba97fd 100644 --- a/src/commands/doctor-session-state-providers.ts +++ b/src/commands/doctor-session-state-providers.ts @@ -424,9 +424,7 @@ function applySessionRouteStateRepair(params: { clear("modelProvider"); clear("contextTokens"); clear("systemPromptReport"); - clear("fallbackNoticeSelectedModel"); - clear("fallbackNoticeActiveModel"); - clear("fallbackNoticeReason"); + clear("fallbackNotice"); } if (params.repair.reasons.includes("pinned runtime")) { for (const key of params.repair.pinnedRuntimeKeys) { diff --git a/src/commands/doctor/shared/codex-route-session-repair.ts b/src/commands/doctor/shared/codex-route-session-repair.ts index a55e156978f4..dabc0290047b 100644 --- a/src/commands/doctor/shared/codex-route-session-repair.ts +++ b/src/commands/doctor/shared/codex-route-session-repair.ts @@ -98,7 +98,7 @@ function clearStaleCodexFallbackNotice( entry: SessionEntry, blockedModelIdentities?: ReadonlySet, ): boolean { - const endpoints = [entry.fallbackNoticeSelectedModel, entry.fallbackNoticeActiveModel]; + const endpoints = [entry.fallbackNotice?.selectedModel, entry.fallbackNotice?.activeModel]; const hasBlockedEndpoint = endpoints.some( (modelRef) => isOpenAICodexModelRef(modelRef) && @@ -107,9 +107,7 @@ function clearStaleCodexFallbackNotice( if (hasBlockedEndpoint || !endpoints.some(isOpenAICodexModelRef)) { return false; } - delete entry.fallbackNoticeSelectedModel; - delete entry.fallbackNoticeActiveModel; - delete entry.fallbackNoticeReason; + delete entry.fallbackNotice; return true; } @@ -255,8 +253,8 @@ function scanCodexSessionStoreRoutes( ); }; const fallbackNoticeEndpoints = [ - entry.fallbackNoticeSelectedModel, - entry.fallbackNoticeActiveModel, + entry.fallbackNotice?.selectedModel, + entry.fallbackNotice?.activeModel, ]; const hasBlockedFallbackNoticeEndpoint = fallbackNoticeEndpoints.some( (modelRef) => diff --git a/src/commands/doctor/shared/codex-route-warnings.test.ts b/src/commands/doctor/shared/codex-route-warnings.test.ts index 8e99766a7455..e73cacd25600 100644 --- a/src/commands/doctor/shared/codex-route-warnings.test.ts +++ b/src/commands/doctor/shared/codex-route-warnings.test.ts @@ -3998,9 +3998,12 @@ describe("collectCodexRouteWarnings", () => { authProfileOverride: "openai-codex:default", authProfileOverrideSource: "auto", authProfileOverrideCompactionCount: 2, - fallbackNoticeSelectedModel: "openai-codex/gpt-5.5", - fallbackNoticeActiveModel: "openai-codex/gpt-5.4", - fallbackNoticeReason: "rate-limit", + fallbackNotice: { + kind: "active", + selectedModel: "openai-codex/gpt-5.5", + activeModel: "openai-codex/gpt-5.4", + reason: "rate-limit", + }, }, other: { sessionId: "s2", @@ -4037,15 +4040,7 @@ describe("collectCodexRouteWarnings", () => { expect(expectDefined(store.main, "store.main test invariant").agentRuntimeOverride).toBe( "codex", ); - expect( - expectDefined(store.main, "store.main test invariant").fallbackNoticeSelectedModel, - ).toBeUndefined(); - expect( - expectDefined(store.main, "store.main test invariant").fallbackNoticeActiveModel, - ).toBeUndefined(); - expect( - expectDefined(store.main, "store.main test invariant").fallbackNoticeReason, - ).toBeUndefined(); + expect(expectDefined(store.main, "store.main test invariant").fallbackNotice).toBeUndefined(); expect(expectDefined(store.other, "store.other test invariant").updatedAt).toBe(2); expect(expectDefined(store.other, "store.other test invariant").agentHarnessId).toBe("codex"); }); @@ -4061,7 +4056,11 @@ describe("collectCodexRouteWarnings", () => { modelOverride: "codex/gpt-5.6-sol", authProfileOverride: "codex:default", authProfileOverrideSource: "auto", - fallbackNoticeSelectedModel: "codex/gpt-5.6-sol", + fallbackNotice: { + kind: "active", + selectedModel: "codex/gpt-5.6-sol", + activeModel: "openai/gpt-5.6-sol", + }, agentRuntimeOverride: "codex", }, }; @@ -4077,7 +4076,7 @@ describe("collectCodexRouteWarnings", () => { authProfileOverride: "codex:default", updatedAt: 123, }); - expect(store.main?.fallbackNoticeSelectedModel).toBeUndefined(); + expect(store.main?.fallbackNotice).toBeUndefined(); expect(store.main?.agentRuntimeOverride).toBe("codex"); }); @@ -4178,18 +4177,19 @@ describe("collectCodexRouteWarnings", () => { updatedAt: 1, modelProvider: "openai", model: "gpt-5.6-sol", - fallbackNoticeSelectedModel: "codex/gpt-5.6-sol", - fallbackNoticeActiveModel: "openai/gpt-5.6-sol", - fallbackNoticeReason: "rate-limit", + fallbackNotice: { + kind: "active", + selectedModel: "codex/gpt-5.6-sol", + activeModel: "openai/gpt-5.6-sol", + reason: "rate-limit", + }, }, }; const result = repairCodexSessionStoreRoutes({ store, now: 123 }); expect(result).toEqual({ changed: true, sessionKeys: ["main"] }); - expect(store.main?.fallbackNoticeSelectedModel).toBeUndefined(); - expect(store.main?.fallbackNoticeActiveModel).toBeUndefined(); - expect(store.main?.fallbackNoticeReason).toBeUndefined(); + expect(store.main?.fallbackNotice).toBeUndefined(); }); it("retains a fallback notice atomically when one legacy endpoint is blocked", () => { @@ -4199,9 +4199,12 @@ describe("collectCodexRouteWarnings", () => { updatedAt: 1, modelProvider: "openai", model: "gpt-5.6-sol", - fallbackNoticeSelectedModel: "codex/gpt-5.6-sol", - fallbackNoticeActiveModel: "openai/gpt-5.6-sol", - fallbackNoticeReason: "rate-limit", + fallbackNotice: { + kind: "active", + selectedModel: "codex/gpt-5.6-sol", + activeModel: "openai/gpt-5.6-sol", + reason: "rate-limit", + }, }, }; // Build the blocked identity through the production plan so the test @@ -4227,9 +4230,12 @@ describe("collectCodexRouteWarnings", () => { expect(result).toEqual({ changed: false, sessionKeys: [] }); expect(store.main).toMatchObject({ updatedAt: 1, - fallbackNoticeSelectedModel: "codex/gpt-5.6-sol", - fallbackNoticeActiveModel: "openai/gpt-5.6-sol", - fallbackNoticeReason: "rate-limit", + fallbackNotice: { + kind: "active", + selectedModel: "codex/gpt-5.6-sol", + activeModel: "openai/gpt-5.6-sol", + reason: "rate-limit", + }, }); }); @@ -4240,16 +4246,19 @@ describe("collectCodexRouteWarnings", () => { updatedAt: 1, modelProvider: "openai", model: "gpt-5.6-sol", - fallbackNoticeSelectedModel: "codex/gpt-5.6-sol", - fallbackNoticeReason: "rate-limit", + fallbackNotice: { + kind: "active", + selectedModel: "codex/gpt-5.6-sol", + activeModel: "openai/gpt-5.6-sol", + reason: "rate-limit", + }, }, }; const result = repairCodexSessionStoreRoutes({ store, now: 123 }); expect(result).toEqual({ changed: true, sessionKeys: ["main"] }); - expect(store.main?.fallbackNoticeSelectedModel).toBeUndefined(); - expect(store.main?.fallbackNoticeReason).toBeUndefined(); + expect(store.main?.fallbackNotice).toBeUndefined(); expect(store.main?.agentRuntimeOverride).toBeUndefined(); expect(store.main?.agentHarnessId).toBeUndefined(); }); @@ -4267,7 +4276,11 @@ describe("collectCodexRouteWarnings", () => { model: "gpt-5.5", providerOverride: "openai-codex", modelOverride: "openai-codex/gpt-5.4", - fallbackNoticeSelectedModel: "openai-codex/gpt-5.5", + fallbackNotice: { + kind: "active", + selectedModel: "openai-codex/gpt-5.5", + activeModel: "openai-codex/gpt-5.4", + }, }; const store: Record = { [supervisedKey]: lockedEntry, diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.ts b/src/config/sessions/session-accessor.sqlite-message-cut.ts index 0161a153765a..606de4b12ab8 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.ts @@ -496,12 +496,7 @@ function cloneMessageCutSessionEntry(params: { contextBudgetStatus: undefined, compactionCount: undefined, compactionCheckpoints: undefined, - memoryFlushAt: undefined, - memoryFlushCompactionCount: undefined, - memoryFlushContextHash: undefined, - memoryFlushFailureCount: undefined, - memoryFlushLastFailedAt: undefined, - memoryFlushLastFailureError: undefined, + memoryFlush: undefined, cliSessionBindings: undefined, cliSessionIds: undefined, claudeCliSessionId: undefined, diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index a336bf6e3ea5..865d7e52a806 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -1351,14 +1351,13 @@ describe("session accessor seam", () => { { sessionId: "existing-session", updatedAt: 10, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "durable reply", - pendingFinalDeliveryCreatedAt: 11, - pendingFinalDeliveryLastAttemptAt: 12, - pendingFinalDeliveryAttemptCount: 2, - pendingFinalDeliveryLastError: "previous failure", - pendingFinalDeliveryContext: { channel: "discord", to: "channel-1" }, - pendingFinalDeliveryIntentId: "intent-1", + pendingFinalDelivery: { + kind: "replayable", + text: "durable reply", + createdAt: 11, + context: { channel: "discord", to: "channel-1" }, + intentId: "intent-1", + }, }, ); @@ -1373,13 +1372,6 @@ describe("session accessor seam", () => { } const currentWithoutPendingDelivery = { ...current }; delete currentWithoutPendingDelivery.pendingFinalDelivery; - delete currentWithoutPendingDelivery.pendingFinalDeliveryAttemptCount; - delete currentWithoutPendingDelivery.pendingFinalDeliveryContext; - delete currentWithoutPendingDelivery.pendingFinalDeliveryCreatedAt; - delete currentWithoutPendingDelivery.pendingFinalDeliveryIntentId; - delete currentWithoutPendingDelivery.pendingFinalDeliveryLastAttemptAt; - delete currentWithoutPendingDelivery.pendingFinalDeliveryLastError; - delete currentWithoutPendingDelivery.pendingFinalDeliveryText; await replaceSessionEntry({ sessionKey, storePath }, currentWithoutPendingDelivery); const committed = await commitReplySessionInitialization({ @@ -1400,23 +1392,9 @@ describe("session accessor seam", () => { throw new Error("expected reply session initialization to commit"); } expect(committed.sessionEntry.pendingFinalDelivery).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryText).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryCreatedAt).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryLastAttemptAt).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryAttemptCount).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryLastError).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryContext).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryIntentId).toBeUndefined(); const persisted = loadSessionEntry({ sessionKey, storePath }); expect(persisted?.pendingFinalDelivery).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryText).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryCreatedAt).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryLastAttemptAt).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryAttemptCount).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryLastError).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryContext).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryIntentId).toBeUndefined(); }); it("does not merge old-session delivery metadata into a rotated session", async () => { @@ -1439,11 +1417,13 @@ describe("session accessor seam", () => { { sessionKey, storePath }, { ...current, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "old reply", - pendingFinalDeliveryCreatedAt: 21, - pendingFinalDeliveryContext: { channel: "discord", to: "channel-1" }, - pendingFinalDeliveryIntentId: "intent-old", + pendingFinalDelivery: { + kind: "replayable", + text: "old reply", + createdAt: 21, + context: { channel: "discord", to: "channel-1" }, + intentId: "intent-old", + }, }, ); @@ -1466,18 +1446,10 @@ describe("session accessor seam", () => { } expect(committed.sessionEntry.sessionId).toBe("new-session"); expect(committed.sessionEntry.pendingFinalDelivery).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryText).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryCreatedAt).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryContext).toBeUndefined(); - expect(committed.sessionEntry.pendingFinalDeliveryIntentId).toBeUndefined(); const persisted = loadSessionEntry({ sessionKey, storePath }); expect(persisted?.sessionId).toBe("new-session"); expect(persisted?.pendingFinalDelivery).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryText).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryCreatedAt).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryContext).toBeUndefined(); - expect(persisted?.pendingFinalDeliveryIntentId).toBeUndefined(); }); it("commits reply session initialization from a guarded legacy alias snapshot", async () => { diff --git a/src/config/sessions/session-snapshot-merge.test.ts b/src/config/sessions/session-snapshot-merge.test.ts index da77fc3558a1..319d0dfcb930 100644 --- a/src/config/sessions/session-snapshot-merge.test.ts +++ b/src/config/sessions/session-snapshot-merge.test.ts @@ -53,7 +53,11 @@ describe("session snapshot merge", () => { modelOverrideFallbackOriginModel: "claude-opus-4-6", authProfileOverride: "openai:fallback", authProfileOverrideSource: "auto", - fallbackNoticeSelectedModel: "openai/gpt-old", + fallbackNotice: { + kind: "active", + selectedModel: "openai/gpt-old", + activeModel: "openai/gpt-fallback", + }, }; const next = { ...initialOverride, @@ -65,7 +69,7 @@ describe("session snapshot merge", () => { modelOverrideFallbackOriginModel: undefined, authProfileOverride: undefined, authProfileOverrideSource: undefined, - fallbackNoticeSelectedModel: undefined, + fallbackNotice: undefined, liveModelSwitchPending: true, }; const current: SessionEntry = { @@ -75,7 +79,11 @@ describe("session snapshot merge", () => { modelOverrideSource: "user", authProfileOverride: "openai:user", authProfileOverrideSource: "user", - fallbackNoticeSelectedModel: "openai/gpt-new", + fallbackNotice: { + kind: "active", + selectedModel: "openai/gpt-new", + activeModel: "openai/gpt-fallback", + }, }; expect(mergeSessionSnapshotChanges({ initial: initialOverride, next, current })).toEqual( @@ -152,9 +160,12 @@ describe("session snapshot merge", () => { modelOverrideSource: "user", modelProvider: "openai", model: "gpt-5.4", - fallbackNoticeSelectedModel: "openai/gpt-5.4", - fallbackNoticeActiveModel: "openai/gpt-5.4-mini", - fallbackNoticeReason: "rate_limit", + fallbackNotice: { + kind: "active", + selectedModel: "openai/gpt-5.4", + activeModel: "openai/gpt-5.4-mini", + reason: "rate_limit", + }, contextTokens: 100_000, contextBudgetStatus: { schemaVersion: 1, @@ -182,9 +193,7 @@ describe("session snapshot merge", () => { modelOverride: "gpt-5.5", modelProvider: undefined, model: undefined, - fallbackNoticeSelectedModel: undefined, - fallbackNoticeActiveModel: undefined, - fallbackNoticeReason: undefined, + fallbackNotice: undefined, contextTokens: undefined, contextBudgetStatus: undefined, }; @@ -193,7 +202,11 @@ describe("session snapshot merge", () => { updatedAt: 3, modelProvider: "openai", model: "gpt-5.4-mini", - fallbackNoticeActiveModel: "openai/gpt-5.4-nano", + fallbackNotice: { + kind: "active", + selectedModel: "openai/gpt-5.4", + activeModel: "openai/gpt-5.4-nano", + }, contextTokens: 80_000, }; @@ -206,9 +219,7 @@ describe("session snapshot merge", () => { }); expect(merged.modelProvider).toBeUndefined(); expect(merged.model).toBeUndefined(); - expect(merged.fallbackNoticeSelectedModel).toBeUndefined(); - expect(merged.fallbackNoticeActiveModel).toBeUndefined(); - expect(merged.fallbackNoticeReason).toBeUndefined(); + expect(merged.fallbackNotice).toBeUndefined(); expect(merged.contextTokens).toBeUndefined(); expect(merged.contextBudgetStatus).toBeUndefined(); }); @@ -231,7 +242,11 @@ describe("session snapshot merge", () => { updatedAt: 3, modelProvider: "openai", model: "gpt-5.4", - fallbackNoticeActiveModel: "openai/gpt-5.4-mini", + fallbackNotice: { + kind: "active", + selectedModel: "openai/gpt-5.4", + activeModel: "openai/gpt-5.4-mini", + }, contextTokens: 80_000, }; @@ -244,7 +259,7 @@ describe("session snapshot merge", () => { }); expect(merged.modelProvider).toBeUndefined(); expect(merged.model).toBeUndefined(); - expect(merged.fallbackNoticeActiveModel).toBeUndefined(); + expect(merged.fallbackNotice).toBeUndefined(); expect(merged.contextTokens).toBeUndefined(); }); @@ -315,7 +330,11 @@ describe("session snapshot merge", () => { updatedAt: 2, modelProvider: "openai", model: "gpt-5.4", - fallbackNoticeSelectedModel: "openai/gpt-5.4", + fallbackNotice: { + kind: "active", + selectedModel: "openai/gpt-5.4", + activeModel: "openai/gpt-5.4-mini", + }, contextTokens: 100_000, thinkingLevel: "medium", }; diff --git a/src/config/sessions/session-snapshot-merge.ts b/src/config/sessions/session-snapshot-merge.ts index 4241e0059c20..30bd99b19863 100644 --- a/src/config/sessions/session-snapshot-merge.ts +++ b/src/config/sessions/session-snapshot-merge.ts @@ -25,9 +25,7 @@ const MODEL_ROUTE_OVERRIDE_FIELDS = [ const MODEL_OVERRIDE_RUNTIME_FIELDS = [ "modelProvider", "model", - "fallbackNoticeSelectedModel", - "fallbackNoticeActiveModel", - "fallbackNoticeReason", + "fallbackNotice", "contextTokens", "contextBudgetStatus", ] as const satisfies ReadonlyArray; diff --git a/src/config/sessions/session-transcript-turn-lifecycle.types.ts b/src/config/sessions/session-transcript-turn-lifecycle.types.ts index c7c70d33e53b..9625607a41b9 100644 --- a/src/config/sessions/session-transcript-turn-lifecycle.types.ts +++ b/src/config/sessions/session-transcript-turn-lifecycle.types.ts @@ -30,13 +30,6 @@ export type SessionTranscriptTurnLifecyclePatch = { abortedLastRun?: boolean; endedAt?: number; pendingFinalDelivery?: SessionEntry["pendingFinalDelivery"]; - pendingFinalDeliveryAttemptCount?: SessionEntry["pendingFinalDeliveryAttemptCount"]; - pendingFinalDeliveryContext?: SessionEntry["pendingFinalDeliveryContext"]; - pendingFinalDeliveryCreatedAt?: SessionEntry["pendingFinalDeliveryCreatedAt"]; - pendingFinalDeliveryIntentId?: SessionEntry["pendingFinalDeliveryIntentId"]; - pendingFinalDeliveryLastAttemptAt?: SessionEntry["pendingFinalDeliveryLastAttemptAt"]; - pendingFinalDeliveryLastError?: SessionEntry["pendingFinalDeliveryLastError"]; - pendingFinalDeliveryText?: SessionEntry["pendingFinalDeliveryText"]; mainRestartRecovery?: SessionEntry["mainRestartRecovery"]; restartRecoveryBeforeAgentReplyState?: SessionRestartRecoveryState["restartRecoveryBeforeAgentReplyState"]; restartRecoveryDeliveryReceiptState?: SessionRestartRecoveryState["restartRecoveryDeliveryReceiptState"]; diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 4d76d8a333a7..d31eeec1388a 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -69,6 +69,18 @@ it("rejects locked key-as-session-id rows instead of treating them as pending", ).toBeUndefined(); }); +it("normalizes boolean-only pending delivery as transport-only", () => { + expect( + normalizePersistedSessionEntryShape({ + sessionId: "session-1", + updatedAt: 42, + pendingFinalDelivery: true, + }), + ).toMatchObject({ + pendingFinalDelivery: { kind: "transport-only", createdAt: 42 }, + }); +}); + describe("session path safety", () => { it("rejects unsafe session IDs", () => { const unsafeSessionIds = [ diff --git a/src/config/sessions/store-entry-shape.ts b/src/config/sessions/store-entry-shape.ts index ffe26aaa3b86..f3d378cd03c8 100644 --- a/src/config/sessions/store-entry-shape.ts +++ b/src/config/sessions/store-entry-shape.ts @@ -1,5 +1,6 @@ // Store entry shape normalization rejects unsafe persisted metadata before runtime use. import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { parseAgentSessionKey } from "../../routing/session-key.js"; import { validateSessionId } from "./paths.js"; import type { SessionEntry } from "./types.js"; @@ -28,10 +29,11 @@ function normalizeTranscriptSessionId(value: string): string | undefined { } function normalizeOptionalTimestamp(value: unknown): number | undefined { - if (value === undefined) { - return undefined; - } - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0; + return value === undefined + ? undefined + : typeof value === "number" && Number.isFinite(value) && value >= 0 + ? value + : 0; } /** Removes retired runtime locator fields before a session entry is persisted or returned. */ @@ -39,11 +41,149 @@ export function projectCanonicalSessionEntryShape(value: Record const { sessionFile: _retiredSessionFile, transcriptPath: _retiredTranscriptPath, + pendingFinalDeliveryCreatedAt, + pendingFinalDeliveryLastAttemptAt: _pendingFinalDeliveryLastAttemptAt, + pendingFinalDeliveryAttemptCount: _pendingFinalDeliveryAttemptCount, + pendingFinalDeliveryLastError: _pendingFinalDeliveryLastError, + pendingFinalDeliveryText, + pendingFinalDeliveryContext, + pendingFinalDeliveryIntentId, + fallbackNoticeSelectedModel, + fallbackNoticeActiveModel, + fallbackNoticeReason, + memoryFlushAt: _memoryFlushAt, + memoryFlushCompactionCount, + memoryFlushContextHash: _memoryFlushContextHash, + memoryFlushFailureCount, + memoryFlushLastFailedAt: _memoryFlushLastFailedAt, + memoryFlushLastFailureError: _memoryFlushLastFailureError, ...canonicalValue } = value; + const legacyPendingText = normalizeOptionalString(pendingFinalDeliveryText); + const legacySelectedModel = normalizeOptionalString(fallbackNoticeSelectedModel); + const legacyActiveModel = normalizeOptionalString(fallbackNoticeActiveModel); + const legacyFlushCompactionCount = normalizeCount(memoryFlushCompactionCount); + const legacyFlushFailureCount = normalizeCount(memoryFlushFailureCount); + const intentId = normalizeOptionalString(pendingFinalDeliveryIntentId); + const pendingFinalDelivery = + normalizePendingFinalDelivery(canonicalValue.pendingFinalDelivery) ?? + (legacyPendingText || value.pendingFinalDelivery === true + ? { + ...(legacyPendingText + ? { kind: "replayable" as const, text: legacyPendingText } + : { kind: "transport-only" as const }), + createdAt: + normalizeOptionalTimestamp(pendingFinalDeliveryCreatedAt) ?? + normalizeOptionalTimestamp(value.updatedAt) ?? + 0, + ...(isRecord(pendingFinalDeliveryContext) + ? { context: pendingFinalDeliveryContext } + : {}), + ...(intentId ? { intentId } : {}), + } + : undefined); + if (pendingFinalDelivery) { + canonicalValue.pendingFinalDelivery = pendingFinalDelivery; + } else { + delete canonicalValue.pendingFinalDelivery; + } + const reason = normalizeOptionalString(fallbackNoticeReason); + const fallbackNotice = + normalizeFallbackNotice(canonicalValue.fallbackNotice) ?? + (legacySelectedModel && legacyActiveModel + ? { + kind: "active" as const, + selectedModel: legacySelectedModel, + activeModel: legacyActiveModel, + ...(reason ? { reason } : {}), + } + : undefined); + if (fallbackNotice) { + canonicalValue.fallbackNotice = fallbackNotice; + } else { + delete canonicalValue.fallbackNotice; + } + const memoryFlush = + normalizeMemoryFlush(canonicalValue.memoryFlush) ?? + (legacyFlushFailureCount && legacyFlushFailureCount > 0 + ? { + kind: "failed" as const, + ...(legacyFlushCompactionCount !== undefined + ? { compactionCount: legacyFlushCompactionCount } + : {}), + failureCount: legacyFlushFailureCount, + } + : legacyFlushCompactionCount !== undefined + ? { kind: "succeeded" as const, compactionCount: legacyFlushCompactionCount } + : undefined); + if (memoryFlush) { + canonicalValue.memoryFlush = memoryFlush; + } else { + delete canonicalValue.memoryFlush; + } return canonicalValue as unknown as SessionEntry; } +function normalizePendingFinalDelivery( + value: unknown, +): SessionEntry["pendingFinalDelivery"] | undefined { + if (!isRecord(value)) { + return undefined; + } + const createdAt = normalizeOptionalTimestamp(value.createdAt); + if (createdAt === undefined) { + return undefined; + } + const intentId = normalizeOptionalString(value.intentId); + const base = { + createdAt, + ...(isRecord(value.context) ? { context: value.context } : {}), + ...(intentId ? { intentId } : {}), + }; + if (value.kind === "transport-only") { + return { kind: "transport-only", ...base }; + } + const text = normalizeOptionalString(value.text); + return value.kind === "replayable" && text ? { kind: "replayable", text, ...base } : undefined; +} + +function normalizeFallbackNotice(value: unknown): SessionEntry["fallbackNotice"] | undefined { + if (!isRecord(value) || value.kind !== "active") { + return undefined; + } + const selectedModel = normalizeOptionalString(value.selectedModel); + const activeModel = normalizeOptionalString(value.activeModel); + const reason = normalizeOptionalString(value.reason); + return selectedModel && activeModel + ? { kind: "active", selectedModel, activeModel, ...(reason ? { reason } : {}) } + : undefined; +} + +function normalizeMemoryFlush(value: unknown): SessionEntry["memoryFlush"] | undefined { + if (!isRecord(value)) { + return undefined; + } + const compactionCount = normalizeCount(value.compactionCount); + if (value.kind === "succeeded" && compactionCount !== undefined) { + return { kind: "succeeded", compactionCount }; + } + const failureCount = normalizeCount(value.failureCount); + if (value.kind !== "failed" || !failureCount) { + return undefined; + } + return { + kind: "failed", + ...(compactionCount !== undefined ? { compactionCount } : {}), + failureCount, + }; +} + +function normalizeCount(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : undefined; +} + /** Normalizes persisted session store entries before they reach runtime callers. */ export function normalizePersistedSessionEntryShape( value: unknown, diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 6f54ac843b05..68ec9dd07c48 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -52,6 +52,27 @@ export type SessionDeliveryState = origin: SessionOrigin; }; +type PendingFinalDeliveryState = { + createdAt: number; + context?: DeliveryContext; + intentId?: string; +} & ({ kind: "replayable"; text: string } | { kind: "transport-only" }); + +type FallbackNoticeState = { + kind: "active"; + selectedModel: string; + activeModel: string; + reason?: string; +}; + +type MemoryFlushState = + | { kind: "succeeded"; compactionCount: number } + | { + kind: "failed"; + compactionCount?: number; + failureCount: number; + }; + export type { AcpSessionRuntimeOptions, SessionAcpIdentity, SessionAcpMeta }; export type CliSessionReseedReceipt = { @@ -473,18 +494,7 @@ type SessionEntryCore = SessionRestartRecoveryState & inputTokens?: number; outputTokens?: number; totalTokens?: number; - /** Durable marker that final user reply delivery still needs a retry/resume pass. */ - pendingFinalDelivery?: boolean; - pendingFinalDeliveryCreatedAt?: number; - pendingFinalDeliveryLastAttemptAt?: number; - pendingFinalDeliveryAttemptCount?: number; - pendingFinalDeliveryLastError?: string | null; - /** Frozen reply text that needs delivery. */ - pendingFinalDeliveryText?: string | null; - /** Original delivery context (channel, recipient, etc). */ - pendingFinalDeliveryContext?: DeliveryContext; - /** Durable send intent backing pending final delivery, when already created. */ - pendingFinalDeliveryIntentId?: string | null; + pendingFinalDelivery?: PendingFinalDeliveryState; /** * Whether totalTokens reflects a fresh context snapshot for the latest run. * Undefined means legacy/unknown freshness; false forces consumers to treat @@ -507,26 +517,12 @@ type SessionEntryCore = SessionRestartRecoveryState & * incompatible runtime harnesses. */ agentHarnessId?: string; - /** - * Last selected/runtime model pair for which a fallback notice was emitted. - * Used to avoid repeating the same fallback notice every turn. - */ - fallbackNoticeSelectedModel?: string; - fallbackNoticeActiveModel?: string; - fallbackNoticeReason?: string; + fallbackNotice?: FallbackNoticeState; contextTokens?: number; contextBudgetStatus?: SessionContextBudgetStatus; compactionCount?: number; compactionCheckpoints?: SessionCompactionCheckpoint[]; - memoryFlushAt?: number; - memoryFlushCompactionCount?: number; - memoryFlushContextHash?: string; - /** Consecutive memory flush failures since the last successful flush. */ - memoryFlushFailureCount?: number; - /** Timestamp (ms) of the last failed memory flush attempt. */ - memoryFlushLastFailedAt?: number; - /** Last memory flush failure error message, truncated for durable metadata. */ - memoryFlushLastFailureError?: string; + memoryFlush?: MemoryFlushState; cliSessionIds?: Record; cliSessionBindings?: Record; /** Initialization fence for seeding canonical ACP metadata; cleared after creation. */ diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index 195198206bd3..1c3c868056ec 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -352,9 +352,12 @@ describe("resolveCronSession", () => { cliSessionBindings: {}, claudeCliSessionId: "old-claude-session", liveModelSwitchPending: true, - fallbackNoticeSelectedModel: "anthropic/claude-opus-4-6", - fallbackNoticeActiveModel: "anthropic/claude-sonnet-4-6", - fallbackNoticeReason: "rate limit", + fallbackNotice: { + kind: "active", + selectedModel: "anthropic/claude-opus-4-6", + activeModel: "anthropic/claude-sonnet-4-6", + reason: "rate limit", + }, inputTokens: 1, outputTokens: 2, totalTokens: 3, @@ -368,7 +371,7 @@ describe("resolveCronSession", () => { cacheWrite: 5, contextTokens: 200_000, compactionCount: 9, - memoryFlushAt: NOW_MS - 500, + memoryFlush: { kind: "succeeded", compactionCount: 9 }, abortCutoffMessageSid: "old-message", spawnedBy: "agent:main:session:parent", skillsSnapshot: { @@ -440,9 +443,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.cliSessionBindings).toBeUndefined(); expect(result.sessionEntry.claudeCliSessionId).toBeUndefined(); expect(result.sessionEntry.liveModelSwitchPending).toBeUndefined(); - expect(result.sessionEntry.fallbackNoticeSelectedModel).toBeUndefined(); - expect(result.sessionEntry.fallbackNoticeActiveModel).toBeUndefined(); - expect(result.sessionEntry.fallbackNoticeReason).toBeUndefined(); + expect(result.sessionEntry.fallbackNotice).toBeUndefined(); expect(result.sessionEntry.inputTokens).toBeUndefined(); expect(result.sessionEntry.outputTokens).toBeUndefined(); expect(result.sessionEntry.totalTokens).toBeUndefined(); @@ -456,7 +457,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.cacheWrite).toBeUndefined(); expect(result.sessionEntry.contextTokens).toBeUndefined(); expect(result.sessionEntry.compactionCount).toBeUndefined(); - expect(result.sessionEntry.memoryFlushAt).toBeUndefined(); + expect(result.sessionEntry.memoryFlush).toBeUndefined(); expect(result.sessionEntry.abortCutoffMessageSid).toBeUndefined(); expect(result.sessionEntry.spawnedBy).toBeUndefined(); expect(result.sessionEntry.skillsSnapshot).toBeUndefined(); diff --git a/src/gateway/server-methods/chat-restart-recovery.ts b/src/gateway/server-methods/chat-restart-recovery.ts index d0fa1a1424eb..6f36e3a64fad 100644 --- a/src/gateway/server-methods/chat-restart-recovery.ts +++ b/src/gateway/server-methods/chat-restart-recovery.ts @@ -223,9 +223,7 @@ function isRestartSafeChatSession(params: { entry.abortedLastRun !== true && entry.archivedAt === undefined && entry.initializationPending !== true && - entry.pendingFinalDelivery !== true && - entry.pendingFinalDeliveryText == null && - entry.pendingFinalDeliveryContext === undefined && + entry.pendingFinalDelivery === undefined && entry.agentHarnessId === undefined && entry.pluginOwnerId === undefined && entry.spawnedBy === undefined && diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.test.ts index 27375df1f678..0ff532a20d1e 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.test.ts @@ -3619,11 +3619,14 @@ describe("gateway server chat", () => { caseName: "pending final delivery", runId: "idem-pending-final-delivery", entry: { - pendingFinalDelivery: true, - pendingFinalDeliveryText: "older reply", - pendingFinalDeliveryContext: { - channel: "whatsapp", - to: "+15551234567", + pendingFinalDelivery: { + kind: "replayable" as const, + text: "older reply", + createdAt: Date.now(), + context: { + channel: "whatsapp", + to: "+15551234567", + }, }, }, }, diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index e7e2ad56d1e9..3dec8d72df30 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -51,9 +51,7 @@ type ResetSessionEntry = { model?: string; authProfileOverrideSource?: string; authProfileOverrideCompactionCount?: number; - fallbackNoticeSelectedModel?: string; - fallbackNoticeActiveModel?: string; - fallbackNoticeReason?: string; + fallbackNotice?: SessionEntry["fallbackNotice"]; sendPolicy?: string; queueMode?: string; queueDebounceMs?: number; @@ -544,9 +542,12 @@ test("sessions.reset clears fallback-pinned model overrides and restores the sel providerOverride: "anthropic", modelOverride: "claude-opus-4-1", modelOverrideSource: "auto", - fallbackNoticeSelectedModel: "openai/gpt-test-a", - fallbackNoticeActiveModel: "anthropic/claude-opus-4-1", - fallbackNoticeReason: "rate limit", + fallbackNotice: { + kind: "active", + selectedModel: "openai/gpt-test-a", + activeModel: "anthropic/claude-opus-4-1", + reason: "rate limit", + }, }, expected: { providerOverride: undefined, @@ -564,9 +565,12 @@ test("sessions.reset follows the updated default after an auto fallback pinned a providerOverride: "anthropic", modelOverride: "claude-opus-4-1", modelOverrideSource: "auto", - fallbackNoticeSelectedModel: "openai/gpt-test-a", - fallbackNoticeActiveModel: "anthropic/claude-opus-4-1", - fallbackNoticeReason: "rate limit", + fallbackNotice: { + kind: "active", + selectedModel: "openai/gpt-test-a", + activeModel: "anthropic/claude-opus-4-1", + reason: "rate limit", + }, }, expected: { providerOverride: undefined, diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 1ecc1da247bf..35e3beddb389 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -25,6 +25,7 @@ import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-l import type { OpenClawConfig } from "../config/types.openclaw.js"; import { projectPluginSessionExtensionsSync } from "../plugins/host-hook-state.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; +import { classifySessionKind } from "../sessions/classify-session-kind.js"; import { resolveActiveSessionAgentStatus } from "../sessions/session-agent-status.js"; import { resolveNonNegativeNumber } from "../shared/number-coercion.js"; import { getUserProfileListItem } from "../state/user-profiles.js"; @@ -55,11 +56,7 @@ import { resolveSessionSelectedModelRef, resolveTranscriptUsageFallback, } from "./session-utils-projection.js"; -import { - classifySessionKey, - isGroupOrChannelDisplaySession, - parseGroupKey, -} from "./session-utils-store.js"; +import { isGroupOrChannelDisplaySession, parseGroupKey } from "./session-utils-store.js"; import type { GatewaySessionRow } from "./session-utils.types.js"; /** Adds the current human profile label without persisting rename-prone display data. */ @@ -117,6 +114,10 @@ export function buildGatewaySessionRow(params: { : undefined; const updatedAt = entry?.updatedAt ?? null; const parsed = parseGroupKey(key); + const sessionKind = classifySessionKind(key, entry); + // The older Gateway wire kind folds cron/spawn-child into direct. + const gatewayKind = + sessionKind === "cron" || sessionKind === "spawn-child" ? "direct" : sessionKind; const deliveryFields = projectSessionDeliveryFields(entry?.delivery); const channel = deliveryFields.channel ?? parsed?.channel; const subject = entry?.subject; @@ -413,7 +414,7 @@ export function buildGatewaySessionRow(params: { createdAt: entry?.createdAt, forkSource: entry?.forkSource, previousSessionId: entry?.previousSessionId, - kind: classifySessionKey(key, entry), + kind: gatewayKind, label: entry?.label, category: entry?.category, boardFace: entry?.boardFace, diff --git a/src/gateway/session-utils-store.ts b/src/gateway/session-utils-store.ts index f60e8363f06f..310a9f1b0c39 100644 --- a/src/gateway/session-utils-store.ts +++ b/src/gateway/session-utils-store.ts @@ -37,7 +37,7 @@ import { resolveGatewaySessionStoreTarget, resolveGatewaySessionStoreTargetWithStore, } from "./session-utils-store-lookup.js"; -import type { GatewayAgentRow, GatewaySessionRow } from "./session-utils.types.js"; +import type { GatewayAgentRow } from "./session-utils.types.js"; /** * Returns the owning agent id if the session key belongs to an agent that is no @@ -250,22 +250,6 @@ export function migrateAndPruneGatewaySessionStoreKey(params: { return { target, primaryKey, entry: params.store[primaryKey] }; } -export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] { - if (key === "global") { - return "global"; - } - if (key === "unknown") { - return "unknown"; - } - if (entry?.chatType === "group" || entry?.chatType === "channel") { - return "group"; - } - if (key.includes(":group:") || key.includes(":channel:")) { - return "group"; - } - return "direct"; -} - export function parseGroupKey( key: string, ): { channel?: string; kind?: "group" | "channel"; id?: string } | null { diff --git a/src/infra/heartbeat-runner-delivery.ts b/src/infra/heartbeat-runner-delivery.ts index ddd73c7c43ad..74d872cd6c36 100644 --- a/src/infra/heartbeat-runner-delivery.ts +++ b/src/infra/heartbeat-runner-delivery.ts @@ -1,4 +1,3 @@ -import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { hasOutboundReplyContent, resolveSendableOutboundReplyParts, @@ -41,13 +40,6 @@ const log = heartbeatLog; // behind keeps the session stuck on a delivery that already happened. const CLEARED_PENDING_FINAL_DELIVERY_FIELDS = { pendingFinalDelivery: undefined, - pendingFinalDeliveryText: undefined, - pendingFinalDeliveryCreatedAt: undefined, - pendingFinalDeliveryLastAttemptAt: undefined, - pendingFinalDeliveryAttemptCount: undefined, - pendingFinalDeliveryLastError: undefined, - pendingFinalDeliveryContext: undefined, - pendingFinalDeliveryIntentId: undefined, } as const; // Clear pending-final only when this run produced it: the agent run stamps @@ -57,7 +49,7 @@ function heartbeatRunOwnsPendingFinalDelivery( entry: SessionEntry | undefined, runStartedAt: number, ): boolean { - const createdAt = entry?.pendingFinalDeliveryCreatedAt; + const createdAt = entry?.pendingFinalDelivery?.createdAt; return typeof createdAt === "number" && createdAt >= runStartedAt; } @@ -456,7 +448,7 @@ async function clearSatisfiedPendingFinalDelivery( if (!context.existingEntry) { return null; } - if (current?.pendingFinalDelivery !== true && !current?.pendingFinalDeliveryText) { + if (!current?.pendingFinalDelivery) { return null; } if (!heartbeatRunOwnsPendingFinalDelivery(current, wake.startedAt)) { @@ -466,7 +458,8 @@ async function clearSatisfiedPendingFinalDelivery( // several. Clear only when the delivered payload represents the whole final. if ( expectedText !== undefined && - normalizeOptionalString(current.pendingFinalDeliveryText) !== expectedText + (current.pendingFinalDelivery.kind !== "replayable" || + current.pendingFinalDelivery.text !== expectedText) ) { return null; } diff --git a/src/infra/heartbeat-runner-execution.ts b/src/infra/heartbeat-runner-execution.ts index bf50af430333..5d64cc602e77 100644 --- a/src/infra/heartbeat-runner-execution.ts +++ b/src/infra/heartbeat-runner-execution.ts @@ -251,7 +251,10 @@ export async function resolveHeartbeatWakeStage(opts: HeartbeatRunOptions) { opts.sessionKey, ); const HEARTBEAT_DEFER_WINDOW_MS = 30_000; - const pendingFinalDeliveryText = recentSessionEntry?.pendingFinalDeliveryText; + const pendingFinalDeliveryText = + recentSessionEntry?.pendingFinalDelivery?.kind === "replayable" + ? recentSessionEntry.pendingFinalDelivery.text + : undefined; const pendingFinalDeliveryIsHeartbeatAck = typeof pendingFinalDeliveryText === "string" && stripHeartbeatToken(pendingFinalDeliveryText, { @@ -259,7 +262,7 @@ export async function resolveHeartbeatWakeStage(opts: HeartbeatRunOptions) { maxAckChars: resolveHeartbeatAckMaxChars(cfg, heartbeat), }).shouldSkip; if ( - recentSessionEntry?.pendingFinalDelivery === true && + recentSessionEntry?.pendingFinalDelivery !== undefined && !pendingFinalDeliveryIsHeartbeatAck && recentSessionEntry?.updatedAt && startedAt - recentSessionEntry.updatedAt < HEARTBEAT_DEFER_WINDOW_MS diff --git a/src/infra/heartbeat-runner.clears-pending-final-delivery.test.ts b/src/infra/heartbeat-runner.clears-pending-final-delivery.test.ts index 74a0069af140..0dd2fd0eb27c 100644 --- a/src/infra/heartbeat-runner.clears-pending-final-delivery.test.ts +++ b/src/infra/heartbeat-runner.clears-pending-final-delivery.test.ts @@ -43,16 +43,13 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery return { telegram: sendTelegram as unknown, getQueueSize: () => 0, - // A fixed clock lets a test seed pendingFinalDeliveryCreatedAt relative to + // A fixed clock lets a test seed pending delivery creation relative to // the run's startedAt, which is what the ownership guard compares against. nowMs: () => now ?? Date.now(), getReplyFromConfig: replySpy, } satisfies HeartbeatDeps; } - // seedMainSessionStore exposes only part of the pendingFinalDelivery* family; - // patch in lastHeartbeat* and the three unexposed pending fields so each test can - // prove all eight recovery fields get cleared. async function patchEntry( storePath: string, sessionKey: string, @@ -67,13 +64,6 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery function expectPendingFinalDeliveryCleared(entry: StoredEntry): void { expect(entry?.pendingFinalDelivery).toBeUndefined(); - expect(entry?.pendingFinalDeliveryText).toBeUndefined(); - expect(entry?.pendingFinalDeliveryCreatedAt).toBeUndefined(); - expect(entry?.pendingFinalDeliveryLastAttemptAt).toBeUndefined(); - expect(entry?.pendingFinalDeliveryAttemptCount).toBeUndefined(); - expect(entry?.pendingFinalDeliveryLastError).toBeUndefined(); - expect(entry?.pendingFinalDeliveryContext).toBeUndefined(); - expect(entry?.pendingFinalDeliveryIntentId).toBeUndefined(); } it("nulls every pendingFinalDelivery* field after delivering substantive heartbeat content", async () => { @@ -90,16 +80,13 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery lastProvider: "telegram", lastTo: TELEGRAM_GROUP, updatedAt: NOW, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "HEARTBEAT_OK", - pendingFinalDeliveryCreatedAt: NOW, - pendingFinalDeliveryAttemptCount: 3, - pendingFinalDeliveryLastError: "prior-error", - }); - await patchEntry(storePath, sessionKey, { - pendingFinalDeliveryLastAttemptAt: NOW, - pendingFinalDeliveryContext: { foo: "bar" }, - pendingFinalDeliveryIntentId: "intent-send-success", + pendingFinalDelivery: { + kind: "replayable", + text: "HEARTBEAT_OK", + createdAt: NOW, + context: { channel: "telegram", to: "target" }, + intentId: "intent-send-success", + }, }); // Substantive reply text forces the post-success store write path @@ -159,22 +146,19 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery lastProvider: "telegram", lastTo: TELEGRAM_GROUP, updatedAt: staleAt, - pendingFinalDelivery: true, - pendingFinalDeliveryText: body, // prefix-less; diverges from deliveredText - // createdAt at run start: this run produced the pending (real runs stamp - // a fresh createdAt during the agent turn the defer gate ran ahead of). - pendingFinalDeliveryCreatedAt: NOW, - pendingFinalDeliveryAttemptCount: 3, - pendingFinalDeliveryLastError: "prior-error", + pendingFinalDelivery: { + kind: "replayable", + text: body, // prefix-less; diverges from deliveredText + createdAt: NOW, + context: { channel: "telegram", to: "target" }, + intentId: "intent-duplicate-skip", + }, }); await patchEntry(storePath, sessionKey, { // lastHeartbeat* proves the same payload already went out within 24h, // which is what makes this run a duplicate and the pending clear safe. lastHeartbeatText: deliveredText, lastHeartbeatSentAt: staleAt, - pendingFinalDeliveryLastAttemptAt: NOW, - pendingFinalDeliveryContext: { foo: "bar" }, - pendingFinalDeliveryIntentId: "intent-duplicate-skip", }); // Reply is the prefix-less body; normalizeHeartbeatReply re-adds "🤖 ", so @@ -216,16 +200,13 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery lastTo: TELEGRAM_GROUP, // Stale so the substantive-pending defer gate does not bail this run. updatedAt: NOW - 60_000, - pendingFinalDelivery: true, - pendingFinalDeliveryText: olderText, - pendingFinalDeliveryCreatedAt: olderCreatedAt, - pendingFinalDeliveryAttemptCount: 2, - pendingFinalDeliveryLastError: "prior-delivery-failure", - }); - await patchEntry(storePath, sessionKey, { - pendingFinalDeliveryLastAttemptAt: NOW - 50_000, - pendingFinalDeliveryContext: { channel: "telegram", to: "older-chat" }, - pendingFinalDeliveryIntentId: "intent-older-unsatisfied", + pendingFinalDelivery: { + kind: "replayable", + text: olderText, + createdAt: olderCreatedAt, + context: { channel: "telegram", to: "older-chat" }, + intentId: "intent-older-unsatisfied", + }, }); // A fresh, different heartbeat payload that gets delivered this run. @@ -245,10 +226,12 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery // Send-success records the dedupe markers for the delivered payload... expect(entry?.lastHeartbeatText).toBe(replyText); // ...but the older, unowned pending-final survives for its own recovery. - expect(entry?.pendingFinalDelivery).toBe(true); - expect(entry?.pendingFinalDeliveryText).toBe(olderText); - expect(entry?.pendingFinalDeliveryCreatedAt).toBe(olderCreatedAt); - expect(entry?.pendingFinalDeliveryIntentId).toBe("intent-older-unsatisfied"); + expect(entry?.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: olderText, + createdAt: olderCreatedAt, + intentId: "intent-older-unsatisfied", + }); }); }); @@ -264,19 +247,18 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery lastProvider: "telegram", lastTo: TELEGRAM_GROUP, updatedAt: NOW - 60_000, - pendingFinalDelivery: true, - pendingFinalDeliveryText: olderText, - pendingFinalDeliveryCreatedAt: olderCreatedAt, - pendingFinalDeliveryAttemptCount: 2, - pendingFinalDeliveryLastError: "prior-delivery-failure", + pendingFinalDelivery: { + kind: "replayable", + text: olderText, + createdAt: olderCreatedAt, + context: { channel: "telegram", to: "older-chat" }, + intentId: "intent-older-dupe", + }, }); await patchEntry(storePath, sessionKey, { // Same payload already delivered within 24h -> this run is a duplicate skip. lastHeartbeatText: body, lastHeartbeatSentAt: NOW - 60_000, - pendingFinalDeliveryLastAttemptAt: NOW - 50_000, - pendingFinalDeliveryContext: { channel: "telegram", to: "older-chat" }, - pendingFinalDeliveryIntentId: "intent-older-dupe", }); replySpy.mockResolvedValue({ text: body }); @@ -293,10 +275,12 @@ describe("runHeartbeatOnce clears stuck pendingFinalDelivery state once delivery const entry = await readEntry(storePath, sessionKey); // The duplicate-skip clear must not retire the older, unowned pending-final. - expect(entry?.pendingFinalDelivery).toBe(true); - expect(entry?.pendingFinalDeliveryText).toBe(olderText); - expect(entry?.pendingFinalDeliveryCreatedAt).toBe(olderCreatedAt); - expect(entry?.pendingFinalDeliveryIntentId).toBe("intent-older-dupe"); + expect(entry?.pendingFinalDelivery).toMatchObject({ + kind: "replayable", + text: olderText, + createdAt: olderCreatedAt, + intentId: "intent-older-dupe", + }); }); }); }); diff --git a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts index a8935c174df1..7b6b6ceb10e7 100644 --- a/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts +++ b/src/infra/heartbeat-runner.skips-busy-session-lane.test.ts @@ -439,8 +439,11 @@ describe("heartbeat runner skips when target session lane is busy", () => { lastProvider: "heartbeat", lastTo: "heartbeat", updatedAt: Date.now(), - pendingFinalDelivery: true, - pendingFinalDeliveryText: "HEARTBEAT_OK", + pendingFinalDelivery: { + kind: "replayable", + text: "HEARTBEAT_OK", + createdAt: Date.now(), + }, }); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); @@ -468,8 +471,11 @@ describe("heartbeat runner skips when target session lane is busy", () => { lastProvider: "heartbeat", lastTo: "heartbeat", updatedAt: Date.now(), - pendingFinalDelivery: true, - pendingFinalDeliveryText: "HEARTBEAT_OK short", + pendingFinalDelivery: { + kind: "replayable", + text: "HEARTBEAT_OK short", + createdAt: Date.now(), + }, }); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); @@ -500,9 +506,11 @@ describe("heartbeat runner skips when target session lane is busy", () => { lastProvider: "telegram", lastTo: "default-heartbeat-target", updatedAt: Date.now() - 60_000, - pendingFinalDelivery: true, - pendingFinalDeliveryText: "private prior user answer", - pendingFinalDeliveryCreatedAt: Date.now() - 60_000, + pendingFinalDelivery: { + kind: "replayable", + text: "private prior user answer", + createdAt: Date.now() - 60_000, + }, }); replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" }); const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "default" }); diff --git a/src/infra/heartbeat-runner.tool-response.test.ts b/src/infra/heartbeat-runner.tool-response.test.ts index dbcdfe87ce02..c96523a60c8d 100644 --- a/src/infra/heartbeat-runner.tool-response.test.ts +++ b/src/infra/heartbeat-runner.tool-response.test.ts @@ -15,6 +15,7 @@ import { } from "../auto-reply/reply/agent-runner-failure-copy.js"; import type { OpenClawConfig } from "../config/config.js"; import { patchSessionEntry } from "../config/sessions/session-accessor.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import { deleteCronJobScratch, readCronJobScratchState, @@ -544,9 +545,11 @@ describe("runHeartbeatOnce heartbeat response tool", () => { await patchSessionEntry( { storePath, sessionKey }, () => ({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: pendingText, - pendingFinalDeliveryCreatedAt: Date.now(), + pendingFinalDelivery: { + kind: "replayable", + text: pendingText, + createdAt: Date.now(), + }, }), { preserveActivity: true }, ); @@ -565,13 +568,14 @@ describe("runHeartbeatOnce heartbeat response tool", () => { ).resolves.toEqual({ status: "failed", reason: "agent-tool-failure" }); const sessionStore = readSessionStoreForTest<{ - pendingFinalDelivery?: boolean; - pendingFinalDeliveryText?: string; + pendingFinalDelivery?: SessionEntry["pendingFinalDelivery"]; }>(storePath); expectTelegramSend(sendTelegram, { text: warning, cfg }); expect(sessionStore[sessionKey]).toMatchObject({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: pendingText, + pendingFinalDelivery: expect.objectContaining({ + kind: "replayable", + text: pendingText, + }), }); }); }); @@ -589,9 +593,11 @@ describe("runHeartbeatOnce heartbeat response tool", () => { await patchSessionEntry( { storePath, sessionKey }, () => ({ - pendingFinalDelivery: true, - pendingFinalDeliveryText: warning, - pendingFinalDeliveryCreatedAt: Date.now(), + pendingFinalDelivery: { + kind: "replayable", + text: warning, + createdAt: Date.now(), + }, }), { preserveActivity: true }, ); @@ -610,12 +616,10 @@ describe("runHeartbeatOnce heartbeat response tool", () => { ).resolves.toEqual({ status: "failed", reason: "agent-tool-failure" }); const sessionStore = readSessionStoreForTest<{ - pendingFinalDelivery?: boolean; - pendingFinalDeliveryText?: string; + pendingFinalDelivery?: SessionEntry["pendingFinalDelivery"]; }>(storePath); expectTelegramSend(sendTelegram, { text: warning, cfg }); expect(sessionStore[sessionKey]?.pendingFinalDelivery).toBeUndefined(); - expect(sessionStore[sessionKey]?.pendingFinalDeliveryText).toBeUndefined(); }); }); diff --git a/src/infra/state-migrations.legacy-session-store.test.ts b/src/infra/state-migrations.legacy-session-store.test.ts index 90e8cf0b92f5..e722b10487b9 100644 --- a/src/infra/state-migrations.legacy-session-store.test.ts +++ b/src/infra/state-migrations.legacy-session-store.test.ts @@ -92,7 +92,7 @@ it("normalizes file-era rows and drops malformed entries", async () => { }); expect(store["agent:main:main"]).not.toHaveProperty("channel"); expect(store["agent:main:main"]).not.toHaveProperty("lastChannel"); - expect(store["agent:main:main"]?.pendingFinalDeliveryAttemptCount).toBeUndefined(); + expect(store["agent:main:main"]).not.toHaveProperty("pendingFinalDeliveryAttemptCount"); }); }); @@ -125,6 +125,6 @@ it("normalizes compatibility writes before persistence", async () => { }, }); expect(persisted["agent:main:main"]).not.toHaveProperty("channel"); - expect(persisted["agent:main:main"]?.pendingFinalDeliveryAttemptCount).toBeUndefined(); + expect(persisted["agent:main:main"]).not.toHaveProperty("pendingFinalDeliveryAttemptCount"); }); }); diff --git a/src/infra/state-migrations.legacy-session-store.ts b/src/infra/state-migrations.legacy-session-store.ts index 3048d89104ca..bfe2cf98e155 100644 --- a/src/infra/state-migrations.legacy-session-store.ts +++ b/src/infra/state-migrations.legacy-session-store.ts @@ -94,26 +94,12 @@ const loadTrajectoryCleanupRuntime = createLazyRuntimeModule( () => import("../trajectory/cleanup.js"), ); -function normalizeOptionalFiniteNumber(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; -} - -function normalizeOptionalAttemptCount(value: unknown): number | undefined { - return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined; -} - -function normalizeOptionalStringOrNull(value: unknown): string | null | undefined { - return value === null || typeof value === "string" ? value : undefined; -} - function normalizeRecordKey(value: string): string | undefined { const key = value.trim(); return key.length > 0 ? key : undefined; } -function normalizeOptionalDeliveryContext( - value: unknown, -): SessionEntry["pendingFinalDeliveryContext"] { +function normalizeOptionalDeliveryContext(value: unknown): DeliveryContext | undefined { if (!isRecord(value)) { return undefined; } @@ -130,8 +116,8 @@ function normalizeOptionalDeliveryContext( } function sameDeliveryContext( - left: SessionEntry["pendingFinalDeliveryContext"], - right: SessionEntry["pendingFinalDeliveryContext"], + left: DeliveryContext | undefined, + right: DeliveryContext | undefined, ): boolean { return ( (left?.channel ?? undefined) === (right?.channel ?? undefined) && @@ -141,7 +127,7 @@ function sameDeliveryContext( ); } -function normalizePendingFinalDeliveryFields(entry: SessionEntry): SessionEntry { +function normalizeRestartRecoveryFields(entry: SessionEntry): SessionEntry { let next = entry; const assign = (key: K, value: SessionEntry[K] | undefined) => { if (entry[key] === value) { @@ -157,32 +143,6 @@ function normalizePendingFinalDeliveryFields(entry: SessionEntry): SessionEntry } }; - assign("pendingFinalDelivery", entry.pendingFinalDelivery === true ? true : undefined); - assign("pendingFinalDeliveryText", normalizeOptionalStringOrNull(entry.pendingFinalDeliveryText)); - assign( - "pendingFinalDeliveryCreatedAt", - normalizeOptionalFiniteNumber(entry.pendingFinalDeliveryCreatedAt), - ); - assign( - "pendingFinalDeliveryLastAttemptAt", - normalizeOptionalFiniteNumber(entry.pendingFinalDeliveryLastAttemptAt), - ); - assign( - "pendingFinalDeliveryAttemptCount", - normalizeOptionalAttemptCount(entry.pendingFinalDeliveryAttemptCount), - ); - assign( - "pendingFinalDeliveryLastError", - normalizeOptionalStringOrNull(entry.pendingFinalDeliveryLastError), - ); - const pendingContext = normalizeOptionalDeliveryContext(entry.pendingFinalDeliveryContext); - if (!sameDeliveryContext(entry.pendingFinalDeliveryContext, pendingContext)) { - assign("pendingFinalDeliveryContext", pendingContext); - } - assign( - "pendingFinalDeliveryIntentId", - normalizeOptionalStringOrNull(entry.pendingFinalDeliveryIntentId), - ); const restartContext = normalizeOptionalDeliveryContext(entry.restartRecoveryDeliveryContext); if (!sameDeliveryContext(entry.restartRecoveryDeliveryContext, restartContext)) { assign("restartRecoveryDeliveryContext", restartContext); @@ -312,7 +272,7 @@ function normalizeLegacySessionStore(store: Record): void store[key] = stripPersistedSkillsCache( normalizePluginExtensionSlotKeys( normalizePluginExtensions( - normalizePendingFinalDeliveryFields( + normalizeRestartRecoveryFields( normalizeLegacySessionEntryDelivery(modelSelectionLocked ? shaped : runtimeFields), ), ), diff --git a/src/plugins/contracts/session-entry-projection.contract.test.ts b/src/plugins/contracts/session-entry-projection.contract.test.ts index afe46d779855..5d4f75ca1c71 100644 --- a/src/plugins/contracts/session-entry-projection.contract.test.ts +++ b/src/plugins/contracts/session-entry-projection.contract.test.ts @@ -315,6 +315,11 @@ describe("plugin session extension SessionEntry projection", () => { description: "retired transcript locator", sessionEntrySlotKey: "transcriptPath", }); + api.registerSessionExtension({ + namespace: "pending-final-text", + description: "retired pending-final field", + sessionEntrySlotKey: "pendingFinalDeliveryText", + }); }, }); @@ -342,6 +347,10 @@ describe("plugin session extension SessionEntry projection", () => { pluginId: "slot-collision", message: "sessionEntrySlotKey is reserved by SessionEntry: transcriptPath", }, + { + pluginId: "slot-collision", + message: "sessionEntrySlotKey is reserved by SessionEntry: pendingFinalDeliveryText", + }, ]); }); diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 1c26c8df4f32..7d384c8e861f 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -111,13 +111,6 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "outputTokens", "totalTokens", "pendingFinalDelivery", - "pendingFinalDeliveryCreatedAt", - "pendingFinalDeliveryLastAttemptAt", - "pendingFinalDeliveryAttemptCount", - "pendingFinalDeliveryLastError", - "pendingFinalDeliveryText", - "pendingFinalDeliveryContext", - "pendingFinalDeliveryIntentId", "restartRecoveryDeliveryContext", "restartRecoveryDeliveryMediaUrls", "restartRecoveryDisableMessageTool", @@ -143,19 +136,12 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "model", "modelSelectionLocked", "agentHarnessId", - "fallbackNoticeSelectedModel", - "fallbackNoticeActiveModel", - "fallbackNoticeReason", + "fallbackNotice", "contextTokens", "contextBudgetStatus", "compactionCount", "compactionCheckpoints", - "memoryFlushAt", - "memoryFlushCompactionCount", - "memoryFlushContextHash", - "memoryFlushFailureCount", - "memoryFlushLastFailedAt", - "memoryFlushLastFailureError", + "memoryFlush", "cliSessionIds", "cliSessionBindings", "acpSessionBinding", @@ -193,7 +179,7 @@ type SessionEntryReservedSlotSetValue = [MissingSessionEntryReservedSlotKey] ext const SESSION_ENTRY_RESERVED_SLOT_KEYS = new Set( SESSION_ENTRY_RESERVED_SLOT_KEY_LIST, ); -const RETIRED_SESSION_DELIVERY_SLOT_KEYS = new Set([ +const RETIRED_SESSION_SLOT_KEYS = new Set([ "channel", "origin", "route", @@ -202,6 +188,22 @@ const RETIRED_SESSION_DELIVERY_SLOT_KEYS = new Set([ "lastTo", "lastAccountId", "lastThreadId", + "pendingFinalDeliveryCreatedAt", + "pendingFinalDeliveryLastAttemptAt", + "pendingFinalDeliveryAttemptCount", + "pendingFinalDeliveryLastError", + "pendingFinalDeliveryText", + "pendingFinalDeliveryContext", + "pendingFinalDeliveryIntentId", + "fallbackNoticeSelectedModel", + "fallbackNoticeActiveModel", + "fallbackNoticeReason", + "memoryFlushAt", + "memoryFlushCompactionCount", + "memoryFlushContextHash", + "memoryFlushFailureCount", + "memoryFlushLastFailedAt", + "memoryFlushLastFailureError", ]); const OBJECT_PROTOTYPE_RESERVED_SLOT_KEYS = new Set([ "prototype", @@ -226,7 +228,7 @@ export function normalizeSessionEntrySlotKey( error: "sessionEntrySlotKey must be an identifier-style field name", }; } - if (SESSION_ENTRY_RESERVED_SLOT_KEYS.has(key) || RETIRED_SESSION_DELIVERY_SLOT_KEYS.has(key)) { + if (SESSION_ENTRY_RESERVED_SLOT_KEYS.has(key) || RETIRED_SESSION_SLOT_KEYS.has(key)) { return { ok: false, error: `sessionEntrySlotKey is reserved by SessionEntry: ${key}`, diff --git a/src/sessions/model-overrides.test.ts b/src/sessions/model-overrides.test.ts index c4cfc36d9efe..53291b7fb599 100644 --- a/src/sessions/model-overrides.test.ts +++ b/src/sessions/model-overrides.test.ts @@ -102,9 +102,12 @@ describe("applyModelOverrideToSessionEntry", () => { model: "claude-sonnet-4-6", contextTokenBudget: 200_000, }), - fallbackNoticeSelectedModel: "anthropic/claude-sonnet-4-6", - fallbackNoticeActiveModel: "anthropic/claude-sonnet-4-6", - fallbackNoticeReason: "provider temporary failure", + fallbackNotice: { + kind: "active", + selectedModel: "anthropic/claude-sonnet-4-6", + activeModel: "anthropic/claude-sonnet-4-6", + reason: "provider temporary failure", + }, }; const result = applyOpenAiSelection(entry); @@ -113,9 +116,7 @@ describe("applyModelOverrideToSessionEntry", () => { expectRuntimeModelFieldsCleared(entry, before); expect(entry.contextTokens).toBeUndefined(); expect(entry.contextBudgetStatus).toBeUndefined(); - expect(entry.fallbackNoticeSelectedModel).toBeUndefined(); - expect(entry.fallbackNoticeActiveModel).toBeUndefined(); - expect(entry.fallbackNoticeReason).toBeUndefined(); + expect(entry.fallbackNotice).toBeUndefined(); expect(entry.modelOverrideSource).toBe("user"); expect(entry.modelOverrideRouteResolution).toBe("resolved"); }); diff --git a/src/sessions/model-overrides.ts b/src/sessions/model-overrides.ts index d1ae0eee3296..d634f31fe04f 100644 --- a/src/sessions/model-overrides.ts +++ b/src/sessions/model-overrides.ts @@ -191,9 +191,7 @@ export function applyModelOverrideToSessionEntry(params: { // runtime model fields so live-switch resolution lands on the default. entry.liveModelSwitchPending = true; } - delete entry.fallbackNoticeSelectedModel; - delete entry.fallbackNoticeActiveModel; - delete entry.fallbackNoticeReason; + delete entry.fallbackNotice; entry.updatedAt = Date.now(); } diff --git a/src/status/fallback-notice-state.ts b/src/status/fallback-notice-state.ts index ca8cd4fa7f0c..d0ec56fa4163 100644 --- a/src/status/fallback-notice-state.ts +++ b/src/status/fallback-notice-state.ts @@ -6,10 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; // Persisted fallback notice state is active only when the current selected and // active runtime refs still match the recorded fallback transition. -export type FallbackNoticeState = Pick< - SessionEntry, - "fallbackNoticeSelectedModel" | "fallbackNoticeActiveModel" | "fallbackNoticeReason" ->; +export type FallbackNoticeState = Pick; export function resolveActiveFallbackState(params: { selectedModelRef: string; @@ -17,9 +14,9 @@ export function resolveActiveFallbackState(params: { config?: OpenClawConfig; state?: FallbackNoticeState; }): { active: boolean; reason?: string } { - const selected = normalizeOptionalString(params.state?.fallbackNoticeSelectedModel); - const active = normalizeOptionalString(params.state?.fallbackNoticeActiveModel); - const reason = normalizeOptionalString(params.state?.fallbackNoticeReason); + const selected = normalizeOptionalString(params.state?.fallbackNotice?.selectedModel); + const active = normalizeOptionalString(params.state?.fallbackNotice?.activeModel); + const reason = normalizeOptionalString(params.state?.fallbackNotice?.reason); const fallbackActive = !areRuntimeModelRefsEquivalent(params.selectedModelRef, params.activeModelRef, { config: params.config, diff --git a/src/status/status-message.ts b/src/status/status-message.ts index 3288f9b19eb7..8a1752ee7720 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -627,7 +627,7 @@ export function buildStatusMessage(args: StatusArgs): string { initialFallbackState.active && normalizeLowercaseStringOrEmpty(runtimeModelRaw) === normalizeLowercaseStringOrEmpty( - normalizeOptionalString(entry?.fallbackNoticeActiveModel ?? "") ?? "", + normalizeOptionalString(entry?.fallbackNotice?.activeModel ?? "") ?? "", ); const runtimeMatchesSelectedModel = normalizeLowercaseStringOrEmpty(runtimeModelRaw) ===