Skip to content

iOS persists two assistant records when a streaming transport fails #285

Closed

Description

@yxlyx

Summary

When an iOS turn's NDJSON stream throws after the assistant placeholder has been created, ChatView persists two assistant records for one logical turn:

  1. an empty or partially streamed assistant message; and
  2. a separate assistant-authored Transport error: ... message.

This corrupts transcript semantics, creates blank bubbles when failure happens before the first event, and makes infrastructure errors indistinguishable from model-authored text.

Physical-device observation

Environment:

  • Graff iOS 0.1
  • iPhone 16 Pro Max, iOS 26.5.2
  • Account-synced cube session

The synced transcript contained this sequence around the first failed request:

user
assistant: ""
assistant: "Transport error: The network connection was lost."

A later request retained partially streamed assistant text/reasoning and then added a standalone transport-error assistant record. These are two records for one submitted user turn.

Root cause

ChatView.runTurn() appends a placeholder before consuming the stream:

session.messages.append(ChatMessage(role: .assistant, text: ""))
let idx = session.messages.count - 1
for try await ev in client.streamTurn(...) {
    // mutate session.messages[idx]
}

But a thrown stream error does not update or remove that placeholder. The catch block appends a new message instead:

} catch {
    session.messages.append(ChatMessage(role: .assistant,
        text: "Transport error: \(error.localizedDescription)"))
}

AppSessionSync.save() then serializes both records. This is independent of the ingress problem in #283 and remains reproducible with any thrown transport/HTTP stream error after placeholder creation.

Decoded NDJSON .error events take a different path and mutate the existing placeholder; the defect specifically concerns thrown stream errors. If createSession() fails before placeholder creation, only the error record is added.

Expected behavior

One user submission should produce at most one corresponding assistant-turn record. Failure state should be metadata/UI state on that logical turn rather than new assistant prose.

Acceptance criteria

  • Failure before the first stream event does not persist a blank assistant bubble.
  • Failure after reasoning/text preserves partial content and marks the same turn interrupted/failed.
  • Transport errors are distinguishable from model-authored assistant text.
  • Save/hydrate round trips preserve one failed assistant turn, not two records.
  • Tests cover failures before the first event, after reasoning only, and after partial text.

Related

Activity

  1. justrach commented on Jul 31, 2026

    @justrach
    Owner

    Fixed in v0.0.226 (PR #328).

    A transport failure now updates the existing turn instead of appending a second assistant record. Part of the turn-model rework: ChatMessage carries an explicit TurnState, so a failure has somewhere to land.

    Tests went 449 -> 575 on this release; every Zig fix carries a pinning test that was falsified independently (revert the production change, confirm the test goes red naming itself, restore).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions