mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(story-mode): the rest of the PR review: lookups, polling, frame budget, linear links
From review round 7 (PR review): - With no hook path (normal after a server restart), the transcript was looked up by conversation id only; it now also tries the Codeman id and a recovered session's `restored-<fragment>` transcript, as last-response does, and keeps what it found briefly (a lookup reads every project folder; a miss is kept only 4 s). - A session Story Mode cannot draw is not polled, and a page with nothing drawn yet is polled like a finished one (every 15 s), not every 4 s. - Helper steps: one request per helper is drawn, the newest; a failed refetch never writes over steps already shown. - The Director's cut toggle, a skin change and the fonts arriving redraw within the frame budget, not every panel in one task. - A bare name under an `ls` header finds its folder in one pass over the lines; a search per link over the text before it was quadratic (about 1.5 s for 700 links on agent output). - A page of many questions still keeps to 12 panels: questions fold last. - Opening Story Mode while it is open never starts a second runtime; the status announcer carries data-i18n-skip; the turn key in the ETag is clipped. - The crafted-input timing test runs at sizes where the old code took 6 to 29 s and the new one under 10 ms, with a 1.5 s bound. - Docs: `older`, `origin` (`scheduled`), which route answers a 500; mobile.css's stale comment; an orphaned doc comment. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e7ce3b8e56
commit
42d1c75a19
10 files changed
+191
-41
No files matched your search
@@ -1056,16 +1056,16 @@ What Story Mode reads (the opt-in header button, App Settings → Header buttons
|
||||
|
||||
### Routes
|
||||
|
||||
- `GET /api/sessions/:id/story[?turn=<id>]`: 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=<id>` 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=<id>]`: 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=<id>` 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, `<conversation>/subagents/agent-<agentId>.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 `<conversation id>.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 `<conversation id>.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 (`<local-command-stdout>`, kept as the prompt event's `output`). A turn still open when the next prompt starts has ended too, and is marked `superseded`.
|
||||
|
||||
+9
-5
@@ -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;
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
@@ -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-<first 8+ hex of its uuid>`, 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<string | null> {
|
||||
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<string, string>();
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<b
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The hook-reported path when it is a transcript in a projects folder, else a lookup by conversation id. */
|
||||
/**
|
||||
* Transcripts found by id, briefly: a lookup reads every project folder, and a view without a hook
|
||||
* path (normal after a server restart) asks every few seconds. A miss is kept shorter, so a session
|
||||
* whose first prompt is on its way finds its transcript soon.
|
||||
*/
|
||||
const lookups = new LRUMap<string, { path: string | null; at: number }>({ 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<string | null> {
|
||||
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<string | null> {
|
||||
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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
+24
-6
@@ -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('<teammate-message teammate_id="a">x ', 400_000))],
|
||||
['system-reminder openers', prompt1('hi ' + rep('<system-reminder>x ', 400_000))],
|
||||
['task-notification field openers', prompt1('<task-notification>\n' + rep('<summary>x ', 400_000))],
|
||||
['git commit tokens', bashOut(rep('git commit ', 8000), 'ok')],
|
||||
['teammate openers', prompt1(rep('<teammate-message teammate_id="a">x ', 1_000_000))],
|
||||
['system-reminder openers', prompt1('hi ' + rep('<system-reminder>x ', 1_000_000))],
|
||||
['task-notification field openers', prompt1('<task-notification>\n' + rep('<summary>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', () => {
|
||||
|
||||
Reference in new issue
Block a user