From ee121ff010abe7cfd5d50ab159e1d99fda1d2fb6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 13:36:41 +0100 Subject: [PATCH] refactor: fork sessions with sqlite transcript locators --- .../reply/session-fork.runtime.test.ts | 33 ++++++++++--------- src/auto-reply/reply/session-fork.runtime.ts | 25 +++++++------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/src/auto-reply/reply/session-fork.runtime.test.ts b/src/auto-reply/reply/session-fork.runtime.test.ts index 6989dd96594..58868e9b501 100644 --- a/src/auto-reply/reply/session-fork.runtime.test.ts +++ b/src/auto-reply/reply/session-fork.runtime.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { createSqliteSessionTranscriptLocator } from "../../config/sessions/paths.js"; import { loadSqliteSessionTranscriptEvents, replaceSqliteSessionTranscriptEvents, @@ -61,10 +62,9 @@ describe("resolveParentForkTokenCountRuntime", () => { it("falls back to recent transcript usage when cached totals are stale", async () => { const root = await makeRoot("openclaw-parent-fork-token-estimate-"); useStateRoot(root); - const transcriptDir = path.join(root, "transcript-fixtures"); const sessionId = "parent-overflow-transcript"; - const sessionFile = path.join(transcriptDir, `${sessionId}.jsonl`); + const sessionFile = createSqliteSessionTranscriptLocator({ agentId: "main", sessionId }); const events: unknown[] = [ { type: "session", @@ -118,10 +118,9 @@ describe("resolveParentForkTokenCountRuntime", () => { it("falls back to a conservative byte estimate when stale parent transcript has no usage", async () => { const root = await makeRoot("openclaw-parent-fork-byte-estimate-"); useStateRoot(root); - const transcriptDir = path.join(root, "transcript-fixtures"); const sessionId = "parent-no-usage-transcript"; - const sessionFile = path.join(transcriptDir, `${sessionId}.jsonl`); + const sessionFile = createSqliteSessionTranscriptLocator({ agentId: "main", sessionId }); const events: unknown[] = [ { type: "session", @@ -160,10 +159,9 @@ describe("resolveParentForkTokenCountRuntime", () => { it("uses the latest usage snapshot instead of tail aggregates for parent fork checks", async () => { const root = await makeRoot("openclaw-parent-fork-latest-usage-"); useStateRoot(root); - const transcriptDir = path.join(root, "transcript-fixtures"); const sessionId = "parent-multiple-usage-transcript"; - const sessionFile = path.join(transcriptDir, `${sessionId}.jsonl`); + const sessionFile = createSqliteSessionTranscriptLocator({ agentId: "main", sessionId }); seedTranscript({ sessionId, transcriptPath: sessionFile, @@ -210,10 +208,9 @@ describe("resolveParentForkTokenCountRuntime", () => { it("keeps parent fork checks conservative for content appended after latest usage", async () => { const root = await makeRoot("openclaw-parent-fork-post-usage-tail-"); useStateRoot(root); - const transcriptDir = path.join(root, "transcript-fixtures"); const sessionId = "parent-post-usage-tail"; - const sessionFile = path.join(transcriptDir, `${sessionId}.jsonl`); + const sessionFile = createSqliteSessionTranscriptLocator({ agentId: "main", sessionId }); seedTranscript({ sessionId, transcriptPath: sessionFile, @@ -261,11 +258,13 @@ describe("forkSessionFromParentRuntime", () => { it("forks the active branch without synchronously opening the session manager", async () => { const root = await makeRoot("openclaw-parent-fork-"); useStateRoot(root); - const transcriptDir = path.join(root, "transcript-fixtures"); const cwd = path.join(root, "workspace"); await fs.mkdir(cwd); const parentSessionId = "parent-session"; - const parentSessionFile = path.join(transcriptDir, `${parentSessionId}.jsonl`); + const parentSessionFile = createSqliteSessionTranscriptLocator({ + agentId: "main", + sessionId: parentSessionId, + }); const events = [ { type: "session", @@ -319,14 +318,16 @@ describe("forkSessionFromParentRuntime", () => { if (fork === null) { throw new Error("Expected forked session"); } - expect(fork.sessionFile).toBe(fork.sessionId); + expect(fork.sessionFile).toBe( + createSqliteSessionTranscriptLocator({ agentId: "main", sessionId: fork.sessionId }), + ); expect(fork.sessionId).not.toBe(parentSessionId); const forkedEntries = readTranscript("main", fork.sessionId) as Array>; expect(forkedEntries[0]).toMatchObject({ type: "session", id: fork.sessionId, cwd, - parentSession: path.resolve(parentSessionFile), + parentSession: parentSessionFile, }); expect(forkedEntries.map((entry) => entry.type)).toEqual([ "session", @@ -344,9 +345,11 @@ describe("forkSessionFromParentRuntime", () => { it("creates a header-only child when the parent has no entries", async () => { const root = await makeRoot("openclaw-parent-fork-empty-"); useStateRoot(root); - const transcriptDir = path.join(root, "transcript-fixtures"); const parentSessionId = "parent-empty"; - const parentSessionFile = path.join(transcriptDir, `${parentSessionId}.jsonl`); + const parentSessionFile = createSqliteSessionTranscriptLocator({ + agentId: "main", + sessionId: parentSessionId, + }); seedTranscript({ sessionId: parentSessionId, transcriptPath: parentSessionFile, @@ -378,7 +381,7 @@ describe("forkSessionFromParentRuntime", () => { expect(entries[0]).toMatchObject({ type: "session", id: fork.sessionId, - parentSession: path.resolve(parentSessionFile), + parentSession: parentSessionFile, }); }); }); diff --git a/src/auto-reply/reply/session-fork.runtime.ts b/src/auto-reply/reply/session-fork.runtime.ts index a9628c8c3e1..b8e81a77be9 100644 --- a/src/auto-reply/reply/session-fork.runtime.ts +++ b/src/auto-reply/reply/session-fork.runtime.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import path from "node:path"; import { CURRENT_SESSION_VERSION, migrateSessionEntries, @@ -9,8 +8,8 @@ import { } from "../../agents/transcript/session-transcript-contract.js"; import { derivePromptTokens } from "../../agents/usage.js"; import { - resolveSessionFilePath, - resolveSessionFilePathOptions, + createSqliteSessionTranscriptLocator, + isSqliteSessionTranscriptLocator, } from "../../config/sessions/paths.js"; import { loadSqliteSessionTranscriptEvents, @@ -76,14 +75,10 @@ async function estimateParentTranscriptTokensFromSqlite(params: { function resolveForkParentSessionFile(parentEntry: StoreSessionEntry, agentId: string): string { const sessionFile = parentEntry.sessionFile?.trim(); - if (sessionFile && path.isAbsolute(sessionFile)) { - return path.resolve(sessionFile); + if (sessionFile && isSqliteSessionTranscriptLocator(sessionFile)) { + return sessionFile; } - return resolveSessionFilePath( - parentEntry.sessionId, - parentEntry, - resolveSessionFilePathOptions({ agentId }), - ); + return createSqliteSessionTranscriptLocator({ agentId, sessionId: parentEntry.sessionId }); } export async function resolveParentForkTokenCountRuntime(params: { @@ -248,7 +243,10 @@ async function writeForkHeaderOnly(params: { }): Promise<{ sessionId: string; sessionFile: string }> { const sessionId = crypto.randomUUID(); const timestamp = new Date().toISOString(); - const sessionFile = sessionId; + const sessionFile = createSqliteSessionTranscriptLocator({ + agentId: params.agentId, + sessionId, + }); const header = { type: "session", version: CURRENT_SESSION_VERSION, @@ -272,7 +270,10 @@ async function writeBranchedSession(params: { }): Promise<{ sessionId: string; sessionFile: string }> { const sessionId = crypto.randomUUID(); const timestamp = new Date().toISOString(); - const sessionFile = sessionId; + const sessionFile = createSqliteSessionTranscriptLocator({ + agentId: params.source.agentId, + sessionId, + }); const pathWithoutLabels = params.source.branchEntries.filter((entry) => entry.type !== "label"); const pathEntryIds = new Set(pathWithoutLabels.map((entry) => entry.id)); const labelEntries = buildBranchLabelEntries({