diff --git a/src/auto-reply/reply/session-fork.runtime.test.ts b/src/auto-reply/reply/session-fork.runtime.test.ts index 8de70911d75..6989dd96594 100644 --- a/src/auto-reply/reply/session-fork.runtime.test.ts +++ b/src/auto-reply/reply/session-fork.runtime.test.ts @@ -319,16 +319,14 @@ describe("forkSessionFromParentRuntime", () => { if (fork === null) { throw new Error("Expected forked session"); } - const agentSessionsDir = path.join(root, "agents", "main", "sessions"); expect(fork.sessionFile).toBe(fork.sessionId); expect(fork.sessionId).not.toBe(parentSessionId); const forkedEntries = readTranscript("main", fork.sessionId) as Array>; - const resolvedParentSessionFile = path.join(agentSessionsDir, `${parentSessionId}.jsonl`); expect(forkedEntries[0]).toMatchObject({ type: "session", id: fork.sessionId, cwd, - parentSession: resolvedParentSessionFile, + parentSession: path.resolve(parentSessionFile), }); expect(forkedEntries.map((entry) => entry.type)).toEqual([ "session", @@ -377,17 +375,10 @@ describe("forkSessionFromParentRuntime", () => { } const entries = readTranscript("main", fork.sessionId) as Array>; expect(entries).toHaveLength(1); - const resolvedParentSessionFile = path.join( - root, - "agents", - "main", - "sessions", - `${parentSessionId}.jsonl`, - ); expect(entries[0]).toMatchObject({ type: "session", id: fork.sessionId, - parentSession: resolvedParentSessionFile, + parentSession: path.resolve(parentSessionFile), }); }); }); diff --git a/src/auto-reply/reply/session-fork.runtime.ts b/src/auto-reply/reply/session-fork.runtime.ts index d256075fb0e..a9628c8c3e1 100644 --- a/src/auto-reply/reply/session-fork.runtime.ts +++ b/src/auto-reply/reply/session-fork.runtime.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import path from "node:path"; import { CURRENT_SESSION_VERSION, migrateSessionEntries, @@ -54,11 +55,7 @@ async function estimateParentTranscriptTokensFromSqlite(params: { agentId: string; }): Promise { try { - const filePath = resolveSessionFilePath( - params.parentEntry.sessionId, - params.parentEntry, - resolveSessionFilePathOptions({ agentId: params.agentId }), - ); + const filePath = resolveForkParentSessionFile(params.parentEntry, params.agentId); const scope = resolveSqliteSessionTranscriptScope({ agentId: params.agentId, sessionId: params.parentEntry.sessionId, @@ -77,6 +74,18 @@ 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); + } + return resolveSessionFilePath( + parentEntry.sessionId, + parentEntry, + resolveSessionFilePathOptions({ agentId }), + ); +} + export async function resolveParentForkTokenCountRuntime(params: { parentEntry: StoreSessionEntry; agentId: string; @@ -298,11 +307,7 @@ export async function forkSessionFromParentRuntime(params: { parentEntry: StoreSessionEntry; agentId: string; }): Promise<{ sessionId: string; sessionFile: string } | null> { - const parentSessionFile = resolveSessionFilePath( - params.parentEntry.sessionId, - params.parentEntry, - { agentId: params.agentId }, - ); + const parentSessionFile = resolveForkParentSessionFile(params.parentEntry, params.agentId); if (!parentSessionFile) { return null; } diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 5bd874aab5c..7678f3dda8f 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -119,9 +119,7 @@ afterAll(async () => { async function makeCaseDir(prefix: string): Promise { const stateDir = path.join(suiteRoot, `${prefix}${++suiteCase}`); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); - const sessionsDir = path.join(stateDir, "agents", "main", "sessions"); - await fs.mkdir(sessionsDir, { recursive: true }); - return sessionsDir; + return path.join(stateDir, "transcript-fixtures", "main"); } type TestSessionRowsTarget = { @@ -353,11 +351,10 @@ describe("initSessionState thread forking", () => { it("forks a new session from the parent session file", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const root = await makeCaseDir("openclaw-thread-session-"); - const sessionsDir = path.join(root, "sessions"); - await fs.mkdir(sessionsDir); + const transcriptDir = path.join(root, "thread-transcripts"); const parentSessionId = "parent-session"; - const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); + const parentSessionFile = path.join(transcriptDir, "parent.jsonl"); const header = { type: "session", version: 3, @@ -438,11 +435,10 @@ describe("initSessionState thread forking", () => { it("forks from parent when thread session key already exists but was not forked yet", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); const root = await makeCaseDir("openclaw-thread-session-existing-"); - const sessionsDir = path.join(root, "sessions"); - await fs.mkdir(sessionsDir); + const transcriptDir = path.join(root, "thread-transcripts"); const parentSessionId = "parent-session"; - const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); + const parentSessionFile = path.join(transcriptDir, "parent.jsonl"); const header = { type: "session", version: 3, @@ -520,11 +516,10 @@ describe("initSessionState thread forking", () => { it("skips fork and creates fresh session when parent tokens exceed threshold", async () => { const root = await makeCaseDir("openclaw-thread-session-overflow-"); - const sessionsDir = path.join(root, "sessions"); - await fs.mkdir(sessionsDir); + const transcriptDir = path.join(root, "thread-transcripts"); const parentSessionId = "parent-overflow"; - const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); + const parentSessionFile = path.join(transcriptDir, "parent.jsonl"); const header = { type: "session", version: 3, @@ -590,11 +585,10 @@ describe("initSessionState thread forking", () => { it("skips fork when resolved parent token estimate exceeds threshold", async () => { const root = await makeCaseDir("openclaw-thread-session-overflow-estimated-"); - const sessionsDir = path.join(root, "sessions"); - await fs.mkdir(sessionsDir); + const transcriptDir = path.join(root, "thread-transcripts"); const parentSessionId = "parent-overflow-estimated"; - const parentSessionFile = path.join(sessionsDir, "parent.jsonl"); + const parentSessionFile = path.join(transcriptDir, "parent.jsonl"); replaceSqliteSessionTranscriptEvents({ agentId: "main", sessionId: parentSessionId, @@ -1244,15 +1238,13 @@ describe("initSessionState RawBody", () => { it("uses the default per-agent sessions store when config store is unset", async () => { const root = await makeCaseDir("openclaw-session-store-default-"); - const stateDir = path.join(root, ".openclaw"); + const stateDir = path.dirname(path.dirname(root)); const agentId = "worker1"; const sessionKey = `agent:${agentId}:telegram:12345`; const sessionId = "sess-worker-1"; - const sessionFile = path.join(stateDir, "agents", agentId, "sessions", `${sessionId}.jsonl`); - const sessionRowsTarget = createSessionRowsTargetFromSessionsDir( - path.join(stateDir, "agents", agentId, "sessions"), - agentId, - ); + const transcriptDir = path.join(stateDir, "transcript-fixtures", agentId); + const sessionFile = path.join(transcriptDir, `${sessionId}.jsonl`); + const sessionRowsTarget = createSessionRowsTargetFromSessionsDir(transcriptDir, agentId); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); try { diff --git a/src/config/sessions/session-file.ts b/src/config/sessions/session-file.ts index 1ae682c93aa..d630579bae5 100644 --- a/src/config/sessions/session-file.ts +++ b/src/config/sessions/session-file.ts @@ -33,13 +33,16 @@ export async function resolveAndPersistSessionFile(params: { : !baseEntry.sessionFile && fallbackSessionFile ? { ...baseEntry, sessionFile: fallbackSessionFile } : baseEntry; + const entrySessionFile = entryForResolve.sessionFile?.trim(); const sessionFile = - fallbackSessionFile && !params.sessionsDir - ? path.resolve(fallbackSessionFile) - : resolveSessionFilePath(sessionId, entryForResolve, { - agentId: params.agentId, - sessionsDir: params.sessionsDir, - }); + !params.sessionsDir && entrySessionFile && path.isAbsolute(entrySessionFile) + ? path.resolve(entrySessionFile) + : fallbackSessionFile && !params.sessionsDir + ? path.resolve(fallbackSessionFile) + : resolveSessionFilePath(sessionId, entryForResolve, { + agentId: params.agentId, + sessionsDir: params.sessionsDir, + }); const persistedEntry: SessionEntry = { ...baseEntry, sessionId,