Keep the last tool call outside its group once it finishes

A call left its group only while it was running, so the moment a command ended
it vanished behind "Called 3 tools" -- and a session that has run its last
command and is composing its answer, or has finished the turn entirely, spends
most of its time in exactly that state. What folds a call back into its run is
therefore not finishing but being overtaken: anything arriving behind it, a
reply included, makes it history.

Standing outside the run is the call's place in the list as it is now rather
than something recorded on the call, so it is asked of the list while grouping
it, where the rest of that decision already lives.

Checked with ktfmtFormat, compileDebugKotlin, testDebugUnitTest and lintDebug,
and on the emulator against the sandbox: "/tools 3 1" settles as "Called 2
tools" with the third Bash card beneath it, and folds to "Called 3 tools" the
moment the next reply lands.
This commit is contained in:
iris-ai committed 2026-09-15 01:52:51 -04:00
1 parent 036eb375aa
commit 1e52b2910c
4 files changed
+75 -42

No files matched your search

@@ -5,9 +5,10 @@ import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* How a run of tool calls is cut into rows: the call still running is drawn on its own, and every
* piece the cut leaves behind still has a key of its own -- two rows sharing one key take the app
* down, and a key that moves takes the reader's place with it.
* How a run of tool calls is cut into rows: the call still running and the last call in the
* transcript are drawn on their own, and every piece the cut leaves behind still has a key of its
* own -- two rows sharing one key take the app down, and a key that moves takes the reader's place
* with it.
*/
class ToolRowsTest {
private var seq = 0L
@@ -23,10 +24,13 @@ class ToolRowsTest {
done = done,
)
/** Something that is not a tool call, to put behind the run so its last call folds in. */
private fun reply() = TranscriptItem.AssistantMsg(seq = ++seq, text = "done")
private fun shape(rows: List<TranscriptRow>) = rows.map { row ->
when (row) {
is TranscriptRow.Tools -> row.calls.map { it.id }
is TranscriptRow.Single -> listOf((row.item as TranscriptItem.ToolRun).id)
is TranscriptRow.Single -> listOf((row.item as? TranscriptItem.ToolRun)?.id ?: "reply")
}
}
@@ -37,9 +41,18 @@ class ToolRowsTest {
fun the_call_still_running_is_a_row_of_its_own() {
val rows =
groupToolRuns(
listOf(call("a"), call("b", runId = "a"), call("c", runId = "a", done = false))
listOf(
call("a"),
call("b", runId = "a"),
call("c", runId = "a", done = false),
call("d", runId = "a"),
reply(),
)
)
assertEquals(listOf(listOf("a", "b"), listOf("c")), shape(rows))
assertEquals(
listOf(listOf("a", "b"), listOf("c"), listOf("d"), listOf("reply")),
shape(rows),
)
assertKeysDistinct(rows)
}
@@ -52,9 +65,13 @@ class ToolRowsTest {
call("b", runId = "a", done = false),
call("c", runId = "a"),
call("d", runId = "a"),
reply(),
)
)
assertEquals(listOf(listOf("a"), listOf("b"), listOf("c", "d")), shape(rows))
assertEquals(
listOf(listOf("a"), listOf("b"), listOf("c", "d"), listOf("reply")),
shape(rows),
)
assertKeysDistinct(rows)
}
@@ -71,23 +88,34 @@ class ToolRowsTest {
assertEquals("b", rows.first().key)
}
/** What happens the moment a command finishes: it folds back into the run it was cut out of. */
/**
* Finishing is not what folds a call back in -- being overtaken is. A session that has run its
* last command and is writing its reply leaves that command standing until the reply starts.
*/
@Test
fun a_call_that_finishes_rejoins_its_run_without_moving_the_run() {
val running = listOf(call("a"), call("b", runId = "a", done = false))
val finished = listOf(running[0], (running[1] as TranscriptItem.ToolRun).copy(done = true))
val before = groupToolRuns(running)
val after = groupToolRuns(finished)
assertEquals(listOf(listOf("a", "b")), shape(after))
// The run keeps the key it was drawn under, so the list rebuilds a row rather than losing
// its anchor.
assertEquals(before.first().key, after.first().key)
fun the_last_call_stays_out_when_it_finishes_and_folds_in_when_something_follows() {
val a = call("a")
val running = call("b", runId = "a", done = false)
val finished = running.copy(done = true)
val whileRunning = groupToolRuns(listOf(a, running))
val afterItEnds = groupToolRuns(listOf(a, finished))
val afterTheReply = groupToolRuns(listOf(a, finished, reply()))
assertEquals(listOf(listOf("a"), listOf("b")), shape(whileRunning))
assertEquals(listOf(listOf("a"), listOf("b")), shape(afterItEnds))
assertEquals(listOf(listOf("a", "b"), listOf("reply")), shape(afterTheReply))
// The run keeps the key it was drawn under throughout, so the list rebuilds a row rather
// than losing its anchor.
assertEquals(whileRunning.first().key, afterItEnds.first().key)
assertEquals(whileRunning.first().key, afterTheReply.first().key)
}
@Test
fun a_run_of_finished_calls_is_still_one_group() {
val rows = groupToolRuns(listOf(call("a"), call("b", runId = "a"), call("c", runId = "a")))
assertEquals(listOf(listOf("a", "b", "c")), shape(rows))
assertTrue(rows.single() is TranscriptRow.Tools, "$rows")
fun a_run_of_finished_calls_is_one_group_once_something_follows_it() {
val rows =
groupToolRuns(
listOf(call("a"), call("b", runId = "a"), call("c", runId = "a"), reply())
)
assertEquals(listOf(listOf("a", "b", "c"), listOf("reply")), shape(rows))
assertTrue(rows.first() is TranscriptRow.Tools, "$rows")
}
}