From 96cee6cb6442e77c8ac1642e4c1cd7e4cd4c76c4 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Tue, 23 Jun 2026 16:52:22 -0700 Subject: [PATCH] refactor: route live model reads through session accessor (#96206) --- scripts/check-session-accessor-boundary.mjs | 3 +- src/agents/live-model-switch.test.ts | 23 +++++----- src/agents/live-model-switch.ts | 20 +++++---- src/config/sessions/session-accessor.test.ts | 44 ++++++++++++++++++- src/config/sessions/session-accessor.ts | 7 ++- .../check-session-accessor-boundary.test.ts | 3 +- 6 files changed, 73 insertions(+), 27 deletions(-) diff --git a/scripts/check-session-accessor-boundary.mjs b/scripts/check-session-accessor-boundary.mjs index fd9f79fcb71..0bc11c4d24a 100644 --- a/scripts/check-session-accessor-boundary.mjs +++ b/scripts/check-session-accessor-boundary.mjs @@ -82,9 +82,10 @@ export const migratedSessionAccessorFiles = new Set([ "src/agents/embedded-agent-runner/tool-result-truncation.ts", "src/agents/embedded-agent-runner/transcript-rewrite.ts", "src/agents/embedded-agent-runner/transcript-runtime-state.ts", - "src/auto-reply/reply/abort.ts", + "src/agents/live-model-switch.ts", "src/agents/subagent-control.ts", "src/agents/subagent-registry-helpers.ts", + "src/auto-reply/reply/abort.ts", "src/auto-reply/reply/agent-runner-helpers.ts", "src/auto-reply/reply/agent-runner.ts", "src/auto-reply/reply/commands-subagents/action-info.ts", diff --git a/src/agents/live-model-switch.test.ts b/src/agents/live-model-switch.test.ts index 982b3b247cd..a94fb529041 100644 --- a/src/agents/live-model-switch.test.ts +++ b/src/agents/live-model-switch.test.ts @@ -27,11 +27,6 @@ vi.mock("./model-selection.js", async () => { }; }); -vi.mock("../config/sessions/store.js", () => ({ - loadSessionStore: (...args: unknown[]) => state.loadSessionStoreMock(...args), - updateSessionStore: (...args: unknown[]) => state.updateSessionStoreMock(...args), -})); - vi.mock("../config/sessions/session-accessor.js", () => ({ loadSessionEntry: (scope: { sessionKey: string }) => { const store = state.loadSessionStoreMock(scope) as Record | undefined; @@ -44,12 +39,6 @@ vi.mock("../config/sessions/paths.js", () => ({ resolveStorePath: (...args: unknown[]) => state.resolveStorePathMock(...args), })); -vi.mock("../config/sessions.js", () => ({ - loadSessionStore: (...args: unknown[]) => state.loadSessionStoreMock(...args), - resolveStorePath: (...args: unknown[]) => state.resolveStorePathMock(...args), - updateSessionStore: (...args: unknown[]) => state.updateSessionStoreMock(...args), -})); - let mod: typeof import("./live-model-switch.js"); async function loadModule() { @@ -193,6 +182,12 @@ describe("live model switch", () => { expect(state.resolveStorePathMock).toHaveBeenCalledWith("/tmp/custom-store.json", { agentId: "reply", }); + expect(state.loadSessionStoreMock).toHaveBeenCalledWith({ + storePath: "/tmp/session-store.json", + sessionKey: "main", + hydrateSkillPromptRefs: false, + readConsistency: "latest", + }); }); it("prefers persisted session overrides ahead of stale runtime model fields", async () => { @@ -468,10 +463,12 @@ describe("live model switch", () => { const result = shouldSwitchToLiveModel(makeShouldSwitchParams()); expect(result).toBeUndefined(); - expect(state.loadSessionStoreMock).toHaveBeenCalledWith("/tmp/session-store.json", { + expect(state.loadSessionStoreMock).toHaveBeenCalledWith({ hydrateSkillPromptRefs: false, - skipCache: true, clone: false, + readConsistency: "latest", + sessionKey: "main", + storePath: "/tmp/session-store.json", }); }); diff --git a/src/agents/live-model-switch.ts b/src/agents/live-model-switch.ts index 43293cb1d94..5b7f6852074 100644 --- a/src/agents/live-model-switch.ts +++ b/src/agents/live-model-switch.ts @@ -3,10 +3,8 @@ */ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; - import { resolveStorePath } from "../config/sessions/paths.js"; -import { patchSessionEntry } from "../config/sessions/session-accessor.js"; -import { loadSessionStore } from "../config/sessions/store.js"; +import { loadSessionEntry, patchSessionEntry } from "../config/sessions/session-accessor.js"; import { normalizeStoredOverrideModel, resolveDefaultModelForAgent, @@ -45,10 +43,12 @@ export function resolveLiveSessionModelSelection(params: { const storePath = resolveStorePath(cfg.session?.store, { agentId, }); - const entry = loadSessionStore(storePath, { + const entry = loadSessionEntry({ + storePath, + sessionKey, hydrateSkillPromptRefs: false, - skipCache: true, - })[sessionKey]; + readConsistency: "latest", + }); const normalizedSelection = normalizeStoredOverrideModel({ providerOverride: entry?.providerOverride, modelOverride: entry?.modelOverride, @@ -151,11 +151,13 @@ export function shouldSwitchToLiveModel(params: { const storePath = resolveStorePath(cfg.session?.store, { agentId: params.agentId?.trim(), }); - const entry = loadSessionStore(storePath, { + const entry = loadSessionEntry({ + storePath, + sessionKey, hydrateSkillPromptRefs: false, - skipCache: true, clone: false, - })[sessionKey]; + readConsistency: "latest", + }); if (!entry?.liveModelSwitchPending) { return undefined; } diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index 74c66019ba2..794c3646ec6 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { SessionManager } from "../../agents/sessions/session-manager.js"; import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import type { OpenClawConfig } from "../types.openclaw.js"; @@ -36,6 +36,7 @@ import { updateSessionEntry, upsertSessionEntry, } from "./session-accessor.js"; +import * as sessionStore from "./store.js"; import { loadSessionStore, saveSessionStore, updateSessionStoreEntry } from "./store.js"; import { withOwnedSessionTranscriptWrites } from "./transcript-write-context.js"; import type { SessionEntry } from "./types.js"; @@ -464,6 +465,47 @@ describe("session accessor file-backed seam", () => { ); }); + it("maps latest entry reads to the file backend cache bypass", () => { + fs.writeFileSync( + storePath, + JSON.stringify({ + "agent:main:main": { + sessionId: "session-1", + model: "gpt-5.4", + }, + }), + "utf8", + ); + const loadSessionStoreSpy = vi.spyOn(sessionStore, "loadSessionStore"); + + try { + expect( + loadSessionEntry({ + readConsistency: "latest", + sessionKey: "agent:main:main", + storePath, + })?.model, + ).toBe("gpt-5.4"); + expect(loadSessionStoreSpy).toHaveBeenLastCalledWith( + storePath, + expect.objectContaining({ skipCache: true }), + ); + + loadSessionEntry({ + clone: false, + readConsistency: "latest", + sessionKey: "agent:main:main", + storePath, + }); + expect(loadSessionStoreSpy).toHaveBeenLastCalledWith( + storePath, + expect.objectContaining({ clone: false, skipCache: true }), + ); + } finally { + loadSessionStoreSpy.mockRestore(); + } + }); + it("resolves canonical entry reads without requiring exact key casing", async () => { fs.writeFileSync( storePath, diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index a236ed8742e..444208e743c 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -125,6 +125,8 @@ export type SessionAccessScope = { env?: NodeJS.ProcessEnv; /** Set false for metadata-only reads that do not need hydrated prompt refs. */ hydrateSkillPromptRefs?: boolean; + /** Use latest when the caller must bypass any in-process metadata snapshot. */ + readConsistency?: "latest"; /** Canonical or alias session key for the entry being read or written. */ sessionKey: string; /** Explicit store path for callers that already resolved the owning store. */ @@ -720,9 +722,10 @@ export async function updateResolvedSessionEntry( /** Returns the entry for a canonical or alias session key, if one exists. */ export function loadSessionEntry(scope: SessionAccessScope): SessionEntry | undefined { - if (scope.clone === false) { + if (scope.clone === false || scope.readConsistency === "latest") { const store = loadSessionStore(resolveAccessStorePath(scope), { - clone: false, + ...(scope.clone === false ? { clone: false } : {}), + ...(scope.readConsistency === "latest" ? { skipCache: true } : {}), ...(scope.hydrateSkillPromptRefs === false ? { hydrateSkillPromptRefs: false } : {}), }); return resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey }).existing; diff --git a/test/scripts/check-session-accessor-boundary.test.ts b/test/scripts/check-session-accessor-boundary.test.ts index 9fa4f1d25b5..2eba8e16279 100644 --- a/test/scripts/check-session-accessor-boundary.test.ts +++ b/test/scripts/check-session-accessor-boundary.test.ts @@ -32,9 +32,10 @@ describe("session accessor boundary guard", () => { "src/agents/embedded-agent-runner/tool-result-truncation.ts", "src/agents/embedded-agent-runner/transcript-rewrite.ts", "src/agents/embedded-agent-runner/transcript-runtime-state.ts", - "src/auto-reply/reply/abort.ts", + "src/agents/live-model-switch.ts", "src/agents/subagent-control.ts", "src/agents/subagent-registry-helpers.ts", + "src/auto-reply/reply/abort.ts", "src/auto-reply/reply/agent-runner-helpers.ts", "src/auto-reply/reply/agent-runner.ts", "src/auto-reply/reply/commands-subagents/action-info.ts",