diff --git a/src/story-mode.ts b/src/story-mode.ts index 7231d59d..8e3bda81 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -278,7 +278,7 @@ export function exactDuration(ms: number): string { if (s < 60) return `${s} s`; const m = Math.floor(s / 60); if (m < 60) return `${m} min ${s % 60} s`; - return `${Math.floor(m / 60)} h ${m % 60} min`; + return `${Math.floor(m / 60)} h ${m % 60} min ${s % 60} s`; } const fmtCounts = (added: number, removed: number): string => @@ -984,7 +984,10 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean const local = /^\s*([\s\S]*?)<\/local-command-\1>/.exec(blockText(content)); if (local) { // eslint-disable-next-line no-control-regex - const text = local[2].replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim(); + const text = local[2] + .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') + .replace(/`([^`\n]+)`/g, '$1') + .trim(); return [{ k: 'local', at, text: clip(text, 2000) }]; } const prompt = readPrompt(blockText(content)); @@ -1546,6 +1549,8 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { } } } + // A compaction after the last turn (no turn came after it to take it) closes that turn. + if (pendingCompact && turns.length) turns[turns.length - 1].events.push(pendingCompact); return turns; } @@ -1915,7 +1920,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde : `Claude calls in ${plural(helpers.length, 'helper')}.` : 'Claude sends a message to another session.'; const inside = evs.reduce((n, e) => n + (e.helper?.toolUses ?? 0), 0); - director = `${tools}${helpers.length ? ` · ${unique(helpers.map((h) => h.type)).join(' + ')}` : ''}${inside ? ` · ${plural(inside, 'call')} inside` : ''}`; + const types = unique(helpers.map((h) => h.type).filter((t) => t !== 'advisor')); + director = `${tools}${advisor ? ' · a second opinion' : ''}${types.length ? ` · ${types.join(' + ')}` : ''}${inside ? ` · ${plural(inside, 'call')} inside` : ''}`; break; } case 'fix': @@ -1930,12 +1936,16 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde panel.removed = edits.reduce((n, e) => n + e.removed, 0); if (draft.kind === 'fix') { const created = unique(edits.filter((e) => e.created).map((e) => e.file)).length; + const whole = + edits.length > 0 && edits.every((d) => d.created || d.hunks.every((h) => !h.oldStart && !h.oldLines)); story = created && created === files.length ? `Claude writes ${plural(created, 'new file')}.` - : files.length - ? `Claude changes ${plural(files.length, 'file')}.` - : 'Claude changes a file.'; + : whole && files.length + ? `Claude writes ${plural(files.length, 'file')}.` + : files.length + ? `Claude changes ${plural(files.length, 'file')}.` + : 'Claude changes a file.'; const perFile = files.slice(0, 3).map((f) => { const mine = edits.filter((e) => e.file === f); return `${basename(f)} ${fmtCounts( @@ -2100,8 +2110,18 @@ function completePanel(turn: StoryTurn, page: Omit, trailin ] .filter(Boolean) .join(' · '); - const panel: StoryPanel = { kind: 'complete', events: [], caption: { story, director } }; + const panel: StoryPanel = { + kind: 'complete', + events: [], + caption: { + story: turn.durationMs !== undefined && turn.durationMs < 1000 && story === done ? 'Done.' : story, + director, + }, + }; + // A local command answers itself: its own first line is the last word. + const said = turn.endedBy === 'local command' ? turn.events[0]?.output?.split('\n')[0] : undefined; if (trailingSay) panel.balloon = { who: 'claude', text: trailingSay }; + else if (said) panel.balloon = { who: 'claude', text: clip(said, 160) }; if (page.filesChanged) { panel.added = page.added; panel.removed = page.removed; diff --git a/src/types/story.ts b/src/types/story.ts index e97e814d..b2b644ff 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -203,4 +203,10 @@ export type StoryHelperResponse = export type StoryResponse = | { supported: false; reason: string } - | { supported: true; turns: StoryTurnSummary[]; page: StoryPage | null }; + | { + supported: true; + turns: StoryTurnSummary[]; + page: StoryPage | null; + /** The transcript is longer than the part that was read: older turns are not listed. */ + older?: boolean; + }; diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 8e9bd75a..42726173 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -45,6 +45,15 @@ const STORY_STAGE_MAX = 680; * panel at display rate cost ~80% of a core with five panels on a 2x screen). */ const STORY_FPS_INTRO = 30; +/** Panel backgrounds kept for reuse (kind × size × palette). */ +const STORY_BG_CACHE = 32; +/** Horizontal bands a new background is built in, one per frame. */ +const STORY_BG_BANDS = 4; +const storyBgCache = new Map(); +/** Milliseconds of drawing per frame; panels past it wait for the next frame (no long tasks). */ +const STORY_FRAME_BUDGET_MS = 10; +/** Panels drawn per frame at most: the raster cost lands at paint time, where the JS budget cannot see it. */ +const STORY_DRAWS_PER_FRAME = 2; const STORY_FPS_LIVE = 15; const STORY_FPS_SETTLED = 6; /** A settled panel animates this long, then holds still until the pointer wakes it again. */ @@ -123,6 +132,16 @@ const StoryArt = (() => { ctx.fillStyle = C.panel; ctx.fillRect(0, 0, W, H); } + /** While a background is built in bands (one per frame), the band being drawn: dots outside it are skipped. */ + let band = null; + function inBand(top, bottom, fn) { + band = { top, bottom }; + try { + fn(); + } finally { + band = null; + } + } function halftone(ctx, W, H, color, fx, fy, { cell = 9, rmax = 3.6, reach = 1, alpha = 1 } = {}) { const diag = Math.hypot(W, H) * reach; const R = Math.hypot(W, H) / 2 + cell; @@ -136,6 +155,7 @@ const StoryArt = (() => { const ux = gx * k - gy * k + W / 2; const uy = gx * k + gy * k + H / 2; if (ux < -cell || uy < -cell || ux > W + cell || uy > H + cell) continue; + if (band && (uy < band.top - cell || uy > band.bottom + cell)) continue; const r = rmax * (1 - Math.hypot(ux - fx, uy - fy) / diag); if (r < 0.4) continue; ctx.moveTo(ux + r, uy); @@ -1418,7 +1438,7 @@ const StoryArt = (() => { }, }; - return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf }; + return { FPS, HERO, VH, C, readColors, spring, SCENES, takeSpeaker, editsOf, inBand }; })(); /** Panel kinds as the wire's group headings name them. */ @@ -1846,10 +1866,25 @@ Object.assign(CodemanApp.prototype, { if (!st.open) return; const dt = last ? Math.min(100, t - last) : 16; last = t; - if (!reduced.matches && !document.hidden) { - for (const rt of st.panels) { + if (!document.hidden) { + // Clocks run for every panel; drawing is rationed per frame, because a canvas rasterizes what + // was drawn when the frame is painted (12 panels redrawn in one frame made 50-80 ms tasks that + // the JS time alone never showed). The panel served first rotates, so none starves. + if (!reduced.matches) for (const rt of st.panels) if (rt.visible) rt.clock += (dt / 1000) * StoryArt.FPS; + const start = performance.now(); + const n = st.panels.length; + let drawn = 0; + for (let j = 0; j < n; j++) { + if (drawn >= STORY_DRAWS_PER_FRAME || performance.now() - start > STORY_FRAME_BUDGET_MS) break; + const rt = st.panels[((st.drawCursor || 0) + j) % n]; if (!rt.visible) continue; - rt.clock += (dt / 1000) * StoryArt.FPS; + if (rt.pending) { + rt.pending = false; + this._storyDrawPanel(rt); + drawn++; + continue; + } + if (reduced.matches) continue; const intro = rt.clock < StoryArt.HERO; const fps = intro ? STORY_FPS_INTRO : rt.panel?.live ? STORY_FPS_LIVE : STORY_FPS_SETTLED; if (!intro && !rt.panel?.live) { @@ -1859,7 +1894,9 @@ Object.assign(CodemanApp.prototype, { if (t < (rt.nextDrawAt || 0)) continue; rt.nextDrawAt = t + 1000 / fps - 4; this._storyDrawPanel(rt); + drawn++; } + st.drawCursor = n ? ((st.drawCursor || 0) + 1) % n : 0; } st.raf = requestAnimationFrame(loop); }; @@ -1982,6 +2019,13 @@ Object.assign(CodemanApp.prototype, { return o; }) ); + if (data?.older) { + const o = document.createElement('option'); + o.disabled = true; + o.value = ''; + o.textContent = '… older turns are not loaded'; + sel.prepend(o); + } if (page) sel.value = page.id; sel.disabled = turns.length < 2; const idx = page ? turns.findIndex((t) => t.id === page.id) : -1; @@ -2019,12 +2063,12 @@ Object.assign(CodemanApp.prototype, { const key = `${page.id}|${done}`; if (st.announced === key) return; const first = st.announced === undefined; + const newTurn = !first && !st.announced.startsWith(`${page.id}|`); st.announced = key; if (first) return; const last = page.panels[page.panels.length - 1]; - box.textContent = done - ? `Turn finished. ${last?.caption.story || ''}`.trim() - : `New turn: ${page.prompt.slice(0, 140)}`; + const finished = `${done ? `Turn finished. ${last?.caption.story || ''}` : ''}`.trim(); + box.textContent = newTurn ? `New turn: ${page.prompt.slice(0, 140)}${finished ? `. ${finished}` : ''}` : finished; }, _storyRenderPanels(page) { @@ -2067,7 +2111,7 @@ Object.assign(CodemanApp.prototype, { rt.el.style.setProperty('--story-span6', String(spans6[i])); rt.el.style.setProperty('--story-span2', String(spans2[i])); this._storyFillPanelText(rt, page, i); - this._storyDrawPanel(rt); + this._storyQueueDraw(rt); }); for (const rt of st.panels.splice(want.length)) this._storyDropPanel(rt); @@ -2093,6 +2137,7 @@ Object.assign(CodemanApp.prototype, { if (focusAt >= 0 && !document.contains(focused)) { const rt = st.panels[Math.min(focusAt, st.panels.length - 1)]; if (rt && !rt.w) this._storySizePanel(rt); + if (rt) this._storyQueueDraw(rt, true); // its click regions must exist to take focus const target = rt && ((focusHit && rt.hitEls.get(focusHit)) || rt.hitEls.values().next().value); if (target) target.focus({ preventScroll: true }); else { @@ -2263,7 +2308,7 @@ Object.assign(CodemanApp.prototype, { const s = Math.round(ms / 1000); if (s < 60) return `${s} s`; const m = Math.floor(s / 60); - return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min`; + return m < 60 ? `${m} min ${s % 60} s` : `${Math.floor(m / 60)} h ${m % 60} min ${s % 60} s`; }, _storySizePanel(rt) { @@ -2277,7 +2322,13 @@ Object.assign(CodemanApp.prototype, { rt.canvas.width = Math.max(1, Math.round(w * dpr)); rt.canvas.height = Math.max(1, Math.round(h * dpr)); rt.bgKey = ''; - this._storyDrawPanel(rt); + this._storyQueueDraw(rt); + }, + + /** Draws on the next frame, within its budget (now, when the frame loop is not running). */ + _storyQueueDraw(rt, now = false) { + if (now || !this._story?.raf) this._storyDrawPanel(rt); + else rt.pending = true; }, _storyDrawPanel(rt) { @@ -2288,21 +2339,48 @@ Object.assign(CodemanApp.prototype, { const s = rt.h / StoryArt.VH; const W = rt.w / s; const k = rt.dpr * s; - const key = `${rt.canvas.width}x${rt.canvas.height}|${StoryArt.C.key}`; + const key = `${rt.kind}|${rt.canvas.width}x${rt.canvas.height}|${StoryArt.C.key}`; if (rt.bgKey !== key) { - rt.bg ??= document.createElement('canvas'); - rt.bg.width = rt.canvas.width; - rt.bg.height = rt.canvas.height; - const b = rt.bg.getContext('2d'); - b.setTransform(k, 0, 0, k, 0, 0); - scene.bg(b, W, StoryArt.VH); + // A background depends only on the kind, the size and the palette: shared between panels and + // turns, so stepping through turns draws no halftone twice. A new one is built in horizontal + // bands, one per frame (a wide halftone at 2x is ~35 ms in one go, a long task that made the + // app drop its WebGL terminal renderer); the panel shows it as it fills in. + let job = storyBgCache.get(key); + if (!job) { + const canvas = document.createElement('canvas'); + canvas.width = rt.canvas.width; + canvas.height = rt.canvas.height; + job = { canvas, next: 0, total: STORY_BG_BANDS }; + storyBgCache.set(key, job); + if (storyBgCache.size > STORY_BG_CACHE) storyBgCache.delete(storyBgCache.keys().next().value); + } + rt.bg = job; rt.bgKey = key; } + const job = rt.bg; + if (job.next < job.total) { + const b = job.canvas.getContext('2d'); + // Without a frame loop (tests, a browser that never paints) the whole background at once. + const steps = st?.raf ? 1 : job.total - job.next; + for (let i = 0; i < steps && b; i++, job.next++) { + const top = (StoryArt.VH / job.total) * job.next; + const bottom = top + StoryArt.VH / job.total; + b.save(); + b.setTransform(k, 0, 0, k, 0, 0); + b.beginPath(); + b.rect(0, top, W, bottom - top); + b.clip(); + StoryArt.inBand(top, bottom, () => scene.bg(b, W, StoryArt.VH)); + b.restore(); + } + if (!b) job.next = job.total; + if (job.next < job.total) rt.pending = true; + } const ctx = rt.ctx; if (!ctx) return; // no 2D context (a browser out of canvas memory): the captions still read ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, rt.canvas.width, rt.canvas.height); - ctx.drawImage(rt.bg, 0, 0); + ctx.drawImage(job.canvas, 0, 0); const stageW = scene.fullWidth ? W : Math.min(W, STORY_STAGE_MAX); const ox = (W - stageW) / 2; ctx.setTransform(k, 0, 0, k, ox * k, 0); diff --git a/src/web/routes/story-routes.ts b/src/web/routes/story-routes.ts index 038decf8..bc77c8d4 100644 --- a/src/web/routes/story-routes.ts +++ b/src/web/routes/story-routes.ts @@ -115,7 +115,12 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con const wanted = turnKey ? turns.find((t) => t.id === turnKey) : turns[turns.length - 1]; return { success: true, - data: { supported: true, turns: summarizeTurns(turns), page: wanted ? buildStoryPage(wanted) : null }, + data: { + supported: true, + turns: summarizeTurns(turns), + page: wanted ? buildStoryPage(wanted) : null, + ...(read.partial ? { older: true } : {}), + }, }; }); diff --git a/test/fixtures/story-corpus/bash-script-background-helper.expected.json b/test/fixtures/story-corpus/bash-script-background-helper.expected.json index 52bcf2ad..559705b4 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -43,7 +43,7 @@ "kind": "backup", "ok": null, "story": "Claude calls in 2 helpers.", - "director": "Advisor · Agent · advisor + general-purpose · 6 calls inside", + "director": "Advisor · Agent · a second opinion · general-purpose · 6 calls inside", "balloon": null, "tests": null, "events": 2 diff --git a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json index c71a6b21..6a42f30d 100644 --- a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json +++ b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json @@ -34,7 +34,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 @@ -116,7 +116,7 @@ "ok": null, "story": "Done in 24 seconds.", "director": "local command · 24 s · 0 tool calls", - "balloon": null, + "balloon": "Compacted", "tests": null, "events": 0 } @@ -166,7 +166,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 diff --git a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json index f804ebcd..d4c872e2 100644 --- a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json +++ b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json @@ -34,7 +34,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 diff --git a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json index 47d4e1ed..6f68eb95 100644 --- a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json +++ b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json @@ -34,7 +34,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 @@ -42,7 +42,7 @@ { "kind": "fix", "ok": null, - "story": "Claude changes 3 files.", + "story": "Claude writes 3 files.", "director": "Bash · pyproject.toml +11 · .gitignore +11 · __init__.py +3", "balloon": null, "tests": null, @@ -112,7 +112,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 diff --git a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json index bcf9e5a8..ddd800ac 100644 --- a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json +++ b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json @@ -34,7 +34,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 @@ -64,7 +64,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 diff --git a/test/fixtures/story-corpus/question-tests-first-push.expected.json b/test/fixtures/story-corpus/question-tests-first-push.expected.json index b55afe74..2c3cc233 100644 --- a/test/fixtures/story-corpus/question-tests-first-push.expected.json +++ b/test/fixtures/story-corpus/question-tests-first-push.expected.json @@ -43,7 +43,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 @@ -103,7 +103,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 diff --git a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json index c898f58b..539925da 100644 --- a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json +++ b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json @@ -34,7 +34,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 @@ -220,7 +220,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 diff --git a/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json b/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json index 13e93b69..2e78b336 100644 --- a/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json +++ b/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json @@ -43,7 +43,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 diff --git a/test/fixtures/story-corpus/web-page-node-test.expected.json b/test/fixtures/story-corpus/web-page-node-test.expected.json index d0f4b93d..a6c6d851 100644 --- a/test/fixtures/story-corpus/web-page-node-test.expected.json +++ b/test/fixtures/story-corpus/web-page-node-test.expected.json @@ -43,7 +43,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 @@ -82,7 +82,7 @@ "kind": "backup", "ok": null, "story": "Claude asks the advisor for a second opinion.", - "director": "Advisor · advisor", + "director": "Advisor · a second opinion", "balloon": null, "tests": null, "events": 1 diff --git a/test/routes/story-routes.test.ts b/test/routes/story-routes.test.ts index b6f95916..c74cadec 100644 --- a/test/routes/story-routes.test.ts +++ b/test/routes/story-routes.test.ts @@ -153,6 +153,22 @@ describe('GET /api/sessions/:id/story', () => { expect(page.panels[0].kind).toBe('mission'); }); + it('says when older turns were not read', async () => { + const big = 'x'.repeat(100_000); + const lines: unknown[] = [prompt('an old turn')]; + for (let i = 0; i < 90; i++) lines.push(...bash(`b${i}`, `cat big${i}.txt`, `Show file ${i}`, big)); + lines.push(end(60_000), prompt('the newest turn'), ...bash('n1', 'ls', 'List', 'a'), end(1000)); + writeFileSync(transcript, jsonl(lines)); + const { app } = await setup(); + const data = (await app.inject({ method: 'GET', url: URL })).json().data; + expect(data.page.prompt).toBe('the newest turn'); + expect(data.older).toBe(true); + + writeFileSync(transcript, jsonl([prompt('one'), end(1000)])); + const small = (await app.inject({ method: 'GET', url: URL })).json().data; + expect(small.older).toBeUndefined(); + }); + it('a live turn grows as the transcript grows', async () => { writeFileSync(transcript, jsonl([prompt('go'), ...read('r1', '/w/a.ts')])); const { app } = await setup(); diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index a29aa1de..77b6c633 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -194,6 +194,9 @@ describe('text helpers', () => { expect(exactDuration(432_000)).toBe('7 min 12 s'); expect(roughDuration(4_000)).toBe('4 seconds'); expect(roughDuration(3_900_000)).toBe('1 h 5 min'); + // Past an hour the exact one keeps its seconds, so the two never disagree on the minute. + expect(roughDuration(4_179_269)).toBe('1 h 10 min'); + expect(exactDuration(4_179_269)).toBe('1 h 9 min 39 s'); }); }); @@ -683,6 +686,22 @@ describe('buildStoryTurns', () => { expect(page.panels.some((p) => /you add \d+ things?/.test(p.caption.story))).toBe(true); }); + it('an automatic compaction after the last turn closes that turn', () => { + const [turn] = buildStoryTurns( + entriesOf([ + prompt('one'), + end(1000), + { + type: 'system', + subtype: 'compact_boundary', + timestamp: '2026-10-18T10:00:00.000Z', + compactMetadata: { trigger: 'auto' }, + }, + ]) + ); + expect(turn.events.map((e) => e.intent)).toEqual(['prompt', 'compact']); + }); + it('an interrupt ends the turn', () => { const turns = buildStoryTurns( entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')])