mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
642befa179
commit
8ee1d046d3
@@ -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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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: {},
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -129,7 +129,7 @@ function resolveMemoryFlushGateState<
|
||||
export function shouldRunMemoryFlush(params: {
|
||||
entry?: Pick<
|
||||
SessionEntry,
|
||||
"totalTokens" | "totalTokensFresh" | "compactionCount" | "memoryFlushCompactionCount"
|
||||
"totalTokens" | "totalTokensFresh" | "compactionCount" | "memoryFlush"
|
||||
>;
|
||||
/**
|
||||
* Optional token count override for flush gating. When provided, this value is
|
||||
@@ -176,9 +176,9 @@ export function shouldRunPreflightCompaction(params: {
|
||||
* important for both the token-based and transcript-size–based trigger paths.
|
||||
*/
|
||||
export function hasAlreadyFlushedForCurrentCompaction(
|
||||
entry: Pick<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;
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) ===
|
||||
|
||||
Reference in New Issue
Block a user