From 02ea76a7ecd837c8cc93de7522979891769b379d Mon Sep 17 00:00:00 2001 From: 3316891527 <135376906+3316891527@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:03:28 +0000 Subject: [PATCH 1/2] fix(provider): sort Claude tool results by tool use order to preserve prompt cache When tools execute in parallel, results are emitted to the database in completion order rather than invocation order. When rebuilding prompt history for subsequent turns, ClaudeProvider output tool_result blocks in XML text order. Because Anthropic Prompt Caching requires strict prefix matching, this ordering divergence between turns broke the prompt cache on every subsequent turn following parallel tool calls. - Sort matched tool calls by openToolUses definition order before building Claude content blocks - Expose ClaudeSerializedHistory and buildSerializedHistory for unit testing - Add regression test in ClaudeProviderHistoryTest verifying parallel tool results are ordered by tool_use definition order Fixes #1159 --- .../api/chat/llmprovider/ClaudeProvider.kt | 11 ++- .../llmprovider/ClaudeProviderHistoryTest.kt | 96 +++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderHistoryTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt index a2f4f07b08..bb1eeab93e 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt @@ -596,7 +596,7 @@ open class ClaudeProvider( return json } - private data class ClaudeSerializedHistory( + internal data class ClaudeSerializedHistory( val messagesArray: JSONArray, val systemBlocks: JSONArray? ) @@ -707,9 +707,9 @@ open class ClaudeProvider( } } - private fun buildSerializedHistory( + internal fun buildSerializedHistory( chatHistory: List, - preserveThinkInHistory: Boolean + preserveThinkInHistory: Boolean = false ): ClaudeSerializedHistory { val messagesArray = JSONArray() val effectiveHistory = @@ -922,11 +922,14 @@ open class ClaudeProvider( if (resultsList.isNotEmpty() && openToolUses.isNotEmpty()) { val contentArray = JSONArray() + val useOrder = openToolUses.mapIndexed { i, c -> c.id to i }.toMap() val matchedCalls = StructuredToolCallBridge.consumeMatchingToolCalls( openToolUses, resultsList.map { it.first } - ) + ).sortedBy { matchedCall -> + useOrder[matchedCall.call.id] ?: Int.MAX_VALUE + } matchedCalls.forEach { matchedCall -> val resultContent = resultsList[matchedCall.resultIndex].second contentArray.put( diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderHistoryTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderHistoryTest.kt new file mode 100644 index 0000000000..02ef6d9d67 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderHistoryTest.kt @@ -0,0 +1,96 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import com.ai.assistance.operit.core.chat.hooks.PromptTurn +import com.ai.assistance.operit.core.chat.hooks.PromptTurnKind +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Test + +class ClaudeProviderHistoryTest { + + private fun createProvider(enableToolCall: Boolean = true): ClaudeProvider { + return ClaudeProvider( + apiEndpoint = "https://api.anthropic.com/v1/messages", + apiKeyProvider = SingleApiKeyProvider("sk-ant-test"), + modelName = "claude-3-5-sonnet-20241022", + client = OkHttpClient(), + enableToolCall = enableToolCall + ) + } + + private fun toolCall(name: String, vararg params: Pair): String { + val body = params.joinToString("") { (key, value) -> + "<" + "param name=\"$key\">" + value + "" + } + return "<" + "tool name=\"$name\">" + body + "" + } + + private fun toolResult(name: String, content: String): String = + "<" + "tool_result name=\"$name\" status=\"success\"><" + "content>" + content + "" + + list_files -> grep_code + val assistantTurn = PromptTurn( + kind = PromptTurnKind.ASSISTANT, + content = toolCall("read_file", "path" to "fileA.txt") + + toolCall("list_files", "path" to "dirB") + + toolCall("grep_code", "pattern" to "queryC") + ) + + // Due to async parallel execution race, the database recorded them in completion order: + // list_files finished first, then grep_code, then read_file + val toolResultTurn = PromptTurn( + kind = PromptTurnKind.TOOL_RESULT, + content = toolResult("list_files", "content_list_files") + + toolResult("grep_code", "content_grep_code") + + toolResult("read_file", "content_read_file") + ) + + val history = listOf( + PromptTurn(kind = PromptTurnKind.USER, content = "Inspect project"), + assistantTurn, + toolResultTurn + ) + + val serialized = provider.buildSerializedHistory(history) + val messages = serialized.messagesArray + + assertEquals(3, messages.length()) // user, then assistant, then user (tool_result) + val assistantMsg = messages.getJSONObject(1) + assertEquals("role", "assistant", assistantMsg.getString("role")) + val assistantContent = assistantMsg.getJSONArray("content") + assertEquals(3, assistantContent.length()) + + val toolUse0Id = assistantContent.getJSONObject(0).getString("id") + val toolUse1Id = assistantContent.getJSONObject(1).getString("id") + val toolUse2Id = assistantContent.getJSONObject(2).getString("id") + + assertEquals("read_file", assistantContent.getJSONObject(0).getString("name")) + assertEquals("list_files", assistantContent.getJSONObject(1).getString("name")) + assertEquals("grep_code", assistantContent.getJSONObject(2).getString("name")) + + val userMsg = messages.getJSONObject(2) + assertEquals("role", "user", userMsg.getString("role")) + val userContent = userMsg.getJSONArray("content") + assertEquals(3, userContent.length()) + + // The tool_result blocks in the user message MUST be in the exact order of tool_use (read_file, list_files, grep_code) + // rather than the out-of-order XML sequence (list_files, grep_code, read_file). + assertEquals("tool_result", userContent.getJSONObject(0).getString("type")) + assertEquals(toolUse0Id, userContent.getJSONObject(0).getString("tool_use_id")) + assertEquals("historical content match", "content_read_file", userContent.getJSONObject(0).getString("content")) + + + assertEquals("tool_result", userContent.getJSONObject(1).getString("type")) + assertEquals(toolUse1Id, userContent.getJSONObject(1).getString("tool_use_id")) + assertEquals("historical content match", "content_list_files", userContent.getJSONObject(1).getString("content")) + + + assertEquals("tool_result", userContent.getJSONObject(2).getString("type")) + assertEquals(toolUse2Id, userContent.getJSONObject(2).getString("tool_use_id")) + assertEquals("historical content match", "content_grep_code", userContent.getJSONObject(2).getString("content")) + } +} From 95332c70b1d3d85d3581852bf7332e8cda3b59eb Mon Sep 17 00:00:00 2001 From: 3316891527 <135376906+3316891527@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:48:47 +0000 Subject: [PATCH 2/2] test(provider): fix @Test annotation in ClaudeProviderHistoryTest --- .../operit/api/chat/llmprovider/ClaudeProviderHistoryTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderHistoryTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderHistoryTest.kt index 02ef6d9d67..9a6920b8a2 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderHistoryTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderHistoryTest.kt @@ -28,7 +28,7 @@ class ClaudeProviderHistoryTest { private fun toolResult(name: String, content: String): String = "<" + "tool_result name=\"$name\" status=\"success\"><" + "content>" + content + "" -