A network chunk is not a message
Gemini streaming arrives as SSE events. A socket read may split a JSON object, combine several events, or end halfway through a frame. PocketChat's readSse accumulates lines, collects data: fields, and dispatches at a blank line. It also bounds a line/frame to avoid unbounded buffering. decodeGeminiEvent then extracts text and the finish reason while excluding thought parts.
Read the decoder separately from the parser:
fun decodeGeminiEvent(data: String): List<ChatEvent> {
if(data == "[DONE]") return emptyList()
val obj = Json.parseToJsonElement(data).jsonObject
if(obj["error"] != null) throw ChatFailure("Gemini returned an error. Check model access and retry.")
val blocked = obj["promptFeedback"]?.jsonObject?.get("blockReason")?.jsonPrimitive?.content
if(blocked != null && blocked != "BLOCK_REASON_UNSPECIFIED") throw ChatFailure("Gemini could not answer this request. Try rephrasing it.")
val candidate = obj["candidates"]?.jsonArray?.firstOrNull()?.jsonObject ?: return emptyList()
val result = mutableListOf<ChatEvent>()
candidate["content"]?.jsonObject?.get("parts")?.jsonArray?.forEach { part ->
val p = part.jsonObject
if(p["thought"]?.jsonPrimitive?.booleanOrNull != true) p["text"]?.jsonPrimitive?.contentOrNull?.let { result += ChatEvent.Text(it) }
}
candidate["finishReason"]?.jsonPrimitive?.content?.let { result += ChatEvent.Finished(it) }
return result
}
The repository must still decide whether the answer completed. An EOF alone is insufficient. The baseline accepts a nonempty answer with finish reason STOP; a token limit, blocking condition, or truncated stream remains an incomplete result with recovery guidance.
Cancellation must reach the transport
The callbackFlow closes its HTTP call in awaitClose. Repository Stop first invalidates the active attempt identity, then cancels the coroutine. That ordering makes late callbacks harmless even when asynchronous cancellation takes time.
fun stop() {
val id = active.value
attempt = null; generation?.cancel(); generation = null; mutableActive.value = null
val turn = library.value.conversations.find { it.id == id }?.turns?.lastOrNull()
if(id != null && turn?.status == ReplyStatus.STREAMING) replace(id, turn.copy(status = ReplyStatus.STOPPED))
}
A stopped reply remains readable, but it is not promoted to completed context. Retry reuses the user turn and starts a fresh in-memory attempt. The baseline replaces the final incomplete reply; it does not yet maintain a durable audit log of every attempt.
Make completion and interruption different states
A spinner that disappears tells the learner very little. COMPLETE, STOPPED, FAILED, and INTERRUPTED explain different outcomes and determine future context eligibility. On startup, an old STREAMING record becomes INTERRUPTED because the process cannot resume its dead HTTP stream.
Check your understanding
Why invalidate the attempt before canceling? A callback can race with cancellation; it must not append to a newer answer. Why not auto-retry? Another request can generate a different answer and incur additional usage. The learner should choose that action. See Android coroutine cancellation guidance and compare it with the HTTP call's independent lifetime.