From 976a57315a665a228768e801e6ee879ec0be18b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 18:22:50 +0000 Subject: [PATCH] fix(openai): pair tool calls and results on the call_id, and check the Responses request before sending A chat whose history held a tool call stored as "call_x|fc_y" and its result stored as "call_x" kept failing with 400 "No tool output found for function call" on every send. The history repairs compared raw ids, called the pair two orphans, deleted the real result and left the request unpaired. They now compare ToolPairing.key (the part before "|"). Responses requests also pass through ResponsesToolPairing.sanitize just before sending: results with no call and repeated results are dropped, calls with no result get a placeholder (the trailing call run is exempt). Both files and the call-site logic are ported from OpenMinis 1.14. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01PV4ECd3Yzu9k81sw7vHjS3 --- CHANGELOG.md | 1 + PROVENANCE.md | 1 + .../openminis/app/agent/ToolBatchRepair.kt | 12 +- .../com/openminis/app/provider/ToolPairing.kt | 25 ++ .../openai/OpenAIProviderResponses.kt | 16 +- .../provider/openai/ResponsesToolPairing.kt | 131 ++++++++++ .../openminis/app/ui/chat/OutgoingHistory.kt | 14 +- .../app/provider/ResponsesToolPairingTest.kt | 228 ++++++++++++++++++ .../ToolResultImageSerializationTest.kt | 12 +- 9 files changed, 429 insertions(+), 11 deletions(-) create mode 100644 src/android/app/src/main/java/com/openminis/app/provider/ToolPairing.kt create mode 100644 src/android/app/src/main/java/com/openminis/app/provider/openai/ResponsesToolPairing.kt create mode 100644 src/android/app/src/test/java/com/openminis/app/provider/ResponsesToolPairingTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ecb7ff09..2b8e171a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This changelog tracks **Minis for Android** as an independently maintained Andro ## Unreleased +- Fixed a chat that kept failing with `400 No tool output found for function call …` (or `No tool call found for function call output …`) on OpenAI Responses models and never recovered, while a new chat worked. A tool call stored as `call_x|fc_y` and its result stored as `call_x` were treated as two different ids, so the history repair deleted a real result and the request went out unpaired on every retry. Calls and results are now matched on the `call_x` part, and the Responses request is checked once more just before sending: a result with no call and a repeated result are dropped, a call with no result gets a placeholder. Ported from OpenMinis 1.14. - Device protection (Settings > Permissions, on by default): privileged commands from the agent, `android-root-cli` and the Android tools can no longer write partitions or the system partitions, remove `/data/system`, `/data/misc`, `/metadata` or `/data/adb`, uninstall or disable core system apps, factory-reset or remove user 0. Nothing is asked: the agent gets `DEVICE_PROTECTED: : ` and tries another way. Everything else stays open. Refusals are listed under the switch. When `/data` is nearly full the app freezes its own terminals and refuses new bulk writes until space is freed. `android-root-cli partition read` exports a partition read-only. - Root only: Shizuku (and AXManager / Sui) support is removed; Root is the app's one privileged path. The code before this change is kept on the branch `archive/before-root-only`. - `android-shizuku-cli` is now `android-root-cli`: the same subcommands and JSON, run as root through `su`. Its Agent permission switch carries over. diff --git a/PROVENANCE.md b/PROVENANCE.md index 904cfdb4..bf72b117 100644 --- a/PROVENANCE.md +++ b/PROVENANCE.md @@ -23,6 +23,7 @@ Minis for Android 含有从开源项目 **OpenMinis** 衍生的代码: - 长按用户消息的「压缩以上内容」入口与确认框(`[T-android-compact-above]`);`compactBefore` 本仓库早已具备,只引入入口。 - 回复 Token 用量的读取与格式(`ChatTokenUsage`、`[T-android-usage-capsule-time]`);展示位置改为回复长按菜单底部,不引入其「点空白处」的手势与聊天列表渲染改动。 - 子代理(`SubAgentDefinition` / `SubAgentRoster` 的规范化规则、`subagent_task` 工具合同与文案、回调文案,`[T-sub-agents-v1]`):花名册规则、工具 schema 与回调文案取自上游;运行时与注册表按本仓库的 `AgentRunner` 重写,模型绑定由「模型组」改为模型条目,工具沿用本仓库既有名称 `subagent`。差异记录在 `docs/development/SUB-AGENTS.md`。 + - 工具调用与结果的配对(`ToolPairing`、`ResponsesToolPairing`,`[T-android-responses-tool-id-normalize]`、`[T-android-responses-orphan-tool-output]`):两个文件原样取自上游;`OutgoingHistory.dropOrphanedToolParts` 与 `ToolBatchRepair` 按上游 `ChatViewModel` 的同名逻辑改为按 `ToolPairing.key` 比较,Responses 请求体在写入 `input` 前经过 `ResponsesToolPairing.sanitize`。测试按本仓库的结构改写。 ## Referenced projects diff --git a/src/android/app/src/main/java/com/openminis/app/agent/ToolBatchRepair.kt b/src/android/app/src/main/java/com/openminis/app/agent/ToolBatchRepair.kt index d6473f56..a3728ac1 100644 --- a/src/android/app/src/main/java/com/openminis/app/agent/ToolBatchRepair.kt +++ b/src/android/app/src/main/java/com/openminis/app/agent/ToolBatchRepair.kt @@ -2,6 +2,7 @@ package com.openminis.app.agent import com.openminis.app.data.model.AgentContentPart import com.openminis.app.data.model.LLMMessage +import com.openminis.app.provider.ToolPairing /** * [T-tool-batch-repair-android] Keeps the conversation well-formed for the provider, and @@ -57,8 +58,11 @@ object ToolBatchRepair { } val answered = repaired.getOrNull(index + 1)?.contentParts ?.filterIsInstance() - ?.map { it.id }?.toSet() ?: emptySet() - val missing = toolUses.filter { it.id !in answered } + ?.map { ToolPairing.key(it.id) }?.toSet() ?: emptySet() + // [T-android-responses-tool-id-normalize] Paired on ToolPairing.key, not the raw + // id: the Responses path stores "call|fc" and a history can hold both forms for one + // call. On raw ids a correctly paired result was called an orphan and deleted below. + val missing = toolUses.filter { ToolPairing.key(it.id) !in answered } if (missing.isEmpty()) { index++ continue @@ -95,7 +99,7 @@ object ToolBatchRepair { // some of them; drop it, and drop the message if that was all it carried. val knownCalls = repaired.flatMap { it.contentParts } .filterIsInstance() - .map { it.id } + .map { ToolPairing.key(it.id) } .toSet() var droppedResults = 0 var droppedMessages = 0 @@ -104,7 +108,7 @@ object ToolBatchRepair { val message = iterator.next() if (message.role != LLMMessage.Role.USER) continue val kept = message.contentParts.filter { part -> - val drop = part is AgentContentPart.ToolResult && part.id !in knownCalls + val drop = part is AgentContentPart.ToolResult && ToolPairing.key(part.id) !in knownCalls if (drop) droppedResults++ !drop } diff --git a/src/android/app/src/main/java/com/openminis/app/provider/ToolPairing.kt b/src/android/app/src/main/java/com/openminis/app/provider/ToolPairing.kt new file mode 100644 index 00000000..2773269f --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/provider/ToolPairing.kt @@ -0,0 +1,25 @@ +package com.openminis.app.provider + +/** + * [T-android-responses-tool-id-normalize] The one notion of "this tool result + * answers that tool call", shared by every pairing check. Port of iOS + * `AIChatViewModel.pairingKey` (bc142f750). + * + * The OpenAI Responses path stores a COMBINED id on each tool call and its + * result, `"|"` (OpenAIProvider.combineResponsesAPIIds), and + * the wire splits it and matches on the call_id half. A history can hold both + * forms for the same call - a model switch or a fallback to a Chat + * Completions model, history reloaded from the DB, a relay that rewrote ids - + * so comparing raw ids calls a correctly paired call/result an orphan: + * the history repair then deleted a real result and injected an "interrupted" + * placeholder for a tool that had worked. + * + * The key is the part before "|". Chat Completions, Anthropic and Gemini ids + * contain no "|", so for them this is the identity and nothing changes. + */ +object ToolPairing { + fun key(id: String): String { + val sep = id.indexOf('|') + return if (sep < 0) id else id.substring(0, sep) + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/provider/openai/OpenAIProviderResponses.kt b/src/android/app/src/main/java/com/openminis/app/provider/openai/OpenAIProviderResponses.kt index 2b0a098e..9ad4772e 100644 --- a/src/android/app/src/main/java/com/openminis/app/provider/openai/OpenAIProviderResponses.kt +++ b/src/android/app/src/main/java/com/openminis/app/provider/openai/OpenAIProviderResponses.kt @@ -505,7 +505,21 @@ internal fun OpenAIProvider.buildResponsesAPIBody( }) } } - body.put("input", input) + // [T-android-responses-orphan-tool-output] Last gate on the tool pairing, over the + // ids this request actually carries (split on "|" and capped). Without it a stranded + // or duplicated tool result 400s every send until the chat is cleared. + // See ResponsesToolPairing (ported from OpenMinis 1.14). + val paired = ResponsesToolPairing.sanitize(input) + if (paired.changed) { + com.openminis.app.logging.AppLogger.warning( + "OpenAIProvider", + "[sanitize-responses] repaired outgoing tool pairing: " + + "droppedOrphanOutputs=${paired.droppedOrphanOutputs} " + + "droppedDuplicateOutputs=${paired.droppedDuplicateOutputs} " + + "placeholderCalls=${paired.placeholderCalls} items=${input.length()}", + ) + } + body.put("input", paired.items) // [T-android-model-use-passthrough-mode GH#72] Same verbatim merge as the // chat-completions builder. Skipped for Codex OAuth inside mergeChatExtraBody. diff --git a/src/android/app/src/main/java/com/openminis/app/provider/openai/ResponsesToolPairing.kt b/src/android/app/src/main/java/com/openminis/app/provider/openai/ResponsesToolPairing.kt new file mode 100644 index 00000000..4fd928cd --- /dev/null +++ b/src/android/app/src/main/java/com/openminis/app/provider/openai/ResponsesToolPairing.kt @@ -0,0 +1,131 @@ +package com.openminis.app.provider.openai + +import org.json.JSONArray +import org.json.JSONObject + +/** + * [T-android-responses-orphan-tool-output] Last gate before a Responses + * request goes out: every `function_call_output` must answer a + * `function_call` with the same `call_id` in the array, at most once, and every + * `function_call` except the trailing run must be answered. Port of iOS + * `sanitizeResponsesToolPairing` (bc142f750, first-output-wins from ffbed35ca). + * + * Why it is needed: the Responses builder emitted `function_call_output` + * unconditionally. An upstream answers a stranded one with + * No tool call found for function call output with call_id call_… . + * and, because the request is rebuilt from the same history every time, the + * 400 repeated on every retry and every fallback model until the chat was + * cleared (the iOS field report). ChatViewModel's history repair catches most + * orphans one layer up, but it works on stored ids while this sees exactly the + * ids the request carries (split on "|" and capped), and it cannot see every + * source of a stranded or duplicated result (a retry, resume or restore that + * left an extra result row). + * + * Repair, same rules as iOS: + * - an output whose call is not in the request is DROPPED - nothing can + * reconstruct the call; + * - a second output for the same call_id is DROPPED - the first one wins, as + * in the Chat Completions path; + * - an unanswered call gets a placeholder output instead of being deleted, + * which would silently discard the assistant's own turn. The TRAILING run of + * calls is exempt: a request ending on function_call is what the API expects + * mid-round, and a placeholder there would tell the model a tool it is about + * to run has already failed. + * + * Placeholders are buffered until the run of function_call items closes and + * land before the first following item, so parallel calls stay + * `function_call ×N, function_call_output ×N` - an output in the middle of the + * call run, or a tool-image carrier between outputs, is itself a 400 + * ([T-android-responses-toolresult-image-split]). Order is otherwise kept as + * built; no items are moved. + */ +internal object ResponsesToolPairing { + + const val PLACEHOLDER_OUTPUT = + "Tool execution result is unavailable (history was truncated or interrupted)." + + class Result( + val items: JSONArray, + val droppedOrphanOutputs: List, + val droppedDuplicateOutputs: List, + val placeholderCalls: List, + ) { + val changed: Boolean + get() = droppedOrphanOutputs.isNotEmpty() || droppedDuplicateOutputs.isNotEmpty() || + placeholderCalls.isNotEmpty() + } + + fun sanitize(items: JSONArray): Result { + val callIds = HashSet() + val outputCounts = HashMap() + for (i in 0 until items.length()) { + val item = items.optJSONObject(i) ?: continue + when (item.optString("type")) { + "function_call" -> callIds.add(item.optString("call_id")) + "function_call_output" -> { + val id = item.optString("call_id") + outputCounts[id] = (outputCounts[id] ?: 0) + 1 + } + } + } + + // Calls at the very end are waiting for their results, not orphaned. + val trailing = HashSet() + for (i in items.length() - 1 downTo 0) { + val item = items.optJSONObject(i) ?: break + when (item.optString("type")) { + "function_call" -> trailing.add(item.optString("call_id")) + "reasoning" -> {} + else -> break + } + } + + val orphanOutputs = outputCounts.keys - callIds + val duplicated = outputCounts.filter { (id, n) -> n > 1 && id in callIds }.keys + val unanswered = callIds - outputCounts.keys - trailing + if (orphanOutputs.isEmpty() && duplicated.isEmpty() && unanswered.isEmpty()) { + return Result(items, emptyList(), emptyList(), emptyList()) + } + + val out = JSONArray() + val seenOutputs = HashSet() + val droppedOrphans = mutableListOf() + val droppedDuplicates = mutableListOf() + val placeholders = mutableListOf() + val pending = mutableListOf() + fun flush() { + for (p in pending) out.put(p) + pending.clear() + } + for (i in 0 until items.length()) { + val item = items.optJSONObject(i) + if (item == null) { + out.put(items.get(i)) + continue + } + val type = item.optString("type") + if (type == "function_call_output") { + val id = item.optString("call_id") + if (id in orphanOutputs) { droppedOrphans += id; continue } + if (!seenOutputs.add(id)) { droppedDuplicates += id; continue } + } + // A real output closes the call run just as a placeholder does, so + // pending placeholders go BEFORE it: one contiguous output block. + if (type != "function_call") flush() + out.put(item) + if (type == "function_call") { + val id = item.optString("call_id") + if (id in unanswered) { + placeholders += id + pending += JSONObject().apply { + put("type", "function_call_output") + put("call_id", id) + put("output", PLACEHOLDER_OUTPUT) + } + } + } + } + flush() + return Result(out, droppedOrphans, droppedDuplicates, placeholders) + } +} diff --git a/src/android/app/src/main/java/com/openminis/app/ui/chat/OutgoingHistory.kt b/src/android/app/src/main/java/com/openminis/app/ui/chat/OutgoingHistory.kt index 802c4a2e..5c7b6f32 100644 --- a/src/android/app/src/main/java/com/openminis/app/ui/chat/OutgoingHistory.kt +++ b/src/android/app/src/main/java/com/openminis/app/ui/chat/OutgoingHistory.kt @@ -6,6 +6,7 @@ import com.openminis.app.data.db.CompactMarkerEntity import com.openminis.app.data.model.AgentContentPart import com.openminis.app.data.model.LLMMessage import com.openminis.app.logging.AppLogger +import com.openminis.app.provider.ToolPairing /** * What is actually sent to the model from the conversation history: the compaction summary spliced @@ -244,13 +245,16 @@ internal object OutgoingHistory { * invalid on several providers. */ fun dropOrphanedToolParts(history: List): List { + // [T-android-responses-tool-id-normalize] Paired on ToolPairing.key, not the raw id: + // the Responses path stores "call|fc" ids and a history can hold both forms for + // one call, which raw ids called orphans (ported from OpenMinis 1.14). val toolUseIds = HashSet() val toolResultIds = HashSet() for (msg in history) { for (part in msg.contentParts) { when (part) { - is AgentContentPart.ToolUse -> toolUseIds.add(part.id) - is AgentContentPart.ToolResult -> toolResultIds.add(part.id) + is AgentContentPart.ToolUse -> toolUseIds.add(ToolPairing.key(part.id)) + is AgentContentPart.ToolResult -> toolResultIds.add(ToolPairing.key(part.id)) else -> {} } } @@ -271,7 +275,7 @@ internal object OutgoingHistory { val last = history.lastOrNull() if (last != null && last.role == LLMMessage.Role.ASSISTANT) { for (part in last.contentParts) { - if (part is AgentContentPart.ToolUse) orphanedUses.remove(part.id) + if (part is AgentContentPart.ToolUse) orphanedUses.remove(ToolPairing.key(part.id)) } } @@ -288,7 +292,7 @@ internal object OutgoingHistory { val cleaned = ArrayList(history.size) for (msg in history) { val kept = msg.contentParts.filter { part -> - if (part is AgentContentPart.ToolResult) !orphanedResults.contains(part.id) else true + if (part is AgentContentPart.ToolResult) !orphanedResults.contains(ToolPairing.key(part.id)) else true } // Only drop the message when it HAD parts and lost them all. A // plain text message legitimately carries no contentParts and must @@ -300,7 +304,7 @@ internal object OutgoingHistory { // placeholder results it never got, so the pair is complete. if (msg.role != LLMMessage.Role.ASSISTANT) continue val unanswered = kept.filterIsInstance() - .filter { orphanedUses.contains(it.id) } + .filter { orphanedUses.contains(ToolPairing.key(it.id)) } if (unanswered.isNotEmpty()) { cleaned.add( LLMMessage( diff --git a/src/android/app/src/test/java/com/openminis/app/provider/ResponsesToolPairingTest.kt b/src/android/app/src/test/java/com/openminis/app/provider/ResponsesToolPairingTest.kt new file mode 100644 index 00000000..75caf7f0 --- /dev/null +++ b/src/android/app/src/test/java/com/openminis/app/provider/ResponsesToolPairingTest.kt @@ -0,0 +1,228 @@ +package com.openminis.app.provider + +import com.openminis.app.data.model.AgentContentPart +import com.openminis.app.data.model.LLMMessage +import com.openminis.app.data.model.LLMModel +import com.openminis.app.agent.ToolBatchRepair +import com.openminis.app.provider.openai.OpenAIProvider +import com.openminis.app.provider.openai.buildResponsesAPIBody +import com.openminis.app.ui.chat.OutgoingHistory +import com.openminis.app.provider.openai.ResponsesToolPairing +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [T-android-responses-orphan-tool-output] + [T-android-responses-tool-id-normalize] + * Ports of iOS bc142f750 / ffbed35ca. + * + * Field report (iOS): every send failed with + * No tool call found for function call output with call_id call_… . + * and kept failing on every retry and fallback model until the chat was + * cleared, because the Responses request is rebuilt from the same history each + * time and nothing checked its tool pairing. + * + * The wire tests drive the real OpenAIProvider.buildResponsesAPIBody. + */ +class ResponsesToolPairingTest { + + private val model = LLMModel(id = "gpt-5", displayName = "GPT-5", provider = "test") + + private fun provider() = OpenAIProvider(apiKey = "k", model = model, + basePath = "https://example.invalid/v1", useResponsesAPI = true) + + private fun use(id: String, name: String = "shell_execute") = + AgentContentPart.ToolUse(id = id, name = name, input = JSONObject()) + + private fun res(id: String, name: String = "shell_execute", content: String = "ok $id") = + AgentContentPart.ToolResult(id = id, name = name, content = content) + + private fun assistant(vararg parts: AgentContentPart) = + LLMMessage(role = LLMMessage.Role.ASSISTANT, content = "", contentParts = parts.toList()) + + private fun results(vararg parts: AgentContentPart) = + LLMMessage(role = LLMMessage.Role.USER, content = "", contentParts = parts.toList()) + + private fun user(text: String) = LLMMessage(LLMMessage.Role.USER, text) + + /** One token per input item: fc:, out:, or the item's role. */ + private fun wire(messages: List): List { + val input = provider().buildResponsesAPIBody(messages = messages, systemPrompt = null, + maxTokens = 1024, stream = false).getJSONArray("input") + return tokens(input) + } + + private fun tokens(input: JSONArray): List = (0 until input.length()).map { i -> + val item = input.getJSONObject(i) + when (item.optString("type")) { + "function_call" -> "fc:" + item.getString("call_id") + "function_call_output" -> "out:" + item.getString("call_id") + else -> item.optString("role") + } + } + + private fun outputs(messages: List): List { + val input = provider().buildResponsesAPIBody(messages = messages, systemPrompt = null, + maxTokens = 1024, stream = false).getJSONArray("input") + return (0 until input.length()).map { input.getJSONObject(it) } + .filter { it.optString("type") == "function_call_output" } + } + + // ── on the wire ─────────────────────────────────────────────────────── + + @Test + fun `a stranded tool result never reaches the wire`() { + // The reported shape: a result whose call is gone from the history. + val seq = wire(listOf( + user("hi"), + results(res("call_gone|fc_gone")), + user("next"), + )) + assertFalse("orphan output sent: $seq", seq.any { it.startsWith("out:") }) + } + + @Test + fun `a call answered twice is sent with its first output only`() { + val msgs = listOf( + user("hi"), + assistant(use("call_a|fc_a")), + results(res("call_a|fc_a", content = "first")), + results(res("call_a", content = "second")), + user("next"), + ) + val outs = outputs(msgs) + assertEquals(1, outs.size) + assertEquals("first", outs.single().getString("output")) + } + + @Test + fun `an unanswered call mid-history gets a placeholder after the whole call run`() { + // Two parallel calls; only the second was answered, then the chat went on. + val seq = wire(listOf( + user("hi"), + assistant(use("call_a|fc_a"), use("call_b|fc_b")), + results(res("call_b|fc_b")), + user("next"), + )) + assertEquals(listOf("user", "fc:call_a", "fc:call_b", "out:call_a", "out:call_b", "user"), seq) + } + + @Test + fun `the trailing call run is left unanswered`() { + // The request that asks the model to continue after it called tools + // but before results exist ends on the calls; no fake failure. + val seq = wire(listOf(user("hi"), assistant(use("call_a|fc_a"), use("call_b|fc_b")))) + assertEquals(listOf("user", "fc:call_a", "fc:call_b"), seq) + } + + @Test + fun `mixed id forms still pair on the wire`() { + // Call stored combined, result stored with the bare call id (a model + // switch or a relay): both are the same call on the wire. + val seq = wire(listOf(user("hi"), assistant(use("call_a|fc_a")), results(res("call_a")), user("next"))) + assertEquals(listOf("user", "fc:call_a", "out:call_a", "user"), seq) + } + + @Test + fun `a well-formed history goes out unchanged`() { + val seq = wire(listOf( + user("hi"), + assistant(use("call_a|fc_a"), use("call_b|fc_b")), + results(res("call_a|fc_a"), res("call_b|fc_b")), + user("next"), + )) + assertEquals(listOf("user", "fc:call_a", "fc:call_b", "out:call_a", "out:call_b", "user"), seq) + } + + // ── the sanitizer on its own ────────────────────────────────────────── + + private fun fc(id: String) = JSONObject().put("type", "function_call").put("call_id", id).put("name", "t") + private fun out(id: String, text: String = "ok") = + JSONObject().put("type", "function_call_output").put("call_id", id).put("output", text) + private fun msg(role: String) = JSONObject().put("role", role).put("content", "x") + private fun arr(vararg items: JSONObject) = JSONArray().apply { items.forEach { put(it) } } + + @Test + fun `nothing to repair returns the same array`() { + val input = arr(msg("user"), fc("a"), out("a"), msg("user")) + val r = ResponsesToolPairing.sanitize(input) + assertSame(input, r.items) + assertFalse(r.changed) + } + + @Test + fun `a placeholder lands before the run's real output, keeping outputs contiguous`() { + val r = ResponsesToolPairing.sanitize(arr(fc("a"), fc("b"), out("b"), msg("user"))) + assertEquals(listOf("fc:a", "fc:b", "out:a", "out:b", "user"), tokens(r.items)) + assertEquals(listOf("a"), r.placeholderCalls) + assertEquals(ResponsesToolPairing.PLACEHOLDER_OUTPUT, + r.items.getJSONObject(2).getString("output")) + } + + @Test + fun `reasoning before the trailing calls keeps them exempt`() { + val reasoning = JSONObject().put("type", "reasoning") + val r = ResponsesToolPairing.sanitize(arr(msg("user"), reasoning, fc("a"))) + assertFalse(r.changed) + } + + @Test + fun `distinct parallel outputs are untouched while a duplicate is dropped`() { + val r = ResponsesToolPairing.sanitize(arr(fc("a"), fc("b"), out("a", "1"), out("b", "2"), out("a", "3"), msg("user"))) + assertEquals(listOf("fc:a", "fc:b", "out:a", "out:b", "user"), tokens(r.items)) + assertEquals(listOf("a"), r.droppedDuplicateOutputs) + assertEquals("1", r.items.getJSONObject(2).getString("output")) + } + + @Test + fun `an orphan output is dropped and reported`() { + val r = ResponsesToolPairing.sanitize(arr(msg("user"), out("gone"), msg("user"))) + assertEquals(listOf("user", "user"), tokens(r.items)) + assertEquals(listOf("gone"), r.droppedOrphanOutputs) + } + + // ── the history layer uses the same identity ───────────────────────── + + @Test + fun `pairing key is the call id half, identity for ids without a bar`() { + assertEquals("call_a", ToolPairing.key("call_a|fc_a")) + assertEquals("call_a", ToolPairing.key("call_a")) + assertEquals("toolu_01", ToolPairing.key("toolu_01")) + } + + @Test + fun `batch repair keeps a combined-id call and its bare-id result as a pair`() { + val history = listOf(user("hi"), assistant(use("call_a|fc_a")), results(res("call_a")), user("next")) + val repaired = ToolBatchRepair.repair(history) + assertFalse("a paired call/result was repaired: $repaired", repaired.changed) + assertEquals(history, repaired.messages) + } + + @Test + fun `batch repair still drops a genuinely stranded result and fills a genuinely missing one`() { + val stranded = ToolBatchRepair.repair(listOf(user("hi"), results(res("call_gone|fc_gone")), user("next"))) + assertEquals(1, stranded.droppedResults) + val missing = ToolBatchRepair.repair(listOf(user("hi"), assistant(use("call_a|fc_a")), user("next"))) + assertEquals(1, missing.injected) + } + + @Test + fun `outgoing history keeps a combined-id call and its bare-id result as a pair`() { + val history = listOf(user("hi"), assistant(use("call_a|fc_a")), results(res("call_a")), user("next")) + assertSame(history, OutgoingHistory.dropOrphanedToolParts(history)) + } + + @Test + fun `outgoing history still repairs a genuine orphan`() { + val cleaned = OutgoingHistory.dropOrphanedToolParts( + listOf(user("hi"), assistant(use("call_a|fc_a")), results(res("call_b|fc_b")), user("next")), + ) + val parts = cleaned.flatMap { it.contentParts } + assertTrue(parts.filterIsInstance().none { it.id == "call_b|fc_b" }) + assertTrue(parts.filterIsInstance().any { it.id == "call_a|fc_a" && it.isError }) + } +} diff --git a/src/android/app/src/test/java/com/openminis/app/provider/ToolResultImageSerializationTest.kt b/src/android/app/src/test/java/com/openminis/app/provider/ToolResultImageSerializationTest.kt index 947213d6..7a44b6d9 100644 --- a/src/android/app/src/test/java/com/openminis/app/provider/ToolResultImageSerializationTest.kt +++ b/src/android/app/src/test/java/com/openminis/app/provider/ToolResultImageSerializationTest.kt @@ -191,8 +191,18 @@ class ToolResultImageSerializationTest { @Test fun `responses api emits an input_image block for a tool-result image`() { // The Responses path is a separate builder, not a flag on buildRequestBody. + // [T-android-responses-orphan-tool-output] With its call: a result with no call is an + // orphan the Responses request now drops (the API 400s on it), which is not what this + // test is about. + val history = historyWithToolResultImage().toMutableList().apply { + add(1, LLMMessage( + role = LLMMessage.Role.ASSISTANT, + content = "", + contentParts = listOf(AgentContentPart.ToolUse(id = "call_abc123", name = "read_image", input = JSONObject())), + )) + } val body = provider(visionModel, useResponsesAPI = true).buildResponsesAPIBody( - messages = historyWithToolResultImage(), + messages = history, systemPrompt = null, maxTokens = 1024, stream = false,