Fix Codex transcript convergence

This commit is contained in:
iris committed 2026-09-10 01:24:16 -04:00
1 parent f9c8f640ce
commit 3c19b5a9bb
9 files changed
+192 -48

No files matched your search

@@ -61,6 +61,9 @@ sealed class SessionEvent {
data class AssistantText(val delta: String) : SessionEvent()
/** The durable value of the open assistant message, replacing its provisional deltas. */
data class AssistantTextFinal(val text: String) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
@@ -225,6 +228,7 @@ fun parseSeqEvent(json: String): SeqEvent {
)
"messageDropped" -> SessionEvent.MessageDropped(body.getString("id"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"assistantTextFinal" -> SessionEvent.AssistantTextFinal(body.getString("text"))
"toolStart" ->
SessionEvent.ToolStart(
id = body.getString("id"),
@@ -83,6 +83,8 @@ import androidx.lifecycle.repeatOnLifecycle
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
@@ -95,6 +97,15 @@ import kotlinx.coroutines.withContext
*/
private val LOADING_SPINNER = 48.dp
/** One callback from the blocking SSE reader, queued in wire order for the UI thread. */
private sealed interface TranscriptDelivery {
data object Opened : TranscriptDelivery
data object Reset : TranscriptDelivery
data class Event(val entry: SeqEvent) : TranscriptDelivery
}
/**
* How close, in screenfuls of estimated scroll, the reader may come to the end of loaded history
* before the next page is fetched.
@@ -360,6 +371,10 @@ fun SessionScreen(
// state rather than of item indices, because a zero-height first item makes an index ambiguous.
// This is what the jump-to-newest button watches, and the gate on recording.
val atNewest by remember { derivedStateOf { !listState.canScrollBackward } }
// A decision made only by an actual scroll gesture. `canScrollBackward` also changes while a
// streaming row is laid out, and treating that displacement as intent freezes the rest of the
// reply off-screen while still advancing the SSE cursor.
var followingNewest by remember(address, epoch) { mutableStateOf(savedAnchor == null) }
// Transcript events that arrived while somebody was reading further back, in the order they
// arrived, waiting for them to return to the newest end. See [record].
var held by remember { mutableStateOf(listOf<SeqEvent>()) }
@@ -492,7 +507,10 @@ fun SessionScreen(
}
// In order, always: one late event recorded ahead of the backlog would fold a
// streamed delta into whatever row happened to be last by then.
if (atNewest && held.isEmpty()) record(entry) else held = held + entry
// Read on the UI thread (the stream below marshals every frame here). This direct
// check closes the small window before the scroll observer records the gesture.
if (listState.isScrollInProgress && !atNewest) followingNewest = false
if (followingNewest && held.isEmpty()) record(entry) else held = held + entry
}
}
}
@@ -797,6 +815,7 @@ fun SessionScreen(
}
// Whatever happened above: an empty transcript is a state the screen can draw, and a
// permanently blank one is not.
followingNewest = atNewest
restoring = false
ready = true
// The opening page is sized for time-to-first-frame, not for reading: it fills a viewport
@@ -852,25 +871,42 @@ fun SessionScreen(
probePassed = true
}
}
withContext(Dispatchers.IO) {
source.follow(
after = lastSeq.get(),
// Connected, measured rather than inferred: this is what takes a
// failure off the screen. Clearing on the first event instead meant
// an idle session kept displaying an error it had recovered from.
onOpen = { streamError = null },
onReset = {
// Too far behind to continue from: what is on screen is a stale
// prefix of a conversation that has moved on, and the window
// arriving next is not adjacent to it. Dropping the rows makes
// this the same as opening the screen. The cache needs no
// telling: the window's first seq is not the one it expected,
// which closes its live run and starts another.
dropLoadedTranscript()
},
) { entry ->
apply(entry)
// The socket is blocking, but Compose state belongs to this UI coroutine.
// Queue every callback -- including reset -- through one channel so none
// can race layout or overtake another while crossing threads.
val delivery = Channel<TranscriptDelivery>(Channel.UNLIMITED)
val follower =
launch(Dispatchers.IO) {
try {
source.follow(
after = lastSeq.get(),
onOpen = { delivery.trySend(TranscriptDelivery.Opened) },
onReset = { delivery.trySend(TranscriptDelivery.Reset) },
) { entry ->
delivery.trySend(TranscriptDelivery.Event(entry))
}
delivery.close()
} catch (error: Throwable) {
delivery.close(error)
}
}
try {
for (next in delivery) {
when (next) {
TranscriptDelivery.Opened -> streamError = null
TranscriptDelivery.Reset -> {
// The fresh window has no position in common with the rows
// just dropped, so it becomes the view even if the reader
// had been further back in the stale prefix.
dropLoadedTranscript()
followingNewest = true
}
is TranscriptDelivery.Event -> apply(next.entry)
}
}
} finally {
source.close()
follower.cancelAndJoin()
}
} catch (e: kotlinx.coroutines.CancellationException) {
// Leaving the screen or going below STARTED. Not a failure, and swallowing
@@ -920,9 +956,12 @@ fun SessionScreen(
// paced out: they are at the bottom, which is the one place the list is allowed to follow new
// content.
LaunchedEffect(listState) {
snapshotFlow { atNewest && held.isNotEmpty() }
.collect { due ->
if (!due) return@collect
snapshotFlow { Triple(listState.isScrollInProgress, atNewest, held.isNotEmpty()) }
.collect { (scrolling, newest, hasHeld) ->
if (scrolling && !newest) followingNewest = false
if (!newest) return@collect
followingNewest = true
if (!hasHeld) return@collect
val backlog = held
held = listOf()
backlog.forEach { record(it) }
@@ -63,6 +63,8 @@ sealed class TranscriptItem {
* inside that reply would step the list under them.
*/
val settled: Boolean = false,
/** A final value that supersedes provisional deltas behind a page boundary. */
val replacesPrefix: Boolean = false,
) : TranscriptItem()
data class ToolRun(
@@ -308,6 +310,7 @@ private fun healSplitMessage(
// The rule between them is put in here too, since the fold that would have made it never saw
// these two side by side.
if (head.settled) return earlier to (listOf(TranscriptItem.TurnBreak(tail.seq)) + later)
if (tail.replacesPrefix) return earlier.dropLast(1) to later
return earlier.dropLast(1) to (listOf(tail.copy(text = head.text + tail.text)) + later.drop(1))
}
@@ -411,6 +414,24 @@ fun foldEvent(items: List<TranscriptItem>, entry: SeqEvent): List<TranscriptItem
items + between + TranscriptItem.AssistantMsg(entry.seq, event.delta)
}
}
is SessionEvent.AssistantTextFinal -> {
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg && !last.settled) {
items.dropLast(1) + last.copy(text = event.text, replacesPrefix = true)
} else {
val between =
if (last is TranscriptItem.AssistantMsg)
listOf(TranscriptItem.TurnBreak(entry.seq))
else emptyList()
items +
between +
TranscriptItem.AssistantMsg(
entry.seq,
event.text,
replacesPrefix = true,
)
}
}
is SessionEvent.ToolStart ->
items +
TranscriptItem.ToolRun(