diff --git a/docs/api-reference.md b/docs/api-reference.md index cc77c374..ae72aabc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1056,16 +1056,16 @@ What Story Mode reads (the opt-in header button, App Settings → Header buttons ### Routes -- `GET /api/sessions/:id/story[?turn=]`: one turn as a page. `data` is `{ supported: true, turns, page }`: `turns` lists up to 60 turns, oldest first (`{ id, at, prompt, origin, complete, interrupted, superseded?, toolCalls }`), and `page` is the newest turn, or the one `turn=` names, or `null` when there is none (an unknown id included). `older: true` means the transcript is longer than the part that was read, so older turns are not listed. +- `GET /api/sessions/:id/story[?turn=]`: one turn as a page. `data` is `{ supported: true, turns, page }`: `turns` lists up to 60 turns, oldest first (`{ id, at, prompt, origin, complete, interrupted, superseded?, toolCalls }`), and `page` is the newest turn, or the one `turn=` names, or `null` when there is none (an unknown id included). `older: true` means older turns exist that `turns` leaves out: before the part of the transcript that was read, or past the 60 listed. - `GET /api/sessions/:id/story/helpers/:agentId`: one helper (an Agent call, `agentId` from its event's `helper.agentId`) drawn from its own transcript, `/subagents/agent-.jsonl` beside the session's. `data` is `{ supported: true, helper: { agentId, type?, description?, finished? }, page }`, the same page shape, or `page: null` while the helper has written nothing yet. `agentId` must match `[A-Za-z0-9_-]{1,64}` (`400` otherwise). -Both answer `{ supported: false, reason }` for a session they cannot draw: its CLI writes no Claude Code transcript (registry `capabilities.transcript` is not `claude-jsonl`), or it is remote or Docker, whose transcript is not on this host. Both are read-only. The session resolves like every session route (ownership via `findSessionOrFail`; another user's session is `404`), and the file read is the session's own transcript, never one the request names: the path the Claude hook reported when it really lies (symlinks resolved) in a Claude projects folder (the session's relocated `CLAUDE_CONFIG_DIR`, or the server's), else `.jsonl` under that folder. Only a regular file is read, through one non-blocking handle, so a FIFO or a device in its place fails at once. An unchanged file is a `304` decided from a stat, before anything is read. Only its tail is read (8 MB, widened in steps to 64 MB while that tail holds no prompt to start the turn); file contents, original files and images never leave the server. A transcript that cannot be read is a `500` with `OPERATION_FAILED`. +Both answer `{ supported: false, reason }` for a session they cannot draw: its CLI writes no Claude Code transcript (registry `capabilities.transcript` is not `claude-jsonl`), or it is remote or Docker, whose transcript is not on this host. Both are read-only. The session resolves like every session route (ownership via `findSessionOrFail`; another user's session is `404`), and the file read is the session's own transcript, never one the request names: the path the Claude hook reported when it really lies (symlinks resolved) in a Claude projects folder (the session's relocated `CLAUDE_CONFIG_DIR`, or the server's), else `.jsonl` under that folder. Only a regular file is read, through one non-blocking handle, so a FIFO or a device in its place fails at once. An unchanged file is a `304` decided from a stat, before anything is read. Only its tail is read (8 MB, widened in steps to 64 MB while that tail holds no prompt to start the turn); file contents, original files and images never leave the server. On the page route, a transcript that cannot be read is a `500` with `OPERATION_FAILED`; the helper route answers `page: null` for a helper transcript it cannot read (one not written yet, or gone). Responses carry a weak `ETag` with `Cache-Control: no-cache`; a matching `If-None-Match` gets a `304` without the page being rebuilt, which keeps a live view's frequent refetches cheap. ### Turns: where they start and end -A turn is one prompt and everything Claude did for it. `origin` says who sent the prompt: `human`, `task` (a background job reporting back), `peer` (another session) or `system`. +A turn is one prompt and everything Claude did for it. `origin` says who sent the prompt: `human`, `task` (a background job reporting back), `peer` (another session), `scheduled` (a scheduled task's run) or `system`. - **Start.** A user line with a prompt starts a turn. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn. A prompt written twice under one `promptId` (a typed `/compact`, then its command record) is one turn. A slash command is a prompt event with tool `Command`. - **End.** At `turn_duration`; at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`; at an interrupt; for a local slash command (`/compact`, `/model`), at its own output (``, kept as the prompt event's `output`). A turn still open when the next prompt starts has ended too, and is marked `superseded`. diff --git a/src/story-mode.ts b/src/story-mode.ts index 7082a720..f9a0a868 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -1860,10 +1860,10 @@ interface DraftPanel { rounds?: number; } -/** Keeps the newest outputs, hunks and helper texts within the page budget. */ /** Every edit an event made: an Edit/Write call's one, or the files a Bash call wrote. */ const editsOf = (e: StoryEvent): StoryEdit[] => (e.edit ? [e.edit] : (e.edits ?? [])); +/** Keeps the newest outputs, hunks and helper texts within the page budget; true when it cut any. */ function applyTextBudget(events: StoryEvent[]): boolean { let used = 0; let cut = false; @@ -2000,9 +2000,10 @@ function capPanels(panels: DraftPanel[], max: number): DraftPanel[] { const firstMergeable = out[0]?.kind === 'mission' ? 1 : 0; while (out.length > max) { let best = -1; - // Three passes: quiet kinds; then anything but a prompt or a question; then, as a last resort, a - // prompt you added mid-turn (the turn's own prompt and a question to you are never folded). - search: for (const pass of [0, 1, 2]) { + // Four passes: quiet kinds; then anything but a prompt or a question; then a prompt you added + // mid-turn; then, only so the page keeps its 12 panels, a question too (the turn's own prompt + // is never folded). + search: for (const pass of [0, 1, 2, 3]) { for (const lastStart of [out.length - 3, out.length - 2]) { let oldest = -1; for (let k = firstMergeable; k <= lastStart; k++) { @@ -2013,7 +2014,10 @@ function capPanels(panels: DraftPanel[], max: number): DraftPanel[] { ? MINOR.has(a.kind) && MINOR.has(b.kind) : pass === 1 ? ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask') - : ![a, b].some((d) => d.kind === 'ask' || (d.kind === 'mission' && d.events[0] === out[0]?.events[0])); + : ![a, b].some( + (d) => + (pass === 2 && d.kind === 'ask') || (d.kind === 'mission' && d.events[0] === out[0]?.events[0]) + ); if (!ok) continue; if (a.kind === 'montage' || b.kind === 'montage') { best = k; diff --git a/src/story-reader.ts b/src/story-reader.ts index d86ab99d..9f666f52 100644 --- a/src/story-reader.ts +++ b/src/story-reader.ts @@ -244,7 +244,8 @@ async function loadFrom( info.size - cached.offset <= STORY_TAIL_BYTES && info.size >= cached.size ) { - // The file only grew: read what was appended since the last complete line. + // The file only grew: read what was appended since the last complete line. (A file rewritten to + // a larger size would read as an append; transcripts are append-only, so it never is.) const buf = await readRange(handle, cached.offset, info.size); await ingest(cached, buf, false); cached.size = info.size; diff --git a/src/utils/claude-transcript.ts b/src/utils/claude-transcript.ts index 7fda9d1f..9fab41ef 100644 --- a/src/utils/claude-transcript.ts +++ b/src/utils/claude-transcript.ts @@ -47,6 +47,44 @@ export function claudeProjectsDir(configDir?: string): string { return join(configDir || fromEnv || join(homedir(), '.claude'), 'projects'); } +/** + * The one top-level transcript a recovered session (`restored-`, how + * reconcileSessions() names a session whose mux record was lost) belongs to, or null when none or + * more than one matches. The same recovery `last-response` makes (session-routes.ts). + */ +export async function findRestoredClaudeTranscriptPath( + codemanSessionId: string, + configDir?: string +): Promise { + const restored = /^restored-([a-f0-9]{8,})$/i.exec(codemanSessionId); + if (!restored) return null; + const fragment = restored[1].toLowerCase(); + const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i; + const projectsDir = claudeProjectsDir(configDir); + let projectDirs: string[]; + try { + projectDirs = await readdir(projectsDir); + } catch { + return null; + } + const found = new Map(); + for (const projectDir of projectDirs) { + let files: string[]; + try { + files = await readdir(join(projectsDir, projectDir)); + } catch { + continue; + } + for (const file of files) { + const id = file.endsWith('.jsonl') ? file.slice(0, -'.jsonl'.length) : ''; + if (!id.toLowerCase().startsWith(fragment) || !uuid.test(id)) continue; + const path = join(projectsDir, projectDir, file); + if ((await stat(path).catch(() => null))?.isFile()) found.set(id, path); + } + } + return found.size === 1 ? [...found.values()][0] : null; +} + /** * Path of the transcript for `conversationId` under any project directory, or null. Unlike * {@link claudeTranscriptExists} it answers with the file (Story Mode reads it), so the id is diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 04d91b40..d4563b10 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -590,8 +590,8 @@ html.mobile-init .file-browser-panel { (inline approve/deny in mobile-overview.js); the bell would only crowd the header it was designed to stay out of. */ .btn-icon-header.btn-approvals, - /* Story Mode: opt-in and desktop-first for now; the overlay itself is responsive, a phone - entry point is a follow-up rather than one more icon in this header. */ + /* Story Mode: phones open it from Session Options (#sessionOptionsStoryBtn), not one more icon + in this header; the overlay itself is responsive. */ .btn-icon-header.btn-story-header { display: none !important; } diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 3faf1209..9e63807b 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -1570,7 +1570,9 @@ Object.assign(CodemanApp.prototype, { return; } const st = this._storyState(); - st.returnFocus = document.activeElement; + // Opened again while open (another session): one runtime, never two frame loops and polls. + if (st.open) this._storyStopRuntime(); + else st.returnFocus = document.activeElement; st.open = true; st.sessionId = sessionId; st.turnId = null; @@ -1703,6 +1705,7 @@ Object.assign(CodemanApp.prototype, { bar.append(brand, nav, seg, live, saveBtn, closeBtn); // What a screen reader hears as the page changes: a new turn, a finished one. const announcer = el('p', 'story-sr'); + announcer.dataset.i18nSkip = ''; // it reads out the agent's prompts announcer.setAttribute('role', 'status'); announcer.setAttribute('aria-live', 'polite'); @@ -1846,7 +1849,8 @@ Object.assign(CodemanApp.prototype, { st.dirBtn.setAttribute('aria-pressed', String(on)); st.wire.hidden = !on; if (on) this._storyRenderWire(); - for (const rt of st.panels) this._storyDrawPanel(rt); + // Within the frame budget, never every panel in one task. + for (const rt of st.panels) this._storyQueueDraw(rt); }, _storyPickTurn(id) { @@ -2040,10 +2044,13 @@ Object.assign(CodemanApp.prototype, { st.pollTimer = setInterval(() => { const page = st.data?.page; if (!st.open || document.hidden) return; - if (!st.turnId && (!page || !page.complete)) { + // A session Story Mode cannot draw does not change that by itself: nothing to poll. + if (st.data && st.data.supported === false) return; + if (!st.turnId && page && !page.complete && !page.interrupted) { this._storyRefresh(); return; } + // Nothing drawn yet (no transcript), a finished page or one parked on: now and then only. // A finished newest page: look again now and then (a local command like /compact raises no // hook). An unchanged page is a 304. // Parked on an older turn: the turn list still learns about new turns now and then. @@ -2082,14 +2089,14 @@ Object.assign(CodemanApp.prototype, { window.addEventListener('keydown', st.keyHandler, true); st.skinObserver = new MutationObserver(() => { this._storyApplyTheme(); - for (const rt of st.panels) this._storyDrawPanel(rt); + for (const rt of st.panels) this._storyQueueDraw(rt); }); st.skinObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-skin', 'class'] }); document.fonts?.ready?.then(() => { if (!st.open) return; for (const rt of st.panels) { rt.bgKey = ''; - this._storyDrawPanel(rt); + this._storyQueueDraw(rt); } }); }, @@ -3114,7 +3121,8 @@ Object.assign(CodemanApp.prototype, { const m = /^([^@]*)@[^:]*:(\d+)$/.exec(key); back = opens.find((n) => n.dataset.storyOpen === key) || - (m && opens.find((n) => new RegExp(`^${m[1]}@[^:]*:${m[2]}$`).test(n.dataset.storyOpen))); + (m && + opens.find((n) => n.dataset.storyOpen.startsWith(`${m[1]}@`) && n.dataset.storyOpen.endsWith(`:${m[2]}`))); } if (refocus && back && document.contains(back)) back.focus({ preventScroll: true }); }, @@ -3362,10 +3370,14 @@ Object.assign(CodemanApp.prototype, { p.textContent = 'Reading its steps…'; box.replaceChildren(p); } + // One request per helper at a time, and only the newest one's answer is drawn. + st.helperSeq ??= new Map(); + const seq = (st.helperSeq.get(agentId) || 0) + 1; + st.helperSeq.set(agentId, seq); const res = await this._api( `/api/sessions/${encodeURIComponent(st.sessionId)}/story/helpers/${encodeURIComponent(agentId)}` ); - if (!box.isConnected || !st.open) return; + if (!box.isConnected || !st.open || st.helperSeq.get(agentId) !== seq) return; const etag = res?.ok ? res.headers?.get?.('etag') || null : null; if (cached && etag && etag === cached.etag) return; let body = null; @@ -3376,7 +3388,10 @@ Object.assign(CodemanApp.prototype, { } if (!box.isConnected || !st.open) return; if (!body?.success) { - if (!cached) box.firstChild.textContent = 'Its steps could not be read.'; + // Only over the placeholder: never over steps another answer already drew. + const only = box.firstElementChild; + if (box.childElementCount === 1 && only?.classList.contains('story-empty')) + only.textContent = 'Its steps could not be read.'; return; } st.helperSteps?.set(agentId, { etag, data: body.data }); @@ -3569,6 +3584,25 @@ Object.assign(CodemanApp.prototype, { ) : []; const workingDir = this.sessions?.get(this._story.sessionId)?.workingDir; + // The `ls` header (`some/dir:`) in force on each line: the newest one above it in its paragraph. + // One pass over the lines (a search per link over the text before it was quadratic). + const lineDirs = []; + const lineEnds = []; + { + let dir = null; + let at = 0; + for (const line of value.split('\n')) { + lineDirs.push(dir); + at += line.length + 1; + lineEnds.push(at); + if (!line.trim()) dir = null; + else { + const m = /^([^\s:][^:]*):[ \t]*$/.exec(line); + if (m) dir = m[1]; + } + } + } + let lineNo = 0; let last = 0; for (const link of links) { pre.append(document.createTextNode(this._storyShortPath(value.slice(last, link.index)))); @@ -3579,15 +3613,9 @@ Object.assign(CodemanApp.prototype, { if (b.textContent !== link.path) b.title = link.path; // A bare name listed under an `ls` header (`some/dir:`) lives in that directory. let target = link.path; - if (!target.includes('/')) { - const block = - value - .slice(0, link.index) - .split(/\n\s*\n/) - .pop() || ''; - const header = /(?:^|\n)([^\s:][^\n:]*):[ \t]*\n(?![\s\S]*\n[^\s:][^\n:]*:[ \t]*\n)/.exec(block); - if (header) target = `${header[1].replace(/\/+$/, '')}/${target}`; - } + while (lineNo < lineEnds.length - 1 && lineEnds[lineNo] <= link.index) lineNo++; + const dir = lineDirs[lineNo]; + if (!target.includes('/') && dir) target = `${dir.replace(/\/+$/, '')}/${target}`; const resolved = typeof resolveLinkedFilePath === 'function' ? resolveLinkedFilePath(target, workingDir) : target; b.addEventListener('click', (e) => this._storyOpenFile(resolved, e.currentTarget)); pre.append(b); diff --git a/src/web/routes/story-routes.ts b/src/web/routes/story-routes.ts index 74917afd..354e7502 100644 --- a/src/web/routes/story-routes.ts +++ b/src/web/routes/story-routes.ts @@ -32,7 +32,12 @@ import { import { getCli } from '../../config/cli-registry/registry.js'; import { STORY_LIMITS, buildHelperPage, buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js'; import { readSmallRegularFile, readStoryEntries, storyFileVersion } from '../../story-reader.js'; -import { claudeProjectsDir, findClaudeTranscriptPath } from '../../utils/claude-transcript.js'; +import { + claudeProjectsDir, + findClaudeTranscriptPath, + findRestoredClaudeTranscriptPath, +} from '../../utils/claude-transcript.js'; +import { LRUMap } from '../../utils/lru-map.js'; import { findSessionOrFail } from '../route-helpers.js'; import type { ConfigPort, SessionPort } from '../ports/index.js'; @@ -69,10 +74,40 @@ async function insideClaudeProjects(path: string, configDir?: string): Promise({ maxSize: 200 }); +const LOOKUP_HIT_MS = 30_000; +const LOOKUP_MISS_MS = 4_000; + +/** For tests: forget the transcripts found by id. */ +export function clearStoryLookupCache(): void { + lookups.clear(); +} + +/** The transcript by conversation id, then by Codeman id, then a recovered session's, as last-response finds it. */ +async function lookUpTranscript(ids: string[], codemanId: string, configDir?: string): Promise { + const key = `${ids.join('|')}|${configDir ?? ''}`; + const hit = lookups.get(key); + if (hit && Date.now() - hit.at < (hit.path ? LOOKUP_HIT_MS : LOOKUP_MISS_MS)) return hit.path; + let path: string | null = null; + for (const id of ids) { + path = await findClaudeTranscriptPath(id, configDir); + if (path) break; + } + path ??= await findRestoredClaudeTranscriptPath(codemanId, configDir); + lookups.set(key, { path, at: Date.now() }); + return path; +} + +/** The hook-reported path when it is a transcript in a projects folder, else a lookup by id. */ async function resolveTranscriptPath( hookPath: string | null, - conversationId: string, + ids: string[], + codemanId: string, configDir?: string ): Promise { if ( @@ -82,7 +117,7 @@ async function resolveTranscriptPath( (await insideClaudeProjects(hookPath, configDir)) ) return hookPath; - return findClaudeTranscriptPath(conversationId, configDir); + return lookUpTranscript(ids, codemanId, configDir); } type Source = { unsupported: string } | { path: string | null }; @@ -102,7 +137,8 @@ async function storySource(ctx: SessionPort & ConfigPort, id: string, req: Fasti return { path: await resolveTranscriptPath( ctx.getTranscriptPath(id), - session.claudeSessionId || session.id, + [...new Set([session.claudeSessionId, session.id].filter((v): v is string => !!v))], + session.id, session.claudeConfigDir ), }; @@ -145,7 +181,7 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con const source = await storySource(ctx, id, req); if ('unsupported' in source) return { success: true, data: { supported: false, reason: source.unsupported } }; if (!source.path) return { success: true, data: { supported: true, turns: [], page: null } }; - const turnKey = typeof turn === 'string' && turn ? turn : ''; + const turnKey = typeof turn === 'string' && turn ? turn.slice(0, 200) : ''; const tagOf = (version: string) => `${version}-${encodeURIComponent(turnKey || 'latest')}`; // An unchanged file is a 304 from a stat alone: no read, no parse, whatever the cache holds. const before = await storyFileVersion(source.path); diff --git a/test/routes/story-routes.test.ts b/test/routes/story-routes.test.ts index 4828054b..7acf1259 100644 --- a/test/routes/story-routes.test.ts +++ b/test/routes/story-routes.test.ts @@ -8,7 +8,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createRouteTestHarness } from './_route-test-utils.js'; -import { registerStoryRoutes } from '../../src/web/routes/story-routes.js'; +import { clearStoryLookupCache, registerStoryRoutes } from '../../src/web/routes/story-routes.js'; import { clearStoryReaderCache, readStoryEntries } from '../../src/story-reader.js'; import { claudeProjectsDir } from '../../src/utils/claude-transcript.js'; import { bash, demoTurn, edit, end, prompt, read, resetClock, result, said, tool } from '../story-fixtures.js'; @@ -32,6 +32,7 @@ async function setup(opts: { hookPath?: string | null; mode?: string; remote?: u beforeEach(() => { resetClock(); clearStoryReaderCache(); + clearStoryLookupCache(); // Where Claude Code writes transcripts (under the test's temp HOME): the only place a hook path is read. mkdirSync(claudeProjectsDir(), { recursive: true }); dir = mkdtempSync(join(claudeProjectsDir(), 'story-route-')); @@ -202,6 +203,14 @@ describe('GET /api/sessions/:id/story', () => { expect(res.json().data).toMatchObject({ supported: true, turns: [], page: null }); }); + it('with no hook path, finds the transcript by the Codeman id too (as last-response does)', async () => { + const byId = join(dir, 'test-session-1.jsonl'); + writeFileSync(byId, jsonl(demoTurn())); + const { app } = await setup({ hookPath: null }); + const body = (await app.inject({ method: 'GET', url: URL })).json(); + expect(body.data.page?.panels.length).toBeGreaterThan(0); + }); + it('says when the list leaves turns out past the 60 it shows, even in a small file', async () => { const lines: unknown[] = []; for (let i = 0; i < 80; i++) lines.push(prompt(`job ${i}`), said('ok'), end(1000)); diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index cdc1a8e6..05ce50e7 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -666,6 +666,22 @@ describe('story-mode.js overlay', () => { expect($('.story-sr')!.textContent).toBe(''); }); + it('a session Story Mode cannot draw is not polled', async () => { + let poll: (() => void) | undefined; + boot({ supported: false, reason: 'A shell session.' }); + win.setInterval = ((fn: () => void) => { + poll = fn; + return 1; + }) as never; + app.openStoryMode(SID); + await settle(); + app._story.lastIdlePoll = 0; + calls.length = 0; + poll!(); + await settle(); + expect(calls).toHaveLength(0); + }); + it('Tab stays inside the overlay', async () => { boot(storyOf(demoTurn())); app.openStoryMode(SID); diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 773a069d..729939f2 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -984,17 +984,35 @@ describe('buildStoryPage', () => { const prompt1 = (content: string) => () => buildStoryTurns(entriesOf([prompt(content), said('ok'), end(1000)])); const bashOut = (command: string, output: string) => () => pageOf([prompt('go'), ...bash('b1', command, 'x', output), end(1000)]); + // At these sizes the old code took 6 to 29 s per shape and the new one takes under 10 ms: the + // bound sits two orders of magnitude from both, so a loaded runner cannot flake it. const shapes: Array<[string, () => unknown]> = [ - ['teammate openers', prompt1(rep('x ', 400_000))], - ['system-reminder openers', prompt1('hi ' + rep('x ', 400_000))], - ['task-notification field openers', prompt1('\n' + rep('x ', 400_000))], - ['git commit tokens', bashOut(rep('git commit ', 8000), 'ok')], + ['teammate openers', prompt1(rep('x ', 1_000_000))], + ['system-reminder openers', prompt1('hi ' + rep('x ', 1_000_000))], + ['task-notification field openers', prompt1('\n' + rep('x ', 1_000_000))], + ['git commit tokens', bashOut(rep('git commit ', 16_000), 'ok')], [ 'modified-note phrases', - bashOut('python3 x.py', rep("This command modified 1 file you've previously read: a", 400_000)), + bashOut('python3 x.py', rep("This command modified 1 file you've previously read: a", 1_000_000)), ], ]; - for (const [name, fn] of shapes) expect([name, ms(fn) < 250]).toEqual([name, true]); + for (const [name, fn] of shapes) expect([name, ms(fn) < 1500]).toEqual([name, true]); + }); + + it('a turn of many questions still keeps to 12 panels: the oldest questions fold last', () => { + const lines: unknown[] = [prompt('ask me a lot')]; + for (let i = 0; i < 14; i++) { + lines.push( + tool(`q${i}`, 'AskUserQuestion', { questions: [{ question: `Question ${i}?`, options: [{ label: 'Yes' }] }] }), + result(`q${i}`, 'ok', { toolUseResult: { answers: { [`Question ${i}?`]: 'Yes' } } }), + ...read(`r${i}`, `/w/f${i}.ts`) + ); + } + lines.push(end(1000)); + const page = pageOf(lines); + expect(page.panels.length).toBeLessThanOrEqual(12); + expect(page.panels[0].kind).toBe('mission'); + expect(page.panels.at(-1)!.kind).toBe('complete'); }); it('a helper messaged twice reads as one finished run, not as superseded', () => {