The goal scenario asserted a paused revision-2 goal, but the pause is
appended only after cancellation reaches idle — after turn/end. Under
parallel snapshot files the subprocess could dispose before the pause
record persisted, folding the log to an active revision-1 goal.
Add a waitForEventAfterTurnEnd input step (the turn-end/title waiters'
shape, parameterized by event type) and use it in the goal scenario to
hold the subprocess open until the goal-state record lands.
The snapshot config serialized all 13 files for every mode, but the
fixture-writing rationale only holds for record (real API quota per
scenario) and refresh (write-back harvests volatile values from
fixtures on disk). Replay — the keyless default and the CI mode — is
read-only with a unique temp dir per scenario subprocess, and the
suite factory already runs replay scenarios concurrently in-file.
Narrow the serialization to non-replay modes: pnpm test:snapshot drops
from ~49s to ~33s wall; record/refresh stay fully serial.
Resuming a long session (196k events, 2.2k steps, 1.8k tool cards) took
~12s to render and ~800ms to echo one keystroke:
- Every step's timing footer called stepTimingAt, which replayed the whole
event log per footer - O(steps x events) on the initial render.
- pi-tui re-renders every component each frame and relies on per-component
line caches, but ToolCardComponent/ContextCardComponent built throwaway
Text/Markdown instances inside render(width), re-wrapping every settled
card's output on every keystroke.
Replace the per-footer replay with one shared StepTimingTracker per chat
mount (single O(events) cursor over the append-only log), and cache card
rows by width via CardLineCache, dropped by every state mutator and
invalidate().
Measured (tmux 200x50, 196k-event session): resume prompt-ready ~12s -> ~7.6s;
per-keystroke echo ~800ms median -> ~11ms.
session-title already registers a title projection unit; /resume now
reads it instead of scanning logs: live rows from the registry
snapshot, persisted rows from the durable checkpoint row
(cachedSnapshot, zero I/O), and only rows without a usable checkpoint
pay a coldSnapshot — checkpoint plus readFrom tail, written back so the
next scan is metadata-only. Cold reads are bounded by the new
resumeScanConcurrency config; compositions without the cache fall back
to the bounded readTitleSnapshots batch. The TUI overlay mounts the
projection registry, storage, and projection-cache rows over the same
storages root the web surface uses, so checkpoints serve both.
TurnEndReasonMap aborted endings no longer map to 'cancelled': the ACP
spec reserves StopReason::Cancelled for the client's own session/cancel
notification (plus disposal, both settled out of band). A turn aborted by
a hook or another agent owner is ordinary quiescence and now settles as
end_turn, matching the README/note contract. interrupted keeps
'cancelled' as the crash-orphan marker.
- turn boundary now returns false for an empty admitted batch (claimed
input removed before the wake) instead of opening a turn and spending a
model call on nothing; the step boundary already had the symmetric guard.
- a max-token step stays sticky when steering or injected work continues
the turn: a later completed step no longer downgrades the outcome,
matching the TurnEndReasonMap contract.
- session/queue wire schema accepts the context placement (previously the
zod union rejected injected-context snapshots wholesale and the client
silently dropped the whole frame); schema tests cover all placements.
- headless runs settle at whole-agent idle instead of the first turn/end,
honoring the one-shot idle-to-idle contract.
- flush JSDoc names the real callers (checkpoint policy, goal-session,
teardown, self-flushing consumers); apiproxy zh README loses its stale
duplicate history section; ACP note/README record the delivered error
rejection and turnless-cancelled behaviors.
Appending todo/write, request/header, and request/context inside an open
turn now exercises the switch cases and the enclosed-branch of the
invariant, closing the last branch gap.
- invariant.spec: a turn/end whose step does not match the last opened
step fails with 'expected last step'.
- session.spec: a seed message whose content is not an array fails with
'message has invalid content'.
The boundary marker row is a 0-height hairline except at the table tail;
the marker button is absolutely positioned and visible anywhere, so the
terminal-marker e2e waits on the button. Also refresh the
cordis-inspect-jsdoc fixture to the typed error reason (LlmFailure).
Making `.sources` a scroll container turned its `padding-left` from spacing
into a correctness constraint. A scroll container clips inline-start overflow
with no way to scroll it back, and `::marker` is right-aligned to the content
edge, so past nine sources the markers rendered as `0.` and `1.` where `10.`
and `11.` belonged.
`searchMaxResults` is an unbounded positive integer, so size the padding in
`em` against the list's own font to hold a three-digit marker. The browser e2e
measures a `999. ` marker in that inherited font and requires the computed
padding to be at least that wide, pinning the room against the widest marker
rather than one fixture's source count.