From 7a4eaa1894f9765464375a7c4ea8b646d8e7eb3d Mon Sep 17 00:00:00 2001 From: Yu-ga <74749461+yuga-hashimoto@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:43:59 +0000 Subject: [PATCH] fix: stop duplicated assistant text and stuck tool spinners in Claude Code chat Claude Code's stream-json protocol assigns mismatched ids across events for the same logical block: text content blocks never carry an "id" so the final full-message replay got a random UUID instead of the deterministic id streamed deltas already used, producing a duplicate text part. Similarly, tool_result lines report their own (often absent) message id rather than the id of the message that held the running tool_use, so results landed on a stranded new message and the original tool card never left the "running" state. Give text/thinking blocks the same deterministic part id as their delta stream, and route tool_result parts back onto the tool_use's originating message (merging into it instead of replacing it, since that store is also what gets persisted to disk). Co-Authored-By: Claude Sonnet 5 --- .../runtime/local/ClaudeStreamJsonParser.kt | 53 ++++++++++++++----- .../local/ClaudeStreamJsonParserTest.kt | 37 +++++++++++++ 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParser.kt b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParser.kt index f774037b..851e880b 100644 --- a/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParser.kt +++ b/app/src/main/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParser.kt @@ -77,8 +77,6 @@ class ClaudeStreamJsonParser( role: String, ): Parsed { val message = root["message"]?.jsonObject ?: return Parsed() - val messageId = message.string("id") ?: newMessageId() - currentMessageId = messageId val content = message["content"] // A plain string content block is valid in the protocol for simple user turns. val blocks: List = @@ -87,7 +85,17 @@ class ClaudeStreamJsonParser( is JsonPrimitive -> listOf(JsonObject(mapOf("type" to JsonPrimitive("text"), "text" to content))) else -> emptyList() } - val parts = + // Claude Code reports tool results on a fresh "user" message with its own (or no) id, but + // each result belongs to the assistant message whose tool_use block it answers. Route it + // back there so the still-running tool card is updated in place instead of being left stuck + // while a second, orphaned "completed" copy appears elsewhere. + val originMessageId = + blocks.firstNotNullOfOrNull { block -> + if (block.string("type") == "tool_result") openTools[block.string("tool_use_id")]?.messageId else null + } + val messageId = originMessageId ?: message.string("id") ?: newMessageId() + currentMessageId = messageId + val newParts = buildList { blocks.forEach { block -> val part = parseContentBlock(messageId, block) ?: return@forEach @@ -99,25 +107,37 @@ class ClaudeStreamJsonParser( } } } - if (parts.isEmpty()) return Parsed() + if (newParts.isEmpty()) return Parsed() // Tool results arrive on a "user" message; surfacing them as assistant activity keeps the // transcript readable instead of interleaving fake user turns. val effectiveRole = if (role == "user") "assistant" else role + val existing = messagesById[messageId] + // Merge rather than replace: a tool_result routed back onto an earlier assistant message + // must update that message's existing tool part in place, not wipe out its other parts. + val mergedParts = + if (existing != null) { + val byId = linkedMapOf() + existing.parts.forEach { byId[it.id] = it } + newParts.forEach { byId[it.id] = it } + byId.values.toList() + } else { + newParts + } val parsedMessage = OpenCodeMessage( info = - OpenCodeMessageInfo( + existing?.info ?: OpenCodeMessageInfo( id = messageId, sessionId = sessionId, role = effectiveRole, time = now(), agent = "claude", ), - parts = parts, + parts = mergedParts, ) messagesById[messageId] = parsedMessage return Parsed( - events = parts.map { OpenCodeEvent.MessagePartUpdated(it) }, + events = newParts.map { OpenCodeEvent.MessagePartUpdated(it) }, messages = listOf(parsedMessage), claudeSessionId = root.string("session_id"), resolvedModel = message.string("model"), @@ -208,13 +228,21 @@ class ClaudeStreamJsonParser( block: JsonObject, ): OpenCodePart? { val blockType = block.string("type") ?: return null - val partId = block.string("id") ?: "$messageId-${block.string("tool_use_id") ?: UUID.randomUUID()}" return when (blockType) { "text" -> - OpenCodePart(partId, sessionId, messageId, "text", text = block.string("text").orEmpty()) + // Must match parsePartialDelta's id for the same field so the final full-message + // replay overwrites the streamed-in text instead of appearing as a duplicate block. + OpenCodePart("$messageId-text", sessionId, messageId, "text", text = block.string("text").orEmpty()) "thinking" -> - OpenCodePart(partId, sessionId, messageId, "reasoning", text = block.string("thinking").orEmpty()) - "tool_use" -> + OpenCodePart( + "$messageId-reasoning", + sessionId, + messageId, + "reasoning", + text = block.string("thinking").orEmpty(), + ) + "tool_use" -> { + val partId = block.string("id") ?: "$messageId-${UUID.randomUUID()}" OpenCodePart( id = partId, sessionId = sessionId, @@ -228,8 +256,9 @@ class ClaudeStreamJsonParser( "input" to (block["input"] ?: JsonObject(emptyMap())), ), ) + } "tool_result" -> { - val callId = block.string("tool_use_id") ?: partId + val callId = block.string("tool_use_id") ?: block.string("id") ?: "$messageId-${UUID.randomUUID()}" val failed = block["is_error"]?.jsonPrimitive?.contentOrNull == "true" OpenCodePart( id = callId, diff --git a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParserTest.kt b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParserTest.kt index 008c8684..f5be2052 100644 --- a/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParserTest.kt +++ b/app/src/test/java/com/yugahashimoto/andcode/runtime/local/ClaudeStreamJsonParserTest.kt @@ -110,6 +110,43 @@ class ClaudeStreamJsonParserTest { assertTrue(parsed.events.last() is OpenCodeEvent.SessionIdle) } + @Test + fun `merges the final assistant message onto the streamed text instead of duplicating it`() { + val parser = parser() + parser.parse("""{"type":"assistant","message":{"id":"m5","content":[{"type":"text","text":""}]}}""") + parser.parse("""{"type":"stream_event","event":{"delta":{"type":"text_delta","text":"chunk"}}}""") + + // Claude Code replays the full text as a final "assistant" line once streaming for the + // block is done; a text content block never carries an "id", so this must land on the same + // synthesized part id the deltas used above rather than becoming a second, duplicate part. + val replayed = + parser.parse("""{"type":"assistant","message":{"id":"m5","content":[{"type":"text","text":"chunk"}]}}""") + + val parts = replayed.messages.single().parts + assertEquals(1, parts.size) + assertEquals("chunk", parts.single().text) + } + + @Test + fun `routes a tool_result back onto the message that started the tool instead of stranding it`() { + val parser = parser() + parser.parse( + """{"type":"assistant","message":{"id":"m-tool","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}""", + ) + + // The result line reports its own, unrelated message id ("m-result"), the way Claude Code's + // CLI actually behaves; the running tool card lives on "m-tool" and must be updated there. + val resultParsed = + parser.parse( + """{"type":"user","message":{"id":"m-result","content":[{"type":"tool_result","tool_use_id":"t1","content":"done"}]}}""", + ) + + val message = resultParsed.messages.single() + assertEquals("m-tool", message.info.id) + val tool = message.parts.single { it.id == "t1" } + assertEquals("completed", tool.state?.get("status")?.jsonPrimitive?.content) + } + @Test fun `settles open tools when a turn ends with an error`() { val parser = parser()