Flow: resume — a stored thread, from the rail to live
A stored thread is one whose log the runtime has flushed — nothing reaches disk before the first
assistant message, so a thread that has only run slash commands has nothing to come back to
(agent-host.ts#listSessions, its comment). Resume is not a code identifier (glossary, Thread →
states): the rail’s listing, the URL naming the thread, GET …/history read cold from the file,
adoption in the browser under the same id, then the first prompt building the runtime over that
file. The thread id is the runtime’s session id (#95): no mapping, no index file, across a restart.
The sequence
Section titled “The sequence”sequenceDiagram
participant B as browser (chat-screen.tsx · thread-url.ts)
participant S as server (server/index.ts)
participant F as follow route (server/follow.ts)
participant T as thread registry
participant H as host (agent-host.ts)
participant D as the runtime's log on disk
B->>S: GET /api/stored-threads
S->>H: listSessions() — the runtime's own listing, one header parse per file, newest first
S-->>B: EnsoStoredThread[] { threadId, title = first user message, lastActivityAt, messageCount, busy = peek }
B->>S: GET /api/threads/:id/history — readThreadUrl(?thread=) at boot, or a rail click
S->>T: peek(threadId) — never builds
S->>H: readStoredThread(threadId) when cold · inspect().entries when live
H->>D: open the file into memory, walk the branch → transcriptOf
S-->>B: EnsoThreadHistory { messages: transcriptToMessages, permissionMode: storedPermissionMode, interrupted? }
B->>B: adoptThread — stores under the stored id, mode applied, urlForThread written
B->>F: GET …/follow
F-->>B: snapshot { live: false, messages, permissionMode, usage (the file's, no context), openDialogs: [] }
B->>S: POST …/prompt
S->>T: acquire → createSession(threadId)
H->>D: the file already under that id is OPENED, not a fresh one
T-->>F: status { live: true, busy: false, generation: 0 } · status { busy: true, generation: 1 }
F-->>B: the same follow, now live — nothing resumed by cursor
The invariants it shows
Section titled “The invariants it shows”- One id, end to end.
createSessionresolves the thread id through the same listingreadStoredThreaduses: a file under that id is opened, none means one is created under it, so the file it later flushes is found next time. The browser adopts under the history’sthreadId(thread-stores.ts#adoptThread), so its next prompt reopens the same file. - Looking must not build (#86).
readStoredThreadopens the file into memory and writes nothing until an append; the history routepeeks the registry, and a thread neither live nor stored is a 404 — while the follow’s cold read is an empty snapshot, so observation may precede the first prompt (follow-snapshot-frames.md, invariant 2). - The rail is the listing, newest first, titled by the first user message. A blank first
message is no title (
server/index.ts#respondWithStoredThreads).busymarks a thread a run on this server holds; the row is shown, not locked (#114), and an idle thread the server still holds is resumable — held is not busy (PR #104 review). - The opening mode is the branch’s last
modescustom entry, or the fresh state.storedPermissionModeStatewalks the transcript from the end overcustomentries and, building nothing, offers the full registered set; a live thread answers from the runtime, fresher than the file. - The transcript is ours; the reader assembles and never decodes. Below the seam
host/transcript.ts#transcriptOfreads the runtime’s stored entries defensively — same length, same order, an unmodelled kind isotherwith itstype. Above ittranscriptToMessagespairs a tool result into the assistant message holding its call, drops one whose call is not on the branch, skipscustomandother; an image’s bytes render as a data URL, never a fetch (selected-message-part.tsx). A refused attempt (#381) — pi keeps it on the branch withstopReason: "error"— is a message carrying itsrefusal, and its bubble says<provider>/<model> refused: <class> (<status>) — <reason>where the reply would have been, not an empty bubble. - The URL names the thread; the latest navigation wins.
readThreadUrl: a thread id resumes, none is fresh, a malformed one is fresh and said;urlForThreadkeeps every other parameter. The boot resumes withpop(no history written), a rail click pushes, and two guards drop a late answer whole:createResumeRequests(last click wins) and the page’sepoch(a navigation since). - Cold, then live, on one follow. The pane’s follow opens before any run and reads
live: false; the first prompt acquires,createSessionopens the file, and the same follow carriesstatus live: truethenbusy: true, generation: 1.
The transcript contract
Section titled “The transcript contract”What crosses the storage seam on every cold read, and what inspect().entries holds live:
export const EnsoTranscriptEntry = Type.Union([ EnsoTranscriptMessage, EnsoTranscriptToolResult, EnsoTranscriptCustom, EnsoTranscriptOther,])The tests that pin it
Section titled “The tests that pin it”Each marker below is checked by test/docs-gate.test.ts: the file must declare a test with exactly
that title, so a renamed or deleted pin fails this page.
- Invariants 2 and 3, at the seam —
agent-host.test.ts, against the real session manager: order, first message, count,undefinedfor an id with no file.
- Invariant 1, at the seam — the inspection’s
sessionFileis the stored file; its entries, the file’s.
- Invariant 3 —
server-handler.test.ts: the wire rows, a blank first message as no title,busyfrom the registry.
- Invariants 2 and 4 — the cold history with
mode: plan, nothing built; an unknown id a 404.
- Invariants 3 and 4, the live side — an idle held thread is not busy, and its history is the runtime’s answer, not the file’s.
- Invariant 4 —
permission-mode-adapter.test.ts.
- Invariant 5, images —
transcript-to-messages.test.ts: the file in, the parts out, an unknown kind a visible marker.
- Invariant 6 —
thread-url.test.ts.
- Invariant 1, in the browser —
threads-rail.test.tsx: adopted under the stored id; a second adoption returns the same stores; a resumed thread never opens the Start-a-session dialog.
- Invariant 7 —
follow.test.ts: the empty cold snapshot, then the two status frames as the thread is built and taken.
- Invariants 4 and 6, end to end —
chat.e2e.ts: the persisted mode after a reload and on a pasted link.
- Invariant 6, the slow boot — the late history changes nothing.
Where the messages come from. The pane’s messages come from /history (adoptThread); the snapshot
carries the same messages, and custom-event-router.ts reads one fact off them: whether the last
assistant message is a refused attempt, which sets the status bar’s class word (#381) with the
snapshot’s output as the mark a later reply must grow past. The image’s rendering from a resumed
history is pinned at the page (below), beside the reader’s pin. The host’s name for the listing is
listSessions; there is no listThreads.
- The image, at the page —
thread-pane.test.tsx: a resumed history’s image is an<img>with the bytes inline.
- Invariant 5, a refused attempt — the stored branch in, the refusal on the attempt’s message, none on the reply.
- The refused attempt, at the page — its line between the prompt and the reply.
- The status word on resume —
custom-event-router.test.ts.
Where it is stated
Section titled “Where it is stated”The seam: seams/storage.md — the listing, the cold read, the open-or-create.
The mode walk: seams/permission-mode.md. The words — stored thread,
resume, live / cold, session reserved for the log: glossary.md → Thread.
The URL as source of truth: thread-url.ts (header) and chat-screen.tsx → BOOT, navigate.