A Rendered Line Is Not Delivered Until write() Completes

My renderer produced the right line.

My CLI tried to write it.

The output stream rejected it.

Then the next state report arrived, the renderer produced the same line again, and the CLI skipped it as a duplicate.

Nothing had been duplicated.

The line had never made it out.

This is the small, unpleasant gap between rendering and delivery.

A streaming terminal UI often needs deduplication because state reports repeat information.

If a completed agent message is rendered on every later state update, the terminal becomes a transcript written by an enthusiastic photocopier.

So the command layer remembers which rendered lines it has already emitted.

The dangerous version of that idea is:

if (rendered.has(line)) continue;
rendered.add(line);
await writeLine(stdout, line);

It records intent before observing delivery.

If writeLine fails, the dedupe set now contains a lie.

The safer order is almost offensively simple:

if (rendered.has(line)) continue;
await writeLine(stdout, line);
rendered.add(line);

The line becomes “rendered” in the command's durable sense only after the output operation completes successfully.

One moved statement turns a lossy retry into an at-least-once delivery path for each unique rendered line.

Rendering is a pure answer

The renderer accepts accumulated turn state and returns terminal-safe strings.

It escapes control-heavy content.

It applies a byte bound.

It turns lifecycle data into stable prefixes.

A completed message that exceeds one line is split into bounded chunks:

message-1 agentMessage completed [1/3]: first chunk
message-1 agentMessage completed [2/3]: second chunk
message-1 agentMessage completed [3/3]: third chunk

Given the same completed state, the renderer returns the same lines.

That idempotence is useful.

The command layer can ask again after another state notification without inventing new terminal output.

But the renderer cannot know whether any returned line reached the operating system stream.

It has no output handle.

It receives no callback.

It should not own delivery state.

The renderer answers:

These are the lines this state represents.

The command layer must answer:

Which of those lines completed a write?

Combining those questions is how “I formatted it” quietly becomes “the user saw it.”

State reports legitimately repeat themselves

An agent turn does not arrive as one final object.

Items start, receive deltas, complete, and eventually settle under a terminal status.

The reducer accumulates that history into a current state.

The reporting callback may run more than once with states that contain the same completed item.

Repeating the entire render on every callback is convenient because it keeps reporting stateless with respect to protocol deltas.

The dedupe set suppresses lines that a previous report already delivered.

Conceptually:

const rendered = new Set<string>();

async function reportTurnState(state: TurnState) {
  for (const line of renderTurnState(state)) {
    if (rendered.has(line)) continue;
    await writeLine(stdout, line);
    rendered.add(line);
  }
}

This gives each successful output line a receipt.

The string itself is the receipt key.

That choice has another consequence for multi-line messages.

Two chunks can contain identical body text.

If their full rendered strings were also identical, the second chunk would look like a duplicate and disappear.

The renderer therefore includes an ordinal such as [2/3] on every chunk of a split message.

The prefix does more than help a human read the output.

It gives each chunk a distinct delivery identity.

A Node write has more than one failure surface

Calling writable.write(bytes) does not always mean the bytes were accepted successfully.

The output may already be destroyed.

write() may throw synchronously.

Its completion callback may receive an error.

The stream may emit an error event.

The callback may never arrive.

The CLI's writeLine wrapper treats those as one stable command-layer error.

It checks the destroyed flag, installs a temporary error listener, invokes write, and waits for the callback.

A completion timeout prevents a broken writer from leaving the command suspended forever.

Every failure becomes a CommandOutputError with the code COMMAND_OUTPUT_FAILED.

That classification matters because output failure is not the same as agent failure.

The turn may have completed perfectly.

The terminal channel did not deliver its report.

If the CLI returned success anyway, automation would interpret silence as a successful transcript.

If it classified the failure as an App Server crash, the remediation would point in the wrong direction.

A stable error code keeps presentation failure in its own lane.

The failure can happen in the middle of one message

Short-message tests are too polite for this contract.

One successful write proves only that one write succeeded.

The regression uses a completed message long enough to become several bounded lines.

Its fake output stream accepts the first message chunk, rejects the second chunk through the write callback, and accepts later attempts.

The first report therefore has this shape:

chunk 1 -> write succeeds -> add chunk 1 to delivered set
chunk 2 -> write callback fails -> throw COMMAND_OUTPUT_FAILED
chunk 3 -> not attempted yet

The harness confirms that the first report rejects with the typed output error.

Then it calls the real reporting callback again with the same state.

The second report behaves like this:

chunk 1 -> already delivered -> skip
chunk 2 -> not recorded -> retry and succeed
chunk 3 -> write and record

Finally, the test joins every delivered message chunk, removes the lifecycle prefixes, and compares the result with the original message.

That assertion protects the property that matters.

It does not merely assert that a retry happened.

It proves that the complete message arrived exactly once across the successful writes.

At the exact source revision checked for this article, the project build passed and this focused failure-injection test ran once and passed.

Why not delete the dedupe set on failure?

One tempting recovery is to clear all delivery state whenever any write fails.

That would make the next report emit the whole render again.

The missing chunk would return.

So would every line that already succeeded.

For a human terminal, duplicate prefixes may look merely untidy.

For a log parser, line-oriented consumer, or approval transcript, duplication can change meaning.

The successful prefix of the batch is real delivery evidence.

It should survive a later failure.

Recording one line only after its own write succeeds gives the retry exactly the boundary it needs.

No batch rollback is required.

No sequence cursor is required.

The set already acts as a sparse acknowledgement ledger.

This is the boring baseline I prefer before inventing a queue with offsets, acknowledgements, and persistent replay.

The output is process-local.

The renderer is deterministic.

The state report will arrive again in this recovery path.

A set of successfully delivered lines is enough.

Do not swallow the first failure and wait forever for another report

The retry path does not mean every output failure should be hidden.

In the regression harness, the first error is observed and asserted.

The next report is an explicit later call.

Production code must still surface COMMAND_OUTPUT_FAILED when reporting is exhausted or no later state report can repair the output.

There are two separate contracts:

If a later real report occurs:
  retry lines whose writes never completed.

If reporting cannot recover:
  persist terminal state where possible and return output failure.

Silent optimism would be worse than losing a chunk.

The command could exit zero even though its final status never reached the reader.

Delivery bookkeeping improves recovery.

Typed failure preserves honesty when recovery ends.

Persist state before surfacing presentation failure

The same separation appears one layer deeper.

The coordinator may receive an authoritative terminal notification before its reporting callback fails.

That terminal result is not invalidated by a closed stdout stream.

The coordinator persists the terminal record, then rethrows the reporting failure.

This produces two true facts at once:

The agent turn completed and its terminal record was stored.
The CLI could not finish reporting that result to this output stream.

Collapsing them into one status would destroy information.

Marking the turn failed would lie about execution.

Returning success would lie about delivery.

The durable record and the command exit answer different questions.

This is especially important for resume commands.

A later invocation can read the stored terminal record even when the previous terminal channel failed.

Presentation should not erase execution history on its way out.

The test needs a real failed write

A weak test might make renderTurnState throw.

That proves the command handles a renderer exception.

It does not prove the delivery ledger updates at the correct boundary.

Another weak test might use a closed stream before the first line.

That never creates a successful prefix, so clearing everything and preserving acknowledgements behave identically.

The useful fixture fails the second chunk exactly once.

It creates all three states the algorithm must distinguish:

  • a line successfully delivered before failure;
  • a line attempted but not delivered;
  • a line not attempted yet.

Then it supplies a later report and checks the reconstructed payload, not only the exit code.

The failure location is the specification.

The practical rule

Keep rendering pure.

Track delivery in the layer that owns the output stream.

Give every split chunk a unique stable identity.

Await the write completion signal.

Only then add the line to the dedupe set.

On a later state report, skip successful lines and retry the rest.

If no recovery report can complete delivery, return a typed output failure.

Persist authoritative execution state before surfacing presentation failure.

And test a failure in the middle of a multi-line payload.

The terminal does not owe your renderer a receipt.

Your write callback does.