From 485b8b943e969e1d85a2bcc15ea503c41798718c Mon Sep 17 00:00:00 2001 From: Liang-HZ <36034777+Liang-HZ@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:13:03 +0800 Subject: [PATCH 1/5] fix(delegation): recognize opencode 2.x code-mode delegate calls wrapped in execute OpenCode 2.x routes MCP tool calls through its built-in `execute` tool, so the ACP update arrives as rawInput {"code": "tools[\"codeg-mcp\"].delegate_to_agent( {...})"} instead of JSON args. Registration, frontend classification and cold replay all keyed on the JSON-args shape, so opencode-as-main-agent delegations fell back to synthetic unkeyed ids: no delegation card, meta lost on refresh, and the FIFO pool could hand a call another call's entry. Adds a conservative JS-source scanner (Rust + TS mirrors) that extracts the delegation match key from the execute payload under the contract "never mint a keyed entry the MCP round-trip cannot claim": transparency pre-pass bails on spread/computed keys/parens/regex/shorthand key words, template cooked text follows ECMA-262 TV semantics (only CR/CRLF fold to LF; U+2028/2029 kept), line comments end at every JS line terminator, keys match at brace depth 1 with LAST-wins inside a single object, working_dir present-but-unreadable bails the whole key, and any assistant_id spelling degrades to unkeyed. The replay gate in conversations.rs reuses is_delegation_invocation as the shape predicate so persisted execute blocks rebind on reload via tool_use_id -> parent_tool_use_id -> task id. Broker pending-overview WARN now truncates dir like task. --- src-tauri/src/acp/delegation/broker.rs | 66 +- src-tauri/src/acp/lifecycle.rs | 1166 ++++++++++++++++- src-tauri/src/commands/conversations.rs | 86 ++ .../message/content-parts-renderer.tsx | 15 +- src/components/message/message-list-view.tsx | 9 +- src/lib/delegation-card.test.ts | 368 ++++++ src/lib/delegation-card.ts | 634 +++++++++ 7 files changed, 2326 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index 979240e222..aa253b593a 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -2242,6 +2242,44 @@ impl DelegationBroker { .await } + /// One-line summaries of the keyed pending entries still unclaimed on + /// this parent, for the synthetic-fallback WARN: a budget expiry with + /// keyed entries present means their keys disagree with the request's key + /// (an ACP-side parse that doesn't match what the MCP call actually + /// carries), and the summaries make that mismatch visible in the log + /// without a reproducer. Keyed entries are never FIFO-claimed, so they + /// persist here until their own round-trip, a terminal tombstone, or + /// teardown. + pub(crate) async fn unmatched_pending_key_summaries( + &self, + parent_connection_id: &str, + ) -> Vec { + let map = self.tool_calls.inner.lock().await; + let Some(bucket) = map.get(parent_connection_id) else { + return Vec::new(); + }; + bucket + .pending + .iter() + .filter_map(|p| { + let key = p.match_key.as_ref()?; + let task_preview: String = key.task.chars().take(80).collect(); + let dir_preview: String = key + .working_dir + .as_deref() + .unwrap_or("-") + .chars() + .take(120) + .collect(); + Some(format!( + "id={} target={target:?} task={task_preview:?} dir={dir_preview}", + p.tool_call_id, + target = key.target, + )) + }) + .collect() + } + /// Remove `handle` from the pre-cancel set, returning whether it was /// present. Used by `handle_request` at two checkpoints (entry + just /// after pending registration) so a cancel that lost the race with the @@ -2505,13 +2543,27 @@ impl DelegationBroker { .claim_pending_tool_call_with_brief_wait(&req.parent_connection_id, &match_key) .await; let claimed_identityless = matches!(claimed, Some((_, true))); - req.parent_tool_use_id = claimed.map(|(id, _)| id).unwrap_or_else(|| { - tracing::warn!( - "[delegation] synthetic fallback for parent_tool_use_id on conn={} (no ACP tool_call_id arrived within claim budget)", - req.parent_connection_id - ); - format!("delegation-{}", uuid::Uuid::new_v4()) - }); + req.parent_tool_use_id = match claimed { + Some((id, _)) => id, + None => { + let unmatched = self + .unmatched_pending_key_summaries(&req.parent_connection_id) + .await; + if unmatched.is_empty() { + tracing::warn!( + "[delegation] synthetic fallback for parent_tool_use_id on conn={} (no ACP tool_call_id arrived within claim budget)", + req.parent_connection_id + ); + } else { + tracing::warn!( + "[delegation] synthetic fallback for parent_tool_use_id on conn={} (no ACP tool_call_id arrived within claim budget); unmatched keyed pending entries remain: {}", + req.parent_connection_id, + unmatched.join(" | ") + ); + } + format!("delegation-{}", uuid::Uuid::new_v4()) + } + }; // The claimed announcement never carried an identity (Cursor's // "MCP: tool" + "{}" — the wire will not re-send title/arguments): // restore it NOW, before the multi-second spawn, so the live card diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 683b8b0410..df26d912a4 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -719,7 +719,12 @@ pub(crate) const CURSOR_IDENTITYLESS_MCP_TITLE: &str = "MCP: tool"; /// any wrapper layers via [`find_delegation_args`] — is a near-zero /// false-positive shape check that catches any host that mangles the title /// beyond recognition, including ones that wrap their tool-call args. -fn is_delegation_invocation(title: &str, raw_input: Option<&str>) -> bool { +/// +/// This is also the replay-side gate: `commands::conversations::inject_delegation_meta` +/// reuses it so a persisted tool block whose title/args (or opencode `code`) +/// shape would have been registered live gets historical meta on reload — +/// live and cold-start recognition stay identical by construction. +pub(crate) fn is_delegation_invocation(title: &str, raw_input: Option<&str>) -> bool { let normalized_title = title.to_ascii_lowercase().replace([' ', '-'], "_"); if normalized_title.contains("delegate_to_agent") { return true; @@ -738,6 +743,11 @@ fn is_delegation_invocation(title: &str, raw_input: Option<&str>) -> bool { } } } + if let Some(raw) = raw_input { + if code_mode_delegation_code(raw) { + return true; + } + } false } @@ -749,13 +759,30 @@ fn is_delegation_invocation(title: &str, raw_input: Option<&str>) -> bool { /// alone, doesn't collide when two parallel calls target different agents — /// or different directories — with the same task text). `working_dir` is the /// LLM's explicit value (`None` when omitted), matching the broker's -/// `DelegationRequest::requested_working_dir`. The args are located via -/// [`find_delegation_args`], so hosts that wrap or double-encode `raw_input` -/// are keyed identically to hosts that send the fields at the top level. -/// Returns `None` when `raw_input` is absent, not JSON, has no locatable -/// delegation object, or is missing/unparseable for `agent_type`/`task` — the -/// broker then falls back to FIFO ordering. +/// `DelegationRequest::requested_working_dir`. +/// +/// Two shapes are understood, in order: +/// +/// * a JSON arg blob the LLM sent to the MCP server (located via +/// [`find_delegation_args`], so hosts that wrap or double-encode `raw_input` +/// are keyed identically to hosts that send the fields at the top level) — +/// see [`extract_args_match_key`]; +/// * opencode's code-mode `execute` payload, whose `code` string wraps the +/// real call — see [`extract_code_mode_match_key`]. +/// +/// Returns `None` when neither shape yields a fully parseable +/// `(target, task, working_dir)` — the broker then falls back to FIFO +/// ordering. A missing key is strictly better than a wrong one: keyed entries +/// are retained indefinitely and claimable ONLY by exact match, so a +/// mismatched key would deadlock the correlation until teardown. fn extract_delegation_match_key(raw_input: Option<&str>) -> Option { + if let Some(key) = extract_args_match_key(raw_input) { + return Some(key); + } + extract_code_mode_match_key(raw_input) +} + +fn extract_args_match_key(raw_input: Option<&str>) -> Option { let raw = raw_input?; let parsed: serde_json::Value = serde_json::from_str(raw).ok()?; let args = find_delegation_args(&parsed, 0)?; @@ -775,6 +802,670 @@ fn extract_delegation_match_key(raw_input: Option<&str>) -> Option"}}`. +/// Neither earlier branch of [`is_delegation_invocation`] matches — the title +/// carries no `delegate_to_agent`, and the args live inside a JS string +/// invisible to [`find_delegation_args`] — so the call never registered, the +/// round-trip fell back to a synthetic `delegation-`, and the child's +/// status/result had no parent card to bind to. +/// +/// The recognizers below parse the `code` string with a small structural JS +/// scanner rather than regexes, and are STRICT about what counts as a call: +/// `tools` must be a standalone identifier reaching `delegate_to_agent(` +/// through a real accessor (`tools["codeg-mcp"].…` or `tools..…`), and +/// the scan skips strings, template literals, and comments, so a task text +/// *quoting* such a call (or a comment mentioning one) cannot register a +/// phantom delegation. +/// +/// Extraction is conservative in the same direction: any ambiguity +/// (interpolation, an unknown escape, an ESCAPED quoted `task`, a value that +/// continues past its literal (`"a" + b`, `` `a`.trim() ``), a present but +/// unreadable `working_dir`, an `assistant_id` in any spelling (quoted, +/// computed, spread-in), spread or computed-key syntax, unbalanced syntax) +/// degrades to NO key — the pre-existing unkeyed path (post-budget FIFO +/// claim) — rather than a WRONG keyed one. Template values are read as the +/// runtime would cook them (escapes decoded, bare CR/CRLF TV-normalized +/// to LF, U+2028/U+2029 preserved verbatim), so the key matches what the +/// MCP listener receives. +/// An escape-free quoted `task` is accepted: with no backslash present, the +/// evaluated value is exactly the source text (reviewed deviation from plan +/// v2's blanket "quoted task → no key"). + +/// `(open, close)` byte offsets of the `(` … `)` wrapping the arguments of +/// the first `delegate_to_agent(` call reached via a `tools` accessor in real +/// (non-string, non-comment) JS. `None` when the code exceeds the scan cap, +/// is structurally unbalanced, or names no such call. +fn code_mode_call_span(code: &str) -> Option<(usize, usize)> { + // A heuristic scan cap, measured in UTF-8 bytes; the TS mirror caps + // UTF-16 code units. The unit mismatch only shows on megabyte-scale + // non-ASCII payloads (backend declines, frontend may still classify) — + // recorded as a known boundary, not worth a TextEncoder pass per call. + const MAX_CODE_BYTES: usize = 1 << 20; + if code.len() > MAX_CODE_BYTES { + return None; + } + let b = code.as_bytes(); + let mut i = 0usize; + while i < b.len() { + match b[i] { + b'\'' | b'"' => i = skip_js_string(code, i)?, + b'`' => i = skip_js_template(code, i)?, + b'/' => match b.get(i + 1) { + Some(b'/') | Some(b'*') => i = skip_js_ws_and_comments(code, i), + _ => i += 1, + }, + _ if is_js_word_at(b, i, b"tools") => { + if let Some(open) = code_mode_tools_call_open(code, i) { + let close = matching_paren(code, open)?; + return Some((open, close)); + } + i += 1; + } + _ => i += 1, + } + } + None +} + +/// Byte offset of the `(` that opens the argument list of the +/// `delegate_to_agent` call whose `tools` token sits at `tools_at`. Two +/// accepted accessor shapes (opencode emits the first; the second covers a +/// namespaced client): `tools["codeg-mcp"].delegate_to_agent(` and +/// `tools.codeg_mcp.delegate_to_agent(`. +fn code_mode_tools_call_open(code: &str, tools_at: usize) -> Option { + let b = code.as_bytes(); + let mut i = tools_at + b"tools".len(); + i = skip_js_ws_and_comments(code, i); + match b.get(i) { + Some(b'[') => { + i += 1; + i = skip_js_ws_and_comments(code, i); + let quote = *b.get(i)?; + if quote != b'\'' && quote != b'"' { + return None; + } + let end = skip_js_string(code, i)?; + // A non-empty member name: `tools[""].delegate…` is no call site. + if end == i + 2 { + return None; + } + i = skip_js_ws_and_comments(code, end); + if b.get(i) != Some(&b']') { + return None; + } + i = skip_js_ws_and_comments(code, i + 1); + if b.get(i) != Some(&b'.') { + return None; + } + i = skip_js_ws_and_comments(code, i + 1); + } + Some(b'.') => { + i += 1; + i = skip_js_ws_and_comments(code, i); + let end = js_ident_end(b, i); + if end == i { + return None; + } + i = skip_js_ws_and_comments(code, end); + if b.get(i) != Some(&b'.') { + return None; + } + i = skip_js_ws_and_comments(code, i + 1); + } + _ => return None, + } + if !is_js_word_at(b, i, b"delegate_to_agent") { + return None; + } + i = skip_js_ws_and_comments(code, i + b"delegate_to_agent".len()); + if b.get(i) == Some(&b'(') { + Some(i) + } else { + None + } +} + +/// Byte offset of the `)` matching the `(` at `open`, skipping strings, +/// templates, and comments so a `)` inside them can't close the call early. +/// `None` on unbalanced input. +fn matching_paren(code: &str, open: usize) -> Option { + let b = code.as_bytes(); + let mut depth: i32 = 0; + let mut i = open; + while i < b.len() { + match b[i] { + b'\'' | b'"' => i = skip_js_string(code, i)?, + b'`' => i = skip_js_template(code, i)?, + b'/' => match b.get(i + 1) { + Some(b'/') | Some(b'*') => i = skip_js_ws_and_comments(code, i), + _ => i += 1, + }, + b'(' => { + depth += 1; + i += 1; + } + b')' => { + depth -= 1; + if depth == 0 { + return Some(i); + } + i += 1; + } + _ => i += 1, + } + } + None +} + +/// Byte offset just past the closing quote of the `'…'`/`"…"` string opening +/// at `open`. Byte-wise: multi-byte UTF-8 continuation bytes never equal an +/// ASCII quote, so `i` only ever lands on boundaries. `None` when the string +/// never closes. +fn skip_js_string(code: &str, open: usize) -> Option { + let b = code.as_bytes(); + let quote = b[open]; + let mut i = open + 1; + while i < b.len() { + match b[i] { + b'\\' => i += 2, + c if c == quote => return Some(i + 1), + _ => i += 1, + } + } + None +} + +/// Byte offset just past the closing backtick of the template literal opening +/// at `open`, tracking `${ … }` interpolation nesting (with strings, nested +/// templates, and comments inside interpolations). Frame-depth-capped; +/// `None` on unbalanced input. +fn skip_js_template(code: &str, open: usize) -> Option { + const MAX_FRAMES: usize = 16; + enum Frame { + Content, + Interp { brace_depth: i32 }, + } + let b = code.as_bytes(); + let mut stack: Vec = vec![Frame::Content]; + let mut i = open + 1; + while !stack.is_empty() { + if stack.len() > MAX_FRAMES { + return None; + } + let c = *b.get(i)?; + match (stack.last_mut()?, c) { + (Frame::Content, b'`') => { + stack.pop(); + i += 1; + if stack.is_empty() { + return Some(i); + } + } + (Frame::Content, b'\\') => i += 2, + (Frame::Content, b'$') if b.get(i + 1) == Some(&b'{') => { + stack.push(Frame::Interp { brace_depth: 0 }); + i += 2; + } + (Frame::Interp { brace_depth }, b'{') => { + *brace_depth += 1; + i += 1; + } + (Frame::Interp { brace_depth }, b'}') => { + if *brace_depth == 0 { + stack.pop(); + } else { + *brace_depth -= 1; + } + i += 1; + } + (Frame::Interp { .. }, b'\'' | b'"') => i = skip_js_string(code, i)?, + (Frame::Interp { .. }, b'`') => { + stack.push(Frame::Content); + i += 1; + } + (Frame::Interp { .. }, b'/') if b.get(i + 1) == Some(&b'/') => { + while i < b.len() && !is_line_terminator_start(b, i) { + i += 1; + } + } + (Frame::Interp { .. }, b'/') if b.get(i + 1) == Some(&b'*') => { + i = code[i + 2..].find("*/").map(|p| i + 2 + p + 2)?; + } + _ => i += 1, + } + } + None +} + +/// Advance past whitespace and both comment forms. Stops at the first +/// non-ws/non-comment byte (or end of input). +fn skip_js_ws_and_comments(code: &str, mut i: usize) -> usize { + let b = code.as_bytes(); + loop { + match b.get(i) { + Some(c) if c.is_ascii_whitespace() => i += 1, + Some(b'/') if b.get(i + 1) == Some(&b'/') => { + i += 2; + while i < b.len() && !is_line_terminator_start(b, i) { + i += 1; + } + } + Some(b'/') if b.get(i + 1) == Some(&b'*') => match code[i + 2..].find("*/") { + Some(p) => i = i + 2 + p + 2, + None => return b.len(), + }, + _ => return i, + } + } +} + +/// True when the byte at `i` begins a JS LineTerminator: LF, CR, U+2028 (LS), +/// or U+2029 (PS). ECMA-262 line comments end at ANY of these — a scanner +/// stopping only at LF would swallow the real code that follows an LS/PS/CR. +fn is_line_terminator_start(b: &[u8], i: usize) -> bool { + match b.get(i) { + Some(b'\n' | b'\r') => true, + Some(0xE2) => { + b.get(i + 1) == Some(&0x80) && matches!(b.get(i + 2), Some(0xA8 | 0xA9)) + } + _ => false, + } +} + +/// True when `word` sits at `at` as a standalone JS identifier: the +/// surrounding bytes are not identifier characters (ASCII ident chars, `$`, +/// or any non-ASCII byte — JS identifiers allow Unicode, and treating every +/// non-ASCII byte as an ident char is the safe over-approximation). +fn is_js_word_at(b: &[u8], at: usize, word: &[u8]) -> bool { + if b.len() < at + word.len() || &b[at..at + word.len()] != word { + return false; + } + let after = at + word.len(); + (at == 0 || !is_js_ident_byte(b[at - 1])) && (after >= b.len() || !is_js_ident_byte(b[after])) +} + +fn is_js_ident_byte(c: u8) -> bool { + c.is_ascii_alphanumeric() || c == b'_' || c == b'$' || c >= 0x80 +} + +fn js_ident_end(b: &[u8], at: usize) -> usize { + let mut end = at; + while end < b.len() && is_js_ident_byte(b[end]) { + end += 1; + } + end +} + +/// True when `raw_input` is an opencode code-mode `execute` payload whose +/// `code` string announces a `delegate_to_agent` call reachable via a `tools` +/// accessor. False for anything else — including other `execute` calls and +/// code that only *mentions* the method inside a string or comment. +fn code_mode_delegation_code(raw_input: &str) -> bool { + // Cheap pre-gate: any JSON payload carrying a `code` key contains the + // literal `"code"` bytes, so skipping the parse for payloads that don't + // keeps this branch free for every non-code-mode tool call. + if !raw_input.contains("\"code\"") { + return false; + } + let Ok(parsed) = serde_json::from_str::(raw_input) else { + return false; + }; + parsed + .get("code") + .and_then(|v| v.as_str()) + .is_some_and(|code| code_mode_call_span(code).is_some()) +} + +/// Build the broker key from a code-mode `execute` payload: locate the +/// `delegate_to_agent(` call span, then read `agent_type`/`working_dir`/`task` +/// out of its argument region. Every ambiguity returns `None` — see the +/// module doc — so the extraction can only produce a key byte-identical to +/// what the MCP listener receives, or no key at all. +fn extract_code_mode_match_key(raw_input: Option<&str>) -> Option { + let raw = raw_input?; + // Same cheap pre-gate as [`code_mode_delegation_code`]: this runs after + // the args-shape extractor already parsed the payload once. + if !raw.contains("\"code\"") { + return None; + } + let parsed: serde_json::Value = serde_json::from_str(raw).ok()?; + let code = parsed.get("code")?.as_str()?; + let (open, close) = code_mode_call_span(code)?; + let region = (open + 1, close); + // Syntax whose contribution the scanner cannot evaluate (spread, computed + // keys, escaped quoted keys) could hide an `assistant_id` or change any + // value — the whole extraction degrades to unkeyed, see + // [`code_mode_args_transparent`]. + if !code_mode_args_transparent(code, region) { + return None; + } + // Mixed-shape guard: a present `assistant_id` resolves to Assistant(id) + // on the MCP side and wins over `agent_type`; guessing Agent(…) from the + // code text would mint a keyed entry no round-trip can ever claim. + // Quoted spellings (`"assistant_id": 3`) are keys too — see + // [`code_mode_find_key`]. + if code_mode_find_key(code, region, b"assistant_id").is_some() { + return None; + } + let agent_type_at = code_mode_find_key(code, region, b"agent_type")?; + let agent_type = object_string_value(code, agent_type_at)?; + // `working_dir` is OPTIONAL in the key: ABSENT yields `working_dir: + // None`, but present-and-unreadable (backslash path, template, variable, + // concatenation) must bail the WHOLE key — the MCP side sees Some(…), + // so a None-keyed entry could never match. + let working_dir = match code_mode_find_key(code, region, b"working_dir") { + Some(at) => Some(object_string_value(code, at)?), + None => None, + }; + let task_at = code_mode_find_key(code, region, b"task")?; + let task = object_task_value(code, task_at)?; + let args = serde_json::json!({ "agent_type": agent_type }); + // The same parser the MCP listener dispatches the call with, so this + // target equals `DelegationRequest::target()` for the same call. + let target = crate::acp::delegation::types::delegation_target_from_args(&args).ok()?; + Some(DelegationMatchKey { + target, + task, + working_dir, + }) +} + +/// Conservative pre-pass over the argument region: `false` when the region +/// contains syntax whose effect the scanner cannot evaluate. Spread +/// (`...base`) may carry an `assistant_id`; a `[` covers computed keys +/// (`["assistant_id"]: 3`) and array values; a quoted KEY containing a +/// backslash evaluates to an unknowable spelling (it could BE the key being +/// searched for); a `(`/`)` or a non-comment `/` is a call/parenthesized/ +/// division/regex expression the scanner cannot evaluate (and a regex body +/// could feed quotes or braces to the string/template arms); a bare +/// `working_dir` / `assistant_id` identifier is a SHORTHAND property whose +/// value is the variable's runtime value. Each degrades to the unkeyed path. +fn code_mode_args_transparent(code: &str, region: (usize, usize)) -> bool { + let b = code.as_bytes(); + let (start, end) = region; + let mut i = start; + while i < end { + match b[i] { + b'\'' | b'"' => { + let Some(str_end) = skip_js_string(code, i) else { + return false; + }; + let after = skip_js_ws_and_comments(code, str_end); + if b.get(after) == Some(&b':') && code[i + 1..str_end - 1].contains('\\') { + return false; + } + i = str_end; + } + b'`' => { + let Some(t) = skip_js_template(code, i) else { + return false; + }; + i = t; + } + b'/' if b.get(i + 1) == Some(&b'/') || b.get(i + 1) == Some(&b'*') => { + i = skip_js_ws_and_comments(code, i); + } + b'/' => return false, + b'(' | b')' => return false, + b'[' => return false, + b'.' if b.get(i + 1) == Some(&b'.') && b.get(i + 2) == Some(&b'.') => return false, + _ if is_js_word_at(b, i, b"working_dir") => { + let after = skip_js_ws_and_comments(code, i + b"working_dir".len()); + if b.get(after) != Some(&b':') { + return false; + } + i += b"working_dir".len(); + } + _ if is_js_word_at(b, i, b"assistant_id") => { + let after = skip_js_ws_and_comments(code, i + b"assistant_id".len()); + if b.get(after) != Some(&b':') { + return false; + } + i += b"assistant_id".len(); + } + _ => i += 1, + } + } + true +} + +/// Byte offset of the VALUE of a top-level `key:` inside the `(start, end)` +/// byte region, skipping strings, templates, and comments so only real-code +/// occurrences count. Both spellings of a key match — bare `agent_type:` and +/// quoted `"agent_type":` / `'agent_type':` — but only escape-free quoted +/// keys (an escaped quoted key evaluates to an unknowable spelling, and +/// guessing could match a key that is really something else) and only at +/// brace depth 1, i.e. directly inside the call's argument object: the MCP +/// listener reads FLAT args, so a same-named key nested in a sub-object is +/// invisible to it too; scanning STOPS when that object closes (depth 1→0), +/// so a second argument object can never override the first's keys. +/// Duplicate keys take the LAST occurrence — the spelling +/// the JS runtime (and the serde JSON path) actually uses. Missing a key +/// degrades to unkeyed; seeing a wrong one could mint a key nothing can +/// claim. `None` when the key is absent or not followed by `:`. +fn code_mode_find_key(code: &str, region: (usize, usize), key: &[u8]) -> Option { + let b = code.as_bytes(); + let (start, end) = region; + let mut i = start; + let mut depth: usize = 0; + let mut found: Option = None; + while i < end { + match b[i] { + b'\'' | b'"' => { + let str_end = skip_js_string(code, i)?; + if depth == 1 && str_end >= i + 2 { + let inner = &code[i + 1..str_end - 1]; + let after = skip_js_ws_and_comments(code, str_end); + if b.get(after) == Some(&b':') + && !inner.contains('\\') + && inner.as_bytes() == key + { + found = Some(skip_js_ws_and_comments(code, after + 1)); + } + } + i = str_end; + } + b'`' => i = skip_js_template(code, i)?, + b'/' if b.get(i + 1) == Some(&b'/') || b.get(i + 1) == Some(&b'*') => { + i = skip_js_ws_and_comments(code, i); + } + b'{' => { + depth += 1; + i += 1; + } + b'}' => { + if depth <= 1 { + // This closes the call's argument object (or is a stray + // close): everything after lies outside the flat args the + // MCP listener reads, so a second object must not + // contribute keys — last-wins applies within ONE object. + break; + } + depth -= 1; + i += 1; + } + _ if depth == 1 && is_js_word_at(b, i, key) => { + let after = skip_js_ws_and_comments(code, i + key.len()); + if b.get(after) == Some(&b':') { + found = Some(skip_js_ws_and_comments(code, after + 1)); + } + i += 1; + } + _ => i += 1, + } + } + found +} + +/// True when the value's last consumed byte is followed (past whitespace and +/// comments) by `,` or `}` — i.e. the literal ends exactly where its object +/// property ends. A literal followed by an operator (`"a" + b`, `` `a`.trim() +/// ``) evaluates to something else at runtime, so the value must not be read +/// as the property's contribution. +fn value_ends_object_property(code: &str, end: usize) -> bool { + let after = skip_js_ws_and_comments(code, end); + matches!(code.as_bytes().get(after), Some(b',') | Some(b'}')) +} + +/// The contents of the plain `'…'`/`"…"` string starting at `at`, plus the +/// byte offset just past its closing quote. `None` for anything containing a +/// backslash: the key must match what the MCP listener receives +/// byte-for-byte, and these argument values are simple slugs/paths in +/// practice — an escaped one is not worth guessing. +fn plain_string_value_end(code: &str, at: usize) -> Option<(String, usize)> { + let b = code.as_bytes(); + let quote = *b.get(at)?; + if quote != b'\'' && quote != b'"' { + return None; + } + let end = skip_js_string(code, at)?; + let inner = &code[at + 1..end - 1]; + if inner.contains('\\') { + return None; + } + Some((inner.to_string(), end)) +} + +/// The evaluated `agent_type` / `working_dir` VALUE starting at `at`: an +/// escape-free plain string that ends where its property ends. Anything else +/// (template, variable, concatenation, number) yields `None`. +fn object_string_value(code: &str, at: usize) -> Option { + let (value, end) = plain_string_value_end(code, at)?; + if !value_ends_object_property(code, end) { + return None; + } + Some(value) +} + +/// The evaluated `task` VALUE starting at `at`. Template literals go through +/// [`template_literal_text_end`]; plain `'…'`/`"…"` strings are accepted only +/// when escape-free (a value with a backslash could decode differently than +/// the source suggests — bail to the unkeyed path instead). Anything else +/// (numbers, shorthand, interpolation) yields `None`. +fn object_task_value(code: &str, at: usize) -> Option { + let (value, end) = match code.as_bytes().get(at) { + Some(b'`') => template_literal_text_end(code, at)?, + Some(b'\'') | Some(b'"') => plain_string_value_end(code, at)?, + _ => return None, + }; + if !value_ends_object_property(code, end) { + return None; + } + Some(value) +} + +/// The evaluated text of the template literal starting at `at`, plus the byte +/// offset just past its closing backtick — the exact string the JS runtime +/// passes to the MCP listener. That means the template's cooked text, not its +/// source: `\n \t \r \b \f \v \0 \\ \` \' \" \$ \u{…} \uXXXX \xXX` decode, +/// line continuations (`\` + CR/CRLF/LF) contribute nothing, and a BARE CR +/// or CRLF is TV-normalized to a single LF — ECMA-262's normalization +/// rewrites only those two, so U+2028/U+2029 pass through verbatim. +/// `None` for `${…}` interpolations (the evaluated value +/// differs from the source text), for any escape outside the minimal set, +/// and for legacy octal — all degrade to the pre-existing unkeyed path. +fn template_literal_text_end(code: &str, at: usize) -> Option<(String, usize)> { + let b = code.as_bytes(); + let mut out = String::new(); + let mut i = at + 1; + while i < b.len() { + match b[i] { + b'`' => return Some((out, i + 1)), + b'\\' => { + i += 1; + let e = *b.get(i)?; + i += 1; + match e { + b'n' => out.push('\n'), + b't' => out.push('\t'), + b'r' => out.push('\r'), + b'b' => out.push('\u{0008}'), + b'f' => out.push('\u{000C}'), + b'v' => out.push('\u{000B}'), + b'0' => { + if b.get(i).is_some_and(|c| c.is_ascii_digit()) { + // Legacy octal (`\01` ≠ `\0` + `1`); not supported. + return None; + } + out.push('\0'); + } + b'\\' => out.push('\\'), + b'`' => out.push('`'), + b'$' => out.push('$'), + b'\'' => out.push('\''), + b'"' => out.push('"'), + b'u' => { + if b.get(i) == Some(&b'{') { + let close = b[i..].iter().position(|&c| c == b'}')?; + let digits = &b[i + 1..i + close]; + if digits.is_empty() || digits.len() > 6 { + return None; + } + let mut v: u32 = 0; + for &d in digits { + v = v * 16 + (d as char).to_digit(16)?; + } + out.push(char::from_u32(v)?); + i += close + 1; + } else { + let (v, next) = read_hex_digits(b, i, 4)?; + out.push(char::from_u32(v)?); + i = next; + } + } + b'x' => { + let (v, next) = read_hex_digits(b, i, 2)?; + out.push(char::from_u32(v)?); + i = next; + } + // Line continuations contribute nothing to the cooked text. + b'\r' => { + if b.get(i) == Some(&b'\n') { + i += 1; + } + } + b'\n' => {} + _ => return None, + } + } + b'$' if b.get(i + 1) == Some(&b'{') => return None, + // A bare CR (or the CR of a CRLF) is TV-normalized to a single LF. + b'\r' => { + out.push('\n'); + i += 1; + if b.get(i) == Some(&b'\n') { + i += 1; + } + } + _ => { + // U+2028/U+2029 are JS line terminators but NOT TV-normalized + // (only CR and CRLF are); folding them to LF here would mint a + // key the MCP side never receives. + let ch = code[i..].chars().next()?; + out.push(ch); + i += ch.len_utf8(); + } + } + } + None +} + +fn read_hex_digits(b: &[u8], at: usize, count: usize) -> Option<(u32, usize)> { + if at + count > b.len() { + return None; + } + let mut v: u32 = 0; + for k in 0..count { + v = v * 16 + (b[at + k] as char).to_digit(16)?; + } + Some((v, at + count)) +} + /// True when an ACP `ToolCallUpdate.status` string is terminal for delegation /// correlation. The live value is `format!("{:?}", ToolCallStatus).to_lowercase()` /// over the `agent-client-protocol-schema` enum (variants `Pending`, @@ -950,6 +1641,7 @@ async fn register_delegation_tool_call_from_event( #[cfg(test)] mod delegation_title_tests { use super::{extract_delegation_match_key, is_delegation_invocation}; + use crate::acp::delegation::broker::DelegationMatchKey; use crate::models::AgentType; #[test] @@ -1138,6 +1830,382 @@ mod delegation_title_tests { assert!(!is_terminal(Some("canceled"))); assert!(!is_terminal(Some("cancelled"))); } + + // ── opencode 2.x code-mode (`execute` tool) recognition ──────────────── + + #[test] + fn recognizes_opencode_code_mode_execute_call() { + // opencode 2.x wraps MCP calls in its built-in `execute` tool: the + // title is "execute" and the real call lives in a JS string under + // `code`. + let raw = r#"{"code":"const res = await tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `smoke test` })"}"#; + assert!(is_delegation_invocation("execute", Some(raw))); + // Namespaced accessor form. + let raw2 = r#"{"code":"await tools.codeg_mcp.delegate_to_agent({agent_type:\"codex\",task:`t`})"}"#; + assert!(is_delegation_invocation("execute", Some(raw2))); + // Loose whitespace is still the same call site. + let raw3 = r#"{"code":"tools [ \"codeg-mcp\" ] . delegate_to_agent ( { agent_type: \"codex\", task: `t` } )"}"#; + assert!(is_delegation_invocation("execute", Some(raw3))); + } + + #[test] + fn rejects_code_mode_shapes_that_are_not_calls() { + // Searching FOR the method is not calling it. + assert!(!is_delegation_invocation( + "execute", + Some(r#"{"code":"const q = tools.search({ query: \"delegate_to_agent\" })"}"#) + )); + // A comment mentioning the call shape registers nothing. + assert!(!is_delegation_invocation( + "execute", + Some(r#"{"code":"// tools[\"codeg-mcp\"].delegate_to_agent({agent_type:\"codex\",task:`t`})\nconst x = 1"}"#) + )); + // The call shape inside a string literal is data, not code. + assert!(!is_delegation_invocation( + "execute", + Some(r#"{"code":"const s = 'tools[\"codeg-mcp\"].delegate_to_agent({task:`t`})'"}"#) + )); + // No `code` field, not JSON, and other tools of the same host. + assert!(!is_delegation_invocation( + "execute", + Some(r#"{"input":"tools[\"codeg-mcp\"].delegate_to_agent({})"}"#) + )); + assert!(!is_delegation_invocation("execute", Some("not json"))); + assert!(!is_delegation_invocation( + "execute", + Some(r#"{"code":"await tools[\"codeg-mcp\"].get_delegation_status({ handle: \"h\" })"}"#) + )); + } + + #[test] + fn extracts_code_mode_key_from_template_task() { + let code = "const res = await tools[\"codeg-mcp\"].delegate_to_agent({\n agent_type: \"codex\",\n task: `帮我 review\n第二行`,\n working_dir: \"/tmp/w\",\n})"; + let raw = serde_json::json!({ "code": code }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("code-mode key parses"); + assert_eq!( + key.target, + crate::acp::delegation::types::DelegationTarget::Agent(AgentType::Codex) + ); + assert_eq!(key.task, "帮我 review\n第二行"); + assert_eq!(key.working_dir.as_deref(), Some("/tmp/w")); + } + + #[test] + fn code_mode_key_unescapes_minimal_js_escapes() { + // `\n` in the template SOURCE is a real newline after JS evaluation — + // the key must match what the MCP listener receives, not the source. + let code = r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `line1\nline2\tend` })"#; + let raw = serde_json::json!({ "code": code }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("escaped key parses"); + assert_eq!(key.task, "line1\nline2\tend"); + } + + #[test] + fn code_mode_key_accepts_escape_free_quoted_task() { + // A quoted string with no backslashes evaluates byte-identically, so + // it keys like a template would. + let code = r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "plain task" })"#; + let raw = serde_json::json!({ "code": code }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("quoted key parses"); + assert_eq!(key.task, "plain task"); + // …but an escaped quoted string bails (unkeyed) instead of guessing. + let escaped = r#"{"code":"tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: \"a\\nb\" })"}"#; + assert!(extract_delegation_match_key(Some(escaped)).is_none()); + } + + #[test] + fn code_mode_key_bails_on_ambiguity() { + // Interpolation evaluates differently from the source text. + let interp = r#"{"code":"tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `sum ${1 + 1}` })"}"#; + assert!(extract_delegation_match_key(Some(interp)).is_none()); + // Unknown escape → bail rather than guess. (`\\q` in the JSON string + // is the JS source's `\q`.) + let unknown_escape = r#"{"code":"tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `a\\qb` })"}"#; + assert!(extract_delegation_match_key(Some(unknown_escape)).is_none()); + // Mixed shape: `assistant_id` wins on the MCP side; guessing + // Agent(…) here would mint an unclaimable keyed entry. + let mixed = r#"{"code":"tools[\"codeg-mcp\"].delegate_to_agent({ assistant_id: 3, agent_type: \"codex\", task: `t` })"}"#; + assert!(extract_delegation_match_key(Some(mixed)).is_none()); + // No target / unknown target → no key. + let no_target = r#"{"code":"tools[\"codeg-mcp\"].delegate_to_agent({ task: `t` })"}"#; + assert!(extract_delegation_match_key(Some(no_target)).is_none()); + let bad_target = r#"{"code":"tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"garbage\", task: `t` })"}"#; + assert!(extract_delegation_match_key(Some(bad_target)).is_none()); + // The invocation is still RECOGNIZED in every ambiguous shape, so the + // id registers as an unkeyed candidate (post-budget FIFO claim). + assert!(is_delegation_invocation("execute", Some(interp))); + assert!(is_delegation_invocation("execute", Some(unknown_escape))); + assert!(is_delegation_invocation("execute", Some(mixed))); + assert!(is_delegation_invocation("execute", Some(no_target))); + } + + #[test] + fn code_mode_region_scan_does_not_bleed_across_calls() { + // A second tool call AFTER the delegate call carries its own `task:` + // field; keys must be read only within the delegate call's parens. + let code = "tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `t1` }); tools[\"codeg-mcp\"].get_delegation_status({ task: `other`, handle: \"h\" })"; + let raw = serde_json::json!({ "code": code }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("key parses"); + assert_eq!(key.task, "t1"); + } + + #[test] + fn code_mode_key_matches_the_mcp_side_for_the_same_call() { + // 对拍: the same logical delegation expressed twice — once as the + // evaluated MCP `tools/call` args (how `handle_request` builds the + // request key), once as opencode's code-mode ACP payload — must + // produce byte-identical keys. + use crate::acp::delegation::types::delegation_target_from_args; + + let task = "review 对拍\nsecond\tline ✓"; + let working_dir = "/tmp/对拍"; + let mcp_args = serde_json::json!({ + "agent_type": "codex", + "task": task, + "working_dir": working_dir, + }); + let request_key = DelegationMatchKey { + target: delegation_target_from_args(&mcp_args).unwrap(), + task: task.to_string(), + working_dir: Some(working_dir.to_string()), + }; + let code = r#"await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", working_dir: "/tmp/对拍", task: `review 对拍\nsecond\tline ✓` })"#; + let raw = serde_json::json!({ "code": code }).to_string(); + let acp_key = extract_delegation_match_key(Some(&raw)).expect("acp key parses"); + assert_eq!(acp_key, request_key); + + // U+2028/U+2029 pass through BOTH sides verbatim (ECMA-262 + // TV-normalizes only CR/CRLF), so the keys still match byte-for-byte. + let task_ls = "seg\u{2028}a\u{2029}b"; + let mcp_args = serde_json::json!({ "agent_type": "codex", "task": task_ls }); + let request_key = DelegationMatchKey { + target: delegation_target_from_args(&mcp_args).unwrap(), + task: task_ls.to_string(), + working_dir: None, + }; + let code_ls = "tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `seg\u{2028}a\u{2029}b` })"; + let raw = serde_json::json!({ "code": code_ls }).to_string(); + let acp_key = extract_delegation_match_key(Some(&raw)).expect("ls acp key parses"); + assert_eq!(acp_key, request_key); + } + + #[test] + fn code_mode_key_bails_when_working_dir_present_but_unreadable() { + // Present-and-unreadable must bail the WHOLE key: the MCP side sees + // Some(dir), so a None-keyed entry could never be claimed. Absent + // `working_dir` stays keyed with working_dir None. + let cases = [ + // JS source `working_dir: "C:\\proj"` — an escaped (unreadable) value. + r#"{ agent_type: "codex", working_dir: "C:\\proj", task: `t` }"#, + r#"{ agent_type: "codex", working_dir: `${cwd}/proj`, task: `t` }"#, + r#"{ agent_type: "codex", working_dir: projectDir, task: `t` }"#, + r#"{ agent_type: "codex", working_dir: "/tmp" + "/w", task: `t` }"#, + ]; + for code in cases { + let raw = + serde_json::json!({ "code": format!("tools[\"codeg-mcp\"].delegate_to_agent({code})") }) + .to_string(); + assert!( + extract_delegation_match_key(Some(&raw)).is_none(), + "expected unkeyed for {code}" + ); + assert!(is_delegation_invocation("execute", Some(&raw))); + } + let absent = r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` })"#; + let raw = serde_json::json!({ "code": absent }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("absent working_dir keys"); + assert_eq!(key.working_dir, None); + assert_eq!(key.task, "t"); + } + + #[test] + fn code_mode_key_bails_when_value_continues_past_literal() { + // A literal followed by an operator evaluates to something else at + // runtime; reading the literal alone would mint a wrong key. + let cases = [ + r#"{ agent_type: "codex", task: "a" + b }"#, + r#"{ agent_type: "codex", task: `a`.trim() }"#, + r#"{ agent_type: "co" + "dex", task: `t` }"#, + r#"{ agent_type: "codex", working_dir: "/a" + suffix, task: `t` }"#, + ]; + for code in cases { + let raw = + serde_json::json!({ "code": format!("tools[\"codeg-mcp\"].delegate_to_agent({code})") }) + .to_string(); + assert!( + extract_delegation_match_key(Some(&raw)).is_none(), + "expected unkeyed for {code}" + ); + assert!(is_delegation_invocation("execute", Some(&raw))); + } + } + + #[test] + fn code_mode_key_bails_when_args_syntax_is_unreadable() { + // A shorthand property evaluates to the variable's runtime value + // (keyed-with-None could never be claimed by THIS call's round-trip + // and would poison the FIFO pool); a nested call/parenthesized + // expression and a regex literal are expressions the scanner cannot + // evaluate — and a regex body could feed quotes/braces to the + // string/template arms. + let cases = [ + r#"{ agent_type: "codex", task: `t`, working_dir }"#, + r#"{ agent_type: "codex", task: `t`, assistant_id }"#, + r#"{ agent_type: pick("codex"), task: `t` }"#, + r#"{ agent_type: "codex", task: `t`, flag: /}/ }"#, + ]; + for code in cases { + let raw = + serde_json::json!({ "code": format!("tools[\"codeg-mcp\"].delegate_to_agent({code})") }) + .to_string(); + assert!( + extract_delegation_match_key(Some(&raw)).is_none(), + "expected unkeyed for {code}" + ); + assert!(is_delegation_invocation("execute", Some(&raw))); + } + } + + #[test] + fn code_mode_key_stops_at_the_argument_object_close() { + // A second object after the first's closing brace is a different + // argument, not part of the flat args; its keys must not win + // (last-wins applies within ONE object only). + let code = r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` }, { agent_type: "goose", task: `u` })"#; + let raw = serde_json::json!({ "code": code }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("first object keys"); + assert_eq!(key.task, "t"); + assert_eq!( + key.target, + crate::acp::delegation::types::DelegationTarget::Agent(AgentType::Codex) + ); + } + + #[test] + fn code_mode_line_comments_end_at_every_js_line_terminator() { + // ECMA-262 line comments end at CR, LF, U+2028 and U+2029; a scanner + // stopping only at LF would swallow the real code after the others. + let cr = "tools[\"codeg-mcp\"].delegate_to_agent({ // pick\r agent_type: \"codex\", task: `t` })"; + let raw = serde_json::json!({ "code": cr }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("CR comment ends"); + assert_eq!(key.task, "t"); + + let ls = "tools[\"codeg-mcp\"].delegate_to_agent({ // pick\u{2028} agent_type: \"codex\", task: `t` })"; + let raw = serde_json::json!({ "code": ls }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("LS comment ends"); + assert_eq!(key.task, "t"); + } + + #[test] + fn code_mode_key_accepts_comments_around_values() { + // Comments between a value and its property terminator (or between + // properties) are whitespace; they must not break a readable call. + let code = r#"tools["codeg-mcp"].delegate_to_agent({ + // choose the agent + agent_type: "codex", /* trailing */ task: `t` /* last */, // done +})"#; + let raw = serde_json::json!({ "code": code }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("commented form parses"); + assert_eq!(key.task, "t"); + assert_eq!( + key.target, + crate::acp::delegation::types::DelegationTarget::Agent(AgentType::Codex) + ); + } + + #[test] + fn code_mode_assistant_id_guard_covers_every_spelling() { + // Bare numeric, quoted-key, and quoted-key+quoted-value spellings all + // resolve to Assistant(…) on the MCP side; no Agent(…) guess may key. + let bare = r#"tools["codeg-mcp"].delegate_to_agent({ assistant_id: 3, task: `t` })"#; + let quoted = r#"tools["codeg-mcp"].delegate_to_agent({ "assistant_id": 3, task: `t` })"#; + let quoted_value = r#"tools["codeg-mcp"].delegate_to_agent({ "assistant_id": "3", task: `t` })"#; + for code in [bare, quoted, quoted_value] { + let raw = serde_json::json!({ "code": code }).to_string(); + assert!( + extract_delegation_match_key(Some(&raw)).is_none(), + "expected unkeyed for {code}" + ); + assert!(is_delegation_invocation("execute", Some(&raw))); + } + // Computed key and spread are unknowable syntax: the transparency + // pre-pass degrades the whole region. + let computed = + r#"tools["codeg-mcp"].delegate_to_agent({ ["assistant_id"]: 3, agent_type: "codex", task: `t` })"#; + let spread = r#"tools["codeg-mcp"].delegate_to_agent({ ...base, task: `t` })"#; + for code in [computed, spread] { + let raw = serde_json::json!({ "code": code }).to_string(); + assert!( + extract_delegation_match_key(Some(&raw)).is_none(), + "expected unkeyed for {code}" + ); + assert!(is_delegation_invocation("execute", Some(&raw))); + } + } + + #[test] + fn code_mode_key_reads_quoted_keys_and_ignores_nested_ones() { + // Quoted property keys are real keys too. + let quoted = + r#"tools["codeg-mcp"].delegate_to_agent({ "agent_type": "codex", 'task': "plain" })"#; + let raw = serde_json::json!({ "code": quoted }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("quoted-key form parses"); + assert_eq!(key.task, "plain"); + assert_eq!( + key.target, + crate::acp::delegation::types::DelegationTarget::Agent(AgentType::Codex) + ); + + // A same-named key nested in a sub-object is invisible to the MCP + // listener (it reads flat args), so it must not be read here either — + // in either source order. + let nested = r#"tools["codeg-mcp"].delegate_to_agent({ + meta: { agent_type: "zzz", task: `decoy` }, + agent_type: "codex", + task: `real`, +})"#; + let raw = serde_json::json!({ "code": nested }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("nested form parses"); + assert_eq!(key.task, "real"); + assert_eq!( + key.target, + crate::acp::delegation::types::DelegationTarget::Agent(AgentType::Codex) + ); + let nested_rev = r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `real`, meta: { task: `decoy`, agent_type: "zzz" } })"#; + let raw = serde_json::json!({ "code": nested_rev }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("nested-rev form parses"); + assert_eq!(key.task, "real"); + } + + #[test] + fn code_mode_template_task_normalizes_line_terminators() { + // The JS runtime TV-normalizes bare line terminators in template + // literals to a single LF (CRLF and CR included); the key must be + // built from the COOKED text the MCP listener receives. + let code = "tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `a\r\nb\rc` })"; + let raw = serde_json::json!({ "code": code }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("crlf key parses"); + assert_eq!(key.task, "a\nb\nc"); + + // U+2028/U+2029 are JS line terminators but NOT TV-normalized + // (ECMA-262 rewrites only CR and CRLF); they pass through verbatim. + let ls = "tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `a\u{2028}b` })"; + let raw = serde_json::json!({ "code": ls }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("LS key parses"); + assert_eq!(key.task, "a\u{2028}b"); + + let ps = "tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `a\u{2029}b` })"; + let raw = serde_json::json!({ "code": ps }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("PS key parses"); + assert_eq!(key.task, "a\u{2029}b"); + + // A backslash line continuation contributes nothing to the cooked text. + let cont = + "tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `a\\\r\nb` })"; + let raw = serde_json::json!({ "code": cont }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("continuation key parses"); + assert_eq!(key.task, "ab"); + } } #[cfg(test)] @@ -1609,6 +2677,90 @@ mod delegation_registration_tests { "args-wrapped delegation input must produce a keyed entry" ); } + + /// opencode 2.x code-mode: the `execute` announcement (title "execute", + /// the real call inside a JS string under `code`) must register KEYED, and + /// the exact key the MCP round-trip presents must claim that id — the + /// full loop that used to end in a synthetic `delegation-`. + #[tokio::test] + async fn opencode_code_mode_execute_yields_keyed_registration() { + let b = broker(); + let code = r#"const res = await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `research code-mode` })"#; + let raw = serde_json::json!({ "code": code }).to_string(); + register_delegation_tool_call_from_event( + &b, + &tool_call_event("tc-codemode", "execute", Some(&raw)), + ) + .await; + assert_eq!( + b.take_matching_tool_call("parent-conn", &codex_key("research code-mode")) + .await + .as_deref(), + Some("tc-codemode"), + "code-mode execute payload must produce a keyed entry the MCP-side key claims" + ); + } + + /// An ambiguous code-mode payload (interpolated task) still registers the + /// id UNKEYED, so the post-budget FIFO path can bind it instead of + /// orphaning the call to a synthetic id. + #[tokio::test] + async fn ambiguous_code_mode_execute_still_registers_unkeyed() { + let b = broker(); + let raw = r#"{"code":"tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `sum ${1 + 1}` })"}"#; + register_delegation_tool_call_from_event( + &b, + &tool_call_event("tc-ambiguous", "execute", Some(raw)), + ) + .await; + // Keyed claim must NOT match (no key was minted); the unkeyed FIFO + // fallback is what picks the id up. + assert!( + b.take_matching_tool_call("parent-conn", &codex_key("sum 2")) + .await + .is_none(), + "an ambiguous payload must not mint a keyed entry" + ); + assert_eq!( + b.take_pending_tool_call("parent-conn").await.as_deref(), + Some("tc-ambiguous"), + "the id must remain claimable via the unkeyed FIFO path" + ); + } + + /// The synthetic-fallback diagnostic: keyed pending entries still waiting + /// on this parent surface as `id=… target=… task=… dir=…` summaries, so a + /// "pending key vs request key" mismatch is visible in the WARN. + #[tokio::test] + async fn unmatched_pending_key_summaries_reports_keyed_entries() { + let b = broker(); + assert!( + b.unmatched_pending_key_summaries("parent-conn") + .await + .is_empty(), + "no entries → no summaries" + ); + b.register_pending_tool_call_with_key( + "parent-conn", + "call-keyed".into(), + Some(codex_key("review 对拍")), + ) + .await; + b.register_pending_tool_call_with_key("parent-conn", "call-unkeyed".into(), None) + .await; + let summaries = b.unmatched_pending_key_summaries("parent-conn").await; + assert_eq!(summaries.len(), 1, "unkeyed entries are not summarized"); + assert!(summaries[0].contains("call-keyed"), "{}", summaries[0]); + assert!(summaries[0].contains("review 对拍"), "{}", summaries[0]); + // Claiming the entry empties the summary again. + b.take_matching_tool_call("parent-conn", &codex_key("review 对拍")) + .await; + assert!( + b.unmatched_pending_key_summaries("parent-conn") + .await + .is_empty() + ); + } } /// Per-connection worker that owns the cache for one connection and diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 142ea40276..cc25e8da1d 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -1347,6 +1347,13 @@ fn parse_resume_task_id(input: &str) -> Option { /// an earlier turn). Without this the resumed card would be frozen at the /// `running` its ack reported, forever — the child's real outcome landed on the /// DB row, not on the resume result. +/// +/// Any OTHER tool block whose title+args pass [`crate::acp::lifecycle::is_delegation_invocation`] +/// binds by `tool_use_id` exactly like `delegate_to_agent`. This is what covers +/// opencode 2.x code mode: the host wraps the MCP call in its `execute` tool, so +/// the persisted block's title is `execute` and the real invocation lives inside +/// a JS `code` string. The live path registers those calls through the same +/// predicate, so replay recognition cannot drift from it. fn inject_delegation_meta(turns: &mut [MessageTurn], children: &[DbConversationSummary]) { if children.is_empty() { return; @@ -1412,6 +1419,20 @@ fn inject_delegation_meta(turns: &mut [MessageTurn], children: &[DbConversationS .as_deref() .and_then(parse_resume_task_id) .and_then(|task_id| by_task_id.get(task_id.as_str()).copied()) + } else if crate::acp::lifecycle::is_delegation_invocation( + tool_name, + input_preview.as_deref(), + ) { + tool_use_id.as_deref().and_then(|tu| { + by_parent_tool_use_id + .get(tu) + .or_else(|| { + task_id_by_call + .get(tu) + .and_then(|task_id| by_task_id.get(task_id.as_str())) + }) + .copied() + }) } else { continue; }; @@ -3464,6 +3485,71 @@ mod tests { ); } + /// opencode 2.x wraps the MCP call in its `execute` tool — the persisted + /// block's title is `execute` and the real invocation lives in a JS `code` + /// string. Replay binds it by the same predicate the live path registers + /// with, so a refreshed card keeps its status mapping and 查看会话. + #[test] + fn inject_delegation_meta_binds_an_opencode_execute_call() { + let code = r#"await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` })"#; + let mut turns = vec![tool_use_turn_with_input( + Some("tu-exec-1"), + "execute", + Some(&serde_json::json!({ "code": code }).to_string()), + )]; + inject_delegation_meta(&mut turns, &[summary_child(42, "tu-exec-1", "completed")]); + let inner = first_block_meta(&turns[0]) + .and_then(|m| m.get("codeg.delegation").cloned()) + .expect("execute card should be bound"); + assert_eq!(inner["status"], "completed"); + assert_eq!(inner["child_conversation_id"], 42); + assert_eq!(inner["agent_type"], "codex"); + } + + /// The binding fallback is the shared closure, not the tool-name branch: + /// an `execute` block whose tool_use_id never meets the child row still + /// binds through the task id echoed on its own result. + #[test] + fn inject_delegation_meta_binds_an_execute_call_by_its_task_id() { + let code = r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: `t` })"#; + let mut turns = vec![ + tool_use_turn_with_input( + Some("call_exec_2"), + "execute", + Some(&serde_json::json!({ "code": code }).to_string()), + ), + tool_result_turn( + "call_exec_2", + "Delegation successful. task_id=8ff4c14c-740c-4482-b758-8f2091f97063.", + ), + ]; + let mut child = summary_child(7, "exec-unmatched-id", "completed"); + child.delegation_call_id = Some("8ff4c14c-740c-4482-b758-8f2091f97063".into()); + + inject_delegation_meta(&mut turns, &[child]); + + let inner = first_block_meta(&turns[0]) + .and_then(|m| m.get("codeg.delegation").cloned()) + .expect("execute card should bind via task id"); + assert_eq!(inner["child_conversation_id"], 7); + } + + /// The shape gate cuts both ways: an `execute` block whose code is not a + /// delegation must stay unbound. + #[test] + fn inject_delegation_meta_does_not_bind_an_unrelated_execute_call() { + let mut turns = vec![tool_use_turn_with_input( + Some("tu-exec-3"), + "execute", + Some(r#"{"code":"const x = 1"}"#), + )]; + inject_delegation_meta(&mut turns, &[summary_child(42, "tu-exec-3", "completed")]); + assert!( + first_block_meta(&turns[0]).is_none(), + "a non-delegation execute call must not be bound" + ); + } + /// A reloaded card of an assistant delegation draws the assistant from the /// child row: its id, and its name as the task started. #[test] diff --git a/src/components/message/content-parts-renderer.tsx b/src/components/message/content-parts-renderer.tsx index 59a7aebfdf..2365bd75e0 100644 --- a/src/components/message/content-parts-renderer.tsx +++ b/src/components/message/content-parts-renderer.tsx @@ -11,7 +11,10 @@ import { import { parseBackgroundLaunch } from "@/lib/background-task" import { isUnsettledToolCall } from "@/lib/tool-call-lifecycle" import { normalizePriority, normalizeStatus } from "@/lib/plan-parse" -import { isDelegateToAgentToolName } from "@/lib/delegation-card" +import { + isCodeModeDelegationInput, + isDelegateToAgentToolName, +} from "@/lib/delegation-card" import { useTranslations } from "next-intl" import { countUnifiedDiffLineChanges, @@ -2789,10 +2792,16 @@ const ToolCallPart = memo(function ToolCallPart({ // server-prefixed form (`mcp____delegate_to_agent`, // `/delegate_to_agent`, `.delegate_to_agent`, etc.) // as a defensive fallback in case the value reaches the renderer - // un-normalized. Falls through to the normal renderer when no + // un-normalized — AND opencode's code-mode `execute` payloads, whose + // delegation call lives inside the `code` source string rather than in + // the tool name. Falls through to the normal renderer when no // toolCallId is available (snapshot replays without a live binding) // so the user still sees the tool input/output. - if (isDelegateToAgentToolName(normalizedToolName) && part.toolCallId) { + if ( + (isDelegateToAgentToolName(normalizedToolName) || + isCodeModeDelegationInput(part.input ?? null)) && + part.toolCallId + ) { return ( { expect(isAffirmedResume(legacyErr)).toBe(false) }) }) + +// opencode 2.x code-mode: MCP calls execute as JS source inside the built-in +// `execute` tool, arriving as raw_input `{"code": "…tools[\"codeg-mcp\"]. +// delegate_to_agent({…})…"}`. The scanner/unescaper here mirrors +// `acp/lifecycle.rs` (`code_mode_call_span` & friends) — these cases pin the +// shared predicate so the two sides can never disagree about what counts as +// a delegation call. +describe("opencode code-mode execute payloads", () => { + // The exact literal the Rust 对拍 test uses (`lifecycle.rs:: + // code_mode_key_matches_the_mcp_side_for_the_same_call`): a real opencode + // emission — bracketed accessor, working_dir, template task with escaped + // newline/tab, CJK text. The `\\n`/`\\t` are JS SOURCE escapes; the runtime + // cooks them to a real newline/tab, which is what the card must show. + const OPENCODE_CALL = + 'await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", working_dir: "/tmp/对拍", task: `review 对拍\\nsecond\\tline ✓` })' + + it("recognizes and parses the opencode emission", () => { + const raw = JSON.stringify({ code: OPENCODE_CALL }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + const parsed = parseInput(raw) + expect(parsed.agentType).toBe("codex") + expect(parsed.task).toBe("review 对拍\nsecond\tline ✓") + expect(parsed.workingDir).toBe("/tmp/对拍") + expect(parsed.assistantId).toBeNull() + }) + + it("parses an LS/PS-bearing emission verbatim like the MCP side", () => { + // U+2028/U+2029 pass through BOTH sides verbatim (ECMA-262 TV-normalizes + // only CR/CRLF), so the card shows exactly what the listener receives. + const raw = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `seg\u2028a\u2029b` })', + }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + const parsed = parseInput(raw) + expect(parsed.agentType).toBe("codex") + expect(parsed.task).toBe("seg\u2028a\u2029b") + }) + + it("parses the namespaced accessor form with working_dir", () => { + const raw = JSON.stringify({ + code: `await tools.codeg_mcp.delegate_to_agent({ agent_type: 'codex', task: 'ship it', working_dir: "/tmp/proj" })`, + }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + const parsed = parseInput(raw) + expect(parsed.agentType).toBe("codex") + expect(parsed.task).toBe("ship it") + expect(parsed.workingDir).toBe("/tmp/proj") + }) + + it("unescapes the minimal JS escape set in template tasks", () => { + const raw = JSON.stringify({ + code: `tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: \`a\\nb\\tc\\u4e2d\\u{1F600}\` })`, + }) + const parsed = parseInput(raw) + expect(parsed.task).toBe("a\nb\tc中😀") + }) + + it("rejects shapes that are not delegation calls", () => { + const negatives: unknown[] = [ + "not json at all", + JSON.stringify({ code: 42 }), + JSON.stringify({}), + JSON.stringify({ command: "ls" }), + // A different method on the same accessor is an execute call, not ours. + JSON.stringify({ code: `tools["codeg-mcp"].list_agents({})` }), + // `tools.search` is a plain method call, not an accessor chain. + JSON.stringify({ code: `tools.search({ query: "delegate_to_agent" })` }), + // The call shape inside a string/template/comment is not a call site. + JSON.stringify({ + code: `const s = "tools[\\"codeg-mcp\\"].delegate_to_agent("`, + }), + JSON.stringify({ + code: "const t = `tools[\"codeg-mcp\"].delegate_to_agent()`", + }), + JSON.stringify({ + code: `// tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode" })`, + }), + ] + for (const raw of negatives) { + expect(isCodeModeDelegationInput(raw as string)).toBe(false) + const parsed = parseInput(raw as string) + expect(parsed.agentType).toBeNull() + expect(parsed.task).toBeNull() + } + }) + + it("keeps the card renderable when a value is unreadable", () => { + // `${…}` interpolation evaluates differently than the source reads — + // the backend goes unkeyed, the card degrades to the generic task row. + const interp = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: `hi ${name}` })', + }) + expect(isCodeModeDelegationInput(interp)).toBe(true) + expect(parseInput(interp)).toEqual({ + agentType: "zcode", + assistantId: null, + task: null, + workingDir: null, + }) + + // An escape outside the minimal set bails the same way. + const unknownEscape = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: `a\\qb` })', + }) + expect(isCodeModeDelegationInput(unknownEscape)).toBe(true) + expect(parseInput(unknownEscape).task).toBeNull() + + // An escaped quoted task could decode differently than it reads — bail. + const escapedQuoted = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "a\\nb" })', + }) + expect(isCodeModeDelegationInput(escapedQuoted)).toBe(true) + expect(parseInput(escapedQuoted).task).toBeNull() + + // No agent_type / an unknown slug: still a call, generic card. + const noAgent = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ task: `t` })', + }) + expect(isCodeModeDelegationInput(noAgent)).toBe(true) + expect(parseInput(noAgent).agentType).toBeNull() + + const badAgent = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "nope", task: `t` })', + }) + expect(parseInput(badAgent).agentType).toBeNull() + }) + + it("reads a stringified assistant_id without mistaking the target", () => { + // Mixed shape `{assistant_id: "3", agent_type: …}` resolves to the + // ASSISTANT on the backend (key deliberately not minted — see + // `lifecycle.rs::extract_code_mode_match_key`); the card only needs + // display values, so both identities surface. + const raw = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ assistant_id: "3", agent_type: "codex", task: `t` })', + }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + const parsed = parseInput(raw) + expect(parsed.assistantId).toBe(3) + expect(parsed.agentType).toBe("codex") + expect(parsed.task).toBe("t") + }) + + it("reads a bare numeric assistant_id in every spelling", () => { + // `assistant_id: 3` is the common spelling; leaving it unread would make + // the card fall back to `agent_type` and mislabel an Assistant run. + const bare = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ assistant_id: 3, agent_type: "codex", task: `t` })', + }) + const bareParsed = parseInput(bare) + expect(bareParsed.assistantId).toBe(3) + expect(bareParsed.agentType).toBe("codex") + + const quotedKey = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ "assistant_id": 3, task: `t` })', + }) + expect(parseInput(quotedKey).assistantId).toBe(3) + + const quotedBoth = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ "assistant_id": "3", task: `t` })', + }) + expect(parseInput(quotedBoth).assistantId).toBe(3) + + // A bare number NOT ending its property is not the value. + const concat = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ assistant_id: 3 + 1, task: `t` })', + }) + expect(parseInput(concat).assistantId).toBeNull() + }) + + it("degrades the parse when working_dir is present but unreadable", () => { + // Present-and-unreadable is not the same as absent: the backend bails + // the whole key (the MCP side sees Some(dir)); the card just shows the + // fields it can read. + const cases = [ + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", working_dir: "C:\\\\proj", task: `t` })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", working_dir: `${cwd}/proj`, task: `t` })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", working_dir: projectDir, task: `t` })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", working_dir: "/tmp" + "/w", task: `t` })', + ] + for (const code of cases) { + const raw = JSON.stringify({ code }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + const parsed = parseInput(raw) + expect(parsed.workingDir).toBeNull() + expect(parsed.agentType).toBe("codex") + expect(parsed.task).toBe("t") + } + }) + + it("does not read a value that continues past its literal", () => { + // A literal followed by an operator evaluates to something else at + // runtime; reading the literal alone would mislabel the card. + const taskConcat = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "a" + b })', + }) + expect(parseInput(taskConcat).task).toBeNull() + expect(parseInput(taskConcat).agentType).toBe("codex") + + const taskMethod = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `a`.trim() })', + }) + expect(parseInput(taskMethod).task).toBeNull() + + const agentConcat = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "co" + "dex", task: `t` })', + }) + expect(parseInput(agentConcat).agentType).toBeNull() + expect(parseInput(agentConcat).task).toBe("t") + + const dirConcat = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", working_dir: "/a" + suffix, task: `t` })', + }) + expect(parseInput(dirConcat).workingDir).toBeNull() + expect(parseInput(dirConcat).task).toBe("t") + + // Duplicate keys take the LAST spelling — the one the runtime uses. + const dup = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `stale`, task: `fresh` })', + }) + expect(parseInput(dup).task).toBe("fresh") + + // …and a later unreadable spelling degrades the read instead of keying + // the earlier readable one. + const dupUnreadable = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `stale`, task: "a" + b })', + }) + expect(parseInput(dupUnreadable).task).toBeNull() + }) + + it("accepts comments between values and their property terminators", () => { + const raw = JSON.stringify({ + code: `tools["codeg-mcp"].delegate_to_agent({ + // choose the agent + agent_type: "codex", /* trailing */ task: \`t\` /* last */, // done +})`, + }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + const parsed = parseInput(raw) + expect(parsed.agentType).toBe("codex") + expect(parsed.task).toBe("t") + }) + + it("reads quoted property keys and ignores nested same-named keys", () => { + const quoted = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ "agent_type": "codex", \'task\': "plain" })', + }) + expect(isCodeModeDelegationInput(quoted)).toBe(true) + expect(parseInput(quoted).agentType).toBe("codex") + expect(parseInput(quoted).task).toBe("plain") + + // The backend reads FLAT args, so a same-named key inside a sub-object + // must not surface — in either source order. + const nested = JSON.stringify({ + code: `tools["codeg-mcp"].delegate_to_agent({ + meta: { agent_type: "zzz", task: \`decoy\` }, + agent_type: "codex", + task: \`real\`, +})`, + }) + const parsed = parseInput(nested) + expect(parsed.agentType).toBe("codex") + expect(parsed.task).toBe("real") + + const nestedRev = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `real`, meta: { task: `decoy`, agent_type: "zzz" } })', + }) + const parsedRev = parseInput(nestedRev) + expect(parsedRev.agentType).toBe("codex") + expect(parsedRev.task).toBe("real") + }) + + it("screens unknowable argument syntax out of the parse entirely", () => { + // Spread may carry an assistant_id; a `[` covers computed keys and array + // values. The call still renders as a delegation card (the backend + // registers it unkeyed), but the card content is the generic task row. + const cases = [ + 'tools["codeg-mcp"].delegate_to_agent({ ...base, task: `t` })', + 'tools["codeg-mcp"].delegate_to_agent({ ["assistant_id"]: 3, agent_type: "codex", task: `t` })', + 'tools["codeg-mcp"].delegate_to_agent({ ["agent_type"]: "codex", task: `t` })', + ] + for (const code of cases) { + const raw = JSON.stringify({ code }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + expect(parseInput(raw)).toEqual({ + agentType: null, + assistantId: null, + task: null, + workingDir: null, + }) + } + }) + + it("normalizes template line terminators the way the JS runtime cooks them", () => { + // TV normalization: bare CR and CRLF collapse to a single LF; a backslash + // line continuation contributes nothing. (A literal CRLF inside the JS + // source survives JSON.stringify round-trip.) + const crlf = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `a\r\nb\rc` })', + }) + expect(parseInput(crlf).task).toBe("a\nb\nc") + + const ls = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `a
b` })', + }) + // U+2028/U+2029 are JS line terminators but NOT TV-normalized (ECMA-262 + // rewrites only CR and CRLF); they pass through verbatim. + expect(parseInput(ls).task).toBe("a\u2028b") + + const ps = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `a\u2029b` })', + }) + expect(parseInput(ps).task).toBe("a\u2029b") + + const cont = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `a\\\r\nb` })', + }) + expect(parseInput(cont).task).toBe("ab") + }) + + it("bails to the generic row when args syntax is unreadable", () => { + // Shorthand properties evaluate to the variable's runtime value; nested + // calls and regex literals are expressions the scanner cannot evaluate — + // and a regex body could feed quotes/braces to the string/template arms. + const cases = [ + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t`, working_dir })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t`, assistant_id })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: pick("codex"), task: `t` })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t`, flag: /}/ })', + ] + for (const code of cases) { + const raw = JSON.stringify({ code }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + expect(parseInput(raw)).toEqual({ + agentType: null, + assistantId: null, + task: null, + workingDir: null, + }) + } + }) + + it("stops reading keys at the argument object close", () => { + // A second object after the first's closing brace is a different + // argument, not part of the flat args; its keys must not win. + const raw = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` }, { agent_type: "goose", task: `u` })', + }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + const parsed = parseInput(raw) + expect(parsed.agentType).toBe("codex") + expect(parsed.task).toBe("t") + }) + + it("ends line comments at every JS line terminator", () => { + // ECMA-262 line comments end at CR, LF, U+2028 and U+2029; a scanner + // stopping only at LF would swallow the real code after the others. + const cr = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ // pick\r agent_type: "codex", task: `t` })', + }) + expect(parseInput(cr).task).toBe("t") + + const ls = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ // pick\u2028 agent_type: "codex", task: `t` })', + }) + expect(parseInput(ls).task).toBe("t") + }) +}) diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index 51d61ec3c2..70384c52ad 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -230,6 +230,633 @@ function findDelegationArgs( return null } +// --------------------------------------------------------------------------- +// opencode code-mode (`execute`) payloads +// +// opencode 2.x runs MCP tools through its built-in `execute` tool: the model +// writes `tools["codeg-mcp"].delegate_to_agent({…})` as JS source packed into +// a `{code: "…"}` raw input. These helpers recognize such a payload and read +// the argument values back out of the JS source — the same structural scanner +// as `acp/lifecycle.rs` (`code_mode_call_span` and friends), so the backend's +// registration decision and the frontend's card classification can never +// disagree about what counts as a delegation call. Like the Rust side, the +// scanner is STRICT: `tools` must reach `delegate_to_agent(` through a real +// accessor, strings/templates/comments are skipped (a task text *quoting* a +// call is not a call), and every ambiguity degrades to "unreadable" rather +// than a guessed value — spread / computed-key / escaped-quoted-key syntax +// screens the whole region out, values must end where their property ends, +// quoted keys match only escape-free at brace depth 1, and template text is +// read as the runtime would cook it (escapes decoded, bare line terminators +// TV-normalized to LF). +// --------------------------------------------------------------------------- + +const CODE_MODE_MAX_CODE_LENGTH = 1 << 20 +const CODE_MODE_MAX_TEMPLATE_FRAMES = 16 + +type JsTemplateFrame = + | { kind: "content" } + | { kind: "interp"; braceDepth: number } + +// Any non-BMP-ish unit counts as an identifier char: the scan only needs +// boundaries of ASCII words (`tools`, `delegate_to_agent`, argument keys), and +// over-approximating Unicode as identifier chars can only reject, never +// mis-split, a boundary. Matches `is_js_ident_byte` (>= 0x80). +function isJsIdentUnit(code: string, at: number): boolean { + const c = code.charCodeAt(at) + if (Number.isNaN(c)) return false + return ( + (c >= 48 && c <= 57) || // 0-9 + (c >= 65 && c <= 90) || // A-Z + (c >= 97 && c <= 122) || // a-z + c === 95 || // _ + c === 36 || // $ + c >= 0x80 + ) +} + +function isJsWordAt(code: string, at: number, word: string): boolean { + if (at < 0 || at + word.length > code.length) return false + if (code.slice(at, at + word.length) !== word) return false + const after = at + word.length + return ( + (at === 0 || !isJsIdentUnit(code, at - 1)) && + (after >= code.length || !isJsIdentUnit(code, after)) + ) +} + +function jsIdentEnd(code: string, at: number): number { + let end = at + while (isJsIdentUnit(code, end)) end += 1 + return end +} + +function skipJsString(code: string, open: number): number | null { + const quote = code[open] + let i = open + 1 + while (i < code.length) { + if (code[i] === "\\") { + i += 2 + continue + } + if (code[i] === quote) return i + 1 + i += 1 + } + return null +} + +function skipJsTemplate(code: string, open: number): number | null { + const stack: JsTemplateFrame[] = [{ kind: "content" }] + let i = open + 1 + while (stack.length > 0) { + if (stack.length > CODE_MODE_MAX_TEMPLATE_FRAMES) return null + if (i >= code.length) return null + const c = code[i] + const frame = stack[stack.length - 1] + if (frame.kind === "content") { + if (c === "`") { + stack.pop() + i += 1 + if (stack.length === 0) return i + } else if (c === "\\") { + i += 2 + } else if (c === "$" && code[i + 1] === "{") { + stack.push({ kind: "interp", braceDepth: 0 }) + i += 2 + } else { + i += 1 + } + } else if (c === "{") { + frame.braceDepth += 1 + i += 1 + } else if (c === "}") { + if (frame.braceDepth === 0) stack.pop() + else frame.braceDepth -= 1 + i += 1 + } else if (c === "'" || c === '"') { + const end = skipJsString(code, i) + if (end === null) return null + i = end + } else if (c === "`") { + stack.push({ kind: "content" }) + i += 1 + } else if (c === "/" && code[i + 1] === "/") { + while (i < code.length && !isJsLineTerminator(code[i])) i += 1 + } else if (c === "/" && code[i + 1] === "*") { + const close = code.indexOf("*/", i + 2) + if (close === -1) return null + i = close + 2 + } else { + i += 1 + } + } + return null +} + +// Rust's `u8::is_ascii_whitespace`: space, \t, \n, \f, \r (not \v). +function isJsWs(c: string | undefined): boolean { + return c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r" +} + +// JS LineTerminator: LF, CR, U+2028 (LS), U+2029 (PS) — ECMA-262 line +// comments end at ANY of these, not just LF. +function isJsLineTerminator(c: string | undefined): boolean { + return c === "\n" || c === "\r" || c === "\u2028" || c === "\u2029" +} + +function skipJsWsAndComments(code: string, i: number): number { + for (;;) { + if (isJsWs(code[i])) { + i += 1 + continue + } + if (code[i] === "/" && code[i + 1] === "/") { + i += 2 + while (i < code.length && !isJsLineTerminator(code[i])) i += 1 + continue + } + if (code[i] === "/" && code[i + 1] === "*") { + const close = code.indexOf("*/", i + 2) + if (close === -1) return code.length + i = close + 2 + continue + } + return i + } +} + +/** `(open, close)` of the `(` … `)` wrapping the first `delegate_to_agent(` + * call reached via a `tools` accessor, or `null`. */ +function codeModeCallSpan(code: string): [number, number] | null { + if (code.length > CODE_MODE_MAX_CODE_LENGTH) return null + let i = 0 + while (i < code.length) { + const c = code[i] + if (c === "'" || c === '"') { + const end = skipJsString(code, i) + if (end === null) return null + i = end + } else if (c === "`") { + const end = skipJsTemplate(code, i) + if (end === null) return null + i = end + } else if (c === "/" && (code[i + 1] === "/" || code[i + 1] === "*")) { + i = skipJsWsAndComments(code, i) + } else if (isJsWordAt(code, i, "tools")) { + const open = codeModeToolsCallOpen(code, i) + if (open !== null) { + const close = matchingParen(code, open) + if (close === null) return null + return [open, close] + } + i += 1 + } else { + i += 1 + } + } + return null +} + +function codeModeToolsCallOpen(code: string, toolsAt: number): number | null { + let i = toolsAt + "tools".length + i = skipJsWsAndComments(code, i) + const c = code[i] + if (c === "[") { + i = skipJsWsAndComments(code, i + 1) + const quote = code[i] + if (quote !== "'" && quote !== '"') return null + const end = skipJsString(code, i) + if (end === null) return null + if (end === i + 2) return null + i = skipJsWsAndComments(code, end) + if (code[i] !== "]") return null + i = skipJsWsAndComments(code, i + 1) + if (code[i] !== ".") return null + i = skipJsWsAndComments(code, i + 1) + } else if (c === ".") { + i = skipJsWsAndComments(code, i + 1) + const end = jsIdentEnd(code, i) + if (end === i) return null + i = skipJsWsAndComments(code, end) + if (code[i] !== ".") return null + i = skipJsWsAndComments(code, i + 1) + } else { + return null + } + if (!isJsWordAt(code, i, "delegate_to_agent")) return null + i = skipJsWsAndComments(code, i + "delegate_to_agent".length) + return code[i] === "(" ? i : null +} + +function matchingParen(code: string, open: number): number | null { + let depth = 0 + let i = open + while (i < code.length) { + const c = code[i] + if (c === "'" || c === '"') { + const end = skipJsString(code, i) + if (end === null) return null + i = end + } else if (c === "`") { + const end = skipJsTemplate(code, i) + if (end === null) return null + i = end + } else if (c === "/" && (code[i + 1] === "/" || code[i + 1] === "*")) { + i = skipJsWsAndComments(code, i) + } else if (c === "(") { + depth += 1 + i += 1 + } else if (c === ")") { + depth -= 1 + if (depth === 0) return i + i += 1 + } else { + i += 1 + } + } + return null +} + +/** Unit offset of the VALUE of a top-level `key:` inside `[start, end)`, + * skipping strings/templates/comments. Both spellings of a key match — bare + * `agent_type:` and quoted `"agent_type":` / `'agent_type':` — but only + * escape-free quoted keys (an escaped quoted key evaluates to an unknowable + * spelling) and only at brace depth 1, i.e. directly inside the call's + * argument object: the backend reads FLAT args, so a same-named key nested + * in a sub-object must not be seen; scanning STOPS when that object closes + * (depth 1→0), so a second argument object can never override the first's + * keys. Duplicate keys take the LAST occurrence + * — the spelling the JS runtime (and the JSON path) actually uses. Missing a + * key degrades the card to the generic task row; reading a wrong one could + * mislabel it. Mirrors `code_mode_find_key` in `acp/lifecycle.rs`. */ +function codeModeFindKey( + code: string, + start: number, + end: number, + key: string +): number | null { + let i = start + let depth = 0 + let found: number | null = null + while (i < end) { + const c = code[i] + if (c === "'" || c === '"') { + const s = skipJsString(code, i) + if (s === null) return null + if (depth === 1 && s >= i + 2) { + const inner = code.slice(i + 1, s - 1) + const after = skipJsWsAndComments(code, s) + if (code[after] === ":" && !inner.includes("\\") && inner === key) { + found = skipJsWsAndComments(code, after + 1) + } + } + i = s + } else if (c === "`") { + const s = skipJsTemplate(code, i) + if (s === null) return null + i = s + } else if (c === "/" && (code[i + 1] === "/" || code[i + 1] === "*")) { + i = skipJsWsAndComments(code, i) + } else if (c === "{") { + depth += 1 + i += 1 + } else if (c === "}") { + if (depth <= 1) { + // Closes the call's argument object (or a stray close): everything + // after lies outside the flat args, so a second object must not + // contribute keys — last-wins applies within ONE object only. + break + } + depth -= 1 + i += 1 + } else if (depth === 1 && isJsWordAt(code, i, key)) { + const after = skipJsWsAndComments(code, i + key.length) + if (code[after] === ":") { + found = skipJsWsAndComments(code, after + 1) + } + i += 1 + } else { + i += 1 + } + } + return found +} + +/** Whether the value's last consumed unit is followed (past whitespace and + * comments) by `,` or `}` — the literal ends where its object property ends. + * A literal followed by an operator (`"a" + b`, `` `a`.trim() ``) evaluates + * to something else at runtime and must not be read as the value. */ +function valueEndsObjectProperty(code: string, end: number): boolean { + const after = skipJsWsAndComments(code, end) + return code[after] === "," || code[after] === "}" +} + +/** Contents of the escape-free `'…'`/`"…"` string at `at` plus the offset + * past its closing quote; `null` when it contains a backslash (an escaped + * value could decode differently than it reads). */ +function plainStringValueEnd( + code: string, + at: number +): [string, number] | null { + const quote = code[at] + if (quote !== "'" && quote !== '"') return null + const end = skipJsString(code, at) + if (end === null) return null + const inner = code.slice(at + 1, end - 1) + if (inner.includes("\\")) return null + return [inner, end] +} + +/** The evaluated `agent_type` / `working_dir` VALUE at `at`: an escape-free + * plain string ending where its property ends; anything else `null`. */ +function objectStringValue(code: string, at: number): string | null { + const r = plainStringValueEnd(code, at) + if (r === null || !valueEndsObjectProperty(code, r[1])) return null + return r[0] +} + +/** The evaluated `task` VALUE at `at`: template literals via the minimal + * unescaper, plain strings only when escape-free, each required to end where + * its property ends; anything else (numbers, shorthand) `null`. */ +function objectTaskValue(code: string, at: number): string | null { + const c = code[at] + let r: [string, number] | null + if (c === "`") r = templateLiteralTextEnd(code, at) + else if (c === "'" || c === '"') r = plainStringValueEnd(code, at) + else return null + if (r === null || !valueEndsObjectProperty(code, r[1])) return null + return r[0] +} + +function hexDigitValue(c: string): number | null { + if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48 + if (c >= "a" && c <= "f") return c.charCodeAt(0) - 87 + if (c >= "A" && c <= "F") return c.charCodeAt(0) - 55 + return null +} + +// JS `\u` escapes denote scalar values: lone surrogates and anything past +// U+10FFFF are invalid (Rust `char::from_u32` returns `None` for both). +function unicodeFromU32(v: number): string | null { + if (v > 0x10ffff) return null + if (v >= 0xd800 && v <= 0xdfff) return null + return String.fromCodePoint(v) +} + +function readHexDigits( + code: string, + at: number, + count: number +): [number, number] | null { + if (at + count > code.length) return null + let v = 0 + for (let k = 0; k < count; k += 1) { + const d = hexDigitValue(code[at + k]) + if (d === null) return null + v = v * 16 + d + } + return [v, at + count] +} + +/** The evaluated text of the template literal starting at `at`, plus the unit + * offset past its closing backtick — the exact string the JS runtime passes + * to the MCP listener. That means the template's cooked text, not its + * source: `\n \t \r \b \f \v \0 \\ \` \' \" \$ \u{…} \uXXXX \xXX` decode, + * line continuations (`\` + CR/CRLF/LF) contribute nothing, and a BARE CR + * or CRLF is TV-normalized to a single LF — ECMA-262's normalization + * rewrites only those two, so U+2028/U+2029 pass through verbatim. + * `null` for `${…}` interpolation, for any escape outside the + * minimal set, and for legacy octal. */ +function templateLiteralTextEnd( + code: string, + at: number +): [string, number] | null { + let out = "" + let i = at + 1 + while (i < code.length) { + const c = code[i] + if (c === "`") return [out, i + 1] + if (c === "\\") { + i += 1 + const e = code[i] + if (e === undefined) return null + i += 1 + if (e === "n") out += "\n" + else if (e === "t") out += "\t" + else if (e === "r") out += "\r" + else if (e === "b") out += "\b" + else if (e === "f") out += "\f" + else if (e === "v") out += "\v" + else if (e === "0") { + if (code[i] !== undefined && code[i] >= "0" && code[i] <= "9") { + // Legacy octal (`\01` ≠ `\0` + `1`); not supported. + return null + } + out += "\0" + } else if (e === "\\") out += "\\" + else if (e === "`") out += "`" + else if (e === "$") out += "$" + else if (e === "'") out += "'" + else if (e === '"') out += '"' + else if (e === "u") { + if (code[i] === "{") { + const close = code.indexOf("}", i) + if (close === -1) return null + const digits = code.slice(i + 1, close) + if (digits.length === 0 || digits.length > 6) return null + let v = 0 + for (const d of digits) { + const h = hexDigitValue(d) + if (h === null) return null + v = v * 16 + h + } + const cp = unicodeFromU32(v) + if (cp === null) return null + out += cp + i = close + 1 + } else { + const r = readHexDigits(code, i, 4) + if (r === null) return null + const cp = unicodeFromU32(r[0]) + if (cp === null) return null + out += cp + i = r[1] + } + } else if (e === "x") { + const r = readHexDigits(code, i, 2) + if (r === null) return null + const cp = unicodeFromU32(r[0]) + if (cp === null) return null + out += cp + i = r[1] + } else if (e === "\r") { + // Line continuation: the newline is dropped. + if (code[i] === "\n") i += 1 + } else if (e === "\n") { + // Line continuation: the newline is dropped. + } else { + return null + } + } else if (c === "$" && code[i + 1] === "{") { + return null + } else if (c === "\r") { + // A bare CR (or the CR of a CRLF) is TV-normalized to a single LF. + out += "\n" + i += 1 + if (code[i] === "\n") i += 1 + } else { + // U+2028/U+2029 are JS line terminators but NOT TV-normalized (only + // CR and CRLF are); folding them to LF here would mint a key the MCP + // side never receives. + out += c + i += 1 + } + } + return null +} + +/** The `code` source and its call span for a recognized code-mode delegation + * payload, or `null`. The exact frontend mirror of `code_mode_delegation_code` + * in `acp/lifecycle.rs`: same predicate, so a card renders exactly when the + * backend registers the call. */ +function codeModeCallOf( + parsed: unknown +): { code: string; span: [number, number] } | null { + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return null + } + const code = (parsed as Record).code + if (typeof code !== "string") return null + const span = codeModeCallSpan(code) + return span === null ? null : { code, span } +} + +/** Conservative pre-pass over the argument region: `false` when the region + * contains syntax whose effect the scanner cannot evaluate. Spread + * (`...base`) may carry an `assistant_id`; a `[` covers computed keys + * (`["assistant_id"]: 3`) and array values; a quoted KEY containing a + * backslash evaluates to an unknowable spelling; a `(`/`)` or a non-comment + * `/` is a call/parenthesized/division/regex expression the scanner cannot + * evaluate; a bare `working_dir` / `assistant_id` identifier is a SHORTHAND + * property whose value is the variable's runtime value. Each degrades the + * parse to `null`. Mirrors `code_mode_args_transparent` in + * `acp/lifecycle.rs`. */ +function codeModeArgsTransparent( + code: string, + start: number, + end: number +): boolean { + let i = start + while (i < end) { + const c = code[i] + if (c === "'" || c === '"') { + const s = skipJsString(code, i) + if (s === null) return false + const after = skipJsWsAndComments(code, s) + if (code[after] === ":" && code.slice(i + 1, s - 1).includes("\\")) { + return false + } + i = s + } else if (c === "`") { + const s = skipJsTemplate(code, i) + if (s === null) return false + i = s + } else if (c === "/" && (code[i + 1] === "/" || code[i + 1] === "*")) { + i = skipJsWsAndComments(code, i) + } else if (c === "/" || c === "(" || c === ")") { + return false + } else if (c === "[") { + return false + } else if (c === "." && code[i + 1] === "." && code[i + 2] === ".") { + return false + } else if (isJsWordAt(code, i, "working_dir")) { + const after = skipJsWsAndComments(code, i + "working_dir".length) + if (code[after] !== ":") return false + i += "working_dir".length + } else if (isJsWordAt(code, i, "assistant_id")) { + const after = skipJsWsAndComments(code, i + "assistant_id".length) + if (code[after] !== ":") return false + i += "assistant_id".length + } else { + i += 1 + } + } + return true +} + +/** The `assistant_id` VALUE at `at`, as a string for `coerceAssistantId`: an + * escape-free quoted number, or bare digits. Numbers must be read — + * `assistant_id: 3` is the common spelling, and leaving it unread would make + * the card fall back to `agent_type` and mislabel an Assistant delegation. */ +function readAssistantIdValue(code: string, at: number): string | null { + const quoted = plainStringValueEnd(code, at) + if (quoted !== null) { + return valueEndsObjectProperty(code, quoted[1]) ? quoted[0] : null + } + let i = at + while (i < code.length && code[i] >= "0" && code[i] <= "9") i += 1 + if (i === at) return null + if (!valueEndsObjectProperty(code, i)) return null + return code.slice(at, i) +} + +/** Parse a code-mode payload's arguments out of the JS source. The region is + * first screened by [`codeModeArgsTransparent`]: syntax the scanner cannot + * evaluate (spread, computed keys, escaped quoted keys) could hide an + * `assistant_id` or change any value, so the whole parse degrades to `null` + * and the card renders the generic task row — correlation goes through the + * tool_call id binding, not these values, so unlike the backend key there is + * nothing to mint wrong. Readable values become card content; unreadable + * ones (interpolation, unknown escapes) stay `null` per field. */ +function parseCodeModeInput(parsed: unknown): ParsedInput | null { + const call = codeModeCallOf(parsed) + if (call === null) return null + const { code, span } = call + const [open, close] = span + const start = open + 1 + if (!codeModeArgsTransparent(code, start, close)) return null + const assistantAt = codeModeFindKey(code, start, close, "assistant_id") + const agentAt = codeModeFindKey(code, start, close, "agent_type") + const workingAt = codeModeFindKey(code, start, close, "working_dir") + const taskAt = codeModeFindKey(code, start, close, "task") + return { + agentType: + agentAt !== null + ? coerceAgentType(objectStringValue(code, agentAt)) + : null, + assistantId: + assistantAt !== null + ? coerceAssistantId(readAssistantIdValue(code, assistantAt)) + : null, + task: taskAt !== null ? objectTaskValue(code, taskAt) : null, + workingDir: + workingAt !== null ? objectStringValue(code, workingAt) : null, + } +} + +/** + * Whether a raw tool input is an opencode code-mode `execute` payload whose + * `code` source announces a `delegate_to_agent` call — the frontend half of + * the backend's code-mode registration branch. Classification gates OR this + * in next to `isDelegateToAgentToolName` so the `execute` call renders as a + * delegation card. + */ +export function isCodeModeDelegationInput( + raw: string | null | undefined +): boolean { + if (!raw || typeof raw !== "string") return false + // Cheap pre-gate, same as the backend's: any JSON payload carrying a `code` + // key contains the literal `"code"` bytes, so skipping the parse for + // payloads that don't keeps this branch free for every non-code-mode call. + if (!raw.includes('"code"')) return false + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return false + } + return codeModeCallOf(parsed) !== null +} + /** * A content-free structural descriptor of a value: object keys (recursively, * depth- and width-capped), array lengths, and primitive *types* — never the @@ -279,6 +906,13 @@ export function parseInput(raw: string | null | undefined): ParsedInput { } const obj = findDelegationArgs(parsed) if (!obj) { + // opencode code-mode: the args live as a JS object literal INSIDE the + // `code` source string, not on the wire object. Return whatever values + // are readable — a `task` that can't be read still leaves the agent + // slug, and the card renders the generic task row (same grade as + // Cursor's identityless `{}`). + const codeMode = parseCodeModeInput(parsed) + if (codeMode) return codeMode // An empty object is the EXPECTED shape on identity-less hosts (Cursor // announces every MCP call with raw_input "{}") — nothing to diagnose, // so don't spam the console on every render of those cards. From 1cce743d9ab2b0a414c58166a4bcdcc0235d7310 Mon Sep 17 00:00:00 2001 From: Liang-HZ <36034777+Liang-HZ@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:39:48 +0800 Subject: [PATCH 2/5] fix(ci): satisfy clippy 1.99 and prettier on the delegation branch Collapse the empty line after the code-mode doc comment in lifecycle.rs (clippy 1.99's empty_line_after_doc_comments fires under -D warnings and fails every Rust job), and reformat the two delegation-card spots prettier flags: single-quote the template-literal fixture string and unwrap the workingDir ternary that now fits the print width. --- src-tauri/src/acp/lifecycle.rs | 1 - src/lib/delegation-card.test.ts | 2 +- src/lib/delegation-card.ts | 3 +-- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index df26d912a4..f3db160a86 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -835,7 +835,6 @@ fn extract_args_match_key(raw_input: Option<&str>) -> Option /// An escape-free quoted `task` is accepted: with no backslash present, the /// evaluated value is exactly the source text (reviewed deviation from plan /// v2's blanket "quoted task → no key"). - /// `(open, close)` byte offsets of the `(` … `)` wrapping the arguments of /// the first `delegate_to_agent(` call reached via a `tools` accessor in real /// (non-string, non-comment) JS. `None` when the code exceeds the scan cap, diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index 7f542c51b0..c145a18e81 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -568,7 +568,7 @@ describe("opencode code-mode execute payloads", () => { code: `const s = "tools[\\"codeg-mcp\\"].delegate_to_agent("`, }), JSON.stringify({ - code: "const t = `tools[\"codeg-mcp\"].delegate_to_agent()`", + code: 'const t = `tools["codeg-mcp"].delegate_to_agent()`', }), JSON.stringify({ code: `// tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode" })`, diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index 70384c52ad..3b6f672f2c 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -828,8 +828,7 @@ function parseCodeModeInput(parsed: unknown): ParsedInput | null { ? coerceAssistantId(readAssistantIdValue(code, assistantAt)) : null, task: taskAt !== null ? objectTaskValue(code, taskAt) : null, - workingDir: - workingAt !== null ? objectStringValue(code, workingAt) : null, + workingDir: workingAt !== null ? objectStringValue(code, workingAt) : null, } } From 9e27ae60008ba09e227f533e9c0de7d8df04c8c9 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 01:19:36 +0800 Subject: [PATCH 3/5] fix(delegation): recognize code-mode delegate calls only on opencode's execute tool The code-mode branch matched any tool whose input carried a `code` string shaped like `tools["codeg-mcp"].delegate_to_agent(...)`. Another tool's script that mocks the call (a Playwright `browser_run_code`, say) then registered as a keyed delegation candidate, and a real delegation with the same arguments could claim its id and bind its child to that card. The backend branch now requires the `execute` title, which opencode 2.0.28 through 2.1.4 put on every input-carrying frame and which the history parser keeps as the tool name, so live registration and replay agree. The frontend classifies with `isCodeModeDelegationCall`, which also requires the name `execute` normalizes to (`bash`). So that a replayed frame keeps that name, `inferLiveToolName` resolves an `execute` frame with a `{code}` script by its title before the input heuristics: a `session/load` replay carries no stamped tool name, and a delegated task quoting a patch otherwise read as `apply_patch`. --- src-tauri/src/acp/lifecycle.rs | 55 ++++++++++++++++++-- src-tauri/src/commands/conversations.rs | 15 ++++++ src/components/message/message-list-view.tsx | 10 ++-- src/lib/activity/describe-tool-call.test.ts | 8 +++ src/lib/activity/describe-tool-call.ts | 10 ++-- src/lib/delegation-card.test.ts | 15 ++++++ src/lib/delegation-card.ts | 23 ++++++-- src/lib/tool-call-normalization.test.ts | 48 +++++++++++++++++ src/lib/tool-call-normalization.ts | 25 +++++++++ 9 files changed, 192 insertions(+), 17 deletions(-) diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index d75400a438..7e509db7e7 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -736,6 +736,9 @@ pub(crate) const CURSOR_IDENTITYLESS_MCP_TITLE: &str = "MCP: tool"; /// any wrapper layers via [`find_delegation_args`] — is a near-zero /// false-positive shape check that catches any host that mangles the title /// beyond recognition, including ones that wrap their tool-call args. +/// * on opencode's code-mode tool (title `execute`), `raw_input` is +/// `{"code": ""}` and the call lives in the script — see +/// [`code_mode_delegation_code`]. /// /// This is also the replay-side gate: `commands::conversations::inject_delegation_meta` /// reuses it so a persisted tool block whose title/args (or opencode `code`) @@ -760,14 +763,25 @@ pub(crate) fn is_delegation_invocation(title: &str, raw_input: Option<&str>) -> } } } - if let Some(raw) = raw_input { - if code_mode_delegation_code(raw) { - return true; + // opencode's code-mode `execute` tool: the call lives in a JS `code` + // string. Gated on that tool, whose every input-carrying frame (2.0.28 + // through 2.1.4) and history block are named `execute`: another tool's + // `code` argument — a Playwright `browser_run_code` script that mocks the + // call, say — must not register, or a real delegation with the same key + // could claim its id. + if title.trim().eq_ignore_ascii_case(CODE_MODE_TOOL_TITLE) { + if let Some(raw) = raw_input { + if code_mode_delegation_code(raw) { + return true; + } } } false } +/// The title (live) and tool name (history) of opencode's code-mode tool. +const CODE_MODE_TOOL_TITLE: &str = "execute"; + /// Build the broker's `(agent_type, task, working_dir)` correlation key from /// a `delegate_to_agent` tool_call's `raw_input` JSON. All three are values /// the LLM passed identically to the ACP tool call and the MCP `tools/call`, @@ -2097,6 +2111,22 @@ mod delegation_title_tests { ); } + #[test] + fn code_mode_recognition_is_scoped_to_the_execute_tool() { + // Another tool's `code` argument — a Playwright script that mocks the + // call, say — is no delegation: registering it would let a real + // delegation with the same key claim its id. + let raw = serde_json::json!({ + "code": "async page => { const tools = {'codeg-mcp': {delegate_to_agent: async x => x}}; await tools['codeg-mcp'].delegate_to_agent({agent_type:'codex',task:'review'}); }" + }) + .to_string(); + for title in ["mcp__playwright__browser_run_code", "browser_run_code", ""] { + assert!(!is_delegation_invocation(title, Some(&raw)), "title {title:?}"); + } + assert!(is_delegation_invocation("execute", Some(&raw))); + assert!(is_delegation_invocation(" Execute ", Some(&raw))); + } + #[test] fn code_mode_line_comments_end_at_every_js_line_terminator() { // ECMA-262 line comments end at CR, LF, U+2028 and U+2029; a scanner @@ -2717,6 +2747,25 @@ mod delegation_registration_tests { ); } + /// A `code` argument of another tool, shaped like the call, registers + /// nothing: a real delegation with the same key must not claim its id. + #[tokio::test] + async fn code_mode_payload_on_another_tool_does_not_register() { + let b = broker(); + let code = r#"await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `review` })"#; + let raw = serde_json::json!({ "code": code }).to_string(); + register_delegation_tool_call_from_event( + &b, + &tool_call_event("browser-1", "mcp__playwright__browser_run_code", Some(&raw)), + ) + .await; + assert!(b + .take_matching_tool_call("parent-conn", &codex_key("review")) + .await + .is_none()); + assert!(b.take_pending_tool_call("parent-conn").await.is_none()); + } + /// An ambiguous code-mode payload (interpolated task) still registers the /// id UNKEYED, so the post-budget FIFO path can bind it instead of /// orphaning the call to a synthetic id. diff --git a/src-tauri/src/commands/conversations.rs b/src-tauri/src/commands/conversations.rs index 7c351c2f51..c45087e540 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -3641,6 +3641,21 @@ mod tests { assert_eq!(inner["child_conversation_id"], 7); } + /// The code-mode shape counts only on opencode's `execute` tool: another + /// tool's `code` argument shaped like the call stays unbound, as it never + /// registered live. + #[test] + fn inject_delegation_meta_does_not_bind_another_tools_code_argument() { + let code = r#"await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` })"#; + let mut turns = vec![tool_use_turn_with_input( + Some("browser-1"), + "mcp__playwright__browser_run_code", + Some(&serde_json::json!({ "code": code }).to_string()), + )]; + inject_delegation_meta(&mut turns, &[summary_child(42, "browser-1", "completed")]); + assert!(first_block_meta(&turns[0]).is_none()); + } + /// The shape gate cuts both ways: an `execute` block whose code is not a /// delegation must stay unbound. #[test] diff --git a/src/components/message/message-list-view.tsx b/src/components/message/message-list-view.tsx index 761ebb3fc4..2f3c0f7596 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -56,7 +56,7 @@ import { SessionViewerHost } from "@/components/message/session-viewer-host" import { normalizeToolName } from "@/lib/tool-call-normalization" import { parseResumeTaskId } from "@/lib/codeg-mcp-tool" import { - isCodeModeDelegationInput, + isCodeModeDelegationCall, isDelegateToAgentToolName, isRefusedResume, parseDelegateTaskId, @@ -493,12 +493,12 @@ function collectDelegationSources( if (part.type === "tool-call") { if (!part.toolCallId) continue const name = normalizeToolName(part.toolName) - // Code-mode payloads (opencode's `execute`) reach this arm by INPUT - // shape — the delegation call lives inside the `code` string, so the - // tool name alone can't classify them. + // Code-mode payloads (opencode's `execute`, which normalizes to `bash`) + // reach this arm by their INPUT — the delegation call lives inside the + // `code` string, so the tool name alone can't classify them. if ( isDelegateToAgentToolName(name) || - isCodeModeDelegationInput(part.input ?? null) + isCodeModeDelegationCall(name, part.input ?? null) ) { out.push({ parentToolUseId: part.toolCallId, diff --git a/src/lib/activity/describe-tool-call.test.ts b/src/lib/activity/describe-tool-call.test.ts index ab408072ad..bc713620fa 100644 --- a/src/lib/activity/describe-tool-call.test.ts +++ b/src/lib/activity/describe-tool-call.test.ts @@ -64,6 +64,14 @@ describe("toolStepKind", () => { // A code-mode `execute` that is not a delegation call keeps its own row: // `execute` normalizes to `bash`, so it stays a command. ["execute", { code: "return 1 + 1" }, "command"], + // Another tool's `code` argument shaped like the call is no delegation. + [ + "mcp__playwright__browser_run_code", + { + code: 'await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` })', + }, + "mcp", + ], [ "mcp__codeg-mcp__get_delegation_status", { task_ids: [] }, diff --git a/src/lib/activity/describe-tool-call.ts b/src/lib/activity/describe-tool-call.ts index 1b051955a2..8637e925eb 100644 --- a/src/lib/activity/describe-tool-call.ts +++ b/src/lib/activity/describe-tool-call.ts @@ -20,7 +20,7 @@ import { isCodegMcpWorkbenchTool } from "@/lib/codeg-mcp-tool" import { COLLAB_AGENT_TOOL_NAME } from "@/lib/collab-tool" import { isContextCompactionMeta } from "@/lib/context-compaction" import { - isCodeModeDelegationInput, + isCodeModeDelegationCall, isDelegateToAgentToolName, } from "@/lib/delegation-card" import { @@ -95,15 +95,15 @@ const TODO_TOOLS = new Set([ /** Which row a tool call gets, before any of its payload is read. */ export function toolStepKind(part: AdaptedToolCallPart): ToolStepKind { if (isContextCompactionMeta(part.meta)) return "compaction" + const name = normalizeToolName(part.toolName).toLowerCase() // opencode 2.x code-mode routes MCP calls through the built-in `execute` // tool, which normalizes to `bash` — so the name alone would call this a // command. The delegation call lives inside the `code` source string, and - // the input shape outranks the name (the same order the old ToolCallPart - // checked in, before its command card). - if (isCodeModeDelegationInput(part.input ?? null)) { + // on that tool the input shape outranks the name (the same order the old + // ToolCallPart checked in, before its command card). + if (isCodeModeDelegationCall(name, part.input ?? null)) { return part.toolCallId ? "delegation" : "generic" } - const name = normalizeToolName(part.toolName).toLowerCase() switch (name) { case "agent": return "agent" diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index 576fa6ceb7..7fe7b258df 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -4,6 +4,7 @@ import { ALL_AGENT_TYPES } from "@/lib/types" import { coerceAssistantId, isAffirmedResume, + isCodeModeDelegationCall, isCodeModeDelegationInput, isRefusedResume, parseDelegateTaskId, @@ -850,6 +851,20 @@ describe("opencode code-mode execute payloads", () => { expect(parsed.task).toBe("t") }) + it("classifies only opencode's execute tool by its code", () => { + // Another tool's `code` argument — a Playwright script that mocks the + // call, say — is no delegation; the backend doesn't register it either. + const raw = JSON.stringify({ + code: "async page => { const tools = {'codeg-mcp': {delegate_to_agent: async x => x}}; await tools['codeg-mcp'].delegate_to_agent({agent_type:'codex',task:'review'}); }", + }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + // `execute` normalizes to `bash`, live and in history. + expect(isCodeModeDelegationCall("bash", raw)).toBe(true) + expect( + isCodeModeDelegationCall("mcp__playwright__browser_run_code", raw) + ).toBe(false) + }) + it("ends line comments at every JS line terminator", () => { // ECMA-262 line comments end at CR, LF, U+2028 and U+2029; a scanner // stopping only at LF would swallow the real code after the others. diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index 55da81d387..a55cb63732 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -834,10 +834,8 @@ function parseCodeModeInput(parsed: unknown): ParsedInput | null { /** * Whether a raw tool input is an opencode code-mode `execute` payload whose - * `code` source announces a `delegate_to_agent` call — the frontend half of - * the backend's code-mode registration branch. Classification gates OR this - * in next to `isDelegateToAgentToolName` so the `execute` call renders as a - * delegation card. + * `code` source announces a `delegate_to_agent` call. Shape only — classify a + * tool call with [`isCodeModeDelegationCall`], which also checks the tool. */ export function isCodeModeDelegationInput( raw: string | null | undefined @@ -856,6 +854,23 @@ export function isCodeModeDelegationInput( return codeModeCallOf(parsed) !== null } +/** + * Whether a tool call is opencode's code-mode `execute` running a + * `delegate_to_agent` call — the frontend half of the backend's code-mode + * registration branch (`is_delegation_invocation`, gated on the `execute` + * title), so the call renders as a delegation card exactly when the backend + * registers it. Takes the NORMALIZED tool name: `execute` normalizes to + * `bash`, live (before and after the opening frame's name stamp) and in + * history. The tool gate keeps another tool's `code` argument — a Playwright + * `browser_run_code` script that mocks the call, say — from passing as one. + */ +export function isCodeModeDelegationCall( + normalizedToolName: string, + raw: string | null | undefined +): boolean { + return normalizedToolName === "bash" && isCodeModeDelegationInput(raw) +} + /** * A content-free structural descriptor of a value: object keys (recursively, * depth- and width-capped), array lengths, and primitive *types* — never the diff --git a/src/lib/tool-call-normalization.test.ts b/src/lib/tool-call-normalization.test.ts index a882c2ef04..3b85ca0f6c 100644 --- a/src/lib/tool-call-normalization.test.ts +++ b/src/lib/tool-call-normalization.test.ts @@ -65,6 +65,54 @@ describe("aliasToolInputKeys", () => { }) }) +describe("inferLiveToolName on OpenCode's code-mode execute frames", () => { + // A `session/load` replay opens a call with its input already present, so + // the frame carries no stamped OpenCode name — only its `execute` title. + const replay = (code: string) => + inferLiveToolName({ + title: "execute", + kind: "other", + rawInput: JSON.stringify({ code }), + meta: null, + }) + + it("keeps the execute identity when the script quotes a patch", () => { + // The delegated task is text; the patch marker in it is no patch call. + expect( + replay( + "await tools['codeg-mcp'].delegate_to_agent({agent_type:'codex',task:`Apply this patch:\n*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch`})" + ) + ).toBe("bash") + expect(replay("return 1 + 1")).toBe("bash") + }) + + it("leaves other tools to the input heuristics", () => { + expect( + inferLiveToolName({ + title: "apply_patch", + rawInput: JSON.stringify({ input: "*** Begin Patch\n*** End Patch" }), + meta: null, + }) + ).toBe("apply_patch") + // A script under another title is not OpenCode's code-mode tool. + expect( + inferLiveToolName({ + title: "browser_run_code", + rawInput: JSON.stringify({ code: "*** Begin Patch" }), + meta: null, + }) + ).toBe("apply_patch") + // Nor is an `execute` whose `code` is no script. + expect( + inferLiveToolName({ + title: "execute", + rawInput: JSON.stringify({ code: 42, input: "*** Begin Patch" }), + meta: null, + }) + ).toBe("apply_patch") + }) +}) + describe("inferLiveToolName meta.claudeCode.toolName override", () => { it("returns memory_recall for synthesized recall events without rawInput", () => { // Mirrors what claude-agent-acp >=0.37 emits for memory recall: diff --git a/src/lib/tool-call-normalization.ts b/src/lib/tool-call-normalization.ts index c9d71b25f3..0794edcf64 100644 --- a/src/lib/tool-call-normalization.ts +++ b/src/lib/tool-call-normalization.ts @@ -272,6 +272,20 @@ function tryParseInputObject(rawInput: string | null | undefined) { } } +/** The title (live) and tool name (history) of OpenCode's code-mode tool. */ +const CODE_MODE_TOOL_TITLE = "execute" + +/** A frame of OpenCode's code-mode tool: titled `execute`, with a `{code}` + * script for input. */ +function isCodeModeExecuteFrame( + title: string | null | undefined, + rawInput: string | null | undefined +): boolean { + if ((title ?? "").trim().toLowerCase() !== CODE_MODE_TOOL_TITLE) return false + if (!rawInput || !rawInput.includes('"code"')) return false + return typeof tryParseInputObject(rawInput)?.code === "string" +} + function hasAnyKey(obj: Record, keys: string[]): boolean { return keys.some( (key) => key in obj && obj[key] !== null && obj[key] !== undefined @@ -781,6 +795,17 @@ export function inferLiveToolName(params: { const kiroToolName = extractKiroToolName(params.meta) if (kiroToolName) return normalizeToolName(kiroToolName) + // OpenCode 2.x code mode: the `execute` tool's input is `{code: ""}`, a + // script that can say anything — a delegated task quoting a patch reads as + // `apply_patch` to the input heuristics below. OpenCode titles a non-shell + // tool by its name on every frame, and the backend registers code-mode + // delegations on that title (`is_delegation_invocation`), so take it here. + // Live frames carry the stamped name above; a `session/load` replay opens + // with the input already present, so it is never stamped. + if (isCodeModeExecuteFrame(params.title, params.rawInput)) { + return normalizeToolName(CODE_MODE_TOOL_TITLE) + } + // Input-shape detection runs FIRST so cross-agent heuristics (Claude Code // `Task` tool routed via `subagent_type`, OpenCode sub-agent calls, etc.) // keep priority. The meta-tool-name override below only kicks in when the From dbd882254444e45f28fff98d174ceb1c647a4948 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 01:19:48 +0800 Subject: [PATCH 4/5] fix(delegation): read a code-mode call's arguments only when they are what runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The code-mode scanner's contract is to mint a broker key, or show a value on the card, only when it equals what the MCP listener receives. Several shapes broke it, keying an entry no round-trip can claim (so the call falls back to a synthetic id) and labelling the card with arguments that never ran: - an argument that is not exactly one object literal: `c ? {…} : {…}`, `x || {…}`, `{…}.y`, a second argument object; - a later shorthand (`{ task: "a", task }`), a Unicode-escaped key (`\u0074ask`), a ternary in a value (its string reads as a key before the `:`), or non-ASCII whitespace hiding a duplicate key from the word matcher; - a regex literal before the call: its quote or backtick desynchronizes the scan, which can then read an example call in a later comment; - several call sites whose first one may not run while a later one does: in a branch (`if (false) { …decoy… } …real…`), in a deferred function or a parameter or destructuring default, or behind a `${…}` interpolation the scan cannot see into. Each now degrades to the existing unkeyed path: the call is still recognized and binds through the post-budget FIFO, and the card shows the generic task row. A script's first call keys only when it is the only call site or provably runs before any other, so `await a; await b` and `Promise.allSettled([a, b])` still key their first call. The frontend mirror applies the same rules, caps the script in UTF-8 bytes as the backend does, and no longer logs its unknown-shape warning for a recognized call whose arguments it cannot read. --- src-tauri/src/acp/lifecycle.rs | 454 +++++++++++++++++++++++++++----- src/lib/delegation-card.test.ts | 192 +++++++++++++- src/lib/delegation-card.ts | 289 ++++++++++++++++---- 3 files changed, 807 insertions(+), 128 deletions(-) diff --git a/src-tauri/src/acp/lifecycle.rs b/src-tauri/src/acp/lifecycle.rs index 7e509db7e7..8ecbf26086 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -857,51 +857,199 @@ fn extract_args_match_key(raw_input: Option<&str>) -> Option /// (interpolation, an unknown escape, an ESCAPED quoted `task`, a value that /// continues past its literal (`"a" + b`, `` `a`.trim() ``), a present but /// unreadable `working_dir`, an `assistant_id` in any spelling (quoted, -/// computed, spread-in), spread or computed-key syntax, unbalanced syntax) -/// degrades to NO key — the pre-existing unkeyed path (post-budget FIFO -/// claim) — rather than a WRONG keyed one. Template values are read as the -/// runtime would cook them (escapes decoded, bare CR/CRLF TV-normalized -/// to LF, U+2028/U+2029 preserved verbatim), so the key matches what the -/// MCP listener receives. +/// computed, spread-in), spread or computed-key syntax, a conditional, a +/// shorthand or Unicode-escaped argument key, non-ASCII code between the +/// properties, an argument that is not exactly one object literal, +/// unbalanced syntax) degrades to NO key — the pre-existing unkeyed path +/// (post-budget FIFO claim) — rather than a WRONG keyed one. Template values +/// are read as the runtime would cook them (escapes decoded, bare CR/CRLF +/// TV-normalized to LF, U+2028/U+2029 preserved verbatim), so the key +/// matches what the MCP listener receives. /// An escape-free quoted `task` is accepted: with no backslash present, the /// evaluated value is exactly the source text (reviewed deviation from plan /// v2's blanket "quoted task → no key"). -/// `(open, close)` byte offsets of the `(` … `)` wrapping the arguments of -/// the first `delegate_to_agent(` call reached via a `tools` accessor in real -/// (non-string, non-comment) JS. `None` when the code exceeds the scan cap, -/// is structurally unbalanced, or names no such call. -fn code_mode_call_span(code: &str) -> Option<(usize, usize)> { - // A heuristic scan cap, measured in UTF-8 bytes; the TS mirror caps - // UTF-16 code units. The unit mismatch only shows on megabyte-scale - // non-ASCII payloads (backend declines, frontend may still classify) — - // recorded as a known boundary, not worth a TextEncoder pass per call. +/// +/// Only the FIRST call site is read, and one `execute` owns one tool-call id, +/// so a script that delegates several times binds at most one child to its +/// card; the others fall back to synthetic ids. Keying that first site is only +/// right when it is the call that runs, so a script with several sites is +/// keyed only when its first one provably runs first (see +/// [`CodeModeCallSite::readable`]); otherwise — and whenever the first call +/// can't be keyed (a loop over a variable task, say) — which of several +/// CONCURRENT calls claims the id is first-come, the same unkeyed FIFO every +/// other host's unparseable call gets. +/// +/// The first `delegate_to_agent(` call reached via a `tools` accessor in real +/// (non-string, non-comment) JS. +struct CodeModeCallSite { + /// Byte offset of the `(` opening the call's arguments. + open: usize, + /// Byte offset of the matching `)`. + close: usize, + /// Whether this call's arguments may be read as the delegation's: it is + /// the only call site in a scan that reached the end of the script, or it + /// provably runs before any other — at brace depth 0, outside any + /// parameter list or destructuring pattern, with nothing before it that + /// branches, defers or hides code (`if`/`else`/`switch`/`for`/`while`/ + /// `do`/`catch`/`yield`/`function`, `?`, `&&`, `||`, `=>`, a `${…}`). A + /// first site in a dead or conditional branch (`if (false) { …decoy… } + /// …real…`) is otherwise read while a later one runs, minting a key no + /// round-trip claims; a call inside a `${…}` after it is one the count + /// cannot see. + readable: bool, +} + +/// Locate the first code-mode delegate call in `code`. `None` when the code +/// exceeds the scan cap, is structurally unbalanced before the call, names no +/// such call, or holds a `/` that is neither a comment nor inside a string +/// BEFORE the call: a regex literal is indistinguishable from division to +/// this scanner, and its body can carry the quote, backtick or `//` that +/// desynchronizes everything after it (`/'/` opens a "string" that skips the +/// real call and closes in a later comment, which then reads as code). +fn code_mode_call_site(code: &str) -> Option { + // A heuristic scan cap in UTF-8 bytes — the TS mirror measures the same. const MAX_CODE_BYTES: usize = 1 << 20; if code.len() > MAX_CODE_BYTES { return None; } let b = code.as_bytes(); let mut i = 0usize; + let mut depth: i32 = 0; + // The `(` / `[` groups open at `i`, innermost last. + let mut groups: Vec = Vec::new(); + let mut branched = false; + let mut first: Option<(usize, usize)> = None; + let mut runs_first = false; + // How many groups enclosing the first site have yet to close. Each must + // close as a call, a grouping or an array — not as an arrow's parameter + // list (`(x = …call…) => x`) or a destructuring pattern (`[a = …call…] = + // xs`), whose defaults run only when a value is missing. + let mut enclosing = 0usize; + let mut sites = 0usize; + // Whether the scan for further call sites reached the end of the script. + let mut counted = true; while i < b.len() { match b[i] { - b'\'' | b'"' => i = skip_js_string(code, i)?, - b'`' => i = skip_js_template(code, i)?, - b'/' => match b.get(i + 1) { - Some(b'/') | Some(b'*') => i = skip_js_ws_and_comments(code, i), - _ => i += 1, - }, - _ if is_js_word_at(b, i, b"tools") => { - if let Some(open) = code_mode_tools_call_open(code, i) { - let close = matching_paren(code, open)?; - return Some((open, close)); + quote @ (b'\'' | b'"' | b'`') => { + let end = if quote == b'`' { + skip_js_template(code, i) + } else { + skip_js_string(code, i) + }; + // Unbalanced: before the first site no call can be found + // (`first?` gives up); after it, only the count is lost. + let Some(end) = end else { + first?; + counted = false; + break; + }; + // A `${…}` runs code the scan skips: a call in it is one the + // count cannot see, and before the first site it may run first. + if quote == b'`' && code[i..end].contains("${") { + counted = false; + if first.is_none() { + branched = true; + } } + i = end; + } + b'/' if matches!(b.get(i + 1), Some(b'/') | Some(b'*')) => { + i = skip_js_ws_and_comments(code, i); + } + // Division or a regex: before the first site it may have + // desynchronized the search itself; after it, only the count. + b'/' => { + first?; + counted = false; + break; + } + b'{' => { + depth += 1; i += 1; } + b'}' => { + depth -= 1; + i += 1; + } + b'(' | b'[' => { + groups.push(b[i]); + i += 1; + } + close @ (b')' | b']') => { + groups.pop(); + i += 1; + if enclosing > groups.len() { + enclosing = groups.len(); + let next = skip_js_ws_and_comments(code, i); + let after = (b.get(next).copied(), b.get(next + 1).copied()); + let parameters = close == b')' && after == (Some(b'='), Some(b'>')); + let pattern = close == b']' + && after.0 == Some(b'=') + && !matches!(after.1, Some(b'=') | Some(b'>')); + if parameters || pattern { + runs_first = false; + } + } + } + b'?' => { + branched = true; + i += 1; + } + b'&' | b'|' if b.get(i + 1) == Some(&b[i]) => { + branched = true; + i += 2; + } + b'=' if b.get(i + 1) == Some(&b'>') => { + branched = true; + i += 2; + } + _ if CODE_MODE_BRANCH_KEYWORDS + .iter() + .any(|k| is_js_word_at(b, i, k)) => + { + branched = true; + i += 1; + } + _ if is_js_word_at(b, i, b"tools") => { + let Some(open) = code_mode_tools_call_open(code, i) else { + i += 1; + continue; + }; + let Some(close) = matching_paren(code, open) else { + // As for an unbalanced string above. + first?; + counted = false; + break; + }; + sites += 1; + if first.is_none() { + first = Some((open, close)); + runs_first = depth == 0 && !branched; + enclosing = groups.len(); + } + i = close + 1; + } _ => i += 1, } } - None + let (open, close) = first?; + // A group around the first site that never closed in the scan could + // still be a parameter list or a pattern. + let runs_first = runs_first && enclosing == 0; + Some(CodeModeCallSite { + open, + close, + readable: runs_first || (sites == 1 && counted), + }) } +/// Keywords that make what follows them conditional, repeated or deferred +/// (see [`CodeModeCallSite::readable`]); `function` defers its body and its +/// parameter defaults alike. +const CODE_MODE_BRANCH_KEYWORDS: [&[u8]; 9] = [ + b"if", b"else", b"switch", b"for", b"while", b"do", b"catch", b"yield", b"function", +]; + /// Byte offset of the `(` that opens the argument list of the /// `delegate_to_agent` call whose `tools` token sits at `tools_at`. Two /// accepted accessor shapes (opencode emits the first; the second covers a @@ -964,6 +1112,12 @@ fn code_mode_tools_call_open(code: &str, tools_at: usize) -> Option { /// templates, and comments so a `)` inside them can't close the call early. /// `None` on unbalanced input. fn matching_paren(code: &str, open: usize) -> Option { + matching_close(code, open, b'(', b')') +} + +/// Byte offset of the `close` matching the `open_byte` at `open`, skipping +/// strings, templates, and comments. `None` on unbalanced input. +fn matching_close(code: &str, open: usize, open_byte: u8, close: u8) -> Option { let b = code.as_bytes(); let mut depth: i32 = 0; let mut i = open; @@ -975,11 +1129,11 @@ fn matching_paren(code: &str, open: usize) -> Option { Some(b'/') | Some(b'*') => i = skip_js_ws_and_comments(code, i), _ => i += 1, }, - b'(' => { + c if c == open_byte => { depth += 1; i += 1; } - b')' => { + c if c == close => { depth -= 1; if depth == 0 { return Some(i); @@ -1148,7 +1302,7 @@ fn code_mode_delegation_code(raw_input: &str) -> bool { parsed .get("code") .and_then(|v| v.as_str()) - .is_some_and(|code| code_mode_call_span(code).is_some()) + .is_some_and(|code| code_mode_call_site(code).is_some()) } /// Build the broker key from a code-mode `execute` payload: locate the @@ -1165,11 +1319,20 @@ fn extract_code_mode_match_key(raw_input: Option<&str>) -> Option) -> Option bool { + let b = code.as_bytes(); + let start = skip_js_ws_and_comments(code, open + 1); + if b.get(start) != Some(&b'{') { + return false; + } + let Some(object_close) = matching_close(code, start, b'{', b'}') else { + return false; + }; + let mut after = skip_js_ws_and_comments(code, object_close + 1); + if b.get(after) == Some(&b',') { + after = skip_js_ws_and_comments(code, after + 1); + } + after == close +} + +/// The argument keys the scanner reads. A bare one not followed by `:` is a +/// SHORTHAND property (or a variable in value position): its value is the +/// variable's runtime value, and a shorthand AFTER an explicit spelling +/// overrides it. +const CODE_MODE_ARG_KEYS: [&[u8]; 4] = [b"agent_type", b"task", b"working_dir", b"assistant_id"]; + /// Conservative pre-pass over the argument region: `false` when the region /// contains syntax whose effect the scanner cannot evaluate. Spread /// (`...base`) may carry an `assistant_id`; a `[` covers computed keys @@ -1212,14 +1405,19 @@ fn extract_code_mode_match_key(raw_input: Option<&str>) -> Option bool { let b = code.as_bytes(); let (start, end) = region; let mut i = start; - while i < end { + 'scan: while i < end { match b[i] { b'\'' | b'"' => { let Some(str_end) = skip_js_string(code, i) else { @@ -1242,23 +1440,23 @@ fn code_mode_args_transparent(code: &str, region: (usize, usize)) -> bool { } b'/' => return false, b'(' | b')' => return false, - b'[' => return false, + b'[' | b'?' | b'\\' => return false, + 0x80..=0xFF => return false, b'.' if b.get(i + 1) == Some(&b'.') && b.get(i + 2) == Some(&b'.') => return false, - _ if is_js_word_at(b, i, b"working_dir") => { - let after = skip_js_ws_and_comments(code, i + b"working_dir".len()); - if b.get(after) != Some(&b':') { - return false; - } - i += b"working_dir".len(); - } - _ if is_js_word_at(b, i, b"assistant_id") => { - let after = skip_js_ws_and_comments(code, i + b"assistant_id".len()); - if b.get(after) != Some(&b':') { - return false; + _ => { + for key in CODE_MODE_ARG_KEYS { + if !is_js_word_at(b, i, key) { + continue; + } + let after = skip_js_ws_and_comments(code, i + key.len()); + if b.get(after) != Some(&b':') { + return false; + } + i += key.len(); + continue 'scan; } - i += b"assistant_id".len(); + i += 1; } - _ => i += 1, } } true @@ -2097,18 +2295,32 @@ mod delegation_title_tests { } #[test] - fn code_mode_key_stops_at_the_argument_object_close() { - // A second object after the first's closing brace is a different - // argument, not part of the flat args; its keys must not win - // (last-wins applies within ONE object only). - let code = r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` }, { agent_type: "goose", task: `u` })"#; - let raw = serde_json::json!({ "code": code }).to_string(); - let key = extract_delegation_match_key(Some(&raw)).expect("first object keys"); + fn code_mode_key_needs_exactly_one_object_literal_argument() { + // What runs is whatever the argument evaluates to, which the first + // literal in it does not determine; a second argument object makes + // opencode throw "expects at most one input object" before any MCP + // call. Each stays a recognized (unkeyed) delegation. + let cases = [ + r#"tools["codeg-mcp"].delegate_to_agent(c ? { agent_type: "codex", task: `a` } : { agent_type: "codex", task: `b` })"#, + r#"tools["codeg-mcp"].delegate_to_agent(x || { agent_type: "codex", task: `decoy` })"#, + r#"tools["codeg-mcp"].delegate_to_agent(x ?? { agent_type: "codex", task: `decoy` })"#, + r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` }.y)"#, + r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` }, { agent_type: "goose", task: `u` })"#, + r#"tools["codeg-mcp"].delegate_to_agent(args)"#, + ]; + for code in cases { + let raw = serde_json::json!({ "code": code }).to_string(); + assert!( + extract_delegation_match_key(Some(&raw)).is_none(), + "expected unkeyed for {code}" + ); + assert!(is_delegation_invocation("execute", Some(&raw))); + } + // A trailing comma still leaves exactly one argument. + let trailing = r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` }, /* end */ )"#; + let raw = serde_json::json!({ "code": trailing }).to_string(); + let key = extract_delegation_match_key(Some(&raw)).expect("trailing comma keys"); assert_eq!(key.task, "t"); - assert_eq!( - key.target, - crate::acp::delegation::types::DelegationTarget::Agent(AgentType::Codex) - ); } #[test] @@ -2127,6 +2339,117 @@ mod delegation_title_tests { assert!(is_delegation_invocation(" Execute ", Some(&raw))); } + #[test] + fn code_mode_declines_a_script_with_a_slash_before_the_call() { + // A regex is indistinguishable from division to the scanner, and + // `/'/` opens a "string" that skips the real call and closes in a + // later comment, which then reads as a call site. + let code = "const re = /'/;\nawait tools[\"codeg-mcp\"].delegate_to_agent({agent_type:\"codex\",task:\"real\"});\n// here's an example: tools[\"codeg-mcp\"].delegate_to_agent({agent_type:\"codex\",task:\"decoy\"})"; + let raw = serde_json::json!({ "code": code }).to_string(); + assert!(extract_delegation_match_key(Some(&raw)).is_none()); + assert!(!is_delegation_invocation("execute", Some(&raw))); + + // After a call that runs first, a slash changes nothing. + let after = r#"const r = await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "t" }); return r.ms / 1000"#; + let raw = serde_json::json!({ "code": after }).to_string(); + assert_eq!(extract_delegation_match_key(Some(&raw)).expect("keys").task, "t"); + + // After a call that may not run, it hides whether another site exists. + let unsure = r#"if (bad) { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "t" }) } const n = 4 / 2"#; + let raw = serde_json::json!({ "code": unsure }).to_string(); + assert!(extract_delegation_match_key(Some(&raw)).is_none()); + assert!(is_delegation_invocation("execute", Some(&raw))); + } + + #[test] + fn code_mode_keys_the_first_of_several_calls_only_when_it_runs_first() { + let key_task = |code: &str| { + let raw = serde_json::json!({ "code": code }).to_string(); + assert!(is_delegation_invocation("execute", Some(&raw)), "{code}"); + extract_delegation_match_key(Some(&raw)).map(|k| k.task) + }; + // Runs first: in order, or eagerly side by side. + assert_eq!( + key_task(r#"await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#).as_deref(), + Some("A") + ); + assert_eq!( + key_task(r#"const [a, b] = await Promise.allSettled([tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }), tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })])"#).as_deref(), + Some("A") + ); + // A lone call keys wherever it sits: if it runs, it is the call. + assert_eq!( + key_task(r#"if (failed) { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "fix" }) }"#).as_deref(), + Some("fix") + ); + assert_eq!( + key_task(r#"const r = await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" }); return `${r.task_id}`"#).as_deref(), + Some("A") + ); + assert_eq!( + key_task(r#"await Promise.allSettled([tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }), tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })]).then(rs => rs)"#).as_deref(), + Some("A") + ); + assert_eq!( + key_task(r#"return await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" })"#).as_deref(), + Some("A") + ); + // The first site may not run while a later one does: a branch, a + // deferred function or parameter default, a destructuring default, or + // a `${…}` the count cannot see; one case per construct. + for code in [ + r#"const later = (x = tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" })) => x; await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })"#, + r#"const later = async (x = tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" })) => x; await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })"#, + r#"function later(x = tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" })) { return x } await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })"#, + r#"const [a = tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" })] = [1]; await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })"#, + r#"if (false) { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" }) } const text = `${await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })}`"#, + r#"const text = `${await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })}`; if (c) { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" }) }"#, + r#"c && await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"c || await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"switch (c) {} await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"for (const x of xs) {} await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"while (false) {} await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"do {} while (false); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"try {} catch (e) {} await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"const t = `${await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })}`; return t; await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"const f = (x = tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" }), y = 1 / 2) => x; await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })"#, + r#"if (false) { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" }) } await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })"#, + r#"c ? tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }) : tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"if (c) await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); else await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"const later = () => tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + r#"async function go() { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }) } await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })"#, + ] { + assert_eq!(key_task(code), None, "{code}"); + } + } + + #[test] + fn code_mode_key_bails_when_later_syntax_can_override_or_hide_a_key() { + // Each passes a different `task`/`agent_type` at runtime than an + // earlier explicit spelling: a later shorthand, a Unicode-escaped key + // (`\u0074ask` IS `task`), a ternary (whose string reads like a key + // before its `:`), an optional chain, and a duplicate key behind + // non-ASCII whitespace the word matcher cannot see. + let cases = [ + r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy", task })"#, + r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "t", agent_type })"#, + r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy", \u0074ask: "real" })"#, + r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: c ? "task" : "real" })"#, + r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: c ? "agent_type" : "codex", task: "t" })"#, + r#"tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: obj?.task })"#, + "tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: \"decoy\",\u{a0}task: \"real\" })", + "tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: \"decoy\",\u{feff}task: \"real\" })", + ]; + for code in cases { + let raw = serde_json::json!({ "code": code }).to_string(); + assert!( + extract_delegation_match_key(Some(&raw)).is_none(), + "expected unkeyed for {code}" + ); + assert!(is_delegation_invocation("execute", Some(&raw))); + } + } + #[test] fn code_mode_line_comments_end_at_every_js_line_terminator() { // ECMA-262 line comments end at CR, LF, U+2028 and U+2029; a scanner @@ -2136,10 +2459,17 @@ mod delegation_title_tests { let key = extract_delegation_match_key(Some(&raw)).expect("CR comment ends"); assert_eq!(key.task, "t"); - let ls = "tools[\"codeg-mcp\"].delegate_to_agent({ // pick\u{2028} agent_type: \"codex\", task: `t` })"; + let ls = "// delegate\u{2028}await tools[\"codeg-mcp\"].delegate_to_agent({ agent_type: \"codex\", task: `t` })"; let raw = serde_json::json!({ "code": ls }).to_string(); let key = extract_delegation_match_key(Some(&raw)).expect("LS comment ends"); assert_eq!(key.task, "t"); + + // Inside the argument object the terminator is non-ASCII code the word + // matcher cannot delimit, so the key degrades rather than guesses. + let ls_in_args = "tools[\"codeg-mcp\"].delegate_to_agent({ // pick\u{2028} agent_type: \"codex\", task: `t` })"; + let raw = serde_json::json!({ "code": ls_in_args }).to_string(); + assert!(extract_delegation_match_key(Some(&raw)).is_none()); + assert!(is_delegation_invocation("execute", Some(&raw))); } #[test] diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index 7fe7b258df..46bb4ecbc6 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { ALL_AGENT_TYPES } from "@/lib/types" import { @@ -839,16 +839,58 @@ describe("opencode code-mode execute payloads", () => { } }) - it("stops reading keys at the argument object close", () => { - // A second object after the first's closing brace is a different - // argument, not part of the flat args; its keys must not win. - const raw = JSON.stringify({ - code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` }, { agent_type: "goose", task: `u` })', + it("reads nothing unless the argument is exactly one object literal", () => { + const empty = { + agentType: null, + assistantId: null, + task: null, + workingDir: null, + } + // What runs is whatever these expressions evaluate to, which the first + // literal in them does not determine. A second argument object makes + // OpenCode throw "expects at most one input object" before any MCP call. + const cases = [ + 'tools["codeg-mcp"].delegate_to_agent(c ? { agent_type: "codex", task: `a` } : { agent_type: "codex", task: `b` })', + 'tools["codeg-mcp"].delegate_to_agent(x || { agent_type: "codex", task: `decoy` })', + 'tools["codeg-mcp"].delegate_to_agent(x ?? { agent_type: "codex", task: `decoy` })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` }.y)', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` }, { agent_type: "goose", task: `u` })', + 'tools["codeg-mcp"].delegate_to_agent(args)', + ] + for (const code of cases) { + const raw = JSON.stringify({ code }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + expect(parseInput(raw)).toEqual(empty) + } + // A trailing comma still leaves exactly one argument. + const trailing = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` }, /* end */ )', }) - expect(isCodeModeDelegationInput(raw)).toBe(true) - const parsed = parseInput(raw) - expect(parsed.agentType).toBe("codex") - expect(parsed.task).toBe("t") + expect(parseInput(trailing).task).toBe("t") + }) + + it("reads nothing when later syntax can override or hide a key", () => { + // Each of these passes a different `task`/`agent_type` at runtime than an + // earlier explicit spelling: a later shorthand, a Unicode-escaped key, a + // ternary (whose string reads like a key before its `:`), and a + // duplicate key behind non-ASCII whitespace the word matcher cannot see. + const cases = [ + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy", task })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "t", agent_type })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy", \\u0074ask: "real" })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: c ? "task" : "real" })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: c ? "agent_type" : "codex", task: "t" })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: obj?.task })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy",\u00a0task: "real" })', + 'tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy",\ufefftask: "real" })', + ] + for (const code of cases) { + const raw = JSON.stringify({ code }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + const parsed = parseInput(raw) + expect(parsed.task).toBeNull() + expect(parsed.agentType).toBeNull() + } }) it("classifies only opencode's execute tool by its code", () => { @@ -865,6 +907,125 @@ describe("opencode code-mode execute payloads", () => { ).toBe(false) }) + it("declines a script with a slash before the call", () => { + // `/'/` opens a "string" that skips the real call and closes in a later + // comment, whose example then reads as the call site. + const desync = JSON.stringify({ + code: `const re = /'/;\nawait tools["codeg-mcp"].delegate_to_agent({agent_type:"codex",task:"real"});\n// here's an example: tools["codeg-mcp"].delegate_to_agent({agent_type:"codex",task:"decoy"})`, + }) + expect(isCodeModeDelegationInput(desync)).toBe(false) + + // After a call that runs first, a slash changes nothing… + const after = JSON.stringify({ + code: 'const r = await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "t" }); return r.ms / 1000', + }) + expect(parseInput(after).task).toBe("t") + + // …after one that may not run, it hides whether another site exists. + const unsure = JSON.stringify({ + code: 'if (bad) { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "t" }) } const n = 4 / 2', + }) + expect(isCodeModeDelegationInput(unsure)).toBe(true) + expect(parseInput(unsure).task).toBeNull() + }) + + it("reads the first of several calls only when it runs first", () => { + const task = (code: string) => { + const raw = JSON.stringify({ code }) + expect(isCodeModeDelegationInput(raw)).toBe(true) + return parseInput(raw).task + } + expect( + task( + 'await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })' + ) + ).toBe("A") + expect( + task( + 'const [a, b] = await Promise.allSettled([tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }), tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })])' + ) + ).toBe("A") + // A lone call reads wherever it sits: if it runs, it is the call. + expect( + task( + 'if (failed) { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "fix" }) }' + ) + ).toBe("fix") + expect( + task( + 'const r = await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" }); return `${r.task_id}`' + ) + ).toBe("A") + expect( + task( + 'await Promise.allSettled([tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }), tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })]).then(rs => rs)' + ) + ).toBe("A") + expect( + task( + 'return await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" })' + ) + ).toBe("A") + // The first site may not run while a later one does: a branch, a deferred + // function or parameter default, a destructuring default, or a `${…}` the + // count cannot see; one case per construct. + for (const code of [ + 'const later = (x = tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" })) => x; await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })', + 'const later = async (x = tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" })) => x; await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })', + 'function later(x = tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" })) { return x } await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })', + 'const [a = tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" })] = [1]; await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })', + 'if (false) { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" }) } const text = `${await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })}`', + 'const text = `${await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })}`; if (c) { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" }) }', + 'c && await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'c || await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'switch (c) {} await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'for (const x of xs) {} await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'while (false) {} await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'do {} while (false); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'try {} catch (e) {} await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'const t = `${await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })}`; return t; await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'const f = (x = tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" }), y = 1 / 2) => x; await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })', + 'if (false) { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "decoy" }) } await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "real" })', + 'c ? tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }) : tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'if (c) await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); else await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'const later = () => tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }); await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + 'async function go() { await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: "A" }) } await tools["codeg-mcp"].delegate_to_agent({ agent_type: "zcode", task: "B" })', + ]) { + expect(task(code)).toBeNull() + } + }) + + it("caps the script in UTF-8 bytes, as the backend does", () => { + const call = + 'tools.codeg_mcp.delegate_to_agent({agent_type:"codex",task:"t"})' + // 350,000 CJK characters are 1,050,000 bytes: past the 1 MiB cap the + // backend measures, though under it in UTF-16 units. + const wide = JSON.stringify({ + code: "/*" + "中".repeat(350000) + "*/" + call, + }) + expect(isCodeModeDelegationInput(wide)).toBe(false) + const ascii = JSON.stringify({ + code: "/*" + "a".repeat((1 << 20) - 200) + "*/" + call, + }) + expect(isCodeModeDelegationInput(ascii)).toBe(true) + }) + + it("reads an unreadable call quietly: its shape is a known one", () => { + // The unknown-shape breadcrumb is for wrappers nobody has accounted for; + // a recognized call whose arguments can't be read is not one, and the + // card re-parses its input on every render. + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}) + try { + const raw = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ ...base, task: `t` })', + }) + expect(parseInput(raw).task).toBeNull() + expect(warn).not.toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) + it("ends line comments at every JS line terminator", () => { // ECMA-262 line comments end at CR, LF, U+2028 and U+2029; a scanner // stopping only at LF would swallow the real code after the others. @@ -874,9 +1035,18 @@ describe("opencode code-mode execute payloads", () => { expect(parseInput(cr).task).toBe("t") const ls = JSON.stringify({ - code: 'tools["codeg-mcp"].delegate_to_agent({ // pick\u2028 agent_type: "codex", task: `t` })', + code: '// delegate\u2028await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` })', }) + expect(isCodeModeDelegationInput(ls)).toBe(true) expect(parseInput(ls).task).toBe("t") + + // Inside the argument object the terminator is non-ASCII code the word + // matcher cannot delimit, so the read degrades rather than guesses. + const lsInArgs = JSON.stringify({ + code: 'tools["codeg-mcp"].delegate_to_agent({ // pick\u2028 agent_type: "codex", task: `t` })', + }) + expect(isCodeModeDelegationInput(lsInArgs)).toBe(true) + expect(parseInput(lsInArgs).task).toBeNull() }) }) diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index a55cb63732..2fe876b87f 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -250,7 +250,9 @@ function findDelegationArgs( // TV-normalized to LF). // --------------------------------------------------------------------------- -const CODE_MODE_MAX_CODE_LENGTH = 1 << 20 +// Scan cap in UTF-8 bytes, the unit the backend measures (`code.len()`), so +// the two sides recognize the same payloads. +const CODE_MODE_MAX_CODE_BYTES = 1 << 20 const CODE_MODE_MAX_TEMPLATE_FRAMES = 16 type JsTemplateFrame = @@ -384,36 +386,158 @@ function skipJsWsAndComments(code: string, i: number): number { } } -/** `(open, close)` of the `(` … `)` wrapping the first `delegate_to_agent(` - * call reached via a `tools` accessor, or `null`. */ -function codeModeCallSpan(code: string): [number, number] | null { - if (code.length > CODE_MODE_MAX_CODE_LENGTH) return null +/** Whether `code` is longer than `cap` UTF-8 bytes, counted without + * encoding it: a UTF-16 unit is 1–3 bytes (a surrogate pair is 4 for two). */ +function utf8LengthExceeds(code: string, cap: number): boolean { + if (code.length > cap) return true + if (code.length * 3 <= cap) return false + let bytes = 0 + for (let i = 0; i < code.length; i += 1) { + const c = code.charCodeAt(i) + bytes += c < 0x80 ? 1 : c < 0x800 || (c >= 0xd800 && c <= 0xdfff) ? 2 : 3 + if (bytes > cap) return true + } + return false +} + +/** Keywords that make what follows them conditional, repeated or deferred; + * `function` defers its body and its parameter defaults alike. Mirrors + * `CODE_MODE_BRANCH_KEYWORDS` in `acp/lifecycle.rs`. */ +const CODE_MODE_BRANCH_KEYWORDS = [ + "if", + "else", + "switch", + "for", + "while", + "do", + "catch", + "yield", + "function", +] + +/** The first `delegate_to_agent(` call reached via a `tools` accessor: the + * `(open, close)` of its argument list, and whether those arguments may be + * read as the delegation's — it is the only call site in a scan that + * reached the end of the script, or it provably runs before any other (at + * brace depth 0, outside any parameter list or destructuring pattern, with + * nothing before it that branches, defers or hides code in a `${…}`). + * `null` past the scan cap, on unbalanced input before the call, with no + * such call, or when a `/` that is no comment precedes the call: a regex + * literal is indistinguishable from division here, and its body can carry + * the quote or backtick that desynchronizes everything after it. Mirrors + * `code_mode_call_site` in `acp/lifecycle.rs`. */ +function codeModeCallSite( + code: string +): { open: number; close: number; readable: boolean } | null { + if (utf8LengthExceeds(code, CODE_MODE_MAX_CODE_BYTES)) return null let i = 0 - while (i < code.length) { + let depth = 0 + // The `(` / `[` groups open at `i`, innermost last. + const groups: string[] = [] + let branched = false + let first: { open: number; close: number } | null = null + let runsFirst = false + // How many groups enclosing the first site have yet to close; each must + // close as a call, a grouping or an array — not as an arrow's parameter + // list or a destructuring pattern, whose defaults run only when a value is + // missing. + let enclosing = 0 + let sites = 0 + let counted = true + scan: while (i < code.length) { const c = code[i] - if (c === "'" || c === '"') { - const end = skipJsString(code, i) - if (end === null) return null - i = end - } else if (c === "`") { - const end = skipJsTemplate(code, i) - if (end === null) return null + if (c === "'" || c === '"' || c === "`") { + const end = c === "`" ? skipJsTemplate(code, i) : skipJsString(code, i) + if (end === null) { + if (first === null) return null + counted = false + break + } + // A `${…}` runs code the scan skips: a call in it is one the count + // cannot see, and before the first site it may run first. + if (c === "`" && code.slice(i, end).includes("${")) { + counted = false + if (first === null) branched = true + } i = end } else if (c === "/" && (code[i + 1] === "/" || code[i + 1] === "*")) { i = skipJsWsAndComments(code, i) - } else if (isJsWordAt(code, i, "tools")) { - const open = codeModeToolsCallOpen(code, i) - if (open !== null) { - const close = matchingParen(code, open) - if (close === null) return null - return [open, close] + } else if (c === "/") { + if (first === null) return null + counted = false + break + } else if (c === "{") { + depth += 1 + i += 1 + } else if (c === "}") { + depth -= 1 + i += 1 + } else if (c === "(" || c === "[") { + groups.push(c) + i += 1 + } else if (c === ")" || c === "]") { + groups.pop() + i += 1 + if (enclosing > groups.length) { + enclosing = groups.length + const next = skipJsWsAndComments(code, i) + const parameters = + c === ")" && code[next] === "=" && code[next + 1] === ">" + const pattern = + c === "]" && + code[next] === "=" && + code[next + 1] !== "=" && + code[next + 1] !== ">" + if (parameters || pattern) runsFirst = false } + } else if (c === "?") { + branched = true i += 1 + } else if ((c === "&" || c === "|") && code[i + 1] === c) { + branched = true + i += 2 + } else if (c === "=" && code[i + 1] === ">") { + branched = true + i += 2 } else { + for (const keyword of CODE_MODE_BRANCH_KEYWORDS) { + if (isJsWordAt(code, i, keyword)) { + branched = true + i += 1 + continue scan + } + } + if (isJsWordAt(code, i, "tools")) { + const open = codeModeToolsCallOpen(code, i) + if (open !== null) { + const close = matchingParen(code, open) + if (close === null) { + if (first === null) return null + counted = false + break + } + sites += 1 + if (first === null) { + first = { open, close } + runsFirst = depth === 0 && !branched + enclosing = groups.length + } + i = close + 1 + continue + } + } i += 1 } } - return null + if (first === null) return null + // A group around the first site that never closed in the scan could still + // be a parameter list or a pattern. + const provable = runsFirst && enclosing === 0 + return { + open: first.open, + close: first.close, + readable: provable || (sites === 1 && counted), + } } function codeModeToolsCallOpen(code: string, toolsAt: number): number | null { @@ -448,6 +572,18 @@ function codeModeToolsCallOpen(code: string, toolsAt: number): number | null { } function matchingParen(code: string, open: number): number | null { + return matchingClose(code, open, "(", ")") +} + +/** Offset of the `closeCh` matching the `openCh` at `open`, skipping + * strings, templates, and comments. Mirrors `matching_close` in + * `acp/lifecycle.rs`. */ +function matchingClose( + code: string, + open: number, + openCh: string, + closeCh: string +): number | null { let depth = 0 let i = open while (i < code.length) { @@ -462,10 +598,10 @@ function matchingParen(code: string, open: number): number | null { i = end } else if (c === "/" && (code[i + 1] === "/" || code[i + 1] === "*")) { i = skipJsWsAndComments(code, i) - } else if (c === "(") { + } else if (c === openCh) { depth += 1 i += 1 - } else if (c === ")") { + } else if (c === closeCh) { depth -= 1 if (depth === 0) return i i += 1 @@ -476,6 +612,26 @@ function matchingParen(code: string, open: number): number | null { return null } +/** Whether the call's argument list `(open, close)` holds exactly ONE object + * literal (a trailing comma aside): its first token is `{` and the matching + * `}` is followed only by whitespace/comments before the call's `)`. Any + * other shape — `c ? {…} : {…}`, `x || {…}`, `{…}.y`, a variable — passes + * something the literal alone does not determine. Mirrors + * `code_mode_single_object_argument` in `acp/lifecycle.rs`. */ +function codeModeSingleObjectArgument( + code: string, + open: number, + close: number +): boolean { + const start = skipJsWsAndComments(code, open + 1) + if (code[start] !== "{") return false + const objectClose = matchingClose(code, start, "{", "}") + if (objectClose === null) return false + let after = skipJsWsAndComments(code, objectClose + 1) + if (code[after] === ",") after = skipJsWsAndComments(code, after + 1) + return after === close +} + /** Unit offset of the VALUE of a top-level `key:` inside `[start, end)`, * skipping strings/templates/comments. Both spellings of a key match — bare * `agent_type:` and quoted `"agent_type":` / `'agent_type':` — but only @@ -714,31 +870,42 @@ function templateLiteralTextEnd( return null } -/** The `code` source and its call span for a recognized code-mode delegation - * payload, or `null`. The exact frontend mirror of `code_mode_delegation_code` - * in `acp/lifecycle.rs`: same predicate, so a card renders exactly when the - * backend registers the call. */ -function codeModeCallOf( - parsed: unknown -): { code: string; span: [number, number] } | null { +/** The `code` source and its first call site for a recognized code-mode + * delegation payload, or `null`. The frontend mirror of + * `code_mode_delegation_code` in `acp/lifecycle.rs`: same predicate, so a + * card renders exactly when the backend registers the call. */ +function codeModeCallOf(parsed: unknown): { + code: string + site: { open: number; close: number; readable: boolean } +} | null { if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return null } const code = (parsed as Record).code if (typeof code !== "string") return null - const span = codeModeCallSpan(code) - return span === null ? null : { code, span } + const site = codeModeCallSite(code) + return site === null ? null : { code, site } } +/** The argument keys the scanner reads; a bare one not followed by `:` is a + * SHORTHAND property (or a variable in value position) — either way its + * value is a runtime value, and a shorthand after an explicit spelling + * overrides it. */ +const CODE_MODE_ARG_KEYS = ["agent_type", "task", "working_dir", "assistant_id"] + /** Conservative pre-pass over the argument region: `false` when the region * contains syntax whose effect the scanner cannot evaluate. Spread * (`...base`) may carry an `assistant_id`; a `[` covers computed keys * (`["assistant_id"]: 3`) and array values; a quoted KEY containing a * backslash evaluates to an unknowable spelling; a `(`/`)` or a non-comment * `/` is a call/parenthesized/division/regex expression the scanner cannot - * evaluate; a bare `working_dir` / `assistant_id` identifier is a SHORTHAND - * property whose value is the variable's runtime value. Each degrades the - * parse to `null`. Mirrors `code_mode_args_transparent` in + * evaluate; a `?` is a conditional, optional chain or `??` (a ternary would + * also put a value string in front of a `:`, where it reads as a key); a + * `\` outside a string is a Unicode-escaped identifier (`\u0074ask` IS + * `task`); a non-ASCII character outside a string is a Unicode identifier + * or whitespace the ASCII word matcher cannot delimit; a bare argument key + * is a SHORTHAND property whose value is the variable's runtime value. Each + * degrades the parse to unreadable. Mirrors `code_mode_args_transparent` in * `acp/lifecycle.rs`. */ function codeModeArgsTransparent( code: string, @@ -746,7 +913,7 @@ function codeModeArgsTransparent( end: number ): boolean { let i = start - while (i < end) { + scan: while (i < end) { const c = code[i] if (c === "'" || c === '"') { const s = skipJsString(code, i) @@ -764,19 +931,20 @@ function codeModeArgsTransparent( i = skipJsWsAndComments(code, i) } else if (c === "/" || c === "(" || c === ")") { return false - } else if (c === "[") { + } else if (c === "[" || c === "?" || c === "\\") { + return false + } else if (code.charCodeAt(i) >= 0x80) { return false } else if (c === "." && code[i + 1] === "." && code[i + 2] === ".") { return false - } else if (isJsWordAt(code, i, "working_dir")) { - const after = skipJsWsAndComments(code, i + "working_dir".length) - if (code[after] !== ":") return false - i += "working_dir".length - } else if (isJsWordAt(code, i, "assistant_id")) { - const after = skipJsWsAndComments(code, i + "assistant_id".length) - if (code[after] !== ":") return false - i += "assistant_id".length } else { + for (const key of CODE_MODE_ARG_KEYS) { + if (!isJsWordAt(code, i, key)) continue + const after = skipJsWsAndComments(code, i + key.length) + if (code[after] !== ":") return false + i += key.length + continue scan + } i += 1 } } @@ -799,21 +967,32 @@ function readAssistantIdValue(code: string, at: number): string | null { return code.slice(at, i) } -/** Parse a code-mode payload's arguments out of the JS source. The region is - * first screened by [`codeModeArgsTransparent`]: syntax the scanner cannot - * evaluate (spread, computed keys, escaped quoted keys) could hide an - * `assistant_id` or change any value, so the whole parse degrades to `null` - * and the card renders the generic task row — correlation goes through the - * tool_call id binding, not these values, so unlike the backend key there is - * nothing to mint wrong. Readable values become card content; unreadable - * ones (interpolation, unknown escapes) stay `null` per field. */ +/** Parse a code-mode payload's arguments out of the JS source; `null` when the + * payload is no code-mode delegation call at all. The argument must be one + * object literal ([`codeModeSingleObjectArgument`]) and its region is + * screened by [`codeModeArgsTransparent`]: syntax the scanner cannot + * evaluate (spread, computed keys, escaped quoted keys, conditionals) could + * hide an `assistant_id` or change any value, so every field reads as + * unknown and the card renders the generic task row — correlation goes + * through the tool_call id binding, not these values, so unlike the backend + * key there is nothing to mint wrong. Readable values become card content; + * unreadable ones (interpolation, unknown escapes) stay `null` per field. */ function parseCodeModeInput(parsed: unknown): ParsedInput | null { const call = codeModeCallOf(parsed) if (call === null) return null - const { code, span } = call - const [open, close] = span + const { code, site } = call + const { open, close } = site const start = open + 1 - if (!codeModeArgsTransparent(code, start, close)) return null + // A recognized call whose arguments can't be read is an expected shape, + // not an unknown wrapper: answer "nothing readable" here so `parseInput` + // doesn't log its unknown-shape breadcrumb on every render. + if ( + !site.readable || + !codeModeSingleObjectArgument(code, open, close) || + !codeModeArgsTransparent(code, start, close) + ) { + return EMPTY_PARSED_INPUT + } const assistantAt = codeModeFindKey(code, start, close, "assistant_id") const agentAt = codeModeFindKey(code, start, close, "agent_type") const workingAt = codeModeFindKey(code, start, close, "working_dir") From 6a0b29cb5ad649e665bb6f4731b83e8d5db484a5 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 27 Oct 2026 01:19:54 +0800 Subject: [PATCH 5/5] fix(chat): list code-mode delegations above the loaded window in the navigator The message navigator collected sub-agents from the history above the loaded window by tool name alone, so an opencode code-mode delegation (an `execute` call carrying the delegation meta) was listed while inside the window but dropped once it scrolled out of it. The outline keeps every tool call's input, so it now classifies them the way the window does. --- .../message/message-nav-entries.test.ts | 50 +++++++++++++++++++ src/components/message/message-nav-entries.ts | 11 +++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/components/message/message-nav-entries.test.ts b/src/components/message/message-nav-entries.test.ts index 4883ceca14..854910ffc5 100644 --- a/src/components/message/message-nav-entries.test.ts +++ b/src/components/message/message-nav-entries.test.ts @@ -261,6 +261,56 @@ describe("buildMessageNavEntries", () => { ]) }) + it("lists an OpenCode code-mode delegation history above the window ran", () => { + // OpenCode 2.x runs MCP calls inside its `execute` tool: the block keeps + // that name and carries the real call in its `code` source. + const input = JSON.stringify({ + code: 'await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` })', + }) + const entries = buildMessageNavEntries({ + earlierTurns: [ + user("turn-0", "first"), + { + id: "turn-1", + role: "assistant", + blocks: [ + { + type: "tool_use", + tool_use_id: "exec-ran", + tool_name: "execute", + input_preview: input, + meta: DELEGATION_META, + }, + // Any other script is no delegation, whatever meta it carries. + { + type: "tool_use", + tool_use_id: "exec-other", + tool_name: "execute", + input_preview: JSON.stringify({ code: "return 1 + 1" }), + meta: DELEGATION_META, + }, + // Nor is another tool's `code` argument shaped like the call. + { + type: "tool_use", + tool_use_id: "browser-1", + tool_name: "mcp__playwright__browser_run_code", + input_preview: input, + meta: DELEGATION_META, + }, + ], + timestamp: "2026-10-01T08:00:01Z", + }, + ], + earlierUserCount: 1, + loadedTurns: [], + threadIndexByTurnId: new Map(), + }) + expect(entries[0].subAgents.map((s) => s.parentToolUseId)).toEqual([ + "exec-ran", + ]) + expect(entries[0].subAgents[0].input).toBe(input) + }) + it("collects a round's sub-agents across the window start", () => { const entries = buildMessageNavEntries({ earlierTurns: [ diff --git a/src/components/message/message-nav-entries.ts b/src/components/message/message-nav-entries.ts index d757bd045f..c1e2e2419c 100644 --- a/src/components/message/message-nav-entries.ts +++ b/src/components/message/message-nav-entries.ts @@ -1,6 +1,7 @@ import type { MessageNavEntry } from "@/components/message/conversation-message-nav" import type { DelegationCardSource } from "@/hooks/use-delegation-card-model" import { + isCodeModeDelegationCall, isDelegateToAgentToolName, parseDelegationMeta, } from "@/lib/delegation-card" @@ -120,7 +121,15 @@ function outlineTurnSubAgents( const sources: DelegationCardSource[] = [] for (const block of turn.blocks) { if (block.type !== "tool_use" || !block.tool_use_id) continue - if (!isDelegateToAgentToolName(normalizeToolName(block.tool_name))) continue + // OpenCode 2.x code mode runs the delegation inside its `execute` tool, + // so the call is known by its input, not its name (as in the window). + const name = normalizeToolName(block.tool_name) + if ( + !isDelegateToAgentToolName(name) && + !isCodeModeDelegationCall(name, block.input_preview) + ) { + continue + } if (parseDelegationMeta(block.meta) == null) continue sources.push({ parentToolUseId: block.tool_use_id,