Skip to content

fix(delegation): recognize opencode 2.x code-mode delegate calls wrapped in execute - #1020

Open
Liang-HZ wants to merge 2 commits into
spacering-net:mainfrom
Liang-HZ:feat/opencode-codemode-delegation
Open

Liang-HZ wants to merge 2 commits into
spacering-net:mainfrom
Liang-HZ:feat/opencode-codemode-delegation

Conversation

@Liang-HZ

Copy link
Copy Markdown
Contributor

Summary

OpenCode 2.x no longer surfaces MCP tool calls as first-class ACP tool calls with JSON args: it routes them through its built-in execute tool, so the update arrives as rawInput: {"code": "tools[\"codeg-mcp\"].delegate_to_agent({agent_type: \"codex\", task: …})"} — raw JS source. Registration, frontend classification and cold replay all keyed on the JSON-args shape, so with opencode as the main agent every delegation:

  • rendered as a bare execute card instead of a delegation card,
  • fell back to a synthetic delegation-<uuid> id (unkeyed), losing child binding and the "查看会话" affordance, with meta gone after refresh,
  • and in parallel-delegation scenarios the FIFO pool could hand a call another call's pending entry, binding child results to the wrong card.

What this adds

A conservative JS-source scanner that extracts the delegation match key from the execute payload, implemented twice (Rust in acp/lifecycle.rs, TS mirror in lib/delegation-card.ts) under one governing contract: never mint a keyed entry the MCP round-trip cannot claim — when the scanner cannot evaluate syntax, the call degrades to the (pre-existing) unkeyed path instead of producing a wrong key.

  • code_mode_call_span locates the accessor-chain call; a transparency pre-pass bails on spread/computed keys, nested parens, regex literals and shorthand working_dir/assistant_id identifiers (a shorthand's value is a runtime variable — keying its absence would mint an entry no round-trip can claim).
  • Template cooked text follows ECMA-262 TV semantics: only bare CR/CRLF fold to LF; U+2028/U+2029 are preserved verbatim; \+newline continuations contribute nothing. Line comments end at every JS line terminator (LF/CR/LS/PS), so a trailing comment cannot swallow the real code that follows.
  • Keys match at brace depth 1 only, LAST-wins inside a single object, and scanning stops when the argument object closes — a second argument object cannot override the first's keys.
  • working_dir present-but-unreadable (template/escape/variable/concatenation) bails the whole key; assistant_id in any spelling degrades to unkeyed (its present value resolves to Assistant on the MCP side and would win over agent_type).
  • The cold-replay gate in commands/conversations.rs reuses is_delegation_invocation (now pub(crate)) as the shape predicate for a third binding branch, so persisted execute blocks rebind on reload via tool_use_id → parent_tool_use_id → task id, exactly like live registration — the two paths cannot drift.
  • Broker pending-overview WARN truncates dir= like task= (log hygiene).

The scanner is deliberately shared-shape with the MCP listener's own dispatch (the 对拍 test code_mode_key_matches_the_mcp_side_for_the_same_call pins Rust scanner output against DelegationRequest::target()/serde task parsing for identical emissions), so keyed entries always match what the round-trip will claim.

Testing

  • cargo test --lib: 6018 passed / 1 failed (commands::remote_proxy::tests::an_event_socket_gets_past_an_address_that_never_answers, pre-existing environment case on this machine) / 2 ignored — includes 37 new code_mode_* unit tests (bails, depth, LAST-wins, TV normalization, comment terminators, and the 对拍 pair).
  • pnpm vitest run src/lib/delegation-card.test.ts: 71/71 (adds the TS mirror cases: emission parsing, escape set, LS/PS preservation, shorthand/paren/regex bails, argument-object close, comment terminators).
  • tsc --noEmit clean.
  • Live-verified on a real deployment: opencode 2.0.28 as a delegation child emitting tools["codeg-mcp"].delegate_to_agent(...) through execute registers keyed=true (log: registering parent … (keyed=true)) with no synthetic-fallback WARN, while the same calls previously fell through unkeyed.

Possible follow-ups (not in this PR)

  • Read a readable assistant_id value and key Assistant(id) (currently any assistant_id degrades to unkeyed; parallel assistant-delegations then rely on the FIFO pool).
  • Frontend classification could also gate on tool name execute as defense-in-depth (the backend predicate intentionally does not — ToolCallUpdate may lack a title).
  • resume_delegation/get_delegation_status/cancel_delegation calls inside execute still render as plain execute cards; their results still bind via the replay gate's task-id fallback.

…ped in execute

OpenCode 2.x routes MCP tool calls through its built-in `execute` tool, so the
ACP update arrives as rawInput {"code": "tools[\"codeg-mcp\"].delegate_to_agent(
{...})"} instead of JSON args. Registration, frontend classification and cold
replay all keyed on the JSON-args shape, so opencode-as-main-agent delegations
fell back to synthetic unkeyed ids: no delegation card, meta lost on refresh,
and the FIFO pool could hand a call another call's entry.

Adds a conservative JS-source scanner (Rust + TS mirrors) that extracts the
delegation match key from the execute payload under the contract "never mint a
keyed entry the MCP round-trip cannot claim": transparency pre-pass bails on
spread/computed keys/parens/regex/shorthand key words, template cooked text
follows ECMA-262 TV semantics (only CR/CRLF fold to LF; U+2028/2029 kept),
line comments end at every JS line terminator, keys match at brace depth 1
with LAST-wins inside a single object, working_dir present-but-unreadable
bails the whole key, and any assistant_id spelling degrades to unkeyed.

The replay gate in conversations.rs reuses is_delegation_invocation as the
shape predicate so persisted execute blocks rebind on reload via
tool_use_id -> parent_tool_use_id -> task id. Broker pending-overview WARN now
truncates dir like task.
Collapse the empty line after the code-mode doc comment in lifecycle.rs
(clippy 1.99's empty_line_after_doc_comments fires under -D warnings and
fails every Rust job), and reformat the two delegation-card spots
prettier flags: single-quote the template-literal fixture string and
unwrap the workingDir ternary that now fits the print width.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant