refactor: route live model reads through session accessor (#96206)

This commit is contained in:
Josh Lehman
2026-06-23 16:52:22 -07:00
committed by GitHub
parent 5839ef519a
commit 96cee6cb64
6 changed files with 73 additions and 27 deletions

View File

@@ -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",

View File

@@ -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<string, unknown> | 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",
});
});

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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<T>(
/** 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;

View File

@@ -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",