Skip to content

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).

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
  1. 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’s seq is greater. Two followers of one thread see every frame with the same seq.
  2. 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.
  3. 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 is dropped, 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).
  4. Provenance is recorded, coalesced, in a ring. The host’s tail names each delivery held, replayed, live or dropped; consecutive events of one type and provenance fold into one row with an exact count; old rows fall off the front.
  5. The snapshot’s lists are complete, so the browser replaces rather than appends. openDialogs is 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 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 },
)

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 same seq; 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 during session_start reaches 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 carries run.
  • 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).

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.