From 0effbb9ce4406e4f7f3c6f4f61e6ba755259d851 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 12:11:54 +0200 Subject: [PATCH] perf(story-mode): no long tasks while stepping through turns; the rest of review round 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stepping through 40 turns made 46 long tasks of 51 to 286 ms, enough for the app's guard to drop the terminal's WebGL renderer for the rest of the page's life. Drawing switched off made none: the cost was canvas raster at paint time, which the per-frame JS budget never saw. Now: - Panel backgrounds are shared between panels and turns (kind × size × palette), and a new one is built in four bands, one per frame (a wide halftone at 2x was ~35 ms in one go; a band is ~8 ms). - At most two panels are drawn per frame, the first one served rotating so none starves; clocks still run for all. - Measured on 20 turns stepped 40 times at 2x: 1 long task (52 ms), against 39; the settled overlay is still ~2% of a core. Also from round 4: - The turn picker says when older turns were not loaded (`older` in the response: the transcript is longer than the part that was read). - A compaction after the last turn closes that turn; a local command's output is the final panel's balloon ("Compacted"), without its markdown ticks, and a turn under a second reads "Done.". - Exact durations keep their seconds past an hour, so the two captions never disagree on the minute ("1 h 10 min." / "1 h 9 min 39 s"). - A turn that arrives already finished is announced as new too. - Whole files written by here-documents read "Claude writes 3 files."; the advisor's caption reads "a second opinion" instead of repeating its name. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/story-mode.ts | 34 ++++-- src/types/story.ts | 8 +- src/web/public/story-mode.js | 114 +++++++++++++++--- src/web/routes/story-routes.ts | 7 +- ...ash-script-background-helper.expected.json | 2 +- .../compact-then-plan-mode.expected.json | 6 +- .../failing-test-on-purpose.expected.json | 2 +- .../long-turn-prompt-added.expected.json | 6 +- .../python-cli-fix-loop.expected.json | 4 +- .../question-tests-first-push.expected.json | 4 +- .../teammates-then-interrupt.expected.json | 4 +- .../text-plan-dry-run-helper.expected.json | 2 +- .../web-page-node-test.expected.json | 4 +- test/routes/story-routes.test.ts | 16 +++ test/story-mode.test.ts | 19 +++ 15 files changed, 188 insertions(+), 44 deletions(-) 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]')])