diff --git a/packages/coding-agent/src/cli/gc-cli.ts b/packages/coding-agent/src/cli/gc-cli.ts index 5e3e2aed9..382a85a95 100644 --- a/packages/coding-agent/src/cli/gc-cli.ts +++ b/packages/coding-agent/src/cli/gc-cli.ts @@ -433,6 +433,7 @@ function sessionArtifactsPath(sessionPath: string): string { interface SessionLineageHeader { id: string; parentSession?: string; + previousSessionFiles: string[]; } function sessionLineageHeaderFromText(text: string): SessionLineageHeader | undefined { @@ -441,7 +442,12 @@ function sessionLineageHeaderFromText(text: string): SessionLineageHeader | unde const line = rawLine.trim(); if (!line) continue; try { - const record = JSON.parse(line) as { type?: unknown; id?: unknown; parentSession?: unknown }; + const record = JSON.parse(line) as { + type?: unknown; + id?: unknown; + parentSession?: unknown; + previousSessionFiles?: unknown; + }; if (!sawTitleSlot && record.type === "title") { sawTitleSlot = true; continue; @@ -450,6 +456,12 @@ function sessionLineageHeaderFromText(text: string): SessionLineageHeader | unde return { id: record.id, parentSession: typeof record.parentSession === "string" ? record.parentSession : undefined, + previousSessionFiles: Array.isArray(record.previousSessionFiles) + ? record.previousSessionFiles.filter( + (previousSessionFile): previousSessionFile is string => + typeof previousSessionFile === "string" && previousSessionFile.length > 0, + ) + : [], }; } catch { return undefined; @@ -458,6 +470,19 @@ function sessionLineageHeaderFromText(text: string): SessionLineageHeader | unde return undefined; } +async function readSessionLineageHeader(file: string): Promise { + const decoder = new TextDecoder(); + const lines: string[] = []; + for await (const line of readLines(Bun.file(file).stream())) { + const decoded = decoder.decode(line).trim(); + if (!decoded) continue; + lines.push(decoded); + const header = sessionLineageHeaderFromText(lines.join("\n")); + if (header || lines.length >= 2) return header; + } + return undefined; +} + async function gzipSessionFile(source: string, destination: string): Promise { await fs.mkdir(path.dirname(destination), { recursive: true }); const tempPath = `${destination}.${process.pid}.${Date.now()}.tmp`; @@ -604,14 +629,17 @@ const STATS_IDENTITY_COLUMNS: Record = { tool_calls: ["entry_id", "timestamp", "tool_call_id"], }; -interface ArchivedStatsSession { +interface StatsSession { path: string; id: string; parentSession?: string; + historicalPaths: string[]; + identities: Record; } -interface StatsLineageNode extends ArchivedStatsSession { +interface StatsLineageNode extends StatsSession { statsPaths: Set; + identityKeys: Set; } interface StatsEntryIdentity { @@ -629,6 +657,33 @@ interface StatsCleanupPlan { sessionPaths: string[]; retainedSessions: StatsLineageNode[]; transfers: StatsTransferTarget[]; + archivedIdentityKeys: Set; + preserveAll: boolean; +} + +interface StatsCleanupContext { + plans: StatsCleanupPlan[]; + incompleteRetainedSessions: StatsLineageNode[]; +} + +function createStatsIdentities(): Record { + return { + messages: [], + user_messages: [], + tool_calls: [], + }; +} + +function statsIdentityKey(table: StatsEntryTable, identity: StatsEntryIdentity): string { + return `${table}\0${identity.entryId}\0${identity.timestamp}\0${identity.toolCallId}`; +} + +function statsIdentityKeys(identities: Record): Set { + const keys = new Set(); + for (const table of STATS_ENTRY_TABLES) { + for (const identity of identities[table]) keys.add(statsIdentityKey(table, identity)); + } + return keys; } function tableHasColumn(db: Database, table: string, column: string): boolean { @@ -636,25 +691,20 @@ function tableHasColumn(db: Database, table: string, column: string): boolean { return rows.some(row => row.name === column); } -function collectStoredStatsSessionPaths(dbPath: string): string[] { - const db = new Database(dbPath); - try { - db.run("PRAGMA busy_timeout = 5000"); - const sessionPaths = new Set(); - for (const table of STATS_SESSION_TABLES) { - if (!tableExists(db, table) || !tableHasColumn(db, table, "session_file")) continue; - const rows = db.prepare(`SELECT DISTINCT session_file FROM ${table}`).all() as Array<{ - session_file?: string | null; - }>; - for (const row of rows) { - if (typeof row.session_file === "string") sessionPaths.add(row.session_file); - } +function collectStoredStatsSessionPaths(db: Database): string[] { + const sessionPaths = new Set(); + for (const table of STATS_SESSION_TABLES) { + if (!tableExists(db, table) || !tableHasColumn(db, table, "session_file")) continue; + const rows = db.prepare(`SELECT DISTINCT session_file FROM ${table}`).all() as Array<{ + session_file?: string | null; + }>; + for (const row of rows) { + if (typeof row.session_file === "string") sessionPaths.add(row.session_file); } - return [...sessionPaths]; - } finally { - db.close(); } + return [...sessionPaths]; } + /** * `/move` preserves the session filename and artifacts-directory basename. * Recover the top-level path represented by any main or nested stats row so @@ -674,6 +724,37 @@ function logicalSessionRootForStatsPath(statsPath: string, sessionPath: string): } } +function sessionPathEncodesId(sessionPath: string, sessionId: string): boolean { + const stem = path.basename(sessionPath, SESSION_SUFFIX); + return stem === sessionId || stem.endsWith(`_${sessionId}`); +} + +function managedHistoricalSessionPaths( + header: SessionLineageHeader, + currentSessionPath: string, + sessionsRoot: string, +): string[] { + const root = path.resolve(sessionsRoot); + const filename = path.basename(currentSessionPath); + const paths = new Set(); + for (const previousSessionFile of header.previousSessionFiles) { + if (!path.isAbsolute(previousSessionFile) || path.basename(previousSessionFile) !== filename) continue; + const resolved = path.resolve(previousSessionFile); + const relative = path.relative(root, resolved); + if ( + !relative || + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) || + !resolved.endsWith(SESSION_SUFFIX) + ) { + continue; + } + paths.add(resolved); + } + return [...paths]; +} + function resolveLogicalSessionMatch( statsPath: string, nodes: StatsLineageNode[], @@ -687,10 +768,14 @@ function resolveLogicalSessionMatch( if (exact.length === 1) return exact[0]; if (exact.length > 1) return undefined; - const stem = matches[0] ? path.basename(matches[0].logicalRoot, SESSION_SUFFIX) : ""; - const idMatches = matches.filter(match => stem === match.node.id || stem.endsWith(`_${match.node.id}`)); - if (idMatches.length === 1) return idMatches[0]; - return undefined; + const known = matches.filter(match => + [...match.node.statsPaths].some(statsPath => path.resolve(statsPath) === path.resolve(match.logicalRoot)), + ); + if (known.length === 1) return known[0]; + if (known.length > 1) return undefined; + + const idMatches = matches.filter(match => sessionPathEncodesId(match.logicalRoot, match.node.id)); + return idMatches.length === 1 ? idMatches[0] : undefined; } /** @@ -699,25 +784,31 @@ function resolveLogicalSessionMatch( * unresolved so cleanup fails safe instead of transferring across sessions. */ function buildStatsCleanupPlans( - archivedSessions: ArchivedStatsSession[], - retainedSessions: SessionInfo[], - storedSessionPaths: string[], -): StatsCleanupPlan[] { + archivedSessions: StatsSession[], + retainedSessions: StatsSession[], + dbPath: string, +): StatsCleanupContext { const archivedNodes: StatsLineageNode[] = archivedSessions.map(session => ({ ...session, - statsPaths: new Set([session.path]), + statsPaths: new Set([session.path, ...session.historicalPaths]), + identityKeys: statsIdentityKeys(session.identities), })); const retainedNodes: StatsLineageNode[] = retainedSessions.map(session => ({ - path: session.path, - id: session.id, - parentSession: session.parentSessionPath, - statsPaths: new Set([session.path]), + ...session, + statsPaths: new Set([session.path, ...session.historicalPaths]), + identityKeys: statsIdentityKeys(session.identities), })); const nodes = [...archivedNodes, ...retainedNodes]; - for (const storedPath of storedSessionPaths) { - const match = resolveLogicalSessionMatch(storedPath, nodes); - if (match) match.node.statsPaths.add(match.logicalRoot); + const db = new Database(dbPath); + try { + db.run("PRAGMA busy_timeout = 5000"); + for (const storedPath of collectStoredStatsSessionPaths(db)) { + const match = resolveLogicalSessionMatch(storedPath, nodes); + if (match) match.node.statsPaths.add(match.logicalRoot); + } + } finally { + db.close(); } for (const child of nodes) { @@ -731,6 +822,17 @@ function buildStatsCleanupPlans( if (match) match.node.statsPaths.add(match.logicalRoot); } + const archivedPathClaimCounts = new Map(); + for (const archived of archivedNodes) { + for (const statsPath of archived.statsPaths) { + const key = path.resolve(statsPath); + archivedPathClaimCounts.set(key, (archivedPathClaimCounts.get(key) ?? 0) + 1); + } + } + const ambiguousArchivedPathKeys = new Set( + [...archivedPathClaimCounts].filter(([, count]) => count > 1).map(([key]) => key), + ); + const aliases = new Map(); const identityKeysByNode = new Map>(); for (const node of nodes) { @@ -748,12 +850,17 @@ function buildStatsCleanupPlans( } } + const incompleteLineage = new Set(); const lineageKeysByNode = new Map>(); for (const node of nodes) { const lineageKeys = new Set(identityKeysByNode.get(node)); let current: StatsLineageNode | null = node; const seen = new Set(); - while (current?.parentSession && !seen.has(current)) { + while (current?.parentSession) { + if (seen.has(current)) { + incompleteLineage.add(node); + break; + } seen.add(current); const parentReference = current.parentSession; const parentKey = @@ -762,7 +869,10 @@ function buildStatsCleanupPlans( : `id:${parentReference}`; lineageKeys.add(parentKey); const parent = aliases.get(parentKey); - if (!parent) break; + if (!parent) { + incompleteLineage.add(node); + break; + } for (const key of identityKeysByNode.get(parent) ?? []) lineageKeys.add(key); current = parent; } @@ -772,97 +882,130 @@ function buildStatsCleanupPlans( const retainedPathKeys = new Set( retainedNodes.flatMap(retained => [...retained.statsPaths].map(statsPath => path.resolve(statsPath))), ); - return archivedNodes.map(archived => { + const plans = archivedNodes.map(archived => { const archivedLineage = lineageKeysByNode.get(archived) ?? new Set(); return { - sessionPaths: [...archived.statsPaths].filter(statsPath => !retainedPathKeys.has(path.resolve(statsPath))), + sessionPaths: [...archived.statsPaths].filter(statsPath => { + const key = path.resolve(statsPath); + return !retainedPathKeys.has(key) && !ambiguousArchivedPathKeys.has(key); + }), retainedSessions: retainedNodes.filter(retained => { + if (incompleteLineage.has(retained)) return false; const retainedLineage = lineageKeysByNode.get(retained); return retainedLineage ? [...retainedLineage].some(key => archivedLineage.has(key)) : false; }), transfers: [], + archivedIdentityKeys: archived.identityKeys, + preserveAll: false, }; }); + return { + plans, + incompleteRetainedSessions: retainedNodes.filter(retained => incompleteLineage.has(retained)), + }; +} + +function addSessionStatsIdentity(line: string, identities: Record): void { + if (line.length === 0) return; + try { + const record: unknown = JSON.parse(line); + if ( + !record || + typeof record !== "object" || + !("type" in record) || + record.type !== "message" || + !("id" in record) || + typeof record.id !== "string" || + record.id.length === 0 || + !("message" in record) || + !record.message || + typeof record.message !== "object" + ) { + return; + } + const message = record.message; + if (!("role" in message)) return; + const parsedEntryTimestamp = + "timestamp" in record && typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN; + if (message.role === "user") { + identities.user_messages.push({ + entryId: record.id, + timestamp: Number.isFinite(parsedEntryTimestamp) ? parsedEntryTimestamp : 0, + toolCallId: "", + }); + return; + } + if (message.role !== "assistant") return; + const timestamp = + "timestamp" in message && typeof message.timestamp === "number" && Number.isFinite(message.timestamp) + ? message.timestamp + : Number.isFinite(parsedEntryTimestamp) + ? parsedEntryTimestamp + : 0; + identities.messages.push({ entryId: record.id, timestamp, toolCallId: "" }); + if (!("content" in message) || !Array.isArray(message.content)) return; + for (const block of message.content) { + if ( + block && + typeof block === "object" && + "type" in block && + block.type === "toolCall" && + "id" in block && + typeof block.id === "string" + ) { + identities.tool_calls.push({ entryId: record.id, timestamp, toolCallId: block.id }); + } + } + } catch { + // Stats parsing is also lenient: a malformed line cannot own a retained row. + } +} + +function collectSessionStatsIdentitiesFromText(text: string): Record { + const identities = createStatsIdentities(); + for (const line of text.split(/\r?\n/)) addSessionStatsIdentity(line, identities); + return identities; } async function collectSessionStatsIdentities( sessionPath: string, ): Promise> { - const identities: Record = { - messages: [], - user_messages: [], - tool_calls: [], - }; + const identities = createStatsIdentities(); const decoder = new TextDecoder(); for await (const line of readLines(Bun.file(sessionPath).stream())) { - if (line.length === 0) continue; - try { - const record: unknown = JSON.parse(decoder.decode(line)); - if ( - !record || - typeof record !== "object" || - !("type" in record) || - record.type !== "message" || - !("id" in record) || - typeof record.id !== "string" || - record.id.length === 0 || - !("message" in record) || - !record.message || - typeof record.message !== "object" - ) { - continue; - } - const message = record.message; - if (!("role" in message)) continue; - const parsedEntryTimestamp = - "timestamp" in record && typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN; - if (message.role === "user") { - identities.user_messages.push({ - entryId: record.id, - timestamp: Number.isFinite(parsedEntryTimestamp) ? parsedEntryTimestamp : 0, - toolCallId: "", - }); - continue; - } - if (message.role !== "assistant") continue; - const timestamp = - "timestamp" in message && typeof message.timestamp === "number" && Number.isFinite(message.timestamp) - ? message.timestamp - : Number.isFinite(parsedEntryTimestamp) - ? parsedEntryTimestamp - : 0; - identities.messages.push({ entryId: record.id, timestamp, toolCallId: "" }); - if (!("content" in message) || !Array.isArray(message.content)) continue; - for (const block of message.content) { - if ( - block && - typeof block === "object" && - "type" in block && - block.type === "toolCall" && - "id" in block && - typeof block.id === "string" && - block.id.length > 0 - ) { - identities.tool_calls.push({ entryId: record.id, timestamp, toolCallId: block.id }); - } - } - } catch { - // Stats parsing is also lenient: a malformed line cannot own a retained row. - } + addSessionStatsIdentity(decoder.decode(line), identities); } return identities; } -async function populateStatsTransferTargets(plans: StatsCleanupPlan[]): Promise { +async function populateStatsTransferTargets(context: StatsCleanupContext): Promise { const identitiesBySession = new Map>>(); - for (const plan of plans) { + const identitiesFor = (session: StatsLineageNode): Promise> => { + let identities = identitiesBySession.get(session.path); + if (!identities) { + identities = collectSessionStatsIdentities(session.path); + identitiesBySession.set(session.path, identities); + } + return identities; + }; + + const incompleteIdentityKeys = new Set(); + let hasUnidentifiableIncompleteSession = false; + for (const retained of context.incompleteRetainedSessions) { + const keys = statsIdentityKeys(await identitiesFor(retained)); + if (keys.size === 0) hasUnidentifiableIncompleteSession = true; + for (const key of keys) incompleteIdentityKeys.add(key); + } + + for (const plan of context.plans) { + if (context.incompleteRetainedSessions.length > 0) { + plan.preserveAll = + hasUnidentifiableIncompleteSession || + plan.archivedIdentityKeys.size === 0 || + [...plan.archivedIdentityKeys].some(key => incompleteIdentityKeys.has(key)); + } for (const retained of plan.retainedSessions) { - let identities = identitiesBySession.get(retained.path); - if (!identities) { - identities = collectSessionStatsIdentities(retained.path); - identitiesBySession.set(retained.path, identities); - } - plan.transfers.push({ path: retained.path, identities: await identities }); + plan.transfers.push({ path: retained.path, identities: await identitiesFor(retained) }); } } } @@ -933,6 +1076,7 @@ function reconcileStatsRowsForSessions(dbPath: string, plans: StatsCleanupPlan[] let deleted = 0; const tx = db.transaction((cleanupPlans: StatsCleanupPlan[]) => { for (const plan of cleanupPlans) { + if (plan.preserveAll) continue; clearRetainedEntries.run(); for (const target of plan.transfers) { for (const table of entryTables) { @@ -975,19 +1119,22 @@ async function collectArchivedStatsSessions( archiveRoot: string, sessionsRoot: string, onError: (file: string, error: unknown) => void, -): Promise { - const sessions: ArchivedStatsSession[] = []; +): Promise { + const sessions: StatsSession[] = []; for (const file of await collectCompressedJsonlFiles(archiveRoot)) { const relative = path.relative(archiveRoot, file); if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) continue; const sourcePath = path.join(sessionsRoot, relative.slice(0, -".gz".length)); try { - const header = sessionLineageHeaderFromText(await readTextIfPresent(file)); + const text = await readTextIfPresent(file); + const header = sessionLineageHeaderFromText(text); if (!header) throw new Error("archive is missing a valid session header"); sessions.push({ path: sourcePath, id: header.id, parentSession: header.parentSession, + historicalPaths: managedHistoricalSessionPaths(header, sourcePath, sessionsRoot), + identities: collectSessionStatsIdentitiesFromText(text), }); } catch (error) { onError(file, error); @@ -1007,32 +1154,31 @@ async function cleanupStatsRowsForArchivedSessions( ? getStatsDbPath() : path.join(options.agentDir, "stats.db"); if (!(await pathExists(dbPath))) return; + const sessionsRoot = getSessionsDir(options.agentDir); - const archivedByPath = new Map(); + const archivedByPath = new Map(); for (const session of newlyArchivedSessions) { archivedByPath.set(path.resolve(session.path), { path: session.path, id: session.id, parentSession: session.parentSessionPath, + historicalPaths: [], + identities: createStatsIdentities(), }); } let retainedSessions: SessionInfo[]; try { - for (const session of await collectArchivedStatsSessions( - archiveRoot, - getSessionsDir(options.agentDir), - (file, error) => { - result.errors.push(`stats cleanup scan ${file}: ${errorMessage(error)}`); - }, - )) { + for (const session of await collectArchivedStatsSessions(archiveRoot, sessionsRoot, (file, error) => { + result.errors.push(`stats cleanup scan ${file}: ${errorMessage(error)}`); + })) { archivedByPath.set(path.resolve(session.path), session); } } catch (error) { result.errors.push(`stats cleanup scan: ${errorMessage(error)}`); } try { - retainedSessions = await listActiveSessions(getSessionsDir(options.agentDir)); + retainedSessions = await listActiveSessions(sessionsRoot); } catch (error) { result.errors.push(`stats cleanup scan: ${errorMessage(error)}`); return; @@ -1040,10 +1186,24 @@ async function cleanupStatsRowsForArchivedSessions( try { await withStatsSyncLock(dbPath, async () => { - const storedSessionPaths = collectStoredStatsSessionPaths(dbPath); - const plans = buildStatsCleanupPlans([...archivedByPath.values()], retainedSessions, storedSessionPaths); - await populateStatsTransferTargets(plans); - result.statsRowsDeleted = reconcileStatsRowsForSessions(dbPath, plans); + const retainedStatsSessions = await Promise.all( + retainedSessions.map(async session => { + const header = await readSessionLineageHeader(session.path); + if (!header || header.id !== session.id) { + throw new Error(`session header changed during stats cleanup: ${session.path}`); + } + return { + path: session.path, + id: session.id, + parentSession: header.parentSession, + historicalPaths: managedHistoricalSessionPaths(header, session.path, sessionsRoot), + identities: createStatsIdentities(), + }; + }), + ); + const context = buildStatsCleanupPlans([...archivedByPath.values()], retainedStatsSessions, dbPath); + await populateStatsTransferTargets(context); + result.statsRowsDeleted = reconcileStatsRowsForSessions(dbPath, context.plans); }); } catch (error) { result.errors.push(`stats cleanup: ${errorMessage(error)}`); diff --git a/packages/coding-agent/src/export/html/index.ts b/packages/coding-agent/src/export/html/index.ts index d4d8cd81e..4e2d10af0 100644 --- a/packages/coding-agent/src/export/html/index.ts +++ b/packages/coding-agent/src/export/html/index.ts @@ -181,10 +181,17 @@ export interface SessionData { subSessions?: Record; } +function sessionHeaderForExport(header: SessionHeader | null): SessionHeader | null { + if (!header) return null; + const exported = { ...header }; + delete exported.previousSessionFiles; + return exported; +} + /** Snapshot the session (plus optional agent state) into the JSON shape the viewer renders. */ export function buildSessionData(sm: SessionManager, state?: AgentState): SessionData { return { - header: sm.getHeader(), + header: sessionHeaderForExport(sm.getHeader()), entries: sm.getEntries(), leafId: sm.getLeafId(), systemPrompt: state?.systemPrompt.join("\n\n"), @@ -231,7 +238,7 @@ async function collectSubSessionsFromDir( out[key] = { agentId, parent: parentKey, - header, + header: sessionHeaderForExport(header), entries, leafId: entries.length > 0 ? entries[entries.length - 1].id : null, }; @@ -295,7 +302,7 @@ export async function exportFromFile(inputPath: string, options?: ExportOptions } const sessionData: SessionData = { - header: sm.getHeader(), + header: sessionHeaderForExport(sm.getHeader()), entries: sm.getEntries(), leafId: sm.getLeafId(), }; diff --git a/packages/coding-agent/src/export/share.ts b/packages/coding-agent/src/export/share.ts index 996f76be0..11012e838 100644 --- a/packages/coding-agent/src/export/share.ts +++ b/packages/coding-agent/src/export/share.ts @@ -223,6 +223,7 @@ function collectShareRegexSecretValues(o: SecretObfuscator, data: SessionData): if (!header) return; add(header.title); add(header.cwd); + for (const previousSessionFile of header.previousSessionFiles ?? []) add(previousSessionFile); }; addHeader(data.header); @@ -246,6 +247,9 @@ function redactShareHeader( ...header, title: header.title === undefined ? undefined : o.obfuscate(header.title, sharedRegexSecretValues), cwd: o.obfuscate(header.cwd, sharedRegexSecretValues), + previousSessionFiles: header.previousSessionFiles?.map(previousSessionFile => + o.obfuscate(previousSessionFile, sharedRegexSecretValues), + ), }; } diff --git a/packages/coding-agent/src/session/session-entries.ts b/packages/coding-agent/src/session/session-entries.ts index 2386ed829..756977076 100644 --- a/packages/coding-agent/src/session/session-entries.ts +++ b/packages/coding-agent/src/session/session-entries.ts @@ -39,6 +39,8 @@ export interface SessionHeader { */ additionalDirectories?: string[]; parentSession?: string; + /** Prior absolute JSONL locations recorded by successful session moves. */ + previousSessionFiles?: string[]; /** Provider prompt-cache identity inherited by exact-route full forks. */ providerPromptCacheKey?: string; } diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index ec451c6fc..9df4129bb 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1383,6 +1383,12 @@ export class SessionManager { throw err; } + if (sessionFileExisted && sessionPathChanged) { + this.#header.previousSessionFiles = [ + ...new Set([...(this.#header.previousSessionFiles ?? []), path.resolve(oldSessionFile)]), + ]; + } + this.#sessionFile = newSessionFile; this.#artifactManager = null; this.#artifactManagerSessionFile = null; diff --git a/packages/coding-agent/test/export-subsessions.test.ts b/packages/coding-agent/test/export-subsessions.test.ts index 551039724..c5814910b 100644 --- a/packages/coding-agent/test/export-subsessions.test.ts +++ b/packages/coding-agent/test/export-subsessions.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { removeWithRetries } from "@oh-my-pi/pi-utils"; -import { collectSubSessions } from "../src/export/html"; +import { collectSubSessions, exportFromFile } from "../src/export/html"; /** * Contract: a session at `/.jsonl` embeds subagent transcripts from @@ -11,9 +11,16 @@ import { collectSubSessions } from "../src/export/html"; * parent links and last-entry leaf ids. Corrupt/empty/backup files are skipped. */ -function sessionJsonl(id: string, entryIds: string[]): string { +function sessionJsonl(id: string, entryIds: string[], previousSessionFiles?: string[]): string { const lines = [ - JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-06-12T00:00:00.000Z", cwd: "/tmp" }), + JSON.stringify({ + type: "session", + version: 3, + id, + timestamp: "2026-06-12T00:00:00.000Z", + cwd: "/tmp", + previousSessionFiles, + }), ]; let parent: string | null = null; for (const entryId of entryIds) { @@ -60,6 +67,28 @@ describe("collectSubSessions", () => { expect(subs.Beta).toMatchObject({ agentId: "Beta", parent: null, leafId: "b1" }); }); + test("omits internal move history from standalone HTML", async () => { + const mainPreviousPath = "/Users/private/main.jsonl"; + const subPreviousPath = "/Users/private/Alpha.jsonl"; + await Bun.write(mainFile, sessionJsonl("main", ["m1"], [mainPreviousPath])); + await Bun.write(path.join(root, "main/Alpha.jsonl"), sessionJsonl("alpha", ["a1"], [subPreviousPath])); + const outputPath = path.join(root, "export.html"); + + await exportFromFile(mainFile, { outputPath }); + + const html = await Bun.file(outputPath).text(); + const encoded = html.match(/