diff --git a/chat.el b/chat.el index a307fb1..e88bc60 100644 --- a/chat.el +++ b/chat.el @@ -3190,12 +3190,31 @@ fn bridge_save(session_id: String, model: String, safe_sys: String, tools_json: // JSON values (not string-escaped) so the round-trip through state_get/json_get_raw // never corrupts nested quotes. Scalar strings (model, safe_sys, tools_log, // tool_use_id) stay as string fields via json_safe as before. + // + // FIELD ORDER IS LOAD-BEARING (round-9 fix, 2026-08-06). json_get is a first- + // substring-match scanner (strstr for "\"key\":", el_runtime.c), and the two raw + // fields embed the UNESCAPED conversation — every key the model's own blocks carry + // ("tool_use_id" in each web_search_tool_result, "content", "type", ...) is findable + // by a whole-blob scan. With messages_raw serialized BEFORE tool_use_id, the resume + // read json_get(blob, "tool_use_id") returned the FIRST web_search_tool_result's + // srvtoolu_… id instead of the saved client-tool id, so every search-then-bridge + // turn 400'd on approval ("unexpected tool_use_id found in tool_result blocks: + // srvtoolu_…") and the run died as {"error":"llm unavailable"}. Same first-match- + // scanner class as BUG-6 (approve "content" matched inside tool_input) and the + // round-8 citation-block fix. + // + // The rule: every json_safe'd scalar precedes both raw fields (escaping means a + // scalar value can never contain a bare "key": byte pattern, so first-match lands + // on the blob's own fields), and tools_raw — our own fixed tool schema — precedes + // messages_raw — arbitrary model/user content — so neither raw extraction can + // first-match into model-controlled bytes either. Do not reorder; do not add a + // field after messages_raw. let blob: String = "{\"model\":\"" + json_safe(model) + "\"" + ",\"safe_sys\":\"" + json_safe(safe_sys) + "\"" - + ",\"messages_raw\":" + messages - + ",\"tools_raw\":" + tools_json + ",\"tools_log\":\"" + json_safe(tools_log) + "\"" - + ",\"tool_use_id\":\"" + json_safe(tool_use_id) + "\"}" + + ",\"tool_use_id\":\"" + json_safe(tool_use_id) + "\"" + + ",\"tools_raw\":" + tools_json + + ",\"messages_raw\":" + messages + "}" state_set("mcp_bridge:" + session_id, blob) return true } @@ -3232,11 +3251,17 @@ fn agentic_resume(session_id: String, tool_use_id: String, content: String) -> S let tools_log: String = json_get(blob, "tools_log") let saved_use_id: String = json_get(blob, "tool_use_id") - // Bind the result to the tool the soul actually suspended on. The client should - // echo the call_id; if it omits or mismatches it, fall back to the saved id so a - // late/partial client still resumes correctly. - let use_id: String = if str_eq(tool_use_id, "") { saved_use_id } else { tool_use_id } - let eff_use_id: String = if str_eq(use_id, saved_use_id) { use_id } else { saved_use_id } + // Bind the result to the tool the loop actually suspended on. The client echoes + // the call_id from the pending envelope; that value came straight from + // pend_tool_id and never round-tripped through this blob, so when both are + // present and disagree the CLIENT's id is the one with clean provenance (a blob + // written by a pre-round-9 binary misreads tool_use_id by first-match scanning + // into messages_raw — see bridge_save). A client that omits call_id still + // resumes on the saved id, which the reordered blob now reads correctly. + // (The old guard here — "on mismatch, prefer saved" — reduced to eff_use_id ≡ + // saved_use_id in both branches: the client's correct id could never win, which + // is what turned the misread into a deterministic 400 on resume.) + let eff_use_id: String = if str_eq(tool_use_id, "") { saved_use_id } else { tool_use_id } // Result may be large (an MCP page/file); truncate like local tool results do. let trimmed: String = if str_len(content) > 6000 {