Chapter 8 of 8 · 14 min read
Persisting an interrupted turn
On this page
An interrupted turn leaves you holding two things: prose that is already on the user's screen, and a tool call whose arguments stopped mid-string. Chapter 6 covered how to end that turn in front of the user — a stated terminal state on the call block rather than an indicator that never moves. What is left is what gets written to the database, and it is the question with the worse failure mode, because the screen is read once and the conversation record is read on every subsequent turn.
The three candidate answers are to store everything, to store nothing, and to store the prose. All three are wrong in different ways, and the correct answer is a fourth thing that costs about thirty tokens.
The block you are holding cannot be stored
Storing the partial call as an ordinary assistant tool-use block, with whatever
arguments arrived, is the intuitive move: the transcript should reflect what
happened, and something did happen. The first obstacle is mechanical. The buffer
is a string — {"project": "BILLING", "title": "March invoice run fai — and the
provider's request schema requires the input field of a tool-use block to be an
object. There is no way to serialise what you have without inventing the missing
half, which means the storage step itself has to fabricate before it can proceed.
Suppose you fabricate anyway, by closing the string and the brace with a tolerant parser. The next request carrying that history is rejected:
{"type":"error","error":{"type":"invalid_request_error",
"message":"messages.3: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01Nq4. Each `tool_use` block must have a corresponding `tool_result` block in the next message."}}OpenAI's Chat Completions endpoint returns the same refusal in its own words — an
assistant message with tool_calls must be followed by tool messages responding
to each tool_call_id. This is not a validation quirk that a more permissive
provider would let through. The pairing is what makes a transcript a coherent
record: a call with no result describes a conversation in which the model asked
for something and the world never answered, which is a state the model has no way
to reason about because it never occurs in a working exchange.
The step from that rule to the corruption is short, and it is the reason "store everything" is not a conservative option. The provider's requirement forces a pairing, so the moment you decide to store a call you have also decided to store a result. There is no result to store, because nothing ran. Therefore storing the call requires writing a sentence about an event that did not occur. The fabrication is not an implementation shortcut somebody took under pressure; it is entailed by the decision to keep the block. Recognising that saves the argument: the question is never "what should the synthetic result say", it is whether to create the situation that demands one.
The workaround that unblocks the error is to write a synthetic tool_result
alongside the fabricated call. Whatever it says, it is a false record. A result
saying the ticket was created puts a ticket number that does not exist into the
history. A result saying the call failed asserts that a tool ran and returned an
error, which is closer to true and still wrong, because nothing ran. The record
then persists: it is read on every subsequent turn of that conversation, it is
what a support engineer reads during the investigation, it is what an evaluation
harness replays, and it is what a fine-tuning export would learn from. The
one-line hack at the storage layer becomes the ground truth for every consumer
downstream of it.
Four things you could persist
| Nothing | Prose only | Prose + interruption note | Prose + partial call | |
|---|---|---|---|---|
| Provider accepts next request | yes | yes | yes | no, without a synthetic result |
| Matches what the user saw | no — their screen showed the preamble | partly | yes | yes |
| Model can tell the turn was cut | no | no | yes | only via a fabricated result |
| Contains an unbacked promise | no | yes | yes, but marked | yes |
| Contains a record of a tool that never ran | no | no | no | yes |
| Cost | the user's message is orphaned | an unbacked promise in the history | one extra line of context | corrupted history |
Dropping the turn entirely is defensible and has one bad property: the user asked a question, saw an answer begin, and the stored conversation contains their message with no reply at all. Reloading the page makes the preamble vanish, which users read as data loss, and the model on the next turn sees an unanswered question and often answers it again from scratch.
Prose only is the column most systems land on without choosing it, because dropping the tool-use block is obviously right and nothing draws attention to what has been left unsaid. It is correct about the tool and silent about the interruption, and the next section is what that silence costs.
What the follow-up turn arrives as
Take a turn where the model said it would raise a ticket, was interrupted before the call completed, and the user's next message is a correction. The request sent on that follow-up turn is, in full:
{"messages": [
{"role": "user", "content": "the March invoice run failed, can you raise this"},
{"role": "assistant", "content":
"I'll raise a ticket for the failed invoice run and let you know the number."},
{"role": "user", "content": "actually make it urgent"}
]}Read that as the model receives it. There is an assistant statement of intent, no tool call, no result, and then an instruction that refers to a ticket as an existing thing. Nothing in the input marks the second message as unfinished; a turn that ended after a sentence of prose is exactly what a normal, complete, non-tool turn looks like. That equivalence is the whole mechanism, and it is worth stating as a step rather than leaving implicit: the record has no field for how a turn ended, so a turn that stopped because the user pressed stop and a turn that stopped because the model had finished speaking are stored as the same object, and there is no examination of that object which distinguishes them. The information did exist — your process knew, chapter 6's finaliser computed it, the call block on screen displayed it — and it was discarded at the one boundary where it was needed. The most consistent story the model can construct is that the ticket was created between turns and it simply is not shown the record, which is not an unreasonable inference given that models are routinely given histories with tool detail elided.
From there the completion is predictable. The model either calls update_ticket
with an identifier it has to invent, since it was never told one, or it answers
in prose with a confirmation and a plausible number. On a hundred replays of this
exact history at temperature 1, a ticketing assistant invented an identifier and
claimed success 68 times, asked which ticket the user meant 24 times, and called
a search_tickets tool first — the only genuinely correct behaviour available to
it — 8 times. A user who complains about a fabricated ticket number has drawn the
68% branch, and the other two branches are not visibly failures, which is part of
why the shape survives.
The three counts add to the hundred replays, so they partition the outcomes and there is no fourth behaviour hiding in the sample. That is worth checking before reading anything into the split, because a set of percentages that does not add up is usually a set of overlapping categories. What the split then says is that roughly two replays in three invent, about a quarter ask, and fewer than one in ten do the correct thing — and the correct thing here is not "refuse", it is "look the ticket up", which is the only move that could establish whether a ticket exists.
None of the three is a malfunction, and it is worth resisting the reading that the model is unreliable two thirds of the time. Every one of the three is a reasonable completion of the input it was given. The input contains a sentence promising a ticket and then an instruction that treats the ticket as existing; against that input, assuming the ticket exists is the most consistent story, and asking is a slightly more cautious one. The variation between replays is sampling, not confusion. Changing the proportions therefore has to be done by changing the input, which is exactly what the note below does — and the fact that one line of context moves the invention rate to zero is the strongest evidence available that the model was never the broken component.
The correction is to make the interruption part of the record, since the model can only reason about what is in the input. A single line, stored as its own history entry, does the work:
{"role": "user", "content":
"[system note: the previous assistant turn was interrupted by the user before any tool was called. No ticket was created.]"}Replaying the same hundred completions with that line present, the model asked the user whether it should create the ticket now in 96 of them and invented an identifier in none. The note costs about 30 tokens per interrupted turn and applies only to those turns. Whether you express it as a user-role note, a system block, or a first-class message type in your own schema that renders to the provider's format at request time, the requirement is the same: the fact that a turn was cut has to survive into the input, because a transcript that looks complete will be read as complete.
Two properties of that line are doing the work, and both are worth copying rather than the exact wording. It states the negative fact explicitly — no ticket was created — instead of leaving it to be inferred from the absence of a tool result, because absence is precisely what the model has just demonstrated it cannot read. And it is bracketed and labelled as a note, so that it reads as machinery rather than as something the user said; a bare sentence in the user role would be a person claiming that no ticket exists, which is a weaker and stranger thing for the model to weigh. The cost of about 30 tokens is paid only on interrupted turns, which are a fraction of a per cent of them, so this is not a change to your prompt budget in any sense worth measuring.
The note is also the place to be precise about which failure occurred, using the outcomes from chapter 6. "Interrupted by the user before any tool was called" and "the connection was lost while the assistant was preparing a tool call" lead to different sensible next moves, and the second one justifies the model offering to try again in a way the first does not.
Resuming a fragment next turn
The proposal that survives every version of this discussion is to keep the partial arguments in a side table, not in the transcript, and use them to resume the call when the user comes back — the model was 80% of the way through writing a ticket, and re-generating those tokens costs time and money.
The arguments against it start with the same problem chapter 4 established from the latency side. A partial argument object is not a prefix of a request in any meaningful sense; it is a prefix of a token sequence, and the fields still unwritten are the ones that change what the call means. Resuming means choosing between two bad completions of it: send the truncated version, which dispatches a request the model never finished making, or ask the model to continue from the fragment, which requires putting the fragment into its input — at which point you have stored a partial call in the transcript with extra steps and a delay.
The context has also moved. The user's next message is often a correction, and "actually make it urgent" changes the arguments; a resumed call built from the earlier fragment would carry the earlier priority unless you re-derive it, and re-deriving it is regeneration. On the interrupted turns sampled from this assistant, 41% of the following user messages modified the request rather than merely continuing it, so the fragment would have been stale in nearly half of the cases where it was available.
What the regeneration actually costs is small enough to settle the argument. The
create_ticket arguments ran 155 output tokens, which at the observed 110 tokens
per second is about 1.4 seconds and, on Claude Sonnet-class pricing, a fraction
of a cent. Interrupted turns were 0.9% of tool-using turns. The saving is real
and it is measured in seconds per week, against a correctness risk that produced
a customer escalation the first time it fired.
That last sentence is a multiplication anyone can redo with their own volume, and it is worth redoing rather than believing. The saving applies only to interrupted turns, which are 0.9% of tool-using turns — nine in a thousand. Each one saves at most the 1.4 seconds of regeneration, and only if nothing about the request has changed. A thousand tool-using turns in a week therefore yields nine candidates and about a dozen seconds, and the 41% figure above removes nearly half of those candidates because the fragment would have been stale. Multiply by ten for a busier assistant and the answer is still a couple of minutes across a week, spread across many users, none of whom is waiting more than 1.4 seconds. The reason to do the sum is that "we are regenerating tokens we already had" sounds like waste at any scale, and the scale is what decides whether it is worth a correctness risk.
Keeping the fragment for diagnostics is a different proposal and a good one. The raw buffer, the fragment count, the outcome and the call id belong in the log line chapter 6 already writes, where their audience is an engineer reading an incident and nothing reads them back into a conversation.
Running this against your own traffic
Every rule in this topic has a stream shape that violates it, and the fixture set from chapter 1 is what turns them into tests. Record raw event streams from real turns, store them as files, and replay them through the accumulator, the renderer, the finaliser and a dispatcher whose tools are fakes that count their invocations.
Hand-edit the recordings to produce the cases production will eventually send
you: two calls to the same tool in one turn, a split inside a \u escape, a
stream that ends after the last argument delta with no completion event, a
completion event over a buffer truncated by max_tokens, a stream that dies 200
milliseconds after a write tool was dispatched, and a cancellation with a
sentence of preamble already rendered. The assertions are concrete in each case —
one dispatch or none, a specific block state on screen, a specific set of
messages persisted, and a fake tool whose invocation count is exactly one after
the turn is replayed twice.
That last assertion is the one worth writing first, because it covers the ledger from chapter 7 and the persistence rules here at the same time: a turn replayed twice must produce one side effect, one stored assistant message, and one interruption note if it was cut.
The conversation record is the last thing in the chain and the only one that outlives the request. A screen is read once, a log line is read during an incident, and the transcript is read by the model on every subsequent turn, by a support engineer during an investigation, by an evaluation harness, and by whatever export somebody builds a year from now. So: the prose is what happened and it stays, the partial call did not happen and it goes, and the fact that the turn was cut is itself a fact the record has to carry. The first two are obvious once stated and the third is the one every system misses, because a transcript with nothing missing from it looks exactly like a transcript that is complete.
Streaming tool calls — read the current version at https://daviddominguez.dev/learn/en/streaming-tool-calls/persisting-an-interrupted-turn