From 404b1527e6bdf84a2b976f80111e5e5334c10e7c Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:37:00 +0100 Subject: [PATCH] fix(acp): persist spawned child session history (#40137) Merged via squash. Prepared head SHA: 62de5d56691e1fd185cc617bb5e7283a14ce90b0 Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- .secrets.baseline | 8 +- CHANGELOG.md | 1 + src/agents/acp-spawn.test.ts | 98 +++++++++++++++++++ src/agents/acp-spawn.ts | 85 +++++++++++++++++ src/commands/agent.acp.test.ts | 68 +++++++++++++ src/commands/agent.ts | 153 +++++++++++++++++++++++++----- src/config/sessions/transcript.ts | 53 ++++++++++- 7 files changed, 436 insertions(+), 30 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index 33aa3b6cecd..be62e5a4ca3 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -10198,21 +10198,21 @@ "filename": "docs/tools/web.md", "hashed_secret": "6b26c117c66a0c030e239eef595c1e18865132a8", "is_verified": false, - "line_number": 131 + "line_number": 135 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "491d458f895b9213facb2ee9375b1b044eaea3ac", "is_verified": false, - "line_number": 224 + "line_number": 228 }, { "type": "Secret Keyword", "filename": "docs/tools/web.md", "hashed_secret": "674397e2c0c2faaa85961c708d2a96a7cc7af217", "is_verified": false, - "line_number": 328 + "line_number": 332 } ], "docs/tts.md": [ @@ -13034,5 +13034,5 @@ } ] }, - "generated_at": "2026-03-08T18:14:00Z" + "generated_at": "2026-03-08T18:30:57Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 203cb9927df..c26849508d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Tools/web search: restore Perplexity OpenRouter/Sonar compatibility for legacy `OPENROUTER_API_KEY`, `sk-or-...`, and explicit `perplexity.baseUrl` / `model` setups while keeping direct Perplexity keys on the native Search API path. (#39937) Thanks @obviyus. - Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera. - Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii. +- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky. ## 2026.3.7 diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 09f7f3f9bcd..0f28b709792 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -35,6 +35,9 @@ const hoisted = vi.hoisted(() => { const initializeSessionMock = vi.fn(); const startAcpSpawnParentStreamRelayMock = vi.fn(); const resolveAcpSpawnStreamLogPathMock = vi.fn(); + const loadSessionStoreMock = vi.fn(); + const resolveStorePathMock = vi.fn(); + const resolveSessionTranscriptFileMock = vi.fn(); const state = { cfg: createDefaultSpawnConfig(), }; @@ -49,6 +52,9 @@ const hoisted = vi.hoisted(() => { initializeSessionMock, startAcpSpawnParentStreamRelayMock, resolveAcpSpawnStreamLogPathMock, + loadSessionStoreMock, + resolveStorePathMock, + resolveSessionTranscriptFileMock, state, }; }); @@ -86,6 +92,24 @@ vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), })); +vi.mock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionStore: (storePath: string) => hoisted.loadSessionStoreMock(storePath), + resolveStorePath: (store: unknown, opts: unknown) => hoisted.resolveStorePathMock(store, opts), + }; +}); + +vi.mock("../config/sessions/transcript.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveSessionTranscriptFile: (params: unknown) => + hoisted.resolveSessionTranscriptFileMock(params), + }; +}); + vi.mock("../acp/control-plane/manager.js", () => { return { getAcpSessionManager: () => ({ @@ -263,6 +287,34 @@ describe("spawnAcpDirect", () => { hoisted.resolveAcpSpawnStreamLogPathMock .mockReset() .mockReturnValue("/tmp/sess-main.acp-stream.jsonl"); + hoisted.resolveStorePathMock.mockReset().mockReturnValue("/tmp/codex-sessions.json"); + hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => { + const store: Record = {}; + return new Proxy(store, { + get(_target, prop) { + if (typeof prop === "string" && prop.startsWith("agent:codex:acp:")) { + return { sessionId: "sess-123", updatedAt: Date.now() }; + } + return undefined; + }, + }); + }); + hoisted.resolveSessionTranscriptFileMock + .mockReset() + .mockImplementation(async (params: unknown) => { + const typed = params as { threadId?: string }; + const sessionFile = typed.threadId + ? `/tmp/agents/codex/sessions/sess-123-topic-${typed.threadId}.jsonl` + : "/tmp/agents/codex/sessions/sess-123.jsonl"; + return { + sessionFile, + sessionEntry: { + sessionId: "sess-123", + updatedAt: Date.now(), + sessionFile, + }, + }; + }); }); it("spawns ACP session, binds a new thread, and dispatches initial task", async () => { @@ -286,6 +338,13 @@ describe("spawnAcpDirect", () => { expect(result.childSessionKey).toMatch(/^agent:codex:acp:/); expect(result.runId).toBe("run-1"); expect(result.mode).toBe("session"); + const patchCalls = hoisted.callGatewayMock.mock.calls + .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) + .filter((request) => request.method === "sessions.patch"); + expect(patchCalls[0]?.params).toMatchObject({ + key: result.childSessionKey, + spawnedBy: "agent:main:main", + }); expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( expect.objectContaining({ targetKind: "session", @@ -308,6 +367,12 @@ describe("spawnAcpDirect", () => { mode: "persistent", }), ); + const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map( + (call: unknown[]) => call[0] as { threadId?: string }, + ); + expect(transcriptCalls).toHaveLength(2); + expect(transcriptCalls[0]?.threadId).toBeUndefined(); + expect(transcriptCalls[1]?.threadId).toBe("child-thread"); }); it("does not inline delivery for fresh oneshot ACP runs", async () => { @@ -328,6 +393,13 @@ describe("spawnAcpDirect", () => { expect(result.status).toBe("accepted"); expect(result.mode).toBe("run"); + expect(hoisted.resolveSessionTranscriptFileMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "sess-123", + storePath: "/tmp/codex-sessions.json", + agentId: "codex", + }), + ); const agentCall = hoisted.callGatewayMock.mock.calls .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) .find((request) => request.method === "agent"); @@ -337,6 +409,32 @@ describe("spawnAcpDirect", () => { expect(agentCall?.params?.threadId).toBeUndefined(); }); + it("keeps ACP spawn running when session-file persistence fails", async () => { + hoisted.resolveSessionTranscriptFileMock.mockRejectedValueOnce(new Error("disk full")); + + const result = await spawnAcpDirect( + { + task: "Investigate flaky tests", + agentId: "codex", + mode: "run", + }, + { + agentSessionKey: "agent:main:main", + agentChannel: "telegram", + agentAccountId: "default", + agentTo: "telegram:6098642967", + agentThreadId: "1", + }, + ); + + expect(result.status).toBe("accepted"); + expect(result.childSessionKey).toMatch(/^agent:codex:acp:/); + const agentCall = hoisted.callGatewayMock.mock.calls + .map((call: unknown[]) => call[0] as { method?: string; params?: Record }) + .find((request) => request.method === "agent"); + expect(agentCall?.params?.sessionKey).toBe(result.childSessionKey); + }); + it("includes cwd in ACP thread intro banner when provided at spawn time", async () => { const result = await spawnAcpDirect( { diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 829ce2ad530..c08cca8fcf8 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -23,6 +23,8 @@ import { } from "../channels/thread-bindings-policy.js"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/config.js"; +import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js"; +import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js"; import { callGateway } from "../gateway/call.js"; import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; import { @@ -30,6 +32,7 @@ import { isSessionBindingError, type SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeDeliveryContext } from "../utils/delivery-context.js"; import { @@ -38,6 +41,9 @@ import { startAcpSpawnParentStreamRelay, } from "./acp-spawn-parent-stream.js"; import { resolveSandboxRuntimeStatus } from "./sandbox/runtime-status.js"; +import { resolveInternalSessionKey, resolveMainSessionAlias } from "./tools/sessions-helpers.js"; + +const log = createSubsystemLogger("agents/acp-spawn"); export const ACP_SPAWN_MODES = ["run", "session"] as const; export type SpawnAcpMode = (typeof ACP_SPAWN_MODES)[number]; @@ -162,6 +168,50 @@ function summarizeError(err: unknown): string { return "error"; } +function resolveRequesterInternalSessionKey(params: { + cfg: OpenClawConfig; + requesterSessionKey?: string; +}): string { + const { mainKey, alias } = resolveMainSessionAlias(params.cfg); + const requesterSessionKey = params.requesterSessionKey?.trim(); + return requesterSessionKey + ? resolveInternalSessionKey({ + key: requesterSessionKey, + alias, + mainKey, + }) + : alias; +} + +async function persistAcpSpawnSessionFileBestEffort(params: { + sessionId: string; + sessionKey: string; + sessionEntry: SessionEntry | undefined; + sessionStore: Record; + storePath: string; + agentId: string; + threadId?: string | number; + stage: "spawn" | "thread-bind"; +}): Promise { + try { + const resolvedSessionFile = await resolveSessionTranscriptFile({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + storePath: params.storePath, + agentId: params.agentId, + threadId: params.threadId, + }); + return resolvedSessionFile.sessionEntry; + } catch (error) { + log.warn( + `ACP session-file persistence failed during ${params.stage} for ${params.sessionKey}: ${summarizeError(error)}`, + ); + return params.sessionEntry; + } +} + function resolveConversationIdForThreadBinding(params: { to?: string; threadId?: string | number; @@ -257,6 +307,10 @@ export async function spawnAcpDirect( ctx: SpawnAcpContext, ): Promise { const cfg = loadConfig(); + const requesterInternalKey = resolveRequesterInternalSessionKey({ + cfg, + requesterSessionKey: ctx.agentSessionKey, + }); if (!isAcpEnabledByPolicy(cfg)) { return { status: "forbidden", @@ -346,11 +400,27 @@ export async function spawnAcpDirect( method: "sessions.patch", params: { key: sessionKey, + spawnedBy: requesterInternalKey, ...(params.label ? { label: params.label } : {}), }, timeoutMs: 10_000, }); sessionCreated = true; + const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId }); + const sessionStore = loadSessionStore(storePath); + let sessionEntry: SessionEntry | undefined = sessionStore[sessionKey]; + const sessionId = sessionEntry?.sessionId; + if (sessionId) { + sessionEntry = await persistAcpSpawnSessionFileBestEffort({ + sessionId, + sessionKey, + sessionStore, + storePath, + sessionEntry, + agentId: targetAgentId, + stage: "spawn", + }); + } const initialized = await acpManager.initializeSession({ cfg, sessionKey, @@ -408,6 +478,21 @@ export async function spawnAcpDirect( `Failed to create and bind a ${preparedBinding.channel} thread for this ACP session.`, ); } + if (sessionId) { + const boundThreadId = String(binding.conversation.conversationId).trim() || undefined; + if (boundThreadId) { + sessionEntry = await persistAcpSpawnSessionFileBestEffort({ + sessionId, + sessionKey, + sessionStore, + storePath, + sessionEntry, + agentId: targetAgentId, + threadId: boundThreadId, + stage: "thread-bind", + }); + } + } } } catch (err) { await cleanupFailedAcpSpawn({ diff --git a/src/commands/agent.acp.test.ts b/src/commands/agent.acp.test.ts index 9beee4b0010..ab8c9da8a6e 100644 --- a/src/commands/agent.acp.test.ts +++ b/src/commands/agent.acp.test.ts @@ -7,6 +7,7 @@ import { AcpRuntimeError } from "../acp/runtime/errors.js"; import * as embeddedModule from "../agents/pi-embedded.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; +import { readSessionMessages } from "../gateway/session-utils.fs.js"; import { onAgentEvent } from "../infra/agent-events.js"; import type { RuntimeEnv } from "../runtime.js"; import { agentCommand } from "./agent.js"; @@ -133,6 +134,17 @@ async function withAcpSessionEnv(fn: () => Promise) { }); } +async function withAcpSessionEnvInfo( + fn: (env: { home: string; storePath: string }) => Promise, +) { + await withTempHome(async (home) => { + const storePath = path.join(home, "sessions.json"); + writeAcpSessionStore(storePath); + mockConfig(home, storePath); + await fn({ home, storePath }); + }); +} + function createRunTurnFromTextDeltas(chunks: string[]) { return vi.fn(async (paramsUnknown: unknown) => { const params = paramsUnknown as { @@ -220,6 +232,62 @@ describe("agentCommand ACP runtime routing", () => { }); }); + it("persists ACP child session history to the transcript store", async () => { + await withAcpSessionEnvInfo(async ({ storePath }) => { + const runTurn = createRunTurnFromTextDeltas(["ACP_", "OK"]); + + mockAcpManager({ + runTurn: (params: unknown) => runTurn(params), + }); + + await agentCommand({ message: "ping", sessionKey: "agent:codex:acp:test" }, runtime); + + const persistedStore = JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record< + string, + { sessionFile?: string } + >; + const sessionFile = persistedStore["agent:codex:acp:test"]?.sessionFile; + const messages = readSessionMessages("acp-session-1", storePath, sessionFile); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: "user", + content: "ping", + }); + expect(messages[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "ACP_OK" }], + }); + }); + }); + + it("preserves exact ACP transcript text without trimming whitespace", async () => { + await withAcpSessionEnvInfo(async ({ storePath }) => { + const runTurn = createRunTurnFromTextDeltas([" ACP_OK\n"]); + + mockAcpManager({ + runTurn: (params: unknown) => runTurn(params), + }); + + await agentCommand({ message: " ping\n", sessionKey: "agent:codex:acp:test" }, runtime); + + const persistedStore = JSON.parse(fs.readFileSync(storePath, "utf-8")) as Record< + string, + { sessionFile?: string } + >; + const sessionFile = persistedStore["agent:codex:acp:test"]?.sessionFile; + const messages = readSessionMessages("acp-session-1", storePath, sessionFile); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: "user", + content: " ping\n", + }); + expect(messages[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: " ACP_OK\n" }], + }); + }); + }); + it("suppresses ACP NO_REPLY lead fragments before emitting assistant text", async () => { await withAcpSessionEnv(async () => { const { assistantEvents, stop } = subscribeAssistantEvents(); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 828f12a43a8..24e62cc8998 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1,6 +1,9 @@ +import fs from "node:fs/promises"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; import { getAcpSessionManager } from "../acp/control-plane/manager.js"; import { resolveAcpAgentPolicyError, resolveAcpDispatchPolicyError } from "../acp/policy.js"; import { toAcpRuntimeError } from "../acp/runtime/errors.js"; +import { resolveAcpSessionCwd } from "../acp/runtime/session-identifiers.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; const log = createSubsystemLogger("commands/agent"); @@ -33,6 +36,7 @@ import { resolveDefaultModelForAgent, resolveThinkingDefault, } from "../agents/model-selection.js"; +import { prepareSessionManagerForRun } from "../agents/pi-embedded-runner/session-manager-init.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; import { buildWorkspaceSkillSnapshot } from "../agents/skills.js"; import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js"; @@ -65,15 +69,11 @@ import { } from "../config/config.js"; import { mergeSessionEntry, - parseSessionThreadInfo, - resolveAndPersistSessionFile, resolveAgentIdFromSessionKey, - resolveSessionFilePath, - resolveSessionFilePathOptions, - resolveSessionTranscriptPath, type SessionEntry, updateSessionStore, } from "../config/sessions.js"; +import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js"; import { clearAgentRunContext, emitAgentEvent, @@ -86,6 +86,7 @@ import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { applyModelOverrideToSessionEntry } from "../sessions/model-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; +import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { resolveMessageChannel } from "../utils/message-channel.js"; import { deliverAgentCommandResult } from "./agent/delivery.js"; import { resolveAgentRunContext } from "./agent/run-context.js"; @@ -230,9 +231,92 @@ function createAcpVisibleTextAccumulator() { finalize(): string { return visibleText.trim(); }, + finalizeRaw(): string { + return visibleText; + }, }; } +const ACP_TRANSCRIPT_USAGE = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, +} as const; + +async function persistAcpTurnTranscript(params: { + body: string; + finalText: string; + sessionId: string; + sessionKey: string; + sessionEntry: SessionEntry | undefined; + sessionStore?: Record; + storePath?: string; + sessionAgentId: string; + threadId?: string | number; + sessionCwd: string; +}): Promise { + const promptText = params.body; + const replyText = params.finalText; + if (!promptText && !replyText) { + return params.sessionEntry; + } + + const { sessionFile, sessionEntry } = await resolveSessionTranscriptFile({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionEntry: params.sessionEntry, + sessionStore: params.sessionStore, + storePath: params.storePath, + agentId: params.sessionAgentId, + threadId: params.threadId, + }); + const hadSessionFile = await fs + .access(sessionFile) + .then(() => true) + .catch(() => false); + const sessionManager = SessionManager.open(sessionFile); + await prepareSessionManagerForRun({ + sessionManager, + sessionFile, + hadSessionFile, + sessionId: params.sessionId, + cwd: params.sessionCwd, + }); + + if (promptText) { + sessionManager.appendMessage({ + role: "user", + content: promptText, + timestamp: Date.now(), + }); + } + + if (replyText) { + sessionManager.appendMessage({ + role: "assistant", + content: [{ type: "text", text: replyText }], + api: "openai-responses", + provider: "openclaw", + model: "acp-runtime", + usage: ACP_TRANSCRIPT_USAGE, + stopReason: "stop", + timestamp: Date.now(), + }); + } + + emitSessionTranscriptUpdate(sessionFile); + return sessionEntry; +} + function runAgentAttempt(params: { providerOverride: string; modelOverride: string; @@ -421,8 +505,8 @@ async function prepareAgentCommandExecution( opts: AgentCommandOpts & { senderIsOwner: boolean }, runtime: RuntimeEnv, ) { - const message = (opts.message ?? "").trim(); - if (!message) { + const message = opts.message ?? ""; + if (!message.trim()) { throw new Error("Message (--message) is required"); } const body = prependInternalEventContext(message, opts.internalEvents); @@ -732,8 +816,29 @@ async function agentCommandInternal( }, }); + const finalTextRaw = visibleTextAccumulator.finalizeRaw(); + const finalText = visibleTextAccumulator.finalize(); + try { + sessionEntry = await persistAcpTurnTranscript({ + body, + finalText: finalTextRaw, + sessionId, + sessionKey, + sessionEntry, + sessionStore, + storePath, + sessionAgentId, + threadId: opts.threadId, + sessionCwd: resolveAcpSessionCwd(acpResolution.meta) ?? workspaceDir, + }); + } catch (error) { + log.warn( + `ACP transcript persistence failed for ${sessionKey}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + const normalizedFinalPayload = normalizeReplyPayload({ - text: visibleTextAccumulator.finalize(), + text: finalText, }); const payloads = normalizedFinalPayload ? [normalizedFinalPayload] : []; const result = { @@ -944,29 +1049,27 @@ async function agentCommandInternal( }); } } - const sessionPathOpts = resolveSessionFilePathOptions({ - agentId: sessionAgentId, - storePath, - }); - let sessionFile = resolveSessionFilePath(sessionId, sessionEntry, sessionPathOpts); + let sessionFile: string | undefined; if (sessionStore && sessionKey) { - const threadIdFromSessionKey = parseSessionThreadInfo(sessionKey).threadId; - const fallbackSessionFile = !sessionEntry?.sessionFile - ? resolveSessionTranscriptPath( - sessionId, - sessionAgentId, - opts.threadId ?? threadIdFromSessionKey, - ) - : undefined; - const resolvedSessionFile = await resolveAndPersistSessionFile({ + const resolvedSessionFile = await resolveSessionTranscriptFile({ sessionId, sessionKey, sessionStore, storePath, sessionEntry, - agentId: sessionPathOpts?.agentId, - sessionsDir: sessionPathOpts?.sessionsDir, - fallbackSessionFile, + agentId: sessionAgentId, + threadId: opts.threadId, + }); + sessionFile = resolvedSessionFile.sessionFile; + sessionEntry = resolvedSessionFile.sessionEntry; + } + if (!sessionFile) { + const resolvedSessionFile = await resolveSessionTranscriptFile({ + sessionId, + sessionKey: sessionKey ?? sessionId, + sessionEntry, + agentId: sessionAgentId, + threadId: opts.threadId, }); sessionFile = resolvedSessionFile.sessionFile; sessionEntry = resolvedSessionFile.sessionEntry; diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index 5e3aa0a082e..e6a8044f5c6 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -2,7 +2,13 @@ import fs from "node:fs"; import path from "node:path"; import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; -import { resolveDefaultSessionStorePath } from "./paths.js"; +import { parseSessionThreadInfo } from "./delivery-info.js"; +import { + resolveDefaultSessionStorePath, + resolveSessionFilePath, + resolveSessionFilePathOptions, + resolveSessionTranscriptPath, +} from "./paths.js"; import { resolveAndPersistSessionFile } from "./session-file.js"; import { loadSessionStore } from "./store.js"; import type { SessionEntry } from "./types.js"; @@ -79,6 +85,51 @@ async function ensureSessionHeader(params: { }); } +export async function resolveSessionTranscriptFile(params: { + sessionId: string; + sessionKey: string; + sessionEntry: SessionEntry | undefined; + sessionStore?: Record; + storePath?: string; + agentId: string; + threadId?: string | number; +}): Promise<{ sessionFile: string; sessionEntry: SessionEntry | undefined }> { + const sessionPathOpts = resolveSessionFilePathOptions({ + agentId: params.agentId, + storePath: params.storePath, + }); + let sessionFile = resolveSessionFilePath(params.sessionId, params.sessionEntry, sessionPathOpts); + let sessionEntry = params.sessionEntry; + + if (params.sessionStore && params.storePath) { + const threadIdFromSessionKey = parseSessionThreadInfo(params.sessionKey).threadId; + const fallbackSessionFile = !sessionEntry?.sessionFile + ? resolveSessionTranscriptPath( + params.sessionId, + params.agentId, + params.threadId ?? threadIdFromSessionKey, + ) + : undefined; + const resolvedSessionFile = await resolveAndPersistSessionFile({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionStore: params.sessionStore, + storePath: params.storePath, + sessionEntry, + agentId: sessionPathOpts?.agentId, + sessionsDir: sessionPathOpts?.sessionsDir, + fallbackSessionFile, + }); + sessionFile = resolvedSessionFile.sessionFile; + sessionEntry = resolvedSessionFile.sessionEntry; + } + + return { + sessionFile, + sessionEntry, + }; +} + export async function appendAssistantMessageToSessionTranscript(params: { agentId?: string; sessionKey: string;