Flow: the follow — snapshot, then frames; held, replayed, live
A browser’s view of a thread is one GET /api/threads/:id/follow: a snapshot exact as of a
sequence number, then every later observation with a greater one, and status frames as the
thread’s busy flag and generation move. There is no cursor to resume from, because the snapshot is
the resync: a reconnect is a new follow and a new snapshot (#112, #114). Beneath the wire, the host
holds events that happen before anyone subscribes and replays them to the first subscriber, and its
tail records how each was delivered — held, replayed, live, dropped — which is the ordering
evidence #88 and #69 were reconstructed without (#97).
The sequence
Section titled “The sequence”sequenceDiagram
participant B as browser (follow-connection.ts)
participant R as follow route (server/follow.ts)
participant T as thread registry
participant H as host (agent-host.ts)
participant P as runtime
Note over H,P: before any subscriber: events are HELD (session_start entries, picc's opening mode)
B->>R: GET …/follow
R->>T: observe(threadId) — same tick as the snapshot
T-->>R: asOfSeq
R->>H: inspect().entries (live) · readStoredThread (cold)
R-->>B: snapshot { asOfSeq, live, busy, generation, permissionMode, model, cwd, usage, messages, openDialogs }
Note over H: first subscriber: held events REPLAYED, origin `session-start`, tail says `replayed`
P->>H: event
H-->>T: observation (LIVE)
T-->>R: frame { seq: asOfSeq+n }
R-->>B: data: { type: "observation", seq, observation }
P->>H: agent_settled / busy flips
T-->>R: status { live, busy, generation }
R-->>B: data: { type: "status", … }
B->>B: reconnect ⇒ a NEW follow ⇒ a NEW snapshot · nothing is resumed
The invariants it shows
Section titled “The invariants it shows”- The snapshot is exact as of
asOfSeq, and no observation falls between the two. The route subscribes and reads the state in the same tick (server/follow.ts#followThread); every later frame’sseqis greater. Two followers of one thread see every frame with the sameseq. - A cold thread is an empty snapshot, not a 404. Observation may precede the first prompt, and must — or the prompt’s opening events would have no follower. The cold read is awaited before subscribing, so a thread built during the read is then seen live.
- Held until the first subscriber; never held after. Events before anyone listens are kept and
replayed to the first subscriber with origin
session-start; once a subscriber has existed, an event with nobody listening isdropped, not queued for the next one. This is what closed #88 (an entry emitted to nobody) without reopening #69 (a settle replayed into the next run’s pump). - Provenance is recorded, coalesced, in a ring. The host’s tail names each delivery
held,replayed,liveordropped; consecutive events of one type and provenance fold into one row with an exact count; old rows fall off the front. - The snapshot’s lists are complete, so the browser replaces rather than appends.
openDialogsis every question the thread is blocked on now — an empty list clears a card for a question that settled while the page was away.
The frame contract
Section titled “The frame contract”/** * The server↔browser wire (#112): a thread's feed, as the follow route serves it. * * Every follow opens with exactly one `snapshot` — the thread as of `asOfSeq` — and then * carries only `observation` frames (each `seq` greater than the last) and `status` * frames (the thread's lifecycle as the server sees it). A reconnect is a new follow and * a new snapshot; there is no cursor to resume from, because the snapshot IS the resync * (deepseek-harness: one complete snapshot per generation, then only deltas). * * The payload is `EnsoObservation` — the same layer the event tails and the debug pane * reason in. AG-UI is not on this wire; a browser adapter derives it if a renderer wants it * (`docs/architecture.md`, decided constraints). */export const EnsoFollowSnapshot = Type.Object( { type: Type.Literal('snapshot'), threadId: Type.String(), /** The feed's position this snapshot is exact as of; every later observation's `seq` is greater. */ asOfSeq: Type.Integer({ minimum: 0 }), /** A pi session exists for the thread on this server. False: served cold from the file, or empty. */ live: Type.Boolean(), busy: Type.Boolean(), /** See `EnsoThreadSummary.generation`. */ generation: Type.Integer({ minimum: 0 }), permissionMode: EnsoPermissionModeState, /** The session's model and thinking level (#338 slice 2); `null` when the thread is not live — a stored thread does not say. */ model: Type.Union([EnsoModelState, Type.Null()]), /** * The tree pi reads and writes for this thread (#44), AS OF THIS SNAPSHOT — the live session's * own cwd; when the thread is not live, the stored session's (pi fixed it at creation); and * for a thread with neither, its project's (#395), where it will be built — or `''` when that * project has been removed, so it will be built nowhere (its prompt is refused). Not re-sent when a * session is built mid-follow: it cannot differ, since a new thread is built in exactly that * directory. What makes a wrong-project session visible. * * ⚠ Adding a field here is wire-breaking for an OPEN TAB: the schema is closed, so a page and * server of different versions reject each other's snapshots. The page says so and asks for a * reload (`follow-connection.ts`) rather than going quietly stale. */ cwd: Type.String(), /** * What the session has spent and how full its context is (#45), as of this snapshot — pi's * totals from the live session, or from the file when the thread is not live (then with no * `context`). Later changes arrive as `session-usage` observations, each the whole state. */ usage: EnsoSessionUsage, /** The branch as TanStack `UIMessage`s — `Unknown` because the shape is the renderer's, not enso's. */ messages: Type.Array(Type.Unknown()), /** Every question pi is blocked on right now, oldest first (#114). Empty when not live. */ openDialogs: Type.Array(EnsoOpenDialog), interrupted: Type.Optional(EnsoInterruptedTail), }, { additionalProperties: false },)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.
- Invariant 1 —
follow.test.ts: two followers share one feed with the sameseq; one leaving detaches nothing.
- Invariants 1 and 5 — a follow opened under an open question gets it in the snapshot; the settle reaches every follower.
- Invariant 3, the #88 half —
agent-host.test.ts: an entry persisted duringsession_startreaches the first subscriber.
- Invariant 3, the #69 half — held only until the first subscriber; afterwards dropped, not replayed.
- Invariant 4 — the tail records
held,replayed,live,dropped.
- Invariant 3, as the browser sees it — a replayed observation carries origin
session-start; a run’s carriesrun.
- Invariant 4, the coalescing rule —
event-tail.test.ts: a change of provenance or name starts a new row.
- The browser’s side —
follow-connection.test.ts: the snapshot crosses whole, named by its type, and the router reads mode, open questions and generation off it (#162 workstream 5).
Where it is stated
Section titled “Where it is stated”The wire: seams/observation.md. The host’s delivery and the tail:
packages/web/src/host/event-tail.ts (header) and agent-host.ts#subscribe. The route:
packages/web/src/server/follow.ts#followThread. The decision that AG-UI is derived in the browser
and never on the wire: architecture.md → Decided constraints.