Repository navigation
iOS persists two assistant records when a streaming transport fails #285
Description
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:
- an empty or partially streamed assistant message; and
- 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
- Cube preview buffers NDJSON and long iOS turns fail with networkConnectionLost #283 tracks the preview buffering/root transport failure. This issue tracks deterministic client transcript handling once any transport failure occurs.
Activity
fix: eleven more open issues (#261 #63 #296 #289 #309 #307 #285 #317 #…
justrach commentedon Jul 31, 2026
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).