perf(story-mode): 304 for an unchanged page, and a longer read for a turn bigger than the tail

GET /story now sends a weak ETag (server start, transcript size and mtime, turn)
with Cache-Control: no-cache, and answers 304 before building anything when the
transcript has not changed, so a live view's polls cost a stat.

A turn can be bigger than the 8 MB tail on its own (tool results carry whole
files, ~60 KB per result line on large local transcripts), which cut its first
panels. When the window holds no prompt, the reader re-reads a 24 MB, then a
64 MB tail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 09:31:40 +02:00
1 parent d20208bda6
commit 7c21973c54
3 files changed
+93 -20

No files matched your search

+37
View File
@@ -116,6 +116,43 @@ describe('GET /api/sessions/:id/story', () => {
expect(res.statusCode).toBe(404);
});
it('answers 304 to a matching If-None-Match until the transcript changes', async () => {
writeFileSync(transcript, jsonl(demoTurn()));
const { app } = await setup();
const first = await app.inject({ method: 'GET', url: URL });
const etag = first.headers.etag as string;
expect(etag).toMatch(/^W\/"story-/);
expect(first.headers['cache-control']).toBe('no-cache');
const again = await app.inject({ method: 'GET', url: URL, headers: { 'if-none-match': etag } });
expect(again.statusCode).toBe(304);
expect(again.body).toBe('');
appendFileSync(transcript, jsonl([prompt('next job')]));
const changed = await app.inject({ method: 'GET', url: URL, headers: { 'if-none-match': etag } });
expect(changed.statusCode).toBe(200);
expect(changed.headers.etag).not.toBe(etag);
// Another turn of the same file is another page, so another tag.
const other = await app.inject({
method: 'GET',
url: `${URL}?turn=x`,
headers: { 'if-none-match': changed.headers.etag as string },
});
expect(other.statusCode).toBe(200);
});
it('reads further back when a long turn does not fit the first tail', async () => {
// A prompt, then ~10 MB of tool results (Read results carry whole files), then the turn's end.
const big = 'x'.repeat(100_000);
const lines: unknown[] = [prompt('a turn longer than eight megabytes')];
for (let i = 0; i < 100; i++) lines.push(...bash(`b${i}`, `cat big${i}.txt`, `Show file ${i}`, big));
lines.push(end(60_000));
writeFileSync(transcript, jsonl(lines));
const { app } = await setup();
const page = (await app.inject({ method: 'GET', url: URL })).json().data.page;
expect(page).toMatchObject({ prompt: 'a turn longer than eight megabytes', partial: false, complete: true });
expect(page.toolCalls).toBe(100);
expect(page.panels[0].kind).toBe('mission');
});
it('a live turn grows as the transcript grows', async () => {
writeFileSync(transcript, jsonl([prompt('go'), ...read('r1', '/w/a.ts')]));
const { app } = await setup();