The most common failures in streaming AI chat are not model-quality failures. Text appears twice, an old request keeps writing after the user presses Stop, the list drags someone back to the bottom while they read history, or a relaunched app still claims that generation is in progress. One isLoading Boolean cannot describe those situations. A response needs an explicit lifecycle.

Distinguish snapshots from deltas

Many hosted APIs emit token deltas that a client must append. Apple’s LanguageModelSession.ResponseStream has different semantics: it is an AsyncSequence of partial-content snapshots. If it yields A, AB, and ABC, the interface should replace the current draft with each new value. Appending every value produces the duplicated result AABABC.

Give the provider adapter one stable contract: every emitted string is the complete answer so far. An adapter for a delta-based service accumulates before yielding. A Foundation Models adapter forwards each partial.content. The view model then has no provider-specific branching.

protocol ChatStreamingClient: Sendable {
    /// Every value is the complete answer so far, not a delta.
    func snapshots(for prompt: String)
        async throws -> AsyncThrowingStream<String, Error>
}

This boundary also makes fake streams straightforward. Tests can control every snapshot, failure, and suspension point without invoking a real model.

Make one task own one attempt

The state model needs messages, an active request identifier, and the Task it owns. Starting a request appends one assistant draft with a stable message identifier. Each snapshot replaces that message’s text. A request-ID check rejects late results even if a provider fails to honor cancellation.

@MainActor
@Observable
final class ChatModel {
    private(set) var messages: [ChatMessage] = []
    private(set) var phase: Phase = .idle

    @ObservationIgnored private var generation: Task<Void, Never>?
    @ObservationIgnored private var activeRequestID: UUID?
    @ObservationIgnored private let client: any ChatStreamingClient

    func send(_ prompt: String) {
        guard generation == nil else { return }

        let requestID = UUID()
        let answerID = UUID()
        activeRequestID = requestID
        phase = .streaming
        appendDraft(id: answerID, prompt: prompt)

        generation = Task { [weak self] in
            guard let self else { return }
            do {
                let stream = try await client.snapshots(for: prompt)
                for try await snapshot in stream {
                    try Task.checkCancellation()
                    guard activeRequestID == requestID else { return }
                    replaceDraft(id: answerID, text: snapshot)
                }
                complete(answerID, requestID: requestID)
            } catch is CancellationError {
                stop(answerID, requestID: requestID)
            } catch {
                fail(answerID, requestID: requestID, error: error)
            }
        }
    }
}

A production implementation uses defer or one finishing function to clear both handles on every path. One LanguageModelSession supports one response at a time, so the Send button should become Stop while streaming instead of starting a concurrent response.

Treat cancellation as a state, not a red error

Swift task cancellation is cooperative. Calling cancel() marks the task and propagates a signal; it cannot forcibly terminate arbitrary networking code or an iterator that ignores cancellation. The consuming loop calls Task.checkCancellation(). When an adapter bridges a callback-based SDK, its termination handler also cancels the underlying request.

After a user stops generation, retain the final visible snapshot and mark the attempt as stopped. Do not present “generation failed.” Regenerate with a new attempt ID, and discard every later snapshot from the old request.

Retry policy should follow error meaning. A timeout or rate limit can offer a bounded retry. Refusals, guardrail violations, and unsupported capabilities should not loop with the same input. A context-size error requires trimming, summarizing, or starting a new session before another attempt. User cancellation never enters an automatic retry queue.

Auto-scroll only while the reader wants it

Give every message, including the in-progress draft, a stable identity. Combine scrollTargetLayout() with ScrollPosition, and follow the newest snapshot only while the reader remains near the bottom. When they scroll upward, pause automatic following and show a Jump to Latest control. Reaching the bottom or tapping that control enables following again.

Avoid an animated scroll for every character. Coalesce snapshot updates over a short interval to reduce layout work and motion. Test with the keyboard visible, large Dynamic Type, rotation, long code blocks, and mixed-height messages. A short preview answer will not expose the problematic geometry.

Recover domain state, not a running task

Messages, the last committed turn, the input draft, and attempt state are persistable. A Task, async iterator, or network connection is not. If a cold launch loads an attempt marked streaming, migrate it to interrupted and let the user choose whether to regenerate. Never resend automatically: the remote request may already have completed, and a tool-enabled conversation could repeat a side effect.

The stable iOS 26 strategy keeps the final UI snapshot as an unfinished draft while model context returns to the last complete boundary. iOS 27 beta adds .preserveTranscript, which can retain partial transcript content after cancellation or a tool error. The application must wait until session.isResponding is false, inspect the tail, and repair incomplete entries before continuing. This is not byte- or token-level stream resumption; continuing still starts a new generation and may require overlap handling.

When the scene enters the background, save a checkpoint and stop a foreground-oriented stream. Lightweight values such as conversation ID, input draft, or last visible message ID fit scene storage. Full conversations and transcripts that may contain sensitive data belong in the application’s protected persistence layer.

Test with a controlled stream

Have a fake client emit A, AB, and ABC; the final UI must contain ABC, not duplicated text. Cancel before the first snapshot, in the middle, and after the final snapshot. Make an old request deliberately emit late and verify that the request-ID barrier rejects it. Continue streaming while the user is reading older messages and verify that their position is not stolen. Restore a persisted streaming attempt and confirm that it becomes interrupted without sending anything.

Also test error classification: a transient failure exposes a retry action, while refusal and context-too-large follow different recovery paths. For iOS 27 beta, test both transcript rollback and preservation, including the requirement that transcript repair occurs only after the session stops responding.

Reliable streaming chat is more than rendering text as it arrives. It requires one snapshot contract, one task owner, cooperative cancellation, stable message identity, reader-controlled scrolling, and explicit interruption recovery. Once those rules live in a state machine, SwiftUI renders explainable facts instead of inheriting timing accidents from the model or network.