refactor: drop session file path options

This commit is contained in:
Peter Steinberger
2026-05-08 13:57:50 +01:00
parent 28c65c5b85
commit ebe697361d
12 changed files with 45 additions and 191 deletions

View File

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

View File

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

View File

@@ -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$/),
}),
);
});

View File

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

View File

@@ -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", () => ({

View File

@@ -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<string, SessionEntry>;
stateDir: string;
sessionPathOpts: ReturnType<typeof resolveSessionFilePathOptions>;
sessionPathOpts: SessionFilePathOptions;
prompter: DoctorPrompterLike;
warnings: string[];
changes: string[];

View File

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

View File

@@ -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<string, unknown> = {}) {
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<string> {
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 () => {

View File

@@ -10,7 +10,6 @@ export {
isSqliteSessionTranscriptLocator,
parseSqliteSessionTranscriptLocator,
resolveSessionFilePath,
resolveSessionFilePathOptions,
resolveSessionTranscriptPath,
resolveSessionTranscriptPathInDir,
resolveSessionTranscriptsDir,

View File

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

View File

@@ -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://";

View File

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