Two bugs this milestone, both about a turn outliving the moment that created it. One was about a document: convert it once, or convert it forever? The other was about the turn itself: does closing the laptop lid actually have to kill the answer that was almost done?
Why PDFs are converted once, not on every turn
The composer already accepted images. Attaching a PDF looked like it should be the same feature with a different file type, but it isn’t, because of what happens to that file on every later turn of the same conversation.
The obvious approach is lazy conversion: store the PDF, and every time the model needs to see it, call the markitdown sidecar (the file-to-markdown converter from Part 13) fresh and hand back the result. I didn’t build it that way, because “fresh” is exactly the problem. Markitdown’s PDF-to-markdown output isn’t guaranteed byte-identical across runs, and the session log’s LLM projection is prefix-stable by design: appending new events never rewrites an earlier projected message, because provider prompt caches key off that stable prefix (see the projection rules in Part 6). Re-converting the same PDF on turn five and getting even slightly different markdown than turn one would silently rewrite a message that already sits inside the cached prefix, and the whole point of that invariant is that it never happens.
So a PDF is converted exactly once, at message-send time, and the resulting markdown is persisted directly on the user_message event next to the attachment’s id and mime type:
// internal/brain/session/events.go
// Documents are PDF attachments converted to markdown once, at
// message-send time (chat.Chat), with the markdown persisted here.
// Re-converting per turn would re-call the markitdown sidecar every
// turn, and any output drift would rewrite an earlier projected
// message, breaking LLMContext's prefix stability that provider
// prompt caches depend on.
Documents []DocumentRef `json:"documents,omitempty"`
From then on, projection just appends the already-converted text as a plain block. No store round-trip, no sidecar call, nothing that could ever produce a different string for the same message twice.
Documents deliberately never touch the vision route that images use. A PDF converted to markdown is text, and routing it through a vision-capable model would be paying for a capability the message doesn’t need. Only an actual image ref flips the route; a document ref rides the model’s ordinary text route regardless of what’s attached alongside it.
The UI follows the same rule attachments have followed since images landed: it gets an id and a mime type, never the markdown itself. TranscriptItem.Documents is []ImageRef, the same ref type images use, specifically so a converted document’s full text (which can be substantial) never has a reason to cross into a browser payload.
One thing I got wrong the first time and had to fix in review: nothing capped how much markdown a single PDF could contribute. The compactor summarizes old turns to keep the session under budget, but it only ever touches previous turns, never the one currently being sent. A large enough PDF could blow straight through the context window on the very turn that attached it, with no compaction pass able to help because there’s nothing older to summarize yet. The fix caps converted markdown at 128KB with a visible truncation marker appended, so an oversized document degrades to “here’s most of it, clearly marked as cut” instead of quietly wrecking the turn it arrived on.
The bug: closing the tab killed an almost-finished answer
Before this milestone, a chat turn’s lifetime was the HTTP request’s lifetime. POST /v1/chat opened a context, the agent loop ran on that context, and when the request context was cancelled, so was the loop. That’s a completely ordinary way to wire a Go HTTP handler. It’s also wrong for a chat turn, and the wrongness only shows up in a very specific, very ordinary moment: someone navigates away, or reloads, or their laptop sleeps mid-answer.
The browser aborting its request cancels that request’s context. Before this fix, that cancellation propagated straight into the tool loop underneath the gateway call, which had no way to tell “the user politely asked me to stop” apart from “the network died.” Either way, the loop tore down mid-turn, and the session ended up with a turn_failed event, even when the model was seconds away from a complete answer. Reopening the session showed a failure that, from the model’s perspective, wasn’t one.
The fix is to stop conflating two different things that happened to share one context: the HTTP request that started the turn, and the turn itself. The turn’s lifetime now belongs to the session, the same way a session’s event log already outlives any single request that reads or writes to it.
Detached, but not unkillable
Chat and Retry now build a second context for the turn to actually run on:
// internal/brain/chat/chat.go
turnCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), s.turnTimeout)
bc.setCancel(cancel)
context.WithoutCancel keeps whatever values rode in on the request (auth, trace id) but drops its cancellation entirely. The agent loop, the gateway stream, the tool calls underneath it, all of it now runs on turnCtx, which nothing about the browser closing can touch.
Removing cancellation outright would trade one bug for a worse one: a request that leaks forever if something upstream hangs, or a user who genuinely wants to stop a turn and has no way to. So turnCtx isn’t cancellation-free, it’s cancellation-owned differently. It carries its own 30-minute ceiling, and the only other thing that can cancel it early is an explicit stop call, stored via bc.setCancel on the turn’s broadcaster the moment the context exists.
Thirty minutes specifically, not some rounder or shorter number, because it has to outlive something else already in the system: the tool loop’s own 10-minute permission-park timeout, from the interactive permission prompt that pauses a turn waiting on the user’s yes/no. If the ceiling were shorter than that park window, a turn legitimately waiting on a permission decision could get killed by its own safety net before the user ever answered the prompt. Thirty minutes gives a parked turn the full ten to wait, plus real headroom for whatever runs before and after that wait, without leaving the ceiling so long that a truly stuck turn burns resources for the better part of an hour.
That last case is the actual cost of this design, and it’s worth being honest about it: a turn that hangs for a reason nobody anticipated, say a provider that stops responding without ever erroring, now runs for up to 30 minutes before its own ceiling ends it, instead of dying the instant a browser tab closes. That’s the trade. I’d rather eat an occasional slow cleanup than keep failing turns that were seconds from finishing.
What client disconnect still does
With the request context out of the loop’s way, something still has to notice when nobody’s listening anymore. That’s relay’s job now, and it runs on two contexts at once on purpose: the detached turnCtx for the actual work, and the original request’s reqCtx purely to decide whether this particular browser tab is still around to stream to.
When reqCtx ends, relay stops forwarding events to that client’s out channel and falls into drainAndPersist, which keeps consuming the still-live upstream until the turn actually finishes, publishing every event to the turn’s broadcaster the whole time:
// internal/brain/chat/broadcast.go
drainAndPersist := func() {
// out closes FIRST, before draining upstream: upstream is bound
// to turnCtx (session-owned), not reqCtx, so once the client is
// gone it may still have minutes left to run.
close(out)
for ev := range upstream {
...
}
s.persistTurn(...)
}
The turn completes and persists exactly as if the client had stayed connected. If the user comes back, the /live re-attach endpoint (the same one that makes a session resumable after a reload, from Part 6) picks up wherever the turn actually landed, not wherever it happened to be when the tab closed.
The stop button that had to exist because of this fix
Removing abort-as-stop meant removing the only way, accidental as it was, that a user could actually cancel a turn mid-flight. Closing the tab used to double as a stop button. Now that it doesn’t, a real one had to exist somewhere, or canceling a turn in progress would have quietly become impossible.
POST /v1/sessions/{id}/stop cancels the turn’s stored context directly:
// internal/brain/chat/broadcast.go
func (s *Service) StopTurn(sessionID string) bool {
s.turnsMu.Lock()
b := s.turns[sessionID]
s.turnsMu.Unlock()
if b == nil {
return false
}
b.mu.Lock()
cancel := b.cancel
b.mu.Unlock()
if cancel != nil {
cancel()
}
return true
}
Cancelling turnCtx here winds the loop down through the exact same path a real 30-minute timeout would take: the abnormal-end machinery in persistTurn records a pending_state if there’s partial text, or turn_failed if there isn’t, and drainAndPersist runs turnDone exactly as it would on any other exit. Stopping a turn isn’t a special case bolted on beside the timeout path, it’s the same path fired early on purpose.
The composer’s send button now swaps into a stop icon while a turn is streaming, wired to a stop() that both aborts the browser’s own fetch (so this tab stops rendering immediately) and calls the new endpoint (so the turn actually dies server-side, not just in this tab’s view of it). Before this change, there was no such button, because there was nothing to press: leaving the page was indistinguishable from cancelling. Now the two are different operations with different consequences, and only one of them stops anything.
What’s still true
A turn that nobody explicitly stops, and that nothing anticipated going wrong, now runs to its 30-minute ceiling before it’s forced to end, rather than dying the moment a tab closes. That’s a real change in behavior, not just a bug fix in one direction: some class of turn that used to fail fast now fails slow instead. It’s the right trade for the common case (a real answer surviving a reload) at the cost of the rare one (a hung turn taking longer to notice).
Next: connector-level sensitivity gets its own routing logic, a same-day bug in it, and the permission system gets alerts that reach across the whole session instead of staying local to one tool call.