Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,19 @@ fun groupConversationTimeline(messages: List<ChatMessage>): List<TimelineEntry>
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<String, Int>()

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()
}
Comment on lines 91 to 99

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · high]
詳細シート(findActivityParts)の再解決が、この suffix schemeで壊れます。"activity:toolu_stream:1" は生成時のカウントで決まるため、messages が 1 つ進むたびにオフセットがずれて(1)既に開いているシートが別のグループの内容に置き換わる/(2)該当 id が見つからずシートが急に閉じる、という事象が起こります。これはコメントで主張している「growing run's identity stays stable」と矛盾します。

「連続で登場する index」ではなく「グループが満たす条件そのもの」で id を決める(例: 同一 firstId の連続出現をまとめ、suffix を覆居的な順序としない、または findActivityParts で suffix から n 番目の出現を解決する)方法に変えることを検討してください。少なくとも、_findActivityParts が開いている sheet の id を安定的に再解決できること_を保証する必要があります。


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,4 +316,54 @@ 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<TimelineEntry.Activity>().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<TimelineEntry.Activity>().map { it.id }
assertEquals(listOf("activity:toolu_stream", "activity:toolu_stream:1"), grownIds)
}
}
Loading