diff --git a/src-tauri/src/acp/delegation/broker.rs b/src-tauri/src/acp/delegation/broker.rs index deb47013b4..f7274208a4 100644 --- a/src-tauri/src/acp/delegation/broker.rs +++ b/src-tauri/src/acp/delegation/broker.rs @@ -2256,6 +2256,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 @@ -2519,13 +2557,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 3f53bccabc..8ecbf26086 100644 --- a/src-tauri/src/acp/lifecycle.rs +++ b/src-tauri/src/acp/lifecycle.rs @@ -736,7 +736,15 @@ 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 { +/// * 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`) +/// 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; @@ -755,9 +763,25 @@ fn is_delegation_invocation(title: &str, raw_input: Option<&str>) -> bool { } } } + // 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`, @@ -766,13 +790,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)?; @@ -792,6 +833,867 @@ 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, 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"). +/// +/// 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] { + 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, + } + } + 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 +/// 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 { + 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; + 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, + }, + c if c == open_byte => { + depth += 1; + i += 1; + } + c if c == close => { + 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_site(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 site = code_mode_call_site(code)?; + if !site.readable { + return None; + } + let (open, close) = (site.open, site.close); + let region = (open + 1, close); + // Only an argument list holding exactly one object literal passes that + // literal to the listener — see [`code_mode_single_object_argument`]. + if !code_mode_single_object_argument(code, open, close) { + return None; + } + // Syntax whose contribution the scanner cannot evaluate (spread, computed + // keys, escaped quoted keys, conditionals) 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, + }) +} + +/// True when 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 passes something the literal alone does not +/// determine — `c ? {…} : {…}` and `x || {…}` evaluate to whichever operand +/// wins at runtime, `{…}.y` to a property of it, a bare variable to its +/// runtime value — and opencode rejects a second argument object outright +/// ("expects at most one input object") before any MCP call. +fn code_mode_single_object_argument(code: &str, open: usize, close: usize) -> 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 +/// (`["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 `?` is a +/// conditional, optional chain or `??` (a ternary also puts 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 byte +/// outside a string is a Unicode identifier or whitespace (`U+00A0`, +/// `U+FEFF`, `U+2028`) the ASCII word matcher cannot delimit; a bare argument +/// key 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; + 'scan: 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'[' | b'?' | b'\\' => return false, + 0x80..=0xFF => return false, + b'.' if b.get(i + 1) == Some(&b'.') && b.get(i + 2) == 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 += 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`, @@ -967,6 +1869,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] @@ -1155,6 +2058,530 @@ 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_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"); + } + + #[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_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 + // 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 = "// 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] + 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)] @@ -1626,6 +3053,109 @@ 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" + ); + } + + /// 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. + #[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 fa0ddfc0d6..c45087e540 100644 --- a/src-tauri/src/commands/conversations.rs +++ b/src-tauri/src/commands/conversations.rs @@ -1349,6 +1349,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; @@ -1414,6 +1421,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; }; @@ -3571,6 +3592,86 @@ 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 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] + 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/message-list-view.tsx b/src/components/message/message-list-view.tsx index 6dde39a5eb..2f3c0f7596 100644 --- a/src/components/message/message-list-view.tsx +++ b/src/components/message/message-list-view.tsx @@ -56,6 +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 { + isCodeModeDelegationCall, isDelegateToAgentToolName, isRefusedResume, parseDelegateTaskId, @@ -492,7 +493,13 @@ function collectDelegationSources( if (part.type === "tool-call") { if (!part.toolCallId) continue const name = normalizeToolName(part.toolName) - if (isDelegateToAgentToolName(name)) { + // 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) || + isCodeModeDelegationCall(name, part.input ?? null) + ) { out.push({ parentToolUseId: part.toolCallId, input: part.input ?? null, 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, diff --git a/src/lib/activity/describe-tool-call.test.ts b/src/lib/activity/describe-tool-call.test.ts index d47d5afdac..bc713620fa 100644 --- a/src/lib/activity/describe-tool-call.test.ts +++ b/src/lib/activity/describe-tool-call.test.ts @@ -52,6 +52,26 @@ describe("toolStepKind", () => { ["mcp__linear__get_issue", { id: "HVAC-1" }, "mcp"], // codeg's own MCP companions are never third-party MCP rows ["mcp__codeg-mcp__delegate_to_agent", { task: "t" }, "delegation"], + // opencode 2.x code-mode: the delegation call rides inside the `execute` + // tool's `code` source string, so only the input shape can classify it. + [ + "execute", + { + code: 'await tools["codeg-mcp"].delegate_to_agent({ agent_type: "codex", task: `t` })', + }, + "delegation", + ], + // 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 b90c07a9d4..8637e925eb 100644 --- a/src/lib/activity/describe-tool-call.ts +++ b/src/lib/activity/describe-tool-call.ts @@ -19,7 +19,10 @@ import { import { isCodegMcpWorkbenchTool } from "@/lib/codeg-mcp-tool" import { COLLAB_AGENT_TOOL_NAME } from "@/lib/collab-tool" import { isContextCompactionMeta } from "@/lib/context-compaction" -import { isDelegateToAgentToolName } from "@/lib/delegation-card" +import { + isCodeModeDelegationCall, + isDelegateToAgentToolName, +} from "@/lib/delegation-card" import { extractAnnouncedSessionId, isShellSessionToolName, @@ -93,6 +96,14 @@ const TODO_TOOLS = new Set([ 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 + // 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" + } switch (name) { case "agent": return "agent" diff --git a/src/lib/delegation-card.test.ts b/src/lib/delegation-card.test.ts index f42e2c099b..46bb4ecbc6 100644 --- a/src/lib/delegation-card.test.ts +++ b/src/lib/delegation-card.test.ts @@ -1,9 +1,11 @@ -import { describe, expect, it } from "vitest" +import { describe, expect, it, vi } from "vitest" import { ALL_AGENT_TYPES } from "@/lib/types" import { coerceAssistantId, isAffirmedResume, + isCodeModeDelegationCall, + isCodeModeDelegationInput, isRefusedResume, parseDelegateTaskId, parseDelegationMeta, @@ -497,6 +499,557 @@ describe("resume verdicts never read a child's prose as a verdict", () => { }) }) +// 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("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(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", () => { + // 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("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. + 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: '// 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() + }) +}) + describe("resolveDelegationStatus and a later check", () => { const ack = parseToolOutput( JSON.stringify({ task_id: "t1", status: "started" }) diff --git a/src/lib/delegation-card.ts b/src/lib/delegation-card.ts index 6d23d5eec2..2fe876b87f 100644 --- a/src/lib/delegation-card.ts +++ b/src/lib/delegation-card.ts @@ -230,6 +230,826 @@ 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). +// --------------------------------------------------------------------------- + +// 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 = + | { 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 + } +} + +/** 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 + 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 === '"' || 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 (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 + } + } + 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 { + 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 { + 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) { + 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 === openCh) { + depth += 1 + i += 1 + } else if (c === closeCh) { + depth -= 1 + if (depth === 0) return i + i += 1 + } else { + i += 1 + } + } + 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 + * 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 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 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 `?` 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, + start: number, + end: number +): boolean { + let i = start + scan: 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 === "[" || c === "?" || c === "\\") { + return false + } else if (code.charCodeAt(i) >= 0x80) { + return false + } else if (c === "." && code[i + 1] === "." && code[i + 2] === ".") { + return false + } 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 + } + } + 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; `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, site } = call + const { open, close } = site + const start = open + 1 + // 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") + 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. Shape only — classify a + * tool call with [`isCodeModeDelegationCall`], which also checks the tool. + */ +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 +} + +/** + * 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 @@ -279,6 +1099,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. 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