From db522bfa716e56a5bc75450727dc7fb7eb1048ec Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 16:27:51 +0800 Subject: [PATCH 1/7] =?UTF-8?q?docs(adr):=20chatting=20with=20a=20connecte?= =?UTF-8?q?d=20agent=20=E2=80=94=20session/prompt=20over=20/acp=20(Part=20?= =?UTF-8?q?C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part B made Studio publish its `oab` tools to the agent (reverse MCP); the remote connection works but the operator still can't talk to the agent — `session/update` chat frames arrive on the live socket and are dropped. This ADR proposes the chat panel: additive on the same `/acp` session (session/prompt + streamed session/update + session/cancel), with a three-layer design (acp-tunnel builders + typed AgentChunk; remote.rs mpsc write-handle + tokio::select! + emit agent-update + id-correlated pendingReqs; console chat panel) and a UX parity target lifted from the working `brettchien/katashiro` client on the same gateway. MVP scope: single bound agent, text single-turn streaming, stop/retry, batched queue, markdown-at-finalize, autoscroll. Multi-agent room, tool-call cards, history/resume deferred. Open questions flag the facade routing + session/update taxonomy for server-side confirmation. Co-Authored-By: Claude Opus 4.8 --- docs/adr/agent-chat-panel.md | 74 ++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/adr/agent-chat-panel.md diff --git a/docs/adr/agent-chat-panel.md b/docs/adr/agent-chat-panel.md new file mode 100644 index 0000000..6f8d5ea --- /dev/null +++ b/docs/adr/agent-chat-panel.md @@ -0,0 +1,74 @@ +# ADR: Chatting with a connected agent — `session/prompt` over the live `/acp` socket (Part C) + +- **Status:** Proposed +- **Date:** 2026-08-14 +- **Author:** Orca (`ecs-claude`) +- **Extends:** [Fleet grouping & connection model](./fleet-grouping-and-connection-model.md) Part B (reverse MCP-over-ACP); [agent connection guide](../connecting-an-agent.md) +- **Reference:** [`brettchien/katashiro`](https://github.com/brettchien/katashiro) — a working ACP chat client against the **same** openab `/acp` gateway; [MCP-over-ACP tunnel contract](https://github.com/openabdev/openab/blob/main/docs/mcp-over-acp-tunnel-contract.md) + +--- + +> Part B made Studio **publish** its `oab` tools to the agent (reverse MCP). This ADR adds the other direction — a **chat panel** that drives the agent with `session/prompt` and renders its streamed `session/update` reply — on the **same** live `/acp` session. The bar is **parity with katashiro's chat experience**; the reference implementation already exists and hits the same gateway, so the wire contract and UX are **copied, not invented**. + +## 1. Context + +- The remote `/acp` connection now succeeds end-to-end (#45 handshake, #46 `protocolVersion`), reaching `session active — oab tools published`. But that is **only the reverse-MCP direction**: the agent can call Studio's `oab` tools; the operator has **no way to talk to the agent** — "we can't work with the agent." +- `remote.rs` opens exactly one ACP session (`initialize` → `session/new`, declaring `oab`) and then serves the gateway-initiated tunnel. Agent chat frames (`session/update`) already arrive on that socket and are **silently dropped** — `parse_inbound` → `Inbound::Other` → the empty match arm. +- `katashiro` is a working ACP chat client on the **same** gateway. It proves the full contract (`session/prompt` / `session/update` / `session/cancel`) and a complete chat UX (streaming markdown, stop/retry, history, a multi-agent room). It is the parity target — we do not reverse-engineer the wire, we follow it. + +## 2. Decision — Part A: chat rides the existing session + +Chat is **additive on the one live `/acp` session**, not a second connection. `session/new` already declares `oab` (reverse MCP); the same session carries prompts and streamed replies (katashiro does exactly this — one socket, both directions). Wire contract (verified live, same gateway): + +- **Prompt** — request `session/prompt` `{ sessionId, prompt: [{ type:"text", text }] }` → resolves `{ stopReason }` (`"cancelled"` ⇒ keep the partial reply). Turns stream long; the request stays pending until end-of-turn (timeout ~10 min, per katashiro `ACP_PROMPT_TIMEOUT_MS`). +- **Stream** — notification `session/update`, `params.update.sessionUpdate === "agent_message_chunk"`, text at `params.update.content.text`. (Other update kinds — thoughts, tool calls — are out of MVP scope; see §5.) +- **Stop** — one-way notification `session/cancel` `{ sessionId }`; the gateway resolves the in-flight `session/prompt` with `stopReason:"cancelled"`, so cancellation flows through the normal resolve path — nothing extra to settle. + +## 3. Decision — Part B: backend (Rust) + +**`crates/acp-tunnel`:** +- Add a `Session::prompt(text)` request builder and a `session/cancel` notification builder, mirroring `open_session`. +- Classify `session/update` (`agent_message_chunk`) into a typed `Inbound::AgentChunk { text }` instead of the catch-all `Inbound::Other`. + +**`src-tauri/src/remote.rs`:** +- **`RemoteState` grows a live write handle** — an `mpsc::UnboundedSender` set when the session goes active (alongside the `sessionId`), cleared on disconnect. This is the missing "reach into the live socket": today `disconnect` can only `.abort()` the whole task. +- `run_once` `tokio::select!`s between `read.next()` and `prompt_rx.recv()`; a received frame goes out through the existing private `send()` helper, so the WS `write` sink never leaves the task. +- **The drop site becomes the chat hook** — the new `Inbound::AgentChunk` arm `app.emit("agent-update", …)`, the same emit/listen model as `remote-status`. +- **Correctness — id correlation:** the read loop today treats *any* method-less frame as a handshake ack that advances the session phase. `session/prompt` responses are **also** method-less; they must be **correlated by request id** (a `pendingReqs` map, as katashiro does) so a prompt result never mis-drives the phase state machine. + +**`src-tauri/src/lib.rs`:** two `#[tauri::command]`s next to `remote_connect` — `agent_prompt` (push a `session/prompt` `Value` into the sender) and `agent_cancel` (push `session/cancel`) — taking `tauri::State<'_, remote::Remote>` (+ `AppHandle`), registered in the `invoke_handler!` list. + +## 4. Decision — Part C: the chat panel (parity with katashiro) + +A new panel/tab in the vanilla-TS `console` (add `
` + a `#tabs` button, `chatHtml`/`renderChat` in `render.ts`, wiring + `listen("agent-update")` in `main.ts`, `agent_prompt`/`agent_cancel` on the delegated click handler). The **load-bearing UX to replicate** (from katashiro's chat): + +- **Two-phase agent rendering** — plain `textContent` while streaming (a typing indicator before the first token), then **markdown rendered once at finalize**. Re-parsing per token flickers on half-open code fences and is O(n²); a stopped/errored stream still finalizes. +- **One sanitized markdown sink** — markdown-it (`html:false`, linkify, GFM tables, `hljs` code fences) → **DOMPurify** (pinned config, relaxing forbidden) → link/media scheme hardening → post-sanitize copy-code buttons. Agent output is **remote-controlled**, so the sanitizer is mandatory, not optional, and is treated as a **security dependency** (pin + track advisories). Links open via Tauri's opener API (re-validating the scheme at click time), not `chrome.tabs`. +- **Turn controls** — Enter sends / Shift+Enter newline; a **stop** button (→ `agent_cancel`; the partial reply is kept and annotated `⏹ 已停止`); a **retry** on failed turns; a **batched** prompt queue (messages typed while the agent is busy coalesce into one next turn, blank-line joined, order preserved). +- **Scroll** — stick-to-bottom with an 80px threshold and a **jump-to-latest** pill when scrolled up; sending always pulls to the bottom, receiving only follows if already there. +- **System/error rows**, single-letter avatars, `HH:MM (TPE)` timestamps — all `textContent`, never markdown. + +**Single-active-agent (the model, not just an MVP shortcut).** Studio has one remote endpoint (`remote.toml`), so chat targets exactly the bound agent — today that is Orca. Katashiro's room / @mention / relay / loop-guard is a **design we keep but gate off** as a size-1 room; multi-agent lights up later, tied to the fleet model (fleet ADR), not this slice. + +## 5. Scope (now) + +- ✅ **In (MVP):** the single bound agent; text single-turn streaming (`agent_message_chunk`); markdown render at finalize; stop / retry; batched prompt queue; autoscroll + jump pill; system/error rows. +- ⚠️ **Out (later slices):** multi-agent room / @mention / relay / loop-guard; the `tool_call` / thought `session/update` kinds (rendered as cards); chat history persistence + `session/resume` on reconnect; a browser-tunnel status strip; accessibility polish (`aria-live` / `role="log"` — katashiro itself lacks these, Studio should do better). + +## 6. Consequences + +- ✅ The remote connection becomes **usable** — the operator drives the agent, not just publishes tools to it. +- ✅ Reuses the existing session / reconnect / status machinery unchanged; only the per-connection loop and `RemoteState` grow a channel. +- ✅ Wire contract and UX are **copied from a working reference on the same gateway** — low protocol risk. +- ⚠️ `remote.rs` gains real bidirectional state (a pending-request map, an outbound channel); "a method-less frame is always a handshake ack" is no longer true. +- ⚠️ Rendering remote agent output via `innerHTML` reintroduces an XSS surface; the DOMPurify sink **plus** a Tauri-side CSP (not the MV3 manifest CSP katashiro uses) are load-bearing, and the `/acp` bearer should live in the Rust core, out of the webview's reach. + +## 7. Open questions + +1. **Facade routing** — does the gateway/facade route `session/prompt` to the bound agent (Orca) and stream `session/update` back, or is prompting gated behind a specific ACP capability? Confirm server-side (Jellyfish) before Part B lands. +2. **`session/update` taxonomy** — beyond `agent_message_chunk`, what kinds does the openab agent emit (thoughts, `tool_call` / `tool_call_update`)? Defines the phase-2 card model. +3. **History & resume** — persist transcript + `sessionId` for continuity across reconnect/restart? Desktop has no per-window `chrome.storage` collision, so this is simpler than katashiro — is it wanted in MVP? +4. **Threat model under Tauri** — where the `/acp` bearer lives relative to the webview, and what Studio CSP replaces katashiro's MV3 egress lock. +5. **Multi-agent trigger** — what lights up the room model: multiple `remote.toml` endpoints, or fleet binding? Ties to the fleet ADR. + +This is a direction-alignment ADR; implementation lands in slices (Part B backend, then Part C MVP panel), each verified against katashiro parity and the live gateway. From 3927621e146fc230b239ddc5e5cb79320eae9ec5 Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 16:30:59 +0800 Subject: [PATCH 2/7] =?UTF-8?q?docs(adr):=20drop=20multi-agent=20room=20?= =?UTF-8?q?=E2=80=94=20single=20bound=20agent=20by=20design=20(Brett)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-agent room / @mention / relay / loop-guard is not a goal (Brett, 2026-08-14). Studio chats exactly one remote.toml-bound agent; the room machinery is left behind, not deferred. Removes it from parity target, scope, and open questions. Co-Authored-By: Claude Opus 4.8 --- docs/adr/agent-chat-panel.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/agent-chat-panel.md b/docs/adr/agent-chat-panel.md index 6f8d5ea..4e6ba2f 100644 --- a/docs/adr/agent-chat-panel.md +++ b/docs/adr/agent-chat-panel.md @@ -14,7 +14,7 @@ - The remote `/acp` connection now succeeds end-to-end (#45 handshake, #46 `protocolVersion`), reaching `session active — oab tools published`. But that is **only the reverse-MCP direction**: the agent can call Studio's `oab` tools; the operator has **no way to talk to the agent** — "we can't work with the agent." - `remote.rs` opens exactly one ACP session (`initialize` → `session/new`, declaring `oab`) and then serves the gateway-initiated tunnel. Agent chat frames (`session/update`) already arrive on that socket and are **silently dropped** — `parse_inbound` → `Inbound::Other` → the empty match arm. -- `katashiro` is a working ACP chat client on the **same** gateway. It proves the full contract (`session/prompt` / `session/update` / `session/cancel`) and a complete chat UX (streaming markdown, stop/retry, history, a multi-agent room). It is the parity target — we do not reverse-engineer the wire, we follow it. +- `katashiro` is a working ACP chat client on the **same** gateway. It proves the full contract (`session/prompt` / `session/update` / `session/cancel`) and a complete chat UX (streaming markdown, stop/retry, history). It is the parity target for the **chat**, not the room — we follow its wire and its rendering, and deliberately leave its multi-agent surface behind (§4). ## 2. Decision — Part A: chat rides the existing session @@ -48,12 +48,13 @@ A new panel/tab in the vanilla-TS `console` (add `
` + a `#tab - **Scroll** — stick-to-bottom with an 80px threshold and a **jump-to-latest** pill when scrolled up; sending always pulls to the bottom, receiving only follows if already there. - **System/error rows**, single-letter avatars, `HH:MM (TPE)` timestamps — all `textContent`, never markdown. -**Single-active-agent (the model, not just an MVP shortcut).** Studio has one remote endpoint (`remote.toml`), so chat targets exactly the bound agent — today that is Orca. Katashiro's room / @mention / relay / loop-guard is a **design we keep but gate off** as a size-1 room; multi-agent lights up later, tied to the fleet model (fleet ADR), not this slice. +**Single-agent, by design.** Studio has one remote endpoint (`remote.toml`) and chat targets exactly that bound agent — today Orca. **No multi-agent room** (Brett, 2026-08-14): katashiro's room / @mention / relay / loop-guard is explicitly **not a goal**. We take its chat primitives — streaming, markdown, turn controls — and leave the room machinery behind, so `remote.rs` and the panel stay one session with no routing layer (`resolveTargets`, `relayAgentReply`, the loop guard, batching-for-relays are all dropped). ## 5. Scope (now) - ✅ **In (MVP):** the single bound agent; text single-turn streaming (`agent_message_chunk`); markdown render at finalize; stop / retry; batched prompt queue; autoscroll + jump pill; system/error rows. -- ⚠️ **Out (later slices):** multi-agent room / @mention / relay / loop-guard; the `tool_call` / thought `session/update` kinds (rendered as cards); chat history persistence + `session/resume` on reconnect; a browser-tunnel status strip; accessibility polish (`aria-live` / `role="log"` — katashiro itself lacks these, Studio should do better). +- ⚠️ **Out (later slices):** the `tool_call` / thought `session/update` kinds (rendered as cards); chat history persistence + `session/resume` on reconnect; a browser-tunnel status strip; accessibility polish (`aria-live` / `role="log"` — katashiro itself lacks these, Studio should do better). +- 🚫 **Not a goal:** the multi-agent room — @mention routing, agent→agent relay, loop guard (Brett, 2026-08-14). Studio chats one bound agent; there is no room to light up later. ## 6. Consequences @@ -69,6 +70,5 @@ A new panel/tab in the vanilla-TS `console` (add `
` + a `#tab 2. **`session/update` taxonomy** — beyond `agent_message_chunk`, what kinds does the openab agent emit (thoughts, `tool_call` / `tool_call_update`)? Defines the phase-2 card model. 3. **History & resume** — persist transcript + `sessionId` for continuity across reconnect/restart? Desktop has no per-window `chrome.storage` collision, so this is simpler than katashiro — is it wanted in MVP? 4. **Threat model under Tauri** — where the `/acp` bearer lives relative to the webview, and what Studio CSP replaces katashiro's MV3 egress lock. -5. **Multi-agent trigger** — what lights up the room model: multiple `remote.toml` endpoints, or fleet binding? Ties to the fleet ADR. This is a direction-alignment ADR; implementation lands in slices (Part B backend, then Part C MVP panel), each verified against katashiro parity and the live gateway. From f2e35ff1da2086a59637b49ed7ab06a5bc5a1e12 Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 16:33:01 +0800 Subject: [PATCH 3/7] docs(adr): history+resume into MVP; a11y out (single-operator YAGNI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brett: history + session/resume are needed -> moved from deferred into MVP scope (persist sessionId in the Rust core, resume-then-new handshake, transcript restored before connect). Accessibility dropped, but recorded with the correct rationale (single-operator internal tool, not 'because it's native' — a11y is a webview concern that would otherwise apply). Co-Authored-By: Claude Opus 4.8 --- docs/adr/agent-chat-panel.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/adr/agent-chat-panel.md b/docs/adr/agent-chat-panel.md index 4e6ba2f..e0b3c16 100644 --- a/docs/adr/agent-chat-panel.md +++ b/docs/adr/agent-chat-panel.md @@ -35,6 +35,7 @@ Chat is **additive on the one live `/acp` session**, not a second connection. `s - `run_once` `tokio::select!`s between `read.next()` and `prompt_rx.recv()`; a received frame goes out through the existing private `send()` helper, so the WS `write` sink never leaves the task. - **The drop site becomes the chat hook** — the new `Inbound::AgentChunk` arm `app.emit("agent-update", …)`, the same emit/listen model as `remote-status`. - **Correctness — id correlation:** the read loop today treats *any* method-less frame as a handshake ack that advances the session phase. `session/prompt` responses are **also** method-less; they must be **correlated by request id** (a `pendingReqs` map, as katashiro does) so a prompt result never mis-drives the phase state machine. +- **Resume, not new:** persist the `sessionId`; on connect, drive `Session::resume()` (already in `acp-tunnel`) when we have one and fall back to `session/new` if the gateway rejects it (katashiro's pattern). This is what keeps the **agent's own** memory of the conversation continuous across reconnect/restart — not just the local scrollback. The `sessionId` lives in the Rust core (`RemoteState`/on disk), out of the webview's reach. **`src-tauri/src/lib.rs`:** two `#[tauri::command]`s next to `remote_connect` — `agent_prompt` (push a `session/prompt` `Value` into the sender) and `agent_cancel` (push `session/cancel`) — taking `tauri::State<'_, remote::Remote>` (+ `AppHandle`), registered in the `invoke_handler!` list. @@ -47,14 +48,15 @@ A new panel/tab in the vanilla-TS `console` (add `
` + a `#tab - **Turn controls** — Enter sends / Shift+Enter newline; a **stop** button (→ `agent_cancel`; the partial reply is kept and annotated `⏹ 已停止`); a **retry** on failed turns; a **batched** prompt queue (messages typed while the agent is busy coalesce into one next turn, blank-line joined, order preserved). - **Scroll** — stick-to-bottom with an 80px threshold and a **jump-to-latest** pill when scrolled up; sending always pulls to the bottom, receiving only follows if already there. - **System/error rows**, single-letter avatars, `HH:MM (TPE)` timestamps — all `textContent`, never markdown. +- **History + resume** — the transcript **persists and is restored before connect**, and the stored `sessionId` drives `session/resume` so the agent's side continues too (§3). Single-window desktop, so none of katashiro's per-window `chrome.storage` isolation is needed; `clear` wipes the local transcript but keeps the session (the agent doesn't forget). **Single-agent, by design.** Studio has one remote endpoint (`remote.toml`) and chat targets exactly that bound agent — today Orca. **No multi-agent room** (Brett, 2026-08-14): katashiro's room / @mention / relay / loop-guard is explicitly **not a goal**. We take its chat primitives — streaming, markdown, turn controls — and leave the room machinery behind, so `remote.rs` and the panel stay one session with no routing layer (`resolveTargets`, `relayAgentReply`, the loop guard, batching-for-relays are all dropped). ## 5. Scope (now) -- ✅ **In (MVP):** the single bound agent; text single-turn streaming (`agent_message_chunk`); markdown render at finalize; stop / retry; batched prompt queue; autoscroll + jump pill; system/error rows. -- ⚠️ **Out (later slices):** the `tool_call` / thought `session/update` kinds (rendered as cards); chat history persistence + `session/resume` on reconnect; a browser-tunnel status strip; accessibility polish (`aria-live` / `role="log"` — katashiro itself lacks these, Studio should do better). -- 🚫 **Not a goal:** the multi-agent room — @mention routing, agent→agent relay, loop guard (Brett, 2026-08-14). Studio chats one bound agent; there is no room to light up later. +- ✅ **In (MVP):** the single bound agent; text single-turn streaming (`agent_message_chunk`); markdown render at finalize; stop / retry; batched prompt queue; autoscroll + jump pill; system/error rows; **persistent chat history + `session/resume`** on reconnect/restart. +- ⚠️ **Out (later slices):** the `tool_call` / thought `session/update` kinds (rendered as cards); a browser-tunnel status strip. +- 🚫 **Not a goal:** (a) the multi-agent room — @mention routing, agent→agent relay, loop guard (Brett, 2026-08-14); Studio chats one bound agent, there is no room to light up later. (b) **Accessibility** (screen-reader / `aria-live` / `role="log"`) — this is a single-operator internal console, so a11y is YAGNI. (Rationale is the single user, *not* "it's native": a11y is a webview-UI concern that would otherwise apply to a Tauri app just as it does to an extension.) ## 6. Consequences @@ -68,7 +70,7 @@ A new panel/tab in the vanilla-TS `console` (add `
` + a `#tab 1. **Facade routing** — does the gateway/facade route `session/prompt` to the bound agent (Orca) and stream `session/update` back, or is prompting gated behind a specific ACP capability? Confirm server-side (Jellyfish) before Part B lands. 2. **`session/update` taxonomy** — beyond `agent_message_chunk`, what kinds does the openab agent emit (thoughts, `tool_call` / `tool_call_update`)? Defines the phase-2 card model. -3. **History & resume** — persist transcript + `sessionId` for continuity across reconnect/restart? Desktop has no per-window `chrome.storage` collision, so this is simpler than katashiro — is it wanted in MVP? +3. **Persistence store** — history + resume are **in MVP** (Brett); the open detail is *where* state lives. Leaning: the `sessionId` in the Rust core (`RemoteState` + a small on-disk file, out of the webview), and the transcript in a Tauri app-data store keyed by agent. Confirm the store choice when Part C lands. 4. **Threat model under Tauri** — where the `/acp` bearer lives relative to the webview, and what Studio CSP replaces katashiro's MV3 egress lock. This is a direction-alignment ADR; implementation lands in slices (Part B backend, then Part C MVP panel), each verified against katashiro parity and the live gateway. From bef206115c405bacd4c225416e722884ba084705 Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 16:37:42 +0800 Subject: [PATCH 4/7] =?UTF-8?q?docs(adr):=20fold=20in=20Jellyfish=20review?= =?UTF-8?q?=20=E2=80=94=20openab=20/acp=20is=20single-shot,=20not=20stream?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jellyfish's live server-side eval (against Orca /acp) established: (1) session/prompt routes to the bound agent and runs tools, returns stopReason:end_turn — Part B viable; (2) openab emits ONLY a single terminal agent_message_chunk (~5.5s silence then one ~329-char chunk), zero tool_call/thought — so token-streaming and tool-call cards are upstream-blocked, not Studio slices; (3) the mpsc+select!+pendingReqs id-correlation refactor is sound. Rewrites the 'streaming' premise across the ADR: reply rendering is single-shot behind a think-spinner, keeping an accumulate-then-render shape as forward-compat if openab later streams; stop = abandon the turn (no partial in single-shot); §7 splits resolved-by-review from still-open (session/resume server-side, persistence store, Tauri threat model). Part B backend skeleton can start (streaming-independent). Co-Authored-By: Claude Opus 4.8 --- docs/adr/agent-chat-panel.md | 42 +++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/docs/adr/agent-chat-panel.md b/docs/adr/agent-chat-panel.md index e0b3c16..50d61ae 100644 --- a/docs/adr/agent-chat-panel.md +++ b/docs/adr/agent-chat-panel.md @@ -5,24 +5,25 @@ - **Author:** Orca (`ecs-claude`) - **Extends:** [Fleet grouping & connection model](./fleet-grouping-and-connection-model.md) Part B (reverse MCP-over-ACP); [agent connection guide](../connecting-an-agent.md) - **Reference:** [`brettchien/katashiro`](https://github.com/brettchien/katashiro) — a working ACP chat client against the **same** openab `/acp` gateway; [MCP-over-ACP tunnel contract](https://github.com/openabdev/openab/blob/main/docs/mcp-over-acp-tunnel-contract.md) +- **Reviewed:** Jellyfish, 2026-08-14 — live server-side eval against Orca's `/acp` (routing, `session/update` taxonomy, refactor). Findings folded into §2/§4/§5/§7. --- -> Part B made Studio **publish** its `oab` tools to the agent (reverse MCP). This ADR adds the other direction — a **chat panel** that drives the agent with `session/prompt` and renders its streamed `session/update` reply — on the **same** live `/acp` session. The bar is **parity with katashiro's chat experience**; the reference implementation already exists and hits the same gateway, so the wire contract and UX are **copied, not invented**. +> Part B made Studio **publish** its `oab` tools to the agent (reverse MCP). This ADR adds the other direction — a **chat panel** that drives the agent with `session/prompt` and renders its `session/update` reply — on the **same** live `/acp` session. The bar is **parity with katashiro's chat surface**; the reference already exists and hits the same gateway, so the wire contract and UX are **followed, not invented** — including the live-verified fact that openab's `/acp` returns a **single terminal reply today**, not a token stream (§2). ## 1. Context - The remote `/acp` connection now succeeds end-to-end (#45 handshake, #46 `protocolVersion`), reaching `session active — oab tools published`. But that is **only the reverse-MCP direction**: the agent can call Studio's `oab` tools; the operator has **no way to talk to the agent** — "we can't work with the agent." - `remote.rs` opens exactly one ACP session (`initialize` → `session/new`, declaring `oab`) and then serves the gateway-initiated tunnel. Agent chat frames (`session/update`) already arrive on that socket and are **silently dropped** — `parse_inbound` → `Inbound::Other` → the empty match arm. -- `katashiro` is a working ACP chat client on the **same** gateway. It proves the full contract (`session/prompt` / `session/update` / `session/cancel`) and a complete chat UX (streaming markdown, stop/retry, history). It is the parity target for the **chat**, not the room — we follow its wire and its rendering, and deliberately leave its multi-agent surface behind (§4). +- `katashiro` is a working ACP chat client on the **same** gateway. It proves the full contract (`session/prompt` / `session/update` / `session/cancel`) and a complete chat UX (markdown, stop/retry, history). It is the parity target for the **chat surface**, not the room — we follow its wire and its rendering, and deliberately leave its multi-agent surface behind (§4). Note: katashiro's *token-streaming* animation is not exercised against this gateway either — openab returns one terminal chunk (§2), so "parity" is the chat surface, not a stream the server can't drive. ## 2. Decision — Part A: chat rides the existing session -Chat is **additive on the one live `/acp` session**, not a second connection. `session/new` already declares `oab` (reverse MCP); the same session carries prompts and streamed replies (katashiro does exactly this — one socket, both directions). Wire contract (verified live, same gateway): +Chat is **additive on the one live `/acp` session**, not a second connection. `session/new` already declares `oab` (reverse MCP); the same session carries prompts and the reply (katashiro does exactly this — one socket, both directions). Wire contract (verified live, same gateway): -- **Prompt** — request `session/prompt` `{ sessionId, prompt: [{ type:"text", text }] }` → resolves `{ stopReason }` (`"cancelled"` ⇒ keep the partial reply). Turns stream long; the request stays pending until end-of-turn (timeout ~10 min, per katashiro `ACP_PROMPT_TIMEOUT_MS`). -- **Stream** — notification `session/update`, `params.update.sessionUpdate === "agent_message_chunk"`, text at `params.update.content.text`. (Other update kinds — thoughts, tool calls — are out of MVP scope; see §5.) -- **Stop** — one-way notification `session/cancel` `{ sessionId }`; the gateway resolves the in-flight `session/prompt` with `stopReason:"cancelled"`, so cancellation flows through the normal resolve path — nothing extra to settle. +- **Prompt** — request `session/prompt` `{ sessionId, prompt: [{ type:"text", text }] }` → resolves `{ stopReason }` (`"end_turn"` on success). The request stays pending for the **whole turn** (timeout ~10 min, per katashiro `ACP_PROMPT_TIMEOUT_MS`) — turns take real wall-clock time even though the reply arrives in one piece. +- **Reply** — notification `session/update`, `params.update.sessionUpdate === "agent_message_chunk"`, text at `params.update.content.text`. **openab's `/acp` emits a single *terminal* `agent_message_chunk` today** — verified live: ~5.5 s of silence, then one ~329-char chunk, then the result. It is **not** token-by-token streaming (openab source: single-terminal-chunk, `streaming=false`; the emitting test is named `phase1_…`, so a later phase may stream). The design below treats the reply as one chunk but keeps an accumulate-then-render shape so incremental chunks, if openab ever emits them, need no rework. +- **Stop** — one-way notification `session/cancel` `{ sessionId }`; the gateway resolves the in-flight `session/prompt` with `stopReason:"cancelled"`. In the single-terminal-chunk model a cancel before the chunk means **no partial reply exists** — stop = *abandon this turn*. (If openab later streams, the keep-the-partial semantics apply for free.) ## 3. Decision — Part B: backend (Rust) @@ -43,19 +44,20 @@ Chat is **additive on the one live `/acp` session**, not a second connection. `s A new panel/tab in the vanilla-TS `console` (add `
` + a `#tabs` button, `chatHtml`/`renderChat` in `render.ts`, wiring + `listen("agent-update")` in `main.ts`, `agent_prompt`/`agent_cancel` on the delegated click handler). The **load-bearing UX to replicate** (from katashiro's chat): -- **Two-phase agent rendering** — plain `textContent` while streaming (a typing indicator before the first token), then **markdown rendered once at finalize**. Re-parsing per token flickers on half-open code fences and is O(n²); a stopped/errored stream still finalizes. +- **Reply rendering (single-shot today).** Send → a **spinner / typing indicator** covers the multi-second think (the wait is real — ~5.5 s+ before any reply, §2). The terminal `agent_message_chunk` arrives and is **rendered to markdown once**. The code keeps the **accumulate-then-render-once** shape (append chunk text into a buffer, markdown at end-of-turn), so it stays correct at one chunk *and* forward-compatible if openab later emits incremental chunks — but there is **no token-by-token phase today**, so per-token flicker / O(n²) concerns simply don't arise. A stopped/errored turn still finalizes (renders whatever, if anything, arrived). - **One sanitized markdown sink** — markdown-it (`html:false`, linkify, GFM tables, `hljs` code fences) → **DOMPurify** (pinned config, relaxing forbidden) → link/media scheme hardening → post-sanitize copy-code buttons. Agent output is **remote-controlled**, so the sanitizer is mandatory, not optional, and is treated as a **security dependency** (pin + track advisories). Links open via Tauri's opener API (re-validating the scheme at click time), not `chrome.tabs`. -- **Turn controls** — Enter sends / Shift+Enter newline; a **stop** button (→ `agent_cancel`; the partial reply is kept and annotated `⏹ 已停止`); a **retry** on failed turns; a **batched** prompt queue (messages typed while the agent is busy coalesce into one next turn, blank-line joined, order preserved). +- **Turn controls** — Enter sends / Shift+Enter newline; a **stop** button (→ `agent_cancel`) that **abandons the turn** (single-shot ⇒ usually no partial to keep; annotate `⏹ 已停止`); a **retry** on failed turns; a **batched** prompt queue (messages typed while the agent is busy coalesce into one next turn, blank-line joined, order preserved). - **Scroll** — stick-to-bottom with an 80px threshold and a **jump-to-latest** pill when scrolled up; sending always pulls to the bottom, receiving only follows if already there. - **System/error rows**, single-letter avatars, `HH:MM (TPE)` timestamps — all `textContent`, never markdown. - **History + resume** — the transcript **persists and is restored before connect**, and the stored `sessionId` drives `session/resume` so the agent's side continues too (§3). Single-window desktop, so none of katashiro's per-window `chrome.storage` isolation is needed; `clear` wipes the local transcript but keeps the session (the agent doesn't forget). -**Single-agent, by design.** Studio has one remote endpoint (`remote.toml`) and chat targets exactly that bound agent — today Orca. **No multi-agent room** (Brett, 2026-08-14): katashiro's room / @mention / relay / loop-guard is explicitly **not a goal**. We take its chat primitives — streaming, markdown, turn controls — and leave the room machinery behind, so `remote.rs` and the panel stay one session with no routing layer (`resolveTargets`, `relayAgentReply`, the loop guard, batching-for-relays are all dropped). +**Single-agent, by design.** Studio has one remote endpoint (`remote.toml`) and chat targets exactly that bound agent — today Orca. **No multi-agent room** (Brett, 2026-08-14): katashiro's room / @mention / relay / loop-guard is explicitly **not a goal**. We take its chat primitives — rendering, markdown, turn controls — and leave the room machinery behind, so `remote.rs` and the panel stay one session with no routing layer (`resolveTargets`, `relayAgentReply`, the loop guard, batching-for-relays are all dropped). ## 5. Scope (now) -- ✅ **In (MVP):** the single bound agent; text single-turn streaming (`agent_message_chunk`); markdown render at finalize; stop / retry; batched prompt queue; autoscroll + jump pill; system/error rows; **persistent chat history + `session/resume`** on reconnect/restart. -- ⚠️ **Out (later slices):** the `tool_call` / thought `session/update` kinds (rendered as cards); a browser-tunnel status strip. +- ✅ **In (MVP):** the single bound agent; **single-shot text reply** (one terminal `agent_message_chunk`) behind a think-spinner; markdown render at finalize; stop / retry; batched prompt queue; autoscroll + jump pill; system/error rows; **persistent chat history + `session/resume`** on reconnect/restart. +- ⚠️ **Out (later slices):** a browser-tunnel status strip. +- ⛔ **Blocked on openab (upstream), not a Studio slice:** `tool_call` / thought rendering — the `/acp` adapter emits **neither** today (verified live: only `agent_message_chunk`; the `tool_call` schema is inert). Real **token streaming** is likewise upstream (openab emits one terminal chunk, `phase1`). Studio's render is already forward-compatible; these light up only after openab's `/acp` emits the frames. - 🚫 **Not a goal:** (a) the multi-agent room — @mention routing, agent→agent relay, loop guard (Brett, 2026-08-14); Studio chats one bound agent, there is no room to light up later. (b) **Accessibility** (screen-reader / `aria-live` / `role="log"`) — this is a single-operator internal console, so a11y is YAGNI. (Rationale is the single user, *not* "it's native": a11y is a webview-UI concern that would otherwise apply to a Tauri app just as it does to an extension.) ## 6. Consequences @@ -65,12 +67,18 @@ A new panel/tab in the vanilla-TS `console` (add `
` + a `#tab - ✅ Wire contract and UX are **copied from a working reference on the same gateway** — low protocol risk. - ⚠️ `remote.rs` gains real bidirectional state (a pending-request map, an outbound channel); "a method-less frame is always a handshake ack" is no longer true. - ⚠️ Rendering remote agent output via `innerHTML` reintroduces an XSS surface; the DOMPurify sink **plus** a Tauri-side CSP (not the MV3 manifest CSP katashiro uses) are load-bearing, and the `/acp` bearer should live in the Rust core, out of the webview's reach. +- ⚠️ The reply is **single-shot** (no live token stream) until openab's `/acp` streams — chat feels *submit → wait → answer*, so the think-spinner and a generous prompt timeout carry the UX, not incremental text. This is a gateway limitation, not a Studio one. -## 7. Open questions +### Resolved by review (Jellyfish, live, 2026-08-14) -1. **Facade routing** — does the gateway/facade route `session/prompt` to the bound agent (Orca) and stream `session/update` back, or is prompting gated behind a specific ACP capability? Confirm server-side (Jellyfish) before Part B lands. -2. **`session/update` taxonomy** — beyond `agent_message_chunk`, what kinds does the openab agent emit (thoughts, `tool_call` / `tool_call_update`)? Defines the phase-2 card model. -3. **Persistence store** — history + resume are **in MVP** (Brett); the open detail is *where* state lives. Leaning: the `sessionId` in the Rust core (`RemoteState` + a small on-disk file, out of the webview), and the transcript in a Tauri app-data store keyed by agent. Confirm the store choice when Part C lands. -4. **Threat model under Tauri** — where the `/acp` bearer lives relative to the webview, and what Studio CSP replaces katashiro's MV3 egress lock. +- **Facade routing — ✅ confirmed.** `session/prompt` routes to the bound agent, runs (incl. a `bash` tool call), and returns `{ stopReason: "end_turn" }`. Not gated behind a capability; Part B is viable. +- **`session/update` taxonomy — resolved.** openab emits **only** `agent_message_chunk`, a **single terminal** one; **zero** `tool_call` / thought frames (two live prompts, incl. a tool-forcing one). Folded into §2/§5 (streaming + cards are upstream-blocked). +- **`remote.rs` refactor — ✅ sound.** The mpsc + `select!` + `pendingReqs` id-correlation is correct and necessary (prompt results are method-less, same shape as handshake acks). -This is a direction-alignment ADR; implementation lands in slices (Part B backend, then Part C MVP panel), each verified against katashiro parity and the live gateway. +### Still open + +1. **`session/resume` server-side** — the resume-not-new handshake (§3) assumes the gateway honours `session/resume`; not yet live-verified. Confirm before the resume path lands. +2. **Persistence store** — history + resume are **in MVP** (Brett); open detail is *where* state lives. Leaning: `sessionId` in the Rust core (`RemoteState` + a small on-disk file, out of the webview), transcript in a Tauri app-data store keyed by agent. Confirm when Part C lands. +3. **Threat model under Tauri** — where the `/acp` bearer lives relative to the webview, and what Studio CSP replaces katashiro's MV3 egress lock. + +This is a direction-alignment ADR; implementation lands in slices (Part B backend — builders + mpsc + id-correlation, streaming-independent, can start first — then Part C MVP panel), each verified against the live gateway. From 85e060238879dec0e7c547281bd97ced4f832cdf Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 16:39:26 +0800 Subject: [PATCH 5/7] docs(adr): fold in session/resume live findings (Jellyfish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resume is honoured and preserves agent memory across reconnect (codeword recall verified). Two implementation requirements folded into §3: resume params must be {sessionId, cwd, mcpServers} (sessionId-only fails -32602 missing cwd), and oab mcpServers must be re-declared so the reverse-MCP tunnel re-attaches on the resumed session (else the agent keeps memory but loses Studio's oab tools). §7 moves session/resume from still-open to resolved-by-review. Co-Authored-By: Claude Opus 4.8 --- docs/adr/agent-chat-panel.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/agent-chat-panel.md b/docs/adr/agent-chat-panel.md index 50d61ae..0549089 100644 --- a/docs/adr/agent-chat-panel.md +++ b/docs/adr/agent-chat-panel.md @@ -36,7 +36,7 @@ Chat is **additive on the one live `/acp` session**, not a second connection. `s - `run_once` `tokio::select!`s between `read.next()` and `prompt_rx.recv()`; a received frame goes out through the existing private `send()` helper, so the WS `write` sink never leaves the task. - **The drop site becomes the chat hook** — the new `Inbound::AgentChunk` arm `app.emit("agent-update", …)`, the same emit/listen model as `remote-status`. - **Correctness — id correlation:** the read loop today treats *any* method-less frame as a handshake ack that advances the session phase. `session/prompt` responses are **also** method-less; they must be **correlated by request id** (a `pendingReqs` map, as katashiro does) so a prompt result never mis-drives the phase state machine. -- **Resume, not new:** persist the `sessionId`; on connect, drive `Session::resume()` (already in `acp-tunnel`) when we have one and fall back to `session/new` if the gateway rejects it (katashiro's pattern). This is what keeps the **agent's own** memory of the conversation continuous across reconnect/restart — not just the local scrollback. The `sessionId` lives in the Rust core (`RemoteState`/on disk), out of the webview's reach. +- **Resume, not new:** persist the `sessionId`; on connect, drive `Session::resume()` (already in `acp-tunnel`) when we have one and fall back to `session/new` if the gateway rejects it (katashiro's pattern). **Resume params are `{ sessionId, cwd, mcpServers }`** — verified live (Jellyfish): `{ sessionId }` alone fails `-32602 missing field cwd`, and the `oab` `mcpServers` **must be re-declared** so the reverse-MCP tunnel re-attaches on the resumed session — otherwise the agent remembers the conversation but **loses Studio's `oab` tools**. So resume keeps **both** the agent's memory *and* the tool tunnel continuous (confirmed: seed a codeword on one socket → close → resume on a fresh socket → the agent recalls it). The `sessionId` lives in the Rust core (`RemoteState`/on disk), out of the webview's reach. **`src-tauri/src/lib.rs`:** two `#[tauri::command]`s next to `remote_connect` — `agent_prompt` (push a `session/prompt` `Value` into the sender) and `agent_cancel` (push `session/cancel`) — taking `tauri::State<'_, remote::Remote>` (+ `AppHandle`), registered in the `invoke_handler!` list. @@ -74,11 +74,11 @@ A new panel/tab in the vanilla-TS `console` (add `
` + a `#tab - **Facade routing — ✅ confirmed.** `session/prompt` routes to the bound agent, runs (incl. a `bash` tool call), and returns `{ stopReason: "end_turn" }`. Not gated behind a capability; Part B is viable. - **`session/update` taxonomy — resolved.** openab emits **only** `agent_message_chunk`, a **single terminal** one; **zero** `tool_call` / thought frames (two live prompts, incl. a tool-forcing one). Folded into §2/§5 (streaming + cards are upstream-blocked). - **`remote.rs` refactor — ✅ sound.** The mpsc + `select!` + `pendingReqs` id-correlation is correct and necessary (prompt results are method-less, same shape as handshake acks). +- **`session/resume` — ✅ honoured, memory preserved across reconnect.** Seed a codeword on one socket → close → `session/resume` on a fresh socket → the agent recalls it. Requires `{ sessionId, cwd, mcpServers }` with `oab` re-declared (folded into §3). ### Still open -1. **`session/resume` server-side** — the resume-not-new handshake (§3) assumes the gateway honours `session/resume`; not yet live-verified. Confirm before the resume path lands. -2. **Persistence store** — history + resume are **in MVP** (Brett); open detail is *where* state lives. Leaning: `sessionId` in the Rust core (`RemoteState` + a small on-disk file, out of the webview), transcript in a Tauri app-data store keyed by agent. Confirm when Part C lands. -3. **Threat model under Tauri** — where the `/acp` bearer lives relative to the webview, and what Studio CSP replaces katashiro's MV3 egress lock. +1. **Persistence store** — history + resume are **in MVP** (Brett); open detail is *where* state lives. Leaning: `sessionId` in the Rust core (`RemoteState` + a small on-disk file, out of the webview), transcript in a Tauri app-data store keyed by agent. Confirm when Part C lands. +2. **Threat model under Tauri** — where the `/acp` bearer lives relative to the webview, and what Studio CSP replaces katashiro's MV3 egress lock. This is a direction-alignment ADR; implementation lands in slices (Part B backend — builders + mpsc + id-correlation, streaming-independent, can start first — then Part C MVP panel), each verified against the live gateway. From 23cb87c5994d5e21d56dcb7c4fd88adf7bd3570d Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 16:40:21 +0800 Subject: [PATCH 6/7] docs(adr): note Part-B-time resume edge checks (mid-turn resume, tunnel re-attach) Co-Authored-By: Claude Opus 4.8 --- docs/adr/agent-chat-panel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/agent-chat-panel.md b/docs/adr/agent-chat-panel.md index 0549089..2191147 100644 --- a/docs/adr/agent-chat-panel.md +++ b/docs/adr/agent-chat-panel.md @@ -36,7 +36,7 @@ Chat is **additive on the one live `/acp` session**, not a second connection. `s - `run_once` `tokio::select!`s between `read.next()` and `prompt_rx.recv()`; a received frame goes out through the existing private `send()` helper, so the WS `write` sink never leaves the task. - **The drop site becomes the chat hook** — the new `Inbound::AgentChunk` arm `app.emit("agent-update", …)`, the same emit/listen model as `remote-status`. - **Correctness — id correlation:** the read loop today treats *any* method-less frame as a handshake ack that advances the session phase. `session/prompt` responses are **also** method-less; they must be **correlated by request id** (a `pendingReqs` map, as katashiro does) so a prompt result never mis-drives the phase state machine. -- **Resume, not new:** persist the `sessionId`; on connect, drive `Session::resume()` (already in `acp-tunnel`) when we have one and fall back to `session/new` if the gateway rejects it (katashiro's pattern). **Resume params are `{ sessionId, cwd, mcpServers }`** — verified live (Jellyfish): `{ sessionId }` alone fails `-32602 missing field cwd`, and the `oab` `mcpServers` **must be re-declared** so the reverse-MCP tunnel re-attaches on the resumed session — otherwise the agent remembers the conversation but **loses Studio's `oab` tools**. So resume keeps **both** the agent's memory *and* the tool tunnel continuous (confirmed: seed a codeword on one socket → close → resume on a fresh socket → the agent recalls it). The `sessionId` lives in the Rust core (`RemoteState`/on disk), out of the webview's reach. +- **Resume, not new:** persist the `sessionId`; on connect, drive `Session::resume()` (already in `acp-tunnel`) when we have one and fall back to `session/new` if the gateway rejects it (katashiro's pattern). **Resume params are `{ sessionId, cwd, mcpServers }`** — verified live (Jellyfish): `{ sessionId }` alone fails `-32602 missing field cwd`, and the `oab` `mcpServers` **must be re-declared** so the reverse-MCP tunnel re-attaches on the resumed session — otherwise the agent remembers the conversation but **loses Studio's `oab` tools**. So resume keeps **both** the agent's memory *and* the tool tunnel continuous (confirmed: seed a codeword on one socket → close → resume on a fresh socket → the agent recalls it). The `sessionId` lives in the Rust core (`RemoteState`/on disk), out of the webview's reach. (Base semantics are confirmed; two edges to re-check when Part B wires resume for real — resuming after a *mid-turn* disconnect, and that the `oab` tunnel actually re-attaches post-resume.) **`src-tauri/src/lib.rs`:** two `#[tauri::command]`s next to `remote_connect` — `agent_prompt` (push a `session/prompt` `Value` into the sender) and `agent_cancel` (push `session/cancel`) — taking `tauri::State<'_, remote::Remote>` (+ `AppHandle`), registered in the `invoke_handler!` list. From 55ce25815620be4f279888b666dcd9d66e4b49ee Mon Sep 17 00:00:00 2001 From: brettchien Date: Fri, 14 Aug 2026 16:43:52 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs(adr):=20mark=20Accepted=20=E2=80=94=20?= =?UTF-8?q?Jellyfish=20review-gate=20approved,=20Brett=20greenlit=20impl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/adr/agent-chat-panel.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/agent-chat-panel.md b/docs/adr/agent-chat-panel.md index 2191147..f8706b2 100644 --- a/docs/adr/agent-chat-panel.md +++ b/docs/adr/agent-chat-panel.md @@ -1,6 +1,6 @@ # ADR: Chatting with a connected agent — `session/prompt` over the live `/acp` socket (Part C) -- **Status:** Proposed +- **Status:** Accepted (2026-08-14) — review-gate APPROVED by Jellyfish (live server-side eval); implementation greenlit by Brett. - **Date:** 2026-08-14 - **Author:** Orca (`ecs-claude`) - **Extends:** [Fleet grouping & connection model](./fleet-grouping-and-connection-model.md) Part B (reverse MCP-over-ACP); [agent connection guide](../connecting-an-agent.md)