From 52af6e3428df3aeb1afafa3e49190bac61e24601 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:18:55 +0000 Subject: [PATCH 1/2] fix: keep activity group keys unique when Claude reuses tool call ids Claude Code reuses tool_use call ids (toolu_...) across retries, so two distinct activity runs can both begin with a part of the same id. The group key was 'activity:${first part id}' which then collided inside LazyColumn, crashing with IllegalArgumentException. Tag repeat groups with an occurrence suffix while keeping the bare id for the first run, so a growing run's identity stays stable as its steps stream in. --- .../feature/chat/AssistantActivityGroup.kt | 11 ++++- .../chat/AssistantActivityGroupTest.kt | 49 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroup.kt b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroup.kt index 386f4926..a09bc467 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroup.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroup.kt @@ -82,10 +82,19 @@ fun groupConversationTimeline(messages: List): List var turnStartAt: Long? = null var turnCompletedAt: Long? = null var turnLastId: String? = null + // Claude Code reuses tool_use call ids (toolu_…) across retries, so two distinct runs can both + // begin with a part of the same id. Tag each group with how many runs have already started with + // that first part id so LazyColumn keys stay unique — the first group keeps the bare id so a + // growing run's identity stays stable while its steps stream in. + val firstIdOccurrences = mutableMapOf() fun flush() { if (pending.isEmpty()) return - entries += TimelineEntry.Activity("activity:${pending.first().id}", pending.toList()) + val firstId = pending.first().id + val occurrence = firstIdOccurrences[firstId] ?: 0 + firstIdOccurrences[firstId] = occurrence + 1 + val id = if (occurrence == 0) "activity:$firstId" else "activity:$firstId:$occurrence" + entries += TimelineEntry.Activity(id, pending.toList()) pending.clear() } diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroupTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroupTest.kt index 1c86f44f..19db5e94 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroupTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroupTest.kt @@ -316,4 +316,53 @@ class AssistantActivityGroupTest { assertEquals(parts.size, keys.toSet().size) } + + @Test + fun `activity group ids stay unique when separate runs reuse the same first part id`() { + // Claude Code reuses tool_use call ids (toolu_…) across retries, so two independent runs can + // each begin with the very same part id. That must not produce duplicate LazyColumn keys. + val entries = + groupConversationTimeline( + listOf( + assistant("m1", tool("toolu_retry", "bash")), + ChatMessage(id = "m2", isUser = true, parts = listOf(ChatPart.Text("u1", "try again"))), + assistant("m3", tool("toolu_retry", "bash")), + ), + ) + + val activityIds = entries.filterIsInstance().map { it.id } + assertEquals(listOf("activity:toolu_retry", "activity:toolu_retry:1"), activityIds) + assertEquals(activityIds.size, activityIds.toSet().size) + } + + @Test + fun `activity group id stays bare for the first run sharing a part id`() { + val entries = + groupConversationTimeline( + listOf( + assistant("m1", tool("toolu_same", "bash")), + ChatMessage(id = "m2", isUser = true, parts = listOf(ChatPart.Text("u1", "again"))), + assistant("m3", tool("toolu_same", "bash")), + ), + ) + + assertEquals("activity:toolu_same", (entries[0] as TimelineEntry.Activity).id) + } + + @Test + fun `activity group ids stay stable as a growing run streams in`() { + // The first occurrence keeps the bare id, so an already-flushed group's identity survives a + // sibling run that later reuses the same first part id. + val first = listOf(assistant("m1", tool("toolu_stream", "bash"))) + val firstId = (groupConversationTimeline(first).single() as TimelineEntry.Activity).id + assertEquals("activity:toolu_stream", firstId) + + val grown = + first + listOf( + ChatMessage(id = "m2", isUser = true, parts = listOf(ChatPart.Text("u1", "retry"))), + assistant("m3", tool("toolu_stream", "bash")), + ) + val grownIds = groupConversationTimeline(grown).filterIsInstance().map { it.id } + assertEquals(listOf("activity:toolu_stream", "activity:toolu_stream:1"), grownIds) + } } From 2bd435ca1799062fea404ef082057deda253b019 Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Sat, 8 Aug 2026 13:25:19 +0000 Subject: [PATCH 2/2] style: satisfy spotless line wrapping in activity group tests --- .../andcode/feature/chat/AssistantActivityGroupTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroupTest.kt b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroupTest.kt index 19db5e94..14e553df 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroupTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/feature/chat/AssistantActivityGroupTest.kt @@ -358,10 +358,11 @@ class AssistantActivityGroupTest { assertEquals("activity:toolu_stream", firstId) val grown = - first + listOf( - ChatMessage(id = "m2", isUser = true, parts = listOf(ChatPart.Text("u1", "retry"))), - assistant("m3", tool("toolu_stream", "bash")), - ) + first + + listOf( + ChatMessage(id = "m2", isUser = true, parts = listOf(ChatPart.Text("u1", "retry"))), + assistant("m3", tool("toolu_stream", "bash")), + ) val grownIds = groupConversationTimeline(grown).filterIsInstance().map { it.id } assertEquals(listOf("activity:toolu_stream", "activity:toolu_stream:1"), grownIds) }