From ebe697361dcda3ac8b51ec94defc83665d28c752 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:57:50 +0100 Subject: [PATCH] refactor: drop session file path options --- docs/plugins/sdk-runtime.md | 4 +- extensions/skill-workshop/src/reviewer.ts | 10 +- .../voice-call/src/response-generator.test.ts | 12 +- .../voice-call/src/response-generator.ts | 4 +- src/auto-reply/reply/session-updates.test.ts | 1 - .../doctor-heartbeat-main-session-repair.ts | 7 +- src/commands/doctor-state-integrity.ts | 3 +- src/config/sessions.test.ts | 151 ++++-------------- src/config/sessions.ts | 1 - src/config/sessions/lifecycle.ts | 10 +- src/config/sessions/paths.ts | 19 --- src/config/sessions/sessions.test.ts | 14 -- 12 files changed, 45 insertions(+), 191 deletions(-) diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index ecec13aa2b5..6cc08ad9013 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -113,6 +113,8 @@ Provider and channel execution paths must use the active runtime config snapshot **SQLite session row helpers** are under `api.runtime.agent.session`: ```typescript + import { createSqliteSessionTranscriptLocator } from "openclaw/plugin-sdk/session-store-runtime"; + const entry = api.runtime.agent.session.getSessionEntry({ agentId, sessionKey }); await api.runtime.agent.session.patchSessionEntry({ agentId, @@ -122,7 +124,7 @@ Provider and channel execution paths must use the active runtime config snapshot thinkingLevel: "high", }), }); - const filePath = api.runtime.agent.session.resolveSessionFilePath(cfg, sessionId); + const sessionFile = createSqliteSessionTranscriptLocator({ agentId, sessionId }); ``` Prefer row helpers such as `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, and `upsertSessionEntry(...)` for runtime writes. They route through the SQLite session row store and preserve concurrent updates. Legacy `sessions.json` parsing belongs in doctor/migration code, not plugin runtime paths. diff --git a/extensions/skill-workshop/src/reviewer.ts b/extensions/skill-workshop/src/reviewer.ts index 07d18a49163..fada2fad624 100644 --- a/extensions/skill-workshop/src/reviewer.ts +++ b/extensions/skill-workshop/src/reviewer.ts @@ -5,6 +5,7 @@ import { resolveAgentEffectiveModelPrimary, resolveDefaultModelForAgent, } from "openclaw/plugin-sdk/agent-runtime"; +import { createSqliteSessionTranscriptLocator } from "openclaw/plugin-sdk/session-store-runtime"; import type { OpenClawPluginApi } from "../api.js"; import type { SkillWorkshopConfig } from "./config.js"; import { normalizeSkillName } from "./skills.js"; @@ -247,13 +248,10 @@ export async function reviewTranscriptForProposal(params: { api: params.api, agentId: params.ctx.agentId, }); - const sessionFile = params.api.runtime.agent.session.resolveSessionFilePath( + const sessionFile = createSqliteSessionTranscriptLocator({ + agentId: params.ctx.agentId, sessionId, - undefined, - { - agentId: params.ctx.agentId, - }, - ); + }); const result = await params.api.runtime.agent.runEmbeddedPiAgent({ sessionId, sessionKey: params.ctx.sessionKey, diff --git a/extensions/voice-call/src/response-generator.test.ts b/extensions/voice-call/src/response-generator.test.ts index aa4708c6fa1..d491e77264d 100644 --- a/extensions/voice-call/src/response-generator.test.ts +++ b/extensions/voice-call/src/response-generator.test.ts @@ -248,7 +248,6 @@ describe("generateVoiceResponse", () => { resolveAgentDir, resolveAgentWorkspaceDir, resolveAgentIdentity, - resolveSessionFilePath, } = createAgentRuntime([{ text: '{"spoken":"Default agent."}' }]); const coreConfig = {} as CoreConfig; @@ -265,16 +264,13 @@ describe("generateVoiceResponse", () => { expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "main"); expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "main"); expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "main"); - expect(resolveSessionFilePath).toHaveBeenCalledWith(expect.any(String), expect.any(Object), { - agentId: "main", - }); expect(runEmbeddedPiAgent).toHaveBeenCalledWith( expect.objectContaining({ agentDir: "/tmp/openclaw/agents/main", agentId: "main", sandboxSessionKey: "agent:main:voice:15550001111", workspaceDir: "/tmp/openclaw/workspace/main", - sessionFile: "/tmp/openclaw/main/sessions/session.jsonl", + sessionFile: expect.stringMatching(/^sqlite-transcript:\/\/main\/.+\.jsonl$/), }), ); }); @@ -286,7 +282,6 @@ describe("generateVoiceResponse", () => { resolveAgentDir, resolveAgentWorkspaceDir, resolveAgentIdentity, - resolveSessionFilePath, } = createAgentRuntime([{ text: '{"spoken":"Voice agent."}' }]); const coreConfig = {} as CoreConfig; @@ -307,16 +302,13 @@ describe("generateVoiceResponse", () => { expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "voice"); expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "voice"); expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "voice"); - expect(resolveSessionFilePath).toHaveBeenCalledWith(expect.any(String), expect.any(Object), { - agentId: "voice", - }); expect(runEmbeddedPiAgent).toHaveBeenCalledWith( expect.objectContaining({ agentDir: "/tmp/openclaw/agents/voice", agentId: "voice", sandboxSessionKey: "agent:voice:voice:15550001111", workspaceDir: "/tmp/openclaw/workspace/voice", - sessionFile: "/tmp/openclaw/voice/sessions/session.jsonl", + sessionFile: expect.stringMatching(/^sqlite-transcript:\/\/voice\/.+\.jsonl$/), }), ); }); diff --git a/extensions/voice-call/src/response-generator.ts b/extensions/voice-call/src/response-generator.ts index 5c26c740155..493b87a2bf0 100644 --- a/extensions/voice-call/src/response-generator.ts +++ b/extensions/voice-call/src/response-generator.ts @@ -5,6 +5,7 @@ import crypto from "node:crypto"; import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime"; +import { createSqliteSessionTranscriptLocator } from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import type { SessionEntry } from "../api.js"; import { resolveVoiceCallSessionKey, type VoiceCallConfig } from "./config.js"; @@ -255,8 +256,9 @@ export async function generateVoiceResponse( } const sessionId = sessionEntry.sessionId; - const sessionFile = agentRuntime.session.resolveSessionFilePath(sessionId, sessionEntry, { + const sessionFile = createSqliteSessionTranscriptLocator({ agentId, + sessionId, }); // Resolve thinking level diff --git a/src/auto-reply/reply/session-updates.test.ts b/src/auto-reply/reply/session-updates.test.ts index cff8749df3a..d0040e79f9b 100644 --- a/src/auto-reply/reply/session-updates.test.ts +++ b/src/auto-reply/reply/session-updates.test.ts @@ -42,7 +42,6 @@ vi.mock("../../agents/skills/refresh.js", () => ({ vi.mock("../../config/sessions.js", () => ({ upsertSessionEntry: vi.fn(), resolveSessionFilePath: vi.fn(), - resolveSessionFilePathOptions: vi.fn(), })); vi.mock("../../infra/skills-remote.js", () => ({ diff --git a/src/commands/doctor-heartbeat-main-session-repair.ts b/src/commands/doctor-heartbeat-main-session-repair.ts index 1495a44913d..0734f4d2376 100644 --- a/src/commands/doctor-heartbeat-main-session-repair.ts +++ b/src/commands/doctor-heartbeat-main-session-repair.ts @@ -1,10 +1,7 @@ import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js"; import { formatFilesystemTimestamp } from "../config/sessions/artifacts.js"; import { resolveMainSessionKey } from "../config/sessions/main-session.js"; -import { - resolveSessionFilePath, - type resolveSessionFilePathOptions, -} from "../config/sessions/paths.js"; +import { resolveSessionFilePath, type SessionFilePathOptions } from "../config/sessions/paths.js"; import { deleteSessionEntry, getSessionEntry, @@ -183,7 +180,7 @@ export async function repairHeartbeatPoisonedMainSession(params: { cfg: OpenClawConfig; store: Record; stateDir: string; - sessionPathOpts: ReturnType; + sessionPathOpts: SessionFilePathOptions; prompter: DoctorPrompterLike; warnings: string[]; changes: string[]; diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 791f6596d41..0e7afdc985c 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -13,7 +13,6 @@ import { isPrimarySessionTranscriptFileName } from "../config/sessions/artifacts import { resolveMainSessionKey } from "../config/sessions/main-session.js"; import { resolveSessionFilePath, - resolveSessionFilePathOptions, resolveSessionTranscriptsDirForAgent, } from "../config/sessions/paths.js"; import { listSessionEntries, upsertSessionEntry } from "../config/sessions/store.js"; @@ -868,7 +867,7 @@ export async function noteStateIntegrity( const store = Object.fromEntries( listSessionEntries({ agentId, env }).map(({ sessionKey, entry }) => [sessionKey, entry]), ); - const sessionPathOpts = resolveSessionFilePathOptions({ agentId }); + const sessionPathOpts = { agentId }; const entries = Object.entries(store).filter(([, entry]) => entry && typeof entry === "object"); if (entries.length > 0) { const recent = entries diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 8909fba28ec..54deef2ee53 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -10,7 +10,6 @@ import { createSqliteSessionTranscriptLocator, deriveSessionKey, resolveSessionFilePath, - resolveSessionFilePathOptions, resolveSessionKey, resolveSessionTranscriptPath, resolveSessionTranscriptsDir, @@ -76,16 +75,6 @@ describe("sessions", () => { ); } - function expectedBot1FallbackSessionPath() { - return path.join( - path.resolve("/different/state"), - "agents", - "bot1", - "sessions", - "sess-1.jsonl", - ); - } - function buildMainSessionEntry(overrides: Record = {}) { return { sessionId: "sess-1", @@ -94,40 +83,6 @@ describe("sessions", () => { }; } - async function createAgentSessionsLayout(label: string): Promise<{ - stateDir: string; - mainSessionsDir: string; - bot2SessionPath: string; - outsidePath: string; - }> { - const stateDir = await createCaseDir(label); - const mainSessionsDir = path.join(stateDir, "agents", "main", "sessions"); - const bot1SessionsDir = path.join(stateDir, "agents", "bot1", "sessions"); - const bot2SessionsDir = path.join(stateDir, "agents", "bot2", "sessions"); - await fs.mkdir(mainSessionsDir, { recursive: true }); - await fs.mkdir(bot1SessionsDir, { recursive: true }); - await fs.mkdir(bot2SessionsDir, { recursive: true }); - - const bot2SessionPath = path.join(bot2SessionsDir, "sess-1.jsonl"); - await fs.writeFile(bot2SessionPath, "{}", "utf-8"); - - const outsidePath = path.join(stateDir, "outside", "not-a-session.jsonl"); - await fs.mkdir(path.dirname(outsidePath), { recursive: true }); - await fs.writeFile(outsidePath, "{}", "utf-8"); - - return { stateDir, mainSessionsDir, bot2SessionPath, outsidePath }; - } - - async function normalizePathForComparison(filePath: string): Promise { - const canonicalFile = await fs.realpath(filePath).catch(() => null); - if (canonicalFile) { - return canonicalFile; - } - const parentDir = path.dirname(filePath); - const canonicalParent = await fs.realpath(parentDir).catch(() => parentDir); - return path.join(canonicalParent, path.basename(filePath)); - } - const deriveSessionKeyCases = [ { name: "returns normalized per-sender key", @@ -680,99 +635,51 @@ describe("sessions", () => { }); }); - it("resolves cross-agent absolute sessionFile paths", async () => { - const { stateDir, bot2SessionPath } = await createAgentSessionsLayout("cross-agent"); - const sessionFile = withStateDir(stateDir, () => - // Agent bot1 resolves a sessionFile that belongs to agent bot2 - resolveSessionFilePath("sess-1", { sessionFile: bot2SessionPath }, { agentId: "bot1" }), - ); - expect(await normalizePathForComparison(sessionFile)).toBe( - await normalizePathForComparison(bot2SessionPath), - ); - }); - - it("resolves cross-agent paths when OPENCLAW_STATE_DIR differs from stored paths", () => { + it("does not reuse legacy cross-agent absolute sessionFile paths", () => { withStateDir(path.resolve("/different/state"), () => { const originalBase = path.resolve("/original/state"); const bot2Session = path.join(originalBase, "agents", "bot2", "sessions", "sess-1.jsonl"); - // sessionFile was created under a different state dir than current env const sessionFile = resolveSessionFilePath( "sess-1", { sessionFile: bot2Session }, { agentId: "bot1" }, ); - expect(sessionFile).toBe(bot2Session); + expect(sessionFile).toBe( + createSqliteSessionTranscriptLocator({ agentId: "bot1", sessionId: "sess-1" }), + ); }); }); - it("falls back when structural cross-root path traverses after sessions", () => { + it("keeps matching SQLite transcript locators", () => { withStateDir(path.resolve("/different/state"), () => { - const originalBase = path.resolve("/original/state"); - const unsafe = path.join(originalBase, "agents", "bot2", "sessions", "..", "..", "etc"); - const sessionFile = resolveSessionFilePath( - "sess-1", - { sessionFile: path.join(unsafe, "passwd") }, - { agentId: "bot1" }, - ); - expect(sessionFile).toBe(expectedBot1FallbackSessionPath()); - }); - }); - - it("falls back when structural cross-root path nests under sessions", () => { - withStateDir(path.resolve("/different/state"), () => { - const originalBase = path.resolve("/original/state"); - const nested = path.join( - originalBase, - "agents", - "bot2", - "sessions", - "nested", - "sess-1.jsonl", - ); - const sessionFile = resolveSessionFilePath( - "sess-1", - { sessionFile: nested }, - { agentId: "bot1" }, - ); - expect(sessionFile).toBe(expectedBot1FallbackSessionPath()); - }); - }); - - it("resolveSessionFilePathOptions keeps explicit agentId alongside absolute sessions dir", () => { - const sessionsDir = "/tmp/openclaw/agents/main/sessions"; - const resolved = resolveSessionFilePathOptions({ - agentId: "bot2", - sessionsDir, - }); - expect(resolved?.agentId).toBe("bot2"); - expect(resolved?.sessionsDir).toBe(path.resolve(sessionsDir)); - }); - - it("resolves sibling agent absolute sessionFile using alternate agentId from options", async () => { - const { stateDir, mainSessionsDir, bot2SessionPath } = - await createAgentSessionsLayout("sibling-agent"); - const sessionFile = withStateDir(stateDir, () => { - const opts = resolveSessionFilePathOptions({ - agentId: "bot2", - sessionsDir: mainSessionsDir, + const locator = createSqliteSessionTranscriptLocator({ + agentId: "bot1", + sessionId: "sess-1", }); - - return resolveSessionFilePath("sess-1", { sessionFile: bot2SessionPath }, opts); + const sessionFile = resolveSessionFilePath( + "sess-1", + { sessionFile: locator }, + { agentId: "bot1" }, + ); + expect(sessionFile).toBe(locator); }); - expect(await normalizePathForComparison(sessionFile)).toBe( - await normalizePathForComparison(bot2SessionPath), - ); }); - it("falls back to derived transcript path when sessionFile is outside agent sessions directories", async () => { - const { stateDir, outsidePath } = await createAgentSessionsLayout("outside-fallback"); - const sessionFile = withStateDir(stateDir, () => - resolveSessionFilePath("sess-1", { sessionFile: outsidePath }, { agentId: "bot1" }), - ); - const expectedPath = path.join(stateDir, "agents", "bot1", "sessions", "sess-1.jsonl"); - expect(await normalizePathForComparison(sessionFile)).toBe( - await normalizePathForComparison(expectedPath), - ); + it("does not reuse SQLite transcript locators for a different agent", () => { + withStateDir(path.resolve("/different/state"), () => { + const bot2Locator = createSqliteSessionTranscriptLocator({ + agentId: "bot2", + sessionId: "sess-1", + }); + const sessionFile = resolveSessionFilePath( + "sess-1", + { sessionFile: bot2Locator }, + { agentId: "bot1" }, + ); + expect(sessionFile).toBe( + createSqliteSessionTranscriptLocator({ agentId: "bot1", sessionId: "sess-1" }), + ); + }); }); it("patchSessionEntry merges concurrent patches", async () => { diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 5bcc565ae30..eceef5ac219 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -10,7 +10,6 @@ export { isSqliteSessionTranscriptLocator, parseSqliteSessionTranscriptLocator, resolveSessionFilePath, - resolveSessionFilePathOptions, resolveSessionTranscriptPath, resolveSessionTranscriptPathInDir, resolveSessionTranscriptsDir, diff --git a/src/config/sessions/lifecycle.ts b/src/config/sessions/lifecycle.ts index 0e3e9d8bd9b..f4f0c243d67 100644 --- a/src/config/sessions/lifecycle.ts +++ b/src/config/sessions/lifecycle.ts @@ -1,8 +1,4 @@ -import { - createSqliteSessionTranscriptLocator, - isSqliteSessionTranscriptLocator, - type SessionFilePathOptions, -} from "./paths.js"; +import { createSqliteSessionTranscriptLocator, isSqliteSessionTranscriptLocator } from "./paths.js"; import { loadSqliteSessionTranscriptEvents, resolveSqliteSessionTranscriptScope, @@ -32,13 +28,11 @@ function parseTimestampMs(value: unknown): number | undefined { export function readSessionHeaderStartedAtMs(params: { entry: SessionLifecycleEntry | undefined; agentId?: string; - pathOptions?: SessionFilePathOptions; }): number | undefined { const sessionId = params.entry?.sessionId?.trim(); if (!sessionId) { return undefined; } - void params.pathOptions; const storedSessionFile = params.entry?.sessionFile?.trim(); const sessionFile = isSqliteSessionTranscriptLocator(storedSessionFile) ? storedSessionFile @@ -82,7 +76,6 @@ export function readSessionHeaderStartedAtMs(params: { export function resolveSessionLifecycleTimestamps(params: { entry: SessionLifecycleEntry | undefined; agentId?: string; - pathOptions?: SessionFilePathOptions; }): { sessionStartedAt?: number; lastInteractionAt?: number } { const entry = params.entry; if (!entry) { @@ -94,7 +87,6 @@ export function resolveSessionLifecycleTimestamps(params: { readSessionHeaderStartedAtMs({ entry, agentId: params.agentId, - pathOptions: params.pathOptions, }), lastInteractionAt: resolveTimestamp(entry.lastInteractionAt), }; diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 7cdeaf0580d..5a73f8a53fb 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -32,27 +32,8 @@ export function resolveSessionTranscriptsDirForAgent( export type SessionFilePathOptions = { agentId?: string; - sessionsDir?: string; }; -export function resolveSessionFilePathOptions(params: { - agentId?: string; - sessionsDir?: string; -}): SessionFilePathOptions | undefined { - const agentId = params.agentId?.trim(); - const sessionsDir = params.sessionsDir?.trim(); - if (sessionsDir) { - const resolvedSessionsDir = path.resolve(sessionsDir); - return agentId - ? { sessionsDir: resolvedSessionsDir, agentId } - : { sessionsDir: resolvedSessionsDir }; - } - if (agentId) { - return { agentId }; - } - return undefined; -} - export const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i; export const SQLITE_SESSION_TRANSCRIPT_LOCATOR_PREFIX = "sqlite-transcript://"; diff --git a/src/config/sessions/sessions.test.ts b/src/config/sessions/sessions.test.ts index 60870ad9701..0a7ed3a5c5f 100644 --- a/src/config/sessions/sessions.test.ts +++ b/src/config/sessions/sessions.test.ts @@ -9,7 +9,6 @@ import { resolveSessionLifecycleTimestamps } from "./lifecycle.js"; import { createSqliteSessionTranscriptLocator, resolveSessionFilePath, - resolveSessionFilePathOptions, resolveSessionTranscriptPathInDir, validateSessionId, } from "./paths.js"; @@ -57,19 +56,6 @@ describe("session path safety", () => { expect(resolved).toBe(createSqliteSessionTranscriptLocator({ sessionId: "sess-1" })); }); - it("derives session file options from an explicit sessions dir", () => { - expect( - resolveSessionFilePathOptions({ - agentId: "worker", - sessionsDir: "/tmp/openclaw/agents/worker/sessions", - }), - ).toEqual({ - agentId: "worker", - sessionsDir: path.resolve("/tmp/openclaw/agents/worker/sessions"), - }); - expect(resolveSessionFilePathOptions({})).toBeUndefined(); - }); - it("uses SQLite transcript locators instead of runtime JSONL paths by default", () => { expect( resolveSessionFilePath("sess-1", {