diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt index 47b196c..6c6ee2c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/TranscriptItems.kt @@ -433,16 +433,24 @@ fun foldEvent(items: List, entry: SeqEvent): List - items + - TranscriptItem.ToolRun( - entry.seq, - event.id, - runIdFor(items, event.id, event.tool), - event.tool, - event.input, - "", - done = false, - ) + // A call id names one call for its whole lifetime. Codex can repeat the start while + // recovering an in-flight item; appending that replay made two rows with one key, and + // Compose aborts the entire LazyColumn when it encounters them. Ignoring the replay + // also repairs transcripts which already contain it when they are folded on reopen. + if (items.any { it is TranscriptItem.ToolRun && it.id == event.id }) { + items + } else { + items + + TranscriptItem.ToolRun( + entry.seq, + event.id, + runIdFor(items, event.id, event.tool), + event.tool, + event.input, + "", + done = false, + ) + } is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) } is SessionEvent.ToolEnd -> // Created when its start is not here, rather than dropped. A fold that only ever diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptItemsTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptItemsTest.kt index 185fa7f..be7b2ea 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptItemsTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/TranscriptItemsTest.kt @@ -142,6 +142,23 @@ class TranscriptItemsTest { assertTrue(items.none { it is TranscriptItem.TurnBreak }, "$items") } + @Test + fun a_repeated_tool_start_is_still_one_row() { + val start = SessionEvent.ToolStart("exec-1", "Bash", "{\"command\":\"cargo test\"}") + val items = + fold( + start, + SessionEvent.AssistantText("The test run is still going."), + start, + SessionEvent.ToolEnd("exec-1", "finished"), + ) + + val tools = items.filterIsInstance() + assertEquals(1, tools.size, "$items") + assertEquals("finished", tools.single().output) + assertTrue(tools.single().done) + } + /** * The page-join half of the same rule. A boundary that cuts one reply leaves an unfinished half * to be rejoined; a boundary that lands between two turns must not join anything, or paging