Skip to content

feat(acp): per-chat ACP runtime — hot-swap a coding agent from the model picker (ADR 0082 S2+S3)#1995

Draft
mabry1985 wants to merge 4 commits into
mainfrom
feat/acp-per-chat-routing
Draft

feat(acp): per-chat ACP runtime — hot-swap a coding agent from the model picker (ADR 0082 S2+S3)#1995
mabry1985 wants to merge 4 commits into
mainfrom
feat/acp-per-chat-routing

Conversation

@mabry1985

Copy link
Copy Markdown
Member

Draft — for local testing before merge (as requested). This is a runtime behavior change CI can't fully exercise (it needs a real ACP agent driving a chat), so it's held for extensive local testing.

Implements the routing + picker for ADR 0082 (Accepted, #1994). Builds on the agent-registration surface #1993 (S1, merged).

What it does

Pick an ACP coding agent (Claude Code / Codex / proto / …) from a chat tab's model dropdown and that chat runs on it — hot-swapping the runtime the way a gateway model already hot-swaps, per tab.

S2 — routing (backend)

  • runtime/acp_runtime.py: resolve_turn_runtime(model, config) — a per-tab model=acp:<agent> selects the ACP runtime FOR THIS TURN, overriding the global agent_runtime (even when it's native); anything else falls back to the global. AcpRuntime(agent=…) can now run on a thread whose global runtime is native/a different agent.
  • server/chat.py: the ACP session registry is keyed by (thread, agent) — a mid-chat swap builds a fresh session while the previous agent's survives for a resumable swap-back. Both dispatch sites (streaming + non-streaming) + _acp_turn_collected thread the agent through.
  • graph/middleware/model_override.py: guard so an acp:* value can never build create_llm's text-only aux relay as the lead model (Trap A). acp:* stays valid for the explicit aux slots.

S3 — picker + boundary (console)

  • ComposerModelSelect.tsx: lists ACP agents alongside gateway models; shows the effective agent + a "coding agent" badge; stops replacing the menu with a static label under a global ACP runtime. Under a global ACP runtime it offers coding agents only (a gateway pick would be inert per the routing). chat-store/api needed no changesession.model already carries an arbitrary string end to end.
  • Option-C boundary note (D4): on a runtime crossing (native↔acp, acp:A↔acp:B — never gateway↔gateway) it drops a system note via noteToThread ("earlier context isn't carried across runtimes"). Native history (checkpointer) and ACP history (the agent's own stateful session) don't sync, so this is the honest boundary rather than faked continuity.

Tests / gates (local)

  • Backend: ruff ✓, lint-imports (3 kept) ✓, full pytest tests/3695 passed, 5 skipped. New tests: the resolver, explicit-agent guard, (thread,agent) keying + swap-back-resume, the middleware guard, per-tab-over-native routing.
  • Frontend: tsc (both configs) ✓, vitest548 passed ✓, vite build ✓.

Please test locally (needs a real ACP agent CLI installed + authed)

  • Global native runtime: open a chat, pick an ACP agent from the model dropdown → the turn runs on it; a boundary note appears; other tabs stay on the gateway.
  • Swap agent mid-chat (acp:A → acp:B → back to A) → each crossing notes; swapping back to A resumes A's session.
  • Pick a gateway model again → back to native; no note between two gateway models.
  • Global acp:X runtime: the picker offers coding agents only, X badged "default".
  • Non-streaming surface (/v1, desktop /api/chat) with model=acp:<agent> routes to ACP.

Out of scope (per ADR 0082)

Cross-runtime history bridging (Option B); /effort//compact/HITL/skill parity under ACP; a UI to author a custom acp.agents.<id> (registration stays config/YAML).

🤖 Generated with Claude Code

mabry1985 and others added 2 commits July 15, 2026 14:54
… chat (ADR 0082 S2)

The native-vs-ACP fork now consults the per-tab model: a `model=acp:<agent>` on the turn
(A2A metadata / `/v1` / `/api/chat`) routes THIS turn to the real AcpRuntime for that agent,
overriding the global agent_runtime — even when the global runtime is native (D1).

- runtime/acp_runtime.py: resolve_turn_runtime(model, config) — acp:<agent> per-tab wins,
  else falls back to the global resolve_runtime. AcpRuntime takes an explicit agent= so it can
  run on a thread whose global runtime is native/a different agent (D2).
- server/chat.py: the ACP session registry is keyed by (thread, agent) — a mid-chat swap
  builds a fresh session while the previous agent's survives for a resumable swap-back; both
  dispatch sites (streaming + non-streaming) + _acp_turn_collected thread the agent through.
- graph/middleware/model_override.py: guard so an acp:* value can NEVER build create_llm's
  text-only aux relay as the lead model (D3 / Trap A); acp:* stays valid for the explicit aux
  slots via create_llm directly.

Backend only — the console picker that offers acp:<agent> per-tab is S3. Reachable now via
API (model=acp:<agent>). +new tests (resolver, explicit-agent guard, (thread,agent) keying &
swap-back, the middleware guard, per-tab-over-native routing); full suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…note (ADR 0082 S3)

The composer model dropdown now lists ACP coding agents (from /api/acp-agents) alongside
gateway models, so an operator can hot-swap a coding agent into a single chat. The per-tab
selection rides the existing `session.model` string (`acp:<id>`) verbatim — chat-store/api
already carry it — and the S2 backend routes the turn accordingly.

- ComposerModelSelect: stops replacing the menu with a static label under a global ACP
  runtime; shows the effective agent + a "coding agent" badge; offers gateway models only
  under a native global runtime (a gateway pick is inert under global ACP per the routing).
- Option-C boundary note (D4): on a runtime crossing (native↔acp, acp:A↔acp:B — never
  gateway↔gateway) it drops a system note via noteToThread ("earlier context isn't carried
  across runtimes"), so the fresh-session boundary is explicit rather than silent.

Frontend gates green locally: tsc (both configs), vitest (548), vite build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: db3b6fcd-93c8-4fa2-89e2-df66d773b830

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/acp-per-chat-routing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

mabry1985 and others added 2 commits July 15, 2026 15:10
…82 S3)

The composer trigger's accessible name is the e2e locator
(getByRole button 'Model for this chat', model-command + quick-settings specs).
The picker now also lists coding agents, but the label stays 'Model for this chat'
so those specs keep matching. Verified: both specs green locally.
The Option-C boundary note ("Earlier context isn't carried across
runtimes…") should only fire when there's actually prior context to
lose. On a fresh chat with no turns yet, switching runtimes is silent.
System notes (including an earlier boundary note) don't count as
context, so the gate looks only for user/assistant turns — double-
switching an empty chat stays quiet too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mabry1985

Copy link
Copy Markdown
Member Author

Triage: keep held. S2/S3 genuinely aren't on main (runtime/acp_runtime.py has resolve_runtime but no resolve_turn_runtime; server/chat.py keys the ACP registry by thread_id only, not (thread, agent) — so no per-tab swap/resume). It recomputes CLEAN/MERGEABLE and #2145 didn't touch these files, so it flips to a clean merge the moment you run local QA with a real ACP CLI. Parked deliberately, not stale — no action needed until that QA.

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