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,