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 @@ -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<JsonObject> =
Expand All @@ -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
}
Comment on lines +92 to +95

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 · medium]
originMessageId の決定に blocks.firstNotNullOfOrNull を使い、最初にマッチした tool_result の属先メッセージに newParts 全体を紐付けています。1つの user メッセージに複数の assistant メッセージ由来の tool_result が含まれる場合、または先頭の tool_result の tool_use_id が既に openTools から削除済み(先に settle 済みなど)で次の tool_result にフォールバックする場合、後続の tool_result が誤ったメッセージにマージされ、本来更新されるべき tool_use カードが未更新のまま残る可能性があります。ブロックごとに属先メッセージを解決し、メッセージ単位でまとめてマージする方が安全です。

val messageId = originMessageId ?: message.string("id") ?: newMessageId()
currentMessageId = messageId

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 · low]
tool_result を元の assistant メッセージにルーティングした結果、currentMessageId が originMessageId(過去の実在メッセージ)に設定されます。parsePartialDelta は currentMessageId を messageId として使うため、直後の assistant ターンのテキストデルタ(content_block_delta)が元メッセージのパーツID "$originMessageId-text" に紐付けられ、新しい応答のテキストが前のメッセージに誤って反映される恐れがあります。ルーティング後は currentMessageId を更新しない(または次ターン用にリセットする)など、tool_result 受信時に currentMessageId を汚さない設計にしてください。

val newParts =
buildList {
blocks.forEach { block ->
val part = parseContentBlock(messageId, block) ?: return@forEach
Expand All @@ -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<String?, OpenCodePart>()
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"),
Expand Down Expand Up @@ -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())

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 · medium]
text ブロックのパーツIDが "$messageId-text" に固定されており、thinking も "$messageId-reasoning" 固定です。1つのメッセージに text ブロックが複数含まれる場合(例: text→tool_use→text、Claude Code の content 配列は複数ブロックを許可)、すべて同じパーツIDになります。この結果、既存メッセージとのマージ時(byId[it.id] = it の後勝ち上書き)に先頭の text ブロックが失われるほか、events にも同一IDのパーツが複数流れ、UI側のパーツ更新が競合します。parsePartialDelta の "$messageId-$field" と一致させる必要があるのは理解できますが、複数ブロックを安全に扱うなら、ブロックごとの一意情報(content_block の index など)をIDに含めることを検討してください。

"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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading