diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c7702d0..53cc6b4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -10,12 +10,14 @@ jobs: runs-on: macos-latest env: NODE_ENV: development + PIDEX_BRANCH_PROOF_DIR: test-results/branch-proof steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: jdx/mise-action@dad1bfd3df957f44999b559dd69dc1671cb4e9ea # v4.2.1 with: install: true - run: bun install --frozen-lockfile + - run: bunx playwright-core install ffmpeg - run: mise run test:e2e - name: upload test artefacts if: always() diff --git a/README.md b/README.md index 883cb0a..c3ad5d1 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,17 @@ These are captures of real Electron interactions with a temporary Git workspace - Add local workspaces and discover existing Pi sessions. - Start, stop, steer, resume, and fork sessions; preview history before launching Pi. +- Browse linked session archives and branch entries; preview and continue the selected path. - Render streamed Markdown, thinking, and expandable tool output. - Browse files, review diffs, stage changes, and commit. - Discover workspace and global skills, switch models, and save model and reasoning defaults per workspace. - Use quick switch for workspaces, sessions, files, and commands. +Session history shows source/fork relationships and an entry tree with selected and archive-tip markers. Select a branch tip or an earlier entry to preview its parent chain. Resume at the archive tip continues in place; **Resume in new fork** continues a historical selection in a separate archive and preserves the source. **Fork from here** uses the selected entry. Pi 1.5.0 RPC does not expose arbitrary-entry navigation, so forks extract a native branch archive before launching Pi so even runtime startup writes preserve the source. Continuation requires an intact Pi v3 archive within the 16 MiB browsing limit. See [ADR 0013](docs/adr/0013-session-branch-selection.md) for runtime semantics and limits. + Active sessions and history use a virtualized transcript. A local development benchmark with 4,500 mixed entries opened in about 1.4 seconds, down from 8.3 seconds, and mounted fewer than 100 transcript DOM nodes instead of 39,000. Timings vary by machine and load; see [ADR 0011](docs/adr/0011-virtualized-transcript.md) for the method and limits. -Session trees, folder-to-folder context migration, and multi-root workflows remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues). +Recovery summaries and work-state signals, folder-to-folder context migration, and multi-root workflows remain on the [issue tracker](https://github.com/echohello-dev/pidex/issues). ## Install diff --git a/docs/adr/0013-session-branch-selection.md b/docs/adr/0013-session-branch-selection.md new file mode 100644 index 0000000..471a168 --- /dev/null +++ b/docs/adr/0013-session-branch-selection.md @@ -0,0 +1,6 @@ +# ADR 0013 — Explicit archive branch selection over Pi RPC + +- **Status**: Proposed +- **Context**: A Pi v3 JSONL archive is an append-only tree. Entries of every type have `id` and `parentId`; the session header is not a tree node. Reloading an archive selects the last non-header entry. Fork archives link to their source with `parentSession`. Inspection of installed Pi 1.5.0 (`docs/rpc-commands.md`, `dist/modes/rpc/rpc-mode.js`, `dist/core/session-manager.js`) confirmed that RPC exposes `get_tree`, `fork`, and `clone`, but does not expose the SDK's `navigateTree`. `fork` branches before a previous user message, while `clone` duplicates the currently active path. Neither selects an arbitrary archived entry. +- **Decision**: Use a shared archive graph and explicit entry ID for preview and continuation. Resume at the archive tip opens the original through `--session`. Continuing a historical entry is visibly labeled **Resume in new fork**. All forks write a new native v3 archive with a fresh session ID, source `parentSession`, and only the selected root-to-entry chain; it retains entry IDs, message/tool records, model changes, labels, compaction references, and unknown entry fields. Open that archive through supported `--session`, then verify the runtime leaf with `get_entries` before accepting prompts. Pi can append model/thinking settings during startup, so accept only a verified linear chain of those setup entries after the selection; reject other movement. Starting Pi against the source before calling `clone` can therefore modify source bytes. Extracting before startup preserves the source for current-tip forks too. Archive extraction does not invoke Pi extension fork hooks. Enforce one live runtime per archive in the main process; refresh runtime state and validate explicit history selections before reusing its existing tab. Running or mismatched selections fail without starting a competing writer. Never rewrite the source or invent a navigation RPC. Group linked fork archives in the workspace's session tree and display selected entry and archive tip separately. Entry lists and transcripts stay virtualized for large archives. +- **Consequences**: Historical continuation creates a separate session rather than moving the original session's in-memory leaf. Old archive versions remain previewable where their lineage is readable; continuation requires intact v3 input and tells the user to migrate older files in Pi. Missing parents, cycles, duplicate IDs, malformed records and stale selections fail closed for affected operations. Archive browsing has a 16 MiB bound. Preview shows stored conversation history along the selected parent chain, including pre-compaction messages; Pi constructs its own compacted runtime context when it opens the resulting archive. Cross-folder lineage migration and multi-root workflows remain follow-ups. A future documented navigation RPC can replace extraction after its exact-entry semantics are verified. diff --git a/docs/adr/README.md b/docs/adr/README.md index 3443ad7..c96b1bf 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,4 @@ Each ADR follows this shape: | [0010](0010-distribution-channels.md) | Ship desktop installers and public package channels | Accepted | | [0011](0011-virtualized-transcript.md) | Virtualize the session transcript | Accepted | | [0012](0012-release-please-calver.md) | Release Please with a CalVer adapter | Accepted | +| [0013](0013-session-branch-selection.md) | Explicit archive branch selection over Pi RPC | Proposed | diff --git a/src/main/index.ts b/src/main/index.ts index 8dc8f40..20c9d9e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,3 +1,9 @@ +import { + forkArchive, + loadArchive, + selectedPath, + runtimeMatchesSelection, +} from './session-tree'; import { app, BrowserWindow, dialog, ipcMain } from 'electron'; import fs from 'fs'; import path from 'path'; @@ -104,20 +110,116 @@ ipcMain.handle('pidex:sessions', (_e, dirName: string) => { ipcMain.handle( 'pidex:session/open', - async (_e, req: { cwd: string; sessionFile?: string; fork?: boolean }) => { + async ( + _e, + req: { + cwd: string; + sessionFile?: string; + entryId?: string | null; + fork?: boolean; + }, + ) => { const cwd = typeof req?.cwd === 'string' ? req.cwd : ''; - const sessionFile = + let sessionFile = typeof req?.sessionFile === 'string' ? req.sessionFile : undefined; if (!cwd || !fs.existsSync(cwd)) { return { error: 'workspace path does not exist' }; } + let expectedEntryId: string | null | undefined; + let extractedFork = false; if (sessionFile) { try { - await sessionPath(sessionFile); - } catch { + sessionFile = await sessionPath(sessionFile); + const archive = await loadArchive(sessionFile); + if (!archive.header || archive.header.version !== 3 || archive.damaged) + throw new Error( + 'Continuing requires an intact Pi v3 archive. Open older archives in Pi to migrate them first.', + ); + if ( + req.entryId !== undefined && + req.entryId !== null && + typeof req.entryId !== 'string' + ) + throw new Error('Invalid selected entry.'); + const selected = selectedPath(archive, req.entryId).at(-1)?.id; + expectedEntryId = selected ?? null; + if (req.fork && !selected) + throw new Error('This session has no entry to fork.'); + if (selected && (req.fork || selected !== archive.entries.at(-1)?.id)) { + sessionFile = await forkArchive(sessionFile, selected); + extractedFork = true; + } + } catch (error) { + return { + error: + error instanceof Error ? error.message : 'Session unavailable.', + }; + } + } + + // Archive ownership is authoritative here, rather than inferred from a + // renderer snapshot that becomes stale as soon as a prompt advances Pi. + async function readRuntime(runtime: RpcSession, reused = false) { + const state = await runtime.getState(); + if (!state.success || runtime.launchError()) return { - error: 'session file outside pi sessions root or unavailable', + error: + state.error ?? + runtime.launchError() ?? + 'Could not read Pi session state.', }; + if (sessionFile && runtime.archiveFile !== sessionFile) + return { + error: + 'The open runtime switched archives. Select this archive again.', + }; + if ( + reused && + (state.data as { isStreaming?: boolean } | undefined)?.isStreaming + ) + return { + error: + 'This archive is running in an open tab. Stop it before resuming, or fork this selection.', + }; + if (expectedEntryId !== undefined) { + const position = await runtime.command({ + type: 'get_entries', + ...(expectedEntryId ? { since: expectedEntryId } : {}), + }); + if ( + !position.success || + !runtimeMatchesSelection(expectedEntryId, position.data) + ) + return { + error: + position.error ?? + 'The runtime branch moved since selection. Refresh history and select the branch again, or fork this entry.', + }; + } + const messages = await runtime.getMessages(); + if (!messages.success) + return { error: messages.error ?? 'Could not read Pi history.' }; + return { + sessionId: runtime.id, + state: state.data, + messages: + (messages.data as { messages?: unknown[] } | undefined)?.messages ?? + [], + }; + } + if (sessionFile && !extractedFork) { + for (const runtime of sessions.values()) { + // Extension commands can switch a runtime's archive. Refresh its real + // state before deciding whether this file already has a live owner. + const state = await runtime.getState(); + if (!state.success) { + if (runtime.launchError()) continue; + return { + error: `Could not verify archive ownership. Close the unresponsive session tab before resuming. ${state.error ?? ''}`, + }; + } + if (runtime.archiveFile === sessionFile) + return readRuntime(runtime, true); } } @@ -131,22 +233,12 @@ ipcMain.handle( } session.onEvent = (sessionId, event) => sendToRenderer('pidex:session:event', { sessionId, event }); - session.onExit = (sessionId) => + session.onExit = (sessionId) => { + sessions.delete(sessionId); sendToRenderer('pidex:session:exit', { sessionId }); + }; sessions.set(id, session); - if (req.fork && sessionFile) { - const cloned = await session.command({ type: 'clone' }); - if ( - !cloned.success || - (cloned.data as { cancelled?: boolean } | undefined)?.cancelled - ) { - sessions.delete(id); - session.dispose(); - return { error: cloned.error ?? 'Session fork was cancelled.' }; - } - } - if (!sessionFile) { const defaults = await sessionDefaults(cwd); if (defaults.model && defaults.provider) { @@ -174,23 +266,12 @@ ipcMain.handle( } } - const [state, messages] = await Promise.all([ - session.getState(), - session.getMessages(), - ]); - const launchError = session.launchError(); - if (launchError) { + const result = await readRuntime(session); + if ('error' in result) { sessions.delete(id); session.dispose(); - return { error: launchError }; } - return { - sessionId: id, - state: state.success ? state.data : null, - messages: messages.success - ? ((messages.data as { messages?: unknown[] })?.messages ?? []) - : [], - }; + return result; }, ); diff --git a/src/main/rpc.ts b/src/main/rpc.ts index 975dcec..46393ad 100644 --- a/src/main/rpc.ts +++ b/src/main/rpc.ts @@ -71,6 +71,7 @@ function childEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { class RpcSession { readonly id: string; readonly cwd: string; + archiveFile?: string; private proc: ChildProcess; private buffer = ''; private seq = 0; @@ -91,6 +92,7 @@ class RpcSession { ) { this.id = id; this.cwd = cwd; + this.archiveFile = sessionFile; const bin = resolvePi(); if (!bin) throw new Error(PI_MISSING); const args = ['--mode', 'rpc']; @@ -204,8 +206,42 @@ class RpcSession { return this.closed ? this.failed : null; } - getState() { - return this.command({ type: 'get_state' }); + async getState() { + const response = await this.command({ type: 'get_state' }); + const file = (response.data as { sessionFile?: unknown } | undefined) + ?.sessionFile; + if ( + response.success && + this.archiveFile && + (typeof file !== 'string' || !file) + ) + return { + ...response, + success: false, + error: 'Pi did not return its active archive path.', + }; + if (response.success && typeof file === 'string') { + try { + this.archiveFile = fs.realpathSync(file); + } catch { + try { + this.archiveFile = path.join( + fs.realpathSync(path.dirname(file)), + path.basename(file), + ); + } catch { + this.archiveFile = path.resolve(file); + } + } + } + if (response.success && typeof file === 'string') + response.data = { + ...(response.data as Record), + sessionFile: this.archiveFile, + }; + if (response.success && typeof file !== 'string') + this.archiveFile = undefined; + return response; } getMessages() { diff --git a/src/main/session-tree.ts b/src/main/session-tree.ts new file mode 100644 index 0000000..7ba674b --- /dev/null +++ b/src/main/session-tree.ts @@ -0,0 +1,239 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import { sessionPath } from './workbench'; +import { SESSIONS_ROOT } from './sessions'; + +type ArchiveEntry = Record & { + id: string; + type: string; + parentId?: unknown; +}; +export type EntryNode = { + id: string; + parentId: string | null; + depth: number; + label: string; + tip: boolean; + valid: boolean; +}; +export type SessionTree = { + nodes: EntryNode[]; + leafId: string | null; + warning?: string; + canContinue: boolean; +}; +export type Archive = { + header?: ArchiveEntry; + entries: ArchiveEntry[]; + damaged: boolean; +}; + +export function parseArchive(text: string): Archive { + const entries: ArchiveEntry[] = []; + let header: ArchiveEntry | undefined; + let damaged = false; + const ids = new Set(); + for (const line of text.split('\n')) { + if (!line.trim()) continue; + try { + const value = JSON.parse(line); + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + typeof value.id !== 'string' || + !value.id || + typeof value.type !== 'string' + ) { + damaged = true; + continue; + } + if (value.type === 'session') { + if (header) damaged = true; + header ??= value; + continue; + } + if (ids.has(value.id)) { + damaged = true; + continue; + } + ids.add(value.id); + entries.push(value); + } catch { + damaged = true; + } + } + return { header, entries, damaged }; +} + +export function selectedPath( + archive: Archive, + entryId?: string | null, +): ArchiveEntry[] { + if (entryId === null) { + if (archive.entries.length) + throw new Error( + 'This empty session has advanced. Select its branch again.', + ); + return []; + } + const byId = new Map(archive.entries.map((e) => [e.id, e])); + let current = + entryId === undefined ? archive.entries.at(-1) : byId.get(entryId); + if (entryId !== undefined && !current) + throw new Error('Selected entry no longer exists. Select it again.'); + const result: ArchiveEntry[] = []; + const seen = new Set(); + while (current) { + if (seen.has(current.id)) + throw new Error('This branch has cyclic lineage.'); + seen.add(current.id); + result.push(current); + if (current.parentId === null) break; + if (typeof current.parentId !== 'string' || !byId.has(current.parentId)) + throw new Error('This branch has missing lineage.'); + current = byId.get(current.parentId); + } + return result.reverse(); +} + +function label(entry: ArchiveEntry): string { + const message = entry.message as + { role?: string; content?: unknown } | undefined; + const content = message?.content; + const text = + typeof content === 'string' + ? content + : Array.isArray(content) + ? content + .filter((p) => p?.type === 'text') + .map((p) => p.text) + .join(' ') + : ''; + return `${message?.role ?? entry.type}: ${String( + text || entry.summary || entry.name || entry.modelId || '', + ) + .replace(/\s+/g, ' ') + .slice(0, 80)}`.replace(/: $/, ''); +} + +export function archiveTree(archive: Archive): SessionTree { + const byId = new Map(archive.entries.map((e) => [e.id, e])); + const children = new Map(); + const roots: ArchiveEntry[] = []; + for (const entry of archive.entries) { + if (typeof entry.parentId === 'string' && byId.has(entry.parentId)) { + const group = children.get(entry.parentId) ?? []; + group.push(entry); + children.set(entry.parentId, group); + } else roots.push(entry); + } + const nodes: EntryNode[] = []; + const seen = new Set(); + const walk = (root: ArchiveEntry, valid: boolean) => { + const stack = [{ entry: root, depth: 0 }]; + while (stack.length) { + const { entry, depth } = stack.pop()!; + if (seen.has(entry.id)) continue; + seen.add(entry.id); + nodes.push({ + id: entry.id, + parentId: typeof entry.parentId === 'string' ? entry.parentId : null, + depth, + label: label(entry), + tip: !children.has(entry.id), + valid, + }); + for (const child of [...(children.get(entry.id) ?? [])].reverse()) + stack.push({ entry: child, depth: depth + 1 }); + } + }; + for (const root of roots) walk(root, root.parentId === null); + for (const entry of archive.entries) + if (!seen.has(entry.id)) walk(entry, false); + return { + nodes, + canContinue: archive.header?.version === 3 && !archive.damaged, + leafId: archive.entries.at(-1)?.id ?? null, + warning: + archive.damaged || nodes.some((n) => !n.valid) + ? 'Archive has incomplete or invalid lineage. Invalid branches cannot be continued; damaged archives cannot be continued.' + : archive.header?.version !== 3 + ? 'Continuation requires Pi v3. Open this archive in Pi to migrate it first.' + : undefined, + }; +} + +export async function loadArchive( + file: string, + root = SESSIONS_ROOT, +): Promise { + const target = await sessionPath(file, root); + if ((await fs.stat(target)).size > 16 * 1024 * 1024) + throw new Error('This session is too large to browse.'); + return parseArchive(await fs.readFile(target, 'utf8')); +} + +export async function readSessionTree(file: string): Promise { + return archiveTree(await loadArchive(file)); +} + +/** Pi RPC cannot navigate an arbitrary entry. Extract a native v3 branch archive, + * retaining entry IDs and every record type (including compaction/tool metadata). + * Never rewrite the source. Opening this file uses Pi's supported --session flag. */ +export async function forkArchive( + file: string, + entryId: string, + root = SESSIONS_ROOT, +): Promise { + const source = await sessionPath(file, root); + const archive = await loadArchive(source, root); + if (!archive.header || archive.header.version !== 3 || archive.damaged) + throw new Error( + 'Forking requires an intact Pi v3 archive. Open older archives in Pi to migrate them first.', + ); + const entries = selectedPath(archive, entryId); + if (!entries.length) throw new Error('This session has no entry to fork.'); + const id = randomUUID(); + const timestamp = new Date().toISOString(); + const target = path.join( + path.dirname(source), + `${timestamp.replace(/[:.]/g, '-')}_${id}.jsonl`, + ); + await fs.writeFile( + target, + [{ ...archive.header, id, timestamp, parentSession: source }, ...entries] + .map((e) => JSON.stringify(e)) + .join('\n') + '\n', + { flag: 'wx' }, + ); + return target; +} + +/** Pi initializes model/thinking settings on open. Those entries may advance the + * leaf without changing the selected conversation. Reject all other movement, + * including messages, branching metadata, unknown records and broken chains. */ +export function runtimeMatchesSelection( + entryId: string | null, + data: unknown, +): boolean { + if (!data || typeof data !== 'object') return false; + const position = data as { entries?: unknown; leafId?: unknown }; + if (!Array.isArray(position.entries)) return false; + let parent = entryId; + for (const value of position.entries) { + if (!value || typeof value !== 'object') return false; + const entry = value as { type?: unknown; id?: unknown; parentId?: unknown }; + if ( + (entry.type !== 'model_change' && + entry.type !== 'thinking_level_change') || + typeof entry.id !== 'string' || + !entry.id || + entry.parentId !== parent + ) + return false; + parent = entry.id; + } + return position.leafId === parent; +} diff --git a/src/main/sessions.ts b/src/main/sessions.ts index e5e2257..200686a 100644 --- a/src/main/sessions.ts +++ b/src/main/sessions.ts @@ -24,6 +24,7 @@ type SessionInfo = { model: string; timestamp: string; messageCount: number; + parentSession?: string; }; function readLines(file: string, maxBytes = 256 * 1024): string[] { @@ -42,13 +43,14 @@ function readLines(file: string, maxBytes = 256 * 1024): string[] { function textOf(content: unknown): string { if (!Array.isArray(content)) return ''; return content - .filter(p => p && p.type === 'text' && typeof p.text === 'string') - .map(p => p.text as string) + .filter((p) => p && p.type === 'text' && typeof p.text === 'string') + .map((p) => p.text as string) .join('\n'); } function parseSession(file: string): SessionInfo | null { try { + file = fs.realpathSync(file); const lines = readLines(file); let id = ''; let cwd = ''; @@ -56,6 +58,7 @@ function parseSession(file: string): SessionInfo | null { let title = ''; let model = ''; let messageCount = 0; + let parentSession: string | undefined; for (const line of lines) { if (!line) continue; let entry: Record; @@ -66,6 +69,17 @@ function parseSession(file: string): SessionInfo | null { } if (entry.type === 'session') { id = String(entry.id ?? ''); + parentSession = + typeof entry.parentSession === 'string' + ? entry.parentSession + : undefined; + if (parentSession) { + try { + parentSession = fs.realpathSync(parentSession); + } catch { + /* A missing source remains visible. */ + } + } cwd = String(entry.cwd ?? ''); timestamp = String(entry.timestamp ?? ''); continue; @@ -76,21 +90,34 @@ function parseSession(file: string): SessionInfo | null { } if (entry.type === 'message') { messageCount += 1; - const message = entry.message as { role?: string; content?: unknown } | undefined; + const message = entry.message as + { role?: string; content?: unknown } | undefined; if (!title && message?.role === 'user') { - title = textOf(message.content).replace(/\s+/g, ' ').trim().slice(0, 90); + title = textOf(message.content) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 90); } } } if (!id) return null; - return { id, file, cwd, title: title || '(empty session)', model, timestamp, messageCount }; + return { + id, + file, + cwd, + title: title || '(empty session)', + model, + timestamp, + messageCount, + parentSession, + }; } catch { return null; } } function gitBranch(cwd: string): Promise { - return new Promise(resolve => { + return new Promise((resolve) => { execFile( 'git', ['-C', cwd, 'branch', '--show-current'], @@ -102,27 +129,30 @@ function gitBranch(cwd: string): Promise { }); } -async function listWorkspaces(extraCwds: string[] = []): Promise { +async function listWorkspaces( + extraCwds: string[] = [], +): Promise { const byDir = new Map(); if (fs.existsSync(SESSIONS_ROOT)) { const dirs = fs .readdirSync(SESSIONS_ROOT, { withFileTypes: true }) - .filter(d => d.isDirectory()) - .map(d => d.name); + .filter((d) => d.isDirectory()) + .map((d) => d.name); for (const dirName of dirs) { const dirPath = path.join(SESSIONS_ROOT, dirName); const files = fs .readdirSync(dirPath) - .filter(f => f.endsWith('.jsonl')) - .map(f => path.join(dirPath, f)); + .filter((f) => f.endsWith('.jsonl')) + .map((f) => path.join(dirPath, f)); if (files.length === 0) continue; let cwd = ''; let lastActive = ''; for (const file of files) { const stat = fs.statSync(file); - if (stat.mtime.toISOString() > lastActive) lastActive = stat.mtime.toISOString(); + if (stat.mtime.toISOString() > lastActive) + lastActive = stat.mtime.toISOString(); if (!cwd) { for (const line of readLines(file, 64 * 1024)) { if (!line) continue; @@ -151,7 +181,7 @@ async function listWorkspaces(extraCwds: string[] = []): Promise w.cwd)); + const knownCwds = new Set([...byDir.values()].map((w) => w.cwd)); for (const cwd of extraCwds) { if (knownCwds.has(cwd) || !fs.existsSync(cwd)) continue; const dirName = `--${cwd.replace(/\//g, '-')}--`; @@ -176,8 +206,8 @@ function listSessions(dirName: string): SessionInfo[] { if (!dirPath.startsWith(SESSIONS_ROOT) || !fs.existsSync(dirPath)) return []; const sessions = fs .readdirSync(dirPath) - .filter(f => f.endsWith('.jsonl')) - .map(f => parseSession(path.join(dirPath, f))) + .filter((f) => f.endsWith('.jsonl')) + .map((f) => parseSession(path.join(dirPath, f))) .filter((s): s is SessionInfo => s !== null); sessions.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); return sessions; diff --git a/src/main/workbench-ipc.ts b/src/main/workbench-ipc.ts index e9c397e..aad931d 100644 --- a/src/main/workbench-ipc.ts +++ b/src/main/workbench-ipc.ts @@ -1,3 +1,4 @@ +import { loadArchive, selectedPath, readSessionTree } from './session-tree'; import { app, dialog, ipcMain, shell, type BrowserWindow } from 'electron'; import fs from 'fs/promises'; import os from 'os'; @@ -11,7 +12,6 @@ import { inside, inspectWorkspace, listSkills, - readSession, readText, validateDefaults, workspacePath, @@ -111,7 +111,12 @@ export function registerWorkbench( } else throw new Error('Unsupported Git action.'); return inspectWorkspace(cwd); }); - handle('session:preview', async (file) => readSession(file)); + handle('session:tree', async (file) => readSessionTree(file)); + handle('session:preview', async (file, entryId) => + selectedPath(await loadArchive(file), entryId) + .filter((e) => e.type === 'message') + .map((e) => e.message), + ); handle('workspace:skills', async (cwd) => listSkills(await knownWorkspace(cwd)), ); diff --git a/src/main/workbench.ts b/src/main/workbench.ts index 9b63c94..f892fe0 100644 --- a/src/main/workbench.ts +++ b/src/main/workbench.ts @@ -259,45 +259,10 @@ export async function readSession( file: string, sessionsRoot = SESSIONS_ROOT, ): Promise { - const target = await sessionPath(file, sessionsRoot); - const stat = await fs.stat(target); - if (stat.size > 16 * MAX_TEXT) - throw new Error( - 'This session is too large to preview. Resume it to continue.', - ); - const records: { - id?: string; - parentId?: string; - type?: string; - message?: unknown; - }[] = []; - for (const line of (await fs.readFile(target, 'utf8')).split('\n')) { - try { - const record = JSON.parse(line); - if ( - record && - typeof record === 'object' && - !Array.isArray(record) && - typeof record.id === 'string' - ) - records.push(record); - } catch { - /* Partial final writes are expected. */ - } - } - // Follow the current branch rather than mixing messages from abandoned branches. - const byId = new Map(records.filter((r) => r.id).map((r) => [r.id, r])); - let current = records.at(-1); - const branch: unknown[] = []; - const seen = new Set(); - while (current) { - if (current.id && seen.has(current.id)) break; - if (current.id) seen.add(current.id); - if (current.type === 'message' && current.message) - branch.unshift(current.message); - current = current.parentId ? byId.get(current.parentId) : undefined; - } - return branch; + const { loadArchive, selectedPath } = await import('./session-tree.js'); + return selectedPath(await loadArchive(file, sessionsRoot)) + .filter((entry) => entry.type === 'message') + .map((entry) => entry.message); } export async function listSkills(cwd: string): Promise { diff --git a/src/preload/index.ts b/src/preload/index.ts index bdfca0c..d7817f8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -21,8 +21,9 @@ contextBridge.exposeInMainWorld('pidex', { ipcRenderer.invoke('pidex:workspace:patch', cwd, file, staged, untracked), gitAction: (cwd: string, action: string, file?: string) => ipcRenderer.invoke('pidex:workspace:git', cwd, action, file), - previewSession: (file: string) => - ipcRenderer.invoke('pidex:session:preview', file), + sessionTree: (file: string) => ipcRenderer.invoke('pidex:session:tree', file), + previewSession: (file: string, entryId?: string | null) => + ipcRenderer.invoke('pidex:session:preview', file, entryId), skills: (cwd: string) => ipcRenderer.invoke('pidex:workspace:skills', cwd), openProject: (cwd: string) => ipcRenderer.invoke('pidex:workspace:open', cwd), attachFiles: () => ipcRenderer.invoke('pidex:workspace:attach'), @@ -34,8 +35,12 @@ contextBridge.exposeInMainWorld('pidex', { setModel: (id: string, provider: string, model: string) => ipcRenderer.invoke('pidex:session:model', id, provider, model), sessions: (dirName: string) => ipcRenderer.invoke('pidex:sessions', dirName), - openSession: (req: { cwd: string; sessionFile?: string; fork?: boolean }) => - ipcRenderer.invoke('pidex:session/open', req), + openSession: (req: { + cwd: string; + sessionFile?: string; + entryId?: string | null; + fork?: boolean; + }) => ipcRenderer.invoke('pidex:session/open', req), sendPrompt: (req: { sessionId: string; text: string; streaming: boolean }) => ipcRenderer.invoke('pidex:session/send', req), abortSession: (sessionId: string) => diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index fed8373..6155403 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,3 +1,4 @@ +import { SessionTree, EntryTree } from './components/SessionTree'; import { useEffect, useState } from 'react'; import { PidexMark } from './components/PidexMark'; import welcomeMark from './assets/workbench/welcome-mark.svg'; @@ -69,7 +70,10 @@ function App() { onModel={b.changeModel} running={view === 'session' && active?.running} disabled={ - !ws || (view === 'session' && active?.exited) || b.previewLoading + !ws || + (view === 'session' && active?.exited) || + b.previewLoading || + (view === 'history' && !preview?.ready) } busy={b.busy} placeholder={placeholder} @@ -499,6 +503,12 @@ function App() { > Review {details.changes.length} files + {active.startedAt && ( +

+ Opened at entry {active.startedAt.slice(0, 8)}. The + live branch advances as you send messages. +

+ )} Activity

{active.entries.filter((e) => e.kind === 'tool').length}{' '} @@ -539,39 +549,13 @@ function App() { label="Search sessions" />

- {(allHistory - ? filteredSessions - : filteredSessions.slice(0, 30) - ).map((s, i, arr) => { - const day = new Date(s.timestamp).toLocaleDateString(); - const prev = - i > 0 - ? new Date( - arr[i - 1].timestamp, - ).toLocaleDateString() - : ''; - return ( -
- {day !== prev && ( -

- {day === new Date().toLocaleDateString() - ? 'Today' - : day} -

- )} - -
- ); - })} + void b.showPreview(s)} + /> {!filteredSessions.length && (

No sessions found.

)} @@ -579,6 +563,13 @@ function App() { setAllHistory((v) => !v)}> {allHistory ? 'Show recent' : 'Show all sessions'} + {preview?.tree && ( + void b.showPreview(preview.info, id)} + /> + )}
{preview ? ( @@ -588,8 +579,17 @@ function App() {

{preview.info.title}

-

- {preview.info.model || 'Pi'} / saved locally +

+ {preview.info.model || 'Pi'} /{' '} + {preview.entryId + ? `Selected ${preview.entryId.slice(0, 8)}` + : 'Empty session'} + {preview.entryId === preview.tree?.leafId + ? ' / Archive tip' + : ''}

+ {preview.tree?.warning && ( +

+ {preview.tree.warning} +

+ )} + {preview.entryId && + preview.entryId !== preview.tree?.leafId && ( +

+ Pi RPC cannot resume an earlier entry in place. + Continuing creates a new fork from this selection + and preserves the source. +

+ )} {b.previewLoading ? ( @@ -611,7 +624,12 @@ function App() { <>
Fork from here @@ -623,7 +641,13 @@ function App() { )} - {composer('Continue this session…', 'Resume')} + {composer( + 'Continue selected branch…', + preview.entryId && + preview.entryId !== preview.tree?.leafId + ? 'Resume in new fork' + : 'Resume', + )} ) : ( diff --git a/src/renderer/api.ts b/src/renderer/api.ts index 840fd8f..f0e7a9a 100644 --- a/src/renderer/api.ts +++ b/src/renderer/api.ts @@ -1,3 +1,4 @@ +import type { SessionTree } from '../main/session-tree'; import type { Defaults, GitChange, @@ -30,6 +31,7 @@ type SessionInfo = { model: string; timestamp: string; messageCount: number; + parentSession?: string; }; type SessionState = { @@ -77,7 +79,11 @@ type PidexApi = { action: 'stage' | 'stage-all' | 'unstage' | 'commit', file?: string, ) => Promise>; - previewSession: (file: string) => Promise>; + sessionTree: (file: string) => Promise>; + previewSession: ( + file: string, + entryId?: string | null, + ) => Promise>; skills: (cwd: string) => Promise>; openProject: (cwd: string) => Promise>; attachFiles: () => Promise>; @@ -94,6 +100,7 @@ type PidexApi = { openSession: (req: { cwd: string; sessionFile?: string; + entryId?: string | null; fork?: boolean; }) => Promise; sendPrompt: (req: { diff --git a/src/renderer/components/SessionTree.tsx b/src/renderer/components/SessionTree.tsx new file mode 100644 index 0000000..e641882 --- /dev/null +++ b/src/renderer/components/SessionTree.tsx @@ -0,0 +1,246 @@ +import { useEffect, useRef, type KeyboardEvent } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import type { SessionInfo } from '../api'; +import type { SessionTree as Tree } from '../../main/session-tree'; + +function treeKeys(event: KeyboardEvent) { + const items = [ + ...event.currentTarget.querySelectorAll( + '[role="treeitem"]', + ), + ]; + const index = items.indexOf(document.activeElement as HTMLButtonElement); + const next = + event.key === 'ArrowDown' + ? index + 1 + : event.key === 'ArrowUp' + ? index - 1 + : event.key === 'Home' + ? 0 + : event.key === 'End' + ? items.length - 1 + : -1; + if (next >= 0 && next < items.length) { + event.preventDefault(); + items[next].focus(); + } +} + +export function sessionForest(sessions: SessionInfo[]) { + const byFile = new Map(sessions.map((s) => [s.file, s])); + const rows: { + info: SessionInfo; + depth: number; + missing: boolean; + cyclic: boolean; + }[] = []; + const seen = new Set(); + const children = new Map(); + for (const s of sessions) { + if (s.parentSession && byFile.has(s.parentSession)) { + const list = children.get(s.parentSession) ?? []; + list.push(s); + children.set(s.parentSession, list); + } + } + const walk = (root: SessionInfo) => { + const stack = [{ info: root, depth: 0 }]; + while (stack.length) { + const row = stack.pop()!; + if (seen.has(row.info.file)) continue; + seen.add(row.info.file); + const chain = new Set(); + let ancestor: SessionInfo | undefined = row.info; + while (ancestor && !chain.has(ancestor.file)) { + chain.add(ancestor.file); + ancestor = ancestor.parentSession + ? byFile.get(ancestor.parentSession) + : undefined; + } + rows.push({ + ...row, + cyclic: Boolean(ancestor), + missing: Boolean( + row.info.parentSession && !byFile.has(row.info.parentSession), + ), + }); + for (const child of [...(children.get(row.info.file) ?? [])].reverse()) + stack.push({ info: child, depth: row.depth + 1 }); + } + }; + for (const s of sessions) + if (!s.parentSession || !byFile.has(s.parentSession)) walk(s); + for (const s of sessions) if (!seen.has(s.file)) walk(s); + return rows; +} + +export function SessionTree({ + sessions, + query = '', + limit, + selectedFile, + onSelect, +}: { + sessions: SessionInfo[]; + query?: string; + limit?: number; + selectedFile?: string; + onSelect: (info: SessionInfo) => void; +}) { + const included = new Set(); + const byFile = new Map(sessions.map((s) => [s.file, s])); + for (const info of sessions + .filter((s) => + `${s.title} ${s.model}`.toLowerCase().includes(query.toLowerCase()), + ) + .slice(0, limit)) { + let current: SessionInfo | undefined = info; + while (current && !included.has(current.file)) { + included.add(current.file); + current = current.parentSession + ? byFile.get(current.parentSession) + : undefined; + } + } + return ( +
+ {sessionForest(sessions) + .filter((row) => included.has(row.info.file)) + .map(({ info, depth, missing, cyclic }) => ( + + ))} +
+ ); +} + +export function EntryTree({ + tree, + selectedId, + onSelect, +}: { + tree: Tree; + selectedId?: string | null; + onSelect: (id: string) => void; +}) { + const ref = useRef(null); + const virtual = useVirtualizer({ + count: tree.nodes.length, + getScrollElement: () => ref.current, + estimateSize: () => 54, + overscan: 8, + enabled: tree.nodes.length > 200, + }); + useEffect(() => { + if (tree.nodes.length > 200) { + const index = tree.nodes.findIndex((node) => node.id === selectedId); + if (index >= 0) virtual.scrollToIndex(index, { align: 'auto' }); + } + }, [selectedId, tree.nodes, virtual]); + const render = (index: number) => { + const node = tree.nodes[index]; + return ( + + ); + }; + return ( +
+ +
+ {tree.nodes.length > 200 ? ( +
+ {virtual.getVirtualItems().map((row) => ( +
+ {render(row.index)} +
+ ))} +
+ ) : ( + tree.nodes.map((node, index) => ( +
{render(index)}
+ )) + )} + {!tree.nodes.length && ( +

+ Empty session. Resume to start its first branch. +

+ )} +
+
+ ); +} diff --git a/src/renderer/useWorkbench.ts b/src/renderer/useWorkbench.ts index 0b7b311..4c65420 100644 --- a/src/renderer/useWorkbench.ts +++ b/src/renderer/useWorkbench.ts @@ -1,3 +1,4 @@ +import type { SessionTree } from '../main/session-tree'; import { useCallback, useEffect, useRef, useState } from 'react'; import { applyEvent, entriesFromMessages, type Entry } from './timeline'; import type { @@ -15,6 +16,7 @@ export type View = type SessionTab = { runtimeId: string; file?: string; + startedAt?: string; cwd: string; title: string; model: string; @@ -56,6 +58,9 @@ export function useWorkbench() { const [preview, setPreview] = useState<{ info: SessionInfo; entries: Entry[]; + tree?: SessionTree; + entryId?: string | null; + ready?: boolean; } | null>(null); const [previewLoading, setPreviewLoading] = useState(false); const [drafts, setDrafts] = useState>({}); @@ -76,7 +81,7 @@ export function useWorkbench() { tabs.find((t) => t.runtimeId === activeId && t.cwd === ws?.cwd) ?? null; const draftKey = view === 'history' - ? `preview:${preview?.info.file ?? ''}` + ? `preview:${preview?.info.file ?? ''}:${preview?.entryId ?? ''}` : active && view !== 'new' ? active.runtimeId : `new:${ws?.cwd ?? ''}`; @@ -155,6 +160,7 @@ export function useWorkbench() { setDetails(EMPTY_DETAILS); setSessions([]); setPreview(null); + setPreviewLoading(false); setModels([]); setModelsLoading(Boolean(ws)); setModelError(''); @@ -245,13 +251,14 @@ export function useWorkbench() { const openSession = async ( info?: SessionInfo, fork = false, + entryId?: string | null, ): Promise => { if (!ws || opening.current) return null; const existing = !fork && info && tabs.find((t) => t.cwd === ws.cwd && t.file === info.file && !t.exited); - if (existing) { + if (existing && entryId === undefined) { setActiveId(existing.runtimeId); return existing; } @@ -263,29 +270,46 @@ export function useWorkbench() { cwd: ws.cwd, sessionFile: info?.file, fork, + entryId, }); if (result.error || !result.sessionId) throw new Error(result.error ?? 'Could not open this session.'); const id = result.sessionId; if (currentCwd.current !== ws.cwd) { - await api.closeSession(id); + if (!tabs.some((t) => t.runtimeId === id)) await api.closeSession(id); return null; } // Mount before loading commands so live events cannot be lost while that request runs. const tab: SessionTab = { runtimeId: id, cwd: ws.cwd, - file: fork - ? result.state?.sessionFile - : (info?.file ?? result.state?.sessionFile), - title: info ? `${info.title}${fork ? ' (fork)' : ''}` : 'New session', + file: result.state?.sessionFile ?? info?.file, + startedAt: entryId ?? undefined, + title: info + ? `${info.title}${fork || (result.state?.sessionFile && result.state.sessionFile !== info.file) ? ' (fork)' : ''}` + : 'New session', model: result.state?.model?.id ?? info?.model ?? '', entries: entriesFromMessages(result.messages ?? []), commands: [], running: Boolean(result.state?.isStreaming), exited: exitedSessions.current.has(id), }; - setTabs((prev) => [...prev, tab]); + setTabs((prev) => + prev.some((t) => t.runtimeId === id) + ? prev.map((t) => + t.runtimeId === id + ? t.file !== tab.file + ? tab + : { + ...t, + running: tab.running, + model: tab.model, + exited: tab.exited, + } + : t, + ) + : [...prev, tab], + ); setActiveId(id); void api .sessionCommands(id) @@ -309,21 +333,34 @@ export function useWorkbench() { if (tab) setView('session'); }); const forkSession = () => - preview && + preview?.ready && + preview.tree?.canContinue && run(async () => { - const tab = await openSession(preview.info, true); + const tab = await openSession(preview.info, true, preview.entryId); if (tab) setView('session'); }); const showPreview = useCallback( - async (info: SessionInfo) => { + async (info: SessionInfo, entryId?: string) => { const seq = ++previewSeq.current; - setPreview({ info, entries: [] }); + setPreview({ info, entries: [], entryId }); setPreviewLoading(true); setError(''); try { - const messages = unwrap(await api.previewSession(info.file)); + const tree = unwrap(await api.sessionTree(info.file)); + const selectedId = entryId === undefined ? tree.leafId : entryId; + if (previewSeq.current !== seq) return; + setPreview({ info, tree, entryId: selectedId, entries: [] }); + const messages = unwrap( + await api.previewSession(info.file, selectedId), + ); if (previewSeq.current === seq) - setPreview({ info, entries: entriesFromMessages(messages) }); + setPreview({ + info, + tree, + entryId: selectedId, + ready: tree.canContinue, + entries: entriesFromMessages(messages), + }); } catch (e) { if (previewSeq.current === seq) report(e); } finally { @@ -340,12 +377,23 @@ export function useWorkbench() { const send = (raw?: string) => run(async () => { const text = (raw ?? draft).trim(); - if (!text || busy || !ws || sending.current) return; + if ( + !text || + busy || + !ws || + sending.current || + (view === 'history' && !preview?.ready) + ) + return; sending.current = true; try { let tab = view === 'new' ? null : active; if (view === 'history' && preview) - tab = await openSession(preview.info); + tab = await openSession( + preview.info, + preview.entryId !== preview.tree?.leafId, + preview.entryId, + ); else if (!tab || tab.exited) tab = await openSession(); if (!tab || tab.exited) return; const key = `local-${crypto.randomUUID()}`; diff --git a/src/renderer/workbench.css b/src/renderer/workbench.css index 29ed0e6..6648992 100644 --- a/src/renderer/workbench.css +++ b/src/renderer/workbench.css @@ -790,8 +790,9 @@ html[data-reduced-motion='true'] .wb-shell *::after { color: var(--copy); } .wb-history-list { - flex: 1; - min-height: 0; + flex: 0 0 auto; + min-height: 100px; + max-height: 30vh; overflow-y: auto; } .wb-day-label { @@ -1458,3 +1459,71 @@ html[data-reduced-motion='true'] .wb-shell *::after { scroll-behavior: auto !important; } } + +.wb-entry-tree { + flex-shrink: 0; + border-top: 1px solid var(--line, #30343a); + margin-top: 12px; + padding-top: 12px; +} +.wb-entry-tree label { + display: grid; + gap: 6px; + padding: 0 10px 10px; +} +.wb-entry-tree select { + width: 100%; + min-width: 0; + background: var(--surface, #171b20); + color: inherit; + border: 1px solid var(--line, #30343a); + padding: 7px; + font: inherit; +} +.wb-entry-scroll { + max-height: 360px; + overflow: auto; +} +.wb-entry-node { + display: grid; + gap: 4px; + width: 100%; + height: 54px; + text-align: left; + padding: 8px; + border: 0; + border-left: 2px solid transparent; + background: transparent; + color: inherit; + cursor: pointer; +} +.wb-entry-node span, +.wb-entry-node small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.wb-entry-node small { + opacity: 0.65; + font-size: 10px; +} +.wb-entry-node:hover { + background: #ffffff08; +} +.wb-entry-node.is-active { + background: #ffffff0d; + border-left-color: #99b9a5; +} +.wb-branch-notice { + font-size: 12px; + line-height: 1.5; + padding: 12px 24px; + margin: 0; + border-bottom: 1px solid #ffffff12; + color: #b9c7bd; +} +.wb-history-heading .wb-meta { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/tests/e2e/app.test.ts b/tests/e2e/app.test.ts index 09b28ea..0571e99 100644 --- a/tests/e2e/app.test.ts +++ b/tests/e2e/app.test.ts @@ -94,7 +94,13 @@ describe('Figma workbench in Electron', () => { Date.now() - (i + 1) * 15 * 60_000, ).toISOString(); const records = [ - { type: 'session', id: `session-${i}`, timestamp, cwd: project }, + { + type: 'session', + version: 3, + id: `session-${i}`, + timestamp, + cwd: project, + }, { type: 'model_change', id: 'm', @@ -245,6 +251,7 @@ describe('Figma workbench in Electron', () => { .filter((c) => c.type === 'get_state').length; await win.getByRole('button', { name: /^Sessions/ }).click(); await win + .locator('.wb-transcript') .getByText('The composer now keeps a visible focus ring', { exact: false, }) @@ -357,11 +364,13 @@ describe('Figma workbench in Electron', () => { .getByRole('heading', { name: 'Fix composer focus (fork)', exact: true }) .waitFor(); expect(await fs.readFile(source, 'utf8')).toBe(before); + const forks = await fs.readdir(path.dirname(source)); + const fork = forks.find( + (file) => !['0.jsonl', '1.jsonl', '2.jsonl'].includes(file), + ); + expect(fork).toBeTruthy(); expect( - await fs.readFile( - path.join(fixtureRoot, 'sessions', 'workspace', 'fork.jsonl'), - 'utf8', - ), + await fs.readFile(path.join(path.dirname(source), fork!), 'utf8'), ).toContain('The composer now keeps'); }); diff --git a/tests/e2e/session-tree.test.ts b/tests/e2e/session-tree.test.ts new file mode 100644 index 0000000..702bc36 --- /dev/null +++ b/tests/e2e/session-tree.test.ts @@ -0,0 +1,511 @@ +import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'; +import { + _electron as electron, + type ElectronApplication, + type Page, +} from 'playwright-core'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +const ROOT = path.resolve(__dirname, '../..'); +let app: ElectronApplication; +let page: Page; +let fixture: string; +let source: string; +let switched: string; +let log: string; +const errors: string[] = []; +const proofDir = process.env.PIDEX_BRANCH_PROOF_DIR; +let proofVideo: ReturnType; +const message = ( + id: string, + parentId: string | null, + role: string, + text: string, +) => ({ + type: 'message', + id, + parentId, + message: { role, content: [{ type: 'text', text }] }, +}); +const readLog = async () => + (await fs.readFile(log, 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + +describe('session branches in Electron', () => { + beforeAll(async () => { + fixture = await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-branches-')); + const project = path.join(fixture, 'project'); + const archives = path.join(fixture, 'sessions/workspace'); + await Promise.all([ + fs.mkdir(project), + fs.mkdir(archives, { recursive: true }), + fs.mkdir(path.join(fixture, 'user-data')), + ]); + source = path.join(archives, 'source.jsonl'); + log = path.join(fixture, 'rpc.jsonl'); + await fs.writeFile( + source, + [ + { + type: 'session', + version: 3, + id: 'source', + cwd: project, + timestamp: '2026-11-05T01:00:00Z', + }, + message('task', null, 'user', 'Explore parser branches'), + message( + 'old-answer', + 'task', + 'assistant', + 'The original branch uses a streaming parser.', + ), + message( + 'old-followup', + 'old-answer', + 'user', + 'Keep the streaming approach', + ), + message( + 'old-tip', + 'old-followup', + 'assistant', + 'Streaming branch is ready.', + ), + message( + 'new-tip', + 'task', + 'assistant', + 'The latest branch uses a buffered parser.', + ), + ] + .map((r) => JSON.stringify(r)) + .join('\n') + '\n', + ); + await fs.writeFile( + path.join(archives, 'empty.jsonl'), + JSON.stringify({ + type: 'session', + version: 3, + id: 'empty', + cwd: project, + timestamp: '2026-11-04T01:00:00Z', + }) + '\n', + ); + switched = path.join(archives, 'switched.jsonl'); + await fs.writeFile( + switched, + [ + { + type: 'session', + version: 3, + id: 'switched', + cwd: project, + timestamp: '2026-11-03T01:00:00Z', + }, + message('b-user', null, 'user', 'Alternate archive after switch'), + message( + 'b-tip', + 'b-user', + 'assistant', + 'Only the alternate archive history belongs here.', + ), + ] + .map((record) => JSON.stringify(record)) + .join('\n') + '\n', + ); + app = await electron.launch({ + executablePath: require('electron'), + args: ['.'], + cwd: ROOT, + recordVideo: proofDir + ? { + dir: path.join(fixture, 'video'), + size: { width: 1280, height: 840 }, + } + : undefined, + env: { + ...process.env, + PIDEX_E2E_CWD: project, + PIDEX_E2E_RENDERER_URL: inject('rendererUrl'), + PIDEX_E2E_USER_DATA: path.join(fixture, 'user-data'), + PIDEX_SESSIONS_ROOT: path.join(fixture, 'sessions'), + PIDEX_PI: path.join(ROOT, 'tests/fixtures/pi-rpc.cjs'), + PIDEX_RPC_LOG: log, + PIDEX_RPC_SWITCH_FILE: switched, + }, + }); + page = await app.firstWindow(); + proofVideo = page.video(); + if (proofDir) await fs.mkdir(proofDir, { recursive: true }); + page.setDefaultTimeout(15000); + await page.setViewportSize({ width: 1100, height: 700 }); + page.on('pageerror', (error) => errors.push(error.message)); + await page + .getByRole('heading', { name: 'Start a session', exact: true }) + .waitFor(); + }); + afterAll(async () => { + await app?.close(); + if (proofDir && proofVideo) + await proofVideo.saveAs( + path.join(proofDir, 'session-tree-navigation.webm'), + ); + if (fixture) await fs.rm(fixture, { recursive: true, force: true }); + }); + + it('previews selected branch and ancestor, forks exact selection and preserves both source branches', async () => { + const original = await fs.readFile(source, 'utf8'); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .locator('.wb-transcript') + .getByText('The latest branch uses a buffered parser.') + .waitFor(); + await page.getByLabel('Branch tip').selectOption('old-tip'); + await page + .locator('.wb-transcript') + .getByText('Streaming branch is ready.') + .waitFor(); + expect( + await page + .locator('.wb-transcript') + .getByText('The latest branch uses a buffered parser.') + .count(), + ).toBe(0); + expect( + await page + .getByRole('treeitem', { name: /Streaming branch is ready/ }) + .getAttribute('aria-selected'), + ).toBe('true'); + if (proofDir) + await page.screenshot({ + path: path.join(proofDir, 'selected-branch.png'), + }); + // Select an ancestor, not just a leaf. Preview and fork must stop here. + await page + .getByRole('treeitem', { name: /assistant: The original branch/ }) + .click(); + await page + .locator('.wb-transcript') + .getByText('The original branch uses a streaming parser.') + .waitFor(); + expect( + await page + .locator('.wb-transcript') + .getByText('Streaming branch is ready.') + .count(), + ).toBe(0); + await page + .getByRole('button', { name: 'Fork from here', exact: true }) + .click(); + await page + .getByRole('heading', { + name: 'Explore parser branches (fork)', + exact: true, + }) + .waitFor(); + if (proofDir) + await page.screenshot({ + path: path.join(proofDir, 'selected-entry-fork.png'), + }); + const stateRequest = (await readLog()) + .filter((c) => c.type === 'get_state' && c.sessionFile) + .at(-1); + const forkPath = stateRequest.sessionFile as string; + expect(forkPath).not.toBe(await fs.realpath(source)); + const forkRecords = (await fs.readFile(forkPath, 'utf8')) + .trim() + .split('\n') + .filter(Boolean) + .map((l) => JSON.parse(l)); + expect(forkRecords[0].parentSession).toBe(await fs.realpath(source)); + expect( + forkRecords + .slice(1) + .filter((e) => e.type === 'message') + .map((e) => e.id), + ).toEqual(['task', 'old-answer']); + expect(await fs.readFile(source, 'utf8')).toBe(original); + await page.getByLabel('Message pi').fill('Continue the fork'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + expect( + (await readLog()).filter((c) => c.type === 'prompt').at(-1).sessionFile, + ).toBe(forkPath); + expect(await fs.readFile(source, 'utf8')).toBe(original); + }); + + it('continues an older tip as an explicit new fork and resumes the archive tip in place', async () => { + const original = await fs.readFile(source, 'utf8'); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem') + .filter({ hasText: 'Explore parser branches' }) + .first() + .click(); + await page.getByLabel('Branch tip').selectOption('old-tip'); + await page + .locator('.wb-transcript') + .getByText('Streaming branch is ready.') + .waitFor(); + await page.getByLabel('Message pi').fill('Continue older tip'); + await page + .getByRole('button', { name: 'Resume in new fork', exact: true }) + .click(); + await page + .getByRole('heading', { + name: 'Explore parser branches (fork)', + exact: true, + }) + .waitFor(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + const prompt = (await readLog()).filter((c) => c.type === 'prompt').at(-1); + expect(prompt.sessionFile).not.toBe(await fs.realpath(source)); + const content = await fs.readFile(prompt.sessionFile, 'utf8'); + expect(content).toContain('old-tip'); + expect(content).not.toContain('new-tip'); + expect(await fs.readFile(source, 'utf8')).toBe(original); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem') + .filter({ hasText: 'Explore parser branches' }) + .first() + .click(); + await page.getByLabel('Branch tip').selectOption('new-tip'); + await page + .locator('.wb-transcript') + .getByText('The latest branch uses a buffered parser.') + .waitFor(); + await page.getByLabel('Message pi').fill('Continue latest tip'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('heading', { name: 'Explore parser branches', exact: true }) + .waitFor(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + expect( + (await readLog()).filter((c) => c.type === 'prompt').at(-1).sessionFile, + ).toBe(await fs.realpath(source)); + const resumed = await fs.readFile(source, 'utf8'); + expect(resumed.startsWith(original)).toBe(true); + expect(resumed).toContain('"parentId":"new-tip"'); + expect(resumed).toContain('old-tip'); + expect(errors).toEqual([]); + }); + it('reuses the live archive owner after sending and reselecting its latest tip', async () => { + const canonical = await fs.realpath(source); + const previous = (await readLog()) + .filter((c) => c.type === 'prompt' && c.sessionFile === canonical) + .at(-1); + const tabsBefore = await page + .locator('.wb-sidebar .wb-session-item') + .filter({ hasText: 'this window' }) + .count(); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem') + .filter({ hasText: 'Explore parser branches' }) + .first() + .click(); + await page + .locator('.wb-transcript') + .getByText('Continue latest tip', { exact: true }) + .waitFor(); + await page.getByLabel('Message pi').fill('Resume this live branch again'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('heading', { name: 'Explore parser branches', exact: true }) + .waitFor(); + await page.getByRole('button', { name: 'Stop', exact: true }).waitFor(); + const records = (await fs.readFile(source, 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + const blocked = await page.evaluate( + async ({ cwd, source, entryId }) => + window.pidex.openSession({ cwd, sessionFile: source, entryId }), + { + cwd: records[0].cwd as string, + source, + entryId: records.at(-1).id as string, + }, + ); + expect(blocked.error).toContain('running in an open tab'); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + const prompts = (await readLog()).filter( + (c) => c.type === 'prompt' && c.sessionFile === canonical, + ); + expect(prompts.at(-1).runtimePid).toBe(previous.runtimePid); + expect(new Set(prompts.map((c) => c.runtimePid)).size).toBe(1); + expect( + await page + .locator('.wb-sidebar .wb-session-item') + .filter({ hasText: 'this window' }) + .count(), + ).toBe(tabsBefore); + expect(await fs.readFile(source, 'utf8')).toContain( + 'Resume this live branch again', + ); + expect(errors).toEqual([]); + }); + + it('handles empty sessions and rejects stale entry selections without opening an unrelated leaf', async () => { + const original = await fs.readFile(source, 'utf8'); + const cwd = JSON.parse(original.split('\n')[0]).cwd as string; + const before = (await readLog()).filter( + (c) => c.type === 'get_state', + ).length; + const stale = await page.evaluate( + async ({ cwd, source }) => + window.pidex.openSession({ + cwd, + sessionFile: source, + entryId: 'missing-entry', + }), + { cwd, source }, + ); + expect(stale.error).toContain('no longer exists'); + const staleEmpty = await page.evaluate( + async ({ cwd, source }) => + window.pidex.openSession({ cwd, sessionFile: source, entryId: null }), + { cwd, source }, + ); + expect(staleEmpty.error).toContain('has advanced'); + expect((await readLog()).filter((c) => c.type === 'get_state').length).toBe( + before, + ); + expect(await fs.readFile(source, 'utf8')).toBe(original); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page.getByRole('treeitem', { name: /\(empty session\)/ }).click(); + await page + .getByText('Empty session. Resume to start its first branch.') + .waitFor(); + expect( + await page + .getByRole('button', { name: 'Fork from here', exact: true }) + .isDisabled(), + ).toBe(true); + await page.getByLabel('Message pi').fill('Start empty branch'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('heading', { name: '(empty session)', exact: true }) + .waitFor(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + expect( + (await readLog()).filter((c) => c.type === 'prompt').at(-1).sessionFile, + ).toBe(await fs.realpath(path.join(path.dirname(source), 'empty.jsonl'))); + expect(errors).toEqual([]); + }); + it('refreshes identity and history when an existing runtime switched archives', async () => { + const canonicalSource = await fs.realpath(source); + const sourcePid = (await readLog()) + .filter((c) => c.type === 'prompt' && c.sessionFile === canonicalSource) + .at(-1)?.runtimePid; + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem') + .filter({ hasText: 'Explore parser branches' }) + .first() + .click(); + await page.getByLabel('Message pi').fill('Switch archive for test'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('heading', { name: 'Explore parser branches', exact: true }) + .waitFor(); + await page.getByRole('button', { name: 'Send', exact: true }).waitFor(); + const tabsBefore = await page + .locator('.wb-sidebar .wb-session-item') + .filter({ hasText: 'this window' }) + .count(); + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem', { name: /Alternate archive after switch/ }) + .click(); + await page + .locator('.wb-transcript') + .getByText('Only the alternate archive history belongs here.') + .waitFor(); + await page.getByLabel('Message pi').fill('Continue the switched archive'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('heading', { + name: 'Alternate archive after switch', + exact: true, + }) + .waitFor(); + await page + .locator('.wb-transcript') + .getByText('Only the alternate archive history belongs here.') + .waitFor(); + expect( + await page + .locator('.wb-transcript') + .getByText('The latest branch uses a buffered parser.') + .count(), + ).toBe(0); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + const prompt = (await readLog()) + .filter( + (c) => + c.type === 'prompt' && c.message === 'Continue the switched archive', + ) + .at(-1); + expect(prompt.runtimePid).toBe(sourcePid); + expect(prompt.sessionFile).toBe(await fs.realpath(switched)); + expect( + await page + .locator('.wb-sidebar .wb-session-item') + .filter({ hasText: 'this window' }) + .count(), + ).toBe(tabsBefore); + expect(errors).toEqual([]); + }); + + it('fails closed on an unresponsive owner without launching a competing writer', async () => { + await page.getByLabel('Message pi').fill('Simulate owner state failure'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await page.getByRole('button', { name: 'Stop', exact: true }).click(); + const before = await fs.readFile(switched, 'utf8'); + const canonical = await fs.realpath(switched); + const ownerPid = (await readLog()) + .filter((c) => c.type === 'prompt' && c.sessionFile === canonical) + .at(-1).runtimePid; + const promptCount = (await readLog()).filter( + (c) => c.type === 'prompt', + ).length; + await page.getByRole('button', { name: /^Sessions/ }).click(); + await page + .getByRole('tree', { name: 'Session archives', exact: true }) + .getByRole('treeitem', { name: /Alternate archive after switch/ }) + .click(); + await page + .getByLabel('Message pi') + .fill('This must not start a second writer'); + await page.getByRole('button', { name: 'Resume', exact: true }).click(); + await page + .getByRole('alert') + .getByText(/Could not verify archive ownership/) + .waitFor(); + expect(await fs.readFile(switched, 'utf8')).toBe(before); + const records = await readLog(); + expect(records.filter((c) => c.type === 'prompt')).toHaveLength( + promptCount, + ); + expect( + new Set( + records + .filter((c) => c.sessionFile === canonical) + .map((c) => c.runtimePid), + ), + ).toEqual(new Set([ownerPid])); + expect(errors).toEqual([]); + }); +}); diff --git a/tests/fixtures/long-session.ts b/tests/fixtures/long-session.ts index 75e07d7..fc38d33 100644 --- a/tests/fixtures/long-session.ts +++ b/tests/fixtures/long-session.ts @@ -3,6 +3,7 @@ export function longSession(cwd: string, turns = 1500) { const records: object[] = [ { type: 'session', + version: 3, id: 'long-session', cwd, timestamp: new Date().toISOString(), diff --git a/tests/fixtures/pi-rpc.cjs b/tests/fixtures/pi-rpc.cjs index 24de792..89e3866 100755 --- a/tests/fixtures/pi-rpc.cjs +++ b/tests/fixtures/pi-rpc.cjs @@ -6,16 +6,45 @@ const args = process.argv.slice(2); let file = args.includes('--session') ? args[args.indexOf('--session') + 1] : undefined; -let messages = file +// Match Pi: loading a file chooses its last non-header entry and follows +// parentId, rather than combining abandoned branches into one transcript. +let records = file ? fs .readFileSync(file, 'utf8') .trim() .split('\n') + .filter(Boolean) .map((line) => JSON.parse(line)) - .filter((entry) => entry.type === 'message') - .map((entry) => entry.message) : []; +const branchMessages = () => { + const entries = records.filter((entry) => entry.type !== 'session'); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + const branch = []; + const seen = new Set(); + let current = entries.at(-1); + while (current && !seen.has(current.id)) { + seen.add(current.id); + if (current.type === 'message') branch.unshift(current.message); + current = byId.get(current.parentId); + } + return branch; +}; +// Pi can append setup metadata while opening a session. Model that behavior +// so forks must avoid loading the source, and position checks allow only setup. +if (file) { + const entry = { + type: 'thinking_level_change', + id: `setup-${process.pid}`, + parentId: records.filter((e) => e.type !== 'session').at(-1)?.id ?? null, + thinkingLevel: 'medium', + }; + records.push(entry); + fs.appendFileSync(file, '\n' + JSON.stringify(entry) + '\n'); +} +let messages = branchMessages(); +let promptSeq = 0; let streaming = false; +let failState = false; let model = { id: 'claude-sonnet', name: 'Claude Sonnet', @@ -37,11 +66,36 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { if (process.env.PIDEX_RPC_LOG) fs.appendFileSync( process.env.PIDEX_RPC_LOG, - JSON.stringify(command) + '\n', + JSON.stringify({ + ...command, + sessionFile: file, + runtimePid: process.pid, + }) + '\n', ); switch (command.type) { case 'get_state': - reply({ model, isStreaming: streaming, sessionFile: file }); + if (failState) + output({ + type: 'response', + id: command.id, + command: command.type, + success: false, + error: 'Fixture state refresh failure.', + }); + else reply({ model, isStreaming: streaming, sessionFile: file }); + break; + case 'get_entries': + { + const entries = records.filter((e) => e.type !== 'session'); + const index = command.since + ? entries.findIndex((e) => e.id === command.since) + : -1; + reply({ + entries: entries.slice(index + 1), + leafId: entries.at(-1)?.id ?? null, + }); + } + break; case 'get_messages': reply({ messages }); @@ -90,6 +144,7 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { const entries = [ { type: 'session', + version: 3, id: 'fork', timestamp: new Date().toISOString(), cwd: process.cwd(), @@ -114,6 +169,23 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { reply(); break; case 'prompt': { + if ( + command.message === 'Switch archive for test' && + process.env.PIDEX_RPC_SWITCH_FILE + ) { + file = fs.realpathSync(process.env.PIDEX_RPC_SWITCH_FILE); + records = fs + .readFileSync(file, 'utf8') + .trim() + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + messages = branchMessages(); + reply(); + event('agent_end'); + break; + } + if (command.message === 'Simulate owner state failure') failState = true; if (command.message === 'reject this prompt') { output({ type: 'response', @@ -124,10 +196,23 @@ readline.createInterface({ input: process.stdin }).on('line', (line) => { }); break; } - messages.push({ + const message = { role: 'user', content: [{ type: 'text', text: command.message }], - }); + }; + if (file) { + const entry = { + type: 'message', + id: `prompt-${process.pid}-${++promptSeq}`, + parentId: + records.filter((e) => e.type !== 'session').at(-1)?.id ?? null, + message, + }; + records.push(entry); + // Accept fixtures with or without a final newline. + fs.appendFileSync(file, '\n' + JSON.stringify(entry) + '\n'); + } + messages.push(message); reply(); streaming = true; event('agent_start'); diff --git a/tests/integration/SessionTree.test.tsx b/tests/integration/SessionTree.test.tsx new file mode 100644 index 0000000..c9c7d48 --- /dev/null +++ b/tests/integration/SessionTree.test.tsx @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from '@testing-library/react'; +import { + EntryTree, + SessionTree, + sessionForest, +} from '../../src/renderer/components/SessionTree'; +import type { SessionInfo } from '../../src/renderer/api'; +import type { SessionTree as Tree } from '../../src/main/session-tree'; +const info = (file: string, parentSession?: string): SessionInfo => ({ + id: file, + file, + title: file, + parentSession, + model: 'pi', + timestamp: '', + messageCount: 2, +}); +const tree: Tree = { + leafId: 'new', + canContinue: true, + nodes: [ + { + id: 'root', + parentId: null, + depth: 0, + label: 'user: Task', + tip: false, + valid: true, + }, + { + id: 'old', + parentId: 'root', + depth: 1, + label: 'assistant: Old answer', + tip: true, + valid: true, + }, + { + id: 'new', + parentId: 'root', + depth: 1, + label: 'assistant: New answer', + tip: true, + valid: true, + }, + ], +}; +describe('session tree navigation', () => { + afterEach(cleanup); + it('nests fork archives, preserves source context when searching and exposes selection', () => { + const onSelect = vi.fn(); + render( + , + ); + const rows = screen.getAllByRole('treeitem'); + expect(rows).toHaveLength(2); + expect(rows[0].textContent).toContain('source'); + expect(rows[1].getAttribute('aria-level')).toBe('2'); + expect(rows[1].getAttribute('aria-selected')).toBe('true'); + fireEvent.click(rows[0]); + expect(onSelect).toHaveBeenCalledWith(info('source')); + rows[0].focus(); + fireEvent.keyDown(rows[0], { key: 'ArrowDown' }); + expect(document.activeElement).toBe(rows[1]); + }); + it('terminates missing and cyclic session lineage without hiding archives', () => { + const sessions = [info('a', 'b'), info('b', 'a'), info('c', 'gone')]; + expect( + sessionForest(sessions) + .map((r) => r.info.file) + .sort(), + ).toEqual(['a', 'b', 'c']); + render(); + expect(screen.getByText(/Source unavailable/)).toBeTruthy(); + }); + it('selects an exact entry or branch tip and distinguishes selected from archive tip', () => { + const onSelect = vi.fn(); + const { rerender } = render( + , + ); + const old = screen.getByRole('treeitem', { name: /Old answer/ }); + fireEvent.click(old); + expect(onSelect).toHaveBeenCalledWith('old'); + fireEvent.change(screen.getByLabelText('Branch tip'), { + target: { value: 'old' }, + }); + expect(onSelect).toHaveBeenLastCalledWith('old'); + rerender(); + expect( + screen + .getByRole('treeitem', { name: /Old answer/ }) + .getAttribute('aria-selected'), + ).toBe('true'); + expect( + within(screen.getByRole('treeitem', { name: /New answer/ })).getByText( + /Archive tip/, + ), + ).toBeTruthy(); + }); + it('explains empty sessions', () => { + render( + , + ); + expect(screen.getByText(/Empty session/)).toBeTruthy(); + }); +}); diff --git a/tests/unit/session-tree.test.ts b/tests/unit/session-tree.test.ts new file mode 100644 index 0000000..a530cbc --- /dev/null +++ b/tests/unit/session-tree.test.ts @@ -0,0 +1,208 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { + archiveTree, + forkArchive, + loadArchive, + parseArchive, + selectedPath, + runtimeMatchesSelection, +} from '../../src/main/session-tree'; + +const message = (id: string, parentId: string | null, text = id) => ({ + type: 'message', + id, + parentId, + message: { role: 'assistant', content: [{ type: 'text', text }] }, +}); +const header = { + type: 'session', + version: 3, + id: 'session', + cwd: '/tmp/project', +}; +const records = [ + header, + message('root', null), + message('old', 'root'), + { type: 'model_change', id: 'model', parentId: 'root', modelId: 'test' }, + message('new', 'model'), + { + type: 'label', + id: 'label', + parentId: 'new', + targetId: 'old', + label: 'Experiment', + }, +]; +const encoded = (value: unknown[]) => + value.map((r) => JSON.stringify(r)).join('\n') + '\n'; +const temporary: string[] = []; +describe('Pi archive branch selection', () => { + afterEach(async () => { + await Promise.all( + temporary + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + it('includes metadata in the tree and resolves explicit abandoned branches without latest-leaf fallback', () => { + const archive = parseArchive(encoded(records)); + expect(archiveTree(archive)).toMatchObject({ + leafId: 'label', + canContinue: true, + nodes: [ + { id: 'root', depth: 0 }, + { id: 'old', depth: 1, tip: true }, + { id: 'model', depth: 1 }, + { id: 'new', depth: 2 }, + { id: 'label', depth: 3, tip: true }, + ], + }); + expect(selectedPath(archive, 'old').map((e) => e.id)).toEqual([ + 'root', + 'old', + ]); + expect(selectedPath(archive).map((e) => e.id)).toEqual([ + 'root', + 'model', + 'new', + 'label', + ]); + expect(() => selectedPath(archive, 'deleted')).toThrow('no longer exists'); + }); + it('shows orphaned and cyclic entries as invalid browseable roots and rejects their continuation', () => { + const archive = parseArchive( + encoded([ + header, + message('orphan', 'gone'), + message('a', 'b'), + message('b', 'a'), + ]), + ); + expect(archiveTree(archive).nodes).toHaveLength(3); + expect(archiveTree(archive).nodes.every((n) => !n.valid)).toBe(true); + expect(() => selectedPath(archive, 'orphan')).toThrow('missing lineage'); + expect(() => selectedPath(archive, 'b')).toThrow('cyclic lineage'); + }); + it('handles empty archives, malformed tails, duplicate ids and unsupported archive versions', () => { + expect(archiveTree(parseArchive(encoded([header])))).toMatchObject({ + nodes: [], + leafId: null, + }); + expect(selectedPath(parseArchive(encoded([header])), null)).toEqual([]); + expect(() => selectedPath(parseArchive(encoded(records)), null)).toThrow( + 'has advanced', + ); + for (const text of [ + encoded(records) + '{partial', + encoded([...records, message('old', null)]), + ]) { + expect(archiveTree(parseArchive(text))).toMatchObject({ + canContinue: false, + warning: expect.any(String), + }); + } + expect( + archiveTree(parseArchive(encoded([{ ...header, version: 2 }]))) + .canContinue, + ).toBe(false); + }); + it('forks exactly the selected parent chain, preserving tool/compaction records and source bytes', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-tree-')); + temporary.push(root); + const file = path.join(root, 'source.jsonl'); + const original = encoded([ + ...records, + { + type: 'compaction', + id: 'compact', + parentId: 'old', + firstKeptEntryId: 'root', + summary: 'keep', + }, + { + type: 'message', + id: 'tool', + parentId: 'compact', + message: { role: 'toolResult', toolCallId: 'call', content: [] }, + }, + ]); + await fs.writeFile(file, original); + const fork = await forkArchive(file, 'tool', root); + const archive = await loadArchive(fork, root); + expect(archive.header).toMatchObject({ + version: 3, + parentSession: await fs.realpath(file), + }); + expect(archive.header?.id).not.toBe('session'); + expect(archive.entries.map((e) => e.id)).toEqual([ + 'root', + 'old', + 'compact', + 'tool', + ]); + expect(archive.entries[2]).toMatchObject({ + firstKeptEntryId: 'root', + summary: 'keep', + }); + expect(await fs.readFile(file, 'utf8')).toBe(original); + await expect(forkArchive(file, 'missing', root)).rejects.toThrow( + 'no longer exists', + ); + await fs.writeFile(file, original + '{partial'); + await expect(forkArchive(file, 'old', root)).rejects.toThrow( + 'intact Pi v3', + ); + }); + it('rejects out-of-root and symlinked source paths before writing a fork', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-tree-')); + temporary.push(root); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'pidex-outside-')); + temporary.push(outside); + const file = path.join(outside, 'source.jsonl'); + await fs.writeFile(file, encoded(records)); + const link = path.join(root, 'link.jsonl'); + await fs.symlink(file, link); + await expect(forkArchive(link, 'old', root)).rejects.toThrow( + 'Invalid session', + ); + expect(await fs.readdir(root)).toEqual(['link.jsonl']); + }); + it('allows only linear model/thinking startup entries after a selection', () => { + const setup = { + type: 'thinking_level_change', + id: 'setup', + parentId: 'old', + }; + expect(runtimeMatchesSelection('old', { entries: [], leafId: 'old' })).toBe( + true, + ); + expect( + runtimeMatchesSelection('old', { entries: [setup], leafId: 'setup' }), + ).toBe(true); + expect( + runtimeMatchesSelection(null, { + entries: [{ ...setup, parentId: null }], + leafId: 'setup', + }), + ).toBe(true); + for (const entry of [ + { ...setup, type: 'message' }, + { ...setup, type: 'custom' }, + { ...setup, parentId: 'unrelated' }, + ]) + expect( + runtimeMatchesSelection('old', { entries: [entry], leafId: 'setup' }), + ).toBe(false); + expect( + runtimeMatchesSelection('old', { entries: [], leafId: 'unrelated' }), + ).toBe(false); + expect(runtimeMatchesSelection(null, { entries: [], leafId: null })).toBe( + true, + ); + expect(runtimeMatchesSelection('old', { leafId: 'old' })).toBe(false); + }); +}); diff --git a/tests/unit/workbench.test.ts b/tests/unit/workbench.test.ts index 04ee6ec..037f3bf 100644 --- a/tests/unit/workbench.test.ts +++ b/tests/unit/workbench.test.ts @@ -235,7 +235,7 @@ describe('workspace inspection and preview', () => { .map((record) => JSON.stringify(record)) .join('\n'), ); - expect(await readSession(file, root)).toEqual([first, second]); + await expect(readSession(file, root)).rejects.toThrow('cyclic lineage'); const secret = path.join(outside, 'private.jsonl'); await fs.writeFile(secret, '{}'); const linked = path.join(root, 'linked.jsonl');