// Unit tests for the pure session-tree helpers. Runs under `node --test`. // The module has no DOM/protocol dependency, so the tests exercise it // directly with hand-shaped SessionListEntry / SessionEvent fixtures. 'use strict' const test = require('node:test') const assert = require('node:assert/strict') const { buildSessionTree, classifyEvent, findChildForks, forkChildLabel, normalizedTitle, summarizeContentBlocks, classifySessionShape, layoutSmartlogRows, partitionSmartlog, } = require('../src/renderer/session-tree.js') function entry(id, { parent, seedLength, title, running, lastEventTime } = {}) { return { sessionId: id, header: { version: 0, id, createdAt: 0, parentSession: parent, seedLength, }, live: true, persisted: true, title, running, lastEventTime, } } test('buildSessionTree returns roots for parentless entries and nests children', () => { const list = [ entry('a'), entry('b', { parent: 'a', seedLength: 4 }), entry('c', { parent: 'b', seedLength: 2 }), entry('d'), ] const tree = buildSessionTree(list) assert.equal(tree.length, 2) const a = tree.find((n) => n.entry.sessionId === 'a') const d = tree.find((n) => n.entry.sessionId === 'd') assert.ok(a && d) assert.equal(a.children.length, 1) assert.equal(a.children[0].entry.sessionId, 'b') assert.equal(a.children[0].depth, 1) assert.equal(a.children[0].children[0].entry.sessionId, 'c') assert.equal(a.children[0].children[0].depth, 2) assert.equal(d.children.length, 0) }) test('buildSessionTree treats an unknown parent as a root and tags orphaned', () => { const list = [entry('x', { parent: 'ghost', seedLength: 1 })] const tree = buildSessionTree(list) assert.equal(tree.length, 1) assert.equal(tree[0].orphaned, true) }) test('buildSessionTree tolerates malformed input', () => { assert.deepEqual(buildSessionTree(null), []) assert.deepEqual(buildSessionTree(undefined), []) // A row without sessionId is skipped from the index but still contributes // no root; input remains stable. const tree = buildSessionTree([{ header: {} }, entry('a')]) assert.equal(tree.length, 1) assert.equal(tree[0].entry.sessionId, 'a') }) test('classifyEvent buckets each SessionEventMap type into a render category', () => { assert.equal(classifyEvent({ type: 'user/message' }), 'user') assert.equal(classifyEvent({ type: 'assistant/message' }), 'assistant') assert.equal(classifyEvent({ type: 'assistant/chunk' }), 'stream-chunk') assert.equal(classifyEvent({ type: 'tool/call' }), 'tool-call') assert.equal(classifyEvent({ type: 'tool/result' }), 'tool-result') assert.equal(classifyEvent({ type: 'context/message' }), 'context-injection') assert.equal(classifyEvent({ type: 'steering/message' }), 'context-injection') assert.equal(classifyEvent({ type: 'compact/start' }), 'compact-begin') assert.equal(classifyEvent({ type: 'compact/summary' }), 'compact-summary') assert.equal(classifyEvent({ type: 'compact/end' }), 'compact-end') assert.equal(classifyEvent({ type: 'turn/start' }), 'turn-boundary') assert.equal(classifyEvent({ type: 'turn/end' }), 'turn-boundary') assert.equal(classifyEvent({ type: 'step/start' }), 'step-boundary') assert.equal(classifyEvent({ type: 'step/end' }), 'step-boundary') assert.equal(classifyEvent({ type: 'todo/write' }), 'todo') assert.equal(classifyEvent({ type: 'prompt/blocked' }), 'blocked') assert.equal(classifyEvent({ type: 'request/header' }), 'header') assert.equal(classifyEvent({ type: 'request/header-delta' }), 'header') assert.equal(classifyEvent({ type: 'plugin/unknown-thing' }), 'other') assert.equal(classifyEvent(null), null) }) test('findChildForks returns child entries with the fork seq (seedLength − 1)', () => { const list = [ entry('parent'), entry('child-a', { parent: 'parent', seedLength: 4, title: 'question about loader', running: true }), entry('child-b', { parent: 'parent', seedLength: 7 }), entry('grandchild', { parent: 'child-a', seedLength: 5 }), entry('other', { parent: 'someone-else' }), ] const forks = findChildForks('parent', list) assert.equal(forks.length, 2) assert.deepEqual( forks.map((f) => ({ id: f.childSessionId, seq: f.forkSeq, running: f.running })), [ { id: 'child-a', seq: 3, running: true }, { id: 'child-b', seq: 6, running: false }, ], ) }) test('findChildForks reports forkSeq=null when seedLength is missing', () => { const list = [entry('p'), entry('c', { parent: 'p' })] const forks = findChildForks('p', list) assert.equal(forks.length, 1) assert.equal(forks[0].forkSeq, null) }) test('summarizeContentBlocks collapses text blocks and caps length', () => { assert.equal(summarizeContentBlocks([]), '') assert.equal(summarizeContentBlocks([{ type: 'text', text: 'hello world' }]), 'hello world') assert.equal( summarizeContentBlocks([{ type: 'image', image: 'x' }, { type: 'text', text: 'caption' }]), '[image] caption', ) // Longer than 20 chars? Truncate with an ellipsis. const long = 'a'.repeat(30) const short = summarizeContentBlocks([{ type: 'text', text: long }], 10) assert.equal(short.length, 10) assert.ok(short.endsWith('…')) }) // -- classifySessionShape (M5) ---------------------------------------------- // // Maps a session-list entry to one of the five smartlog shapes borrowed from // jj / git-branchless / ISL. Discriminant priority (most-informative wins): // interrupted/errored → '✕' // running turn → '●' // subagent origin → '⇢' // fork with a parent → '⌘' // done or idle root → '◇' (compacted / immutable ancestor) // The color/label are baked in so views don't have to re-derive them. test('classifySessionShape returns ● for a running session', () => { const info = classifySessionShape({ sessionId: 'a', running: true }) assert.equal(info.shape, '●') assert.equal(info.role, 'running') }) test('classifySessionShape returns ⇢ for a subagent-origin child', () => { const info = classifySessionShape({ sessionId: 'a', header: { parentSession: 'p', originKind: 'subagent' }, }) assert.equal(info.shape, '⇢') assert.equal(info.role, 'subagent') }) test('classifySessionShape returns ⌘ for a user-initiated fork', () => { const info = classifySessionShape({ sessionId: 'a', header: { parentSession: 'p', seedLength: 3 }, }) assert.equal(info.shape, '⌘') assert.equal(info.role, 'fork') }) test('classifySessionShape returns ✕ for an interrupted/failed session', () => { // Ticket B §B-4/B-5: `lastError` moved off `header` (phantom — never on // the wire) to `entry.meta.lastError`, which the renderer derives from // SessionFinishedNotification. Reading TurnEndReason.kind !== 'ok' // covers both error and user-cancel with one signal. const info = classifySessionShape({ sessionId: 'a', meta: { lastError: { kind: 'error', message: 'user_aborted' } }, }) assert.equal(info.shape, '✕') assert.equal(info.role, 'interrupted') }) test('classifySessionShape returns ◇ for a root or dormant session', () => { const rootInfo = classifySessionShape({ sessionId: 'a' }) assert.equal(rootInfo.shape, '◇') assert.equal(rootInfo.role, 'idle') }) test('classifySessionShape prefers running over subagent/fork', () => { // A subagent that is currently running should still read as "running" — // the pulsing dot is the higher-value signal for the reader. const info = classifySessionShape({ sessionId: 'a', running: true, header: { parentSession: 'p', originKind: 'subagent' }, }) assert.equal(info.shape, '●') assert.equal(info.role, 'running') }) // -- partitionSmartlog (M2) -------------------------------------------------- // // Split raw entries into "meaningful" (real title, has children, or has a // user message) vs "dormant" (0-event untitled leaves). The dormant group is // what the "⋮ N dormant sessions" fold row summarizes. Uses the shared // filterEmptySessions predicate when provided; falls back to a local check. test('partitionSmartlog keeps rooted + titled sessions, folds untitled leaves', () => { const list = [ { sessionId: 'a', title: 'Real work' }, // meaningful (titled) { sessionId: 'b', title: '', header: { parentSession: 'a', seedLength: 2 } }, // meaningful (child of a) { sessionId: 'c', title: '' }, // dormant { sessionId: 'd', title: '', hasUserMessage: false, eventCount: 0 }, // dormant { sessionId: 'e', title: '', hasUserMessage: true }, // meaningful (user typed something) ] const p = partitionSmartlog(list) const meaningfulIds = p.meaningful.map((e) => e.sessionId).sort() const dormantIds = p.dormant.map((e) => e.sessionId).sort() assert.deepEqual(meaningfulIds, ['a', 'b', 'e']) assert.deepEqual(dormantIds, ['c', 'd']) }) test('partitionSmartlog keeps a dormant session whose child is meaningful', () => { // A titleless root that has a titled child must survive the cut — losing // it would orphan the child in the graph. const list = [ { sessionId: 'root', title: '' }, { sessionId: 'child', title: 'nested', header: { parentSession: 'root', seedLength: 2 } }, ] const p = partitionSmartlog(list) assert.deepEqual(p.meaningful.map((e) => e.sessionId).sort(), ['child', 'root']) assert.equal(p.dormant.length, 0) }) test('partitionSmartlog handles null / empty input gracefully', () => { assert.deepEqual(partitionSmartlog(null), { meaningful: [], dormant: [] }) assert.deepEqual(partitionSmartlog([]), { meaningful: [], dormant: [] }) }) // -- layoutSmartlogRows (M1) ------------------------------------------------- // // Given the forest produced by buildSessionTree, return a flat row list in // smartlog order (root then descendants, DFS, siblings by recency). Each row // carries a `lane` (0..N-1) — the rail column it occupies. Lanes are assigned // greedily and released the moment a subtree ends, so the picture is compact. // The row also lists the `parentLane` so the SVG renderer can draw the curve. test('layoutSmartlogRows returns roots on lane 0 in recency order', () => { const forest = [ { entry: { sessionId: 'a', lastEventTime: 200 }, depth: 0, children: [] }, { entry: { sessionId: 'b', lastEventTime: 300 }, depth: 0, children: [] }, { entry: { sessionId: 'c', lastEventTime: 100 }, depth: 0, children: [] }, ] const rows = layoutSmartlogRows(forest) assert.deepEqual(rows.map((r) => r.entry.sessionId), ['b', 'a', 'c']) for (const r of rows) assert.equal(r.lane, 0) }) test('layoutSmartlogRows assigns a fresh lane per branch, inherits only-child lane, reuses on subtree exit', () => { // Shape: // a lane 0 (root) // ├─ b lane 1 (first of multiple — branch to new lane) // │ └─ d lane 1 (only child of b — inherit parent's lane) // └─ c lane 1 (b's subtree exited, lane 1 is free; c reuses it) // // "Only-child inherits, non-only-child branches, branched lane releases on // subtree exit" is enough to keep the picture narrow in the common case // (many forks over time, few concurrent forks at any one row). const forest = [{ entry: { sessionId: 'a', lastEventTime: 500 }, depth: 0, children: [ { entry: { sessionId: 'b', lastEventTime: 400 }, depth: 1, children: [{ entry: { sessionId: 'd', lastEventTime: 300 }, depth: 2, children: [] }], }, { entry: { sessionId: 'c', lastEventTime: 100 }, depth: 1, children: [] }, ], }] const rows = layoutSmartlogRows(forest) assert.deepEqual(rows.map((r) => r.entry.sessionId), ['a', 'b', 'd', 'c']) assert.deepEqual(rows.map((r) => r.lane), [0, 1, 1, 1]) // Each non-root row records the lane its parent occupies so an SVG renderer // can trace a curve from (parentLane, parentRow) to (lane, thisRow). assert.equal(rows[1].parentLane, 0) // b's parent a is lane 0 assert.equal(rows[2].parentLane, 1) // d's parent b is lane 1 assert.equal(rows[3].parentLane, 0) // c's parent a is lane 0 }) test('layoutSmartlogRows branches multi-child parents to distinct lanes and reuses on subtree exit', () => { // Multi-child parent p with two subtrees of its own: // p lane 0 (root) // ├─ q lane 1 (first of 2 → branch) // │ └─ qq lane 1 (only child → inherit) // └─ r lane 1 (q's subtree exited, lane 1 free again → reuse) // └─ rr lane 1 (only child → inherit) // // Lane 1 is reused because q's subtree closed and the picture stays narrow. // If q and r had been going concurrently in wall-clock terms they'd sit on // different rails — but in the DFS walk order they don't, and the smartlog // convention (only-child inherits, siblings branch, branched columns // release on subtree exit) gives the compact reuse pattern. const forest = [{ entry: { sessionId: 'p', lastEventTime: 500 }, depth: 0, children: [ { entry: { sessionId: 'q', lastEventTime: 400 }, depth: 1, children: [{ entry: { sessionId: 'qq', lastEventTime: 350 }, depth: 2, children: [] }], }, { entry: { sessionId: 'r', lastEventTime: 300 }, depth: 1, children: [{ entry: { sessionId: 'rr', lastEventTime: 250 }, depth: 2, children: [] }], }, ], }] const rows = layoutSmartlogRows(forest) assert.deepEqual(rows.map((r) => r.entry.sessionId), ['p', 'q', 'qq', 'r', 'rr']) assert.deepEqual(rows.map((r) => r.lane), [0, 1, 1, 1, 1]) assert.equal(rows[0].parentLane, null) assert.equal(rows[1].parentLane, 0) assert.equal(rows[2].parentLane, 1) assert.equal(rows[3].parentLane, 0) assert.equal(rows[4].parentLane, 1) }) test('layoutSmartlogRows returns [] for empty forest', () => { assert.deepEqual(layoutSmartlogRows([]), []) assert.deepEqual(layoutSmartlogRows(null), []) }) // -- forkChildLabel / normalizedTitle (task #198 readability fix) ----------- // // The bug: fork children are seeded from the parent's event log at // `Session.create` time, which includes the first user message, so the // daemon derives an IDENTICAL title from the same first message. Rendering // `child.title` verbatim produced rows that looked like duplicates of the // parent — the user's "不太看得懂" feedback. forkChildLabel replaces the // visible label with a fork-point identity string so a row's own signal // (its NEW user message, or "no new messages yet") reads at a glance. test('normalizedTitle prefers header.title, falls back to entry.title, strips placeholders', () => { assert.equal(normalizedTitle(null), '') assert.equal(normalizedTitle({}), '') assert.equal(normalizedTitle({ title: ' hello ' }), 'hello') assert.equal(normalizedTitle({ header: { title: 'from-header' }, title: 'from-entry' }), 'from-header') // Smoke fixtures and the "(shortId)" fallback are treated as no-title. assert.equal(normalizedTitle({ title: 'smoke-abc' }), '') assert.equal(normalizedTitle({ title: '(smoke-abc)' }), '') assert.equal(normalizedTitle({ sessionId: '0123456789ab', title: '(01234567)' }), '') }) test('forkChildLabel: parent-title copy collapses to "no new messages yet"', () => { const parent = { title: 'parent turn one', header: { title: 'parent turn one' } } const child = { title: 'parent turn one', header: { title: 'parent turn one', parentSession: { id: 'p', seq: 3 }, seedLength: 4 } } const label = forkChildLabel(child, parent, 3) assert.equal(label.text, 'fork @ seq 3 · (no new messages yet)') assert.equal(label.hasOwnMessage, false) assert.equal(label.forkSeq, 3) }) test('forkChildLabel: child with its own message wins', () => { const parent = { title: 'debug the tree page' } const child = { title: 'try a different palette', header: { seedLength: 8, parentSession: { id: 'p', seq: 7 } } } const label = forkChildLabel(child, parent, 7) assert.equal(label.text, 'fork @ seq 7 · try a different palette') assert.equal(label.hasOwnMessage, true) }) test('forkChildLabel: null forkSeq omits the seq part', () => { const parent = { title: 'shared' } const child = { title: 'shared' } const label = forkChildLabel(child, parent, null) assert.equal(label.text, 'fork · (no new messages yet)') assert.equal(label.forkSeq, null) }) test('forkChildLabel: subagent origin swaps the kind prefix', () => { const parent = { title: 'debug tree page' } const child = { title: 'run lint report', header: { seedLength: 5, parentSession: 'p', originKind: 'subagent' } } const label = forkChildLabel(child, parent, 4) assert.equal(label.text, 'subagent @ seq 4 · run lint report') assert.equal(label.kind, 'subagent') }) test('forkChildLabel: missing parent still produces a usable label', () => { // Defensive path: when the parent entry is null (e.g. wire race, parent // filtered out of the visible list), fall back to "no new messages yet" // unless the child has a non-placeholder title. This keeps the row from // ever crashing when the seed race between parent list + child list races. const child = { title: '', header: { seedLength: 3 } } const label = forkChildLabel(child, null, 2) assert.equal(label.text, 'fork @ seq 2 · (no new messages yet)') })