fix: preserve sqlite command session updates

This commit is contained in:
Josh Lehman
2026-07-06 07:43:58 -07:00
parent 518e23a716
commit 77665163bb
21 changed files with 975 additions and 36 deletions

View File

@@ -80,6 +80,13 @@ describe("resetReplyRunSession", () => {
fallbackNoticeSelectedModel: "anthropic/claude",
fallbackNoticeActiveModel: "openai/gpt",
fallbackNoticeReason: "rate limit",
compactionCount: 4,
memoryFlushAt: 50,
memoryFlushCompactionCount: 3,
memoryFlushContextHash: "context-hash",
memoryFlushFailureCount: 2,
memoryFlushLastFailedAt: 60,
memoryFlushLastFailureError: "memory failed",
systemPromptReport: {
source: "run",
generatedAt: 1,
@@ -126,6 +133,13 @@ describe("resetReplyRunSession", () => {
expect(activeSessionEntry?.fallbackNoticeActiveModel).toBeUndefined();
expect(activeSessionEntry?.fallbackNoticeReason).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(refreshQueuedFollowupSessionMock).toHaveBeenCalledWith({
key: "main",
previousSessionId: "session",
@@ -138,6 +152,8 @@ describe("resetReplyRunSession", () => {
expect(persisted?.sessionId).toBe(activeSessionEntry?.sessionId);
expect(persisted?.contextBudgetStatus).toBeUndefined();
expect(persisted?.fallbackNoticeReason).toBeUndefined();
expect(persisted?.compactionCount).toBe(0);
expect(persisted?.memoryFlushAt).toBeUndefined();
});
it("cleans up the old transcript when requested", async () => {

View File

@@ -79,6 +79,13 @@ export async function resetReplyRunSession(params: {
fallbackNoticeSelectedModel: undefined,
fallbackNoticeActiveModel: undefined,
fallbackNoticeReason: undefined,
compactionCount: 0,
memoryFlushAt: undefined,
memoryFlushCompactionCount: undefined,
memoryFlushContextHash: undefined,
memoryFlushFailureCount: undefined,
memoryFlushLastFailedAt: undefined,
memoryFlushLastFailureError: undefined,
};
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
const nextSessionFile = formatSqliteSessionFileMarker({

View File

@@ -35,7 +35,20 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
if (HANDLERS === null) {
HANDLERS = (await loadCommandHandlersRuntime()).loadCommandHandlers();
}
const resetResult = await maybeHandleResetCommand(params);
const allowCreateSessionEntry = params.allowCreateSessionEntry === true;
const initialSessionEntry =
params.initialSessionEntry ??
(allowCreateSessionEntry
? undefined
: params.sessionEntry
? { ...params.sessionEntry }
: undefined);
const commandParams: HandleCommandsParams = {
...params,
initialSessionEntry,
allowCreateSessionEntry,
};
const resetResult = await maybeHandleResetCommand(commandParams);
if (resetResult) {
return normalizeCommandHandlerResult(resetResult);
}
@@ -47,7 +60,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
});
for (const handler of HANDLERS) {
const result = await handler(params, allowTextCommands);
const result = await handler(commandParams, allowTextCommands);
if (result) {
return normalizeCommandHandlerResult(result);
}

View File

@@ -175,7 +175,10 @@ export const handleDockCommand: CommandHandler = async (params, allowTextCommand
sessionEntry.lastTo = target.peerId;
sessionEntry.lastAccountId = resolveTargetChannelAccountId(params, targetChannel);
params.sessionEntry = sessionEntry;
const persisted = await persistSessionEntry(params);
const persisted = await persistSessionEntry({
...params,
touchedFields: ["lastChannel", "lastTo", "lastAccountId"],
});
if (!persisted) {
return {
shouldContinue: false,

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { persistAbortTargetEntry, persistSessionEntry } from "./commands-session-store.js";
@@ -16,6 +16,63 @@ async function withTempStore<T>(run: (storePath: string) => Promise<T>): Promise
}
describe("commands session store persistence", () => {
it("creates a missing row for the first command-only session mutation", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:first-command";
const entry: SessionEntry = {
sessionId: "first-command-session",
updatedAt: 1,
responseUsage: "tokens",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
await expect(
persistSessionEntry({
allowCreateSessionEntry: true,
sessionEntry: entry,
sessionStore,
sessionKey,
storePath,
touchedFields: ["responseUsage"],
}),
).resolves.toBe(true);
const persisted = loadSessionEntry({ storePath, sessionKey });
expect(persisted).toMatchObject({
sessionId: "first-command-session",
responseUsage: "tokens",
});
expect(sessionStore[sessionKey]).toMatchObject({
sessionId: "first-command-session",
responseUsage: "tokens",
});
});
});
it("does not recreate a missing row without explicit create ownership", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:missing-existing";
const entry: SessionEntry = {
sessionId: "missing-existing-session",
updatedAt: 1,
responseUsage: "tokens",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
await expect(
persistSessionEntry({
sessionEntry: entry,
sessionStore,
sessionKey,
storePath,
touchedFields: ["responseUsage"],
}),
).resolves.toBe(false);
expect(loadSessionEntry({ storePath, sessionKey })).toBeUndefined();
});
});
it("persists a single command session entry through the accessor", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:command";
@@ -29,7 +86,14 @@ describe("commands session store persistence", () => {
sessionId: "other-session",
updatedAt: 2,
};
await replaceSessionEntry({ storePath, sessionKey }, { ...entry });
const seedEntry = { ...entry };
await persistSessionEntry({
allowCreateSessionEntry: true,
sessionEntry: seedEntry,
sessionStore: { [sessionKey]: seedEntry },
sessionKey,
storePath,
});
await replaceSessionEntry({ storePath, sessionKey: otherKey }, { ...otherEntry });
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
@@ -44,7 +108,11 @@ describe("commands session store persistence", () => {
const persisted = loadSessionEntry({ storePath, sessionKey });
const persistedOther = loadSessionEntry({ storePath, sessionKey: otherKey });
expect(sessionStore[sessionKey]).toBe(entry);
expect(sessionStore[sessionKey]).toMatchObject({
sessionId: "command-session",
model: "gpt-5.5",
});
expect(sessionStore[sessionKey]?.updatedAt).toBeGreaterThanOrEqual(entry.updatedAt);
expect(entry.updatedAt).not.toBe(1);
expect(persisted).toMatchObject({
sessionId: "command-session",
@@ -55,6 +123,180 @@ describe("commands session store persistence", () => {
});
});
it("persists command state without reverting concurrent session management", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:command";
const otherKey = "agent:main:other";
const entry: SessionEntry = {
sessionId: "command-session",
updatedAt: 1,
model: "gpt-5.5",
label: "Before rename",
pinnedAt: 100,
};
const otherEntry: SessionEntry = {
sessionId: "other-session",
updatedAt: 2,
};
const concurrentUpdatedAt = 300;
const concurrentEntry = {
...entry,
updatedAt: concurrentUpdatedAt,
label: "After rename",
pinnedAt: undefined,
};
await persistSessionEntry({
allowCreateSessionEntry: true,
sessionEntry: concurrentEntry,
sessionStore: { [sessionKey]: concurrentEntry },
sessionKey,
storePath,
});
await replaceSessionEntry({ storePath, sessionKey: otherKey }, { ...otherEntry });
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(200).mockReturnValue(400);
try {
await expect(
persistSessionEntry({
sessionEntry: entry,
sessionStore,
sessionKey,
storePath,
}),
).resolves.toBe(true);
} finally {
nowSpy.mockRestore();
}
const persisted = loadSessionEntry({ storePath, sessionKey });
const persistedOther = loadSessionEntry({ storePath, sessionKey: otherKey });
expect(entry.updatedAt).not.toBe(1);
expect(sessionStore[sessionKey]).toMatchObject({
sessionId: "command-session",
label: "After rename",
model: "gpt-5.5",
});
expect(sessionStore[sessionKey]?.updatedAt).toBeGreaterThanOrEqual(concurrentUpdatedAt);
expect(sessionStore[sessionKey]?.pinnedAt).toBeUndefined();
expect(persisted).toMatchObject({
sessionId: "command-session",
label: "After rename",
model: "gpt-5.5",
});
expect(persisted?.updatedAt).toBeGreaterThanOrEqual(concurrentUpdatedAt);
expect(persisted?.pinnedAt).toBeUndefined();
expect(persistedOther).toStrictEqual(otherEntry);
});
});
it("rejects command persistence after the session rotates", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:command";
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
queueMode: "collect",
};
const sessionEntry: SessionEntry = {
...initialEntry,
queueMode: "followup",
};
const rotatedEntry: SessionEntry = {
sessionId: "session-2",
updatedAt: 3,
queueMode: "interrupt",
};
await replaceSessionEntry({ storePath, sessionKey }, rotatedEntry);
const sessionStore = { [sessionKey]: sessionEntry };
await expect(
persistSessionEntry({
initialSessionEntry: initialEntry,
sessionEntry,
sessionStore,
sessionKey,
storePath,
}),
).resolves.toBe(false);
expect(sessionStore[sessionKey]).toEqual(rotatedEntry);
expect(loadSessionEntry({ storePath, sessionKey })).toEqual(rotatedEntry);
});
});
it("rejects an explicit same-value command after a concurrent change", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:command";
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
sendPolicy: "deny",
};
const sessionEntry = { ...initialEntry };
const concurrentEntry: SessionEntry = {
...initialEntry,
updatedAt: 2,
sendPolicy: "allow",
};
await replaceSessionEntry({ storePath, sessionKey }, concurrentEntry);
const sessionStore = { [sessionKey]: sessionEntry };
await expect(
persistSessionEntry({
initialSessionEntry: initialEntry,
sessionEntry,
sessionStore,
sessionKey,
storePath,
touchedFields: ["sendPolicy"],
}),
).resolves.toBe(false);
expect(sessionStore[sessionKey]).toMatchObject({
sessionId: "session-1",
sendPolicy: "allow",
});
});
});
it("rejects a grouped command before committing any non-conflicting field", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:command";
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
groupActivation: "mention",
groupActivationNeedsSystemIntro: true,
};
const sessionEntry: SessionEntry = {
...initialEntry,
groupActivation: "always",
};
const concurrentEntry: SessionEntry = {
...initialEntry,
updatedAt: 2,
groupActivationNeedsSystemIntro: false,
};
await replaceSessionEntry({ storePath, sessionKey }, concurrentEntry);
const sessionStore = { [sessionKey]: sessionEntry };
await expect(
persistSessionEntry({
initialSessionEntry: initialEntry,
sessionEntry,
sessionStore,
sessionKey,
storePath,
touchedFields: ["groupActivation", "groupActivationNeedsSystemIntro"],
}),
).resolves.toBe(false);
expect(sessionStore[sessionKey]).toEqual(concurrentEntry);
expect(loadSessionEntry({ storePath, sessionKey })).toEqual(concurrentEntry);
});
});
it("falls back to the supplied abort target when the persisted row is missing", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:abort-target";

View File

@@ -1,14 +1,21 @@
// Shared session-store helpers for command handlers that mutate sessions.
import { resolveSessionStoreEntry, type SessionEntry } from "../../config/sessions.js";
import { patchSessionEntry } from "../../config/sessions/session-accessor.js";
import { sessionSnapshotChangesApplied } from "../../config/sessions/session-snapshot-merge.js";
import { applyAbortCutoffToSessionEntry, type AbortCutoff } from "./abort-cutoff.js";
import type { CommandHandler } from "./commands-types.js";
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
import { persistReplySessionEntry } from "./session-entry-persistence.js";
type CommandParams = Parameters<CommandHandler>[0];
type PersistSessionEntryParams = Pick<
CommandParams,
"sessionEntry" | "sessionStore" | "sessionKey" | "storePath"
>;
| "allowCreateSessionEntry"
| "initialSessionEntry"
| "sessionEntry"
| "sessionKey"
| "sessionStore"
| "storePath"
> & { touchedFields?: ReadonlyArray<keyof SessionEntry> };
/** Resolves a command target entry through canonical and legacy session keys. */
export function resolveCommandSessionEntryForKey(
@@ -33,25 +40,47 @@ export async function persistSessionEntry(params: PersistSessionEntryParams): Pr
return false;
}
const sessionEntry = params.sessionEntry;
const creatingSession = params.allowCreateSessionEntry === true;
const initialEntry = params.initialSessionEntry ?? { ...sessionEntry };
sessionEntry.updatedAt = Date.now();
params.sessionStore[params.sessionKey] = sessionEntry;
if (params.storePath) {
// Slash commands mutate one known session entry; skipping global session
// maintenance avoids scanning the whole sessions directory for simple
// command-only writes.
await patchSessionEntry(
{ storePath: params.storePath, sessionKey: params.sessionKey },
() => sessionEntry,
{
fallbackEntry: sessionEntry,
replaceEntry: true,
skipMaintenance: true,
},
);
const persistence = await persistReplySessionEntry({
storePath: params.storePath,
sessionKey: params.sessionKey,
allowCreate: creatingSession,
initialEntry,
entry: sessionEntry,
skipMaintenance: true,
touchedFields: params.touchedFields,
});
if (persistence.status === "lifecycle-invalidated") {
if (persistence.entry) {
params.sessionStore[params.sessionKey] = persistence.entry;
}
return false;
}
params.sessionStore[params.sessionKey] = persistence.entry;
return sessionSnapshotChangesApplied({
initial: initialEntry,
next: sessionEntry,
current: persistence.entry,
touchedFields: params.touchedFields,
});
}
return true;
}
export function sessionEntryPersistenceConflictReply(): CommandHandlerResult {
return {
shouldContinue: false,
reply: { text: "⚠️ Session changed before this setting could be saved. Retry the command." },
};
}
export async function persistAbortTargetEntry(params: {
entry?: SessionEntry;
key?: string;

View File

@@ -90,6 +90,9 @@ describe("handleActivationCommand", () => {
});
expect(params.sessionEntry?.groupActivation).toBe("always");
expect(params.sessionEntry?.groupActivationNeedsSystemIntro).toBe(true);
expect(persistSessionEntryMock).toHaveBeenCalledWith(params);
expect(persistSessionEntryMock).toHaveBeenCalledWith({
...params,
touchedFields: ["groupActivation", "groupActivationNeedsSystemIntro"],
});
});
});

View File

@@ -48,7 +48,10 @@ import {
import { resolveCommandSurfaceChannel } from "./channel-context.js";
import { rejectNonOwnerCommand, rejectUnauthorizedCommand } from "./command-gates.js";
import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js";
import { persistSessionEntry } from "./commands-session-store.js";
import {
persistSessionEntry,
sessionEntryPersistenceConflictReply,
} from "./commands-session-store.js";
import type { CommandHandler, HandleCommandsParams } from "./commands-types.js";
import { resolveConversationBindingContextFromAcpCommand } from "./conversation-binding-input.js";
@@ -230,7 +233,14 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
params.sessionEntry.groupActivation = activationCommand.mode;
params.sessionEntry.groupActivationNeedsSystemIntro = true;
await persistSessionEntry(params);
if (
!(await persistSessionEntry({
...params,
touchedFields: ["groupActivation", "groupActivationNeedsSystemIntro"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
}
return {
shouldContinue: false,
@@ -268,7 +278,9 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
} else {
params.sessionEntry.sendPolicy = sendPolicyCommand.mode;
}
await persistSessionEntry(params);
if (!(await persistSessionEntry({ ...params, touchedFields: ["sendPolicy"] }))) {
return sessionEntryPersistenceConflictReply();
}
}
const label =
sendPolicyCommand.mode === "inherit"
@@ -357,7 +369,15 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
delete targetSessionEntry.responseUsage;
params.sessionStore[params.sessionKey] = targetSessionEntry;
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
if (
!(await persistSessionEntry({
...params,
sessionEntry: targetSessionEntry,
touchedFields: ["responseUsage"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
}
return {
shouldContinue: false,
@@ -377,7 +397,15 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
targetSessionEntry.responseUsage = next;
params.sessionStore[params.sessionKey] = targetSessionEntry;
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
if (
!(await persistSessionEntry({
...params,
sessionEntry: targetSessionEntry,
touchedFields: ["responseUsage"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
}
return {
@@ -437,7 +465,15 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand
if (resetsToDefault) {
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
delete targetSessionEntry.fastMode;
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
if (
!(await persistSessionEntry({
...params,
sessionEntry: targetSessionEntry,
touchedFields: ["fastMode"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
}
return {
shouldContinue: false,
@@ -452,7 +488,15 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
targetSessionEntry.fastMode = nextMode;
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
if (
!(await persistSessionEntry({
...params,
sessionEntry: targetSessionEntry,
touchedFields: ["fastMode"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
}
return {

View File

@@ -33,7 +33,10 @@ import {
} from "../../tts/tts.js";
import { isSilentReplyPayloadText } from "../tokens.js";
import type { ReplyPayload } from "../types.js";
import { persistSessionEntry } from "./commands-session-store.js";
import {
persistSessionEntry,
sessionEntryPersistenceConflictReply,
} from "./commands-session-store.js";
import type { CommandHandler } from "./commands-types.js";
type ParsedTtsCommand = {
@@ -228,17 +231,23 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
if (requested === "on") {
params.sessionEntry.ttsAuto = "always";
await persistSessionEntry(params);
if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: { text: "🔊 TTS enabled for this chat." } };
}
if (requested === "off") {
params.sessionEntry.ttsAuto = "off";
await persistSessionEntry(params);
if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: { text: "🔇 TTS disabled for this chat." } };
}
if (requested === "default" || requested === "inherit" || requested === "clear") {
delete params.sessionEntry.ttsAuto;
await persistSessionEntry(params);
if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: { text: "🔊 TTS chat override cleared." } };
}
return { shouldContinue: false, reply: ttsUsage() };
@@ -289,7 +298,14 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
params.sessionEntry.lastTtsReadLatestHash = hash;
params.sessionEntry.lastTtsReadLatestAt = Date.now();
await persistSessionEntry(params);
if (
!(await persistSessionEntry({
...params,
touchedFields: ["lastTtsReadLatestHash", "lastTtsReadLatestAt"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: audio.reply };
}

View File

@@ -1,9 +1,9 @@
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
/** Shared command handler context and result contracts. */
import type { BlockReplyChunking } from "../../agents/embedded-agent-block-chunker.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import type { SkillCommandSpec } from "../../skills/types.js";
import type { MsgContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
@@ -49,6 +49,10 @@ export type HandleCommandsParams = {
failures: Array<{ gate: string; key: string }>;
};
sessionEntry?: SessionEntry;
/** Snapshot captured before command handlers mutate the active entry. */
initialSessionEntry?: SessionEntry;
/** True only when the current command owns first creation of this session row. */
allowCreateSessionEntry?: boolean;
previousSessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;

View File

@@ -7,9 +7,10 @@ import {
import { normalizeChatType } from "../../channels/chat-type.js";
import { normalizeAnyChannelId } from "../../channels/registry.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import { resolveSessionTranscriptPath, resolveStorePath } from "../../config/sessions/paths.js";
import { resolveStorePath } from "../../config/sessions/paths.js";
import { loadSessionEntry, listSessionEntries } from "../../config/sessions/session-accessor.js";
import { resolveSessionKey } from "../../config/sessions/session-key.js";
import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
import type { SessionEntry, SessionScope } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js";
@@ -246,7 +247,7 @@ export function initFastReplySessionState(params: {
const sessionFile =
!resetTriggered && existingEntry?.sessionFile
? existingEntry.sessionFile
: resolveSessionTranscriptPath(sessionId, agentId);
: formatSqliteSessionFileMarker({ agentId, sessionId, storePath });
const sessionEntry: SessionEntry = {
...(!resetTriggered ? existingEntry : undefined),
sessionId,
@@ -299,6 +300,7 @@ export function initFastReplySessionState(params: {
return {
sessionCtx,
sessionEntry,
initialSessionEntry: existingEntry ? { ...existingEntry } : undefined,
sessionEntryHandle,
sessionStore,
sessionKey,

View File

@@ -178,6 +178,8 @@ export async function handleInlineActions(params: {
agentId: string;
agentDir?: string;
sessionEntry?: SessionEntry;
initialSessionEntry?: SessionEntry;
allowCreateSessionEntry?: boolean;
previousSessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
@@ -220,6 +222,8 @@ export async function handleInlineActions(params: {
agentId,
agentDir,
sessionEntry,
initialSessionEntry,
allowCreateSessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,
@@ -516,6 +520,8 @@ export async function handleInlineActions(params: {
failures: elevatedFailures,
},
sessionEntry: targetSessionEntry,
initialSessionEntry,
allowCreateSessionEntry,
previousSessionEntry,
sessionStore,
sessionKey,

View File

@@ -28,6 +28,7 @@ import { resolveReplyDirectives } from "./get-reply-directives.js";
import { initFastReplySessionState } from "./get-reply-fast-path.js";
import { handleInlineActions } from "./get-reply-inline-actions.js";
import { stripStructuralPrefixes } from "./mentions.js";
import { persistReplySessionEntry } from "./session-entry-persistence.js";
import type { createTypingController } from "./typing.js";
type AgentDefaults = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]> | undefined;
@@ -137,6 +138,34 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
commandAuthorized: params.commandAuthorized,
workspaceDir: params.workspaceDir,
});
if (params.commandAuthorized) {
const creatingSession = sessionState.initialSessionEntry === undefined;
const initializationEntry = sessionState.initialSessionEntry ?? sessionState.sessionEntry;
const persistence = await persistReplySessionEntry({
storePath: sessionState.storePath,
sessionKey: sessionState.sessionKey,
allowCreate: creatingSession,
initialEntry: initializationEntry,
entry: sessionState.sessionEntry,
skipMaintenance: !creatingSession,
});
if (persistence.status === "lifecycle-invalidated") {
params.typing.cleanup();
return {
handled: true,
reply: markCommandReplyForDelivery({
text: persistence.error,
}),
};
}
const persistedInitialEntry = persistence.entry;
// Commit the synthesized activity/channel touch before commands or directives
// capture their own mutation baseline.
sessionState.sessionEntry = persistedInitialEntry;
sessionState.sessionEntryHandle.replaceCurrent(persistedInitialEntry);
sessionState.sessionStore[sessionState.sessionKey] = persistedInitialEntry;
sessionState.sessionId = persistedInitialEntry.sessionId;
}
const command = buildCommandContext({
ctx: params.ctx,
cfg: params.cfg,
@@ -291,6 +320,10 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
agentId: params.agentId,
agentDir: params.agentDir,
sessionEntry: sessionState.sessionEntry,
...(sessionState.initialSessionEntry
? { initialSessionEntry: sessionState.initialSessionEntry }
: {}),
allowCreateSessionEntry: sessionState.initialSessionEntry === undefined,
previousSessionEntry: sessionState.previousSessionEntry,
sessionStore: sessionState.sessionStore,
sessionKey: sessionState.sessionKey,

View File

@@ -7,6 +7,7 @@ import { testing as cliBackendsTesting } from "../../agents/cli-backends.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getSessionEntry, type SessionEntry } from "../../config/sessions.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
import { getReplyPayloadMetadata } from "../reply-payload.js";
import {
buildFastReplyCommandContext,
@@ -646,13 +647,14 @@ describe("getReplyFromConfig fast test bootstrap", () => {
});
it("uses native command target session keys during fast bootstrap", () => {
const storePath = "/tmp/sessions.json";
const result = initFastReplySessionState({
ctx: buildGetReplyCtx({
SessionKey: "telegram:slash:123",
CommandSource: "native",
CommandTargetSessionKey: "agent:main:main",
}),
cfg: { session: { store: "/tmp/sessions.json" } } as OpenClawConfig,
cfg: { session: { store: storePath } } as OpenClawConfig,
agentId: "main",
commandAuthorized: true,
workspaceDir: "/tmp/workspace",
@@ -660,6 +662,13 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(result.sessionKey).toBe("agent:main:main");
expect(result.sessionCtx.SessionKey).toBe("agent:main:main");
expect(result.sessionEntry.sessionFile).toBe(
formatSqliteSessionFileMarker({
agentId: "main",
sessionId: result.sessionId,
storePath,
}),
);
});
it("preserves usage footer mode during fast reset bootstrap", async () => {
@@ -691,6 +700,36 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(result.sessionEntry.responseUsage).toBe("full");
});
it("captures the initial SQLite session entry during fast bootstrap", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-initial-entry-"));
const storePath = path.join(home, "sessions.json");
const sessionKey = "agent:main:telegram:123";
await seedFastPathSessionStore(storePath, {
[sessionKey]: {
sessionId: "existing-fast-initial",
updatedAt: Date.now(),
responseUsage: "tokens",
},
});
const result = initFastReplySessionState({
ctx: buildGetReplyCtx({
Body: "hello",
RawBody: "hello",
CommandBody: "hello",
SessionKey: sessionKey,
}),
cfg: { session: { store: storePath } } as OpenClawConfig,
agentId: "main",
commandAuthorized: true,
workspaceDir: home,
});
expect(result.initialSessionEntry?.sessionId).toBe("existing-fast-initial");
expect(result.initialSessionEntry?.responseUsage).toBe("tokens");
expect(result.initialSessionEntry).not.toBe(result.sessionEntry);
});
it("maps explicit gateway origin into command context", () => {
const command = buildFastReplyCommandContext({
ctx: buildGetReplyCtx({

View File

@@ -495,6 +495,7 @@ export async function getReplyFromConfig(
const {
sessionCtx,
sessionEntry,
initialSessionEntry,
sessionEntryHandle,
previousSessionEntry,
sessionStore,
@@ -859,6 +860,8 @@ export async function getReplyFromConfig(
agentId,
agentDir,
sessionEntry,
...(initialSessionEntry ? { initialSessionEntry } : {}),
allowCreateSessionEntry: useFastTestBootstrap && initialSessionEntry === undefined,
previousSessionEntry,
sessionStore,
sessionKey,

View File

@@ -0,0 +1,88 @@
// Atomic persistence for broad auto-reply session snapshots.
import type { SessionEntry } from "../../config/sessions.js";
import { resolveSessionWorkStartError } from "../../config/sessions/lifecycle.js";
import { patchSessionEntry } from "../../config/sessions/session-accessor.js";
import {
projectSessionSnapshotChanges,
sessionSnapshotTouchedFieldsConflict,
} from "../../config/sessions/session-snapshot-merge.js";
type PersistReplySessionEntryParams = {
allowCreate?: boolean;
entry: SessionEntry;
initialEntry: SessionEntry;
reassertLiveModelSwitchPending?: boolean;
sessionKey: string;
skipMaintenance?: boolean;
storePath: string;
touchedFields?: ReadonlyArray<keyof SessionEntry>;
};
export type PersistReplySessionEntryResult =
| { status: "current"; entry: SessionEntry }
| { status: "lifecycle-invalidated"; error: string; entry?: SessionEntry };
/** Persists reply-owned state without reverting concurrent session management. */
export async function persistReplySessionEntry(
params: PersistReplySessionEntryParams,
): Promise<PersistReplySessionEntryResult> {
let lifecycleError: string | undefined;
let lifecycleEntry: SessionEntry | undefined;
const persisted = await patchSessionEntry(
{ sessionKey: params.sessionKey, storePath: params.storePath },
(_entry, context) => {
if (!context.existingEntry) {
if (params.allowCreate !== true) {
lifecycleError = resolveSessionWorkStartError(params.sessionKey, undefined, {
expectedSessionId: params.initialEntry.sessionId,
});
return null;
}
return params.entry;
}
lifecycleError = resolveSessionWorkStartError(params.sessionKey, context.existingEntry, {
expectedSessionId: params.initialEntry.sessionId,
});
if (lifecycleError) {
lifecycleEntry = context.existingEntry;
return null;
}
if (
sessionSnapshotTouchedFieldsConflict({
initial: params.initialEntry,
next: params.entry,
current: context.existingEntry,
touchedFields: params.touchedFields,
})
) {
return null;
}
// Reply flows persist broad snapshots. Project only reply-owned changes
// so concurrent lifecycle, policy, and privacy updates remain authoritative.
return projectSessionSnapshotChanges({
initial: params.initialEntry,
next: params.entry,
current: context.existingEntry,
reassertLiveModelSwitchPending: params.reassertLiveModelSwitchPending,
});
},
{
fallbackEntry: params.entry,
skipMaintenance: params.skipMaintenance,
},
);
if (lifecycleError) {
return {
status: "lifecycle-invalidated",
error: lifecycleError,
...(lifecycleEntry ? { entry: lifecycleEntry } : {}),
};
}
if (!persisted) {
return {
status: "lifecycle-invalidated",
error: `Session "${params.sessionKey}" changed while starting work. Retry.`,
};
}
return { status: "current", entry: persisted };
}

View File

@@ -199,4 +199,46 @@ describe("ensureSkillSnapshot", () => {
expect(result.systemSent).toBe(false);
expect(sessionStore[sessionKey]).toEqual(reboundEntry);
});
it("persists first-turn skill snapshots as a guarded partial update", async () => {
vi.stubEnv("OPENCLAW_TEST_FAST", "0");
const sessionKey = "agent:main:main";
const sessionEntry = {
sessionId: "session-1",
updatedAt: 10,
modelOverride: "gpt-5.5",
};
const sessionStore = { [sessionKey]: sessionEntry };
updateSessionEntryMock.mockImplementationOnce(async (_scope, update) => {
const patch = await update({
...sessionEntry,
updatedAt: 20,
modelOverride: "sonnet-4.6",
});
expect(patch).toMatchObject({
sessionId: "session-1",
systemSent: true,
});
expect(patch).not.toHaveProperty("modelOverride");
return {
...sessionEntry,
...patch,
modelOverride: "sonnet-4.6",
};
});
const result = await ensureSkillSnapshot({
sessionEntry,
sessionStore,
sessionKey,
sessionId: "session-1",
storePath: "/tmp/sessions.json",
isFirstTurnInSession: true,
workspaceDir: TEST_WORKSPACE_DIR,
cfg: {},
});
expect(result.sessionEntry?.modelOverride).toBe("sonnet-4.6");
expect(sessionStore[sessionKey]?.modelOverride).toBe("sonnet-4.6");
});
});

View File

@@ -28,6 +28,7 @@ async function persistSessionEntryUpdate(params: {
sessionKey?: string;
storePath?: string;
nextEntry: SessionEntry;
updates: Partial<SessionEntry>;
}): Promise<SessionEntry | undefined> {
if (!params.sessionEntryHandle && (!params.sessionStore || !params.sessionKey)) {
return undefined;
@@ -48,7 +49,7 @@ async function persistSessionEntryUpdate(params: {
storePath: params.storePath,
sessionKey: params.sessionKey,
},
(entry) => (entry.sessionId === params.expectedSessionId ? params.nextEntry : null),
(entry) => (entry.sessionId === params.expectedSessionId ? params.updates : null),
);
if (persistedEntry) {
if (params.sessionEntryHandle) {
@@ -220,6 +221,12 @@ export async function ensureSkillSnapshot(params: {
sessionKey,
storePath,
nextEntry,
updates: {
sessionId: nextEntry.sessionId,
updatedAt: nextEntry.updatedAt,
systemSent: nextEntry.systemSent,
skillsSnapshot: nextEntry.skillsSnapshot,
},
});
nextEntry = persistedEntry;
systemSent = persistedEntry?.systemSent ?? systemSent;
@@ -258,6 +265,11 @@ export async function ensureSkillSnapshot(params: {
sessionKey,
storePath,
nextEntry,
updates: {
sessionId: nextEntry.sessionId,
updatedAt: nextEntry.updatedAt,
skillsSnapshot: nextEntry.skillsSnapshot,
},
});
}

View File

@@ -166,6 +166,7 @@ function hasProviderOwnedSession(entry: SessionEntry | undefined): boolean {
export type SessionInitResult = {
sessionCtx: TemplateContext;
sessionEntry: SessionEntry;
initialSessionEntry?: SessionEntry;
previousSessionEntry?: SessionEntry;
sessionEntryHandle: ReplySessionEntryHandle;
sessionStore: Record<string, SessionEntry>;

View File

@@ -15,6 +15,52 @@ type SessionLifecycleEntry = Pick<
"sessionId" | "sessionFile" | "sessionStartedAt" | "lastInteractionAt" | "updatedAt"
>;
type SessionWorkStartEntry = Pick<SessionEntry, "sessionId">;
type SessionWorkStartOptions = {
expectedSessionId?: string;
};
/** Stable Gateway error detail for stale session lifecycle requests. */
export const SESSION_LIFECYCLE_CHANGED_ERROR_REASON = "session-changed";
export const SESSION_WORK_START_INVALIDATED_ERROR_CODE = "SESSION_WORK_START_INVALIDATED";
export class SessionWorkStartInvalidatedError extends Error {
readonly code = SESSION_WORK_START_INVALIDATED_ERROR_CODE;
constructor(message: string) {
super(message);
this.name = "SessionWorkStartInvalidatedError";
}
}
export function isSessionWorkStartInvalidatedError(
error: unknown,
): error is SessionWorkStartInvalidatedError {
return (
error instanceof SessionWorkStartInvalidatedError ||
(typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === SESSION_WORK_START_INVALIDATED_ERROR_CODE)
);
}
/** Stale session starts are rejected after another lifecycle owner moves the row. */
export function resolveSessionWorkStartError(
sessionKey: string,
entry: SessionWorkStartEntry | null | undefined,
options?: SessionWorkStartOptions,
): string | undefined {
if (options?.expectedSessionId && !entry) {
return `Session "${sessionKey}" was deleted while starting work. Retry.`;
}
if (options?.expectedSessionId && entry?.sessionId !== options.expectedSessionId) {
return `Session "${sessionKey}" changed while starting work. Retry.`;
}
return undefined;
}
// Transcript headers are read lazily to recover startedAt without parsing full files.
type TerminalMainSessionTranscriptRegistryParams = {

View File

@@ -0,0 +1,290 @@
import { isDeepStrictEqual } from "node:util";
import type { SessionEntry } from "./types.js";
type SessionEntryRecord = Partial<Record<keyof SessionEntry, unknown>>;
export const SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS = [
"providerOverride",
"modelOverride",
"agentRuntimeOverride",
"modelOverrideSource",
"modelOverrideFallbackOriginProvider",
"modelOverrideFallbackOriginModel",
"authProfileOverride",
"authProfileOverrideSource",
"authProfileOverrideCompactionCount",
] as const satisfies ReadonlyArray<keyof SessionEntry>;
const MODEL_ROUTE_OVERRIDE_FIELDS = [
"providerOverride",
"modelOverride",
"agentRuntimeOverride",
] as const satisfies ReadonlyArray<keyof SessionEntry>;
const MODEL_OVERRIDE_RUNTIME_FIELDS = [
"modelProvider",
"model",
"fallbackNoticeSelectedModel",
"fallbackNoticeActiveModel",
"fallbackNoticeReason",
"contextTokens",
"contextBudgetStatus",
] as const satisfies ReadonlyArray<keyof SessionEntry>;
const MODEL_OVERRIDE_RUNTIME_FIELD_SET = new Set<keyof SessionEntry>(MODEL_OVERRIDE_RUNTIME_FIELDS);
const MODEL_OVERRIDE_DEPENDENT_FIELDS = new Set<keyof SessionEntry>([
...MODEL_OVERRIDE_RUNTIME_FIELDS,
"liveModelSwitchPending",
"thinkingLevel",
]);
const MODEL_OVERRIDE_CONFLICT_DEPENDENT_FIELDS = ["thinkingLevel"] as const satisfies ReadonlyArray<
keyof SessionEntry
>;
function anySessionFieldChanged(
before: SessionEntryRecord,
after: SessionEntryRecord,
fields: ReadonlyArray<keyof SessionEntry>,
): boolean {
return fields.some((field) => !isDeepStrictEqual(before[field], after[field]));
}
/** Projects run-local snapshot changes without restoring concurrently changed fields. */
export function projectSessionSnapshotChanges(params: {
initial: SessionEntry;
next: SessionEntry;
current: SessionEntry;
reassertLiveModelSwitchPending?: boolean;
}): Partial<SessionEntry> {
if (params.current.sessionId !== params.initial.sessionId) {
return {};
}
const initial = params.initial as SessionEntryRecord;
const next = params.next as SessionEntryRecord;
const current = params.current as SessionEntryRecord;
const patch: Partial<SessionEntry> = {};
const patchRecord = patch as SessionEntryRecord;
const fields = new Set<keyof SessionEntry>([
...(Object.keys(params.initial) as Array<keyof SessionEntry>),
...(Object.keys(params.next) as Array<keyof SessionEntry>),
]);
const modelOverrideChanged = anySessionFieldChanged(
initial,
next,
SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS,
);
const modelRouteOverrideChanged = anySessionFieldChanged(
initial,
next,
MODEL_ROUTE_OVERRIDE_FIELDS,
);
const modelOverrideChangedConcurrently = anySessionFieldChanged(
initial,
current,
SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS,
);
if (modelOverrideChanged && !modelOverrideChangedConcurrently) {
// Model, provenance, and auth overrides form one selection transaction.
// Project the whole family or none of it so concurrent switches cannot hybridize rows.
for (const field of SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS) {
patchRecord[field] = Object.hasOwn(params.next, field) ? next[field] : undefined;
}
if (modelRouteOverrideChanged) {
// A winning route switch invalidates any runtime facts written for the
// old model, including facts that appeared after this snapshot began.
for (const field of MODEL_OVERRIDE_RUNTIME_FIELDS) {
patchRecord[field] = Object.hasOwn(params.next, field) ? next[field] : undefined;
}
}
for (const field of MODEL_OVERRIDE_DEPENDENT_FIELDS) {
if (modelRouteOverrideChanged && MODEL_OVERRIDE_RUNTIME_FIELD_SET.has(field)) {
continue;
}
if (!isDeepStrictEqual(initial[field], next[field])) {
patchRecord[field] = Object.hasOwn(params.next, field) ? next[field] : undefined;
}
}
if (params.reassertLiveModelSwitchPending) {
// A second explicit switch can inherit true from the first snapshot while
// the active runner concurrently clears it. Re-arm the winning transaction.
patch.liveModelSwitchPending = true;
}
}
const runtimeModelChanged =
!isDeepStrictEqual(initial.model, next.model) ||
!isDeepStrictEqual(initial.modelProvider, next.modelProvider);
const runtimeModelChangedConcurrently =
!isDeepStrictEqual(current.model, initial.model) ||
!isDeepStrictEqual(current.modelProvider, initial.modelProvider);
if (
runtimeModelChanged &&
!runtimeModelChangedConcurrently &&
!modelOverrideChanged &&
!modelOverrideChangedConcurrently
) {
// Runtime model metadata is one pair. A model-only patch intentionally
// clears provider state, so snapshot projection must emit both fields.
patch.model = params.next.model;
patch.modelProvider = params.next.modelProvider;
}
for (const field of fields) {
if (
field === "model" ||
field === "modelProvider" ||
SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS.includes(
field as (typeof SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS)[number],
)
) {
continue;
}
if (isDeepStrictEqual(initial[field], next[field])) {
continue;
}
if (field === "updatedAt") {
patch.updatedAt = Math.max(params.current.updatedAt, params.next.updatedAt);
continue;
}
if (
(modelOverrideChanged || modelOverrideChangedConcurrently) &&
MODEL_OVERRIDE_DEPENDENT_FIELDS.has(field)
) {
continue;
}
// The latest row wins conflicts. This prevents a stale run snapshot from
// reviving policy or privacy fields cleared while the run was active.
if (!isDeepStrictEqual(current[field], initial[field])) {
continue;
}
patchRecord[field] = Object.hasOwn(params.next, field) ? next[field] : undefined;
}
return patch;
}
/** Reports whether every caller-owned snapshot delta is present in the current row. */
export function sessionSnapshotChangesApplied(params: {
initial: SessionEntry;
next: SessionEntry;
current: SessionEntry;
touchedFields?: ReadonlyArray<keyof SessionEntry>;
}): boolean {
if (params.current.sessionId !== params.initial.sessionId) {
return false;
}
const initial = params.initial as SessionEntryRecord;
const next = params.next as SessionEntryRecord;
const current = params.current as SessionEntryRecord;
const fields = new Set<keyof SessionEntry>([
...(Object.keys(params.initial) as Array<keyof SessionEntry>),
...(Object.keys(params.next) as Array<keyof SessionEntry>),
...(params.touchedFields ?? []),
]);
fields.delete("updatedAt");
for (const field of fields) {
const explicitlyTouched = params.touchedFields?.includes(field) === true;
if (
(explicitlyTouched || !isDeepStrictEqual(initial[field], next[field])) &&
!isDeepStrictEqual(current[field], next[field])
) {
return false;
}
}
return true;
}
/** Reports an explicit grouped-write conflict before any snapshot fields are committed. */
export function sessionSnapshotTouchedFieldsConflict(params: {
initial: SessionEntry;
next: SessionEntry;
current: SessionEntry;
touchedFields?: ReadonlyArray<keyof SessionEntry>;
}): boolean {
if (params.current.sessionId !== params.initial.sessionId) {
return true;
}
const initial = params.initial as SessionEntryRecord;
const next = params.next as SessionEntryRecord;
const current = params.current as SessionEntryRecord;
const fields = new Set(params.touchedFields ?? []);
if (SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS.some((field) => fields.has(field))) {
for (const field of MODEL_OVERRIDE_CONFLICT_DEPENDENT_FIELDS) {
if (!isDeepStrictEqual(initial[field], next[field])) {
fields.add(field);
}
}
}
return [...fields].some(
(field) =>
!isDeepStrictEqual(current[field], initial[field]) &&
!isDeepStrictEqual(current[field], next[field]),
);
}
/** Replaces a caller-held snapshot with the latest persisted row in place. */
export function adoptPersistedSessionSnapshot(target: SessionEntry, current: SessionEntry): void {
const targetRecord = target as SessionEntryRecord;
const currentRecord = current as SessionEntryRecord;
for (const field of Object.keys(target) as Array<keyof SessionEntry>) {
if (!Object.hasOwn(current, field)) {
delete targetRecord[field];
}
}
for (const field of Object.keys(current) as Array<keyof SessionEntry>) {
targetRecord[field] = currentRecord[field];
}
}
/** Reports whether a model/auth selection transaction is the persisted winner. */
export function sessionModelOverrideChangesApplied(params: {
initial: SessionEntry;
next: SessionEntry;
current: SessionEntry;
reassertLiveModelSwitchPending?: boolean;
}): boolean {
if (params.current.sessionId !== params.initial.sessionId) {
return false;
}
const next = params.next as SessionEntryRecord;
const current = params.current as SessionEntryRecord;
const initial = params.initial as SessionEntryRecord;
const changedDependentFields = [...MODEL_OVERRIDE_DEPENDENT_FIELDS].filter(
(field) => !isDeepStrictEqual(initial[field], next[field]),
);
const modelRouteOverrideChanged = anySessionFieldChanged(
initial,
next,
MODEL_ROUTE_OVERRIDE_FIELDS,
);
const fields = [
...SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS,
...(modelRouteOverrideChanged ? MODEL_OVERRIDE_RUNTIME_FIELDS : []),
...changedDependentFields,
];
if (fields.some((field) => !isDeepStrictEqual(current[field], next[field]))) {
return false;
}
return !params.reassertLiveModelSwitchPending || params.current.liveModelSwitchPending === true;
}
/** Merges run-local snapshot changes into the latest persisted session row. */
export function mergeSessionSnapshotChanges(params: {
initial: SessionEntry;
next: SessionEntry;
current: SessionEntry;
reassertLiveModelSwitchPending?: boolean;
}): SessionEntry {
const merged = { ...params.current };
const mergedRecord = merged as SessionEntryRecord;
const patch = projectSessionSnapshotChanges(params) as SessionEntryRecord;
for (const field of Object.keys(patch) as Array<keyof SessionEntry>) {
if (patch[field] === undefined) {
delete mergedRecord[field];
} else {
mergedRecord[field] = patch[field];
}
}
return merged;
}