Telling an agent's answer from its thinking
A coding agent's turn is a stream. It thinks, says a sentence, reads a file, runs a command, says another sentence, edits something, and ends with a paragraph addressed to you. A transcript that draws all of it at the same weight buries the paragraph you wanted under everything the agent did to get there. A transcript that hides too much hides the reply. This is the rule Cydonia used to tell the two apart, the case it got wrong, and what it does now.
What arrives
Cydonia speaks the Agent Client Protocol. During a turn the agent sends session/update notifications, and the session folds them into a flat list of items:
| Update | Item |
|---|---|
AgentMessageChunk |
Agent(text), appended to the last item if that is already Agent |
AgentThoughtChunk |
Thinking { text, done }, appended to the last item if that is an unfinished thought |
ToolCall |
a new Tool item; any open thought is marked done |
ToolCallUpdate |
updates the Tool with the same id, found by rposition |
So prose is not one item per turn. Every tool call or thought between two stretches of prose ends one Agent item and the next chunk starts another. A turn that thinks, explains, calls three tools and sums up is something like:
User · Thinking · Agent · Tool · Tool · Tool · Agent
Nothing in the protocol marks the last Agent as the answer. A model's thinking out loud arrives as the same AgentMessageChunk as its reply. Whatever separates them has to be a rule over the order of items, which is why PR #2 routed every agent-side event through one channel: a turn's final updates reach the UI before its stop reason, in wire order.
Turns themselves are easy. A turn starts at every User item. The chunk before the first question is a turn of its own, since a connection that fails before the first prompt still has something to show.
The first rule: the prose after the last tool call
PR #2 rebuilt the client on bezel and cacp, and transcript.rs was the one file that grew. Its rule, as the PR put it: turns split into zones by an rposition on the last tool call or thought — the prose after it is the answer, everything before is interim work.
let interim =
|item: &ChatItem| matches!(item, ChatItem::Tool { .. } | ChatItem::Thinking { .. });
let answer_from = items[start..ix]
.iter()
.rposition(interim)
.map_or(start, |last| start + last + 1);
Everything between the question and answer_from went under one collapsible header, Worked · 3 steps, or Thought when there were no tool calls. Inside it, adjacent tool calls were boxed together, and thoughts and interim prose were drawn small and muted between the boxes. Everything from answer_from to the end of the turn was drawn as the reply, in full Markdown.
For the example above that gives:
User │ Thinking · Agent · Tool · Tool · Tool │ Agent
│ work │ answer
The header followed the turn: open while the turn was running, closed once it finished, and from the first press on, whatever the person chose. That is bezel's Takeover, an Option<bool> that answers the automatic value until it is toggled and the user's value after.
Where it broke
The rule assumes the turn ends on prose. When it does not, rposition lands on the last item, the answer is empty, and every sentence the agent wrote is inside the collapsed work header, drawn as muted interim text. A turn whose last item was a tool call showed nothing but a header.
It also put all the work in one place. A turn that alternated, explaining a step and then doing it, had all its explanation folded into the same box as its tool calls.
Runs instead of a split
PR #34, on 2026-09-19, replaced the split with runs. The Turn lost its answer_from, and the transcript's module doc now reads:
A zone is a run split: prose the agent addressed to you is the answer wherever it falls, and tool calls and thoughts are the work. Each run of work collapses under its own header, in the place it happened, so a turn that ends on a tool call still shows the prose before it as prose.
The body of a turn is cut with chunk_by on one predicate:
fn interim(item: &ChatItem) -> bool {
matches!(item, ChatItem::Tool { .. } | ChatItem::Thinking { .. })
}
for run in chat.items[body.clone()].chunk_by(|a, b| interim(a) == interim(b)) {
// prose: drawn in place as Markdown
// work: a header, and the run under it when open
}
The same turn now reads:
User │ Thinking │ Agent │ Tool · Tool · Tool │ Agent
│ work │ prose │ work │ prose
Every Agent item is drawn as prose, at full weight, wherever it falls. Notices and process output sit in the prose runs too, notices at reduced opacity. Each run of work gets its own header, labelled by the tool calls in that run alone, and its open state is keyed by the run's first item rather than the turn's. Only the run the turn is still inside follows the turn: it is open while the agent is working in it and closes when the agent moves on. Earlier runs are closed unless someone opened them.
Inside a run, tool calls are still boxed by adjacency, so a thought between two calls breaks the box where it falls. Interim prose no longer appears inside a run, because prose is never interim now.
Thoughts on one line
A run that is mostly thinking could still be long once opened. PR #46, on 2026-09-24, folded each thought to a single line: its text with all whitespace collapsed, truncated to the width, behind a disclosure. Pressing it shows the whole thought below, indented, with a rule at its left. A thought's open state is per item, so opening one does not open the others.
What is still true
The distinction is the same one PR #2 drew: tool calls and thoughts are work, and the agent's prose is what it said to you. What changed is where the line falls. It used to be one position per turn, found from the end. Now it is every boundary between work and prose, found in order, and the answer is whatever the agent wrote in the gaps.
The turn split has since moved into the artifact crate, artifact::session::chat::turns, so turn n on the transcript's rail is turn n for the session tools. It skips runs that are nothing but the agent process's stderr, which are not turns.
The code is in crates/gui/src/view/component/transcript.rs and crates/gui/src/model/session.rs.