Skip to content

Commit 96cee6c

Browse files
authored
refactor: route live model reads through session accessor (#96206)
1 parent 5839ef5 commit 96cee6c

6 files changed

Lines changed: 73 additions & 27 deletions

File tree

scripts/check-session-accessor-boundary.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,10 @@ export const migratedSessionAccessorFiles = new Set([
8282
"src/agents/embedded-agent-runner/tool-result-truncation.ts",
8383
"src/agents/embedded-agent-runner/transcript-rewrite.ts",
8484
"src/agents/embedded-agent-runner/transcript-runtime-state.ts",
85-
"src/auto-reply/reply/abort.ts",
85+
"src/agents/live-model-switch.ts",
8686
"src/agents/subagent-control.ts",
8787
"src/agents/subagent-registry-helpers.ts",
88+
"src/auto-reply/reply/abort.ts",
8889
"src/auto-reply/reply/agent-runner-helpers.ts",
8990
"src/auto-reply/reply/agent-runner.ts",
9091
"src/auto-reply/reply/commands-subagents/action-info.ts",

src/agents/live-model-switch.test.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,6 @@ vi.mock("./model-selection.js", async () => {
2727
};
2828
});
2929

30-
vi.mock("../config/sessions/store.js", () => ({
31-
loadSessionStore: (...args: unknown[]) => state.loadSessionStoreMock(...args),
32-
updateSessionStore: (...args: unknown[]) => state.updateSessionStoreMock(...args),
33-
}));
34-
3530
vi.mock("../config/sessions/session-accessor.js", () => ({
3631
loadSessionEntry: (scope: { sessionKey: string }) => {
3732
const store = state.loadSessionStoreMock(scope) as Record<string, unknown> | undefined;
@@ -44,12 +39,6 @@ vi.mock("../config/sessions/paths.js", () => ({
4439
resolveStorePath: (...args: unknown[]) => state.resolveStorePathMock(...args),
4540
}));
4641

47-
vi.mock("../config/sessions.js", () => ({
48-
loadSessionStore: (...args: unknown[]) => state.loadSessionStoreMock(...args),
49-
resolveStorePath: (...args: unknown[]) => state.resolveStorePathMock(...args),
50-
updateSessionStore: (...args: unknown[]) => state.updateSessionStoreMock(...args),
51-
}));
52-
5342
let mod: typeof import("./live-model-switch.js");
5443

5544
async function loadModule() {
@@ -193,6 +182,12 @@ describe("live model switch", () => {
193182
expect(state.resolveStorePathMock).toHaveBeenCalledWith("/tmp/custom-store.json", {
194183
agentId: "reply",
195184
});
185+
expect(state.loadSessionStoreMock).toHaveBeenCalledWith({
186+
storePath: "/tmp/session-store.json",
187+
sessionKey: "main",
188+
hydrateSkillPromptRefs: false,
189+
readConsistency: "latest",
190+
});
196191
});
197192

198193
it("prefers persisted session overrides ahead of stale runtime model fields", async () => {
@@ -468,10 +463,12 @@ describe("live model switch", () => {
468463
const result = shouldSwitchToLiveModel(makeShouldSwitchParams());
469464

470465
expect(result).toBeUndefined();
471-
expect(state.loadSessionStoreMock).toHaveBeenCalledWith("/tmp/session-store.json", {
466+
expect(state.loadSessionStoreMock).toHaveBeenCalledWith({
472467
hydrateSkillPromptRefs: false,
473-
skipCache: true,
474468
clone: false,
469+
readConsistency: "latest",
470+
sessionKey: "main",
471+
storePath: "/tmp/session-store.json",
475472
});
476473
});
477474

src/agents/live-model-switch.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,8 @@
33
*/
44
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
55
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
6-
76
import { resolveStorePath } from "../config/sessions/paths.js";
8-
import { patchSessionEntry } from "../config/sessions/session-accessor.js";
9-
import { loadSessionStore } from "../config/sessions/store.js";
7+
import { loadSessionEntry, patchSessionEntry } from "../config/sessions/session-accessor.js";
108
import {
119
normalizeStoredOverrideModel,
1210
resolveDefaultModelForAgent,
@@ -45,10 +43,12 @@ export function resolveLiveSessionModelSelection(params: {
4543
const storePath = resolveStorePath(cfg.session?.store, {
4644
agentId,
4745
});
48-
const entry = loadSessionStore(storePath, {
46+
const entry = loadSessionEntry({
47+
storePath,
48+
sessionKey,
4949
hydrateSkillPromptRefs: false,
50-
skipCache: true,
51-
})[sessionKey];
50+
readConsistency: "latest",
51+
});
5252
const normalizedSelection = normalizeStoredOverrideModel({
5353
providerOverride: entry?.providerOverride,
5454
modelOverride: entry?.modelOverride,
@@ -151,11 +151,13 @@ export function shouldSwitchToLiveModel(params: {
151151
const storePath = resolveStorePath(cfg.session?.store, {
152152
agentId: params.agentId?.trim(),
153153
});
154-
const entry = loadSessionStore(storePath, {
154+
const entry = loadSessionEntry({
155+
storePath,
156+
sessionKey,
155157
hydrateSkillPromptRefs: false,
156-
skipCache: true,
157158
clone: false,
158-
})[sessionKey];
159+
readConsistency: "latest",
160+
});
159161
if (!entry?.liveModelSwitchPending) {
160162
return undefined;
161163
}

src/config/sessions/session-accessor.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from "node:fs";
22
import os from "node:os";
33
import path from "node:path";
4-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55
import { SessionManager } from "../../agents/sessions/session-manager.js";
66
import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
77
import type { OpenClawConfig } from "../types.openclaw.js";
@@ -36,6 +36,7 @@ import {
3636
updateSessionEntry,
3737
upsertSessionEntry,
3838
} from "./session-accessor.js";
39+
import * as sessionStore from "./store.js";
3940
import { loadSessionStore, saveSessionStore, updateSessionStoreEntry } from "./store.js";
4041
import { withOwnedSessionTranscriptWrites } from "./transcript-write-context.js";
4142
import type { SessionEntry } from "./types.js";
@@ -464,6 +465,47 @@ describe("session accessor file-backed seam", () => {
464465
);
465466
});
466467

468+
it("maps latest entry reads to the file backend cache bypass", () => {
469+
fs.writeFileSync(
470+
storePath,
471+
JSON.stringify({
472+
"agent:main:main": {
473+
sessionId: "session-1",
474+
model: "gpt-5.4",
475+
},
476+
}),
477+
"utf8",
478+
);
479+
const loadSessionStoreSpy = vi.spyOn(sessionStore, "loadSessionStore");
480+
481+
try {
482+
expect(
483+
loadSessionEntry({
484+
readConsistency: "latest",
485+
sessionKey: "agent:main:main",
486+
storePath,
487+
})?.model,
488+
).toBe("gpt-5.4");
489+
expect(loadSessionStoreSpy).toHaveBeenLastCalledWith(
490+
storePath,
491+
expect.objectContaining({ skipCache: true }),
492+
);
493+
494+
loadSessionEntry({
495+
clone: false,
496+
readConsistency: "latest",
497+
sessionKey: "agent:main:main",
498+
storePath,
499+
});
500+
expect(loadSessionStoreSpy).toHaveBeenLastCalledWith(
501+
storePath,
502+
expect.objectContaining({ clone: false, skipCache: true }),
503+
);
504+
} finally {
505+
loadSessionStoreSpy.mockRestore();
506+
}
507+
});
508+
467509
it("resolves canonical entry reads without requiring exact key casing", async () => {
468510
fs.writeFileSync(
469511
storePath,

src/config/sessions/session-accessor.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ export type SessionAccessScope = {
125125
env?: NodeJS.ProcessEnv;
126126
/** Set false for metadata-only reads that do not need hydrated prompt refs. */
127127
hydrateSkillPromptRefs?: boolean;
128+
/** Use latest when the caller must bypass any in-process metadata snapshot. */
129+
readConsistency?: "latest";
128130
/** Canonical or alias session key for the entry being read or written. */
129131
sessionKey: string;
130132
/** Explicit store path for callers that already resolved the owning store. */
@@ -720,9 +722,10 @@ export async function updateResolvedSessionEntry<T>(
720722

721723
/** Returns the entry for a canonical or alias session key, if one exists. */
722724
export function loadSessionEntry(scope: SessionAccessScope): SessionEntry | undefined {
723-
if (scope.clone === false) {
725+
if (scope.clone === false || scope.readConsistency === "latest") {
724726
const store = loadSessionStore(resolveAccessStorePath(scope), {
725-
clone: false,
727+
...(scope.clone === false ? { clone: false } : {}),
728+
...(scope.readConsistency === "latest" ? { skipCache: true } : {}),
726729
...(scope.hydrateSkillPromptRefs === false ? { hydrateSkillPromptRefs: false } : {}),
727730
});
728731
return resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey }).existing;

test/scripts/check-session-accessor-boundary.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ describe("session accessor boundary guard", () => {
3232
"src/agents/embedded-agent-runner/tool-result-truncation.ts",
3333
"src/agents/embedded-agent-runner/transcript-rewrite.ts",
3434
"src/agents/embedded-agent-runner/transcript-runtime-state.ts",
35-
"src/auto-reply/reply/abort.ts",
35+
"src/agents/live-model-switch.ts",
3636
"src/agents/subagent-control.ts",
3737
"src/agents/subagent-registry-helpers.ts",
38+
"src/auto-reply/reply/abort.ts",
3839
"src/auto-reply/reply/agent-runner-helpers.ts",
3940
"src/auto-reply/reply/agent-runner.ts",
4041
"src/auto-reply/reply/commands-subagents/action-info.ts",

0 commit comments

Comments
 (0)