The field that was never populated
Continuing a conversation with an agent that already finished turned out to be one line of SDK config and three days of admitting my data model thought agents were disposable.
An agent finishes. It has read six files, made a decision you disagree with in one specific way, and produced a diff that is 90% right. You want to say “good, but use the token, not the hex value”.
In VEX, until this change, you could not. The agent was done. Done meant gone. Your only move was to describe the whole task again to a fresh agent that had never seen your repository, and hope its 90% overlapped differently.
This is the story of fixing that, which was mostly the story of discovering my own assumptions.
The one-line part
The Claude Agent SDK persists sessions. Pass a session_id to a client with the
same working directory and it loads the prior conversation from disk:
~/.claude/projects/<cwd>/<session_id>.jsonl
That is it. That is the feature. No conversation store to build, no history to
serialize, no long-lived client to keep warm in memory. Construct a new client,
give it vex-{agent_id}, and the agent remembers.
I want to be precise about how this landed, because it is funny in a way that
stings. The SDKAgentSession dataclass already had a session_id field. It had
been there since the adapter was written. It was declared, typed, and never once
assigned. Past me had read the SDK docs, understood that sessions existed,
written the field to hold one, and then built the entire system as though agents
were fire-and-forget.
The field was a comment I had left myself in the shape of code.
The parts that were not one line
The streaming loop had to be shared. send_task() contained about 340 lines
of message handling — SystemMessage, AssistantMessage, UserMessage,
TaskProgressMessage, ResultMessage — plus another hundred of completion and
error handling. resume() needs every one of those behaviours and differs only
in setup: an existing session instead of a new one.
Copy-pasting it would have worked on the day and rotted by the third change. The
loop came out as _stream_response(session, task_id) and both paths call it.
Boring refactor; the only correct one. Two code paths that must behave
identically should be one code path with two entry points.
The agent had to come back to life. Every subject VEX publishes on is keyed
by agent id — vex.agent.{id}.step, vex.agent.{id}.status. The Electron trace
view and the extension’s cursors are subscribed to those before the agent
finishes.
So a continuation reuses the same agent_id and cycles the status:
terminal → running → terminal
Every existing subscriber picks up the new steps with no resubscription, no new subject, no coordination. A new agent id would have been the “cleaner” model and would have severed the UI from the thing it was watching mid-conversation.
Traces became plural. One agent_traces row per turn, not per agent. The
schema already allowed it — agent_id was a foreign key, nothing enforced one —
and each turn genuinely has its own cost, tokens, and duration. Appending a
second turn’s steps onto the first trace would have merged two dollar amounts
into one number that answers no question anybody asks. The trace view
concatenates turns with a separator; totals are summed client-side.
Lesson: the schema had been right for months and the code had been treating it as if it were not. When a change feels like it needs a migration, check whether you are actually just enforcing an assumption that was never in the data model — only in your head.
The frontend had a keep-out sign
In the Chrome extension, AgentCursors.tsx maintains a completedAgentIdsRef.
When an agent finishes, its id goes in that set and stays there, so a late NATS
message can never resurrect a cursor that has already been dismissed.
Sensible defensive code. Also, precisely, the mechanism that made continuation impossible: an agent I explicitly restarted was indistinguishable from a stale message about a dead one.
The fix is small — an explicit continuation clears the id — but it is worth naming what happened, because I think this is the most common shape of bug in a system that grows a new capability. Nothing was broken. Guard code written for one lifecycle simply had no vocabulary for a lifecycle that did not exist when it was written. The guard is not wrong; its assumption became wrong underneath it.
The UX ended up cursor-anchored: the completion badge gets a reply button, and the reply box opens next to the element the agent was working on. You answer where the work happened, which is the entire premise of VEX. Sending you back to the desktop app to type a follow-up would have been the sort of context switch the tool exists to delete.
What this changes about using it
The workflow before was describe, run, inspect, describe better, run again. Every iteration paid full price for context the agent had already built — which in practice meant I would accept an 80% diff rather than re-explain the world for a 95% one.
Now it is a conversation with something that already read your code. “Use the design token.” “Same change on the mobile breakpoint.” “Undo the second half.” Each of those is a sentence, not a specification, because the shared context is still on disk.
The interesting part is not that the SDK supports it. It is that supporting it cost almost nothing at the model layer and quite a lot at every layer that had quietly encoded an agent runs once — the guard set, the trace row, the status enum, the id lifecycle. None of those were wrong. They were just built by somebody who had not yet noticed he was designing for amnesia.