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): the ordering evidence #88 and #69 were reconstructed without (#97).

Recorded by follow.test.ts: ⚠ two followers share one feed: both see every frame with the same seq, and one leaving detaches nothing. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.

sequenceDiagram
  participant B as first tab (the test client)
  participant C as second tab (the test client)
  participant R as follow route (server/follow.ts)
  participant T as thread registry
  participant P as runtime (fake host)
  participant F as feed (observation log)
  B->>R: GET /api/threads/t1/follow
  Note over R: snapshot as of seq 0: live false, busy false, run 0, 0 open questions
  C->>R: GET /api/threads/t1/follow
  Note over R: snapshot as of seq 0: live false, busy false, run 0, 0 open questions
  B->>R: POST /api/threads/t1/prompt {message: "hello"}
  Note over T: status to followers: live true, busy false, run 0
  Note over T: thread built: session s1 in /s
  Note over T: status to followers: live true, busy true, run 1
  Note over T: run 1 acquired
  R->>P: prompt "hello" { onAccepted, onRejected }
  Note over R: prompt [id] opened on run 1
  P->>F: text-delta
  Note over F: text-delta
  Note over F: run 1 first delta after [ms]ms
  B->>R: closes its follow
  P->>F: settled
  Note over F: settled
  Note over T: status to followers: live true, busy false, run 1
  Note over T: run 1 released · idle timer [ms] ms

Recorded by agent-host.test.ts: ⚠ the emitted tail records each event’s provenance: held at startup, replayed to the first subscriber, live, dropped (#97). Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.

sequenceDiagram
  participant S as subscriber (the test)
  participant H as host (agent-host.ts: emit, subscribe, the tail)
  S->>H: emittedEvents(), before anyone subscribed
  H-->>S: the tail: held
  S->>H: subscribe(listener)
  H->>S: observations, origin session-start
  S->>H: emittedEvents()
  H-->>S: the tail: held, replayed
  S->>H: prompt "/enso-late-notify", subscribed
  H->>S: observations, origin run
  S->>H: unsubscribe()
  S->>H: prompt "/enso-late-notify", nobody subscribed
  S->>H: emittedEvents()
  H-->>S: its notify rows since: live, dropped

The snapshot is exact as of asOfSeq, and no observation falls between the two.

Section titled “The snapshot is exact as of asOfSeq, and no observation falls between the two.”

The snapshot is read through the one live-or-cold reader (thread-read.ts#readThreadMount, #214), the same read /history serves, so the two routes cannot disagree about a thread that goes live mid-read; and it is taken in the same tick as the subscription, so every later frame’s seq is greater (dsh: one complete snapshot per generation, then only deltas; reconnect = a new follow = a new snapshot). Two followers of one thread share one feed and see every frame with the same seq; one leaving detaches nothing.

Stated at followThread in server/follow.ts.

Pinned by:

  • follow.test.ts: ⚠ a question answered in one tab is settled on every follower’s feed, and a follow opened under it gets it in the snapshot (#114)
  • follow.test.ts: ⚠ two followers share one feed: both see every frame with the same seq, and one leaving detaches nothing

A cold thread is an empty snapshot, not a 404.

Section titled “A cold thread is an empty snapshot, not a 404.”

A thread with neither a session nor a file is an EMPTY snapshot (present is ignored here): observation may precede the first prompt, and must, or the prompt’s opening events have no follower. The cold read is awaited BEFORE subscribing, so a thread built during the read is then seen live.

Stated at followThread in server/follow.ts.

Pinned by:

  • follow.test.ts: prompt answers at admission with a message id; the run is released by pi’s settle, no HTTP response in between

The snapshot’s lists are complete, so the browser replaces rather than appends.

Section titled “The snapshot’s lists are complete, so the browser replaces rather than appends.”

openDialogs is every question the thread is blocked on now (#114): a reconnect’s snapshot is the resync, so an empty list is a fact (“nothing is waiting”) this page must apply too; it clears a card for a question that settled while the page was away. One card at a time: a second concurrent question shows once the first settles and the next snapshot lists it.

Stated at routeEnsoCustomEvent in custom-event-router.ts.

Pinned by:

  • custom-event-router.test.ts: the snapshot’s openDialogs is the complete list: it replaces the card, an empty list clears it, a malformed snapshot moves nothing (#114)
  • follow.test.ts: ⚠ a question answered in one tab is settled on every follower’s feed, and a follow opened under it gets it in the snapshot (#114)

Held until the first subscriber; never held after.

Section titled “Held until the first subscriber; never held after.”

Records emitted before the FIRST subscriber (what an extension says during session_start: multi-account’s startup notify, a dialog a probe opens, an extension’s session_start entry) are held and replayed to it with origin session-start, so the browser sees them exactly as it saw pi’s startup stdout over the pipe; the replay is recorded again, as replayed, when it actually reaches a subscriber (see subscribe).

Never between runs: the session lives across runs (#69 PR 2) and each run subscribes its own pump, so holding would replay an aborted run’s agent_settled into the NEXT run’s pump and end it before its first token (the disconnect-then-prompt race). Once a subscriber has existed, an event with nobody listening is dropped, not queued for the next one, like status heartbeats with no rpc client reading the pipe. That closed #88 (an entry emitted to nobody) without reopening #69 (a settle replayed into the next run’s pump).

Stated at emit in host/agent-host.ts.

Pinned by:

  • agent-host.test.ts: ⚠ an entry an extension persists during session_start reaches the first subscriber (#88)
  • agent-host.test.ts: ⚠ events are held only until the FIRST subscriber; with nobody listening afterwards they are dropped, not replayed
  • agent-host.test.ts: ⚠ the emitted tail records each event’s provenance: held at startup, replayed to the first subscriber, live, dropped (#97)
  • agent-host.test.ts: ⚠ subscribe delivers startup records with origin session-start and a run’s with run (#96)

Provenance is recorded, coalesced, in a ring.

Section titled “Provenance is recorded, coalesced, in a ring.”

The host’s tail names each delivery held, replayed, live or dropped (the header’s ordering evidence); consecutive events of one type, name and provenance fold into one row with an exact count, so a change of provenance or name starts a new row; old rows fall off the front.

Stated at createEventTail in host/event-tail.ts.

Pinned by:

  • agent-host.test.ts: ⚠ the emitted tail records each event’s provenance: held at startup, replayed to the first subscriber, live, dropped (#97)
  • event-tail.test.ts: consecutive events of one type, name and provenance fold into one row with an exact count
  • event-tail.test.ts: ⚠ a change of provenance or name starts a new row — held vs replayed is the ordering evidence
/**
* 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 — nothing is built to say what pi would run. */
model: Type.Union([EnsoModelState, Type.Null()]),
/** What a NOT-live thread's branch records it last ran on (#434); absent when live, or when the branch records neither. */
recordedModel: Type.Optional(EnsoRecordedModel),
/**
* 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),
/** A login waiting on its provider (#348), which Stop can end; absent when none is. */
login: Type.Optional(EnsoLoginInProgress),
},
{ additionalProperties: false },
)