not-your-slave · commit 5 of 6 · 5d117e6

The window: a stream that survives a refresh

One channel, three routes, and 537 lines of vanilla page. Almost every bug in here was a bug about where the transcript lives.

After these six commits

This page walks commit 5d117e6. The running tree is one shared TIMELINE address on the home channel; heartbeat is a handler that to(home, {}).sends onto that timeline. Chat and cron share the session — not /eve/v1/session chat, from("heartbeat"), or a new cron session each fire.

Background

Deep background: why streaming chat UIs are harder than they look

Skip if you have built one before.

A chat UI has to solve three problems that pull against each other. It must show text as it arrives, so the page feels alive. It must survive a reload, because people reload. And it must not show the same message twice, which sounds trivial until the first two requirements are in place.

The usual solution is for the client to keep its own copy of the conversation: append each message to an array, persist it, render from it. That works and it is what most chat apps do. It also means two sources of truth - the server's history and the client's array - and every bug in a chat UI you have ever hit is the seam between them.

eve offers a way out. Every event of a session is durably recorded before its step completes, and the stream endpoint takes an absolute cursor:

GET /eve/v1/session/:id/stream                       # follow live
GET /eve/v1/session/:id/stream?startIndex=0          # replay from the beginning
GET /eve/v1/session/:id/stream?startIndex=-1         # start at the tail
GET /eve/v1/session/:id/stream?startIndex=12&includeTailIndex=1   # catch up, then stop

If the server can replay the conversation on demand, the client does not need to store it. That single fact shapes the whole page.

Narrow background: custom channels

A file in agent/channels/ can declare HTTP routes with GET and POST helpers, and each route receives operation surfaces - most importantly from(address), which binds send and friends to a channel-local conversation address, creating a session if that address has none.

The gotcha that names the file

eve's framework default already serves GET /, from a source at channels/home.ts. Declaring GET / in channels/web.ts compiles, then warns: "GET / from channels/web.ts is shadowed by channels/home.ts". Framework defaults occupy ordinary agent slots, so authoring the same path replaces the default - which means the file has to be called home.ts. A five-second fix that is invisible until you read the warning.

Intuition

The interesting design decision is the one that got made twice. Here is the first version, which is what you write if you think of a stream as a request:

attempt 1: attach per message send → open a stream → read until the turn ends → close. Repeat next time.
Reasonable-looking, and wrong.

The bug it produces is instructive. Open a stream with no cursor and you get the session from the beginning, so the events of the previous turn arrive first. The page happily paints the previous answer into the bubble you just created for the new one - so you ask a question, and watch the answer to your last question appear above your reply that has not been written yet. It looks like a race condition. It is actually a cursor problem.

attempt 2: one long-lived reader this commit: on load, replay from index 0 to restore history, then follow from the tail forever. Sending a message adds nothing to the read path.
This commit: the client stores no transcript at all. There is only one source of truth, so there is nothing to drift.

Concretely, this commit, with a session that already has a two-turn history:

load?startIndex=0&includeTailIndex=1 replays four events. Four bubbles painted. The response ends by itself.
load+1?startIndex=-1 attaches at the tail and stays open.
you typePOST the message. Nothing is attached, nothing replayed. The open reader sees message.received, then the deltas.
reloadSame as load. Six bubbles, no duplicates, because nothing was ever stored client-side to duplicate.

What the page looks like

an agent that thinks for itself · idle

YOU
what's up?

IT
Two things since you last asked. One: I went looking for the mindlog as an actual file…

mindlog · wake it
14:14:00  HEARD  what's up?
14:14:05  THOUGHT  User asked mid-conversation…
14:14:16  SAID  **Two things since you last asked.** One…
Left pane renders markdown, right pane deliberately does not. The right pane is a log: it shows the characters that were actually written.

The code

The channel: 25 lines

export default defineChannel({
  routes: [
    GET("/", async () =>
      new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } })),

    GET("/api/mindlog", async (request) => {
      const limit = Number(new URL(request.url).searchParams.get("limit") ?? 80);
      return Response.json({ entries: await read(Number.isFinite(limit) ? limit : 80) });
    }),

    // Fire a heartbeat by hand. `eve dev` never runs cron, so this is how you
    // watch it think without waiting for production's next tick.
    POST("/api/think", async (_request, { from }) => {
      const session = await from("heartbeat").send(HEARTBEAT, { auth: null });
      return Response.json({ sessionId: session.id });
    }),
  ],
});

In this commit, chat was conspicuously absent: the page talked to eve's own /eve/v1/session API for that, so the channel only supplied what eve did not already have. Three things to notice.

The reader

async function follow(id, startIndex) {
  while (id === sessionId) {
    try {
      const res = await fetch("/eve/v1/session/" + id + "/stream?startIndex=" + startIndex);
      if (!res.ok) return;
      const reader = res.body.getReader();
      …
      for (const line of lines) { handle(JSON.parse(line)); }
    } catch {}
    startIndex = -1;              // reconnect at the tail, not the beginning
    await new Promise((r) => setTimeout(r, 1000));
  }
}

An NDJSON reader with a reconnect loop. In this commit, two details did real work: the loop condition tied the reader's life to the session it was started for, so switching sessions retired the old reader; and the reconnect set startIndex = -1, because a dropped connection had to resume at the tail rather than replay the conversation.

function handle(event) {
  switch (event.type) {
    case "turn.started":      statusEl.textContent = "thinking"; break;
    case "message.appended":  if (!live) live = bubble("it", "it", "");
                              setMessage(live, data.messageSoFar || ""); break;
    case "message.completed": if (data.message) setMessage(live ?? (live = bubble("it","it","")), data.message);
                              live = null; void refreshMindlog(); break;
    case "turn.failed":       bubble("it", "it", "[" + (data.message || "turn failed") + "]"); break;
    case "turn.completed":
    case "session.waiting":   statusEl.textContent = "idle"; void refreshMindlog(); break;
  }
}
Key concept: cumulative deltas

message.appended carries both messageDelta (the new characters) and messageSoFar (everything in this block). Rendering messageSoFar is idempotent, so a coalesced or replayed event cannot corrupt the text - whereas appending messageDelta would duplicate on replay. It also explains commit 6's discovery that one session's event store had grown to 2.6 GB: every delta stores the whole message so far, so a long reply is written repeatedly at growing length.

Restoring history

async function restore(id) {
  const res = await fetch("/eve/v1/session/" + id + "/stream?startIndex=0&includeTailIndex=1");
  if (res.status === 409) {                  // session is gone
    sessionId = null;
    localStorage.removeItem("nys.session");
    return;
  }
  … for each event:
      if (event.type === "message.received")  bubble("me", "you", data.message);
      else if (event.type === "message.completed" && data.message) bubble("it", "it", data.message);
  void follow(id, -1);                        // then go live
}

In this commit, includeTailIndex=1 asked for a catch-up read that ended rather than following, so this loop terminated on its own and handed over to follow. The whole of "chat history across refreshes" was these fifteen lines. The running tree restores spoken history from the mindlog and attaches the stream at the tail.

Rendering markdown without letting it become markup

The model writes markdown, so the pane renders a small subset of it: paragraphs, bullets, numbered lists, headings, fenced code, and inline bold, italic and code. The safety property is structural rather than a sanitiser:

const node = document.createElement(tag);
node.textContent = bold ?? italic ?? code;   // never innerHTML
into.append(node);
…
if (at < text.length) into.append(text.slice(at));   // plain text as text

Every piece of model output enters the DOM as a text node. There is no path by which a <script> in a reply becomes a script, because nothing is ever parsed as HTML.

The mindlog pane keeps raw text on purpose. It is a log, and a log that reformats its contents is lying slightly: seeing **bold** as characters is the honest view of what was written to the file.

Smaller decisions, briefly

Quiz

Question 1

Attaching a fresh stream on every send produced the previous answer appearing under the new question. Why?

Question 2

Chat history survives a refresh with no client-side transcript. What makes that possible?

Question 3

Why render messageSoFar instead of appending messageDelta?

Question 4

The channel file has to be named home.ts. Why?

Question 5

The chat pane renders markdown from the model. What stops a reply containing <script> from executing?

Answer a question to start scoring.

Next: the last commit measures what actually survives a wipe, and closes the gap that measurement exposes.