/** * Reads prior session transcript context for `/btw` side-question handoffs. */ import { readFile } from "node:fs/promises"; import { resolveSessionFilePath, resolveSessionFilePathOptions, type SessionEntry as StoredSessionEntry, } from "../config/sessions.js"; import { diagnosticLogger as diag } from "../logging/diagnostic.js"; import { buildSessionContext, migrateSessionEntries, parseSessionEntries, type SessionEntry as AgentSessionEntry, } from "./sessions/session-manager.js"; /** Resolves the persisted transcript file for a BTW session handoff. */ export function resolveBtwSessionTranscriptPath(params: { sessionId: string; sessionEntry?: StoredSessionEntry; sessionKey?: string; storePath?: string; }): string | undefined { try { const agentId = params.sessionKey?.split(":")[1]; const pathOpts = resolveSessionFilePathOptions({ agentId, storePath: params.storePath, }); return resolveSessionFilePath(params.sessionId, params.sessionEntry, pathOpts); } catch (error) { diag.debug( `resolveSessionTranscriptPath failed: sessionId=${params.sessionId} err=${String(error)}`, ); return undefined; } } // Session entries can come from older transcript formats, so id fields are // narrowed at this boundary before branch reconstruction trusts them. function readSessionEntryId(entry: AgentSessionEntry): string | undefined { const id = (entry as { id?: unknown }).id; return typeof id === "string" && id.trim().length > 0 ? id : undefined; } function readSessionEntryParentId(entry: AgentSessionEntry): string | null | undefined { const parentId = (entry as { parentId?: unknown }).parentId; if (parentId === null) { return null; } return typeof parentId === "string" && parentId.trim().length > 0 ? parentId : undefined; } // Parent links mark fork-aware transcripts. Without them, the flat session // context builder preserves the legacy append-only transcript behavior. function hasParentLinkedEntries(entries: AgentSessionEntry[]): boolean { return entries.some((entry) => Boolean(readSessionEntryId(entry) && "parentId" in entry)); } // Reconstructs the selected branch from leaf to root. Missing links or cycles // mean the snapshot cannot be trusted, so callers fall back to a safe branch. function buildSessionBranchEntries( entries: AgentSessionEntry[], leafId: string | undefined, ): AgentSessionEntry[] | undefined { if (!leafId) { return undefined; } const byId = new Map(); for (const entry of entries) { const id = readSessionEntryId(entry); if (id) { byId.set(id, entry); } } const branch: AgentSessionEntry[] = []; const seen = new Set(); let currentId: string | undefined = leafId; while (currentId) { if (seen.has(currentId)) { return undefined; } seen.add(currentId); const entry = byId.get(currentId); if (!entry) { return undefined; } branch.push(entry); currentId = readSessionEntryParentId(entry) ?? undefined; } return branch.toReversed(); } function readDefaultLeafId(entries: AgentSessionEntry[]): string | undefined { for (let index = entries.length - 1; index >= 0; index -= 1) { const id = readSessionEntryId(entries[index]); if (id) { return id; } } return undefined; } function isTrailingUserMessage(entry: AgentSessionEntry | undefined): boolean { return ( entry?.type === "message" && (entry as { message?: { role?: unknown } }).message?.role === "user" ); } /** * Reads prior messages for BTW continuation. * * When a transcript has fork links, this returns the selected snapshot branch * instead of the full file so a resumed agent does not inherit sibling-branch * messages. */ export async function readBtwTranscriptMessages(params: { sessionFile: string; sessionId: string; snapshotLeafId?: string | null; }): Promise { try { const entries = parseSessionEntries(await readFile(params.sessionFile, "utf-8")); migrateSessionEntries(entries); const sessionEntries = entries.filter( (entry): entry is AgentSessionEntry => entry.type !== "session", ); if (!hasParentLinkedEntries(sessionEntries)) { return buildSessionContext(sessionEntries).messages; } let branchEntries = params.snapshotLeafId ? buildSessionBranchEntries(sessionEntries, params.snapshotLeafId) : undefined; if (params.snapshotLeafId && !branchEntries) { diag.debug( `btw snapshot leaf unavailable: sessionId=${params.sessionId} leaf=${params.snapshotLeafId}`, ); } branchEntries ??= buildSessionBranchEntries(sessionEntries, readDefaultLeafId(sessionEntries)); if (!params.snapshotLeafId && isTrailingUserMessage(branchEntries?.at(-1))) { // Auto-selecting the newest branch must not include the current user turn // that triggered BTW handoff; the subagent should continue from its parent. const parentId = readSessionEntryParentId(branchEntries!.at(-1)!); branchEntries = parentId ? (buildSessionBranchEntries(sessionEntries, parentId) ?? []) : []; } const sessionContext = buildSessionContext(branchEntries ?? sessionEntries); return Array.isArray(sessionContext.messages) ? sessionContext.messages : []; } catch { return []; } }