refactor(sessions): consolidate entry state (#115299)

* refactor(sessions): consolidate entry state

* refactor(gateway): unify session kind classification

* refactor(sessions): keep entry state types private

* test(sessions): drop retired pending delivery case

* fix(sessions): clear stale transport-only delivery
This commit is contained in:
Peter Steinberger
2026-07-28 14:31:02 -04:00
committed by GitHub
parent 642befa179
commit 8ee1d046d3
70 changed files with 1022 additions and 992 deletions
@@ -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 () => {
@@ -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 }),
}),
);
});
+6 -3
View File
@@ -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);
+1 -3
View File
@@ -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;
}
+7 -7
View File
@@ -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),
});
+1 -11
View File
@@ -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,
@@ -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,
+3 -7
View File
@@ -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);
}
+1 -1
View File
@@ -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)
@@ -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:
@@ -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,
@@ -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 () => {
+8 -8
View File
@@ -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,
};
+24 -12
View File
@@ -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: {},
});
+3 -3
View File
@@ -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(
@@ -159,9 +159,7 @@ export async function clearRecoveredAutoFallbackPrimaryProbeSelection(params: {
authProfileOverrideCompactionCount: undefined,
}
: {}),
fallbackNoticeSelectedModel: undefined,
fallbackNoticeActiveModel: undefined,
fallbackNoticeReason: undefined,
fallbackNotice: undefined,
updatedAt: persistedEntry.updatedAt,
};
},
@@ -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<string, typeof sessionEntry>;
activeSessionEntry?: SessionEntry;
activeSessionStore?: Record<string, SessionEntry>;
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) {
@@ -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 () => {
+18 -13
View File
@@ -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) {
@@ -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);
@@ -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");
}
@@ -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 () => {
@@ -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;
@@ -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);
+18 -9
View File
@@ -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",
@@ -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",
});
@@ -36,13 +36,6 @@ function buildPendingFinalDeliveryCleanupPatch(entry: SessionEntry): Partial<Ses
const endedAt = completesHookHandledTurn ? Date.now() : undefined;
return {
pendingFinalDelivery: undefined,
pendingFinalDeliveryText: undefined,
pendingFinalDeliveryCreatedAt: undefined,
pendingFinalDeliveryLastAttemptAt: undefined,
pendingFinalDeliveryAttemptCount: undefined,
pendingFinalDeliveryLastError: undefined,
pendingFinalDeliveryContext: undefined,
pendingFinalDeliveryIntentId: undefined,
...(clearsRestartRecoveryProof
? {
restartRecoveryBeforeAgentReplyState: undefined,
@@ -68,16 +61,17 @@ function matchesPendingFinalDeliveryIdentity(
entry: SessionEntry,
expected: PendingFinalDeliveryIdentity,
): boolean {
const currentPresent = Boolean(entry.pendingFinalDelivery || entry.pendingFinalDeliveryText);
const pending = entry.pendingFinalDelivery;
const currentPresent = pending !== undefined;
if (currentPresent !== expected.present) {
return false;
}
if (expected.intentId) {
return normalizeOptionalString(entry.pendingFinalDeliveryIntentId) === expected.intentId;
return pending?.intentId === expected.intentId;
}
return (
entry.pendingFinalDeliveryCreatedAt === expected.createdAt &&
normalizeOptionalString(entry.pendingFinalDeliveryText) === expected.text
pending?.createdAt === expected.createdAt &&
(pending?.kind === "replayable" ? pending.text : undefined) === expected.text
);
}
@@ -96,7 +90,7 @@ export async function clearPendingFinalDeliveryAfterSuccess(params: {
if (!matchesPendingFinalDeliveryIdentity(entry, identity)) {
return null;
}
if (!entry.pendingFinalDelivery && !entry.pendingFinalDeliveryText) {
if (!entry.pendingFinalDelivery) {
return null;
}
return {
@@ -123,20 +117,15 @@ export function capturePendingFinalDeliveryIdentity(params: {
hydrateSkillPromptRefs: false,
readConsistency: "latest",
});
if (
params.intentId &&
normalizeOptionalString(entry?.pendingFinalDeliveryIntentId) !== params.intentId
) {
const pending = entry?.pendingFinalDelivery;
if (params.intentId && pending?.intentId !== params.intentId) {
return { present: false };
}
return {
present: Boolean(entry?.pendingFinalDelivery || entry?.pendingFinalDeliveryText),
intentId: params.intentId ?? normalizeOptionalString(entry?.pendingFinalDeliveryIntentId),
createdAt:
typeof entry?.pendingFinalDeliveryCreatedAt === "number"
? entry.pendingFinalDeliveryCreatedAt
: undefined,
text: normalizeOptionalString(entry?.pendingFinalDeliveryText),
present: pending !== undefined,
intentId: params.intentId ?? pending?.intentId,
createdAt: pending?.createdAt,
text: pending?.kind === "replayable" ? pending.text : undefined,
};
} catch {
return params.intentId ? { present: true, intentId: params.intentId } : undefined;
@@ -209,17 +198,18 @@ export async function reconcilePendingFinalDeliveryAfterSettlement(params: {
if (!matchesPendingFinalDeliveryIdentity(entry, identity)) {
return null;
}
const pendingText = normalizeOptionalString(entry.pendingFinalDeliveryText);
if (!entry.pendingFinalDelivery && !pendingText) {
const pending = entry.pendingFinalDelivery;
if (!pending) {
return null;
}
const pendingPayloads = pendingText
? resolvePendingFinalDeliveryPayloads({
intentId: identity.intentId,
pendingText,
replies: params.replies,
})
: undefined;
const pendingPayloads =
pending.kind === "replayable"
? resolvePendingFinalDeliveryPayloads({
intentId: identity.intentId,
pendingText: pending.text,
replies: params.replies,
})
: undefined;
const pendingPayloadSet = pendingPayloads ? new Set(pendingPayloads) : undefined;
const relevantDeliveries = pendingPayloadSet
? params.deliveries.filter((delivery) => 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(),
};
}
@@ -65,6 +65,13 @@ function createDeferred<T>() {
return { promise, resolve };
}
function pendingFinalDelivery(
text: string,
overrides: { createdAt?: number; context?: Record<string, unknown>; 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 () => {
@@ -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 () => {
+2 -2
View File
@@ -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.
+3 -3
View File
@@ -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-sizebased trigger paths.
*/
export function hasAlreadyFlushedForCurrentCompaction(
entry: Pick<SessionEntry, "compactionCount" | "memoryFlushCompactionCount">,
entry: Pick<SessionEntry, "compactionCount" | "memoryFlush">,
): boolean {
const compactionCount = entry.compactionCount ?? 0;
const lastFlushAt = entry.memoryFlushCompactionCount;
const lastFlushAt = entry.memoryFlush?.compactionCount;
return typeof lastFlushAt === "number" && lastFlushAt === compactionCount;
}
+7 -1
View File
@@ -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,
});
@@ -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<SessionEntry>;
function collectDurableMediaDirectives(payload: ReplyPayload): string[] {
+14 -6
View File
@@ -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);
});
+13 -15
View File
@@ -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;
+12 -33
View File
@@ -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: "<available_skills><skill><name>stale</name></skill></available_skills>",
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,
+2 -11
View File
@@ -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;
@@ -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;
+78 -40
View File
@@ -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,
+8 -9
View File
@@ -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();
});
});
@@ -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) {
@@ -98,7 +98,7 @@ function clearStaleCodexFallbackNotice(
entry: SessionEntry,
blockedModelIdentities?: ReadonlySet<LegacyCodexModelIdentity>,
): 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) =>
@@ -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<string, SessionEntry> = {
[supervisedKey]: lockedEntry,
@@ -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,
+14 -42
View File
@@ -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 () => {
@@ -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",
};
@@ -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<keyof SessionEntry>;
@@ -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"];
+12
View File
@@ -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 = [
+144 -4
View File
@@ -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<string, unknown>
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,
+24 -28
View File
@@ -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<string, string>;
cliSessionBindings?: Record<string, CliSessionBinding>;
/** Initialization fence for seeding canonical ACP metadata; cleared after creation. */
+9 -8
View File
@@ -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();
@@ -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 &&
@@ -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",
},
},
},
},
@@ -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,
+7 -6
View File
@@ -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,
+1 -17
View File
@@ -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 {
+4 -11
View File
@@ -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;
}
+5 -2
View File
@@ -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
@@ -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",
});
});
});
});
@@ -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" });
@@ -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();
});
});
@@ -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");
});
});
@@ -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 = <K extends keyof SessionEntry>(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<string, SessionEntry>): void
store[key] = stripPersistedSkillsCache(
normalizePluginExtensionSlotKeys(
normalizePluginExtensions(
normalizePendingFinalDeliveryFields(
normalizeRestartRecoveryFields(
normalizeLegacySessionEntryDelivery(modelSelectionLocked ? shaped : runtimeFields),
),
),
@@ -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",
},
]);
});
+20 -18
View File
@@ -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<SessionEntryReservedSlotSetValue>(
SESSION_ENTRY_RESERVED_SLOT_KEY_LIST,
);
const RETIRED_SESSION_DELIVERY_SLOT_KEYS = new Set<string>([
const RETIRED_SESSION_SLOT_KEYS = new Set<string>([
"channel",
"origin",
"route",
@@ -202,6 +188,22 @@ const RETIRED_SESSION_DELIVERY_SLOT_KEYS = new Set<string>([
"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<string>([
"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}`,
+7 -6
View File
@@ -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");
});
+1 -3
View File
@@ -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();
}
+4 -7
View File
@@ -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<SessionEntry, "fallbackNotice">;
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,
+1 -1
View File
@@ -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) ===