From bc74b7deafc1b2f2cdb072377434a93d39071d80 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Thu, 23 Jul 2026 20:55:11 +0800 Subject: [PATCH 01/14] Design WTA CLI terminal action proposals Define a typed, helper-bound CLI proposal flow that preserves existing card confirmation without reintroducing MCP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d998a7af-5b49-496b-8b59-ea48f40e50c8 --- .../WTA-CLI-terminal-action-proposals.md | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 doc/specs/WTA-CLI-terminal-action-proposals.md diff --git a/doc/specs/WTA-CLI-terminal-action-proposals.md b/doc/specs/WTA-CLI-terminal-action-proposals.md new file mode 100644 index 0000000000..09458905c5 --- /dev/null +++ b/doc/specs/WTA-CLI-terminal-action-proposals.md @@ -0,0 +1,352 @@ +# WTA CLI terminal-action proposals + +## Status + +Proposed design. This document supersedes the MCP-based proposal work in +PR #428. MCP is not part of the repository architecture and is not a transport +option for this feature. + +## Summary + +Autofix and Terminal Agent currently turn model-authored JSON in assistant text +into recommendation cards. This works, but card creation depends on extracting +and validating structured data from a streamed chat response. + +The replacement keeps the existing card and execution pipeline while moving +proposal submission to a WTA CLI contract: + +```text +wta propose-terminal-actions --payload-base64 +``` + +The command is proposal-only. It cannot send input, create panes, launch +delegates, or otherwise mutate Windows Terminal. A valid proposal becomes a +local recommendation card; the existing card confirmation remains the only +path to terminal mutation. + +## Goals + +- Use WTA CLI, not MCP, for typed terminal-action proposals. +- Share one versioned wire schema between Autofix and Terminal Agent. +- Keep session, helper, tab, window, and pane identifiers out of model-authored + payloads. +- Reject malformed, stale, duplicate, or wrong-origin proposals without side + effects. +- Preserve the existing Run, Insert, Open, Split, and Delegate confirmation UI. +- Preserve one user confirmation between proposal and terminal mutation. +- Keep the current assistant-text JSON path as a compatibility fallback until + each built-in agent proves reliable CLI and permission behavior. + +## Non-goals + +- Reintroducing an MCP server, MCP route, or MCP dependency. +- Letting the proposal CLI execute terminal actions directly. +- Treating model-authored shell text as trusted or intrinsically + non-destructive. +- Supporting arbitrary shell-wrapped proposal invocations in v1. +- Replacing the existing `wtcli` to COM execution path. +- Solving delegate startup latency tracked by #445. + +## Current flow + +Today both features submit a prompt over ACP and parse the streamed assistant +message: + +```text +shell failure or user prompt + -> wta-helper builds terminal context + -> helper -> master -> agent CLI over ACP + -> assistant message chunks + -> parse_autofix_response / parse_recommendation_set + -> RecommendationSet + -> recommendation card + -> user confirms + -> recommendation executor + -> ShellManager -> wtcli -> COM IProtocolServer -> Windows Terminal +``` + +The execution half is already the desired trust boundary. This proposal changes +only how a typed `RecommendationSet` reaches the helper. + +## Proposed flow + +```text +1. WTA submits an Autofix or Terminal Agent prompt over ACP. +2. The agent requests create_terminal for direct argv: + command = "wta" + args = ["propose-terminal-actions", "--payload-base64", "..."] +3. wta-master routes create_terminal by ACP session id to the owning helper. +4. The helper recognizes the reserved direct WTA subcommand. +5. The helper mints a short-lived, one-use capability bound to the active + session and prompt, injects its private pipe name and capability into the + child environment, and launches the co-located wta.exe locally. +6. The CLI decodes the versioned payload and submits it over the helper-local + pipe. +7. The helper validates the wire schema. App performs the authoritative + active-turn, generation, origin, target, and duplicate checks. +8. App converts the accepted wire proposal into the existing + RecommendationSet and displays the existing confirmation card. +9. The CLI returns a structured "presented" disposition. This means the card + was shown; it does not mean an action ran. +10. Only a later user card confirmation sends ChoiceExecution to the existing + executor and reaches wtcli/COM. +``` + +The master does not host the proposal endpoint. Its only role in this flow is +the existing ACP `session_id -> helper` routing for `create_terminal`. + +## CLI contract + +### Invocation + +v1 accepts only a direct structured ACP terminal request: + +```text +command: wta or wta.exe +args: + - propose-terminal-actions + - --payload-base64 + - +``` + +The helper rewrites the executable to its own trusted, co-located `wta.exe` +before spawning. The payload has a decoded size limit. + +v1 deliberately does not support: + +- `pwsh -Command "wta propose-terminal-actions ..."` +- `cmd /c wta propose-terminal-actions ...` +- `bash -lc "wta propose-terminal-actions ..."` +- stdin or shell pipelines +- a model-provided proposal pipe, token, session id, or target id + +These forms either lose the structured argv boundary or bypass the current +`ShellManager` direct-WTA local execution rule. + +### Output + +For every protocol-complete request, stdout contains exactly one compact JSON +object: + +```json +{"schema_version":1,"status":"presented"} +``` + +Defined statuses: + +| Status | Meaning | +|---|---| +| `presented` | The proposal was accepted and a card was displayed. No action has run. | +| `duplicate` | This active prompt already surfaced an equivalent proposal. | +| `stale` | The prompt, Autofix generation, or target context is no longer active. | +| `rejected` | Schema or origin policy rejected the proposal. | +| `unavailable` | The helper proposal channel or required target context is unavailable. | + +Protocol-complete dispositions exit with code 0 so agents do not retry rejected +or stale proposals as transport failures. Nonzero exit codes are reserved for +invalid CLI syntax, undecodable payloads, broken local transport, or internal +failures. + +stderr is diagnostic-only. The implementation must stop merging proposal +stdout and stderr before the agent consumes the result. + +## Wire schema + +The public CLI schema is separate from the internal `RecommendationSet`: + +```json +{ + "schema_version": 1, + "origin": "terminal_agent", + "recommended_choice": 1, + "choices": [ + { + "choice": 1, + "title": "Run tests", + "rationale": "Uses the active shell and working directory.", + "actions": [ + { + "type": "send_input", + "input": "cargo test" + } + ] + } + ] +} +``` + +The wire types use `deny_unknown_fields`, explicit size/count limits, and +hand-written conversion to internal types. They never accept: + +- ACP session ids +- helper ids +- window, tab, or pane ids +- proposal pipe names or capability tokens +- arbitrary executable paths + +Supported actions: + +- `send_input` +- `open` +- `open_and_send` + +`open` and `open_and_send` may describe `tab` or `panel`, cwd, title, profile, +direction, and whether the destination is the configured delegate. The helper +injects the real parent pane and resolves the configured delegate runtime. + +## Trusted binding and freshness + +The helper removes reserved proposal environment variables +case-insensitively, then injects: + +- a cryptographically random helper-local pipe name; +- a cryptographically random, one-use capability; +- no reusable session, tab, window, or pane credential. + +Each capability is stored in a bounded map with a short TTL and is bound to: + +- ACP session id from `create_terminal`; +- the helper's active prompt id; +- proposal origin; +- the owning helper. + +The capability is consumed on first submission. Unused entries expire. + +App remains authoritative for state that the ACP client does not own: + +- current `TurnState`; +- Autofix generation; +- failing pane recorded by `AutofixContext`; +- active pane captured for a Terminal Agent prompt; +- whether a recommendation already surfaced; +- configured delegate availability. + +The shared agent process can still send a `create_terminal` request containing +another live ACP session id. The capability does not turn the shared agent into +a security boundary. Freshness checks prevent unsolicited or stale cards, pane +targets are injected locally, and explicit card confirmation remains the final +security boundary. + +## Origin policies + +### Terminal Agent + +- One to three ordered choices. +- `send_input` targets the active pane captured for the prompt. +- Panel actions use that same captured pane as parent. +- Delegate actions resolve only to the configured, policy-allowed delegate. +- Existing target availability and coordinator-self-target checks remain. + +### Autofix + +- Exactly one choice with exactly one `send_input` action. +- The target is always the failing pane from `AutofixContext`; the payload + cannot override it. +- No open, split, or delegate actions. +- The Autofix generation must still match when the proposal arrives. +- Ambiguous, destructive, multi-step, or explanatory outcomes remain normal + Markdown. + +The validator cannot prove that arbitrary shell text is non-destructive. The +prompt narrows eligible fixes, the card displays the command, and the user +confirmation controls execution. + +## Permission and single-confirmation requirement + +Calling the proposal CLI is non-mutating, but some agents may issue an ACP +`request_permission` before `create_terminal`. The current permission request's +human-readable title is agent-authored and cannot be used for safe +auto-approval. + +Therefore CLI proposal mode is enabled per built-in agent only after live +verification proves one of: + +1. the agent does not request permission for this exact direct WTA command; +2. the permission request exposes trustworthy structured command/argv data + that WTA can match to its co-located executable and reserved subcommand; or +3. the agent has an official command allowlist that can permit only this + proposal subcommand. + +If none applies, that agent stays on assistant-text JSON fallback. Shipping a +permission confirmation followed by a card confirmation is not acceptable. + +## Local transport + +The proposal channel is a per-helper local named pipe, not COM and not the +helper-master ACP pipe. + +Required hardening: + +- random, unguessable pipe name; +- first-instance creation; +- DACL restricted to the current user; +- one-use capability required before payload processing; +- bounded payload, capability map, and TTL; +- one response per connection; +- no terminal-operation methods on the pipe. + +Using the helper-master pipe would require the short-lived CLI to impersonate a +helper or extend the ACP multiplexer with a non-ACP protocol. COM events would +unnecessarily expose proposal routing to the WT process and weaken +session/helper ownership. + +## Compatibility and rollout + +### Phase 1: contract only + +- Add versioned wire types, limits, validators, and conversion tests. +- Add the CLI parser and stable disposition schema behind a feature gate. +- Keep assistant-text JSON as the only live card source. + +### Phase 2: helper-local transport + +- Add the hardened per-helper proposal pipe and capability registry. +- Recognize only direct WTA proposal invocations. +- Add AppEvent plus oneshot response plumbing. +- Surface Terminal Agent cards through the existing `TurnState`. +- Keep the JSON fallback enabled. + +### Phase 3: agent compatibility + +- Verify direct invocation and permission behavior for every built-in agent. +- Enable CLI proposals only for agents that preserve single confirmation. +- Suppress the internal proposal command's ToolCall row only after it is + positively identified from the helper-owned invocation. + +### Phase 4: Autofix + +- Reuse the same wire schema and transport with Autofix policy. +- Bind the action to the recorded failing pane and generation. +- Verify stashed, split-pane, tab-switch, and stale-response behavior. + +### Phase 5: fallback decision + +- Measure CLI proposal success and fallback use by canonical agent id. +- Remove assistant-text card parsing only after all supported agents reach + parity. Markdown explanations remain unchanged. + +## Validation + +Focused automated coverage must include: + +- wire round-trip and schema-version rejection; +- unknown-field, payload-size, choice-count, and action-count limits; +- origin-specific policy rejection; +- direct invocation matching and shell-wrapper rejection; +- trusted executable rewriting; +- case-insensitive reserved environment stripping; +- capability one-use, TTL, bounded-map, and wrong-pipe rejection; +- current prompt, generation, target, duplicate, and stale checks; +- no model-authored pane/session/helper identifiers; +- stdout/stderr separation and disposition exit behavior; +- one visible card and one execution after confirmation; +- no terminal mutation before confirmation; +- JSON fallback for agents without CLI proposal support; +- multi-tab, multi-window, stashed-pane, and session-load routing; +- per-agent live permission behavior. + +## Related work + +- PR #428: superseded MCP-based implementation. +- Issue #445: delegate new-tab creation latency after card confirmation. The + execution problem remains valid but is independent of proposal transport. From f1daca52676e531316fb0675ab474580b8e323dc Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Thu, 23 Jul 2026 22:06:38 +0800 Subject: [PATCH 02/14] Route CLI proposals through master Make agent sessions execute WTA directly, then use a prompt-scoped route token and the existing master ACP pipe to reach the owning helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d998a7af-5b49-496b-8b59-ea48f40e50c8 --- .../WTA-CLI-terminal-action-proposals.md | 203 ++++++++++-------- 1 file changed, 112 insertions(+), 91 deletions(-) diff --git a/doc/specs/WTA-CLI-terminal-action-proposals.md b/doc/specs/WTA-CLI-terminal-action-proposals.md index 09458905c5..49e8e460a7 100644 --- a/doc/specs/WTA-CLI-terminal-action-proposals.md +++ b/doc/specs/WTA-CLI-terminal-action-proposals.md @@ -16,7 +16,7 @@ The replacement keeps the existing card and execution pipeline while moving proposal submission to a WTA CLI contract: ```text -wta propose-terminal-actions --payload-base64 +wta propose-terminal-actions --route ``` The command is proposal-only. It cannot send input, create panes, launch @@ -27,6 +27,8 @@ path to terminal mutation. ## Goals - Use WTA CLI, not MCP, for typed terminal-action proposals. +- Expect the agent session to execute the WTA CLI directly. +- Reuse the existing short-lived CLI-to-master ACP connection pattern. - Share one versioned wire schema between Autofix and Terminal Agent. - Keep session, helper, tab, window, and pane identifiers out of model-authored payloads. @@ -41,9 +43,9 @@ path to terminal mutation. - Reintroducing an MCP server, MCP route, or MCP dependency. - Letting the proposal CLI execute terminal actions directly. +- Relying on ACP `create_terminal` to proxy the WTA CLI through the helper. - Treating model-authored shell text as trusted or intrinsically non-destructive. -- Supporting arbitrary shell-wrapped proposal invocations in v1. - Replacing the existing `wtcli` to COM execution path. - Solving delegate startup latency tracked by #445. @@ -71,57 +73,69 @@ only how a typed `RecommendationSet` reaches the helper. ## Proposed flow ```text -1. WTA submits an Autofix or Terminal Agent prompt over ACP. -2. The agent requests create_terminal for direct argv: - command = "wta" - args = ["propose-terminal-actions", "--payload-base64", "..."] -3. wta-master routes create_terminal by ACP session id to the owning helper. -4. The helper recognizes the reserved direct WTA subcommand. -5. The helper mints a short-lived, one-use capability bound to the active - session and prompt, injects its private pipe name and capability into the - child environment, and launches the co-located wta.exe locally. -6. The CLI decodes the versioned payload and submits it over the helper-local - pipe. -7. The helper validates the wire schema. App performs the authoritative - active-turn, generation, origin, target, and duplicate checks. -8. App converts the accepted wire proposal into the existing - RecommendationSet and displays the existing confirmation card. -9. The CLI returns a structured "presented" disposition. This means the card - was shown; it does not mean an action ran. +1. The helper sends an Autofix or Terminal Agent prompt to wta-master over ACP. +2. Master already knows the source HelperId and ACP session id. It increments + that session's turn generation, mints a short-lived one-use route token, and + stores token -> {session id, helper id, generation, expiry}. +3. Master injects the opaque token and CLI instruction into the prompt, then + forwards the prompt to the agent CLI over ACP stdio. +4. The agent session directly executes `wta propose-terminal-actions`, passing + the token and versioned proposal JSON. +5. The short-lived WTA CLI discovers and connects to the existing master named + pipe, performs an ACP initialize, and sends a WTA ExtRequest. +6. Master atomically consumes the token, derives the owning session/helper, and + forwards the proposal to that helper as an ExtNotification containing the + trusted session id. +7. The helper routes by session id. App performs the authoritative active-turn, + Autofix-generation, origin, target, and duplicate checks. +8. App converts an accepted wire proposal into the existing RecommendationSet, + displays the existing confirmation card, and immediately sends a correlated + disposition ExtRequest back to master. +9. Master resolves the pending CLI request. The CLI prints "presented" and + exits, unblocking the agent's command tool. This does not mean an action ran. 10. Only a later user card confirmation sends ChoiceExecution to the existing executor and reaches wtcli/COM. ``` -The master does not host the proposal endpoint. Its only role in this flow is -the existing ACP `session_id -> helper` routing for `create_terminal`. +No new server is introduced. The proposal command reuses the master named pipe, +ACP handshake, and ExtRequest mechanism already used by `wta sessions list`. ## CLI contract ### Invocation -v1 accepts only a direct structured ACP terminal request: +The agent session executes: ```text -command: wta or wta.exe -args: - - propose-terminal-actions - - --payload-base64 - - +wta propose-terminal-actions --route ``` -The helper rewrites the executable to its own trusted, co-located `wta.exe` -before spawning. The payload has a decoded size limit. +The CLI reads one UTF-8 JSON proposal from stdin. Agents whose command tools +cannot provide stdin may use: -v1 deliberately does not support: +```text +wta propose-terminal-actions --route --payload-file +``` + +The model must not base64-encode the payload. Payload size is capped before +deserialization. Shell wrapping is allowed when required by the agent's native +command tool, but each built-in agent must prove its quoting and stdin/file +behavior before CLI proposal mode is enabled. + +The route token is the only routing input. The CLI does not accept a +model-provided master pipe, session id, helper id, or target id. + +### Master discovery -- `pwsh -Command "wta propose-terminal-actions ..."` -- `cmd /c wta propose-terminal-actions ...` -- `bash -lc "wta propose-terminal-actions ..."` -- stdin or shell pipelines -- a model-provided proposal pipe, token, session id, or target id +The CLI resolves master in this order: -These forms either lose the structured argv boundary or bypass the current -`ShellManager` direct-WTA local execution rule. +1. `WTA_MASTER_PIPE`, set by master on the agent process so direct child + commands inherit the exact pipe; +2. the existing package-private `master-pipe.txt` discovery file used by + `wta sessions list`. + +The route token still must validate on the connected master. A stale discovery +file therefore fails closed instead of routing to another helper. ### Output @@ -140,15 +154,14 @@ Defined statuses: | `duplicate` | This active prompt already surfaced an equivalent proposal. | | `stale` | The prompt, Autofix generation, or target context is no longer active. | | `rejected` | Schema or origin policy rejected the proposal. | -| `unavailable` | The helper proposal channel or required target context is unavailable. | +| `unavailable` | The master/helper route or required target context is unavailable. | Protocol-complete dispositions exit with code 0 so agents do not retry rejected or stale proposals as transport failures. Nonzero exit codes are reserved for -invalid CLI syntax, undecodable payloads, broken local transport, or internal +invalid CLI syntax, unreadable payloads, broken master transport, or internal failures. -stderr is diagnostic-only. The implementation must stop merging proposal -stdout and stderr before the agent consumes the result. +stderr is diagnostic-only; stdout contains only the disposition object. ## Wire schema @@ -196,21 +209,18 @@ injects the real parent pane and resolves the configured delegate runtime. ## Trusted binding and freshness -The helper removes reserved proposal environment variables -case-insensitively, then injects: - -- a cryptographically random helper-local pipe name; -- a cryptographically random, one-use capability; -- no reusable session, tab, window, or pane credential. - -Each capability is stored in a bounded map with a short TTL and is bound to: +Master mints the route token while handling the helper-originated prompt, where +it already has the source HelperId and ACP session id. The token registry is +bounded and each entry contains: -- ACP session id from `create_terminal`; -- the helper's active prompt id; -- proposal origin; -- the owning helper. +- ACP session id; +- source HelperId; +- master-owned per-session turn generation; +- expiry. -The capability is consumed on first submission. Unused entries expire. +Starting a newer prompt invalidates the previous token for that session. The +token is consumed atomically on the first proposal submission. The CLI payload +contains no routing identifiers. App remains authoritative for state that the ACP client does not own: @@ -221,11 +231,10 @@ App remains authoritative for state that the ACP client does not own: - whether a recommendation already surfaced; - configured delegate availability. -The shared agent process can still send a `create_terminal` request containing -another live ACP session id. The capability does not turn the shared agent into -a security boundary. Freshness checks prevent unsolicited or stale cards, pane -targets are injected locally, and explicit card confirmation remains the final -security boundary. +The token is visible to the agent session and may be retained in its transcript. +One-use, short expiry, per-turn invalidation, and master-owned routing limit its +authority to proposing one card for the session that received it. Explicit card +confirmation remains the final security boundary. ## Origin policies @@ -253,42 +262,47 @@ confirmation controls execution. ## Permission and single-confirmation requirement -Calling the proposal CLI is non-mutating, but some agents may issue an ACP -`request_permission` before `create_terminal`. The current permission request's -human-readable title is agent-authored and cannot be used for safe -auto-approval. +Calling the proposal CLI is non-mutating, but some agents may request permission +before directly executing the command. The human-readable tool-call title is +agent-authored and cannot be used for safe auto-approval. Therefore CLI proposal mode is enabled per built-in agent only after live verification proves one of: 1. the agent does not request permission for this exact direct WTA command; -2. the permission request exposes trustworthy structured command/argv data - that WTA can match to its co-located executable and reserved subcommand; or +2. the agent exposes trustworthy structured command/argv data that can be + matched to the WTA proposal subcommand; or 3. the agent has an official command allowlist that can permit only this proposal subcommand. If none applies, that agent stays on assistant-text JSON fallback. Shipping a permission confirmation followed by a card confirmation is not acceptable. -## Local transport +## CLI-to-master transport + +The proposal CLI follows the existing `wta sessions list` connection shape: + +1. resolve and open the master named pipe; +2. initialize as a short-lived ACP client named `wta-proposal`; +3. send `_intellterm.wta/terminal_actions/propose` with the route token and + versioned payload; +4. wait for a bounded structured response and disconnect. -The proposal channel is a per-helper local named pipe, not COM and not the -helper-master ACP pipe. +Master recognizes `wta-proposal` during initialize and does not bind/spawn an +agent or register the connection as a helper live-set subscriber. -Required hardening: +After validating the token, master sends the target helper an ExtNotification +containing `{proposal_id, session_id, payload}`. The helper immediately returns +`_intellterm.wta/terminal_actions/result` with the proposal id and disposition. +The result acknowledges that the card was presented or rejected; it never waits +for user confirmation, which would deadlock the in-flight agent tool and prompt. -- random, unguessable pipe name; -- first-instance creation; -- DACL restricted to the current user; -- one-use capability required before payload processing; -- bounded payload, capability map, and TTL; -- one response per connection; -- no terminal-operation methods on the pipe. +Master keeps a bounded pending-response map keyed by proposal id and tagged with +the target HelperId. Helper disconnect, timeout, or master shutdown resolves +pending CLI requests as unavailable. Late or duplicate results are ignored. -Using the helper-master pipe would require the short-lived CLI to impersonate a -helper or extend the ACP multiplexer with a non-ACP protocol. COM events would -unnecessarily expose proposal routing to the WT process and weaken -session/helper ownership. +This reuses the existing ACP pipe and WTA extension namespace. It does not use +COM, MCP, or a second local server. ## Compatibility and rollout @@ -298,20 +312,25 @@ session/helper ownership. - Add the CLI parser and stable disposition schema behind a feature gate. - Keep assistant-text JSON as the only live card source. -### Phase 2: helper-local transport +### Phase 2: direct CLI routing -- Add the hardened per-helper proposal pipe and capability registry. -- Recognize only direct WTA proposal invocations. -- Add AppEvent plus oneshot response plumbing. +- Inject master-owned turn route tokens while forwarding prompts. +- Reuse the existing CLI-to-master ACP connection and add proposal/result + extension methods. +- Add the bounded token and pending-response registries. +- Add AppEvent plus immediate disposition plumbing. - Surface Terminal Agent cards through the existing `TurnState`. - Keep the JSON fallback enabled. ### Phase 3: agent compatibility -- Verify direct invocation and permission behavior for every built-in agent. +- Verify direct process execution, payload delivery, and permission behavior for + every built-in agent. - Enable CLI proposals only for agents that preserve single confirmation. -- Suppress the internal proposal command's ToolCall row only after it is - positively identified from the helper-owned invocation. +- Suppress the internal proposal command's ToolCall row only when the agent + exposes a trustworthy structured identity for that invocation. +- Keep the JSON fallback for WSL-hosted agents unless their WTA command can + reach the Windows master pipe. ### Phase 4: Autofix @@ -332,13 +351,15 @@ Focused automated coverage must include: - wire round-trip and schema-version rejection; - unknown-field, payload-size, choice-count, and action-count limits; - origin-specific policy rejection; -- direct invocation matching and shell-wrapper rejection; -- trusted executable rewriting; -- case-insensitive reserved environment stripping; -- capability one-use, TTL, bounded-map, and wrong-pipe rejection; +- master pipe environment/discovery fallback and stale-master rejection; +- stdin and payload-file handling across supported agent command tools; +- capability one-use, TTL, per-turn invalidation, and bounded-map behavior; +- short-lived proposal clients do not spawn agents or register as helpers; +- proposal/result correlation, timeout, helper disconnect, and late-result handling; - current prompt, generation, target, duplicate, and stale checks; - no model-authored pane/session/helper identifiers; - stdout/stderr separation and disposition exit behavior; +- immediate proposal acknowledgement does not deadlock the in-flight turn; - one visible card and one execution after confirmation; - no terminal mutation before confirmation; - JSON fallback for agents without CLI proposal support; From 78eabb62cc2518c2a81beace7a8017dd2d26bae0 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Fri, 24 Jul 2026 17:30:18 +0800 Subject: [PATCH 03/14] Route terminal action proposals directly to helpers Replace master token routing with per-Helper proposal channels, canonical silent AllowOnce handling, secure direct named pipes, and two-phase validation/final feedback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d998a7af-5b49-496b-8b59-ea48f40e50c8 --- .../WTA-CLI-terminal-action-proposals.md | 717 ++++---- tools/wta/Cargo.lock | 1 + tools/wta/Cargo.toml | 1 + tools/wta/prompts/auto-fix.md | 11 + tools/wta/prompts/terminal-agent.md | 12 + tools/wta/src/app.rs | 1501 +++++++++++++---- tools/wta/src/cli_tests.rs | 91 +- tools/wta/src/coordinator.rs | 2 +- tools/wta/src/main.rs | 308 +++- tools/wta/src/named_pipe_security.rs | 122 ++ tools/wta/src/proposal_channel.rs | 710 ++++++++ tools/wta/src/proposal_invocation.rs | 138 ++ tools/wta/src/proposal_pipe.rs | 513 ++++++ tools/wta/src/protocol/acp/client.rs | 788 ++++++--- .../wta/src/protocol/acp/mock_agent_tests.rs | 7 +- tools/wta/src/protocol/acp/prompt_context.rs | 16 +- tools/wta/src/protocol/acp/spawn.rs | 42 +- tools/wta/src/terminal_action_proposal.rs | 739 ++++++++ 18 files changed, 4750 insertions(+), 969 deletions(-) create mode 100644 tools/wta/src/named_pipe_security.rs create mode 100644 tools/wta/src/proposal_channel.rs create mode 100644 tools/wta/src/proposal_invocation.rs create mode 100644 tools/wta/src/proposal_pipe.rs create mode 100644 tools/wta/src/terminal_action_proposal.rs diff --git a/doc/specs/WTA-CLI-terminal-action-proposals.md b/doc/specs/WTA-CLI-terminal-action-proposals.md index 49e8e460a7..5803aa07f8 100644 --- a/doc/specs/WTA-CLI-terminal-action-proposals.md +++ b/doc/specs/WTA-CLI-terminal-action-proposals.md @@ -1,373 +1,344 @@ -# WTA CLI terminal-action proposals - -## Status - -Proposed design. This document supersedes the MCP-based proposal work in -PR #428. MCP is not part of the repository architecture and is not a transport -option for this feature. - -## Summary - -Autofix and Terminal Agent currently turn model-authored JSON in assistant text -into recommendation cards. This works, but card creation depends on extracting -and validating structured data from a streamed chat response. - -The replacement keeps the existing card and execution pipeline while moving -proposal submission to a WTA CLI contract: - -```text -wta propose-terminal-actions --route -``` - -The command is proposal-only. It cannot send input, create panes, launch -delegates, or otherwise mutate Windows Terminal. A valid proposal becomes a -local recommendation card; the existing card confirmation remains the only -path to terminal mutation. - -## Goals - -- Use WTA CLI, not MCP, for typed terminal-action proposals. -- Expect the agent session to execute the WTA CLI directly. -- Reuse the existing short-lived CLI-to-master ACP connection pattern. -- Share one versioned wire schema between Autofix and Terminal Agent. -- Keep session, helper, tab, window, and pane identifiers out of model-authored - payloads. -- Reject malformed, stale, duplicate, or wrong-origin proposals without side - effects. -- Preserve the existing Run, Insert, Open, Split, and Delegate confirmation UI. -- Preserve one user confirmation between proposal and terminal mutation. -- Keep the current assistant-text JSON path as a compatibility fallback until - each built-in agent proves reliable CLI and permission behavior. - -## Non-goals - -- Reintroducing an MCP server, MCP route, or MCP dependency. -- Letting the proposal CLI execute terminal actions directly. -- Relying on ACP `create_terminal` to proxy the WTA CLI through the helper. -- Treating model-authored shell text as trusted or intrinsically - non-destructive. -- Replacing the existing `wtcli` to COM execution path. -- Solving delegate startup latency tracked by #445. - -## Current flow - -Today both features submit a prompt over ACP and parse the streamed assistant -message: - -```text -shell failure or user prompt - -> wta-helper builds terminal context - -> helper -> master -> agent CLI over ACP - -> assistant message chunks - -> parse_autofix_response / parse_recommendation_set - -> RecommendationSet - -> recommendation card - -> user confirms - -> recommendation executor - -> ShellManager -> wtcli -> COM IProtocolServer -> Windows Terminal -``` - -The execution half is already the desired trust boundary. This proposal changes -only how a typed `RecommendationSet` reaches the helper. - -## Proposed flow - -```text -1. The helper sends an Autofix or Terminal Agent prompt to wta-master over ACP. -2. Master already knows the source HelperId and ACP session id. It increments - that session's turn generation, mints a short-lived one-use route token, and - stores token -> {session id, helper id, generation, expiry}. -3. Master injects the opaque token and CLI instruction into the prompt, then - forwards the prompt to the agent CLI over ACP stdio. -4. The agent session directly executes `wta propose-terminal-actions`, passing - the token and versioned proposal JSON. -5. The short-lived WTA CLI discovers and connects to the existing master named - pipe, performs an ACP initialize, and sends a WTA ExtRequest. -6. Master atomically consumes the token, derives the owning session/helper, and - forwards the proposal to that helper as an ExtNotification containing the - trusted session id. -7. The helper routes by session id. App performs the authoritative active-turn, - Autofix-generation, origin, target, and duplicate checks. -8. App converts an accepted wire proposal into the existing RecommendationSet, - displays the existing confirmation card, and immediately sends a correlated - disposition ExtRequest back to master. -9. Master resolves the pending CLI request. The CLI prints "presented" and - exits, unblocking the agent's command tool. This does not mean an action ran. -10. Only a later user card confirmation sends ChoiceExecution to the existing - executor and reaches wtcli/COM. -``` - -No new server is introduced. The proposal command reuses the master named pipe, -ACP handshake, and ExtRequest mechanism already used by `wta sessions list`. - -## CLI contract - -### Invocation - -The agent session executes: - -```text -wta propose-terminal-actions --route -``` - -The CLI reads one UTF-8 JSON proposal from stdin. Agents whose command tools -cannot provide stdin may use: - -```text -wta propose-terminal-actions --route --payload-file -``` - -The model must not base64-encode the payload. Payload size is capped before -deserialization. Shell wrapping is allowed when required by the agent's native -command tool, but each built-in agent must prove its quoting and stdin/file -behavior before CLI proposal mode is enabled. - -The route token is the only routing input. The CLI does not accept a -model-provided master pipe, session id, helper id, or target id. - -### Master discovery - -The CLI resolves master in this order: - -1. `WTA_MASTER_PIPE`, set by master on the agent process so direct child - commands inherit the exact pipe; -2. the existing package-private `master-pipe.txt` discovery file used by - `wta sessions list`. - -The route token still must validate on the connected master. A stale discovery -file therefore fails closed instead of routing to another helper. - -### Output - -For every protocol-complete request, stdout contains exactly one compact JSON -object: - -```json -{"schema_version":1,"status":"presented"} -``` - -Defined statuses: - -| Status | Meaning | -|---|---| -| `presented` | The proposal was accepted and a card was displayed. No action has run. | -| `duplicate` | This active prompt already surfaced an equivalent proposal. | -| `stale` | The prompt, Autofix generation, or target context is no longer active. | -| `rejected` | Schema or origin policy rejected the proposal. | -| `unavailable` | The master/helper route or required target context is unavailable. | - -Protocol-complete dispositions exit with code 0 so agents do not retry rejected -or stale proposals as transport failures. Nonzero exit codes are reserved for -invalid CLI syntax, unreadable payloads, broken master transport, or internal -failures. - -stderr is diagnostic-only; stdout contains only the disposition object. - -## Wire schema - -The public CLI schema is separate from the internal `RecommendationSet`: - -```json -{ - "schema_version": 1, - "origin": "terminal_agent", - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Run tests", - "rationale": "Uses the active shell and working directory.", - "actions": [ - { - "type": "send_input", - "input": "cargo test" - } - ] - } - ] -} -``` - -The wire types use `deny_unknown_fields`, explicit size/count limits, and -hand-written conversion to internal types. They never accept: - -- ACP session ids -- helper ids -- window, tab, or pane ids -- proposal pipe names or capability tokens -- arbitrary executable paths - -Supported actions: - -- `send_input` -- `open` -- `open_and_send` - -`open` and `open_and_send` may describe `tab` or `panel`, cwd, title, profile, -direction, and whether the destination is the configured delegate. The helper -injects the real parent pane and resolves the configured delegate runtime. - -## Trusted binding and freshness - -Master mints the route token while handling the helper-originated prompt, where -it already has the source HelperId and ACP session id. The token registry is -bounded and each entry contains: - -- ACP session id; -- source HelperId; -- master-owned per-session turn generation; -- expiry. - -Starting a newer prompt invalidates the previous token for that session. The -token is consumed atomically on the first proposal submission. The CLI payload -contains no routing identifiers. - -App remains authoritative for state that the ACP client does not own: - -- current `TurnState`; -- Autofix generation; -- failing pane recorded by `AutofixContext`; -- active pane captured for a Terminal Agent prompt; -- whether a recommendation already surfaced; -- configured delegate availability. - -The token is visible to the agent session and may be retained in its transcript. -One-use, short expiry, per-turn invalidation, and master-owned routing limit its -authority to proposing one card for the session that received it. Explicit card -confirmation remains the final security boundary. - -## Origin policies - -### Terminal Agent - -- One to three ordered choices. -- `send_input` targets the active pane captured for the prompt. -- Panel actions use that same captured pane as parent. -- Delegate actions resolve only to the configured, policy-allowed delegate. -- Existing target availability and coordinator-self-target checks remain. - -### Autofix - -- Exactly one choice with exactly one `send_input` action. -- The target is always the failing pane from `AutofixContext`; the payload - cannot override it. -- No open, split, or delegate actions. -- The Autofix generation must still match when the proposal arrives. -- Ambiguous, destructive, multi-step, or explanatory outcomes remain normal - Markdown. - -The validator cannot prove that arbitrary shell text is non-destructive. The -prompt narrows eligible fixes, the card displays the command, and the user -confirmation controls execution. - -## Permission and single-confirmation requirement - -Calling the proposal CLI is non-mutating, but some agents may request permission -before directly executing the command. The human-readable tool-call title is -agent-authored and cannot be used for safe auto-approval. - -Therefore CLI proposal mode is enabled per built-in agent only after live -verification proves one of: - -1. the agent does not request permission for this exact direct WTA command; -2. the agent exposes trustworthy structured command/argv data that can be - matched to the WTA proposal subcommand; or -3. the agent has an official command allowlist that can permit only this - proposal subcommand. - -If none applies, that agent stays on assistant-text JSON fallback. Shipping a -permission confirmation followed by a card confirmation is not acceptable. - -## CLI-to-master transport - -The proposal CLI follows the existing `wta sessions list` connection shape: - -1. resolve and open the master named pipe; -2. initialize as a short-lived ACP client named `wta-proposal`; -3. send `_intellterm.wta/terminal_actions/propose` with the route token and - versioned payload; -4. wait for a bounded structured response and disconnect. - -Master recognizes `wta-proposal` during initialize and does not bind/spawn an -agent or register the connection as a helper live-set subscriber. - -After validating the token, master sends the target helper an ExtNotification -containing `{proposal_id, session_id, payload}`. The helper immediately returns -`_intellterm.wta/terminal_actions/result` with the proposal id and disposition. -The result acknowledges that the card was presented or rejected; it never waits -for user confirmation, which would deadlock the in-flight agent tool and prompt. - -Master keeps a bounded pending-response map keyed by proposal id and tagged with -the target HelperId. Helper disconnect, timeout, or master shutdown resolves -pending CLI requests as unavailable. Late or duplicate results are ignored. - -This reuses the existing ACP pipe and WTA extension namespace. It does not use -COM, MCP, or a second local server. - -## Compatibility and rollout - -### Phase 1: contract only - -- Add versioned wire types, limits, validators, and conversion tests. -- Add the CLI parser and stable disposition schema behind a feature gate. -- Keep assistant-text JSON as the only live card source. - -### Phase 2: direct CLI routing - -- Inject master-owned turn route tokens while forwarding prompts. -- Reuse the existing CLI-to-master ACP connection and add proposal/result - extension methods. -- Add the bounded token and pending-response registries. -- Add AppEvent plus immediate disposition plumbing. -- Surface Terminal Agent cards through the existing `TurnState`. -- Keep the JSON fallback enabled. - -### Phase 3: agent compatibility - -- Verify direct process execution, payload delivery, and permission behavior for - every built-in agent. -- Enable CLI proposals only for agents that preserve single confirmation. -- Suppress the internal proposal command's ToolCall row only when the agent - exposes a trustworthy structured identity for that invocation. -- Keep the JSON fallback for WSL-hosted agents unless their WTA command can - reach the Windows master pipe. - -### Phase 4: Autofix - -- Reuse the same wire schema and transport with Autofix policy. -- Bind the action to the recorded failing pane and generation. -- Verify stashed, split-pane, tab-switch, and stale-response behavior. - -### Phase 5: fallback decision - -- Measure CLI proposal success and fallback use by canonical agent id. -- Remove assistant-text card parsing only after all supported agents reach - parity. Markdown explanations remain unchanged. - -## Validation - -Focused automated coverage must include: - -- wire round-trip and schema-version rejection; -- unknown-field, payload-size, choice-count, and action-count limits; -- origin-specific policy rejection; -- master pipe environment/discovery fallback and stale-master rejection; -- stdin and payload-file handling across supported agent command tools; -- capability one-use, TTL, per-turn invalidation, and bounded-map behavior; -- short-lived proposal clients do not spawn agents or register as helpers; -- proposal/result correlation, timeout, helper disconnect, and late-result handling; -- current prompt, generation, target, duplicate, and stale checks; -- no model-authored pane/session/helper identifiers; -- stdout/stderr separation and disposition exit behavior; -- immediate proposal acknowledgement does not deadlock the in-flight turn; -- one visible card and one execution after confirmation; -- no terminal mutation before confirmation; -- JSON fallback for agents without CLI proposal support; -- multi-tab, multi-window, stashed-pane, and session-load routing; -- per-agent live permission behavior. - -## Related work - -- PR #428: superseded MCP-based implementation. -- Issue #445: delegate new-tab creation latency after card confirmation. The - execution problem remains valid but is independent of proposal transport. +# WTA CLI terminal-action proposals + +## Status + +Implemented design. This document supersedes the MCP-based work in PR #428 and +the CLI-to-master routing originally implemented for PR #484. MCP is not part +of the repository architecture. Terminal-action proposals do not transit +through wta-master. + +## Summary + +Autofix and Terminal Agent use a short-lived WTA CLI to submit typed action +proposals directly to the Helper that owns the current turn: + +```text +Agent CLI + -> wta propose-terminal-actions + -> per-Helper named pipe + -> existing recommendation card + -> user confirmation + -> existing wtcli/COM executor +``` + +The proposal command cannot mutate Windows Terminal. It can only ask the +owning Helper to display a recommendation card. The existing card confirmation +remains the sole mutation boundary. + +wta-master remains responsible for the shared agent process, ACP multiplexing, +session-to-Helper routing, and forwarding permission requests to the Helper +that owns the ACP session. It does not mint proposal tokens, receive proposal +payloads, correlate proposal results, or acknowledge cards. + +## Goals + +- Use WTA CLI, not MCP, for typed terminal-action proposals. +- Have the agent session execute one canonical command directly. +- Route the short-lived CLI directly to the owning Helper. +- Keep session, Helper, tab, window, and pane identifiers out of proposal JSON. +- Reject wrong-Helper, stale-turn, unapproved, modified, and replayed requests. +- Give the agent immediate validation feedback and final user-decision feedback. +- Preserve the existing Run, Insert, Open, Split, and Delegate card UI. +- Preserve exactly one visible user confirmation before terminal mutation. +- Keep assistant-text parsing as a compatibility fallback during rollout. + +## Non-goals + +- Reintroducing MCP or adding another shared server. +- Using wta-master as a proposal router. +- Letting the proposal CLI execute terminal actions. +- Treating arbitrary model-authored shell commands as trusted. +- Proving that arbitrary proposed shell input is non-destructive. +- Reporting whether a confirmed shell command eventually succeeded. +- Persisting proposal channels across Helper process restarts. + +## End-to-end flow + +```text +1. Helper starts a turn and creates one opaque channel. +2. Helper injects the channel and canonical invocation contract into the prompt. +3. Agent emits the exact canonical WTA command with inline proposal JSON. +4. Before executing it, the Agent CLI sends session/request_permission over ACP. +5. Master routes that request by trusted ACP session ownership to the Helper. +6. Helper parses the command before creating a Permission AppEvent. +7. For an exact current-channel invocation, Helper records the payload digest, + moves the channel from Issued to Armed, and silently selects AllowOnce. +8. Agent runs the short-lived WTA CLI. +9. The CLI derives the per-Helper pipe from the opaque channel and connects + directly to it. +10. Helper verifies channel state, lease, turn freshness, and payload digest, + then atomically consumes the armed attempt. +11. Helper validates the typed proposal and stages the existing card. +12. CLI receives a validation response. If accepted, it remains connected. +13. User confirms or cancels the card, or lifecycle invalidation ends the turn. +14. CLI receives the final response and exits. +``` + +The permission request still crosses master because that is part of the ACP +topology. Proposal data and proposal state do not. + +## Channel and endpoint + +Each Helper process creates a random instance identifier and one stable named +pipe: + +```text +channel: v1.. +pipe: \\.\pipe\IntelligentTerminal.Proposal. +``` + +Both identifiers use lower-case UUID simple form: 32 hexadecimal characters +without braces or separators. The agent copies the complete channel as an +opaque string. It never constructs or receives separate Helper, session, tab, +window, pane, or prompt identifiers. + +The Helper instance and pipe survive pane stash/restore and `/new`. A Helper +process restart creates a new instance and pipe, permanently invalidating all +old channels. + +Only one channel is active per Helper. Starting a newer turn invalidates the +previous channel before the new one is issued. + +## Canonical invocation + +The only auto-approvable PowerShell form is: + +```powershell +& "$env:WTA_CLI_PATH" propose-terminal-actions --channel --payload-json '' +``` + +`WTA_CLI_PATH` is set by WTA to its trusted current executable. Proposal JSON +must be compact UTF-8 JSON encoded as one PowerShell single-quoted argument; +literal apostrophes are escaped by doubling them. The command has no pipeline, +redirection, here-string, command substitution, temporary file, extra +argument, or alternate executable spelling. + +The Helper uses one renderer/parser implementation for prompt generation and +permission matching. It does not infer safety from the agent-authored tool +title. + +Permission policy has three outcomes: + +| Input | Outcome | +|---|---| +| Exact canonical command for the current channel and compact JSON payload | Silently `AllowOnce`; arm channel with SHA-256 payload digest | +| Recognizable proposal command with unsafe or non-canonical syntax | Silently cancel | +| Any unrelated command | Use the existing Permission UI | + +An exact canonical command for a different or stale channel may be allowed +once, but it is not armed; its CLI request receives a structured routing error. +`AllowAlways` is never selected because every proposal must pass through +per-turn arming. + +## CLI contract + +```text +wta propose-terminal-actions --channel --payload-json +``` + +The command does not accept stdin, `--payload-file`, a master pipe, a Helper +pipe, or separate routing identifiers. Inline payload size is limited to +8 KiB UTF-8 before deserialization. + +The CLI parses the channel, derives the pipe name, sends one request frame, and +reads newline-delimited compact JSON responses. stdout is protocol-only. +Diagnostics go to stderr. + +Protocol-complete rejections exit successfully so the agent can interpret the +response and decide whether to retry. CLI syntax, malformed channel, broken +transport, and internal failures use a nonzero exit status. + +## Pipe protocol + +Protocol version 1 uses UTF-8 JSON Lines with a maximum encoded line size of +49 KiB, covering worst-case JSON escaping for the 8 KiB payload plus protocol +overhead. One connection carries exactly one request and its responses. + +Request: + +```json +{"version":1,"channel":"v1..","payload":"{...}"} +``` + +Immediate validation success: + +```json +{"phase":"validation","status":"accepted","proposal_id":"","retryable":false} +``` + +Immediate validation failure: + +```json +{"phase":"validation","status":"invalid_schema","reason":"...","retryable":true} +``` + +Final response after an accepted validation: + +```json +{"phase":"final","status":"confirmed","proposal_id":""} +``` + +Validation statuses: + +- `accepted` +- `unknown_channel` +- `helper_mismatch` +- `not_armed` +- `stale` +- `superseded` +- `expired` +- `digest_mismatch` +- `already_consumed` +- `invalid_schema` +- `rejected` +- `unavailable` + +Final statuses: + +- `confirmed` +- `cancelled` +- `superseded` +- `session_replaced` +- `timed_out` +- `unavailable` + +`confirmed` means the selected card action was dispatched to the existing +executor. It does not claim that a target shell command finished successfully. + +## Helper channel state + +```text +Issued + -> Armed + -> Validating + -> AwaitingUser + -> Confirmed | Cancelled | Superseded | SessionReplaced + | TimedOut | Unavailable +``` + +The Helper stores: + +```text +ProposalChannelManager + helper_instance_id + session_epoch + active_channel + bounded_tombstones +``` + +An active channel contains its nonce, session epoch, Helper-local prompt +identity, state, retry count, optional digest, lease deadline, and optional +pending final responder. It does not contain model-authored target identity. + +Before accepting a pipe request, the Helper checks in order: + +1. protocol version and frame limits; +2. Helper instance encoded in the channel; +3. active channel or bounded tombstone; +4. channel state is `Armed`; +5. 30-second armed lease has not expired; +6. session epoch and prompt identity are still current; +7. SHA-256 of the exact payload bytes matches the armed digest; +8. atomic one-use transition to `Validating`; +9. strict proposal schema and origin policy; +10. trusted active target injection by App. + +After schema rejection, the channel returns to `Issued` and clears its digest. +The agent may correct the payload, request permission again, and retry up to +two times. An accepted proposal is one-use. Lifecycle and user-decision +terminal states are not retryable. + +## Proposal schema and trusted target + +The public payload is the versioned schema defined by +`terminal_action_proposal.rs`. It uses `deny_unknown_fields`, explicit count +and size limits, and hand-written conversion to `RecommendationSet`. + +It never accepts: + +- ACP session or Helper identifiers; +- window, tab, pane, prompt, or pipe identifiers; +- model-authored parent pane identifiers; +- arbitrary agent executable paths. + +Terminal Agent proposals contain one to three choices. App binds `send` and +panel actions to the trusted pane captured for that turn and resolves delegate +actions only through configured policy. + +Autofix accepts exactly one choice with one `send` action. App binds it to the +recorded failing pane and matching Autofix generation. + +## Lifecycle + +The ownership hierarchy is: + +```text +Helper process + -> ACP session epoch + -> active turn channel +``` + +Lifecycle transitions invalidate the channel before replacing the owning +session or process: + +| Event | Result | +|---|---| +| New prompt in same session | Previous channel becomes `superseded`; issue a new channel | +| `/stop` | In-flight channel becomes `cancelled` | +| `/new` or load another session | Increment epoch; old channel becomes `session_replaced`; pipe remains | +| `/restart` | Helper and pipe are destroyed; waiting clients become unavailable | +| Pane stash/restore | Preserve Helper, pipe, session, channel, and card | +| Card confirm | Atomically claim the live proposal, dispatch through the existing executor, then send `confirmed` | +| Card cancel/dismiss | Send `cancelled` | +| User-decision timeout | Send `timed_out` after 10 minutes | +| ACP transport lost | Send `unavailable` and refuse new channels | +| Tab/window close or Ctrl+C twice | Destroy Helper and pipe | + +The Helper retains at most four terminal tombstones for three minutes. +Tombstones contain only a channel hash, terminal status, and timestamp. They +improve errors for late clients without retaining payload, digest, or target +data. They are not persisted. + +## Security model + +The channel is an unguessable, short-lived bearer handle. The per-user named +pipe ACL, Helper-instance routing, per-turn nonce, ACP-session permission +routing, payload digest, short armed lease, and one-use transition prevent +normal cross-tab mistakes, stale turns, payload changes, and replay. + +The Helper-side permission decision is essential: knowing an issued channel is +not enough to submit a proposal; the exact payload must first be armed through +the owning ACP session. + +A malicious process that can read the complete armed channel and exact payload +from the agent process and race that process can still use the bearer handle. +The direct-pipe design does not claim process attestation. This residual risk +is bounded because the CLI only proposes a visible card and user confirmation +is still required before mutation. + +## Rollout and validation + +Direct Helper routing is enabled for Copilot first. Assistant-text parsing +remains the fallback until each built-in agent proves canonical command, +permission, and Windows/WSL reachability behavior. + +Automated and live coverage must include: + +- channel parsing, uniqueness, one-use, lease, retries, tombstones, and epochs; +- canonical rendering/parsing, quoting, extra-token rejection, and auto-policy; +- pipe framing, size limits, disconnects, and two-phase responses; +- wrong Helper, wrong turn, stale, unarmed, digest mismatch, and replay; +- schema and origin policy validation with trusted target injection; +- card confirm, cancel, supersede, timeout, and Helper shutdown; +- `/stop`, `/new`, session load, `/restart`, stash/restore, tab/window close; +- multi-tab and multi-window isolation; +- no Permission UI for an exact canonical Copilot proposal; +- no terminal mutation before card confirmation; +- explicit Windows-target WTA build and packaged live verification. + +## Related work + +- PR #428: superseded MCP-based implementation. +- PR #484: WTA CLI proposal implementation and this direct Helper revision. +- Issue #445: delegate creation latency after card confirmation; independent of + proposal transport. diff --git a/tools/wta/Cargo.lock b/tools/wta/Cargo.lock index 1b47c696f1..4a5cde37fe 100644 --- a/tools/wta/Cargo.lock +++ b/tools/wta/Cargo.lock @@ -3351,6 +3351,7 @@ dependencies = [ "rust-i18n", "serde", "serde_json", + "sha2", "strsim", "sys-locale", "textwrap", diff --git a/tools/wta/Cargo.toml b/tools/wta/Cargo.toml index a6e2689e4b..1b5f7f5049 100644 --- a/tools/wta/Cargo.toml +++ b/tools/wta/Cargo.toml @@ -52,6 +52,7 @@ tracing-appender = "0.2" tracelogging = "1" which = "7" uuid = { version = "1", features = ["v4"] } +sha2 = "0.10" rust-i18n = "3" sys-locale = "0.3" # Damerau-Levenshtein string distance for autofix "did you mean" near-matches diff --git a/tools/wta/prompts/auto-fix.md b/tools/wta/prompts/auto-fix.md index 70d33bed32..b6f75f32c1 100644 --- a/tools/wta/prompts/auto-fix.md +++ b/tools/wta/prompts/auto-fix.md @@ -4,6 +4,17 @@ A command failed. Diagnose the error from the terminal output and shell context --- +## Direct submission (when you can execute commands) + +If, in THIS session, you can execute shell commands directly AND the runtime context above includes an `[intellterm.wta proposal]` block with a `--channel `, you may submit your `fix` decision directly instead of relying on the fenced JSON block below being parsed out of your reply: + +1. For a `fix` decision only (never `explain` — there is nothing to submit), build one JSON object: `{"schema_version": 1, "origin": "autofix", "choices": [{"choice": 1, "title": "<≤6 word summary>", "rationale": "", "actions": [{"type": "send", "input": ""}]}]}`. Exactly one choice, exactly one `send` action, no `parent` (autofix always binds the real failing pane itself and ignores/strips any `parent` you supply). +2. Run exactly the command form shown in the `[intellterm.wta proposal]` block, replacing only `` with that object. Keep it compact and PowerShell single-quoted (double any literal apostrophe). Do not use stdin, a pipeline, here-string, redirection, temporary file, alternate executable spelling, or extra arguments. +3. Read both JSON response phases. Validation is immediate. If accepted, wait for the final user decision. If validation reports `retryable:true`, correct the payload and retry at most twice; never retry cancellation, supersession, timeout, or unavailability. +4. Do not also emit the fenced `json` block below after a direct attempt (it would risk a duplicate card). This proposal call never runs the fix itself — the user still confirms the card exactly as today. + +If you cannot execute commands in this session, or no `[intellterm.wta proposal]` block is present, ignore this section and use the fenced ```json``` block below as normal — that fallback is unchanged. + ## Output Return exactly one JSON object in a fenced ```json block. No prose around it. diff --git a/tools/wta/prompts/terminal-agent.md b/tools/wta/prompts/terminal-agent.md index e30aac115f..b76ff651bb 100644 --- a/tools/wta/prompts/terminal-agent.md +++ b/tools/wta/prompts/terminal-agent.md @@ -23,6 +23,18 @@ Read the runtime context (cwd, shell, activeTarget, buffer, supported delegate a Once you have picked a mode, follow only that mode's rules. Do not mix them — chat answers never include JSON; Mode B answers never include JSON; Modes A and C always include exactly one JSON block. +### Direct submission (Modes A and C, when you can execute commands) + +If, in THIS session, you can execute shell commands directly (the same capability Mode B's Self-Execute Rules use — e.g. an `execute_command` tool) AND the runtime context below includes an `[intellterm.wta proposal]` block with a `--channel `, submit the recommendation directly instead of relying on the fenced JSON block being parsed out of your reply: + +1. Build the same recommendation as one JSON object matching this wire shape (NOT the fenced-block schema below — this is the direct-submission schema): `{"schema_version": 1, "origin": "terminal_agent", "recommended_choice": , "choices": [{"choice": , "title": "...", "rationale": "...", "actions": [...]}]}`. Actions use `{"type":"send","input":"..."}`, `{"type":"open","target":"tab|panel",...}`, or `{"type":"open_and_send","target":"tab|panel","input":"...","delegate":true|false,...}`. Do not include `parent`, `agent`, or any session/window/tab/pane id: the helper injects the active pane and resolves `delegate:true` to the configured delegate. +2. Run exactly the command form shown in the `[intellterm.wta proposal]` block, replacing only `` with that object. Keep it compact and PowerShell single-quoted (double any literal apostrophe). Do not use stdin, a pipeline, here-string, redirection, temporary file, alternate executable spelling, or extra arguments. +3. Read the JSON Lines output. `phase:"validation"` is immediate. On `accepted`, keep waiting for `phase:"final"` (`confirmed`, `cancelled`, `superseded`, `session_replaced`, `timed_out`, or `unavailable`). `confirmed` means the card action was dispatched, not that a shell command finished successfully. +4. If validation reports `retryable:true`, correct the payload and retry at most twice; the corrected command will request a fresh one-time permission. Never retry lifecycle/final outcomes. Do not also emit the fenced JSON block after a direct attempt because that could show a duplicate card. +5. This never executes anything by itself — the user must still confirm the card exactly as today. It only gets the recommendation onto the card faster/more reliably than parsing your final text. + +If you cannot execute commands in this session, or no `[intellterm.wta proposal]` block is present in the runtime context, ignore this section entirely and use the fenced ```json``` block as described below — that fallback is unchanged and always works. + ### Tie-breakers - If A and B both seem to fit, pick **A**. The shell command in the user's pane is cheaper, more transparent, and leaves the user with state they can build on. diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index 2009404023..adf7a70a5e 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -43,8 +43,8 @@ pub struct AvailableAgent { pub display_name: String, } -mod turn_state; mod autofix; +mod turn_state; use autofix::*; pub use turn_state::{AutofixContext, ChunkKind, SubmittedPrompt, TurnOutcome, TurnState}; @@ -323,9 +323,7 @@ fn is_post_login_auth_failure(failure: &crate::protocol::acp::failure::AgentFail /// was missing. Login succeeds in the browser, but reconnecting to the saved /// pipe fails before initialize/authenticate/new_session can run. The right /// recovery is still the same fresh-master restart used for stale auth state. -fn is_post_login_master_unavailable( - failure: &crate::protocol::acp::failure::AgentFailure, -) -> bool { +fn is_post_login_master_unavailable(failure: &crate::protocol::acp::failure::AgentFailure) -> bool { use crate::protocol::acp::failure::{AgentFailure, HandshakeStage}; matches!( failure, @@ -501,13 +499,17 @@ impl PermOption { /// Prefix-checked (not lowercased) to stay allocation-free on the render / /// key-handling hot path. pub fn is_allow(&self) -> bool { - self.kind.get(..5).is_some_and(|p| p.eq_ignore_ascii_case("allow")) + self.kind + .get(..5) + .is_some_and(|p| p.eq_ignore_ascii_case("allow")) } /// True if this is a "reject" option. Allocation-free, case-insensitive — /// see [`PermOption::is_allow`]. pub fn is_reject(&self) -> bool { - self.kind.get(..6).is_some_and(|p| p.eq_ignore_ascii_case("reject")) + self.kind + .get(..6) + .is_some_and(|p| p.eq_ignore_ascii_case("reject")) } } @@ -762,13 +764,19 @@ where .unwrap_or("") .to_string(); if crate::agent_sessions::is_user_input_tool(&tool_name) { - let tool_event = SessionEvent::ToolStarting { key: key.clone(), tool_name }; + let tool_event = SessionEvent::ToolStarting { + key: key.clone(), + tool_name, + }; reg.apply(tool_event.clone()); hook_sink(tool_event); - let message = payload.get("tool_input") - .and_then(|ti| ti.get("question") - .or_else(|| ti.get("prompt")) - .or_else(|| ti.get("message"))) + let message = payload + .get("tool_input") + .and_then(|ti| { + ti.get("question") + .or_else(|| ti.get("prompt")) + .or_else(|| ti.get("message")) + }) .and_then(|v| v.as_str()) .unwrap_or("waiting for user input") .to_string(); @@ -912,7 +920,7 @@ pub fn classify_wt_event( summary: String::new(), acknowledged: true, // auto-acknowledge so it never shows age_ticks: 100, // will be auto-dismissed immediately - } + }; } _ => WtNotification { severity: WtEventSeverity::Informational, @@ -1323,6 +1331,18 @@ pub enum AppEvent { /// See [`crate::agent_sessions::AgentSessionRegistry::apply_alive_session_join`]. AliveJoinUpgrade(Vec<(String, Option)>), SessionsChanged, + DirectTerminalActionProposal { + context: crate::proposal_channel::ValidationContext, + payload: String, + responder: tokio::sync::oneshot::Sender, + }, + DirectTerminalActionProposalCommit { + proposal_id: String, + }, + DirectTerminalActionProposalInvalidate { + proposal_id: String, + session_id: String, + }, AgentsSnapshotLoaded { request_id: u64, sessions: Vec, @@ -1399,7 +1419,6 @@ impl Scroll { } } - /// Everything that conceptually belongs to one tab's conversation: the /// message history, the streaming buffer of the in-flight prompt, the /// pending tool calls, the recommendations panel state, etc. @@ -1408,10 +1427,20 @@ impl Scroll { /// the currently focused entry. Renderers read via `app.current_tab()`; /// event handlers route updates to the relevant `TabSession` rather than /// mutating shared `App` fields. +struct PendingTerminalActionProposal { + proposal_id: String, + session_id: String, + prompt_id: u64, + is_autofix: bool, + recommendations: RecommendationSet, +} + #[derive(Default)] pub struct TabSession { /// Per-tab autofix state machine (see `TabAutofixState`). pub autofix: TabAutofixState, + pending_terminal_action_proposal: Option, + active_direct_proposal_id: Option, // Conversation history pub messages: Vec, @@ -1493,7 +1522,6 @@ pub struct TabSession { /// actually changes. pub last_emitted_chip_override: Option, - // Input editor state — per-tab so each tab keeps its own draft text, // cursor, and slash-command popup across switches. pub input: String, @@ -1900,11 +1928,18 @@ impl TabSession { if input.is_empty() { return; } - if let Some(index) = self.input_history.entries.iter().position(|entry| entry == input) { + if let Some(index) = self + .input_history + .entries + .iter() + .position(|entry| entry == input) + { self.input_history.entries.remove(index); } self.input_history.entries.push_front(input.to_string()); - self.input_history.entries.truncate(INPUT_HISTORY_MAX_ENTRIES); + self.input_history + .entries + .truncate(INPUT_HISTORY_MAX_ENTRIES); } fn input_history_is_browsing(&self) -> bool { @@ -2113,6 +2148,7 @@ pub struct App { rename_session_tx: mpsc::UnboundedSender, restart_tx: mpsc::UnboundedSender, master_request_tx: mpsc::UnboundedSender, + proposal_channels: Arc, debug_capture_enabled: Arc, /// Cached for creating DeferredAcpParams after auth-error recovery. shell_mgr: Arc, @@ -2208,8 +2244,7 @@ pub struct App { /// `agent_config_changed` settings event so the configured delegate /// agent/model can change without restarting the agent pane. None in /// tests / manual runs where no executor is wired. - delegate_agents: - Option>>>, + delegate_agents: Option>>>, /// The helper's own `--agent` cmdline. Needed to re-derive the delegate /// runtime commandline when only the delegate agent/model change. delegate_base_agent_cmd: String, @@ -2318,10 +2353,10 @@ pub struct AgentsViewState { pub(crate) fn known_cli_id(src: &crate::agent_sessions::CliSource) -> Option<&'static str> { use crate::agent_sessions::CliSource; match src { - CliSource::Claude => Some("claude"), - CliSource::Codex => Some("codex"), + CliSource::Claude => Some("claude"), + CliSource::Codex => Some("codex"), CliSource::Copilot => Some("copilot"), - CliSource::Gemini => Some("gemini"), + CliSource::Gemini => Some("gemini"), CliSource::OpenCode => Some("opencode"), CliSource::Unknown(_) => None, } @@ -2430,6 +2465,7 @@ impl App { rename_session_tx, restart_tx, master_request_tx, + proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), debug_capture_enabled, help_overlay_visible: false, transport_lost: false, @@ -2469,6 +2505,13 @@ impl App { } } + pub fn set_proposal_channels( + &mut self, + proposal_channels: Arc, + ) { + self.proposal_channels = proposal_channels; + } + /// Stash pipe-mode launch parameters on App so that a post-FRE-login /// reconnect via [`Self::try_start_acp`] goes back through /// `run_acp_client_over_pipe` (talking to wta-master). @@ -2626,29 +2669,32 @@ impl App { let recovery_tab_id = owner_tab_opt.clone(); let recovery_agent_id = self.current_agent_id.clone(); let event_tx_for_pipe = event_tx.clone(); + let proposal_channels = Arc::clone(&self.proposal_channels); + let direct_proposals_enabled = self.current_agent_id == "copilot"; tokio::task::spawn_local(async move { - if let Err(e) = - crate::protocol::acp::client::run_acp_client_over_pipe( - pipe_name, - acp_model, - agent_id_opt, - owner_tab_opt, - None, // initial_load_session_id: already handled by the dead initial task - event_tx_for_pipe.clone(), - prompt_rx, - cancel_rx, - new_session_rx, - load_session_rx, - drop_session_rx, - rename_session_rx, - restart_rx, - shrx, - master_ext_rx, - shell_mgr, - wt_connected, - post_login_auth, // only true on genuine LoginComplete reconnects - ) - .await + if let Err(e) = crate::protocol::acp::client::run_acp_client_over_pipe( + pipe_name, + acp_model, + agent_id_opt, + owner_tab_opt, + None, // initial_load_session_id: already handled by the dead initial task + event_tx_for_pipe.clone(), + prompt_rx, + cancel_rx, + new_session_rx, + load_session_rx, + drop_session_rx, + rename_session_rx, + restart_rx, + shrx, + master_ext_rx, + shell_mgr, + wt_connected, + post_login_auth, // only true on genuine LoginComplete reconnects + proposal_channels, + direct_proposals_enabled, + ) + .await { tracing::error!( target: "helper", @@ -2892,8 +2938,7 @@ impl App { .available_agents .iter() .find(|agent| { - agent.id.eq_ignore_ascii_case(arg) - || agent.display_name.eq_ignore_ascii_case(arg) + agent.id.eq_ignore_ascii_case(arg) || agent.display_name.eq_ignore_ascii_case(arg) }) .cloned(); match selected { @@ -2934,7 +2979,10 @@ impl App { fn commit_agent_pick(&mut self) { let selected = self.current_tab().agent_picker_selected; - let agent_id = self.available_agents.get(selected).map(|agent| agent.id.clone()); + let agent_id = self + .available_agents + .get(selected) + .map(|agent| agent.id.clone()); self.close_agent_picker(); if let Some(agent_id) = agent_id { self.apply_agent_pick(agent_id); @@ -3280,8 +3328,7 @@ impl App { }; let msg = match reason { NotResumableReason::LiveWithoutPane => { - t!("system.cannot_focus_session", session_id = s.key.as_str()) - .into_owned() + t!("system.cannot_focus_session", session_id = s.key.as_str()).into_owned() } NotResumableReason::LoadSessionNotSupported => { let agent: String = if self.agent_name.is_empty() { @@ -3528,8 +3575,7 @@ impl App { format!("Resuming {cli_id} session {short_key}...") } }; - let launch_commandline = - format!("cmd /c echo \x1b[2;37m{banner}\x1b[0m && {commandline}"); + let launch_commandline = format!("cmd /c echo \x1b[2;37m{banner}\x1b[0m && {commandline}"); let mut argv = vec![ "new-tab".to_string(), "-c".to_string(), @@ -3547,7 +3593,8 @@ impl App { // second Enter on the same row sees a non-terminal status and // skips this branch (idempotent: ResumeDispatched no-ops on live // rows). See `agent_sessions::SessionEvent::ResumeDispatched`. - let resume_event = crate::agent_sessions::SessionEvent::ResumeDispatched { key: key.clone() }; + let resume_event = + crate::agent_sessions::SessionEvent::ResumeDispatched { key: key.clone() }; self.agent_sessions.apply(resume_event.clone()); self.publish_session_hook(resume_event); self.dispatch_session_resume_dispatched_rpc(&key); @@ -3683,15 +3730,22 @@ impl App { // Mirror dispatch_resume's optimistic state flip so a rapid // double press doesn't double-dispatch. - let resume_event = crate::agent_sessions::SessionEvent::ResumeDispatched { key: key.clone() }; + let resume_event = + crate::agent_sessions::SessionEvent::ResumeDispatched { key: key.clone() }; self.agent_sessions.apply(resume_event.clone()); self.publish_session_hook(resume_event); self.dispatch_session_resume_dispatched_rpc(&key); let mut params = serde_json::Map::new(); - params.insert("session_id".to_string(), serde_json::Value::String(key.clone())); + params.insert( + "session_id".to_string(), + serde_json::Value::String(key.clone()), + ); if !cwd_string.is_empty() { - params.insert("cwd".to_string(), serde_json::Value::String(cwd_string.clone())); + params.insert( + "cwd".to_string(), + serde_json::Value::String(cwd_string.clone()), + ); } let evt = serde_json::json!({ "type": "event", @@ -3938,8 +3992,9 @@ impl App { .and_then(|sid| rows.iter().position(|row| row.key == sid.0.as_ref())) .unwrap_or_else(|| old_selected.min(rows.len() - 1)); tab.agents_list_state.select(Some(idx)); - tab.agents_view.focused_sid = - Some(agent_client_protocol::schema::v1::SessionId::new(rows[idx].key.clone())); + tab.agents_view.focused_sid = Some(agent_client_protocol::schema::v1::SessionId::new( + rows[idx].key.clone(), + )); } fn update_agents_focus_for_tab(&mut self, tab_id: &str) { @@ -4238,7 +4293,10 @@ impl App { "login failed" ); } - tracing::info!("login: spawn_blocking returned, sending LoginComplete success={}", success); + tracing::info!( + "login: spawn_blocking returned, sending LoginComplete success={}", + success + ); let send_result = tx.send(AppEvent::LoginComplete { agent_id: id, success, @@ -4683,6 +4741,13 @@ impl App { AppEvent::AliveSessionRemoved(_) => "alive_session_removed", AppEvent::AliveJoinUpgrade(_) => "alive_join_upgrade", AppEvent::SessionsChanged => "sessions_changed", + AppEvent::DirectTerminalActionProposal { .. } => "direct_terminal_action_proposal", + AppEvent::DirectTerminalActionProposalCommit { .. } => { + "direct_terminal_action_proposal_commit" + } + AppEvent::DirectTerminalActionProposalInvalidate { .. } => { + "direct_terminal_action_proposal_invalidate" + } AppEvent::AgentsSnapshotLoaded { .. } => "agents_snapshot_loaded", AppEvent::AgentsSnapshotFailed { .. } => "agents_snapshot_failed", AppEvent::RegisterBornBoundSession { .. } => "register_born_bound_session", @@ -4738,9 +4803,7 @@ impl App { CheckStatus::Failed(t!("agent.status.not_found").into_owned()) }, cli_path: agent_status.cli_path.clone(), - auth_status: CheckStatus::Failed( - t!("system.authentication_failed").into_owned(), - ), + auth_status: CheckStatus::Failed(t!("system.authentication_failed").into_owned()), install_hint: profile.install_hint.to_string(), install_url: String::new(), auth_hint: profile.auth_hint.to_string(), @@ -4751,11 +4814,9 @@ impl App { options, title: t!("setup.title.sign_in").into_owned(), subtitle: if profile.id == "copilot" { - t!("setup.subtitle.copilot_auth", agent = profile.display_name) - .into_owned() + t!("setup.subtitle.copilot_auth", agent = profile.display_name).into_owned() } else { - t!("setup.subtitle.agent_auth", agent = profile.display_name) - .into_owned() + t!("setup.subtitle.agent_auth", agent = profile.display_name).into_owned() }, }); let tab = self.current_tab_mut(); @@ -4803,7 +4864,8 @@ impl App { }; tokio::task::spawn_local(async move { let tab_for_result = target_tab.clone(); - let result = tokio::task::spawn_blocking(crate::win32::read_paste_string_from_clipboard).await; + let result = + tokio::task::spawn_blocking(crate::win32::read_paste_string_from_clipboard).await; let event = match result { Ok(Ok(text)) => AppEvent::AgentPasteTextReady { tab_id: tab_for_result, @@ -4826,7 +4888,10 @@ impl App { } fn agent_paste_target_tab<'a>(&self, params: &'a serde_json::Value) -> Option<&'a str> { - let target_window = params.get("window_id").and_then(|v| v.as_str()).unwrap_or(""); + let target_window = params + .get("window_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); let target_tab = params.get("tab_id").and_then(|v| v.as_str()).unwrap_or(""); let our_window = self.window_id.as_deref().unwrap_or(""); let owner_tab = self.owner_tab_id.as_deref().unwrap_or(""); @@ -5144,6 +5209,7 @@ impl App { tab.pending_user_replay.clear(); tab.timing_note = None; tab.turn = TurnState::Idle; + tab.active_direct_proposal_id = None; tab.messages.push(ChatMessage::Error(message)); tab.scroll_to_bottom(); } @@ -5208,6 +5274,7 @@ impl App { crate::protocol::acp::failure::AgentFailure::TransportLost ) { self.transport_lost = true; + self.proposal_channels.set_transport_available(false); } let is_auth_error = failure.is_auth(); @@ -5730,6 +5797,28 @@ impl App { AppEvent::SessionsChanged => { self.schedule_agents_refetch_for_open_views(); } + AppEvent::DirectTerminalActionProposal { + context, + payload, + responder, + } => { + let decision = self.evaluate_direct_terminal_action_proposal(&context, &payload); + let _ = responder.send(decision); + } + AppEvent::DirectTerminalActionProposalCommit { proposal_id } => { + if !self.commit_terminal_action_proposal(&proposal_id) { + self.proposal_channels.resolve_final( + &proposal_id, + crate::proposal_channel::ProposalFinalStatus::Cancelled, + ); + } + } + AppEvent::DirectTerminalActionProposalInvalidate { + proposal_id, + session_id, + } => { + self.invalidate_terminal_action_proposal(&proposal_id, &session_id); + } AppEvent::AgentsSnapshotLoaded { request_id, sessions, @@ -5743,9 +5832,7 @@ impl App { if self .master_request_tx .send( - crate::protocol::acp::client::MasterExtRequest::SessionBornBound { - event, - }, + crate::protocol::acp::client::MasterExtRequest::SessionBornBound { event }, ) .is_err() { @@ -5831,10 +5918,7 @@ impl App { if method == "agent_prompt" { // Command palette `?` delegation. Not a WT // notification — has nothing to do with banner/queue. - let prompt = params - .get("prompt") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let prompt = params.get("prompt").and_then(|v| v.as_str()).unwrap_or(""); tracing::info!(target: "autofix", prompt_len = prompt.len(), "agent_prompt: delegating"); if !prompt.is_empty() { self.delegate_to_tab_agent(prompt); @@ -5856,9 +5940,7 @@ impl App { // — all in place, with NO agent-pane teardown/restart. // (Agent *identity* changes go through a master respawn // on the C++ side, not this event.) - if let Some(enabled) = - params.get("autofix_enabled").and_then(|v| v.as_bool()) - { + if let Some(enabled) = params.get("autofix_enabled").and_then(|v| v.as_bool()) { tracing::info!( target: "autofix", old = self.autofix_enabled, @@ -6292,9 +6374,7 @@ impl App { // Capture the key BEFORE PaneClosed clears // the pane→key binding, so the log can report // which row was demoted. - let key_before = self - .agent_sessions - .key_for_pane(&pane_id); + let key_before = self.agent_sessions.key_for_pane(&pane_id); let event = crate::agent_sessions::SessionEvent::PaneClosed { pane_session_id: pane_id.clone(), }; @@ -6373,7 +6453,8 @@ impl App { // keeps working for its original shell-pane use // case without nuking agent panes. let origin = self.agent_sessions.origin_for_pane(&pane_id); - let is_shell_agent = matches!(origin, Some(crate::agent_sessions::SessionOrigin::Unknown)); + let is_shell_agent = + matches!(origin, Some(crate::agent_sessions::SessionOrigin::Unknown)); if seq == "osc:133;A" && is_shell_agent { tracing::info!( target: "agent_session_registry", @@ -6397,14 +6478,10 @@ impl App { // this helper's concern. Drop notifications whose tab_id // doesn't match our owner_tab_id; empty/missing tab_id falls // through (no per-tab scope). - if let (Some(event_tab), Some(self_tab)) = ( - notification.tab_id.as_deref(), - self.owner_tab_id.as_deref(), - ) { - if !event_tab.is_empty() - && !self_tab.is_empty() - && event_tab != self_tab - { + if let (Some(event_tab), Some(self_tab)) = + (notification.tab_id.as_deref(), self.owner_tab_id.as_deref()) + { + if !event_tab.is_empty() && !self_tab.is_empty() && event_tab != self_tab { // Per-cross-tab-event (very high volume in multi-tab // windows) — trace-only. tracing::trace!( @@ -6428,11 +6505,7 @@ impl App { WtEventSeverity::Informational => None, }; if let Some(severity_str) = severity_str { - crate::telemetry::log_error_detected( - severity_str, - &method, - &pane_id, - ); + crate::telemetry::log_error_detected(severity_str, &method, &pane_id); } } @@ -6494,9 +6567,8 @@ impl App { .trigger_echo_pane .clone(); if echo.as_deref() == Some(pane_id.as_str()) { - self.tab_mut(&t.to_string()) - .autofix - .trigger_echo_pane = None; + self.tab_mut(&t.to_string()).autofix.trigger_echo_pane = + None; false } else { true @@ -6522,11 +6594,8 @@ impl App { // command exited cleanly — the user's problem resolved. // Elapsed is monotonic (`Instant::elapsed`) from arm to // clean exit, not wall-clock. - if let Some(armed) = self - .tab_mut(&target_tab) - .autofix - .armed_at - .take() + if let Some(armed) = + self.tab_mut(&target_tab).autofix.armed_at.take() { let elapsed_ms = armed.elapsed().as_secs_f64() * 1000.0; crate::telemetry::log_error_fix_resolved( @@ -6703,8 +6772,16 @@ impl App { } } } - AppEvent::LoginComplete { success, error, agent_id } => { - tracing::info!("LoginComplete received: success={} deferred_acp={}", success, self.deferred_acp.is_some()); + AppEvent::LoginComplete { + success, + error, + agent_id, + } => { + tracing::info!( + "LoginComplete received: success={} deferred_acp={}", + success, + self.deferred_acp.is_some() + ); // Ignore stale/late completions: only act on a completion that // matches the currently active auth attempt. After the user // escapes the auth screen (auth = None) or switches agents, a @@ -6735,7 +6812,10 @@ impl App { // try_start_acp can spawn a new ACP client. if self.deferred_acp.is_none() { let new_cmd = self.build_agent_cmd(&agent_id); - tracing::info!("LoginComplete: creating deferred_acp for reconnect cmd={}", new_cmd); + tracing::info!( + "LoginComplete: creating deferred_acp for reconnect cmd={}", + new_cmd + ); self.deferred_acp = Some(DeferredAcpParams { agent_cmd: new_cmd, acp_model: None, @@ -7074,14 +7154,14 @@ impl App { .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => { - self.current_tab_mut().agents_view.search_query.push(*character); + self.current_tab_mut() + .agents_view + .search_query + .push(*character); self.reset_agents_search_selection(&tab_id); return; } - KeyCode::Up - | KeyCode::Down - | KeyCode::Enter - | KeyCode::F(5) => {} + KeyCode::Up | KeyCode::Down | KeyCode::Enter | KeyCode::F(5) => {} _ => return, } } @@ -7272,8 +7352,7 @@ impl App { } match key.code { - KeyCode::Up if self.current_tab().turn.recommendations().is_some() => - { + KeyCode::Up if self.current_tab().turn.recommendations().is_some() => { if self.current_tab_mut().selected_recommendation > 0 { self.current_tab_mut().selected_recommendation -= 1; self.current_tab_mut().selected_button = self.default_button_for_selected(); @@ -7284,8 +7363,7 @@ impl App { self.recompute_chip_override(&tab_id); } } - KeyCode::Down if self.current_tab().turn.recommendations().is_some() => - { + KeyCode::Down if self.current_tab().turn.recommendations().is_some() => { let choices_len = self .current_tab() .turn @@ -8036,7 +8114,9 @@ impl App { /// down (reuses the existing `connection.lost` string). fn push_degraded_command_hint(&mut self) { let msg = t!("connection.lost").into_owned(); - self.current_tab_mut().messages.push(ChatMessage::System(msg)); + self.current_tab_mut() + .messages + .push(ChatMessage::System(msg)); } /// Dispatch a parsed slash-command. The Enter handler is responsible @@ -8121,9 +8201,8 @@ impl App { fn cmd_new(&mut self, in_flight: bool) { if in_flight { let tab = self.current_tab_mut(); - tab.messages.push(ChatMessage::System( - t!("system.busy_use_stop").into_owned(), - )); + tab.messages + .push(ChatMessage::System(t!("system.busy_use_stop").into_owned())); tab.scroll_to_bottom(); return; } @@ -8775,7 +8854,6 @@ impl App { let _ = cmd.spawn(); } - /// Ask WT to tear down this agent pane. Wired to the second tap of the /// double-Ctrl+C close sequence. WT closes the Pane, which causes its /// ConPty to SIGKILL us — so the natural side effect of pane teardown @@ -8807,7 +8885,6 @@ impl App { send_wt_protocol_event(evt.to_string()); } - /// Recompute the chip-target override for the tab and, if it changed /// since the last emit, publish a `set_agent_chip_target` event so the /// C++ side pins the "Agent" chip on the right pane (or releases it, @@ -8963,6 +9040,7 @@ impl App { // `Surfaced{end_pending:false}` is dismissed by the new submit. tab.selected_recommendation = 0; tab.selected_button = 0; + tab.active_direct_proposal_id = None; tab.rec_scroll.reset(); // Autofix prompts are synthesized by the system; they don't render // as a User bubble (the user already sees the error line in the @@ -8974,6 +9052,8 @@ impl App { tab.progress_status = None; tab.activity_frame = 0; tab.timing_note = None; + tab.pending_terminal_action_proposal = None; + tab.active_direct_proposal_id = None; tab.turn = TurnState::Submitted(prompt); // Submitting a new prompt dismisses any prior leftover card (the @@ -9100,6 +9180,209 @@ impl App { } } + /// Apply the Helper's authoritative turn, schema, origin, and action policy + /// checks, then stage an accepted proposal until the direct pipe completes + /// its validation handshake and posts `DirectTerminalActionProposalCommit`. + fn validate_and_stage_terminal_action_proposal( + &mut self, + sid: &str, + prompt_id: u64, + active_target: Option<&str>, + payload: &str, + proposal_id: &str, + ) -> ( + crate::terminal_action_proposal::ProposalStatus, + Option, + ) { + use crate::terminal_action_proposal::{ + build_recommendation_set, parse_proposal_payload, ProposalStatus, + }; + + // Defensive: `session_tab`/`tab_for_session` silently fall back to + // the "active" tab for an unmapped session id (a convenience for + // pre-attach UI events). That fallback would be a routing bug in + // disguise here — the direct channel should only name a session this + // Helper owns, but fail closed instead of guessing if it somehow does. + if !self.session_to_tab.contains_key(sid) { + return ( + ProposalStatus::Unavailable, + Some("session is not bound to this helper".to_string()), + ); + } + + // (1) The turn must still be in flight. Already-`Surfaced` means a + // card is already showing for this turn (eager text-fallback, or an + // earlier proposal) -> duplicate. `Idle` means the turn concluded + // (or never existed) before this proposal could act -> stale. + match &self.session_tab(sid).turn { + TurnState::Surfaced { .. } => { + return ( + ProposalStatus::Duplicate, + Some("a card is already showing for this turn".to_string()), + ); + } + TurnState::Idle => { + return ( + ProposalStatus::Stale, + Some("no turn is in flight for this session".to_string()), + ); + } + TurnState::Submitted(_) | TurnState::Streaming { .. } => {} + } + + if self.session_tab(sid).turn.prompt().map(|prompt| prompt.id) != Some(prompt_id) { + return ( + ProposalStatus::Stale, + Some("proposal belongs to an earlier prompt".to_string()), + ); + } + + let is_autofix = self.session_tab(sid).turn.is_autofix(); + + // (2) Autofix freshness: the turn's own `AutofixContext` generation + // must still match the tab's live counter — the same staleness + // check `turn_observe_chunk`/`turn_close` already apply to every + // other autofix response path (a newer trigger, or an Esc cancel, + // invalidates this turn). + if is_autofix { + let turn_gen = self.session_tab(sid).turn.autofix_generation(); + let current_gen = self.session_tab(sid).autofix.generation; + if turn_gen != Some(current_gen) { + return ( + ProposalStatus::Stale, + Some("autofix turn was superseded".to_string()), + ); + } + } + + // (3) Decode + origin/schema/policy validation. + let wire = match parse_proposal_payload(payload.as_bytes()) { + Ok(wire) => wire, + Err(err) => return (err.to_status(), Some(err.reason())), + }; + + let configured_delegate_id = self + .delegate_agents + .as_ref() + .and_then(|shared| shared.lock().ok()) + .and_then(|guard| guard.first().map(|runtime| runtime.id.clone())); + + let recommendations = match build_recommendation_set( + &wire, + is_autofix, + configured_delegate_id.as_deref(), + active_target, + self.pane_id.as_deref(), + ) { + Ok(set) => set, + Err(err) => return (err.to_status(), Some(err.reason())), + }; + + self.session_tab_mut(sid).pending_terminal_action_proposal = + Some(PendingTerminalActionProposal { + proposal_id: proposal_id.to_string(), + session_id: sid.to_string(), + prompt_id, + is_autofix, + recommendations, + }); + (ProposalStatus::Presented, None) + } + + fn evaluate_direct_terminal_action_proposal( + &mut self, + context: &crate::proposal_channel::ValidationContext, + payload: &str, + ) -> crate::proposal_pipe::ProposalValidationDecision { + use crate::proposal_channel::ProposalValidationStatus; + use crate::terminal_action_proposal::ProposalStatus; + + let binding = &context.binding; + let (status, reason) = self.validate_and_stage_terminal_action_proposal( + &binding.session_id, + binding.prompt_id, + binding.active_target.as_deref(), + payload, + &context.proposal_id, + ); + match status { + ProposalStatus::Presented => { + crate::proposal_pipe::ProposalValidationDecision::accepted() + } + ProposalStatus::Duplicate => crate::proposal_pipe::ProposalValidationDecision { + status: ProposalValidationStatus::AlreadyConsumed, + reason, + retryable: false, + }, + ProposalStatus::Stale => crate::proposal_pipe::ProposalValidationDecision { + status: ProposalValidationStatus::Stale, + reason, + retryable: false, + }, + ProposalStatus::Rejected => crate::proposal_pipe::ProposalValidationDecision { + status: ProposalValidationStatus::InvalidSchema, + reason, + retryable: true, + }, + ProposalStatus::Unavailable => crate::proposal_pipe::ProposalValidationDecision { + status: ProposalValidationStatus::Unavailable, + reason, + retryable: false, + }, + } + } + + fn commit_terminal_action_proposal(&mut self, proposal_id: &str) -> bool { + let pending = self.tab_sessions.values_mut().find_map(|tab| { + let matches = tab + .pending_terminal_action_proposal + .as_ref() + .map(|pending| pending.proposal_id == proposal_id) + .unwrap_or(false); + matches + .then(|| tab.pending_terminal_action_proposal.take()) + .flatten() + }); + let Some(pending) = pending else { + return false; + }; + match &self.session_tab(&pending.session_id).turn { + TurnState::Submitted(prompt) | TurnState::Streaming { prompt, .. } + if prompt.id == pending.prompt_id => {} + _ => return false, + } + if pending.is_autofix { + self.turn_surface_fix( + &pending.session_id, + pending.recommendations, + "direct_proposal_fix", + ); + } else { + self.turn_surface_recommendation( + &pending.session_id, + pending.recommendations, + "direct_proposal", + ); + } + self.session_tab_mut(&pending.session_id) + .active_direct_proposal_id = Some(proposal_id.to_string()); + true + } + + fn invalidate_terminal_action_proposal(&mut self, proposal_id: &str, session_id: &str) { + let tab = self.session_tab_mut(session_id); + if tab + .pending_terminal_action_proposal + .as_ref() + .is_some_and(|pending| pending.proposal_id == proposal_id) + { + tab.pending_terminal_action_proposal = None; + } + if tab.active_direct_proposal_id.as_deref() == Some(proposal_id) { + self.turn_cancel(session_id); + } + } + /// Close the in-flight turn on `AgentMessageEnd`. Dispatches across /// four termination paths: /// @@ -9339,6 +9622,10 @@ impl App { // so we can stamp the chat history with an "executed" marker after // dispatch. let executed_title = choice.title.clone(); + let direct_proposal_id = self + .session_tab(session_id) + .active_direct_proposal_id + .clone(); let insert_only = self.session_tab(session_id).selected_button == 1 && self.is_send_choice(&choice); // Autofill parent for Send actions when this is an autofix turn. @@ -9364,12 +9651,34 @@ impl App { .prompt() .and_then(|p| p.autofix.as_ref()) .map(|a| a.target_pane_id.clone()); - let _ = self + let final_responder = if let Some(proposal_id) = direct_proposal_id.as_deref() { + let Some(responder) = self.proposal_channels.claim_confirmation(proposal_id) else { + self.turn_cancel(session_id); + return; + }; + Some(responder) + } else { + None + }; + let dispatched = self .recommendation_tx .send(crate::coordinator::ChoiceExecution { choice, insert_only, - }); + }) + .is_ok(); + if let Some(responder) = final_responder { + let status = if dispatched { + crate::proposal_channel::ProposalFinalStatus::Confirmed + } else { + crate::proposal_channel::ProposalFinalStatus::Unavailable + }; + let _ = responder.send(status); + if !dispatched { + self.turn_cancel(session_id); + return; + } + } if armed_pane.is_some() { self.emit_autofix_state_cleared(&target_tab); } @@ -9387,6 +9696,7 @@ impl App { }; tab.selected_recommendation = 0; tab.selected_button = 0; + tab.active_direct_proposal_id = None; tab.rec_scroll.reset(); // Stamp the matching completed_turn (pushed during surface) with an // "executed" marker so chat history reflects the user's choice. @@ -9411,6 +9721,10 @@ impl App { /// `autofix_generation` so any chunks that arrive after this point are /// dropped by the stale-check in `turn_observe_chunk`. pub fn turn_cancel(&mut self, session_id: &str) { + let direct_proposal_id = self + .session_tab(session_id) + .active_direct_proposal_id + .clone(); let target_tab = self.tab_for_session(session_id); let pane_id = { let tab = self.session_tab_mut(session_id); @@ -9485,6 +9799,14 @@ impl App { tab.progress_status = None; tab.activity_frame = 0; tab.turn = TurnState::Idle; + tab.pending_terminal_action_proposal = None; + tab.active_direct_proposal_id = None; + if let Some(proposal_id) = direct_proposal_id.as_deref() { + self.proposal_channels.resolve_final( + proposal_id, + crate::proposal_channel::ProposalFinalStatus::Cancelled, + ); + } // Esc on a Send card or in-flight autofix exits the chip-override // state; release whatever the helper had pinned. C++ falls back to @@ -10025,7 +10347,6 @@ fn build_switch_agent_event(window_id: &str, tab_id: &str, agent_id: &str) -> St .to_string() } - /// Tell WT which pane in `tab_id` should display the blue "Agent" chip. /// `pane_session_id = None` releases the override and lets the C++ side /// fall back to its source-of-agent driven default. Fires per-tab; multiple @@ -10447,7 +10768,10 @@ mod tests { let tab = app.tab_sessions.get("tab-a").expect("target tab exists"); assert_eq!(tab.input, expected); assert_eq!(tab.cursor_pos, tab.input.len()); - assert!(tab.messages.iter().all(|m| !matches!(m, ChatMessage::User(_)))); + assert!(tab + .messages + .iter() + .all(|m| !matches!(m, ChatMessage::User(_)))); } #[test] @@ -10479,8 +10803,14 @@ mod tests { app.tab_id = Some("tab-a".into()); app.tab_mut("tab-a"); - assert_eq!(app.agent_paste_target_tab(&agent_paste_params("w2", "tab-a")), None); - assert_eq!(app.agent_paste_target_tab(&agent_paste_params("w1", "tab-b")), None); + assert_eq!( + app.agent_paste_target_tab(&agent_paste_params("w2", "tab-a")), + None + ); + assert_eq!( + app.agent_paste_target_tab(&agent_paste_params("w1", "tab-b")), + None + ); assert!(app.tab_sessions.get("tab-a").unwrap().input.is_empty()); assert!( @@ -10497,8 +10827,15 @@ mod tests { let mut app = test_app(); app.window_id = Some("w1".into()); app.owner_tab_id = None; - assert_eq!(app.agent_paste_target_tab(&agent_paste_params("w1", "tab-a")), None); - assert!(app.tab_sessions.get("tab-a").map(|t| t.input.is_empty()).unwrap_or(true)); + assert_eq!( + app.agent_paste_target_tab(&agent_paste_params("w1", "tab-a")), + None + ); + assert!(app + .tab_sessions + .get("tab-a") + .map(|t| t.input.is_empty()) + .unwrap_or(true)); app.owner_tab_id = Some("tab-a".into()); let missing_window = json!({ "tab_id": "tab-a" }); @@ -10604,7 +10941,10 @@ mod tests { let tab = app.tab_sessions.get("tab-a").unwrap(); assert!(tab.input.is_empty()); - assert!(tab.paste_pending, "stale completion must not clear a newer pending paste"); + assert!( + tab.paste_pending, + "stale completion must not clear a newer pending paste" + ); } #[test] @@ -10621,7 +10961,11 @@ mod tests { tab_id: Some("tab-a".into()), params: agent_paste_params("w1", "tab-a"), }); - assert!(app.tab_sessions.get("tab-a").map(|t| t.input.is_empty()).unwrap_or(true)); + assert!(app + .tab_sessions + .get("tab-a") + .map(|t| t.input.is_empty()) + .unwrap_or(true)); app.mode = AppMode::Setup; app.handle_event(AppEvent::WtEvent { @@ -10630,7 +10974,11 @@ mod tests { tab_id: Some("tab-a".into()), params: agent_paste_params("w1", "tab-a"), }); - assert!(app.tab_sessions.get("tab-a").map(|t| t.input.is_empty()).unwrap_or(true)); + assert!(app + .tab_sessions + .get("tab-a") + .map(|t| t.input.is_empty()) + .unwrap_or(true)); } #[test] @@ -10653,12 +11001,18 @@ mod tests { |event| published.push(event), ); - assert!(!dirty, "an internal sidekick event must not dirty the registry"); + assert!( + !dirty, + "an internal sidekick event must not dirty the registry" + ); assert!( reg.iter_sorted().is_empty(), "an internal sidekick must not create a session row" ); - assert!(published.is_empty(), "an internal sidekick event must not reach master"); + assert!( + published.is_empty(), + "an internal sidekick event must not reach master" + ); } /// Bug-1 fix (PR #73 follow-up): an `agent.notification` hook event @@ -10677,9 +11031,7 @@ mod tests { /// `Attention` locally AND a real-key event is published to master. #[test] fn sessionless_notification_falls_back_to_recent_live_cli_session() { - use crate::agent_sessions::{ - AgentSessionRegistry, AgentStatus, CliSource, SessionEvent, - }; + use crate::agent_sessions::{AgentSessionRegistry, AgentStatus, CliSource, SessionEvent}; let mut reg = AgentSessionRegistry::new(); // One live Copilot session bound to a known pane. reg.apply(SessionEvent::SessionStarted { @@ -10703,15 +11055,14 @@ mod tests { }); let mut published: Vec = Vec::new(); - route_agent_event_to_registry_with_hook_sink( - &mut reg, - unrelated_pane, - ¶ms, - |ev| published.push(ev), - ); + route_agent_event_to_registry_with_hook_sink(&mut reg, unrelated_pane, ¶ms, |ev| { + published.push(ev) + }); // Local reducer flipped the real row to Attention. - let s = reg.get(&"real-copilot-sid".to_string()).expect("row preserved"); + let s = reg + .get(&"real-copilot-sid".to_string()) + .expect("row preserved"); assert_eq!( s.status, AgentStatus::Attention, @@ -10746,9 +11097,7 @@ mod tests { /// multi-tool turn flickers to (and sits at) Idle while the agent is busy. #[test] fn copilot_tool_finished_keeps_working_only_agent_stop_idles() { - use crate::agent_sessions::{ - AgentSessionRegistry, AgentStatus, CliSource, SessionEvent, - }; + use crate::agent_sessions::{AgentSessionRegistry, AgentStatus, CliSource, SessionEvent}; let mut reg = AgentSessionRegistry::new(); let pane = "11111111-1111-1111-1111-111111111111"; let sid = "copilot-sid"; @@ -10773,13 +11122,19 @@ mod tests { // User prompt → Working (turn start). route(&mut reg, "agent.prompt.submit"); - assert_eq!(reg.get(&sid.to_string()).unwrap().status, AgentStatus::Working); + assert_eq!( + reg.get(&sid.to_string()).unwrap().status, + AgentStatus::Working + ); // A parallel batch: three starts, then three finishes. route(&mut reg, "agent.tool.starting"); route(&mut reg, "agent.tool.starting"); route(&mut reg, "agent.tool.starting"); - assert_eq!(reg.get(&sid.to_string()).unwrap().status, AgentStatus::Working); + assert_eq!( + reg.get(&sid.to_string()).unwrap().status, + AgentStatus::Working + ); route(&mut reg, "agent.tool.finished"); assert_eq!( reg.get(&sid.to_string()).unwrap().status, @@ -10808,9 +11163,7 @@ mod tests { /// wins over the heuristic. #[test] fn notification_with_real_session_id_skips_fallback() { - use crate::agent_sessions::{ - AgentSessionRegistry, AgentStatus, CliSource, SessionEvent, - }; + use crate::agent_sessions::{AgentSessionRegistry, AgentStatus, CliSource, SessionEvent}; let mut reg = AgentSessionRegistry::new(); // Two Copilot sessions; `target` is the explicit one in the hook, // `other` is the most-recently-active and would win the fallback. @@ -10838,9 +11191,7 @@ mod tests { "payload": { "message": "explicit" } }); let unrelated_pane = "99999999-9999-9999-9999-999999999999"; - route_agent_event_to_registry_with_hook_sink( - &mut reg, unrelated_pane, ¶ms, |_| {}, - ); + route_agent_event_to_registry_with_hook_sink(&mut reg, unrelated_pane, ¶ms, |_| {}); assert_eq!( reg.get(&"target".to_string()).unwrap().status, @@ -10860,9 +11211,7 @@ mod tests { /// the most recent across ALL CLIs. #[test] fn sessionless_notification_with_unknown_cli_does_not_fall_back() { - use crate::agent_sessions::{ - AgentSessionRegistry, AgentStatus, CliSource, SessionEvent, - }; + use crate::agent_sessions::{AgentSessionRegistry, AgentStatus, CliSource, SessionEvent}; let mut reg = AgentSessionRegistry::new(); reg.apply(SessionEvent::SessionStarted { key: "copilot".into(), @@ -11115,7 +11464,10 @@ mod tests { other => panic!("unexpected event: {:?}", other), } } - assert!(count >= 1, "at least one real-keyed event must reach master"); + assert!( + count >= 1, + "at least one real-keyed event must reach master" + ); } fn test_app_with_master_rx() -> ( @@ -11780,7 +12132,9 @@ mod tests { assert_eq!(t0.prompt, "# Terminal Agent…"); // details = [original full User, Agent reply]. assert_eq!(t0.details.len(), 2); - assert!(matches!(&t0.details[0], ChatMessage::User(s) if s.starts_with("# Terminal Agent\nYou are"))); + assert!( + matches!(&t0.details[0], ChatMessage::User(s) if s.starts_with("# Terminal Agent\nYou are")) + ); assert!(matches!(&t0.details[1], ChatMessage::Agent(_))); assert!(!t0.expanded, "replayed turn must default to collapsed"); assert!(t0.trailing_marker.is_none()); @@ -11865,10 +12219,14 @@ mod tests { }); // Simulate replay chunks landing in messages. let tab = app.tab_sessions.get_mut("OWNER-TAB").unwrap(); - tab.messages.push(ChatMessage::User("first prompt".to_string())); - tab.messages.push(ChatMessage::Agent("first reply".to_string())); - tab.messages.push(ChatMessage::User("second prompt".to_string())); - tab.messages.push(ChatMessage::Agent("second reply".to_string())); + tab.messages + .push(ChatMessage::User("first prompt".to_string())); + tab.messages + .push(ChatMessage::Agent("first reply".to_string())); + tab.messages + .push(ChatMessage::User("second prompt".to_string())); + tab.messages + .push(ChatMessage::Agent("second reply".to_string())); app.handle_event(AppEvent::SessionAttached { tab_id: "OWNER-TAB".to_string(), @@ -12252,9 +12610,9 @@ mod tests { .try_recv() .expect("registration should use the replacement sender") { - crate::protocol::acp::client::MasterExtRequest::SessionBornBound { - event: actual, - } => assert_eq!(actual, event), + crate::protocol::acp::client::MasterExtRequest::SessionBornBound { event: actual } => { + assert_eq!(actual, event) + } other => panic!("expected SessionBornBound, got {other:?}"), } } @@ -12467,7 +12825,8 @@ mod tests { cwd: PathBuf::from("/x"), title: "pane".into(), }); - app.agent_sessions.set_origin("pane-key", SessionOrigin::AgentPane); + app.agent_sessions + .set_origin("pane-key", SessionOrigin::AgentPane); let rows = app.agents_rows_for_tab(DEFAULT_TAB_ID); assert_eq!(rows.len(), 1); @@ -12494,7 +12853,9 @@ mod tests { let mut info = session_info_for_test("wsl-1"); info.origin = Some(crate::agent_sessions::SessionOrigin::Unknown); - info.location = SessionLocation::Wsl { distro: "Ubuntu".into() }; + info.location = SessionLocation::Wsl { + distro: "Ubuntu".into(), + }; app.current_tab_mut().current_view = View::Agents; app.current_tab_mut().agents_view.snapshot = Some(vec![info]); @@ -12508,7 +12869,9 @@ mod tests { ); assert_eq!( rows[0].location, - SessionLocation::Wsl { distro: "Ubuntu".into() }, + SessionLocation::Wsl { + distro: "Ubuntu".into() + }, "distro name must round-trip through session_info_to_agent_session" ); } @@ -12532,7 +12895,9 @@ mod tests { let mut info = session_info_for_test("wsl-render-1"); info.title = Some("hack on wsl".into()); info.origin = Some(crate::agent_sessions::SessionOrigin::Unknown); - info.location = SessionLocation::Wsl { distro: "Ubuntu".into() }; + info.location = SessionLocation::Wsl { + distro: "Ubuntu".into(), + }; app.current_tab_mut().current_view = View::Agents; app.current_tab_mut().agents_view.snapshot = Some(vec![info]); @@ -12560,17 +12925,29 @@ mod tests { let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); std::env::remove_var("WTA_SESSIONS_SHOW_AGENT_PANE"); - assert_eq!(crate::app::resolve_sessions_origin_filter(), MVP_SESSIONS_ORIGIN_FILTER); + assert_eq!( + crate::app::resolve_sessions_origin_filter(), + MVP_SESSIONS_ORIGIN_FILTER + ); assert_eq!(MVP_SESSIONS_ORIGIN_FILTER, OriginFilter::ShellOnly); std::env::set_var("WTA_SESSIONS_SHOW_AGENT_PANE", "1"); - assert_eq!(crate::app::resolve_sessions_origin_filter(), OriginFilter::All); + assert_eq!( + crate::app::resolve_sessions_origin_filter(), + OriginFilter::All + ); std::env::set_var("WTA_SESSIONS_SHOW_AGENT_PANE", "true"); - assert_eq!(crate::app::resolve_sessions_origin_filter(), OriginFilter::All); + assert_eq!( + crate::app::resolve_sessions_origin_filter(), + OriginFilter::All + ); std::env::set_var("WTA_SESSIONS_SHOW_AGENT_PANE", "0"); - assert_eq!(crate::app::resolve_sessions_origin_filter(), MVP_SESSIONS_ORIGIN_FILTER); + assert_eq!( + crate::app::resolve_sessions_origin_filter(), + MVP_SESSIONS_ORIGIN_FILTER + ); std::env::remove_var("WTA_SESSIONS_SHOW_AGENT_PANE"); } @@ -12834,7 +13211,10 @@ mod tests { // First snapshot landed: rows present, fetch settled — not loading. app.current_tab_mut().agents_view.snapshot = Some(vec![session_info_for_test("a")]); app.current_tab_mut().agents_view.refetch_in_flight = false; - assert!(!app.agents_view_awaiting_snapshot(), "a settled list is not loading"); + assert!( + !app.agents_view_awaiting_snapshot(), + "a settled list is not loading" + ); // F5 dispatches a rescan: the loading shimmer must show even though the // list already has rows, so the refresh is visible. @@ -12941,24 +13321,21 @@ mod tests { let mut title_match = session_info_for_test("title-match"); title_match.title = Some("PowerShell repair".into()); title_match.cwd = std::path::PathBuf::from(r"C:\Windows"); - title_match.pane_session_id = - Some("00000000-0000-0000-0000-0000000000a1".into()); + title_match.pane_session_id = Some("00000000-0000-0000-0000-0000000000a1".into()); title_match.origin = Some(SessionOrigin::Unknown); title_match.last_activity_at_ms = Some(300); let mut unrelated = session_info_for_test("unrelated"); unrelated.title = Some("fix the build".into()); unrelated.cwd = std::path::PathBuf::from(r"C:\Windows"); - unrelated.pane_session_id = - Some("00000000-0000-0000-0000-0000000000b2".into()); + unrelated.pane_session_id = Some("00000000-0000-0000-0000-0000000000b2".into()); unrelated.origin = Some(SessionOrigin::Unknown); unrelated.last_activity_at_ms = Some(200); let mut second_title_match = session_info_for_test("second-title-match"); second_title_match.title = Some("portal review".into()); second_title_match.cwd = std::path::PathBuf::from(r"C:\repos\portal"); - second_title_match.pane_session_id = - Some("00000000-0000-0000-0000-0000000000c3".into()); + second_title_match.pane_session_id = Some("00000000-0000-0000-0000-0000000000c3".into()); second_title_match.origin = Some(SessionOrigin::Unknown); second_title_match.last_activity_at_ms = Some(100); @@ -13026,7 +13403,6 @@ mod tests { View::Agents, "the first Esc dismisses search instead of closing session management" ); - } // Esc out of the session-management (Agents) view restores the pane @@ -13065,7 +13441,8 @@ mod tests { "fold-restore must not switch to chat (would flash before stashing)" ); assert_eq!( - app.current_tab().agents_view_prev_pane_open, None, + app.current_tab().agents_view_prev_pane_open, + None, "the snapshot must be cleared after Esc so a re-entry re-captures" ); } @@ -13207,7 +13584,9 @@ mod tests { argv ); assert!( - cmd.argv.windows(2).any(|args| args == ["--title", "Fix the build"]), + cmd.argv + .windows(2) + .any(|args| args == ["--title", "Fix the build"]), "resume tab must use the session title: {:?}", cmd.argv ); @@ -13222,9 +13601,7 @@ mod tests { // low-contrast tone similar to the cwd line in a typical // Copilot-CLI shell prompt.) assert!( - argv.contains( - "cmd /c echo \x1b[2;37mResuming claude session abc-123...\x1b[0m" - ), + argv.contains("cmd /c echo \x1b[2;37mResuming claude session abc-123...\x1b[0m"), "expected dim-white Resuming banner echo; argv: {:?}", argv ); @@ -13892,15 +14269,19 @@ mod tests { })); // An authenticate-RPC rejection/timeout must NOT trigger auth recovery // (a master restart can't fix bad credentials) — it routes to sign-in. - assert!(!is_post_login_auth_failure(&AgentFailure::HandshakeFailed { - stage: HandshakeStage::Authenticate, - detail: "authenticate rejected/timed out".to_string() - })); + assert!(!is_post_login_auth_failure( + &AgentFailure::HandshakeFailed { + stage: HandshakeStage::Authenticate, + detail: "authenticate rejected/timed out".to_string() + } + )); // A non-auth handshake stage must NOT trigger auth recovery. - assert!(!is_post_login_auth_failure(&AgentFailure::HandshakeFailed { - stage: HandshakeStage::Initialize, - detail: "boom".to_string() - })); + assert!(!is_post_login_auth_failure( + &AgentFailure::HandshakeFailed { + stage: HandshakeStage::Initialize, + detail: "boom".to_string() + } + )); } #[test] @@ -13960,19 +14341,11 @@ mod tests { detail: "pipe missing".to_string(), }; assert!( - should_trigger_post_login_recovery( - true, - false, - &pipe_connect - ), + should_trigger_post_login_recovery(true, false, &pipe_connect), "post-login master-unavailable recovery must not be gated on External auth flow" ); assert!( - !should_trigger_post_login_recovery( - false, - false, - &pipe_connect - ), + !should_trigger_post_login_recovery(false, false, &pipe_connect), "non-post-login pipe failures should surface normally" ); @@ -14138,8 +14511,7 @@ mod tests { failure: crate::protocol::acp::failure::AgentFailure::AuthRequired { message: "authentication required".to_string(), }, - message: "new_session over master pipe failed: authentication required" - .to_string(), + message: "new_session over master pipe failed: authentication required".to_string(), }); assert_eq!( app.mode, @@ -14327,7 +14699,10 @@ mod tests { cwd: PathBuf::from("/work"), title: "t".into(), }); - assert!(app.agent_sessions.is_agent_pane(pane), "precondition: pane is registered as agent-bound"); + assert!( + app.agent_sessions.is_agent_pane(pane), + "precondition: pane is registered as agent-bound" + ); app.handle_event(AppEvent::WtEvent { method: "vt_sequence".to_string(), @@ -14714,8 +15089,7 @@ mod tests { .expect("row exists"); assert!(matches!( before.status, - crate::agent_sessions::AgentStatus::Idle - | crate::agent_sessions::AgentStatus::Working + crate::agent_sessions::AgentStatus::Idle | crate::agent_sessions::AgentStatus::Working )); assert_eq!(before.origin, SessionOrigin::AgentPane); @@ -14783,7 +15157,6 @@ mod tests { async fn mock_agent_reply_streams_into_app_chat() { use crate::protocol::acp::client::mock_agent_tests::connect_mock_agent; use agent_client_protocol as acp; - let local = tokio::task::LocalSet::new(); local @@ -14791,9 +15164,11 @@ mod tests { // Borrow the acp-module harness: deterministic mock wired to a // real WtaClient over an in-memory duplex. let (conn, mut event_rx, _seen) = connect_mock_agent(); - conn.initialize(acp::schema::v1::InitializeRequest::new(acp::schema::ProtocolVersion::LATEST)) - .await - .expect("initialize failed"); + conn.initialize(acp::schema::v1::InitializeRequest::new( + acp::schema::ProtocolVersion::LATEST, + )) + .await + .expect("initialize failed"); let session = conn .new_session(acp::schema::v1::NewSessionRequest::new("/test")) .await @@ -14828,7 +15203,10 @@ mod tests { } }) .await; - assert!(pumped.is_ok(), "timed out waiting for the agent message chunk"); + assert!( + pumped.is_ok(), + "timed out waiting for the agent message chunk" + ); // "What the chat shows" while streaming: the mock's reply is in // the active tab's streaming buffer. @@ -14851,12 +15229,13 @@ mod tests { async fn run_permission_scenario(expected_keys: &[KeyCode], want: &str) { use crate::protocol::acp::client::mock_agent_tests::connect_mock_agent_asking_permission; use agent_client_protocol as acp; - let (conn, mut event_rx, outcome) = connect_mock_agent_asking_permission(); - conn.initialize(acp::schema::v1::InitializeRequest::new(acp::schema::ProtocolVersion::LATEST)) - .await - .expect("initialize failed"); + conn.initialize(acp::schema::v1::InitializeRequest::new( + acp::schema::ProtocolVersion::LATEST, + )) + .await + .expect("initialize failed"); let session = conn .new_session(acp::schema::v1::NewSessionRequest::new("/test")) .await @@ -14888,7 +15267,10 @@ mod tests { } }) .await; - assert!(pumped.is_ok(), "timed out waiting for the permission request"); + assert!( + pumped.is_ok(), + "timed out waiting for the permission request" + ); // Display assertion: the permission card is queued with allow/reject, // allow selected by default. @@ -14961,10 +15343,7 @@ mod tests { async fn permission_quick_allow_key_round_trips_to_agent() { let local = tokio::task::LocalSet::new(); local - .run_until(run_permission_scenario( - &[KeyCode::Char('y')], - "allow-once", - )) + .run_until(run_permission_scenario(&[KeyCode::Char('y')], "allow-once")) .await; } @@ -15054,8 +15433,16 @@ mod tests { .push_back(PermissionState { description: "Allow tool X?".into(), options: vec![ - PermOption { id: "allow-once".into(), name: "Allow".into(), kind: "AllowOnce".into() }, - PermOption { id: "reject-once".into(), name: "Deny".into(), kind: "RejectOnce".into() }, + PermOption { + id: "allow-once".into(), + name: "Allow".into(), + kind: "AllowOnce".into(), + }, + PermOption { + id: "reject-once".into(), + name: "Deny".into(), + kind: "RejectOnce".into(), + }, ], selected: 0, responder: None, @@ -15080,15 +15467,16 @@ mod tests { async fn tool_call_surfaces_card_in_chat() { use crate::protocol::acp::client::mock_agent_tests::connect_mock_agent_proposing_tool; use agent_client_protocol as acp; - let local = tokio::task::LocalSet::new(); local .run_until(async { let (conn, mut event_rx) = connect_mock_agent_proposing_tool(); - conn.initialize(acp::schema::v1::InitializeRequest::new(acp::schema::ProtocolVersion::LATEST)) - .await - .expect("initialize failed"); + conn.initialize(acp::schema::v1::InitializeRequest::new( + acp::schema::ProtocolVersion::LATEST, + )) + .await + .expect("initialize failed"); let session = conn .new_session(acp::schema::v1::NewSessionRequest::new("/test")) .await @@ -15121,9 +15509,9 @@ mod tests { assert!(pumped.is_ok(), "timed out waiting for the tool call"); // Display assertion: the proposed command shows as a tool-call card. - let has_card = app.current_tab().messages.iter().any(|m| { - matches!(m, ChatMessage::ToolCall { title, .. } if title == "Run: echo hi") - }); + let has_card = app.current_tab().messages.iter().any( + |m| matches!(m, ChatMessage::ToolCall { title, .. } if title == "Run: echo hi"), + ); assert!( has_card, "a tool-call card must surface in the chat; got {:?}", @@ -15162,14 +15550,14 @@ mod tests { /// leaving an in-flight turn whose streamed notifications the caller pumps /// into a real `App`. Returns `()` — it only drives ACP traffic; the caller /// owns the `App`. - async fn app_after_prompt( - conn: &crate::protocol::acp::conn::ClientLink, - ) { + async fn app_after_prompt(conn: &crate::protocol::acp::conn::ClientLink) { use agent_client_protocol as acp; - - conn.initialize(acp::schema::v1::InitializeRequest::new(acp::schema::ProtocolVersion::LATEST)) - .await - .expect("initialize failed"); + + conn.initialize(acp::schema::v1::InitializeRequest::new( + acp::schema::ProtocolVersion::LATEST, + )) + .await + .expect("initialize failed"); let session = conn .new_session(acp::schema::v1::NewSessionRequest::new("/test")) .await @@ -15241,13 +15629,22 @@ mod tests { .messages .iter() .filter_map(|m| match m { - ChatMessage::ToolCall { id, status, .. } => Some((id.clone(), status.clone())), + ChatMessage::ToolCall { id, status, .. } => { + Some((id.clone(), status.clone())) + } _ => None, }) .collect(); - assert_eq!(cards.len(), 1, "the update must edit in place, not add a card"); + assert_eq!( + cards.len(), + 1, + "the update must edit in place, not add a card" + ); assert_eq!(cards[0].0, "mock-tool-1"); - assert_eq!(cards[0].1, "Completed", "card status must reflect the update"); + assert_eq!( + cards[0].1, "Completed", + "card status must reflect the update" + ); }) .await; } @@ -15266,7 +15663,10 @@ mod tests { let mut app = test_app(); submit_test_prompt(&mut app, "go"); - pump_until(&mut app, &mut event_rx, |ev| matches!(ev, AppEvent::Plan { .. })).await; + pump_until(&mut app, &mut event_rx, |ev| { + matches!(ev, AppEvent::Plan { .. }) + }) + .await; let plan = app.current_tab().messages.iter().find_map(|m| match m { ChatMessage::Plan(entries) => Some(entries.clone()), @@ -15851,7 +16251,10 @@ mod tests { app.begin_auth_checking(); let auth = app.auth.as_ref().expect("auth screen present"); - assert!(auth.checking, "begin_auth_checking must enter the checking state"); + assert!( + auth.checking, + "begin_auth_checking must enter the checking state" + ); assert!( auth.status_message.is_empty(), "a retry must clear the stale failure status so the checking view \ @@ -15878,9 +16281,16 @@ mod tests { app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert_eq!(app.mode, AppMode::Auth, "collapse stays on the sign-in screen"); + assert_eq!( + app.mode, + AppMode::Auth, + "collapse stays on the sign-in screen" + ); let auth = app.auth.as_ref().expect("collapse keeps the auth screen"); - assert!(!auth.enterprise_mode, "first Esc collapses the enterprise input"); + assert!( + !auth.enterprise_mode, + "first Esc collapses the enterprise input" + ); assert!( auth.status_message.is_empty(), "collapsing must clear the enterprise failure status so it does not linger" @@ -15955,11 +16365,22 @@ mod tests { .auth .as_ref() .expect("Esc collapse must not leave the sign-in screen"); - assert!(!auth.enterprise_mode, "Esc must collapse the enterprise input"); - assert_eq!(app.mode, AppMode::Auth, "Esc collapse must stay in Auth mode"); + assert!( + !auth.enterprise_mode, + "Esc must collapse the enterprise input" + ); + assert_eq!( + app.mode, + AppMode::Auth, + "Esc collapse must stay in Auth mode" + ); } - fn agent_status_for_test(id: &str, display: &str, cli_found: bool) -> crate::agent_check::AgentStatus { + fn agent_status_for_test( + id: &str, + display: &str, + cli_found: bool, + ) -> crate::agent_check::AgentStatus { crate::agent_check::AgentStatus { id: id.into(), display_name: display.into(), @@ -16006,7 +16427,10 @@ mod tests { app.show_copilot_auth_screen(); assert_eq!(app.mode, AppMode::Auth); - assert!(app.setup.is_none(), "auth screen should replace setup state"); + assert!( + app.setup.is_none(), + "auth screen should replace setup state" + ); assert_eq!(app.current_agent_id, "copilot"); let auth = app.auth.as_ref().expect("copilot auth state"); assert_eq!(auth.agent_id, "copilot"); @@ -16155,7 +16579,6 @@ mod tests { ); } - /// the action's command body (the card shows the command, not the choice /// `title` field, which only surfaces for action-less choices) plus the /// run-command button. Lifts `ui/recommendations.rs` (reached only when @@ -16211,9 +16634,12 @@ mod tests { { let tab = app.current_tab_mut(); tab.messages.push(ChatMessage::User("USER_MSG_XYZ".into())); - tab.messages.push(ChatMessage::Agent("AGENT_MSG_XYZ".into())); - tab.messages.push(ChatMessage::System("SYSTEM_MSG_XYZ".into())); - tab.messages.push(ChatMessage::Error("ERROR_MSG_XYZ".into())); + tab.messages + .push(ChatMessage::Agent("AGENT_MSG_XYZ".into())); + tab.messages + .push(ChatMessage::System("SYSTEM_MSG_XYZ".into())); + tab.messages + .push(ChatMessage::Error("ERROR_MSG_XYZ".into())); tab.messages .push(ChatMessage::AgentEvent("AGENT_EVENT_MSG_XYZ".into())); tab.messages.push(ChatMessage::Plan(vec![ @@ -16415,7 +16841,11 @@ mod tests { // The matching prompt id binds the resolved working pane. app.apply_autofix_target_resolved(Some(DEFAULT_TAB_ID.into()), 42, "pane-7".into()); - assert_eq!(fix_target_pane(&app), "pane-7", "matching id binds the pane"); + assert_eq!( + fix_target_pane(&app), + "pane-7", + "matching id binds the pane" + ); } #[test] @@ -16677,6 +17107,479 @@ mod tests { assert!(app.current_tab().turn.accepts_new_prompt()); } + // ─── Direct Helper proposal validation and staging ──────────────────── + // + // Session ids are explicitly staged via `session_to_tab` rather than + // relying on `tab_for_session`'s unknown-id fallback, so validation + // exercises the direct channel's fail-closed Helper ownership boundary. + + fn stage_proposal_session(app: &mut App, sid: &str) { + app.session_to_tab + .insert(sid.to_string(), DEFAULT_TAB_ID.to_string()); + } + + fn submit_prompt_for_session( + app: &mut App, + sid: &str, + text: &str, + autofix: Option, + ) { + let prompt = SubmittedPrompt { + id: 99, + text: text.into(), + submitted_at_unix_s: 0.0, + autofix, + }; + app.turn_submit_prompt(sid, prompt); + } + + const TERMINAL_AGENT_PROPOSAL_PAYLOAD: &str = r#"{"schema_version":1,"origin":"terminal_agent","recommended_choice":1,"choices":[{"choice":1,"title":"restart service","rationale":"r","actions":[{"type":"send","input":"Restart-Service foo"}]}]}"#; + + fn autofix_proposal_payload() -> String { + r#"{"schema_version":1,"origin":"autofix","choices":[{"choice":1,"title":"fix it","rationale":"r","actions":[{"type":"send","input":"echo fix"}]}]}"#.to_string() + } + + fn evaluate_direct_proposal( + app: &mut App, + sid: &str, + prompt_id: u64, + active_target: Option<&str>, + payload: &str, + ) -> (crate::proposal_pipe::ProposalValidationDecision, String) { + let manager = crate::proposal_channel::ProposalChannelManager::new(); + let is_autofix = app + .session_to_tab + .contains_key(sid) + .then(|| app.session_tab(sid).turn.is_autofix()) + .unwrap_or(false); + let channel = manager + .issue( + sid.to_string(), + prompt_id, + active_target.map(str::to_string), + is_autofix, + ) + .expect("issue direct proposal channel"); + manager + .arm(sid, &channel, payload.as_bytes()) + .expect("arm direct proposal channel"); + let context = manager + .begin_validation(&channel, payload.as_bytes()) + .expect("begin direct proposal validation"); + let proposal_id = context.proposal_id.clone(); + ( + app.evaluate_direct_terminal_action_proposal(&context, payload), + proposal_id, + ) + } + + #[test] + fn direct_proposal_confirm_resolves_waiting_cli() { + let mut app = test_app(); + let (recommendation_tx, mut recommendation_rx) = tokio::sync::mpsc::unbounded_channel(); + app.recommendation_tx = recommendation_tx; + let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + app.set_proposal_channels(Arc::clone(&manager)); + let sid = "sess-direct-confirm"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "restart it", None); + let channel = manager + .issue(sid.to_string(), 99, Some("pane-9".to_string()), false) + .unwrap(); + manager + .arm(sid, &channel, TERMINAL_AGENT_PROPOSAL_PAYLOAD.as_bytes()) + .unwrap(); + let context = manager + .begin_validation(&channel, TERMINAL_AGENT_PROPOSAL_PAYLOAD.as_bytes()) + .unwrap(); + let proposal_id = context.proposal_id.clone(); + let (decision_tx, decision_rx) = tokio::sync::oneshot::channel(); + + app.handle_event(AppEvent::DirectTerminalActionProposal { + context, + payload: TERMINAL_AGENT_PROPOSAL_PAYLOAD.to_string(), + responder: decision_tx, + }); + assert_eq!( + decision_rx.blocking_recv().unwrap().status, + crate::proposal_channel::ProposalValidationStatus::Accepted + ); + let (final_tx, final_rx) = tokio::sync::oneshot::channel(); + assert!(manager.accept_validation(&proposal_id, final_tx)); + app.handle_event(AppEvent::DirectTerminalActionProposalCommit { + proposal_id: proposal_id.clone(), + }); + assert!(matches!( + app.session_tab(sid).turn, + TurnState::Surfaced { + outcome: TurnOutcome::Recommendation(_), + .. + } + )); + + app.turn_execute_card(sid); + assert_eq!( + final_rx.blocking_recv().unwrap(), + crate::proposal_channel::ProposalFinalStatus::Confirmed + ); + assert!(recommendation_rx.try_recv().is_ok()); + } + + #[test] + fn direct_proposal_cancel_between_validation_and_commit_does_not_surface() { + let mut app = test_app(); + let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + app.set_proposal_channels(Arc::clone(&manager)); + let sid = "sess-direct-cancel-before-commit"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "restart it", None); + let channel = manager + .issue(sid.to_string(), 99, Some("pane-9".to_string()), false) + .unwrap(); + manager + .arm(sid, &channel, TERMINAL_AGENT_PROPOSAL_PAYLOAD.as_bytes()) + .unwrap(); + let context = manager + .begin_validation(&channel, TERMINAL_AGENT_PROPOSAL_PAYLOAD.as_bytes()) + .unwrap(); + let proposal_id = context.proposal_id.clone(); + let (decision_tx, decision_rx) = tokio::sync::oneshot::channel(); + app.handle_event(AppEvent::DirectTerminalActionProposal { + context, + payload: TERMINAL_AGENT_PROPOSAL_PAYLOAD.to_string(), + responder: decision_tx, + }); + assert_eq!( + decision_rx.blocking_recv().unwrap().status, + crate::proposal_channel::ProposalValidationStatus::Accepted + ); + let (final_tx, final_rx) = tokio::sync::oneshot::channel(); + assert!(manager.accept_validation(&proposal_id, final_tx)); + + app.turn_cancel(sid); + app.handle_event(AppEvent::DirectTerminalActionProposalCommit { proposal_id }); + + assert!(app.session_tab(sid).turn.is_idle()); + assert_eq!( + final_rx.blocking_recv().unwrap(), + crate::proposal_channel::ProposalFinalStatus::Cancelled + ); + } + + #[test] + fn direct_proposal_cancel_resolves_waiting_cli() { + let mut app = test_app(); + let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + app.set_proposal_channels(Arc::clone(&manager)); + let sid = "sess-direct-cancel"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "restart it", None); + let channel = manager + .issue(sid.to_string(), 99, Some("pane-9".to_string()), false) + .unwrap(); + manager + .arm(sid, &channel, TERMINAL_AGENT_PROPOSAL_PAYLOAD.as_bytes()) + .unwrap(); + let context = manager + .begin_validation(&channel, TERMINAL_AGENT_PROPOSAL_PAYLOAD.as_bytes()) + .unwrap(); + let proposal_id = context.proposal_id.clone(); + let (decision_tx, decision_rx) = tokio::sync::oneshot::channel(); + app.handle_event(AppEvent::DirectTerminalActionProposal { + context, + payload: TERMINAL_AGENT_PROPOSAL_PAYLOAD.to_string(), + responder: decision_tx, + }); + assert_eq!( + decision_rx.blocking_recv().unwrap().status, + crate::proposal_channel::ProposalValidationStatus::Accepted + ); + let (final_tx, final_rx) = tokio::sync::oneshot::channel(); + assert!(manager.accept_validation(&proposal_id, final_tx)); + app.handle_event(AppEvent::DirectTerminalActionProposalCommit { proposal_id }); + + app.turn_cancel(sid); + assert_eq!( + final_rx.blocking_recv().unwrap(), + crate::proposal_channel::ProposalFinalStatus::Cancelled + ); + } + + #[test] + fn direct_proposal_presents_terminal_agent_card() { + let mut app = test_app(); + let sid = "sess-proposal-terminal-agent"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "please help", None); + + let (decision, proposal_id) = evaluate_direct_proposal( + &mut app, + sid, + 99, + Some("pane-9"), + TERMINAL_AGENT_PROPOSAL_PAYLOAD, + ); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::Accepted + ); + assert!(decision.reason.is_none()); + app.commit_terminal_action_proposal(&proposal_id); + let tab = app.session_tab(sid); + assert!( + matches!( + tab.turn, + TurnState::Surfaced { + outcome: TurnOutcome::Recommendation(_), + end_pending: true, + .. + } + ), + "expected the proposal to surface a recommendation card, got {:?}", + tab.turn + ); + } + + #[test] + fn direct_proposal_presents_autofix_card() { + let mut app = test_app(); + let sid = "sess-proposal-autofix-ok"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session( + &mut app, + sid, + "autofix run", + Some(AutofixContext { + target_pane_id: "pane-9".into(), + generation: 0, + }), + ); + + let payload = autofix_proposal_payload(); + let (decision, proposal_id) = evaluate_direct_proposal(&mut app, sid, 99, None, &payload); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::Accepted + ); + assert!(decision.reason.is_none()); + app.commit_terminal_action_proposal(&proposal_id); + match &app.session_tab(sid).turn { + TurnState::Surfaced { + outcome: TurnOutcome::Recommendation(set), + .. + } => { + // Autofix accepts exactly one Send; the real failing pane is + // bound at card-execution time (existing `AutofixContext` + // flow), never taken from the model's proposal. + assert_eq!(set.choices.len(), 1); + } + other => panic!("expected a surfaced recommendation, got {other:?}"), + } + } + + #[test] + fn direct_proposal_for_an_earlier_prompt_is_stale() { + let mut app = test_app(); + let sid = "sess-proposal-old-turn"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "new prompt", None); + + let (decision, _) = evaluate_direct_proposal( + &mut app, + sid, + 98, + Some("pane-9"), + TERMINAL_AGENT_PROPOSAL_PAYLOAD, + ); + + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::Stale + ); + assert_eq!( + decision.reason.as_deref(), + Some("proposal belongs to an earlier prompt") + ); + assert!(matches!(app.session_tab(sid).turn, TurnState::Submitted(_))); + } + + #[test] + fn direct_proposal_after_eager_text_fallback_surface_is_duplicate() { + let mut app = test_app(); + let sid = "sess-proposal-dup"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "please help", None); + // Eager text-fallback already surfaced a card for this turn before + // the model's `propose-terminal-actions` tool call landed. + let json = r#"```json +{"recommended_choice":1,"choices":[{"choice":1,"title":"do it","rationale":"r","actions":[{"type":"send","parent":"pane-X","input":"ls"}]}]} +```"#; + app.turn_observe_chunk(sid, ChunkKind::Message, json); + app.turn_try_eager_surface(sid); + assert!(matches!( + app.session_tab(sid).turn, + TurnState::Surfaced { .. } + )); + + let (decision, _) = evaluate_direct_proposal( + &mut app, + sid, + 99, + Some("pane-9"), + TERMINAL_AGENT_PROPOSAL_PAYLOAD, + ); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::AlreadyConsumed + ); + assert!(decision.reason.is_some()); + } + + #[test] + fn direct_proposal_card_survives_turn_close_without_a_second_card() { + // The de-duplication mechanism: once the proposal surfaces the card + // (transitioning `tab.turn` into `Surfaced{end_pending:true}`), + // `turn_close`'s existing "eager surface already fired" path must + // just release the UI gate on `AgentMessageEnd` -- never re-parse + // the streamed buffer (which, after a tool call, is often JSON-only + // or empty) into a second card. + let mut app = test_app(); + let sid = "sess-proposal-close-dedup"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "please help", None); + + let (decision, proposal_id) = evaluate_direct_proposal( + &mut app, + sid, + 99, + Some("pane-9"), + TERMINAL_AGENT_PROPOSAL_PAYLOAD, + ); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::Accepted + ); + app.commit_terminal_action_proposal(&proposal_id); + + app.turn_close(sid); + let tab = app.session_tab(sid); + assert_eq!( + tab.completed_turns.len(), + 1, + "exactly one card must exist, no duplicate from turn_close" + ); + assert!( + matches!( + tab.turn, + TurnState::Surfaced { + end_pending: false, + outcome: TurnOutcome::Recommendation(_), + .. + } + ), + "end_pending released, card retained, got {:?}", + tab.turn + ); + } + + #[test] + fn direct_proposal_with_no_turn_in_flight_is_stale() { + let mut app = test_app(); + let sid = "sess-proposal-idle"; + stage_proposal_session(&mut app, sid); + // No prompt was ever submitted for this session -> Idle. + let (decision, _) = evaluate_direct_proposal( + &mut app, + sid, + 99, + Some("pane-9"), + TERMINAL_AGENT_PROPOSAL_PAYLOAD, + ); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::Stale + ); + assert!(decision.reason.is_some()); + } + + #[test] + fn direct_proposal_stale_when_autofix_generation_diverges() { + let mut app = test_app(); + let sid = "sess-proposal-autofix-stale"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session( + &mut app, + sid, + "autofix run", + Some(AutofixContext { + target_pane_id: "pane-1".into(), + generation: 0, + }), + ); + // A newer trigger (or an Esc cancel) bumps the tab's live counter + // while this turn is still in flight. + app.tab_mut(DEFAULT_TAB_ID).autofix.generation = 1; + + let payload = autofix_proposal_payload(); + let (decision, _) = evaluate_direct_proposal(&mut app, sid, 99, None, &payload); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::Stale + ); + assert!(decision.reason.is_some()); + } + + #[test] + fn direct_proposal_rejects_unsupported_schema_version() { + let mut app = test_app(); + let sid = "sess-proposal-badschema"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "please help", None); + + let bad = r#"{"schema_version":99,"origin":"terminal_agent","choices":[]}"#; + let (decision, _) = evaluate_direct_proposal(&mut app, sid, 99, Some("pane-9"), bad); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::InvalidSchema + ); + assert!(decision.reason.unwrap().contains("schema_version")); + } + + #[test] + fn direct_proposal_rejects_origin_mismatch_with_live_turn() { + let mut app = test_app(); + let sid = "sess-proposal-originmismatch"; + stage_proposal_session(&mut app, sid); + // Plain (non-autofix) turn, but the proposal claims Autofix origin. + submit_prompt_for_session(&mut app, sid, "please help", None); + + let payload = autofix_proposal_payload(); + let (decision, _) = evaluate_direct_proposal(&mut app, sid, 99, None, &payload); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::InvalidSchema + ); + assert!(decision.reason.unwrap().contains("does not match")); + } + + #[test] + fn direct_proposal_unknown_session_is_unavailable() { + let mut app = test_app(); + // Never staged via `session_to_tab`; the Helper fails closed. + let (decision, _) = evaluate_direct_proposal( + &mut app, + "sess-never-bound", + 99, + Some("pane-9"), + TERMINAL_AGENT_PROPOSAL_PAYLOAD, + ); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::Unavailable + ); + assert!(decision.reason.is_some()); + } + // ─── card / panel height math ─────────────────────────────────────────── use crate::app::turn_state::{SubmittedPrompt, TurnOutcome, TurnState}; @@ -17037,11 +17940,10 @@ mod tests { assert!(app.current_tab().input.is_empty()); assert_eq!(app.current_tab().input_history.entries[0], "remember me"); - assert!( - app.tab_sessions - .get("another-tab") - .is_some_and(|tab| tab.input_history.entries.is_empty()) - ); + assert!(app + .tab_sessions + .get("another-tab") + .is_some_and(|tab| tab.input_history.entries.is_empty())); } #[test] @@ -17189,7 +18091,8 @@ mod tests { fn command_popup_keeps_arrow_priority_over_input_history() { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let mut app = test_app(); - app.current_tab_mut().record_input_history("historical prompt"); + app.current_tab_mut() + .record_input_history("historical prompt"); app.current_tab_mut().input.push('/'); app.current_tab_mut().cursor_pos = 1; app.current_tab_mut().refresh_command_popup(); @@ -17282,12 +18185,7 @@ mod tests { #[test] fn chip_target_uses_send_parent_when_set() { let mut app = test_app(); - stage_surfaced_recommendation( - &mut app, - vec![send_choice("pane-A", "ls")], - 0, - None, - ); + stage_surfaced_recommendation(&mut app, vec![send_choice("pane-A", "ls")], 0, None); assert_eq!( app.current_tab().compute_chip_card_target(), Some("pane-A".to_string()), @@ -17318,12 +18216,7 @@ mod tests { // Some("") would let the helper's dedupe believe it pinned the chip // while WT silently ignores the event. let mut app = test_app(); - stage_surfaced_recommendation( - &mut app, - vec![send_choice("", "fix")], - 0, - Some(""), - ); + stage_surfaced_recommendation(&mut app, vec![send_choice("", "fix")], 0, Some("")); assert_eq!(app.current_tab().compute_chip_card_target(), None); } @@ -17361,12 +18254,7 @@ mod tests { // the next recompute observe a different value and clear the // last_emitted slot. let mut app = test_app(); - stage_surfaced_recommendation( - &mut app, - vec![send_choice("pane-A", "ls")], - 0, - None, - ); + stage_surfaced_recommendation(&mut app, vec![send_choice("pane-A", "ls")], 0, None); app.recompute_chip_override(DEFAULT_TAB_ID); assert_eq!( app.tab_mut(DEFAULT_TAB_ID).last_emitted_chip_override, @@ -17377,26 +18265,26 @@ mod tests { // the dedupe slot must follow so a fresh surface re-emits cleanly. app.tab_mut(DEFAULT_TAB_ID).turn = TurnState::Idle; app.recompute_chip_override(DEFAULT_TAB_ID); - assert_eq!( - app.tab_mut(DEFAULT_TAB_ID).last_emitted_chip_override, - None, - ); + assert_eq!(app.tab_mut(DEFAULT_TAB_ID).last_emitted_chip_override, None,); } #[test] fn known_cli_id_returns_some_for_all_first_party_clis() { use crate::agent_sessions::CliSource; - assert_eq!(known_cli_id(&CliSource::Claude), Some("claude")); - assert_eq!(known_cli_id(&CliSource::Codex), Some("codex")); + assert_eq!(known_cli_id(&CliSource::Claude), Some("claude")); + assert_eq!(known_cli_id(&CliSource::Codex), Some("codex")); assert_eq!(known_cli_id(&CliSource::Copilot), Some("copilot")); - assert_eq!(known_cli_id(&CliSource::Gemini), Some("gemini")); + assert_eq!(known_cli_id(&CliSource::Gemini), Some("gemini")); assert_eq!(known_cli_id(&CliSource::OpenCode), Some("opencode")); } #[test] fn known_cli_id_returns_none_for_unknown_variant() { use crate::agent_sessions::CliSource; - assert_eq!(known_cli_id(&CliSource::Unknown("anything".to_string())), None); + assert_eq!( + known_cli_id(&CliSource::Unknown("anything".to_string())), + None + ); } #[test] @@ -17404,22 +18292,24 @@ mod tests { use crate::agent_sessions::{AgentStatus, CliSource, SessionLocation, SessionOrigin}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let row = crate::agent_sessions::AgentSession { - key: "abc-123".to_string(), - cli_source: CliSource::Copilot, - pane_session_id: None, - window_id: None, - tab_id: None, - title: "t".to_string(), - cwd: std::path::PathBuf::from("/home/u/proj"), - started_at: std::time::SystemTime::UNIX_EPOCH, + key: "abc-123".to_string(), + cli_source: CliSource::Copilot, + pane_session_id: None, + window_id: None, + tab_id: None, + title: "t".to_string(), + cwd: std::path::PathBuf::from("/home/u/proj"), + started_at: std::time::SystemTime::UNIX_EPOCH, last_activity_at: std::time::SystemTime::UNIX_EPOCH, - status: AgentStatus::Historical, - last_error: None, - current_tool: None, + status: AgentStatus::Historical, + last_error: None, + current_tool: None, attention_reason: None, - log_path: None, - origin: SessionOrigin::Unknown, - location: SessionLocation::Wsl { distro: "Ubuntu".to_string() }, + log_path: None, + origin: SessionOrigin::Unknown, + location: SessionLocation::Wsl { + distro: "Ubuntu".to_string(), + }, }; let mut app = test_app(); app.agent_sessions.merge_historical(vec![row]); @@ -17433,7 +18323,9 @@ mod tests { assert_eq!(cmd.kind, DispatchedCommandKind::NewTabResume); let argv = cmd.argv.join(" "); assert!( - argv.contains("wsl -d Ubuntu --cd \"/home/u/proj\" -- bash -lc \"copilot --resume abc-123\""), + argv.contains( + "wsl -d Ubuntu --cd \"/home/u/proj\" -- bash -lc \"copilot --resume abc-123\"" + ), "expected in-distro resume; argv: {argv}" ); // The loading banner keeps the short session id and also names the @@ -17443,6 +18335,9 @@ mod tests { "expected distro-named WSL banner; argv: {argv}" ); // WSL rows must not also pass the Windows `-d ` flag. - assert!(!argv.contains(" -d /home"), "WSL row must not pass Windows -d cwd"); + assert!( + !argv.contains(" -d /home"), + "WSL row must not pass Windows -d cwd" + ); } } diff --git a/tools/wta/src/cli_tests.rs b/tools/wta/src/cli_tests.rs index 60e331a258..da94a27562 100644 --- a/tools/wta/src/cli_tests.rs +++ b/tools/wta/src/cli_tests.rs @@ -67,7 +67,9 @@ fn sessions_list_cli_parses_json_and_master_override() { assert!(cli.json); match cli.command { - Some(Command::Sessions { action: SessionsAction::List { master, origin } }) => { + Some(Command::Sessions { + action: SessionsAction::List { master, origin }, + }) => { assert_eq!(master.as_deref(), Some(r"\\.\pipe\wta-master-test")); // Default keeps the historical debug behavior — show // every origin. MVP sessions picker has its own default in @@ -85,12 +87,11 @@ fn sessions_list_cli_parses_origin_shell() { let cli = Cli::try_parse_from(["wta", "sessions", "list", "--origin", "shell"]) .expect("sessions list --origin shell parses"); match cli.command { - Some(Command::Sessions { action: SessionsAction::List { origin, .. } }) => { + Some(Command::Sessions { + action: SessionsAction::List { origin, .. }, + }) => { assert_eq!(origin, SessionsOriginArg::Shell); - assert_eq!( - origin.to_filter(), - agent_sessions::OriginFilter::ShellOnly, - ); + assert_eq!(origin.to_filter(), agent_sessions::OriginFilter::ShellOnly,); } other => panic!("expected sessions list command, got {other:?}"), } @@ -101,7 +102,9 @@ fn sessions_list_cli_parses_origin_agent_pane() { let cli = Cli::try_parse_from(["wta", "sessions", "list", "--origin", "agent-pane"]) .expect("sessions list --origin agent-pane parses"); match cli.command { - Some(Command::Sessions { action: SessionsAction::List { origin, .. } }) => { + Some(Command::Sessions { + action: SessionsAction::List { origin, .. }, + }) => { assert_eq!(origin, SessionsOriginArg::AgentPane); assert_eq!( origin.to_filter(), @@ -153,10 +156,19 @@ fn sessions_table_prints_header_and_rows() { // operator can tell "legacy / unclassified" from "shell". assert!(out.contains("ORIGIN")); let body = out.lines().nth(1).expect("body row present"); - assert!(body.contains(" - "), "untagged origin renders as '-' got: {body}"); + assert!( + body.contains(" - "), + "untagged origin renders as '-' got: {body}" + ); // Leading 1-based index column. - assert!(out.lines().next().expect("header").starts_with("#"), "header has # column"); - assert!(body.starts_with("1"), "first row is numbered 1, got: {body}"); + assert!( + out.lines().next().expect("header").starts_with("#"), + "header has # column" + ); + assert!( + body.starts_with("1"), + "first row is numbered 1, got: {body}" + ); } #[test] @@ -174,7 +186,10 @@ fn sessions_table_renders_origin_labels() { let out = format_sessions_table(&[shell, pane]); assert!(out.contains("Shell"), "shell origin label present: {out}"); - assert!(out.contains("AgentPane"), "agent-pane origin label present: {out}"); + assert!( + out.contains("AgentPane"), + "agent-pane origin label present: {out}" + ); } #[test] @@ -188,12 +203,17 @@ fn sessions_table_renders_location_labels() { agent_client_protocol::schema::v1::SessionId::new("sid-wsl"), std::path::PathBuf::from("/home/u"), ); - wsl.location = agent_sessions::SessionLocation::Wsl { distro: "Ubuntu".into() }; + wsl.location = agent_sessions::SessionLocation::Wsl { + distro: "Ubuntu".into(), + }; let out = format_sessions_table(&[host, wsl]); assert!(out.contains("LOCATION"), "LOCATION header present: {out}"); assert!(out.contains("host"), "host location label present: {out}"); - assert!(out.contains("wsl:Ubuntu"), "wsl distro label present: {out}"); + assert!( + out.contains("wsl:Ubuntu"), + "wsl distro label present: {out}" + ); } #[test] @@ -402,7 +422,10 @@ fn active_pane_wsl_distro_rejects_non_wsl_shells() { assert_eq!(active_pane_wsl_distro(Some(&pane_with_shell("pwsh"))), None); assert_eq!(active_pane_wsl_distro(Some(&pane_with_shell("cmd"))), None); // A pane name that merely contains "wsl" is not the `wsl:` prefix. - assert_eq!(active_pane_wsl_distro(Some(&pane_with_shell("my-wsl"))), None); + assert_eq!( + active_pane_wsl_distro(Some(&pane_with_shell("my-wsl"))), + None + ); // Bare `wsl:` with an empty distro name is not a valid WSL pane — shell // integration only emits `wsl:` when `$WSL_DISTRO_NAME` is set — // and would otherwise build an invalid `wsl -d "" …` command. @@ -449,3 +472,43 @@ fn delegate_launchable_for_target_ors_host_and_wsl() { assert!(delegate_launchable_for_target(true, false)); assert!(delegate_launchable_for_target(true, true)); } + +#[test] +fn propose_terminal_actions_cli_parses_channel_and_inline_payload() { + let cli = Cli::try_parse_from([ + "wta", + "propose-terminal-actions", + "--channel", + "v1.0123456789abcdef0123456789abcdef.abcdef0123456789abcdef0123456789", + "--payload-json", + r#"{"schema_version":1}"#, + ]) + .expect("propose-terminal-actions flags must parse"); + + match cli.command { + Some(Command::ProposeTerminalActions { + channel, + payload_json, + }) => { + assert_eq!( + channel, + "v1.0123456789abcdef0123456789abcdef.abcdef0123456789abcdef0123456789" + ); + assert_eq!(payload_json, r#"{"schema_version":1}"#); + } + other => panic!("expected ProposeTerminalActions command, got {other:?}"), + } +} + +#[test] +fn propose_terminal_actions_cli_requires_channel_and_payload() { + Cli::try_parse_from(["wta", "propose-terminal-actions"]) + .expect_err("channel and payload are required"); + Cli::try_parse_from([ + "wta", + "propose-terminal-actions", + "--channel", + "channel-only", + ]) + .expect_err("payload is required"); +} diff --git a/tools/wta/src/coordinator.rs b/tools/wta/src/coordinator.rs index 6c491e220f..254e73bcd5 100644 --- a/tools/wta/src/coordinator.rs +++ b/tools/wta/src/coordinator.rs @@ -632,7 +632,7 @@ async fn execute_choice( Ok(()) } -fn validate_recommendation_set(set: &RecommendationSet) -> Result<()> { +pub(crate) fn validate_recommendation_set(set: &RecommendationSet) -> Result<()> { if !(1..=3).contains(&set.choices.len()) { bail!("expected 1 to 3 choices, got {}", set.choices.len()); } diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 90a98bf467..57e247bd6a 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -15,13 +15,17 @@ mod cwd_util; mod event; mod helper; mod history_loader; -mod logging; #[cfg(test)] #[path = "locale_parity_tests.rs"] mod locale_parity_tests; +mod logging; mod master; +mod named_pipe_security; mod osc52; mod pane_context; +mod proposal_channel; +mod proposal_invocation; +mod proposal_pipe; mod protocol; mod resolve_command; mod rtl; @@ -32,6 +36,7 @@ mod session_registry; mod session_watcher; mod shell; mod telemetry; +mod terminal_action_proposal; #[cfg(test)] mod test_support; mod theme; @@ -562,8 +567,21 @@ enum Command { #[arg(long)] cli: Option, }, -} + /// Submit a typed terminal-action proposal directly to the Helper that + /// owns the current turn. Intended to be run by an agent session using + /// the exact canonical command injected into its prompt. + ProposeTerminalActions { + /// Opaque per-turn channel from the Helper's runtime instruction. + #[arg(long)] + channel: String, + + /// Compact versioned proposal JSON. stdin and payload files are + /// intentionally unsupported so permission matching has one form. + #[arg(long)] + payload_json: String, + }, +} /// Subcommands for `wta sessions`. #[derive(Subcommand, Debug)] @@ -602,9 +620,9 @@ enum SessionsOriginArg { impl SessionsOriginArg { fn to_filter(self) -> agent_sessions::OriginFilter { match self { - SessionsOriginArg::Shell => agent_sessions::OriginFilter::ShellOnly, + SessionsOriginArg::Shell => agent_sessions::OriginFilter::ShellOnly, SessionsOriginArg::AgentPane => agent_sessions::OriginFilter::AgentPaneOnly, - SessionsOriginArg::All => agent_sessions::OriginFilter::All, + SessionsOriginArg::All => agent_sessions::OriginFilter::All, } } } @@ -992,6 +1010,12 @@ async fn main() -> Result<()> { // ── WSL ACP history-scan probe (diagnostic) ── Some(Command::ProbeWslSessions { cli }) => run_probe_wsl_sessions(cli.as_deref()).await, + // ── Direct terminal-action proposal (agent session -> master) ── + Some(Command::ProposeTerminalActions { + channel, + payload_json, + }) => run_propose_terminal_actions(channel, payload_json).await, + // ── No subcommand: a singleton-service mode, or an error. There // is no standalone/default ACP TUI mode — the direct agent-spawn // path was removed, so bare `wta` always runs as a WT-launched @@ -1157,8 +1181,9 @@ async fn run_probe_host_sessions(agent: &str) -> Result<()> { // Resolve the CliSource from the agent command so the probe labels and // classifies rows the way production seeding does (which uses the real // `state.cli_source`), instead of assuming Copilot for every agent. - let cli_source = - CliSource::parse(Some(crate::agent_registry::resolve_agent_id_from_cmd(agent))); + let cli_source = CliSource::parse(Some(crate::agent_registry::resolve_agent_id_from_cmd( + agent, + ))); let local = tokio::task::LocalSet::new(); let rows = match local @@ -1499,7 +1524,6 @@ async fn get_first_tab_id(channel: &CliChannel, window_id: &str) -> Result, -) -> Result> { + client_name: &str, + telemetry_route: &str, +) -> Result { let pipe_name = resolve_master_pipe(master_override).await?; let pipe = open_master_pipe_for_cli(&pipe_name).await?; let (read_half, write_half) = tokio::io::split(pipe); let outgoing = write_half.compat_write(); let incoming = read_half.compat(); let (conn, handle_io) = crate::protocol::acp::conn::spawn_client( - acp::Client.builder().name("wta-sessions"), + acp::Client.builder().name(client_name), crate::protocol::acp::conn::byte_streams(outgoing, incoming), ); tokio::task::spawn_local(async move { @@ -1550,19 +1588,20 @@ async fn fetch_sessions_from_master( }); let init_started = std::time::Instant::now(); - let init_result = conn.initialize( - acp::schema::v1::InitializeRequest::new(acp::schema::ProtocolVersion::V1) - .client_capabilities(acp::schema::v1::ClientCapabilities::new()) - .client_info( - acp::schema::v1::Implementation::new("wta-sessions", env!("CARGO_PKG_VERSION")) - .title("Windows Terminal Agent sessions CLI"), - ), - ) - .await; + let init_result = conn + .initialize( + acp::schema::v1::InitializeRequest::new(acp::schema::ProtocolVersion::V1) + .client_capabilities(acp::schema::v1::ClientCapabilities::new()) + .client_info( + acp::schema::v1::Implementation::new(client_name, env!("CARGO_PKG_VERSION")) + .title("Windows Terminal Agent CLI client"), + ), + ) + .await; telemetry::log_acp_initialize_complete( init_started.elapsed().as_secs_f64() * 1000.0, init_result.is_ok(), - "SessionsCli", + telemetry_route, if init_result.is_ok() { "" } else { "AcpError" }, init_result .as_ref() @@ -1571,7 +1610,13 @@ async fn fetch_sessions_from_master( .unwrap_or(0), ); init_result.map_err(|_| anyhow::anyhow!(MASTER_NOT_RUNNING))?; + Ok(conn) +} +async fn fetch_sessions_from_master( + master_override: Option, +) -> Result> { + let conn = connect_master_as(master_override, "wta-sessions", "SessionsCli").await?; let req = session_registry::build_sessions_list_request(false); let resp = conn .ext_method(req) @@ -1582,6 +1627,114 @@ async fn fetch_sessions_from_master( Ok(parsed.sessions) } +async fn run_propose_terminal_actions(channel: String, payload: String) -> Result<()> { + use tokio::io::{AsyncWriteExt, BufReader}; + + let channel = channel + .parse::() + .context("invalid --channel")?; + if payload.len() > terminal_action_proposal::MAX_PAYLOAD_BYTES { + anyhow::bail!( + "--payload-json exceeds the {}-byte inline limit", + terminal_action_proposal::MAX_PAYLOAD_BYTES + ); + } + let pipe = open_proposal_pipe(&channel.pipe_name()).await?; + let (read_half, mut write_half) = tokio::io::split(pipe); + let request = proposal_pipe::ProposalPipeRequest { + version: proposal_pipe::PROTOCOL_VERSION, + channel: channel.to_string(), + payload, + }; + let mut request_line = serde_json::to_vec(&request)?; + request_line.push(b'\n'); + write_half + .write_all(&request_line) + .await + .context("write proposal request")?; + write_half.flush().await.context("flush proposal request")?; + + let mut reader = BufReader::new(read_half); + let validation: proposal_pipe::ProposalValidationResponse = + read_proposal_response(&mut reader).await?; + println!("{}", serde_json::to_string(&validation)?); + std::io::Write::flush(&mut std::io::stdout()).context("flush validation response")?; + if validation.status != proposal_channel::ProposalValidationStatus::Accepted { + return Ok(()); + } + + let final_response: proposal_pipe::ProposalFinalResponse = + read_proposal_response(&mut reader).await?; + println!("{}", serde_json::to_string(&final_response)?); + Ok(()) +} + +async fn open_proposal_pipe( + pipe_name: &str, +) -> Result { + const ERROR_FILE_NOT_FOUND: i32 = 2; + const ERROR_PIPE_BUSY: i32 = 231; + const BACKOFF_MS: &[u64] = &[20, 50, 100, 200, 500, 1000]; + + for (attempt, wait_ms) in BACKOFF_MS.iter().enumerate() { + match tokio::net::windows::named_pipe::ClientOptions::new().open(pipe_name) { + Ok(pipe) => return Ok(pipe), + Err(error) + if matches!( + error.raw_os_error(), + Some(ERROR_FILE_NOT_FOUND | ERROR_PIPE_BUSY) + ) => + { + tracing::debug!( + target: "proposal_cli", + pipe = %pipe_name, + attempt = attempt + 1, + wait_ms, + "proposal pipe not ready" + ); + tokio::time::sleep(std::time::Duration::from_millis(*wait_ms)).await; + } + Err(error) => { + return Err(error) + .with_context(|| format!("open owning Helper pipe '{pipe_name}'")); + } + } + } + anyhow::bail!("owning Helper pipe is unavailable") +} + +async fn read_proposal_response(reader: &mut R) -> Result +where + R: tokio::io::AsyncBufRead + Unpin, + T: serde::de::DeserializeOwned, +{ + use tokio::io::AsyncBufReadExt; + + let mut line = Vec::new(); + loop { + let available = reader.fill_buf().await.context("read proposal response")?; + if available.is_empty() { + if line.is_empty() { + anyhow::bail!("owning Helper disconnected before responding"); + } + anyhow::bail!("owning Helper response is not newline terminated"); + } + let take = available + .iter() + .position(|byte| *byte == b'\n') + .map_or(available.len(), |index| index + 1); + if line.len() + take > proposal_pipe::MAX_FRAME_BYTES { + anyhow::bail!("proposal response exceeds the frame limit"); + } + line.extend_from_slice(&available[..take]); + reader.consume(take); + if line.last() == Some(&b'\n') { + break; + } + } + serde_json::from_slice(&line).context("decode proposal response") +} + /// Best-effort: register a WTA-launched CLI session with `wta-master` as a /// *born-bound* row — bound to its pane, with no hooks involved. Sends a /// `SessionStarted` over the `intellterm.wta/session_born_bound` method, which @@ -1621,30 +1774,7 @@ async fn register_launched_session_with_master( let local = tokio::task::LocalSet::new(); let result: Result<()> = local .run_until(async move { - let pipe_name = resolve_master_pipe(None).await?; - let pipe = open_master_pipe_for_cli(&pipe_name).await?; - let (read_half, write_half) = tokio::io::split(pipe); - let outgoing = write_half.compat_write(); - let incoming = read_half.compat(); - let (conn, handle_io) = crate::protocol::acp::conn::spawn_client( - acp::Client.builder().name("wta-delegate"), - crate::protocol::acp::conn::byte_streams(outgoing, incoming), - ); - tokio::task::spawn_local(async move { - let _ = handle_io.await; - }); - - conn.initialize( - acp::schema::v1::InitializeRequest::new(acp::schema::ProtocolVersion::V1) - .client_capabilities(acp::schema::v1::ClientCapabilities::new()) - .client_info( - acp::schema::v1::Implementation::new("wta-delegate", env!("CARGO_PKG_VERSION")) - .title("Windows Terminal Agent delegate"), - ), - ) - .await - .map_err(|_| anyhow::anyhow!(MASTER_NOT_RUNNING))?; - + let conn = connect_master_as(None, "wta-delegate", "DelegateCli").await?; conn.ext_method(req) .await .map_err(|_| anyhow::anyhow!(MASTER_NOT_RUNNING))?; @@ -1665,7 +1795,6 @@ async fn resolve_master_pipe(master_override: Option) -> Result if let Some(pipe) = master_override.filter(|s| !s.trim().is_empty()) { return Ok(pipe); } - for attempt in 0..2 { if let Some(path) = runtime_paths::master_pipe_file_path() { if let Ok(contents) = std::fs::read_to_string(path) { @@ -1718,7 +1847,11 @@ fn format_sessions_table(sessions: &[session_registry::SessionInfo]) -> String { )); for (i, session) in sessions.iter().enumerate() { let sid = session.session_id.to_string(); - let short_sid = if sid.len() > 24 { &sid[..24] } else { sid.as_str() }; + let short_sid = if sid.len() > 24 { + &sid[..24] + } else { + sid.as_str() + }; out.push_str(&format!( "{:<4} {:<24} {:<10} {:<10} {:<10} {:<16} {:<20} {:<20} {}\n", i + 1, @@ -1736,15 +1869,17 @@ fn format_sessions_table(sessions: &[session_registry::SessionInfo]) -> String { } fn status_label(status: Option<&agent_sessions::AgentStatus>) -> String { - status.map(|s| format!("{s:?}")).unwrap_or_else(|| "-".to_string()) + status + .map(|s| format!("{s:?}")) + .unwrap_or_else(|| "-".to_string()) } fn cli_source_label(source: Option<&agent_sessions::CliSource>) -> String { match source { - Some(agent_sessions::CliSource::Claude) => "Claude".to_string(), - Some(agent_sessions::CliSource::Codex) => "Codex".to_string(), + Some(agent_sessions::CliSource::Claude) => "Claude".to_string(), + Some(agent_sessions::CliSource::Codex) => "Codex".to_string(), Some(agent_sessions::CliSource::Copilot) => "Copilot".to_string(), - Some(agent_sessions::CliSource::Gemini) => "Gemini".to_string(), + Some(agent_sessions::CliSource::Gemini) => "Gemini".to_string(), Some(agent_sessions::CliSource::OpenCode) => "OpenCode".to_string(), Some(agent_sessions::CliSource::Unknown(s)) if !s.is_empty() => s.clone(), _ => "-".to_string(), @@ -1759,8 +1894,8 @@ fn cli_source_label(source: Option<&agent_sessions::CliSource>) -> String { fn origin_label(origin: Option<&agent_sessions::SessionOrigin>) -> &'static str { match origin { Some(agent_sessions::SessionOrigin::AgentPane) => "AgentPane", - Some(agent_sessions::SessionOrigin::Unknown) => "Shell", - None => "-", + Some(agent_sessions::SessionOrigin::Unknown) => "Shell", + None => "-", } } @@ -2068,7 +2203,11 @@ async fn run_delegate( cwd: Option<&str>, ) -> Result<()> { // Log the prompt length, not the text — the prompt is user content. - tracing::info!(prompt_chars = prompt.map(|p| p.chars().count()), agent = agent_cmd, "run_delegate started"); + tracing::info!( + prompt_chars = prompt.map(|p| p.chars().count()), + agent = agent_cmd, + "run_delegate started" + ); tracing::trace!(target: "delegate.content", prompt = ?prompt, "run_delegate prompt"); let (debug_tx, _) = tokio::sync::mpsc::unbounded_channel::(); @@ -2533,7 +2672,8 @@ async fn delegate_with_context( tracing::trace!(target: "delegate.content", commandline, "delegate_with_context commandline"); let windows_home = std::env::var("USERPROFILE").ok(); - let sanitized_cwd = crate::coordinator::sanitize_windows_agent_cwd(cwd, windows_home.as_deref()); + let sanitized_cwd = + crate::coordinator::sanitize_windows_agent_cwd(cwd, windows_home.as_deref()); let create_resp = shell_mgr .wt_create_tab(Some(&commandline), sanitized_cwd.as_deref(), None, None) @@ -2645,7 +2785,10 @@ async fn discover_pane_identity(shell_mgr: &ShellManager) -> Option<(String, Str Some(t) => t, None => continue, }; - let panes = shell_mgr.wt_list_panes(&tab_id_str, Some(&window_id)).await.ok()?; + let panes = shell_mgr + .wt_list_panes(&tab_id_str, Some(&window_id)) + .await + .ok()?; let panes_arr = panes.get("panes")?.as_array()?; for pane in panes_arr { @@ -2913,9 +3056,7 @@ async fn run_info_mode() -> Result<()> { } fn spawn_restart_agent_stack_forwarder( - mut restart_rx: tokio::sync::mpsc::UnboundedReceiver< - protocol::acp::client::RestartRequest, - >, + mut restart_rx: tokio::sync::mpsc::UnboundedReceiver, ) { tokio::task::spawn_local(async move { while let Some(req) = restart_rx.recv().await { @@ -2952,6 +3093,52 @@ async fn run_acp_app( .run_until(async move { let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); let (prompt_tx, prompt_rx) = tokio::sync::mpsc::unbounded_channel(); + let proposal_channels = Arc::new(proposal_channel::ProposalChannelManager::new()); + let (proposal_pipe_tx, mut proposal_pipe_rx) = + tokio::sync::mpsc::unbounded_channel(); + let proposal_server_manager = Arc::clone(&proposal_channels); + let proposal_server_lifecycle = Arc::clone(&proposal_channels); + tokio::task::spawn_local(async move { + if let Err(error) = + proposal_pipe::run_server(proposal_server_manager, proposal_pipe_tx).await + { + proposal_server_lifecycle.set_transport_available(false); + tracing::error!( + target: "proposal_pipe", + error = %format!("{error:#}"), + "proposal pipe server stopped" + ); + } + }); + let proposal_event_tx = event_tx.clone(); + tokio::task::spawn_local(async move { + while let Some(event) = proposal_pipe_rx.recv().await { + let app_event = match event { + proposal_pipe::ProposalPipeEvent::Validate { + context, + payload, + responder, + } => app::AppEvent::DirectTerminalActionProposal { + context, + payload, + responder, + }, + proposal_pipe::ProposalPipeEvent::Commit { proposal_id } => { + app::AppEvent::DirectTerminalActionProposalCommit { proposal_id } + } + proposal_pipe::ProposalPipeEvent::Invalidate { + proposal_id, + session_id, + } => app::AppEvent::DirectTerminalActionProposalInvalidate { + proposal_id, + session_id, + }, + }; + if proposal_event_tx.send(app_event).is_err() { + break; + } + } + }); let evt_tx = event_tx.clone(); tokio::task::spawn_local(event::read_crossterm_events(evt_tx)); @@ -3278,6 +3465,8 @@ async fn run_acp_app( let agent_id = cli.agent_id.clone(); let owner_tab = cli.owner_tab_id.clone(); let initial_load_sid = cli.initial_load_session_id.clone(); + let proposal_channels_for_pipe = Arc::clone(&proposal_channels); + let direct_proposals_enabled = canonical_agent_id == "copilot"; tokio::task::spawn_local(async move { if let Err(e) = protocol::acp::client::run_acp_client_over_pipe( pipe_name, @@ -3298,6 +3487,8 @@ async fn run_acp_app( shell_mgr_for_pipe, wt_connected, false, // post_login_reconnect: first connection, no authenticate needed + proposal_channels_for_pipe, + direct_proposals_enabled, ) .await { @@ -3352,6 +3543,7 @@ async fn run_acp_app( let autofix_enabled = !cli.no_autofix; let mut app_state = app::App::new(prompt_tx, recommendation_tx, permission_tx, cancel_tx, new_session_tx, load_session_tx, drop_session_tx, rename_session_tx, restart_tx, master_ext_tx, debug_capture_enabled, wt_connected, autofix_enabled, Arc::clone(&shell_mgr)); + app_state.set_proposal_channels(Arc::clone(&proposal_channels)); app_state.set_allowed_agent_ids(cli.allowed_agent_ids.clone()); // Seed the hot-updatable runtime agent config: the shared // delegate runtime table, the helper's own agent_cmd (needed to diff --git a/tools/wta/src/named_pipe_security.rs b/tools/wta/src/named_pipe_security.rs new file mode 100644 index 0000000000..5e4c1cd1f6 --- /dev/null +++ b/tools/wta/src/named_pipe_security.rs @@ -0,0 +1,122 @@ +use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions}; + +pub struct PipeSecurity { + sa: windows_sys::Win32::Security::SECURITY_ATTRIBUTES, + psd: *mut std::ffi::c_void, +} + +impl PipeSecurity { + fn sa_ptr(&self) -> *mut std::ffi::c_void { + &self.sa as *const _ as *mut std::ffi::c_void + } +} + +impl Drop for PipeSecurity { + fn drop(&mut self) { + if !self.psd.is_null() { + unsafe { + windows_sys::Win32::Foundation::LocalFree(self.psd); + } + } + } +} + +pub fn build() -> Option { + use windows_sys::Win32::Security::Authorization::{ + ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + + let user_sid = current_user_sid_string()?; + let sddl = format!("D:P(A;;GA;;;SY)(A;;GA;;;{user_sid})S:(ML;;NW;;;ME)"); + let sddl_w: Vec = sddl.encode_utf16().chain(std::iter::once(0)).collect(); + let mut psd: *mut std::ffi::c_void = std::ptr::null_mut(); + let ok = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl_w.as_ptr(), + SDDL_REVISION_1 as u32, + &mut psd, + std::ptr::null_mut(), + ) + }; + if ok == 0 || psd.is_null() { + tracing::warn!( + target: "named_pipe", + "failed to build current-user security descriptor" + ); + return None; + } + + Some(PipeSecurity { + sa: SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: psd, + bInheritHandle: 0, + }, + psd, + }) +} + +pub fn build_required() -> std::io::Result { + build().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "failed to build the current-user named-pipe security descriptor", + ) + }) +} + +pub fn create_server( + pipe_name: &str, + first_instance: bool, + security: Option<&PipeSecurity>, +) -> std::io::Result { + let mut options = ServerOptions::new(); + options.first_pipe_instance(first_instance); + options.reject_remote_clients(true); + match security { + Some(security) => unsafe { + options.create_with_security_attributes_raw(pipe_name, security.sa_ptr()) + }, + None => options.create(pipe_name), + } +} + +fn current_user_sid_string() -> Option { + use windows_sys::Win32::Foundation::{CloseHandle, LocalFree, HANDLE}; + use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW; + use windows_sys::Win32::Security::{GetTokenInformation, TokenUser, TOKEN_QUERY, TOKEN_USER}; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + unsafe { + let mut token: HANDLE = std::ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return None; + } + let mut len = 0; + GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut len); + if len == 0 { + CloseHandle(token); + return None; + } + let mut buffer = vec![0u8; len as usize]; + let ok = GetTokenInformation(token, TokenUser, buffer.as_mut_ptr().cast(), len, &mut len); + CloseHandle(token); + if ok == 0 { + return None; + } + let token_user = std::ptr::read_unaligned(buffer.as_ptr().cast::()); + let mut sid_string: *mut u16 = std::ptr::null_mut(); + if ConvertSidToStringSidW(token_user.User.Sid, &mut sid_string) == 0 || sid_string.is_null() + { + return None; + } + let mut length = 0; + while *sid_string.add(length) != 0 { + length += 1; + } + let result = String::from_utf16_lossy(std::slice::from_raw_parts(sid_string, length)); + LocalFree(sid_string.cast()); + Some(result) + } +} diff --git a/tools/wta/src/proposal_channel.rs b/tools/wta/src/proposal_channel.rs new file mode 100644 index 0000000000..83b6401292 --- /dev/null +++ b/tools/wta/src/proposal_channel.rs @@ -0,0 +1,710 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::VecDeque; +use std::fmt; +use std::str::FromStr; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tokio::sync::oneshot; +use uuid::Uuid; + +pub const CHANNEL_VERSION: &str = "v1"; +pub const PIPE_PREFIX: &str = r"\\.\pipe\IntelligentTerminal.Proposal."; + +#[derive(Debug, Clone, Copy)] +pub struct ProposalChannelConfig { + pub armed_lease: Duration, + pub max_validation_retries: u8, + pub max_tombstones: usize, + pub tombstone_ttl: Duration, +} + +impl Default for ProposalChannelConfig { + fn default() -> Self { + Self { + armed_lease: Duration::from_secs(30), + max_validation_retries: 2, + max_tombstones: 4, + tombstone_ttl: Duration::from_secs(3 * 60), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ProposalChannel { + helper_instance_id: Uuid, + turn_nonce: Uuid, +} + +impl ProposalChannel { + fn new(helper_instance_id: Uuid) -> Self { + Self { + helper_instance_id, + turn_nonce: Uuid::new_v4(), + } + } + + pub fn pipe_name(&self) -> String { + format!("{PIPE_PREFIX}{:x}", self.helper_instance_id.simple()) + } +} + +impl fmt::Display for ProposalChannel { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{CHANNEL_VERSION}.{:x}.{:x}", + self.helper_instance_id.simple(), + self.turn_nonce.simple() + ) + } +} + +impl FromStr for ProposalChannel { + type Err = ChannelParseError; + + fn from_str(value: &str) -> Result { + let mut parts = value.split('.'); + let version = parts.next().ok_or(ChannelParseError)?; + let helper = parts.next().ok_or(ChannelParseError)?; + let turn = parts.next().ok_or(ChannelParseError)?; + if parts.next().is_some() + || version != CHANNEL_VERSION + || !is_lower_hex_uuid(helper) + || !is_lower_hex_uuid(turn) + { + return Err(ChannelParseError); + } + Ok(Self { + helper_instance_id: Uuid::parse_str(helper).map_err(|_| ChannelParseError)?, + turn_nonce: Uuid::parse_str(turn).map_err(|_| ChannelParseError)?, + }) + } +} + +fn is_lower_hex_uuid(value: &str) -> bool { + value.len() == 32 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChannelParseError; + +impl fmt::Display for ChannelParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("expected v1.<32 lowercase hex>.<32 lowercase hex>") + } +} + +impl std::error::Error for ChannelParseError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposalBinding { + pub session_id: String, + pub session_epoch: u64, + pub prompt_id: u64, + pub active_target: Option, + pub is_autofix: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProposalChannelState { + Issued, + Armed, + Validating, + AwaitingUser, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalValidationStatus { + Accepted, + UnknownChannel, + HelperMismatch, + NotArmed, + Stale, + Superseded, + Expired, + DigestMismatch, + AlreadyConsumed, + InvalidSchema, + Rejected, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalFinalStatus { + Confirmed, + Cancelled, + Superseded, + SessionReplaced, + TimedOut, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelFailure { + pub status: ProposalValidationStatus, + pub reason: &'static str, + pub retryable: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationContext { + pub proposal_id: String, + pub channel: ProposalChannel, + pub binding: ProposalBinding, +} + +struct ActiveChannel { + channel: ProposalChannel, + binding: ProposalBinding, + state: ProposalChannelState, + validation_retries: u8, + payload_digest: Option<[u8; 32]>, + armed_until: Option, + proposal_id: Option, + final_responder: Option>, +} + +#[derive(Debug, Clone, Copy)] +struct Tombstone { + channel_hash: [u8; 32], + status: ProposalFinalStatus, + created_at: Instant, +} + +struct ChannelState { + session_epoch: u64, + transport_available: bool, + active: Option, + tombstones: VecDeque, +} + +pub struct ProposalChannelManager { + helper_instance_id: Uuid, + config: ProposalChannelConfig, + state: Mutex, +} + +impl ProposalChannelManager { + pub fn new() -> Self { + Self::with_config(ProposalChannelConfig::default()) + } + + fn with_config(config: ProposalChannelConfig) -> Self { + Self { + helper_instance_id: Uuid::new_v4(), + config, + state: Mutex::new(ChannelState { + session_epoch: 0, + transport_available: true, + active: None, + tombstones: VecDeque::new(), + }), + } + } + + pub fn pipe_name(&self) -> String { + format!("{PIPE_PREFIX}{:x}", self.helper_instance_id.simple()) + } + + pub fn issue( + &self, + session_id: String, + prompt_id: u64, + active_target: Option, + is_autofix: bool, + ) -> Result { + let mut state = self.lock_state(); + self.prune_tombstones(&mut state); + if !state.transport_available { + return Err(failure( + ProposalValidationStatus::Unavailable, + "proposal transport is unavailable", + false, + )); + } + self.invalidate_active(&mut state, ProposalFinalStatus::Superseded); + let channel = ProposalChannel::new(self.helper_instance_id); + state.active = Some(ActiveChannel { + channel: channel.clone(), + binding: ProposalBinding { + session_id, + session_epoch: state.session_epoch, + prompt_id, + active_target, + is_autofix, + }, + state: ProposalChannelState::Issued, + validation_retries: 0, + payload_digest: None, + armed_until: None, + proposal_id: None, + final_responder: None, + }); + Ok(channel) + } + + pub fn arm( + &self, + session_id: &str, + channel: &ProposalChannel, + payload: &[u8], + ) -> Result<(), ChannelFailure> { + let mut state = self.lock_state(); + self.prune_tombstones(&mut state); + self.ensure_local_channel(channel)?; + if !state.transport_available { + return Err(failure( + ProposalValidationStatus::Unavailable, + "proposal transport is unavailable", + false, + )); + } + let session_epoch = state.session_epoch; + let Some(active) = state.active.as_mut() else { + return Err(self.inactive_failure(&state, channel)); + }; + if active.channel != *channel { + return Err(self.inactive_failure(&state, channel)); + } + if active.binding.session_epoch != session_epoch || active.binding.session_id != session_id + { + return Err(failure( + ProposalValidationStatus::Stale, + "channel does not belong to the requesting ACP session", + false, + )); + } + if active.state != ProposalChannelState::Issued { + return Err(failure( + ProposalValidationStatus::AlreadyConsumed, + "channel is already armed or consumed", + false, + )); + } + active.payload_digest = Some(payload_digest(payload)); + active.armed_until = Some(Instant::now() + self.config.armed_lease); + active.state = ProposalChannelState::Armed; + Ok(()) + } + + pub fn begin_validation( + &self, + channel: &ProposalChannel, + payload: &[u8], + ) -> Result { + let mut state = self.lock_state(); + self.prune_tombstones(&mut state); + self.ensure_local_channel(channel)?; + let session_epoch = state.session_epoch; + let transport_available = state.transport_available; + let Some(active) = state.active.as_mut() else { + return Err(self.inactive_failure(&state, channel)); + }; + if active.channel != *channel { + return Err(self.inactive_failure(&state, channel)); + } + if !transport_available { + return Err(failure( + ProposalValidationStatus::Unavailable, + "proposal transport is unavailable", + false, + )); + } + if active.binding.session_epoch != session_epoch { + return Err(failure( + ProposalValidationStatus::Stale, + "channel belongs to a replaced session", + false, + )); + } + if active.state != ProposalChannelState::Armed { + let (status, reason) = if active.state == ProposalChannelState::Issued { + ( + ProposalValidationStatus::NotArmed, + "channel was not approved for this payload", + ) + } else { + ( + ProposalValidationStatus::AlreadyConsumed, + "channel is already being validated or awaiting the user", + ) + }; + return Err(failure(status, reason, false)); + } + if active + .armed_until + .is_none_or(|deadline| deadline <= Instant::now()) + { + active.state = ProposalChannelState::Issued; + active.payload_digest = None; + active.armed_until = None; + return Err(failure( + ProposalValidationStatus::Expired, + "channel approval lease expired", + true, + )); + } + if active.payload_digest != Some(payload_digest(payload)) { + active.validation_retries = active.validation_retries.saturating_add(1); + let can_retry = active.validation_retries <= self.config.max_validation_retries; + if can_retry { + active.state = ProposalChannelState::Issued; + active.payload_digest = None; + active.armed_until = None; + } else { + self.invalidate_active(&mut state, ProposalFinalStatus::Cancelled); + } + return Err(failure( + ProposalValidationStatus::DigestMismatch, + "payload differs from the approved command", + can_retry, + )); + } + let proposal_id = Uuid::new_v4().to_string(); + active.state = ProposalChannelState::Validating; + active.proposal_id = Some(proposal_id.clone()); + Ok(ValidationContext { + proposal_id, + channel: active.channel.clone(), + binding: active.binding.clone(), + }) + } + + pub fn accept_validation( + &self, + proposal_id: &str, + final_responder: oneshot::Sender, + ) -> bool { + let mut state = self.lock_state(); + let Some(active) = state.active.as_mut() else { + return false; + }; + if active.state != ProposalChannelState::Validating + || active.proposal_id.as_deref() != Some(proposal_id) + { + return false; + } + active.state = ProposalChannelState::AwaitingUser; + active.final_responder = Some(final_responder); + true + } + + pub fn reject_validation(&self, proposal_id: &str, retryable: bool) -> bool { + let mut state = self.lock_state(); + let Some(active) = state.active.as_mut() else { + return false; + }; + if active.state != ProposalChannelState::Validating + || active.proposal_id.as_deref() != Some(proposal_id) + { + return false; + } + active.validation_retries = active.validation_retries.saturating_add(1); + let can_retry = + retryable && active.validation_retries <= self.config.max_validation_retries; + if can_retry { + active.state = ProposalChannelState::Issued; + active.payload_digest = None; + active.armed_until = None; + active.proposal_id = None; + } else { + self.invalidate_active(&mut state, ProposalFinalStatus::Cancelled); + } + can_retry + } + + pub fn claim_confirmation( + &self, + proposal_id: &str, + ) -> Option> { + let mut state = self.lock_state(); + let active = state.active.as_ref()?; + if active.state != ProposalChannelState::AwaitingUser + || active.proposal_id.as_deref() != Some(proposal_id) + || active.final_responder.is_none() + { + return None; + } + let mut active = state.active.take()?; + let responder = active.final_responder.take()?; + state.tombstones.push_back(Tombstone { + channel_hash: channel_hash(&active.channel), + status: ProposalFinalStatus::Confirmed, + created_at: Instant::now(), + }); + self.prune_tombstones(&mut state); + Some(responder) + } + + pub fn resolve_final(&self, proposal_id: &str, status: ProposalFinalStatus) -> bool { + let mut state = self.lock_state(); + let matches = state + .active + .as_ref() + .is_some_and(|active| active.proposal_id.as_deref() == Some(proposal_id)); + if !matches { + return false; + } + self.invalidate_active(&mut state, status); + true + } + + pub fn replace_session(&self) { + let mut state = self.lock_state(); + self.invalidate_active(&mut state, ProposalFinalStatus::SessionReplaced); + state.session_epoch = state.session_epoch.wrapping_add(1); + } + + pub fn cancel_active(&self) { + let mut state = self.lock_state(); + self.invalidate_active(&mut state, ProposalFinalStatus::Cancelled); + } + + pub fn set_transport_available(&self, available: bool) { + let mut state = self.lock_state(); + if !available { + self.invalidate_active(&mut state, ProposalFinalStatus::Unavailable); + } + state.transport_available = available; + } + + #[cfg(test)] + fn active_state(&self) -> Option { + self.lock_state().active.as_ref().map(|active| active.state) + } + + fn ensure_local_channel(&self, channel: &ProposalChannel) -> Result<(), ChannelFailure> { + if channel.helper_instance_id != self.helper_instance_id { + return Err(failure( + ProposalValidationStatus::HelperMismatch, + "channel belongs to another Helper", + false, + )); + } + Ok(()) + } + + fn inactive_failure(&self, state: &ChannelState, channel: &ProposalChannel) -> ChannelFailure { + let hash = channel_hash(channel); + if let Some(tombstone) = state + .tombstones + .iter() + .rev() + .find(|item| item.channel_hash == hash) + { + let (status, reason) = match tombstone.status { + ProposalFinalStatus::Superseded => ( + ProposalValidationStatus::Superseded, + "channel was superseded by a newer turn", + ), + ProposalFinalStatus::SessionReplaced => ( + ProposalValidationStatus::Stale, + "channel belongs to a replaced session", + ), + ProposalFinalStatus::Unavailable => ( + ProposalValidationStatus::Unavailable, + "owning Helper became unavailable", + ), + _ => ( + ProposalValidationStatus::AlreadyConsumed, + "channel already reached a terminal state", + ), + }; + return failure(status, reason, false); + } + failure( + ProposalValidationStatus::UnknownChannel, + "channel is not active on this Helper", + false, + ) + } + + fn invalidate_active(&self, state: &mut ChannelState, status: ProposalFinalStatus) { + let Some(mut active) = state.active.take() else { + return; + }; + if let Some(responder) = active.final_responder.take() { + let _ = responder.send(status); + } + state.tombstones.push_back(Tombstone { + channel_hash: channel_hash(&active.channel), + status, + created_at: Instant::now(), + }); + self.prune_tombstones(state); + } + + fn prune_tombstones(&self, state: &mut ChannelState) { + let now = Instant::now(); + while state.tombstones.front().is_some_and(|item| { + now.saturating_duration_since(item.created_at) >= self.config.tombstone_ttl + }) { + state.tombstones.pop_front(); + } + while state.tombstones.len() > self.config.max_tombstones { + state.tombstones.pop_front(); + } + } + + fn lock_state(&self) -> std::sync::MutexGuard<'_, ChannelState> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +impl Default for ProposalChannelManager { + fn default() -> Self { + Self::new() + } +} + +fn payload_digest(payload: &[u8]) -> [u8; 32] { + Sha256::digest(payload).into() +} + +fn channel_hash(channel: &ProposalChannel) -> [u8; 32] { + payload_digest(channel.to_string().as_bytes()) +} + +fn failure( + status: ProposalValidationStatus, + reason: &'static str, + retryable: bool, +) -> ChannelFailure { + ChannelFailure { + status, + reason, + retryable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manager() -> ProposalChannelManager { + ProposalChannelManager::with_config(ProposalChannelConfig { + armed_lease: Duration::from_secs(30), + max_validation_retries: 2, + max_tombstones: 4, + tombstone_ttl: Duration::from_secs(180), + }) + } + + #[test] + fn channel_round_trips_and_derives_pipe() { + let manager = manager(); + let channel = manager + .issue("session".into(), 7, Some("pane".into()), false) + .unwrap(); + let encoded = channel.to_string(); + assert_eq!(encoded.parse::().unwrap(), channel); + assert_eq!(channel.pipe_name(), manager.pipe_name()); + assert_eq!(encoded.len(), 68); + } + + #[test] + fn channel_parser_rejects_noncanonical_forms() { + let manager = manager(); + let channel = manager + .issue("session".into(), 1, None, false) + .unwrap() + .to_string(); + assert!(channel + .to_ascii_uppercase() + .parse::() + .is_err()); + assert!(channel + .replace("v1.", "v2.") + .parse::() + .is_err()); + assert!(format!("{channel}.extra") + .parse::() + .is_err()); + } + + #[test] + fn validation_requires_matching_permission_digest() { + let manager = manager(); + let channel = manager.issue("session".into(), 1, None, false).unwrap(); + let unarmed = manager.begin_validation(&channel, b"payload").unwrap_err(); + assert_eq!(unarmed.status, ProposalValidationStatus::NotArmed); + + manager.arm("session", &channel, b"payload").unwrap(); + let mismatch = manager.begin_validation(&channel, b"changed").unwrap_err(); + assert_eq!(mismatch.status, ProposalValidationStatus::DigestMismatch); + assert!(mismatch.retryable); + assert_eq!(manager.active_state(), Some(ProposalChannelState::Issued)); + + manager.arm("session", &channel, b"payload").unwrap(); + let context = manager.begin_validation(&channel, b"payload").unwrap(); + assert_eq!(context.binding.prompt_id, 1); + assert_eq!( + manager.active_state(), + Some(ProposalChannelState::Validating) + ); + } + + #[test] + fn accepted_proposal_resolves_waiting_cli() { + let manager = manager(); + let channel = manager.issue("session".into(), 1, None, false).unwrap(); + manager.arm("session", &channel, b"payload").unwrap(); + let context = manager.begin_validation(&channel, b"payload").unwrap(); + let (tx, rx) = oneshot::channel(); + assert!(manager.accept_validation(&context.proposal_id, tx)); + assert!(manager.resolve_final(&context.proposal_id, ProposalFinalStatus::Confirmed)); + assert_eq!(rx.blocking_recv().unwrap(), ProposalFinalStatus::Confirmed); + } + + #[test] + fn newer_turn_supersedes_old_channel() { + let manager = manager(); + let old = manager.issue("session".into(), 1, None, false).unwrap(); + let _new = manager.issue("session".into(), 2, None, false).unwrap(); + let failure = manager.begin_validation(&old, b"payload").unwrap_err(); + assert_eq!(failure.status, ProposalValidationStatus::Superseded); + } + + #[test] + fn schema_retry_returns_channel_to_issued() { + let manager = manager(); + let channel = manager.issue("session".into(), 1, None, false).unwrap(); + manager.arm("session", &channel, b"bad").unwrap(); + let context = manager.begin_validation(&channel, b"bad").unwrap(); + assert!(manager.reject_validation(&context.proposal_id, true)); + assert_eq!(manager.active_state(), Some(ProposalChannelState::Issued)); + manager.arm("session", &channel, b"fixed").unwrap(); + assert!(manager.begin_validation(&channel, b"fixed").is_ok()); + } + + #[test] + fn session_replacement_returns_stale_tombstone() { + let manager = manager(); + let channel = manager.issue("session".into(), 1, None, false).unwrap(); + manager.replace_session(); + let failure = manager.begin_validation(&channel, b"payload").unwrap_err(); + assert_eq!(failure.status, ProposalValidationStatus::Stale); + } + + #[test] + fn expired_arm_can_be_approved_again() { + let manager = ProposalChannelManager::with_config(ProposalChannelConfig { + armed_lease: Duration::ZERO, + ..ProposalChannelConfig::default() + }); + let channel = manager.issue("session".into(), 1, None, false).unwrap(); + manager.arm("session", &channel, b"payload").unwrap(); + let failure = manager.begin_validation(&channel, b"payload").unwrap_err(); + assert_eq!(failure.status, ProposalValidationStatus::Expired); + assert!(failure.retryable); + assert_eq!(manager.active_state(), Some(ProposalChannelState::Issued)); + } +} diff --git a/tools/wta/src/proposal_invocation.rs b/tools/wta/src/proposal_invocation.rs new file mode 100644 index 0000000000..d05228dfc5 --- /dev/null +++ b/tools/wta/src/proposal_invocation.rs @@ -0,0 +1,138 @@ +use crate::proposal_channel::ProposalChannel; +use crate::terminal_action_proposal::MAX_PAYLOAD_BYTES; + +const PREFIX: &str = r#"& "$env:WTA_CLI_PATH" propose-terminal-actions --channel "#; +const PAYLOAD_MARKER: &str = " --payload-json "; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposalInvocation { + pub channel: ProposalChannel, + pub payload: String, +} + +pub fn render(channel: &ProposalChannel, payload: &str) -> Result { + validate_payload(payload)?; + Ok(format!( + "{PREFIX}{channel}{PAYLOAD_MARKER}'{}'", + payload.replace('\'', "''") + )) +} + +pub fn parse(command: &str) -> Result { + if command.contains('\r') || command.contains('\n') { + return Err("proposal command must be one line"); + } + let rest = command + .strip_prefix(PREFIX) + .ok_or("proposal command does not use WTA_CLI_PATH")?; + let (channel_text, payload_expression) = rest + .split_once(PAYLOAD_MARKER) + .ok_or("proposal command is missing --payload-json")?; + let channel = channel_text + .parse::() + .map_err(|_| "proposal channel is malformed")?; + let payload = decode_single_quoted(payload_expression) + .ok_or("proposal payload must be one PowerShell single-quoted argument")?; + validate_payload(&payload)?; + let invocation = ProposalInvocation { channel, payload }; + if render(&invocation.channel, &invocation.payload)? != command { + return Err("proposal command is not canonical"); + } + Ok(invocation) +} + +fn validate_payload(payload: &str) -> Result<(), &'static str> { + if payload.is_empty() || payload.len() > MAX_PAYLOAD_BYTES { + return Err("proposal payload is empty or too large"); + } + let value: serde_json::Value = + serde_json::from_str(payload).map_err(|_| "proposal payload is invalid JSON")?; + let compact = + serde_json::to_string(&value).map_err(|_| "proposal payload could not be encoded")?; + if compact != payload { + return Err("proposal payload must be compact JSON"); + } + Ok(()) +} + +fn decode_single_quoted(expression: &str) -> Option { + let body = expression.strip_prefix('\'')?.strip_suffix('\'')?; + let mut decoded = String::with_capacity(body.len()); + let mut chars = body.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\'' { + if chars.next() != Some('\'') { + return None; + } + decoded.push('\''); + } else { + decoded.push(ch); + } + } + Some(decoded) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proposal_channel::ProposalChannelManager; + + fn payload() -> &'static str { + r#"{"schema_version":1,"origin":"terminal_agent","recommended_choice":1,"choices":[{"choice":1,"title":"Run user's test","rationale":"","actions":[{"type":"send","input":"cargo test"}]}]}"# + } + + #[test] + fn canonical_command_round_trips_apostrophes() { + let manager = ProposalChannelManager::new(); + let channel = manager.issue("session".into(), 1, None, false).unwrap(); + let command = render(&channel, payload()).unwrap(); + assert!(command.contains("user''s test")); + let parsed = parse(&command).unwrap(); + assert_eq!(parsed.channel, channel); + assert_eq!(parsed.payload, payload()); + } + + #[test] + fn rejects_former_pipe_and_here_string_shapes() { + let manager = ProposalChannelManager::new(); + let channel = manager.issue("session".into(), 1, None, false).unwrap(); + assert!(parse(&format!( + "'{}' | & \"$env:WTA_CLI_PATH\" propose-terminal-actions --channel {channel}", + payload() + )) + .is_err()); + assert!(parse(&format!( + "@'\n{}\n'@ | & \"$env:WTA_CLI_PATH\" propose-terminal-actions --channel {channel}", + payload() + )) + .is_err()); + } + + #[test] + fn rejects_extra_tokens_and_noncompact_json() { + let manager = ProposalChannelManager::new(); + let channel = manager.issue("session".into(), 1, None, false).unwrap(); + let command = render(&channel, payload()).unwrap(); + assert!(parse(&format!("{command} --extra")).is_err()); + assert!(render(&channel, &payload().replace(",", ", ")).is_err()); + } + + #[test] + fn rejects_alternate_executable_spelling() { + let manager = ProposalChannelManager::new(); + let channel = manager.issue("session".into(), 1, None, false).unwrap(); + let command = render(&channel, payload()) + .unwrap() + .replace("$env:WTA_CLI_PATH", "wta.exe"); + assert!(parse(&command).is_err()); + } + + #[test] + fn defers_proposal_schema_validation_to_helper() { + let manager = ProposalChannelManager::new(); + let channel = manager.issue("session".into(), 1, None, false).unwrap(); + let payload = r#"{"schema_version":999}"#; + let command = render(&channel, payload).unwrap(); + assert_eq!(parse(&command).unwrap().payload, payload); + } +} diff --git a/tools/wta/src/proposal_pipe.rs b/tools/wta/src/proposal_pipe.rs new file mode 100644 index 0000000000..19db2ea3f4 --- /dev/null +++ b/tools/wta/src/proposal_pipe.rs @@ -0,0 +1,513 @@ +use crate::proposal_channel::{ + ProposalChannel, ProposalChannelManager, ProposalFinalStatus, ProposalValidationStatus, + ValidationContext, +}; +use crate::terminal_action_proposal::MAX_PAYLOAD_BYTES; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::net::windows::named_pipe::NamedPipeServer; +use tokio::sync::{mpsc, oneshot}; + +pub const PROTOCOL_VERSION: u32 = 1; +pub const MAX_FRAME_BYTES: usize = MAX_PAYLOAD_BYTES * 6 + 1024; +const VALIDATION_TIMEOUT: Duration = Duration::from_secs(10); +const USER_DECISION_TIMEOUT: Duration = Duration::from_secs(10 * 60); + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProposalPipeRequest { + pub version: u32, + pub channel: String, + pub payload: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProposalValidationResponse { + pub phase: ValidationPhase, + pub status: ProposalValidationStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub proposal_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub retryable: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ValidationPhase { + Validation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProposalFinalResponse { + pub phase: FinalPhase, + pub status: ProposalFinalStatus, + pub proposal_id: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FinalPhase { + Final, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProposalValidationDecision { + pub status: ProposalValidationStatus, + pub reason: Option, + pub retryable: bool, +} + +impl ProposalValidationDecision { + pub fn accepted() -> Self { + Self { + status: ProposalValidationStatus::Accepted, + reason: None, + retryable: false, + } + } +} + +pub enum ProposalPipeEvent { + Validate { + context: ValidationContext, + payload: String, + responder: oneshot::Sender, + }, + Commit { + proposal_id: String, + }, + Invalidate { + proposal_id: String, + session_id: String, + }, +} + +pub async fn run_server( + manager: Arc, + event_tx: mpsc::UnboundedSender, +) -> Result<()> { + let pipe_name = manager.pipe_name(); + let security = crate::named_pipe_security::build_required() + .context("build hardened proposal pipe security")?; + let mut server = crate::named_pipe_security::create_server(&pipe_name, true, Some(&security)) + .with_context(|| format!("create proposal pipe '{pipe_name}'"))?; + tracing::info!( + target: "proposal_pipe", + pipe = %pipe_name, + "proposal pipe listening" + ); + + loop { + server + .connect() + .await + .with_context(|| format!("connect proposal pipe '{pipe_name}'"))?; + let connected = std::mem::replace( + &mut server, + crate::named_pipe_security::create_server(&pipe_name, false, Some(&security)) + .with_context(|| format!("create follow-up proposal pipe '{pipe_name}'"))?, + ); + let manager = Arc::clone(&manager); + let event_tx = event_tx.clone(); + tokio::task::spawn_local(async move { + if let Err(error) = serve_connection(connected, manager, event_tx).await { + tracing::warn!( + target: "proposal_pipe", + error = %format!("{error:#}"), + "proposal pipe connection failed" + ); + } + }); + } +} + +async fn serve_connection( + pipe: NamedPipeServer, + manager: Arc, + event_tx: mpsc::UnboundedSender, +) -> Result<()> { + let (read_half, mut write_half) = tokio::io::split(pipe); + let frame = read_frame(read_half).await?; + let request: ProposalPipeRequest = match serde_json::from_slice(&frame) { + Ok(request) => request, + Err(error) => { + return write_validation_failure( + &mut write_half, + ProposalValidationStatus::Rejected, + format!("invalid request frame: {error}"), + false, + ) + .await; + } + }; + if request.version != PROTOCOL_VERSION { + return write_validation_failure( + &mut write_half, + ProposalValidationStatus::Rejected, + format!( + "unsupported proposal pipe version {} (expected {PROTOCOL_VERSION})", + request.version + ), + false, + ) + .await; + } + if request.payload.len() > MAX_PAYLOAD_BYTES { + return write_validation_failure( + &mut write_half, + ProposalValidationStatus::InvalidSchema, + format!("payload exceeds the {MAX_PAYLOAD_BYTES}-byte inline limit"), + false, + ) + .await; + } + let channel = match request.channel.parse::() { + Ok(channel) => channel, + Err(error) => { + return write_validation_failure( + &mut write_half, + ProposalValidationStatus::UnknownChannel, + error.to_string(), + false, + ) + .await; + } + }; + let context = match manager.begin_validation(&channel, request.payload.as_bytes()) { + Ok(context) => context, + Err(failure) => { + return write_validation_failure( + &mut write_half, + failure.status, + failure.reason.to_string(), + failure.retryable, + ) + .await; + } + }; + let proposal_id = context.proposal_id.clone(); + let session_id = context.binding.session_id.clone(); + let (validation_tx, validation_rx) = oneshot::channel(); + if event_tx + .send(ProposalPipeEvent::Validate { + context, + payload: request.payload, + responder: validation_tx, + }) + .is_err() + { + manager.reject_validation(&proposal_id, false); + return write_validation_failure( + &mut write_half, + ProposalValidationStatus::Unavailable, + "Helper UI is unavailable".to_string(), + false, + ) + .await; + } + let decision = match tokio::time::timeout(VALIDATION_TIMEOUT, validation_rx).await { + Ok(Ok(decision)) => decision, + Ok(Err(_)) => ProposalValidationDecision { + status: ProposalValidationStatus::Unavailable, + reason: Some("Helper dropped the validation response".to_string()), + retryable: false, + }, + Err(_) => ProposalValidationDecision { + status: ProposalValidationStatus::Unavailable, + reason: Some("Helper validation timed out".to_string()), + retryable: false, + }, + }; + if decision.status != ProposalValidationStatus::Accepted { + let retryable = manager.reject_validation(&proposal_id, decision.retryable); + return write_response( + &mut write_half, + &ProposalValidationResponse { + phase: ValidationPhase::Validation, + status: decision.status, + proposal_id: None, + reason: decision.reason, + retryable, + }, + ) + .await; + } + + let (final_tx, final_rx) = oneshot::channel(); + if !manager.accept_validation(&proposal_id, final_tx) { + return write_validation_failure( + &mut write_half, + ProposalValidationStatus::Stale, + "proposal was invalidated while validation completed".to_string(), + false, + ) + .await; + } + if let Err(error) = write_response( + &mut write_half, + &ProposalValidationResponse { + phase: ValidationPhase::Validation, + status: ProposalValidationStatus::Accepted, + proposal_id: Some(proposal_id.clone()), + reason: None, + retryable: false, + }, + ) + .await + { + manager.resolve_final(&proposal_id, ProposalFinalStatus::Unavailable); + let _ = event_tx.send(ProposalPipeEvent::Invalidate { + proposal_id, + session_id, + }); + return Err(error); + } + if event_tx + .send(ProposalPipeEvent::Commit { + proposal_id: proposal_id.clone(), + }) + .is_err() + { + manager.resolve_final(&proposal_id, ProposalFinalStatus::Unavailable); + } + + let final_status = match tokio::time::timeout(USER_DECISION_TIMEOUT, final_rx).await { + Ok(Ok(status)) => status, + Ok(Err(_)) => ProposalFinalStatus::Unavailable, + Err(_) => { + manager.resolve_final(&proposal_id, ProposalFinalStatus::TimedOut); + ProposalFinalStatus::TimedOut + } + }; + if !matches!( + final_status, + ProposalFinalStatus::Confirmed | ProposalFinalStatus::Cancelled + ) { + let _ = event_tx.send(ProposalPipeEvent::Invalidate { + proposal_id: proposal_id.clone(), + session_id, + }); + } + write_response( + &mut write_half, + &ProposalFinalResponse { + phase: FinalPhase::Final, + status: final_status, + proposal_id, + }, + ) + .await +} + +async fn read_frame(reader: R) -> Result> +where + R: tokio::io::AsyncRead + Unpin, +{ + let mut frame = Vec::new(); + let mut reader = BufReader::new(reader).take(MAX_FRAME_BYTES as u64 + 1); + let bytes_read = reader + .read_until(b'\n', &mut frame) + .await + .context("read proposal request frame")?; + if bytes_read == 0 { + anyhow::bail!("proposal client disconnected before sending a frame"); + } + if frame.len() > MAX_FRAME_BYTES { + anyhow::bail!("proposal request frame exceeds {MAX_FRAME_BYTES} bytes"); + } + if frame.last() != Some(&b'\n') { + anyhow::bail!("proposal request frame is not newline terminated"); + } + frame.pop(); + if frame.last() == Some(&b'\r') { + frame.pop(); + } + Ok(frame) +} + +async fn write_validation_failure( + writer: &mut W, + status: ProposalValidationStatus, + reason: String, + retryable: bool, +) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + write_response( + writer, + &ProposalValidationResponse { + phase: ValidationPhase::Validation, + status, + proposal_id: None, + reason: Some(reason), + retryable, + }, + ) + .await +} + +async fn write_response(writer: &mut W, response: &T) -> Result<()> +where + W: AsyncWrite + Unpin, + T: Serialize, +{ + let mut encoded = serde_json::to_vec(response).context("encode proposal pipe response")?; + encoded.push(b'\n'); + writer + .write_all(&encoded) + .await + .context("write proposal pipe response")?; + writer.flush().await.context("flush proposal pipe response") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_and_two_response_phases_have_stable_json_shapes() { + let request = ProposalPipeRequest { + version: PROTOCOL_VERSION, + channel: "v1.helper.turn".to_string(), + payload: "{}".to_string(), + }; + assert_eq!( + serde_json::to_string(&request).unwrap(), + r#"{"version":1,"channel":"v1.helper.turn","payload":"{}"}"# + ); + + let validation = ProposalValidationResponse { + phase: ValidationPhase::Validation, + status: ProposalValidationStatus::Accepted, + proposal_id: Some("proposal".to_string()), + reason: None, + retryable: false, + }; + assert_eq!( + serde_json::to_string(&validation).unwrap(), + r#"{"phase":"validation","status":"accepted","proposal_id":"proposal","retryable":false}"# + ); + + let final_response = ProposalFinalResponse { + phase: FinalPhase::Final, + status: ProposalFinalStatus::Confirmed, + proposal_id: "proposal".to_string(), + }; + assert_eq!( + serde_json::to_string(&final_response).unwrap(), + r#"{"phase":"final","status":"confirmed","proposal_id":"proposal"}"# + ); + + let worst_case_request = serde_json::to_vec(&ProposalPipeRequest { + version: PROTOCOL_VERSION, + channel: "v1.helper.turn".to_string(), + payload: "\\".repeat(MAX_PAYLOAD_BYTES), + }) + .unwrap(); + assert!(worst_case_request.len() < MAX_FRAME_BYTES); + } + + #[tokio::test] + async fn frame_reader_requires_newline_and_enforces_limit() { + let valid = read_frame(std::io::Cursor::new(b"{}\n".to_vec())) + .await + .unwrap(); + assert_eq!(valid, b"{}"); + + let missing_newline = read_frame(std::io::Cursor::new(b"{}".to_vec())) + .await + .unwrap_err(); + assert!(missing_newline.to_string().contains("newline terminated")); + + let oversized = vec![b'x'; MAX_FRAME_BYTES + 1]; + let error = read_frame(std::io::Cursor::new(oversized)) + .await + .unwrap_err(); + assert!(error.to_string().contains("exceeds")); + } + + #[tokio::test] + async fn named_pipe_round_trip_returns_validation_then_final_status() { + let manager = Arc::new(ProposalChannelManager::new()); + let payload = r#"{"schema_version":1,"origin":"terminal_agent","choices":[{"choice":1,"title":"run","rationale":"","actions":[{"type":"send","input":"echo ok"}]}]}"#; + let channel = manager + .issue("session".to_string(), 1, None, false) + .unwrap(); + manager + .arm("session", &channel, payload.as_bytes()) + .unwrap(); + let pipe_name = manager.pipe_name(); + let security = crate::named_pipe_security::build_required().unwrap(); + let server = + crate::named_pipe_security::create_server(&pipe_name, true, Some(&security)).unwrap(); + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + + let server_manager = Arc::clone(&manager); + let server_future = async move { + server.connect().await.unwrap(); + serve_connection(server, server_manager, event_tx) + .await + .unwrap(); + }; + let event_manager = Arc::clone(&manager); + let event_future = async move { + let proposal_id = match event_rx.recv().await.unwrap() { + ProposalPipeEvent::Validate { + context, responder, .. + } => { + let proposal_id = context.proposal_id; + responder + .send(ProposalValidationDecision::accepted()) + .unwrap(); + proposal_id + } + ProposalPipeEvent::Commit { .. } => panic!("commit arrived before validation"), + ProposalPipeEvent::Invalidate { .. } => { + panic!("invalidation arrived before validation") + } + }; + match event_rx.recv().await.unwrap() { + ProposalPipeEvent::Commit { + proposal_id: committed, + } => assert_eq!(committed, proposal_id), + ProposalPipeEvent::Validate { .. } => panic!("duplicate validation event"), + ProposalPipeEvent::Invalidate { .. } => { + panic!("unexpected invalidation for confirmed proposal") + } + } + assert!(event_manager.resolve_final(&proposal_id, ProposalFinalStatus::Confirmed)); + }; + let client_future = async move { + let client = tokio::net::windows::named_pipe::ClientOptions::new() + .open(&pipe_name) + .unwrap(); + let (read_half, mut write_half) = tokio::io::split(client); + let mut request = serde_json::to_vec(&ProposalPipeRequest { + version: PROTOCOL_VERSION, + channel: channel.to_string(), + payload: payload.to_string(), + }) + .unwrap(); + request.push(b'\n'); + write_half.write_all(&request).await.unwrap(); + write_half.flush().await.unwrap(); + + let mut lines = BufReader::new(read_half).lines(); + let validation: ProposalValidationResponse = + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert_eq!(validation.status, ProposalValidationStatus::Accepted); + let proposal_id = validation.proposal_id.unwrap(); + let final_response: ProposalFinalResponse = + serde_json::from_str(&lines.next_line().await.unwrap().unwrap()).unwrap(); + assert_eq!(final_response.status, ProposalFinalStatus::Confirmed); + assert_eq!(final_response.proposal_id, proposal_id); + }; + + tokio::join!(server_future, event_future, client_future); + } +} diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index 211388bff0..3544ca6c9f 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -1,5 +1,5 @@ -use super::failure::{AgentFailure, HandshakeStage}; use super::conn; +use super::failure::{AgentFailure, HandshakeStage}; use super::prompt; use super::prompt_context::{self, ContextRequest}; use super::soft_stop::SoftStopReason; @@ -22,8 +22,8 @@ const ACTIVE_PANE_CONTEXT_MAX_CHARS: usize = 4000; // pipe only after spawning and initializing the agent CLI (up to 60s for npx // adapters), so keep a long budget there. const MASTER_PIPE_BACKOFF_MS: &[u64] = &[ - 50, 100, 100, 200, 200, 500, 500, 1000, 1000, 2000, 2000, 2000, 5000, 5000, 5000, 5000, - 10000, 10000, 10000, 15000, + 50, 100, 100, 200, 200, 500, 500, 1000, 1000, 2000, 2000, 2000, 5000, 5000, 5000, 5000, 10000, + 10000, 10000, 15000, ]; // Post-login reconnect is different: if the old master pipe is gone, the right // recovery is a fresh master restart. Keep a short bounded retry so brief @@ -548,8 +548,7 @@ impl PromptTimingState { // jumps (NTP/DST) that could otherwise produce a negative delta // we'd silently drop, skewing the aggregate. if let Some(sent_mono) = prompt_sent_at_mono { - let first_token_latency_ms = - sent_mono.elapsed().as_secs_f64() * 1000.0; + let first_token_latency_ms = sent_mono.elapsed().as_secs_f64() * 1000.0; crate::telemetry::log_agent_response_first_token( session_id, first_token_latency_ms, @@ -576,7 +575,9 @@ impl PromptTimingState { drop(guard); prompt_timing_log(turn_id, submitted_at_unix_s, "first_tool_call", &details); // Tool-call title is agent-generated content — trace only. - acp_trace_content(&format!("turn {turn_id} first_tool_call title={title_preview:?}")); + acp_trace_content(&format!( + "turn {turn_id} first_tool_call title={title_preview:?}" + )); } } } @@ -1105,7 +1106,10 @@ async fn resolve_pane_by_session_id( let Some(tab_id) = json_str_or_num(tab.get("tab_id")) else { continue; }; - let Ok(panes) = shell_mgr.wt_list_panes(&tab_id, Some(window_id.as_str())).await else { + let Ok(panes) = shell_mgr + .wt_list_panes(&tab_id, Some(window_id.as_str())) + .await + else { continue; }; let Some(panes_arr) = panes.get("panes").and_then(|v| v.as_array()) else { @@ -1122,7 +1126,12 @@ async fn resolve_pane_by_session_id( None } -pub(crate) async fn build_terminal_context_json(shell_mgr: &ShellManager) -> Option { +pub(crate) struct TerminalContext { + pub json: String, + pub active_target: String, +} + +pub(crate) async fn build_terminal_context(shell_mgr: &ShellManager) -> Option { // WT's GetActivePane already resolves the agent pane to the user's working // pane (the "source"), so a single active-pane query gives us the right // target. Pane IDs are process-globally unique, so we only need the pane @@ -1169,7 +1178,7 @@ pub(crate) async fn build_terminal_context_json(shell_mgr: &ShellManager) -> Opt ) .await; - serde_json::to_string(&serde_json::json!({ + let json = serde_json::to_string(&serde_json::json!({ "activeTarget": target_pane_id, "window_title": target_window_title, "cwd": target_cwd, @@ -1177,7 +1186,11 @@ pub(crate) async fn build_terminal_context_json(shell_mgr: &ShellManager) -> Opt "locale": user_locale_tag(), "buffer": buffer, })) - .ok() + .ok()?; + Some(TerminalContext { + json, + active_target: target_pane_id, + }) } /// User's UI locale as a BCP-47 tag, suitable for embedding in @@ -1201,7 +1214,7 @@ async fn build_prompt_text( shell_mgr: &ShellManager, wt_connected: bool, pane_context: Option<&PaneContext>, -) -> (String, String, String, Option) { +) -> (String, String, String, Option, Option) { let total_started = std::time::Instant::now(); let mut runtime_sections = Vec::new(); // Working pane resolved from the active pane for a manual `/fix` (one with @@ -1209,6 +1222,7 @@ async fn build_prompt_text( // `AutofixContext.target_pane_id` — empty otherwise (auto-fix carries its // failing pane explicitly; planner turns let the agent fill `Send.parent`). let mut resolved_fix_pane: Option = None; + let mut planner_terminal_context: Option = None; let template_started = std::time::Instant::now(); let planner_template = if is_autofix { @@ -1303,6 +1317,9 @@ async fn build_prompt_text( .await; } } + if !is_autofix && wt_connected { + planner_terminal_context = build_terminal_context(shell_mgr).await; + } // ── Provider-driven section assembly ──────────────────────────────────── // Each `### …` context source is a `ContextProvider`; the chain self-gates @@ -1312,7 +1329,9 @@ async fn build_prompt_text( let context_request = ContextRequest { is_autofix, wt_connected, - shell_mgr, + terminal_context_json: planner_terminal_context + .as_ref() + .map(|context| context.json.as_str()), context_pane: context_pane.as_ref(), shell_exe: shell_exe.as_deref(), terminal_output: terminal_output.as_deref(), @@ -1387,6 +1406,7 @@ async fn build_prompt_text( planner_template.source_label, planner_template.display_name, resolved_fix_pane, + planner_terminal_context.map(|context| context.active_target), ) } @@ -1510,6 +1530,8 @@ struct ClientState { event_tx: mpsc::UnboundedSender, shell_mgr: Arc, prompt_timing: Arc, + proposal_channels: Arc, + direct_proposals_enabled: bool, } /// Our Client trait implementation — handles incoming agent requests and notifications. @@ -1529,6 +1551,38 @@ fn session_update_kind(update: &acp::schema::v1::SessionUpdate) -> &'static str } } +fn copilot_permission_command(args: &acp::schema::v1::RequestPermissionRequest) -> Option<&str> { + if args.tool_call.fields.kind != Some(acp::schema::v1::ToolKind::Execute) { + return None; + } + let raw_input = args.tool_call.fields.raw_input.as_ref()?.as_object()?; + if raw_input.len() != 2 { + return None; + } + let command = raw_input.get("command")?.as_str()?; + let commands = raw_input.get("commands")?.as_array()?; + (commands.len() == 1 && commands.first()?.as_str()? == command).then_some(command) +} + +fn proposal_permission_command_candidate( + args: &acp::schema::v1::RequestPermissionRequest, +) -> Option<&str> { + if args.tool_call.fields.kind != Some(acp::schema::v1::ToolKind::Execute) { + return None; + } + args.tool_call + .fields + .raw_input + .as_ref()? + .as_object()? + .get("command")? + .as_str() +} + +fn looks_like_proposal_command(command: &str) -> bool { + command.contains("propose-terminal-actions") +} + impl WtaClient { async fn request_permission( &self, @@ -1551,6 +1605,71 @@ impl WtaClient { .prompt_timing .permission_requested(&session_id, &description); + if self.state.direct_proposals_enabled { + if let Some(command) = copilot_permission_command(&args) { + match crate::proposal_invocation::parse(command) { + Ok(invocation) => { + let Some(option) = args.options.iter().find(|option| { + option.kind == acp::schema::v1::PermissionOptionKind::AllowOnce + }) else { + self.state + .prompt_timing + .permission_resolved(&session_id, "proposal_cancelled"); + return Ok(acp::schema::v1::RequestPermissionResponse::new( + acp::schema::v1::RequestPermissionOutcome::Cancelled, + )); + }; + let arm_result = self.state.proposal_channels.arm( + &session_id, + &invocation.channel, + invocation.payload.as_bytes(), + ); + tracing::info!( + target: "proposal_permission", + session_id = %session_id, + armed = arm_result.is_ok(), + status = ?arm_result.as_ref().err().map(|failure| failure.status), + "silently resolving canonical proposal permission" + ); + self.state + .prompt_timing + .permission_resolved(&session_id, "proposal_allow_once"); + return Ok(acp::schema::v1::RequestPermissionResponse::new( + acp::schema::v1::RequestPermissionOutcome::Selected( + acp::schema::v1::SelectedPermissionOutcome::new( + option.option_id.clone(), + ), + ), + )); + } + Err(reason) if looks_like_proposal_command(command) => { + tracing::info!( + target: "proposal_permission", + session_id = %session_id, + reason, + "silently cancelled non-canonical proposal command" + ); + self.state + .prompt_timing + .permission_resolved(&session_id, "proposal_noncanonical"); + return Ok(acp::schema::v1::RequestPermissionResponse::new( + acp::schema::v1::RequestPermissionOutcome::Cancelled, + )); + } + Err(_) => {} + } + } else if proposal_permission_command_candidate(&args) + .is_some_and(looks_like_proposal_command) + { + self.state + .prompt_timing + .permission_resolved(&session_id, "proposal_noncanonical"); + return Ok(acp::schema::v1::RequestPermissionResponse::new( + acp::schema::v1::RequestPermissionOutcome::Cancelled, + )); + } + } + let options: Vec = args .options .iter() @@ -1577,9 +1696,9 @@ impl WtaClient { .prompt_timing .permission_resolved(&session_id, "selected"); Ok(acp::schema::v1::RequestPermissionResponse::new( - acp::schema::v1::RequestPermissionOutcome::Selected(acp::schema::v1::SelectedPermissionOutcome::new( - option_id, - )), + acp::schema::v1::RequestPermissionOutcome::Selected( + acp::schema::v1::SelectedPermissionOutcome::new(option_id), + ), )) } Err(_) => { @@ -1593,7 +1712,10 @@ impl WtaClient { } } - async fn session_notification(&self, args: acp::schema::v1::SessionNotification) -> acp::Result<()> { + async fn session_notification( + &self, + args: acp::schema::v1::SessionNotification, + ) -> acp::Result<()> { let kind = session_update_kind(&args.update); // Per-streamed-chunk; trace-only (not via acp_log's debug) so default // debug logs aren't flooded with one line per token chunk. @@ -1602,9 +1724,7 @@ impl WtaClient { // content, plan bodies, and replayed user-message chunks — trace only. acp_trace_content(&format!("session_notification update: {:?}", args.update)); let sid = args.session_id.0.to_string(); - self.state - .prompt_timing - .observe_session_update(&sid, kind); + self.state.prompt_timing.observe_session_update(&sid, kind); match args.update { acp::schema::v1::SessionUpdate::UserMessageChunk(chunk) => { // Replayed historical user prompt from `session/load`. @@ -1685,8 +1805,12 @@ impl WtaClient { .map(|e| PlanEntry { content: e.content.clone(), status: match e.status { - acp::schema::v1::PlanEntryStatus::Completed => PlanEntryStatus::Completed, - acp::schema::v1::PlanEntryStatus::InProgress => PlanEntryStatus::InProgress, + acp::schema::v1::PlanEntryStatus::Completed => { + PlanEntryStatus::Completed + } + acp::schema::v1::PlanEntryStatus::InProgress => { + PlanEntryStatus::InProgress + } _ => PlanEntryStatus::Pending, }, }) @@ -1757,7 +1881,8 @@ impl WtaClient { Ok(output) => { let mut resp = acp::schema::v1::TerminalOutputResponse::new(output.data, false); if let Some(code) = output.exit_status { - resp = resp.exit_status(acp::schema::v1::TerminalExitStatus::new().exit_code(code)); + resp = resp + .exit_status(acp::schema::v1::TerminalExitStatus::new().exit_code(code)); } Ok(resp) } @@ -2109,6 +2234,8 @@ pub async fn run_acp_client_over_pipe( shell_mgr: Arc, wt_connected: bool, post_login_reconnect: bool, + proposal_channels: Arc, + direct_proposals_enabled: bool, ) -> Result<()> { let startup_probe = StartupProbe::new(); startup_probe.log(&format!( @@ -2209,6 +2336,8 @@ pub async fn run_acp_client_over_pipe( event_tx: event_tx.clone(), shell_mgr: shell_mgr.clone(), prompt_timing: prompt_timing.clone(), + proposal_channels: Arc::clone(&proposal_channels), + direct_proposals_enabled, }); let client = WtaClient { @@ -2218,30 +2347,73 @@ pub async fn run_acp_client_over_pipe( let builder = acp::Client .builder() .name("wta-helper") - .on_receive_request({ let c = client.clone(); move |req: acp::schema::v1::AgentRequest, responder, _cx| { let c = c.clone(); async move { - use acp::schema::v1::{AgentRequest as Q, ClientResponse as R}; - match req { - Q::RequestPermissionRequest(a) => conn::respond_enum(responder, c.request_permission(a).await.map(R::RequestPermissionResponse)), - Q::CreateTerminalRequest(a) => conn::respond_enum(responder, c.create_terminal(a).await.map(R::CreateTerminalResponse)), - Q::TerminalOutputRequest(a) => conn::respond_enum(responder, c.terminal_output(a).await.map(R::TerminalOutputResponse)), - Q::WaitForTerminalExitRequest(a) => conn::respond_enum(responder, c.wait_for_terminal_exit(a).await.map(R::WaitForTerminalExitResponse)), - Q::ReleaseTerminalRequest(a) => conn::respond_enum(responder, c.release_terminal(a).await.map(R::ReleaseTerminalResponse)), - Q::KillTerminalRequest(a) => conn::respond_enum(responder, c.kill_terminal(a).await.map(R::KillTerminalResponse)), - _ => responder.respond_with_error(acp::Error::method_not_found()), - } - } } }, acp::on_receive_request!()) - .on_receive_notification({ let c = client.clone(); move |notif: acp::schema::v1::AgentNotification, _cx| { let c = c.clone(); async move { - use acp::schema::v1::AgentNotification as N; - match notif { - N::SessionNotification(n) => { let _ = c.session_notification(n).await; } - N::ExtNotification(n) => { let _ = c.ext_notification(n).await; } - _ => {} - } - Ok(()) - } } }, acp::on_receive_notification!()); + .on_receive_request( + { + let c = client.clone(); + move |req: acp::schema::v1::AgentRequest, responder, _cx| { + let c = c.clone(); + async move { + use acp::schema::v1::{AgentRequest as Q, ClientResponse as R}; + match req { + Q::RequestPermissionRequest(a) => conn::respond_enum( + responder, + c.request_permission(a) + .await + .map(R::RequestPermissionResponse), + ), + Q::CreateTerminalRequest(a) => conn::respond_enum( + responder, + c.create_terminal(a).await.map(R::CreateTerminalResponse), + ), + Q::TerminalOutputRequest(a) => conn::respond_enum( + responder, + c.terminal_output(a).await.map(R::TerminalOutputResponse), + ), + Q::WaitForTerminalExitRequest(a) => conn::respond_enum( + responder, + c.wait_for_terminal_exit(a) + .await + .map(R::WaitForTerminalExitResponse), + ), + Q::ReleaseTerminalRequest(a) => conn::respond_enum( + responder, + c.release_terminal(a).await.map(R::ReleaseTerminalResponse), + ), + Q::KillTerminalRequest(a) => conn::respond_enum( + responder, + c.kill_terminal(a).await.map(R::KillTerminalResponse), + ), + _ => responder.respond_with_error(acp::Error::method_not_found()), + } + } + } + }, + acp::on_receive_request!(), + ) + .on_receive_notification( + { + let c = client.clone(); + move |notif: acp::schema::v1::AgentNotification, _cx| { + let c = c.clone(); + async move { + use acp::schema::v1::AgentNotification as N; + match notif { + N::SessionNotification(n) => { + let _ = c.session_notification(n).await; + } + N::ExtNotification(n) => { + let _ = c.ext_notification(n).await; + } + _ => {} + } + Ok(()) + } + } + }, + acp::on_receive_notification!(), + ); - let (conn, handle_io) = - conn::spawn_client(builder, conn::byte_streams(outgoing, incoming)); + let (conn, handle_io) = conn::spawn_client(builder, conn::byte_streams(outgoing, incoming)); startup_probe.log("ACP client connection created (over pipe)"); let io_probe = startup_probe.clone(); @@ -2286,8 +2458,7 @@ pub async fn run_acp_client_over_pipe( startup_probe.log("Initializing ACP (over pipe)"); let init_started = std::time::Instant::now(); let init_request = { - let mut req = - acp::schema::v1::InitializeRequest::new(acp::schema::ProtocolVersion::V1) + let mut req = acp::schema::v1::InitializeRequest::new(acp::schema::ProtocolVersion::V1) .client_capabilities(acp::schema::v1::ClientCapabilities::new().terminal(true)) .client_info( acp::schema::v1::Implementation::new("wta-helper", env!("CARGO_PKG_VERSION")) @@ -2314,17 +2485,14 @@ pub async fn run_acp_client_over_pipe( let id = s.trim().to_ascii_lowercase(); crate::agent_registry::is_known_id(&id).then_some(id) }), - model: acp_model_override - .clone() - .filter(|s| !s.trim().is_empty()), + model: acp_model_override.clone().filter(|s| !s.trim().is_empty()), ..Default::default() }, ); req }; let init_future = conn.initialize(init_request); - let init_result = - tokio::time::timeout(std::time::Duration::from_secs(60), init_future).await; + let init_result = tokio::time::timeout(std::time::Duration::from_secs(60), init_future).await; log_acp_initialize_timeout_result("HelperPipe", init_started, &init_result); let init_resp = init_result .map_err(|_| { @@ -2378,10 +2546,7 @@ pub async fn run_acp_client_over_pipe( // "authenticate-OK-but-still-auth" recovery signal below. let mut post_login_authenticated = false; if post_login_reconnect { - let auth_method_id = init_resp - .auth_methods - .first() - .map(|m| m.id().clone()); + let auth_method_id = init_resp.auth_methods.first().map(|m| m.id().clone()); if let Some(method_id) = auth_method_id { tracing::info!( target: "helper", @@ -2460,7 +2625,10 @@ pub async fn run_acp_client_over_pipe( // older master without `unstable_session_list`) the alive mirror // just stays empty and `alive_loaded` stays false, which keeps // session management routing on the legacy path. - match conn.list_sessions(acp::schema::v1::ListSessionsRequest::new()).await { + match conn + .list_sessions(acp::schema::v1::ListSessionsRequest::new()) + .await + { Ok(resp) => { let items: Vec = resp .sessions @@ -2500,44 +2668,45 @@ pub async fn run_acp_client_over_pipe( // sid (both bound to the same WT pane) and the session management view showed two // Live rows for the same agent pane. let cwd = std::env::current_dir().unwrap_or_default(); - let (session_id, available_models, current_model_id, has_bootstrap) = - if let Some(load_sid) = initial_load_session_id.as_deref() { - // No bootstrap. AgentConnected fires with the to-be-loaded - // sid as a placeholder so the App flips to Connected (and - // binds session_id → owner_tab in `session_to_tab` early, - // so any session/update chunks arriving before the - // load_session response route to the right tab). The - // actual `load_session` is driven by the App after it - // processes the queued WtEvent — see `load_session_rx` - // arm below for success/failure handling, including the - // fallback-to-new-session on boot-time load failure. - startup_probe.log(&format!( - "skipping bootstrap session/new (initial_load_session_id={} set)", - load_sid, - )); - // Resume is intentionally silent: show the same neutral connecting - // stage a fresh pane would, never "Resuming session …", so a - // resumed pane is indistinguishable from a normal connection. - let _ = event_tx.send(AppEvent::ConnectionStage("Connecting...".to_string())); - ( - acp::schema::v1::SessionId::new(load_sid.to_string()), - Vec::::new(), - None, - false, - ) - } else { - let _ = event_tx.send(AppEvent::ConnectionStage("Creating session...".to_string())); - startup_probe.log("Creating session (over pipe)"); - let mut new_session_req = acp::schema::v1::NewSessionRequest::new(cwd.clone()); - inject_wta_pane_meta(&mut new_session_req.meta); - let new_session_started = std::time::Instant::now(); - let new_session_result = conn.new_session(new_session_req).await; - log_acp_new_session_result( - "HelperPipeStartup", - new_session_started, - &new_session_result, - ); - let session = new_session_result.map_err(|e| { + let (session_id, available_models, current_model_id, has_bootstrap) = if let Some(load_sid) = + initial_load_session_id.as_deref() + { + // No bootstrap. AgentConnected fires with the to-be-loaded + // sid as a placeholder so the App flips to Connected (and + // binds session_id → owner_tab in `session_to_tab` early, + // so any session/update chunks arriving before the + // load_session response route to the right tab). The + // actual `load_session` is driven by the App after it + // processes the queued WtEvent — see `load_session_rx` + // arm below for success/failure handling, including the + // fallback-to-new-session on boot-time load failure. + startup_probe.log(&format!( + "skipping bootstrap session/new (initial_load_session_id={} set)", + load_sid, + )); + // Resume is intentionally silent: show the same neutral connecting + // stage a fresh pane would, never "Resuming session …", so a + // resumed pane is indistinguishable from a normal connection. + let _ = event_tx.send(AppEvent::ConnectionStage("Connecting...".to_string())); + ( + acp::schema::v1::SessionId::new(load_sid.to_string()), + Vec::::new(), + None, + false, + ) + } else { + let _ = event_tx.send(AppEvent::ConnectionStage("Creating session...".to_string())); + startup_probe.log("Creating session (over pipe)"); + let mut new_session_req = acp::schema::v1::NewSessionRequest::new(cwd.clone()); + inject_wta_pane_meta(&mut new_session_req.meta); + let new_session_started = std::time::Instant::now(); + let new_session_result = conn.new_session(new_session_req).await; + log_acp_new_session_result( + "HelperPipeStartup", + new_session_started, + &new_session_result, + ); + let session = new_session_result.map_err(|e| { let failure = AgentFailure::from_acp_error(&e); // If we just completed post-login authenticate successfully // but new_session STILL returns AuthRequired, do NOT route @@ -2579,28 +2748,28 @@ pub async fn run_acp_client_over_pipe( .context(format!("new_session over master pipe failed: {e}")) })?; - let session_id = session.session_id.clone(); - startup_probe.log(&format!("Session created (over pipe): {}", session_id)); - if is_agent_pane { - let pane_session_id = std::env::var("WT_SESSION").unwrap_or_default(); - let pane_for_index = if pane_session_id.is_empty() { - None - } else { - Some(pane_session_id.as_str()) - }; - tracing::info!( - target: "agent_pane_origin", - session_id = %session_id, - pane_session_id = %pane_session_id, - "recording agent-pane session origin (startup over pipe)", - ); - crate::agent_pane_origin::append_default(session_id.0.as_ref(), pane_for_index); - } + let session_id = session.session_id.clone(); + startup_probe.log(&format!("Session created (over pipe): {}", session_id)); + if is_agent_pane { + let pane_session_id = std::env::var("WT_SESSION").unwrap_or_default(); + let pane_for_index = if pane_session_id.is_empty() { + None + } else { + Some(pane_session_id.as_str()) + }; + tracing::info!( + target: "agent_pane_origin", + session_id = %session_id, + pane_session_id = %pane_session_id, + "recording agent-pane session origin (startup over pipe)", + ); + crate::agent_pane_origin::append_default(session_id.0.as_ref(), pane_for_index); + } - let (available_models, current_model_id) = - crate::protocol::acp::model_select::models_from_new_session(&session); - (session_id, available_models, current_model_id, true) - }; + let (available_models, current_model_id) = + crate::protocol::acp::model_select::models_from_new_session(&session); + (session_id, available_models, current_model_id, true) + }; // Apply --acp-model if requested. Only valid when we actually have // a bootstrap session to mutate; for the initial-load path the @@ -2623,11 +2792,7 @@ pub async fn run_acp_client_over_pipe( ) .await .map_err(|e| { - anyhow::anyhow!( - "failed to set requested model {}: {}", - requested_model, - e - ) + anyhow::anyhow!("failed to set requested model {}: {}", requested_model, e) })?; startup_probe.log(&format!( "ACP session model set to {} (over pipe)", @@ -2665,6 +2830,7 @@ pub async fn run_acp_client_over_pipe( load_session_supported, image_supported, }); + proposal_channels.set_transport_available(true); // Per-tab session cache. Only // prepopulate the owner-tab binding when we actually have a @@ -2736,6 +2902,7 @@ pub async fn run_acp_client_over_pipe( dispatch_master_ext_request(req, &conn, &event_tx, &tab_to_session); } Some(req) = restart_rx.recv() => { + proposal_channels.set_transport_available(false); // Helper can't restart the agent CLI in-process — master owns // its lifetime, and master itself is a singleton owned by // `SharedWta` on the C++ side. Ask the C++ side to do a full @@ -2763,9 +2930,11 @@ pub async fn run_acp_client_over_pipe( crate::app::send_wt_protocol_event(evt.to_string()); } Some(req) = cancel_rx.recv() => { + proposal_channels.cancel_active(); dispatch_cancel(req, &conn, &cancel_signals); } Some(req) = new_session_rx.recv() => { + proposal_channels.replace_session(); dispatch_new_session( req, &conn, @@ -2779,6 +2948,7 @@ pub async fn run_acp_client_over_pipe( ); } Some(req) = load_session_rx.recv() => { + proposal_channels.replace_session(); dispatch_load_session( req, &conn, @@ -2791,13 +2961,14 @@ pub async fn run_acp_client_over_pipe( ); } Some(req) = drop_session_rx.recv() => { + proposal_channels.replace_session(); dispatch_drop_session(req, &conn, &tab_to_session, &template_memo, &cancel_signals); } Some(req) = rename_session_rx.recv() => { dispatch_rename_session(req, &tab_to_session); } Some(prompt) = prompt_rx.recv() => { - dispatch_prompt( + dispatch_prompt_with_proposals( prompt, &conn, &tab_to_session, @@ -2809,12 +2980,15 @@ pub async fn run_acp_client_over_pipe( &prompt_timing, wt_connected, is_agent_pane, + &proposal_channels, + direct_proposals_enabled, ); } else => break, } } + proposal_channels.set_transport_available(false); startup_probe.log("run_acp_client_over_pipe loop ended"); Ok(()) } @@ -2910,8 +3084,7 @@ fn dispatch_master_ext_request( } } MasterExtRequest::SessionBornBound { event } => { - const BORN_BOUND_TIMEOUT: std::time::Duration = - std::time::Duration::from_secs(8); + const BORN_BOUND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8); let wire = crate::session_registry::build_born_bound_request(&event); match tokio::time::timeout(BORN_BOUND_TIMEOUT, conn.ext_method(wire)).await { Ok(Ok(response)) => tracing::debug!( @@ -2969,9 +3142,7 @@ fn dispatch_master_ext_request( let sessions: Vec = { let g = tab_to_session.lock().await; match &session_id { - Some(target) => { - g.values().filter(|s| *s == target).cloned().collect() - } + Some(target) => g.values().filter(|s| *s == target).cloned().collect(), None => g.values().cloned().collect(), } }; @@ -3080,7 +3251,8 @@ fn dispatch_load_session( } let session_id = acp::schema::v1::SessionId::new(req.session_id.clone()); - let mut load_req = acp::schema::v1::LoadSessionRequest::new(session_id.clone(), cwd.clone()); + let mut load_req = + acp::schema::v1::LoadSessionRequest::new(session_id.clone(), cwd.clone()); // Tell master which WT pane owns the session we're about to // rehydrate, so the registry row for the resumed sid carries // `pane_session_id = ` and cross-helper Focus @@ -3452,15 +3624,14 @@ fn build_prompt_content( ) -> Vec { let mut content: Vec = vec![text.to_string().into()]; for image in images { - content.push(acp::schema::v1::ContentBlock::Image(acp::schema::v1::ImageContent::new( - image.data_base64.clone(), - image.mime_type.clone(), - ))); + content.push(acp::schema::v1::ContentBlock::Image( + acp::schema::v1::ImageContent::new(image.data_base64.clone(), image.mime_type.clone()), + )); } content } -fn dispatch_prompt( +fn dispatch_prompt_with_proposals( prompt: PromptSubmission, conn: &conn::ClientLink, tab_to_session: &Arc>>, @@ -3472,6 +3643,8 @@ fn dispatch_prompt( prompt_timing: &Arc, wt_connected: bool, is_agent_pane: bool, + proposal_channels: &Arc, + direct_proposals_enabled: bool, ) { let tab_key = prompt .pane_context @@ -3497,6 +3670,7 @@ fn dispatch_prompt( let event_tx_task = event_tx.clone(); let shell_mgr_task = Arc::clone(shell_mgr); let prompt_timing_task = Arc::clone(prompt_timing); + let proposal_channels_task = Arc::clone(proposal_channels); let tab_key_task = tab_key.clone(); tokio::task::spawn_local(dispatch_prompt_body( @@ -3512,9 +3686,42 @@ fn dispatch_prompt( tab_key_task, wt_connected, is_agent_pane, + proposal_channels_task, + direct_proposals_enabled, )); } +#[cfg(test)] +fn dispatch_prompt( + prompt: PromptSubmission, + conn: &conn::ClientLink, + tab_to_session: &Arc>>, + template_memo: &TemplateMemo, + in_flight_tabs: &Arc>>, + cancel_signals: &Arc>>>, + event_tx: &mpsc::UnboundedSender, + shell_mgr: &Arc, + prompt_timing: &Arc, + wt_connected: bool, + is_agent_pane: bool, +) { + dispatch_prompt_with_proposals( + prompt, + conn, + tab_to_session, + template_memo, + in_flight_tabs, + cancel_signals, + event_tx, + shell_mgr, + prompt_timing, + wt_connected, + is_agent_pane, + &Arc::new(crate::proposal_channel::ProposalChannelManager::new()), + false, + ); +} + /// The per-prompt task body: lazily resolves the tab's ACP session, /// streams the prompt, listens for cancel, and cleans up. Spawned by /// [`dispatch_prompt`] and never called directly from the event loop. @@ -3532,6 +3739,8 @@ async fn dispatch_prompt_body( tab_key_task: String, wt_connected: bool, is_agent_pane: bool, + proposal_channels: Arc, + direct_proposals_enabled: bool, ) { // Resolve (or lazily create) the ACP session for this tab. let prompt_session_id = { @@ -3606,26 +3815,53 @@ async fn dispatch_prompt_body( .await; prompt_timing_task.activate(&prompt_session_id_str, &prompt); - let (text, prompt_source, prompt_name, resolved_fix_pane) = build_prompt_text( - prompt.id, - prompt.submitted_at_unix_s, - &prompt.text, - prompt.is_autofix, - include_template, - &shell_mgr_task, - wt_connected, - prompt.pane_context.as_ref(), - ) - .await; + let (mut text, prompt_source, prompt_name, resolved_fix_pane, active_target) = + build_prompt_text( + prompt.id, + prompt.submitted_at_unix_s, + &prompt.text, + prompt.is_autofix, + include_template, + &shell_mgr_task, + wt_connected, + prompt.pane_context.as_ref(), + ) + .await; + if direct_proposals_enabled { + match proposal_channels.issue( + prompt_session_id_str.clone(), + prompt.id, + active_target.clone(), + prompt.is_autofix, + ) { + Ok(channel) => { + text.push_str(&format!( + "\n\n[intellterm.wta proposal]\n\ + To present terminal actions, run exactly one command in this form:\n\ + & \"$env:WTA_CLI_PATH\" propose-terminal-actions --channel {channel} \ + --payload-json ''\n\ + Replace only . Do not use stdin, a pipeline, a here-string, \ + redirection, a temporary file, or another executable spelling. Read both \ + JSON response lines: validation is immediate; final reports the user's \ + confirm or cancel decision." + )); + } + Err(error) => { + tracing::warn!( + target: "proposal_channel", + status = ?error.status, + reason = error.reason, + "failed to issue proposal channel for prompt" + ); + } + } + } // A manual `/fix` resolved its working pane in build_prompt_text (it had no // explicit source pane). Plumb it back so the App fills the turn's // `target_pane_id`; the host fills `Send.parent` from it at execute time. if let Some(pane_id) = resolved_fix_pane { let _ = event_tx_task.send(AppEvent::AutofixTargetResolved { - tab_id: prompt - .pane_context - .as_ref() - .and_then(|c| c.tab_id.clone()), + tab_id: prompt.pane_context.as_ref().and_then(|c| c.tab_id.clone()), prompt_id: prompt.id, pane_id, }); @@ -3679,10 +3915,8 @@ async fn dispatch_prompt_body( // through master → agent CLI verbatim; the agent only receives them if it // advertised `promptCapabilities.image` (the UI gates Alt+V on that flag). let content = build_prompt_content(&text, &prompt.images); - let prompt_fut = conn_task.prompt(acp::schema::v1::PromptRequest::new( - prompt_session_id.clone(), - content, - )); + let prompt_request = acp::schema::v1::PromptRequest::new(prompt_session_id.clone(), content); + let prompt_fut = conn_task.prompt(prompt_request); tokio::pin!(prompt_fut); let cancelled = tokio::select! { @@ -3734,14 +3968,16 @@ async fn dispatch_prompt_body( #[cfg(test)] mod tests { + use super::acp; use super::{ - acp_result_failure_fields, complete_prompt_request, inject_wta_pane_meta, shell_from_active, - post_login_authenticate_error, timeout_result_failure_fields, user_locale_tag, - PromptTimingState, SoftStopReason, + acp_result_failure_fields, complete_prompt_request, inject_wta_pane_meta, + post_login_authenticate_error, shell_from_active, timeout_result_failure_fields, + user_locale_tag, ClientState, PromptTimingState, SoftStopReason, WtaClient, }; - use super::acp; - use crate::protocol::acp::failure::{AgentFailure, HandshakeStage}; use crate::app::AppEvent; + use crate::protocol::acp::failure::{AgentFailure, HandshakeStage}; + use crate::shell::ShellManager; + use std::sync::Arc; use tokio::sync::mpsc; /// `shell_from_active` resolves our own pid to a real exe name (the test @@ -3783,10 +4019,8 @@ mod tests { #[test] fn post_login_authenticate_auth_required_routes_to_recovery_failure() { let err = post_login_authenticate_error("copilot-login", &acp::Error::auth_required()); - let failure = crate::protocol::acp::failure::classify_anyhow( - &err, - HandshakeStage::Authenticate, - ); + let failure = + crate::protocol::acp::failure::classify_anyhow(&err, HandshakeStage::Authenticate); assert!( matches!(failure, AgentFailure::AuthRequired { .. }), "AuthRequired from post-login authenticate should stay recoverable, got {failure:?}" @@ -3795,14 +4029,9 @@ mod tests { #[test] fn post_login_authenticate_non_auth_stays_authenticate_handshake_failure() { - let err = post_login_authenticate_error( - "copilot-login", - &acp::Error::new(-32603, "boom"), - ); - let failure = crate::protocol::acp::failure::classify_anyhow( - &err, - HandshakeStage::Authenticate, - ); + let err = post_login_authenticate_error("copilot-login", &acp::Error::new(-32603, "boom")); + let failure = + crate::protocol::acp::failure::classify_anyhow(&err, HandshakeStage::Authenticate); assert!( matches!( failure, @@ -4117,15 +4346,15 @@ mod tests { // Completed in time, inner Err → surface the ACP error code. let inner_err: Result, tokio::time::error::Elapsed> = Ok(Err(acp::Error::new(-32000, "nope"))); - assert_eq!(timeout_result_failure_fields(&inner_err), ("AcpError", -32000)); + assert_eq!( + timeout_result_failure_fields(&inner_err), + ("AcpError", -32000) + ); // Outer future elapsed → Timeout, no ACP code. - let elapsed = tokio::time::timeout( - std::time::Duration::ZERO, - std::future::pending::<()>(), - ) - .await - .expect_err("a zero-duration timeout over a pending future must elapse"); + let elapsed = tokio::time::timeout(std::time::Duration::ZERO, std::future::pending::<()>()) + .await + .expect_err("a zero-duration timeout over a pending future must elapse"); let timed_out: Result, tokio::time::error::Elapsed> = Err(elapsed); assert_eq!(timeout_result_failure_fields(&timed_out), ("Timeout", 0)); } @@ -4242,7 +4471,7 @@ mod tests { #[tokio::test] async fn build_terminal_context_json_none_without_wt_channel() { let mgr = crate::shell::ShellManager::new(); - assert!(super::build_terminal_context_json(&mgr).await.is_none()); + assert!(super::build_terminal_context(&mgr).await.is_none()); } #[tokio::test] @@ -4252,7 +4481,7 @@ mod tests { "is_agent_pane": true, })); assert!( - super::build_terminal_context_json(&mgr).await.is_none(), + super::build_terminal_context(&mgr).await.is_none(), "an active agent pane has no terminal output to ship" ); } @@ -4266,9 +4495,10 @@ mod tests { "pid": std::process::id(), "is_agent_pane": false, })); - let json = super::build_terminal_context_json(&mgr) + let json = super::build_terminal_context(&mgr) .await - .expect("a non-agent active pane must yield context json"); + .expect("a non-agent active pane must yield context json") + .json; let v: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(v["activeTarget"], "pane-9"); assert_eq!(v["window_title"], "My Tab"); @@ -4296,7 +4526,7 @@ mod tests { async fn build_prompt_text_planner_includes_template_and_user_request() { let mgr = crate::shell::ShellManager::new(); let expected = super::prompt::load_planner_prompt_template(); - let (prompt, _source, display_name, fix_pane) = + let (prompt, _source, display_name, fix_pane, _target) = super::build_prompt_text(1, 0.0, "list files", false, true, &mgr, false, None).await; assert_eq!(display_name, expected.display_name); assert!( @@ -4317,7 +4547,7 @@ mod tests { let mgr = crate::shell::ShellManager::new(); let planner = super::prompt::load_planner_prompt_template(); let autofix = super::prompt::load_autofix_prompt_template(); - let (prompt, _s, display_name, fix_pane) = + let (prompt, _s, display_name, fix_pane, _target) = super::build_prompt_text(2, 0.0, "fix the build", true, true, &mgr, false, None).await; assert_eq!(display_name, autofix.display_name); assert_ne!( @@ -4340,7 +4570,7 @@ mod tests { #[tokio::test] async fn build_prompt_text_autofix_blank_hint_has_no_user_request() { let mgr = crate::shell::ShellManager::new(); - let (prompt, _s, _d, _f) = + let (prompt, _s, _d, _f, _target) = super::build_prompt_text(3, 0.0, " ", true, true, &mgr, false, None).await; assert!( !prompt.contains("## User Request"), @@ -4359,7 +4589,7 @@ mod tests { !planner.content.trim().is_empty(), "test precondition: planner template body is non-empty" ); - let (prompt, _s, _d, _f) = + let (prompt, _s, _d, _f, _target) = super::build_prompt_text(4, 0.0, "hi", false, false, &mgr, false, None).await; assert!( !prompt.contains(planner.content.trim()), @@ -4380,7 +4610,7 @@ mod tests { "pid": std::process::id(), "is_agent_pane": false, })); - let (prompt, _s, _d, fix_pane) = + let (prompt, _s, _d, fix_pane, _target) = super::build_prompt_text(5, 0.0, "", true, true, &mgr, true, None).await; assert_eq!( fix_pane.as_deref(), @@ -4407,7 +4637,7 @@ mod tests { source_pane_id: Some("explicit-src".to_string()), ..Default::default() }; - let (_p, _s, _d, fix_pane) = + let (_p, _s, _d, fix_pane, _target) = super::build_prompt_text(6, 0.0, "", true, true, &mgr, true, Some(&ctx)).await; assert!( fix_pane.is_none(), @@ -4447,7 +4677,7 @@ mod tests { source_pane_id: Some("src-pane".to_string()), ..Default::default() }; - let (prompt, _s, _d, _f) = + let (prompt, _s, _d, _f, _target) = super::build_prompt_text(7, 0.0, "", true, true, &mgr, true, Some(&ctx)).await; assert!(prompt.contains("### Shell Context"), "got: {prompt}"); // The shell-context JSON must carry the SOURCE pane's shell + cwd… @@ -4519,14 +4749,20 @@ mod tests { let content = super::build_prompt_content("", &images); assert_eq!(content.len(), 2); assert!(matches!(content[0], acp::schema::v1::ContentBlock::Text(_))); - assert!(matches!(content[1], acp::schema::v1::ContentBlock::Image(_))); + assert!(matches!( + content[1], + acp::schema::v1::ContentBlock::Image(_) + )); } #[test] fn truncate_for_prompt_appends_marker_only_when_over_budget() { assert_eq!(super::truncate_for_prompt("hello", 10), "hello"); assert_eq!(super::truncate_for_prompt("hello", 5), "hello"); - assert_eq!(super::truncate_for_prompt("hello", 3), "hel\n..."); + assert_eq!( + super::truncate_for_prompt("hello", 3), + "hel\n..." + ); } #[test] @@ -4556,6 +4792,178 @@ mod tests { assert_eq!(super::session_short("abc"), "abc"); } + fn proposal_permission_request(command: &str) -> acp::schema::v1::RequestPermissionRequest { + use acp::schema::v1::{ + PermissionOption, PermissionOptionKind, RequestPermissionRequest, ToolCallId, + ToolCallUpdate, ToolCallUpdateFields, ToolKind, + }; + + RequestPermissionRequest::new( + acp::schema::v1::SessionId::new("proposal-session"), + ToolCallUpdate::new( + ToolCallId::new("proposal-tool"), + ToolCallUpdateFields::new() + .kind(ToolKind::Execute) + .raw_input(serde_json::json!({ + "command": command, + "commands": [command], + })), + ), + vec![PermissionOption::new( + "allow-once", + "Allow once", + PermissionOptionKind::AllowOnce, + )], + ) + } + + #[tokio::test] + async fn canonical_proposal_permission_is_silent_and_arms_payload() { + let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + let payload = r#"{"schema_version":1,"origin":"terminal_agent","choices":[{"choice":1,"title":"run test","rationale":"","actions":[{"type":"send","input":"cargo test"}]}]}"#; + let channel = manager + .issue("proposal-session".into(), 1, None, false) + .unwrap(); + let command = crate::proposal_invocation::render(&channel, payload).unwrap(); + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let client = WtaClient { + state: Arc::new(ClientState { + event_tx, + shell_mgr: Arc::new(ShellManager::new()), + prompt_timing: Arc::new(PromptTimingState::default()), + proposal_channels: Arc::clone(&manager), + direct_proposals_enabled: true, + }), + }; + + let response = client + .request_permission(proposal_permission_request(&command)) + .await + .unwrap(); + assert!(matches!( + response.outcome, + acp::schema::v1::RequestPermissionOutcome::Selected(_) + )); + assert!( + event_rx.try_recv().is_err(), + "canonical proposal permission must not reach the TUI" + ); + assert!(manager + .begin_validation(&channel, payload.as_bytes()) + .is_ok()); + } + + #[tokio::test] + async fn noncanonical_proposal_permission_is_silently_cancelled() { + let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + let channel = manager + .issue("proposal-session".into(), 1, None, false) + .unwrap(); + let command = format!( + "'{{}}' | & \"$env:WTA_CLI_PATH\" propose-terminal-actions --channel {channel}" + ); + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let client = WtaClient { + state: Arc::new(ClientState { + event_tx, + shell_mgr: Arc::new(ShellManager::new()), + prompt_timing: Arc::new(PromptTimingState::default()), + proposal_channels: manager, + direct_proposals_enabled: true, + }), + }; + + let response = client + .request_permission(proposal_permission_request(&command)) + .await + .unwrap(); + assert!(matches!( + response.outcome, + acp::schema::v1::RequestPermissionOutcome::Cancelled + )); + assert!(event_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn proposal_without_allow_once_is_cancelled_without_arming() { + let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + let payload = r#"{"schema_version":1,"origin":"terminal_agent","choices":[{"choice":1,"title":"run test","rationale":"","actions":[{"type":"send","input":"cargo test"}]}]}"#; + let channel = manager + .issue("proposal-session".into(), 1, None, false) + .unwrap(); + let command = crate::proposal_invocation::render(&channel, payload).unwrap(); + let mut request = proposal_permission_request(&command); + request.options.clear(); + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let client = WtaClient { + state: Arc::new(ClientState { + event_tx, + shell_mgr: Arc::new(ShellManager::new()), + prompt_timing: Arc::new(PromptTimingState::default()), + proposal_channels: Arc::clone(&manager), + direct_proposals_enabled: true, + }), + }; + + let response = client.request_permission(request).await.unwrap(); + assert!(matches!( + response.outcome, + acp::schema::v1::RequestPermissionOutcome::Cancelled + )); + assert_eq!( + manager + .begin_validation(&channel, payload.as_bytes()) + .unwrap_err() + .status, + crate::proposal_channel::ProposalValidationStatus::NotArmed + ); + assert!(event_rx.try_recv().is_err()); + } + + #[tokio::test] + async fn proposal_with_noncanonical_permission_wrapper_is_silently_cancelled() { + let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + let payload = r#"{"schema_version":1,"origin":"terminal_agent","choices":[{"choice":1,"title":"run test","rationale":"","actions":[{"type":"send","input":"cargo test"}]}]}"#; + let channel = manager + .issue("proposal-session".into(), 1, None, false) + .unwrap(); + let command = crate::proposal_invocation::render(&channel, payload).unwrap(); + let mut request = proposal_permission_request(&command); + request + .tool_call + .fields + .raw_input + .as_mut() + .unwrap() + .as_object_mut() + .unwrap() + .insert("extra".to_string(), serde_json::json!(true)); + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let client = WtaClient { + state: Arc::new(ClientState { + event_tx, + shell_mgr: Arc::new(ShellManager::new()), + prompt_timing: Arc::new(PromptTimingState::default()), + proposal_channels: Arc::clone(&manager), + direct_proposals_enabled: true, + }), + }; + + let response = client.request_permission(request).await.unwrap(); + assert!(matches!( + response.outcome, + acp::schema::v1::RequestPermissionOutcome::Cancelled + )); + assert_eq!( + manager + .begin_validation(&channel, payload.as_bytes()) + .unwrap_err() + .status, + crate::proposal_channel::ProposalValidationStatus::NotArmed + ); + assert!(event_rx.try_recv().is_err()); + } + // ── json_str_or_num ───────────────────────────────────────────────────── #[test] @@ -4607,6 +5015,8 @@ mod tests { event_tx: tx, shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(super::super::PromptTimingState::default()), + proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), + direct_proposals_enabled: false, }); (WtaClient { state }, rx) } diff --git a/tools/wta/src/protocol/acp/mock_agent_tests.rs b/tools/wta/src/protocol/acp/mock_agent_tests.rs index 75ee56e512..091c04610c 100644 --- a/tools/wta/src/protocol/acp/mock_agent_tests.rs +++ b/tools/wta/src/protocol/acp/mock_agent_tests.rs @@ -302,6 +302,8 @@ fn connect_with( event_tx, shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(PromptTimingState::default()), + proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), + direct_proposals_enabled: false, }); let wta = WtaClient { state }; @@ -554,6 +556,8 @@ fn connect_for_dispatch(behavior: MockBehavior) -> DispatchHarness { event_tx: event_tx.clone(), shell_mgr: shell_mgr.clone(), prompt_timing: prompt_timing.clone(), + proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), + direct_proposals_enabled: false, }); let wta = WtaClient { state }; @@ -1549,6 +1553,8 @@ fn bare_client() -> (WtaClient, mpsc::UnboundedReceiver) { event_tx, shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(PromptTimingState::default()), + proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), + direct_proposals_enabled: false, }); (WtaClient { state }, event_rx) } @@ -1846,4 +1852,3 @@ async fn request_permission_cancelled_when_responder_dropped() { } - diff --git a/tools/wta/src/protocol/acp/prompt_context.rs b/tools/wta/src/protocol/acp/prompt_context.rs index ec08c7d1b5..9267de85f1 100644 --- a/tools/wta/src/protocol/acp/prompt_context.rs +++ b/tools/wta/src/protocol/acp/prompt_context.rs @@ -21,9 +21,8 @@ use async_trait::async_trait; -use super::client::{build_terminal_context_json, user_locale_tag}; +use super::client::user_locale_tag; use crate::coordinator::default_supported_delegate_agents; -use crate::shell::ShellManager; /// Read-only inputs a [`ContextProvider`] may consult when deciding whether it /// applies and what section to emit. @@ -38,9 +37,10 @@ pub(crate) struct ContextRequest<'a> { pub is_autofix: bool, /// Whether the WT protocol channel is live (pane queries are meaningful). pub wt_connected: bool, - /// Shell manager for providers that query WT directly (planner terminal - /// context). - pub shell_mgr: &'a ShellManager, + /// Planner only: terminal context JSON resolved once by the prompt + /// assembler. Keeping it here ensures the Helper-local channel target is + /// exactly the one shown to the agent in the prompt. + pub terminal_context_json: Option<&'a str>, /// Autofix only: the JSON of the pane whose shell/cwd describe the failing /// command (the source pane — for error-triggered autofix this can be a /// pane in a non-focused tab, not the active pane). `None` when WT is not @@ -145,7 +145,7 @@ impl ContextProvider for TerminalContextProvider { } async fn provide(&self, req: &ContextRequest<'_>) -> Option { - let json = build_terminal_context_json(req.shell_mgr).await?; + let json = req.terminal_context_json?; Some(ContextSection { heading: "Terminal Context JSON", body: format!("```json\n{}\n```", json), @@ -267,11 +267,11 @@ mod tests { use super::*; use crate::shell::ShellManager; - fn req_planner(mgr: &ShellManager, wt_connected: bool) -> ContextRequest<'_> { + fn req_planner(_mgr: &ShellManager, wt_connected: bool) -> ContextRequest<'_> { ContextRequest { is_autofix: false, wt_connected, - shell_mgr: mgr, + terminal_context_json: None, context_pane: None, shell_exe: None, terminal_output: None, diff --git a/tools/wta/src/protocol/acp/spawn.rs b/tools/wta/src/protocol/acp/spawn.rs index 908414f60e..1eee0df40e 100644 --- a/tools/wta/src/protocol/acp/spawn.rs +++ b/tools/wta/src/protocol/acp/spawn.rs @@ -14,7 +14,7 @@ use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use tokio::io::{AsyncBufReadExt, BufReader}; const STARTUP_STDERR_MAX_LINES: usize = 32; @@ -184,9 +184,7 @@ impl AgentSpawn { /// Human-readable agent label for error messages. Prefers the npx /// adapter package id when present. pub fn label(&self) -> &str { - self.adapter_package - .as_deref() - .unwrap_or(&self.raw_program) + self.adapter_package.as_deref().unwrap_or(&self.raw_program) } } @@ -197,7 +195,10 @@ impl AgentSpawn { /// when its shell wrapper doesn't explicitly set one — starts in the user's /// project. None preserves the parent's cwd (probe path, where it doesn't /// matter). -pub(crate) fn spawn_agent_process(agent_cmd: &str, cwd: Option<&Path>) -> Result { +pub(crate) fn spawn_agent_process( + agent_cmd: &str, + cwd: Option<&Path>, +) -> Result { let parts: Vec<&str> = agent_cmd.split_whitespace().collect(); let raw_program = parts .first() @@ -218,7 +219,11 @@ pub(crate) fn spawn_agent_process(agent_cmd: &str, cwd: Option<&Path>) -> Result None }; - let program = if needs_cmd { "cmd" } else { resolved_program.as_str() }; + let program = if needs_cmd { + "cmd" + } else { + resolved_program.as_str() + }; let mut cmd = tokio::process::Command::new(program); if needs_cmd { cmd.arg("/c").arg(&resolved_program); @@ -254,6 +259,11 @@ pub(crate) fn spawn_agent_process(agent_cmd: &str, cwd: Option<&Path>) -> Result // `hook-trace.log` lands alongside this build's Rust + C++ logs. cmd.env("WTA_HOOK_LOG_DIR", crate::logging::log_dir()); + // Proposal commands must execute this exact trusted binary path. + let wta_cli_path = + std::env::current_exe().context("failed to resolve the running wta executable")?; + cmd.env("WTA_CLI_PATH", wta_cli_path); + // Forward the user's locale to the agent process via standard POSIX // environment variables. Many agent CLIs (and the large language models // they speak to) honor `LANG` / `LC_ALL` to choose their response @@ -401,17 +411,11 @@ mod tests { let line = "a".repeat(STARTUP_STDERR_MAX_CHARS_PER_LINE + 1); let truncated = truncate_stderr_line(&line); - assert_eq!( - truncated.chars().count(), - STARTUP_STDERR_MAX_CHARS_PER_LINE - ); + assert_eq!(truncated.chars().count(), STARTUP_STDERR_MAX_CHARS_PER_LINE); assert!(truncated.ends_with("...")); assert_eq!( truncated, - format!( - "{}...", - "a".repeat(STARTUP_STDERR_MAX_CHARS_PER_LINE - 3) - ) + format!("{}...", "a".repeat(STARTUP_STDERR_MAX_CHARS_PER_LINE - 3)) ); } @@ -420,16 +424,10 @@ mod tests { let line = "界".repeat(STARTUP_STDERR_MAX_CHARS_PER_LINE + 1); let truncated = truncate_stderr_line(&line); - assert_eq!( - truncated.chars().count(), - STARTUP_STDERR_MAX_CHARS_PER_LINE - ); + assert_eq!(truncated.chars().count(), STARTUP_STDERR_MAX_CHARS_PER_LINE); assert_eq!( truncated, - format!( - "{}...", - "界".repeat(STARTUP_STDERR_MAX_CHARS_PER_LINE - 3) - ) + format!("{}...", "界".repeat(STARTUP_STDERR_MAX_CHARS_PER_LINE - 3)) ); } diff --git a/tools/wta/src/terminal_action_proposal.rs b/tools/wta/src/terminal_action_proposal.rs new file mode 100644 index 0000000000..167b54eab0 --- /dev/null +++ b/tools/wta/src/terminal_action_proposal.rs @@ -0,0 +1,739 @@ +//! Wire schema for the direct WTA CLI terminal-action proposal flow +//! (`wta propose-terminal-actions`). See +//! `doc/specs/WTA-CLI-terminal-action-proposals.md`. +//! +//! An agent session that can execute tools directly (rather than relying on +//! the ACP `create_terminal`/helper-proxy path) submits a proposal as one +//! compact JSON object matching [`ProposalWire`]. This module owns: +//! +//! * the strict (`deny_unknown_fields`) wire types — deliberately narrower +//! than [`crate::coordinator::RecommendationSet`]: they never accept a +//! session/helper/window/tab/pane id, and Open/OpenAndSend carry a +//! `delegate: bool` flag instead of a free-text `agent` id, so a proposal +//! can ask for "the user's configured delegate" but never name an +//! arbitrary agent; +//! * origin-aware policy (`ProposalOrigin::TerminalAgent` vs `::Autofix`); +//! * size/count bounds enforced *before* `serde_json` ever sees the bytes; +//! * conversion into [`crate::coordinator::RecommendationSet`], which then +//! flows through the exact same card-surfacing / execution code as the +//! long-standing assistant-text JSON fallback. +//! +//! The proposal travels over the owning Helper's direct proposal pipe; Master +//! is not involved. The Helper invokes this module from App's direct proposal +//! validation path and is solely responsible for decoding and policy checks. + +use serde::{Deserialize, Serialize}; + +use crate::coordinator::{ + validate_recommendation_set, OpenTarget, RecommendationChoice, RecommendationSet, + RecommendedAction, +}; + +/// The only wire schema version this build understands. Bumped only on a +/// breaking change to [`ProposalWire`]; an older/newer CLI talking to this +/// helper gets [`ProposalError::UnsupportedSchemaVersion`]. +pub const SCHEMA_VERSION: u32 = 1; + +/// Hard cap on the raw JSON payload size, enforced by the CLI (before +/// sending) and again here (before `serde_json` parses it) — a proposal is +/// a handful of short strings, never a multi-megabyte blob. Keeps a +/// misbehaving/compromised agent from pushing an oversized payload through +/// the named pipe or holding the bounded pending-proposal map open with a +/// slow parse. +pub const MAX_PAYLOAD_BYTES: usize = 8 * 1024; + +/// Max choices per proposal — matches the long-standing fallback-JSON +/// policy in [`crate::coordinator::validate_recommendation_set`] (1..=3). +pub const MAX_CHOICES: usize = 3; +/// Max actions per choice. +pub const MAX_ACTIONS_PER_CHOICE: usize = 3; +/// Character caps on free-text fields. Generous enough for a real +/// recommendation, small enough that a runaway proposal can't bloat chat +/// history or the pending-proposal map. +pub const MAX_TITLE_CHARS: usize = 200; +pub const MAX_RATIONALE_CHARS: usize = 2000; +pub const MAX_INPUT_CHARS: usize = 8000; + +/// Disposition returned to the CLI (and, before that, decided by the +/// owning helper). All five are "protocol-complete" outcomes: the CLI +/// exits 0 and prints this as compact JSON for every one of them. A +/// non-zero CLI exit is reserved for transport/IO failures that never +/// reached this far (can't read stdin/payload file, can't reach master at +/// all). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalStatus { + /// The recommendation card is now visible in the agent pane. + Presented, + /// A card was already showing for this turn (eager text-fallback + /// surface, or an earlier proposal) — this proposal was not the one + /// that ended up on screen. + Duplicate, + /// The route/turn was valid when minted but is no longer current by + /// the time the proposal arrived (token expired/consumed already, or + /// the turn moved on before the helper could act). + Stale, + /// The route was fresh and reached the owning helper, but the payload + /// failed origin/schema/coordinator-target policy. + Rejected, + /// The owning helper/session could not be reached at all (disconnected, + /// shut down, or the response timed out). + Unavailable, +} + +impl ProposalStatus { + pub fn as_str(self) -> &'static str { + match self { + ProposalStatus::Presented => "presented", + ProposalStatus::Duplicate => "duplicate", + ProposalStatus::Stale => "stale", + ProposalStatus::Rejected => "rejected", + ProposalStatus::Unavailable => "unavailable", + } + } +} + +/// Why a proposal failed before ever reaching the "did the helper accept +/// it" decision. Distinct from [`ProposalStatus`]: this is the *local* +/// (CLI or master, pre-relay) or *decode* failure classification; +/// `to_status` collapses it onto the wire disposition so callers don't +/// need two vocabularies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProposalError { + /// Raw payload exceeded [`MAX_PAYLOAD_BYTES`] — rejected before parsing. + TooLarge { size: usize }, + /// `serde_json` (or the strict wire schema's `deny_unknown_fields`) + /// rejected the payload outright. + Malformed(String), + /// `schema_version` in the payload doesn't match [`SCHEMA_VERSION`]. + UnsupportedSchemaVersion(u32), + /// Decoded fine, but violates origin/shape/count/length policy (wrong + /// action for the declared origin, too many choices, empty title, + /// oversized field, etc.) or the coordinator-target filter rejected + /// every choice. + PolicyViolation(String), +} + +impl ProposalError { + /// Collapse onto the wire disposition. Every variant here maps to + /// `Rejected` except the size cap, which is its own thing conceptually + /// but still a policy rejection from the caller's point of view — no + /// separate status exists for it in the five-way table the spec + /// defines, so it also reports `Rejected` with a specific reason. + pub fn to_status(&self) -> ProposalStatus { + ProposalStatus::Rejected + } + + pub fn reason(&self) -> String { + match self { + ProposalError::TooLarge { size } => { + format!("payload too large ({size} bytes, max {MAX_PAYLOAD_BYTES})") + } + ProposalError::Malformed(msg) => format!("malformed payload: {msg}"), + ProposalError::UnsupportedSchemaVersion(v) => { + format!("unsupported schema_version {v} (expected {SCHEMA_VERSION})") + } + ProposalError::PolicyViolation(msg) => msg.clone(), + } + } +} + +/// Which system prompt asked for this proposal. Validated against the +/// owning helper's OWN authoritative `TurnState::is_autofix()` — a +/// mismatch (e.g. `autofix` origin claimed on a plain chat turn) is a +/// policy violation, never trusted from the payload alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalOrigin { + TerminalAgent, + Autofix, +} + +/// Top-level proposal payload. `deny_unknown_fields` so a future field a +/// model hallucinates (or an attempt to sneak in e.g. `session_id`) is a +/// hard parse failure, not silently ignored. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProposalWire { + pub schema_version: u32, + pub origin: ProposalOrigin, + #[serde(default)] + pub recommended_choice: Option, + pub choices: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProposalChoiceWire { + pub choice: usize, + pub title: String, + #[serde(default)] + pub rationale: String, + pub actions: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProposalOpenTargetWire { + Tab, + Panel, +} + +impl From for OpenTarget { + fn from(value: ProposalOpenTargetWire) -> Self { + match value { + ProposalOpenTargetWire::Tab => OpenTarget::Tab, + ProposalOpenTargetWire::Panel => OpenTarget::Panel, + } + } +} + +/// Action wire shape. Deliberately has no session/helper/window/tab/pane id +/// field. The helper captures the active working pane for the prompt and +/// supplies it separately as trusted metadata; model-authored JSON cannot +/// redirect a send or panel action to another pane. Autofix continues to bind +/// its failing pane at card-execution time. +/// +/// `agent: Option` from [`RecommendedAction`] is intentionally +/// *not* exposed on `OpenAndSend` here — `delegate: bool` replaces it so a +/// proposal can ask for "the user's configured delegate" but can never +/// name an arbitrary agent id. `Open` never carries an agent selector at +/// all (mirrors [`RecommendedAction::Open`], which has no `agent` field — +/// a bare `Open` just opens a plain shell target, no agent involved). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ProposalActionWire { + Send { + input: String, + }, + Open { + target: ProposalOpenTargetWire, + #[serde(default)] + cwd: Option, + #[serde(default)] + title: Option, + #[serde(default)] + direction: Option, + #[serde(default)] + profile: Option, + }, + OpenAndSend { + target: ProposalOpenTargetWire, + input: String, + #[serde(default)] + delegate: bool, + #[serde(default)] + cwd: Option, + #[serde(default)] + title: Option, + #[serde(default)] + direction: Option, + #[serde(default)] + profile: Option, + }, +} + +/// Decode raw bytes into a [`ProposalWire`], enforcing the size cap before +/// `serde_json` ever touches the buffer. Used by both the CLI (a cheap +/// local pre-check so an oversized payload never reaches the pipe) and the +/// owning helper (the authoritative decode). +pub fn parse_proposal_payload(bytes: &[u8]) -> Result { + if bytes.len() > MAX_PAYLOAD_BYTES { + return Err(ProposalError::TooLarge { size: bytes.len() }); + } + let wire: ProposalWire = + serde_json::from_slice(bytes).map_err(|e| ProposalError::Malformed(e.to_string()))?; + if wire.schema_version != SCHEMA_VERSION { + return Err(ProposalError::UnsupportedSchemaVersion(wire.schema_version)); + } + Ok(wire) +} + +/// Convert a decoded [`ProposalWire`] into a [`RecommendationSet`], applying +/// origin policy and the shared count/length/coordinator-target validation +/// that the assistant-text fallback path already enforces. +/// +/// * `is_autofix_turn` — the owning turn's OWN `TurnState::is_autofix()` +/// (never taken from the payload). A mismatch against `wire.origin` is a +/// [`ProposalError::PolicyViolation`]. +/// * `configured_delegate_id` — the helper's currently configured delegate +/// agent id (`App.delegate_agents`), substituted for `delegate: true` +/// actions. `None` means no delegate is configured — an action with +/// `delegate: true` is then a policy violation rather than silently +/// falling back to "no agent" (which would defeat the point of asking +/// for the delegate). +/// * `coordinator_target` — this pane's own id, filtered out of `Send` +/// targets exactly as [`crate::coordinator::validate_recommendation_set_for_coordinator_target`] +/// already does for the text-fallback path. +pub fn build_recommendation_set( + wire: &ProposalWire, + is_autofix_turn: bool, + configured_delegate_id: Option<&str>, + trusted_active_target: Option<&str>, + coordinator_target: Option<&str>, +) -> Result { + let origin_is_autofix = matches!(wire.origin, ProposalOrigin::Autofix); + if origin_is_autofix != is_autofix_turn { + return Err(ProposalError::PolicyViolation(format!( + "origin {:?} does not match the current turn (is_autofix={})", + wire.origin, is_autofix_turn + ))); + } + + if wire.choices.is_empty() || wire.choices.len() > MAX_CHOICES { + return Err(ProposalError::PolicyViolation(format!( + "expected 1 to {MAX_CHOICES} choices, got {}", + wire.choices.len() + ))); + } + + if origin_is_autofix { + // Autofix MVP policy: exactly one choice, exactly one Send action. + // No Open/OpenAndSend — autofix never spawns a new pane. `parent` + // is stripped/ignored unconditionally; the real failing pane is + // bound by the caller (App::turn_execute_card's existing autofill), + // exactly like today's manual `/fix` flow. + if wire.choices.len() != 1 { + return Err(ProposalError::PolicyViolation(format!( + "autofix proposals must have exactly one choice, got {}", + wire.choices.len() + ))); + } + let choice = &wire.choices[0]; + if choice.actions.len() != 1 { + return Err(ProposalError::PolicyViolation(format!( + "autofix proposals must have exactly one action, got {}", + choice.actions.len() + ))); + } + let ProposalActionWire::Send { input, .. } = &choice.actions[0] else { + return Err(ProposalError::PolicyViolation( + "autofix proposals must use a single send action".to_string(), + )); + }; + check_len("title", &choice.title, MAX_TITLE_CHARS)?; + check_len("rationale", &choice.rationale, MAX_RATIONALE_CHARS)?; + check_len("input", input, MAX_INPUT_CHARS)?; + let set = RecommendationSet { + recommended_choice: Some(choice.choice), + choices: vec![RecommendationChoice { + choice: choice.choice, + title: choice.title.clone(), + rationale: choice.rationale.clone(), + actions: vec![RecommendedAction::Send { + parent: String::new(), + input: input.clone(), + }], + }], + }; + validate_recommendation_set(&set) + .map_err(|e| ProposalError::PolicyViolation(e.to_string()))?; + return Ok(set); + } + + // Terminal Agent origin: "current policies" — same 1..=3 choices / + // 1..=3 actions / Send+Open+OpenAndSend shape as the text-fallback + // path, converted 1:1 apart from `delegate` resolution. + let mut choices = Vec::with_capacity(wire.choices.len()); + for choice in &wire.choices { + if choice.actions.is_empty() || choice.actions.len() > MAX_ACTIONS_PER_CHOICE { + return Err(ProposalError::PolicyViolation(format!( + "choice {} must have 1 to {MAX_ACTIONS_PER_CHOICE} actions, got {}", + choice.choice, + choice.actions.len() + ))); + } + check_len("title", &choice.title, MAX_TITLE_CHARS)?; + check_len("rationale", &choice.rationale, MAX_RATIONALE_CHARS)?; + let mut actions = Vec::with_capacity(choice.actions.len()); + for action in &choice.actions { + actions.push(convert_terminal_agent_action( + action, + configured_delegate_id, + trusted_active_target, + )?); + } + choices.push(RecommendationChoice { + choice: choice.choice, + title: choice.title.clone(), + rationale: choice.rationale.clone(), + actions, + }); + } + let set = RecommendationSet { + recommended_choice: wire.recommended_choice, + choices, + }; + validate_recommendation_set(&set).map_err(|e| ProposalError::PolicyViolation(e.to_string()))?; + let set = crate::coordinator::validate_recommendation_set_for_coordinator_target( + &set, + coordinator_target, + ) + .map_err(|e| ProposalError::PolicyViolation(e.to_string()))?; + Ok(set) +} + +fn convert_terminal_agent_action( + action: &ProposalActionWire, + configured_delegate_id: Option<&str>, + trusted_active_target: Option<&str>, +) -> Result { + match action { + ProposalActionWire::Send { input } => { + check_len("input", input, MAX_INPUT_CHARS)?; + Ok(RecommendedAction::Send { + parent: require_active_target(trusted_active_target)?, + input: input.clone(), + }) + } + ProposalActionWire::Open { + target, + cwd, + title, + direction, + profile, + } => Ok(RecommendedAction::Open { + target: (*target).into(), + parent: panel_parent(*target, trusted_active_target)?, + cwd: cwd.clone(), + title: title.clone(), + direction: direction.clone(), + profile: profile.clone(), + }), + ProposalActionWire::OpenAndSend { + target, + input, + delegate, + cwd, + title, + direction, + profile, + } => { + check_len("input", input, MAX_INPUT_CHARS)?; + Ok(RecommendedAction::OpenAndSend { + target: (*target).into(), + parent: panel_parent(*target, trusted_active_target)?, + input: input.clone(), + cwd: cwd.clone(), + title: title.clone(), + direction: direction.clone(), + profile: profile.clone(), + agent: resolve_delegate(*delegate, configured_delegate_id)?, + }) + } + } +} + +fn require_active_target(active_target: Option<&str>) -> Result { + active_target + .filter(|target| !target.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| { + ProposalError::PolicyViolation( + "the prompt has no active pane for this action".to_string(), + ) + }) +} + +fn panel_parent( + target: ProposalOpenTargetWire, + active_target: Option<&str>, +) -> Result, ProposalError> { + match target { + ProposalOpenTargetWire::Tab => Ok(None), + ProposalOpenTargetWire::Panel => require_active_target(active_target).map(Some), + } +} + +/// `delegate: false` -> no agent override (the opened pane gets the +/// default agent). `delegate: true` -> the helper's own configured +/// delegate id — never a string taken from the payload. `delegate: true` +/// with no configured delegate is a policy violation: silently falling +/// back to "no agent" would make the flag a no-op the caller can't detect. +fn resolve_delegate( + delegate: bool, + configured_delegate_id: Option<&str>, +) -> Result, ProposalError> { + if !delegate { + return Ok(None); + } + configured_delegate_id + .map(|id| Some(id.to_string())) + .ok_or_else(|| { + ProposalError::PolicyViolation( + "delegate: true requested but no delegate agent is configured".to_string(), + ) + }) +} + +fn check_len(field: &str, value: &str, max_chars: usize) -> Result<(), ProposalError> { + if value.chars().count() > max_chars { + return Err(ProposalError::PolicyViolation(format!( + "{field} exceeds {max_chars} characters" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn terminal_agent_wire() -> ProposalWire { + ProposalWire { + schema_version: SCHEMA_VERSION, + origin: ProposalOrigin::TerminalAgent, + recommended_choice: Some(1), + choices: vec![ProposalChoiceWire { + choice: 1, + title: "Run tests".to_string(), + rationale: "verify the fix".to_string(), + actions: vec![ProposalActionWire::Send { + input: "cargo test".to_string(), + }], + }], + } + } + + fn autofix_wire() -> ProposalWire { + ProposalWire { + schema_version: SCHEMA_VERSION, + origin: ProposalOrigin::Autofix, + recommended_choice: Some(1), + choices: vec![ProposalChoiceWire { + choice: 1, + title: "Fix typo".to_string(), + rationale: String::new(), + actions: vec![ProposalActionWire::Send { + input: "git status".to_string(), + }], + }], + } + } + + #[test] + fn round_trips_through_json() { + let wire = terminal_agent_wire(); + let json = serde_json::to_string(&wire).unwrap(); + let parsed = parse_proposal_payload(json.as_bytes()).unwrap(); + assert_eq!(parsed.schema_version, SCHEMA_VERSION); + assert_eq!(parsed.choices.len(), 1); + } + + #[test] + fn rejects_oversized_payload_before_parsing() { + let huge = "x".repeat(MAX_PAYLOAD_BYTES + 1); + let err = parse_proposal_payload(huge.as_bytes()).unwrap_err(); + assert!(matches!(err, ProposalError::TooLarge { .. })); + } + + #[test] + fn rejects_unsupported_schema_version() { + let mut wire = terminal_agent_wire(); + wire.schema_version = 99; + let json = serde_json::to_string(&wire).unwrap(); + let err = parse_proposal_payload(json.as_bytes()).unwrap_err(); + assert!(matches!(err, ProposalError::UnsupportedSchemaVersion(99))); + } + + #[test] + fn rejects_unknown_top_level_field() { + let mut value: serde_json::Value = serde_json::to_value(terminal_agent_wire()).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("session_id".to_string(), serde_json::json!("sneaky")); + let bytes = serde_json::to_vec(&value).unwrap(); + let err = parse_proposal_payload(&bytes).unwrap_err(); + assert!(matches!(err, ProposalError::Malformed(_))); + } + + #[test] + fn rejects_unknown_action_field() { + let json = r#"{ + "schema_version": 1, + "origin": "terminal_agent", + "choices": [{ + "choice": 1, + "title": "x", + "actions": [{"type": "send", "input": "echo hi", "pane_id": "sneaky"}] + }] + }"#; + let err = parse_proposal_payload(json.as_bytes()).unwrap_err(); + assert!(matches!(err, ProposalError::Malformed(_))); + } + + #[test] + fn terminal_agent_converts_cleanly() { + let wire = terminal_agent_wire(); + let set = build_recommendation_set(&wire, false, None, Some("pane-123"), None).unwrap(); + assert_eq!(set.choices.len(), 1); + match &set.choices[0].actions[0] { + RecommendedAction::Send { parent, input } => { + assert_eq!(parent, "pane-123"); + assert_eq!(input, "cargo test"); + } + other => panic!("unexpected action {other:?}"), + } + } + + #[test] + fn terminal_agent_send_requires_trusted_active_target() { + let wire = terminal_agent_wire(); + let err = build_recommendation_set(&wire, false, None, None, None).unwrap_err(); + assert!(matches!(err, ProposalError::PolicyViolation(_))); + } + + #[test] + fn terminal_agent_panel_injects_trusted_parent() { + let mut wire = terminal_agent_wire(); + wire.choices[0].actions = vec![ProposalActionWire::Open { + target: ProposalOpenTargetWire::Panel, + cwd: None, + title: None, + direction: Some("right".to_string()), + profile: None, + }]; + let set = build_recommendation_set(&wire, false, None, Some("pane-123"), None).unwrap(); + match &set.choices[0].actions[0] { + RecommendedAction::Open { parent, .. } => { + assert_eq!(parent.as_deref(), Some("pane-123")); + } + other => panic!("unexpected action {other:?}"), + } + } + + #[test] + fn origin_mismatch_is_rejected() { + let wire = terminal_agent_wire(); + let err = build_recommendation_set(&wire, true, None, None, None).unwrap_err(); + assert!(matches!(err, ProposalError::PolicyViolation(_))); + } + + #[test] + fn autofix_leaves_parent_for_execution_time_binding() { + let wire = autofix_wire(); + let set = build_recommendation_set(&wire, true, None, None, None).unwrap(); + match &set.choices[0].actions[0] { + RecommendedAction::Send { parent, .. } => assert_eq!(parent, ""), + other => panic!("unexpected action {other:?}"), + } + } + + #[test] + fn autofix_rejects_open_action() { + let mut wire = autofix_wire(); + wire.choices[0].actions = vec![ProposalActionWire::Open { + target: ProposalOpenTargetWire::Tab, + cwd: None, + title: None, + direction: None, + profile: None, + }]; + let err = build_recommendation_set(&wire, true, None, None, None).unwrap_err(); + assert!(matches!(err, ProposalError::PolicyViolation(_))); + } + + #[test] + fn autofix_rejects_multiple_choices() { + let mut wire = autofix_wire(); + let mut second = wire.choices[0].clone(); + second.choice = 2; + wire.choices.push(second); + let err = build_recommendation_set(&wire, true, None, None, None).unwrap_err(); + assert!(matches!(err, ProposalError::PolicyViolation(_))); + } + + #[test] + fn delegate_true_resolves_configured_delegate_id() { + let mut wire = terminal_agent_wire(); + wire.choices[0].actions = vec![ProposalActionWire::OpenAndSend { + target: ProposalOpenTargetWire::Tab, + input: "echo hi".to_string(), + delegate: true, + cwd: None, + title: None, + direction: None, + profile: None, + }]; + let set = build_recommendation_set(&wire, false, Some("claude"), None, None).unwrap(); + match &set.choices[0].actions[0] { + RecommendedAction::OpenAndSend { agent, .. } => { + assert_eq!(agent.as_deref(), Some("claude")); + } + other => panic!("unexpected action {other:?}"), + } + } + + #[test] + fn delegate_true_without_configured_delegate_is_rejected() { + let mut wire = terminal_agent_wire(); + wire.choices[0].actions = vec![ProposalActionWire::OpenAndSend { + target: ProposalOpenTargetWire::Tab, + input: "echo hi".to_string(), + delegate: true, + cwd: None, + title: None, + direction: None, + profile: None, + }]; + let err = build_recommendation_set(&wire, false, None, None, None).unwrap_err(); + assert!(matches!(err, ProposalError::PolicyViolation(_))); + } + + #[test] + fn delegate_false_never_sets_an_agent_id() { + let mut wire = terminal_agent_wire(); + wire.choices[0].actions = vec![ProposalActionWire::OpenAndSend { + target: ProposalOpenTargetWire::Tab, + input: "echo hi".to_string(), + delegate: false, + cwd: None, + title: None, + direction: None, + profile: None, + }]; + let set = build_recommendation_set(&wire, false, Some("claude"), None, None).unwrap(); + match &set.choices[0].actions[0] { + RecommendedAction::OpenAndSend { agent, .. } => assert_eq!(agent, &None), + other => panic!("unexpected action {other:?}"), + } + } + + #[test] + fn coordinator_target_filters_self_targeted_choices() { + let wire = terminal_agent_wire(); + let err = build_recommendation_set(&wire, false, None, Some("pane-123"), Some("pane-123")) + .unwrap_err(); + assert!(matches!(err, ProposalError::PolicyViolation(_))); + } + + #[test] + fn title_length_cap_is_enforced() { + let mut wire = terminal_agent_wire(); + wire.choices[0].title = "x".repeat(MAX_TITLE_CHARS + 1); + let err = build_recommendation_set(&wire, false, None, Some("pane-123"), None).unwrap_err(); + assert!(matches!(err, ProposalError::PolicyViolation(_))); + } + + #[test] + fn too_many_choices_is_rejected() { + let mut wire = terminal_agent_wire(); + for i in 2..=(MAX_CHOICES as usize + 1) { + let mut extra = wire.choices[0].clone(); + extra.choice = i; + wire.choices.push(extra); + } + let err = build_recommendation_set(&wire, false, None, Some("pane-123"), None).unwrap_err(); + assert!(matches!(err, ProposalError::PolicyViolation(_))); + } + + #[test] + fn status_as_str_matches_wire_disposition_table() { + assert_eq!(ProposalStatus::Presented.as_str(), "presented"); + assert_eq!(ProposalStatus::Duplicate.as_str(), "duplicate"); + assert_eq!(ProposalStatus::Stale.as_str(), "stale"); + assert_eq!(ProposalStatus::Rejected.as_str(), "rejected"); + assert_eq!(ProposalStatus::Unavailable.as_str(), "unavailable"); + } +} From 21e13cd305d7a9753ef40b731f84788e62ad0ef6 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Tue, 28 Jul 2026 12:03:11 +0800 Subject: [PATCH 04/14] Remove assistant JSON proposal fallback Make the direct helper channel the only card-producing path and hide its internal proposal tool calls from chat while preserving normal tool call rendering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7743e270-ccd3-40be-b382-42639b800d46 --- doc/release-check-list.md | 16 +- .../WTA-CLI-terminal-action-proposals.md | 28 +- test/e2e/README.md | 9 +- .../Feature.AgentProposedCommand.Tests.ps1 | 53 +- test/e2e/tests/Feature.AutofixPane.Tests.ps1 | 116 +-- tools/wta/prompts/auto-fix.md | 57 +- tools/wta/prompts/terminal-agent.md | 188 +--- tools/wta/src/app.rs | 15 +- tools/wta/src/app_events.rs | 19 +- tools/wta/src/app_tests.rs | 157 ++-- tools/wta/src/app_turn.rs | 295 +++---- tools/wta/src/coordinator.rs | 822 +++--------------- tools/wta/src/main.rs | 2 - tools/wta/src/protocol/acp/client.rs | 253 +++--- .../wta/src/protocol/acp/mock_agent_tests.rs | 8 +- tools/wta/src/ui/chat.rs | 239 +---- 16 files changed, 658 insertions(+), 1619 deletions(-) diff --git a/doc/release-check-list.md b/doc/release-check-list.md index 7d072bc247..f3baf15cfb 100644 --- a/doc/release-check-list.md +++ b/doc/release-check-list.md @@ -154,11 +154,11 @@ Net effect: UT shrinks the manual matrix to "did the wiring and UI connect", not - [ ] `C218` `[new]` `[UT✓]` `[E2E]` **Image paste (Alt+V) works:** A copied screenshot (`CF_DIB`/`CF_DIBV5`) or image file is queued and sent to the agent as an ACP image content block on the next prompt; the action is gated on the agent advertising image support. When the agent does not support images, or the clipboard has no image, it does not paste but surfaces a clear system message (e.g. "image not supported" / "clipboard empty") rather than silently ignoring the keypress. _(UT: `clipboard_image` + `mock_agent_tests` `seen_images` side-channel; #354.)_ - [ ] `C066` `[E2E]` **Keyboard navigation works:** Arrow keys, Tab completion, Ctrl combinations, and Esc behave correctly. - [x] `C067` `[UT✓]` `[E2E]` `[MANUAL]` **IME/non-ASCII input works:** IME and non-ASCII input are usable if the release supports localized typing. _(UT: `render_agent_input_accepts_non_ascii` types accented-Latin/Greek/CJK via the real key handler and asserts the input buffer holds them verbatim (multi-byte caret advance) + they render. E2E send path (wtcli send-keys) cannot carry non-ASCII, so the product side is UT-covered; IME composition stays MANUAL.)_ -- [ ] `C068` `[UT✓]` `[E2E]` **Streaming output renders correctly:** Agent response chunks, tool calls, plans, and status lines render without corruption. _(UT: `streaming_two_chunks_coalesce_in_app_chat`, `tool_call_surfaces_card_in_chat`, `tool_call_completion_updates_card_status` (in-place, no dup), `plan_surfaces_card_in_chat`, `render_chat_all_message_variants`; streaming-JSON unwrap incl. emoji/surrogate pairs in `ui::chat::tests`.)_ +- [ ] `C068` `[UT✓]` `[E2E]` **Streaming output renders correctly:** Agent response chunks, tool calls, plans, status lines, and literal JSON render without corruption. _(UT: `streaming_two_chunks_coalesce_in_app_chat`, `tool_call_surfaces_card_in_chat`, `tool_call_completion_updates_card_status` (in-place, no dup), `plan_surfaces_card_in_chat`, `render_chat_all_message_variants`; Assistant JSON remains ordinary chat text.)_ - [x] `C069` `[UT✓]` `[E2E]` **Permission UI works:** When the agent requests a command/tool permission, the user can allow or reject it. _(UT: `permission_allow_round_trips_to_agent`, `permission_reject_round_trips_to_agent`, `permission_quick_allow/reject_key_round_trips_to_agent`, `render_permission_card_shows_options`, `render_permission_compact_shows_hint`; the `y`/`n` quick-key case-match bug was fixed here.)_ -- [ ] `C070` `[E2E]` **Insert into pane works:** Agent-proposed command/text can be inserted into the target terminal pane without running. -- [ ] `C071` `[E2E]` **Run in pane works:** Agent-proposed command can be run in the target terminal pane. -- [ ] `C072` `[E2E]` **Command target is correct:** Insert/run applies to the intended active pane, not the agent pane itself or another tab. +- [ ] `C070` `[E2E]` **Insert into pane works:** A validated Direct Helper Proposal can be inserted into the target terminal pane without running. +- [ ] `C071` `[E2E]` **Run in pane works:** A validated Direct Helper Proposal can be run in the target terminal pane. +- [ ] `C072` `[E2E]` **Command target is correct:** The Helper-injected trusted target routes Insert/Run to the intended active pane, not the agent pane itself or another tab. ### Agent pane slash commands @@ -186,7 +186,7 @@ Net effect: UT shrinks the manual matrix to "did the wiring and UI connect", not ## 3. Autofix flow -**Feature definition:** Autofix detects terminal command failures, captures relevant pane context, asks the configured agent for a fix, and lets the user insert or run the suggested command. +**Feature definition:** Autofix detects terminal command failures, captures relevant pane context, asks the configured agent for a fix, and accepts actionable fixes only through a Direct Helper Proposal before offering Insert or Run. ### Shell integration and detection @@ -215,8 +215,8 @@ Net effect: UT shrinks the manual matrix to "did the wiring and UI connect", not - [ ] `C100` `[E2E]` **Run suggestion works:** Suggested fix can be run in the source pane. - [ ] `C101` `[UT✓]` `[E2E]` **Reject/dismiss works:** User can dismiss an autofix suggestion without side effects. _(UT: `trigger_echo_pane_clears_when_state_returns_to_idle`.)_ - [ ] `C102` `[UT✓]` `[E2E]` **Autofix target pane is correct:** Failure in one pane does not offer/run a fix in the wrong pane. _(UT: target-tab routing — busy-pane tests + `autofix_still_triggers_for_non_agent_pane`.)_ -- [ ] `C103` `[E2E]` `[MANUAL]` **Autofix with Copilot works:** Copilot returns a useful suggestion. -- [ ] `C104` `[E2E]` **Autofix with non-Copilot agents works:** Autofix produces a usable suggestion with a non-Copilot built-in agent (Claude/Codex/Gemini) and a custom ACP agent — same path as Copilot, covered once across the available agents. +- [ ] `C103` `[E2E]` `[MANUAL]` **Autofix with Copilot works:** Copilot submits a valid Direct Helper Proposal and the card presents a useful suggestion. +- [ ] `C104` `[E2E]` **Autofix with non-Copilot agents works:** A non-Copilot built-in agent (Claude/Codex/Gemini) and a custom ACP agent each execute the canonical command, complete permission arming, and submit a usable Direct Helper Proposal through the same path. - [ ] `C221` `[new]` `[E2E]` `[MANUAL]` **Environment-aware answers/fixes:** For a failed or "how do I use X" prompt, the agent investigates the live environment first — checks whether the command actually exists on PATH and surfaces local scripts / near-matches for a mistyped command — instead of giving generic advice or fixing a nonexistent command. _(#306.)_ ### Autofix across layout changes @@ -404,4 +404,4 @@ Net effect: UT shrinks the manual matrix to "did the wiring and UI connect", not - Slash commands: `tools\wta\src\commands.rs`. - Session state model: `tools\wta\src\agent_sessions.rs`, `tools\wta\AGENTS.md`. - Multi-window agent pane architecture: `doc\specs\Multi-window-agent-pane.md`. -- Autofix flow and logging/runtime layout: `AGENTS.md`. +- Autofix flow and logging/runtime layout: `AGENTS.md`. diff --git a/doc/specs/WTA-CLI-terminal-action-proposals.md b/doc/specs/WTA-CLI-terminal-action-proposals.md index 5803aa07f8..d5d5b76719 100644 --- a/doc/specs/WTA-CLI-terminal-action-proposals.md +++ b/doc/specs/WTA-CLI-terminal-action-proposals.md @@ -2,10 +2,8 @@ ## Status -Implemented design. This document supersedes the MCP-based work in PR #428 and -the CLI-to-master routing originally implemented for PR #484. MCP is not part -of the repository architecture. Terminal-action proposals do not transit -through wta-master. +Implemented direct-only design. Terminal-action proposals travel from a +short-lived WTA CLI to the owning Helper. ## Summary @@ -25,6 +23,9 @@ The proposal command cannot mutate Windows Terminal. It can only ask the owning Helper to display a recommendation card. The existing card confirmation remains the sole mutation boundary. +ACP Assistant text is always chat content. It is rendered verbatim and is never +parsed into terminal actions or recommendation cards. + wta-master remains responsible for the shared agent process, ACP multiplexing, session-to-Helper routing, and forwarding permission requests to the Helper that owns the ACP session. It does not mint proposal tokens, receive proposal @@ -40,13 +41,13 @@ payloads, correlate proposal results, or acknowledge cards. - Give the agent immediate validation feedback and final user-decision feedback. - Preserve the existing Run, Insert, Open, Split, and Delegate card UI. - Preserve exactly one visible user confirmation before terminal mutation. -- Keep assistant-text parsing as a compatibility fallback during rollout. ## Non-goals - Reintroducing MCP or adding another shared server. - Using wta-master as a proposal router. - Letting the proposal CLI execute terminal actions. +- Parsing terminal actions from ACP Assistant text. - Treating arbitrary model-authored shell commands as trusted. - Proving that arbitrary proposed shell input is non-destructive. - Reporting whether a confirmed shell command eventually succeeded. @@ -117,6 +118,11 @@ The Helper uses one renderer/parser implementation for prompt generation and permission matching. It does not infer safety from the agent-authored tool title. +The permission request must describe one execute operation with a `command` +field and a single-entry `commands` array containing the same canonical command. +Agent adapters that emit another permission envelope fail arming and therefore +surface their integration gap instead of falling back to Assistant text. + Permission policy has three outcomes: | Input | Outcome | @@ -316,11 +322,12 @@ The direct-pipe design does not claim process attestation. This residual risk is bounded because the CLI only proposes a visible card and user confirmation is still required before mutation. -## Rollout and validation +## Validation -Direct Helper routing is enabled for Copilot first. Assistant-text parsing -remains the fallback until each built-in agent proves canonical command, -permission, and Windows/WSL reachability behavior. +Direct Helper routing is the only card-producing path for every configured ACP +agent. Integrations must prove canonical command execution, permission routing, +and Windows/WSL reachability directly; Assistant text cannot mask a broken +proposal path. Automated and live coverage must include: @@ -332,7 +339,8 @@ Automated and live coverage must include: - card confirm, cancel, supersede, timeout, and Helper shutdown; - `/stop`, `/new`, session load, `/restart`, stash/restore, tab/window close; - multi-tab and multi-window isolation; -- no Permission UI for an exact canonical Copilot proposal; +- no Permission UI for an exact canonical proposal; +- Assistant JSON remains visible chat text and never surfaces a card; - no terminal mutation before card confirmation; - explicit Windows-target WTA build and packaged live verification. diff --git a/test/e2e/README.md b/test/e2e/README.md index 77403d844a..fa5c540cb7 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -7,8 +7,9 @@ Design rationale is captured in the inline notes below and in each suite's heade ## Release-checklist coverage The `tests/` folder implements the `[E2E]` items from -`doc/release-check-list.md` that are automatable in a single-machine, Copilot-only -environment. Current status (run on the Store package): +`doc/release-check-list.md` that are automatable on one machine. Copilot drives +the baseline suites, while the agent matrix covers other installed and +authenticated ACP agents. Current status (run on the Store package): | Suite (file) | Covers | Cases | |---|---|---| @@ -18,12 +19,12 @@ environment. Current status (run on the Store package): | `Feature.FreExecutionPolicy.Tests.ps1` | §0 FRE execution-policy verdict (deterministic via registry; **Dev**, auto-skips) | 3 (1 conditional skip) | | `Feature.AgentPaneInteraction.Tests.ps1` | open/hide/focus, input/rendering, slash, Copilot chat | 14 | | `Feature.PromptHistory.Tests.ps1` | PR #478: per-tab Up/Down prompt recall, draft restoration, and multiline preservation | 3 | -| `Feature.AutofixPane.Tests.ps1` | autofix card render/insert/run/reject/target/stashed + across layout | 10 | +| `Feature.AutofixPane.Tests.ps1` | Direct Helper Autofix proposal card render/insert/run/reject/target/stashed + across layout | 10 | | `Feature.AutofixParser.Tests.ps1` | issue #474: PowerShell ParserError-to-Autofix pipeline + success/handled-error/blank-input negative controls | 4 | | `Feature.SessionList.Tests.ps1` | session view (button + `/sessions` slash), session states, view switching (incl. draft-preservation), focus/restore | 13 (+1 skip) | | `Feature.AgentRestart.Tests.ps1` | agent restart after a settings change (/restart reconnects and answers) | 1 | | `Feature.ShellIntegration.Tests.ps1` | §3 shell-integration OSC 133 marks (success/failure, ParserError dedup, handled errors, WinPS 5.1 errors) + non-integrated cmd.exe safety | 6 | -| `Feature.AgentProposedCommand.Tests.ps1` | §2 agent-proposed command Insert/Run into the shell pane (non-autofix chat path) | 2 | +| `Feature.AgentProposedCommand.Tests.ps1` | §2 Direct Helper Proposal Insert/Run into the shell pane | 2 | | `Feature.AgentMatrix.Tests.ps1` | §2 non-Copilot built-in agents (Claude/Codex/Gemini) connect+chat through the ACP adapter — ONE consolidated case (Copilot is the in-depth suite); skips when none installed+authed | 1 | | `Feature.PerTabAgent.Tests.ps1` | C225-C228 + PR #487: `/agent` picker/prefix completion/direct selection, invalid-id safety, per-tab isolation/shared-master reuse, and global-default/override behavior | 8 | | `Feature.WslAgentBackend.Tests.ps1` | PR #481 profile-scoped WSL agent backend: settings hot reload, helper/master source routing, and authenticated chat | 2 (environment-gated) | diff --git a/test/e2e/tests/Feature.AgentProposedCommand.Tests.ps1 b/test/e2e/tests/Feature.AgentProposedCommand.Tests.ps1 index 6506f22966..21e458e895 100644 --- a/test/e2e/tests/Feature.AgentProposedCommand.Tests.ps1 +++ b/test/e2e/tests/Feature.AgentProposedCommand.Tests.ps1 @@ -1,12 +1,11 @@ #Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } # Release checklist §2 "Insert into pane works" / "Run in pane works" / "Command target is -# correct" via the NON-autofix chat path: ask Copilot to propose a specific command, which -# surfaces the same Run/Insert recommendation card the autofix flow uses, then Insert / Run it -# into the active shell pane. Distinct trigger from Feature.AutofixPane.Tests.ps1 (which arrives -# via a command failure); this is the agent proposing a command in normal chat. +# correct" via the Direct Helper Proposal path: ask Copilot to submit a specific command through +# the canonical WTA CLI, then Insert / Run it into the active shell pane. Distinct trigger from +# Feature.AutofixPane.Tests.ps1 (which arrives via a command failure). # -# LLM nondeterminism is tamed by (a) a UNIQUE marker we fully control, so the card text and the -# pane assertion are exact, and (b) cross-retry + skip if Copilot explains instead of carding. +# A UNIQUE marker makes the card text and pane assertion exact. Missing cards fail the test so +# canonical command, permission arming, and direct-pipe regressions remain visible. # IMPORTANT: Insert and Run each use their OWN fresh terminal (like Feature.AutofixPane). With a # shared terminal a prior card's "Run command"/"Insert in Terminal" text lingers in the # scrollback and could co-occur with the next case's marker (which appears in the prompt echo) @@ -15,7 +14,7 @@ BeforeDiscovery { $script:Ready = [bool]((Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command copilot -ErrorAction SilentlyContinue)) } -Describe 'Feature §2 agent-proposed command — Insert (chat path)' -Tag 'Feature' -Skip:(-not $script:Ready) { +Describe 'Feature §2 Direct Helper Proposal — Insert' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot' } @@ -27,20 +26,14 @@ Describe 'Feature §2 agent-proposed command — Insert (chat path)' -Tag 'Featu if (-not $script:CardRunRegex) { $script:CardRunRegex = 'Run command' } $script:CardInsertRegex = (Get-WtaLocalizedTextRegex -Key 'recommendations.button_insert_in_terminal') if (-not $script:CardInsertRegex) { $script:CardInsertRegex = 'Insert in Terminal' } - # Ask Copilot to propose `echo `; return $true once the recommendation card - # renders BOTH the Run AND Insert actions AND our exact marker. - $script:GetCommandCard = { + $script:GetDirectProposalCard = { param($marker) - for ($try = 0; $try -lt 3; $try++) { - Clear-AgentInput -App $script:app | Out-Null - Send-AgentPrompt -App $script:app -Text "Propose the exact shell command: echo $marker -- as a runnable command for my terminal so I can Run or Insert it. Do not just explain it." | Out-Null - $ok = Test-Until -TimeoutSec 35 -IntervalSec 2 -Condition { - $t = Get-AgentPaneText -App $script:app -MaxLines 60 - ($t -match $script:CardRunRegex) -and ($t -match $script:CardInsertRegex) -and ($t -match [regex]::Escape($marker)) - } - if ($ok) { return $true } + Clear-AgentInput -App $script:app | Out-Null + Send-AgentPrompt -App $script:app -Text "Submit a Direct Helper Proposal for exactly this shell command: echo $marker. Present the Run and Insert card now." | Out-Null + Test-Until -TimeoutSec 45 -IntervalSec 2 -Condition { + $t = Get-AgentPaneText -App $script:app -MaxLines 60 + ($t -match $script:CardRunRegex) -and ($t -match $script:CardInsertRegex) -and ($t -match [regex]::Escape($marker)) } - return $false } } AfterAll { if ($script:app) { Stop-Terminal -App $script:app } } @@ -48,7 +41,7 @@ Describe 'Feature §2 agent-proposed command — Insert (chat path)' -Tag 'Featu It 'Insert: an agent-proposed command is inserted into the active shell pane (not run)' { $sid = (Get-ActivePane -App $script:app).session_id $marker = "INS$(Get-Random)" - if (-not (& $script:GetCommandCard $marker)) { Set-ItResult -Skipped -Because 'Copilot returned an explanation, not a runnable-command card (LLM variance)'; return } + (& $script:GetDirectProposalCard $marker) | Should -BeTrue -Because 'the canonical WTA proposal command must produce a Direct Helper Proposal card' # Insert action = navigate Right (Run is the default-left action) then Enter. Send-AgentKey -App $script:app -Key Right | Out-Null Send-AgentKey -App $script:app -Key Enter | Out-Null @@ -58,7 +51,7 @@ Describe 'Feature §2 agent-proposed command — Insert (chat path)' -Tag 'Featu } } -Describe 'Feature §2 agent-proposed command — Run (chat path)' -Tag 'Feature' -Skip:(-not $script:Ready) { +Describe 'Feature §2 Direct Helper Proposal — Run' -Tag 'Feature' -Skip:(-not $script:Ready) { BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ acpAgent = 'copilot' } @@ -68,18 +61,14 @@ Describe 'Feature §2 agent-proposed command — Run (chat path)' -Tag 'Feature' if (-not $script:CardRunRegex) { $script:CardRunRegex = 'Run command' } $script:CardInsertRegex = (Get-WtaLocalizedTextRegex -Key 'recommendations.button_insert_in_terminal') if (-not $script:CardInsertRegex) { $script:CardInsertRegex = 'Insert in Terminal' } - $script:GetCommandCard = { + $script:GetDirectProposalCard = { param($marker) - for ($try = 0; $try -lt 3; $try++) { - Clear-AgentInput -App $script:app | Out-Null - Send-AgentPrompt -App $script:app -Text "Propose the exact shell command: echo $marker -- as a runnable command for my terminal so I can Run or Insert it. Do not just explain it." | Out-Null - $ok = Test-Until -TimeoutSec 35 -IntervalSec 2 -Condition { - $t = Get-AgentPaneText -App $script:app -MaxLines 60 - ($t -match $script:CardRunRegex) -and ($t -match $script:CardInsertRegex) -and ($t -match [regex]::Escape($marker)) - } - if ($ok) { return $true } + Clear-AgentInput -App $script:app | Out-Null + Send-AgentPrompt -App $script:app -Text "Submit a Direct Helper Proposal for exactly this shell command: echo $marker. Present the Run and Insert card now." | Out-Null + Test-Until -TimeoutSec 45 -IntervalSec 2 -Condition { + $t = Get-AgentPaneText -App $script:app -MaxLines 60 + ($t -match $script:CardRunRegex) -and ($t -match $script:CardInsertRegex) -and ($t -match [regex]::Escape($marker)) } - return $false } } AfterAll { if ($script:app) { Stop-Terminal -App $script:app } } @@ -87,7 +76,7 @@ Describe 'Feature §2 agent-proposed command — Run (chat path)' -Tag 'Feature' It 'Run: an agent-proposed command runs in the active shell pane' { $sid = (Get-ActivePane -App $script:app).session_id $marker = "RUN$(Get-Random)" - if (-not (& $script:GetCommandCard $marker)) { Set-ItResult -Skipped -Because 'Copilot returned an explanation, not a runnable-command card (LLM variance)'; return } + (& $script:GetDirectProposalCard $marker) | Should -BeTrue -Because 'the canonical WTA proposal command must produce a Direct Helper Proposal card' # Run action is the default (left) selection -> Enter. Send-AgentKey -App $script:app -Key Left | Out-Null Send-AgentKey -App $script:app -Key Enter | Out-Null diff --git a/test/e2e/tests/Feature.AutofixPane.Tests.ps1 b/test/e2e/tests/Feature.AutofixPane.Tests.ps1 index fb80191482..9485400ff4 100644 --- a/test/e2e/tests/Feature.AutofixPane.Tests.ps1 +++ b/test/e2e/tests/Feature.AutofixPane.Tests.ps1 @@ -1,6 +1,6 @@ #Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } -# Release checklist: Autofix with agent pane (Copilot) + Autofix across layout changes. -# Observable: a failed command makes the agent pane render a suggestion card with +# Release checklist: Direct Helper Autofix proposals + Autofix across layout changes. +# Observable: a failed command makes the agent submit a typed proposal and render a card with # `[ Run command ]` / `Insert in Terminal` actions. IMPORTANT: autofix throttles/dedups # repeated identical corrections within one session, so tests that need a FRESH card each # (Insert / Run / Stashed) use their own fresh terminal and trigger exactly once. @@ -33,34 +33,18 @@ Describe 'Feature: autofix card render + reject + AI correctness' -Tag 'Feature' It 'Visible agent pane autofix works (suggestion card renders)' { $sid = (Get-ActivePane -App $script:app).session_id - # Autofix sometimes returns "explain" (no card) or drops the first failure; retry - # distinct typos until a runnable-fix card renders. - $typos = @("ggit status","gti status","got status","gitt status") - $gotCard = $false - foreach ($cmd in $typos) { - $listener = Start-WtEventListener -App $script:app - try { - Start-Sleep -Milliseconds 400 - Invoke-FailingCommand -App $script:app -SessionId $sid -Command $cmd | Out-Null - Wait-WtEvent -Listener $listener -TimeoutSec 45 -Predicate { $_.method -eq 'agent_event' } | Out-Null - } catch { } finally { Stop-WtEventListener -Listener $listener } - if (Test-Until -TimeoutSec 18 -IntervalSec 1 -Condition { & $script:CardShown }) { $gotCard = $true; break } - } - # When the LLM returns an explanation (not a runnable-fix card) for ALL retried typos, - # that's model variance, not a product failure — skip like the WSL autofix case below. - if (-not $gotCard) { - Set-ItResult -Skipped -Because 'autofix returned explain (no runnable-fix card) for all typos this run (LLM variance)' - return - } + $listener = Start-WtEventListener -App $script:app + try { + Start-Sleep -Milliseconds 400 + Invoke-FailingCommand -App $script:app -SessionId $sid -Command 'gti status' | Out-Null + Wait-WtEvent -Listener $listener -TimeoutSec 45 -Predicate { $_.method -eq 'agent_event' } | Out-Null + } finally { Stop-WtEventListener -Listener $listener } + (Test-Until -TimeoutSec 30 -IntervalSec 1 -Condition { & $script:CardShown }) | + Should -BeTrue -Because 'Autofix must submit a valid Direct Helper Proposal for an obvious typo' (& $script:CardShown) | Should -BeTrue } It 'Autofix suggests a runnable fix (AI oracle on the card)' { - # Depends on the card from the previous case; if model variance produced no card this - # run, skip rather than fail the AI oracle on a card-less pane. - if (-not (& $script:CardShown)) { - Set-ItResult -Skipped -Because 'no autofix card rendered this run (LLM variance; see the card-render case)' - return - } + (& $script:CardShown) | Should -BeTrue -Because 'the previous case requires a Direct Helper Proposal card' Assert-AI -Claim 'The displayed card presents a shell command that the user can Run or Insert into the terminal (it has Run and Insert action buttons).' -Context (Get-AgentPaneText -App $script:app -MaxLines 60) } It 'Reject/dismiss works (Esc closes the card)' { @@ -88,20 +72,14 @@ Describe 'Feature: autofix Insert action' -Tag 'Feature' -Skip:(-not $script:Rea It 'Insert suggestion types the fix into the shell pane' { $sid = (Get-ActivePane -App $script:app).session_id - # Autofix may return an "explain" (no card) for some failures; retry distinct typos - # until a runnable-fix card with the Insert action appears. - $typos = @("ggit status","gti status","got status","gitt status") - $gotCard = $false - foreach ($cmd in $typos) { - $listener = Start-WtEventListener -App $script:app - try { - Start-Sleep -Milliseconds 400 - Invoke-FailingCommand -App $script:app -SessionId $sid -Command $cmd | Out-Null - Wait-Autofix -Listener $listener -TimeoutSec 45 | Out-Null - } catch { } finally { Stop-WtEventListener -Listener $listener } - if (Test-Until -TimeoutSec 18 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match (Get-RecommendationCardRegex) }) { $gotCard = $true; break } - } - if (-not $gotCard) { Set-ItResult -Skipped -Because 'autofix returned explain (no runnable-fix card) for all typos this run (LLM variance)'; return } + $listener = Start-WtEventListener -App $script:app + try { + Start-Sleep -Milliseconds 400 + Invoke-FailingCommand -App $script:app -SessionId $sid -Command 'gti status' | Out-Null + Wait-Autofix -Listener $listener -TimeoutSec 45 | Out-Null + } finally { Stop-WtEventListener -Listener $listener } + (Test-Until -TimeoutSec 30 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match (Get-RecommendationCardRegex) }) | + Should -BeTrue -Because 'Autofix must submit a Direct Helper Proposal before Insert' Send-AgentKey -App $script:app -Key Right | Out-Null Send-AgentKey -App $script:app -Key Enter | Out-Null # No fixed settle: Assert-Pane polls (Verify.ps1) and returns as soon as the @@ -122,18 +100,14 @@ Describe 'Feature: autofix Run action' -Tag 'Feature' -Skip:(-not $script:Ready) It 'Run suggestion executes the fix in the shell pane' { $sid = (Get-ActivePane -App $script:app).session_id - $typos = @("ggit status","gti status","got status","gitt status") - $gotCard = $false - foreach ($cmd in $typos) { - $listener = Start-WtEventListener -App $script:app - try { - Start-Sleep -Milliseconds 400 - Invoke-FailingCommand -App $script:app -SessionId $sid -Command $cmd | Out-Null - Wait-Autofix -Listener $listener -TimeoutSec 45 | Out-Null - } catch { } finally { Stop-WtEventListener -Listener $listener } - if (Test-Until -TimeoutSec 18 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match (Get-RecommendationCardRegex) }) { $gotCard = $true; break } - } - if (-not $gotCard) { Set-ItResult -Skipped -Because 'autofix returned explain (no runnable-fix card) for all typos this run (LLM variance)'; return } + $listener = Start-WtEventListener -App $script:app + try { + Start-Sleep -Milliseconds 400 + Invoke-FailingCommand -App $script:app -SessionId $sid -Command 'gti status' | Out-Null + Wait-Autofix -Listener $listener -TimeoutSec 45 | Out-Null + } finally { Stop-WtEventListener -Listener $listener } + (Test-Until -TimeoutSec 30 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match (Get-RecommendationCardRegex) }) | + Should -BeTrue -Because 'Autofix must submit a Direct Helper Proposal before Run' Send-AgentKey -App $script:app -Key Left | Out-Null Send-AgentKey -App $script:app -Key Enter | Out-Null # No fixed settle: Assert-Pane polls (Verify.ps1) and returns as soon as the @@ -255,29 +229,19 @@ Describe 'Feature: autofix in a WSL pane (OSC 9001;ShellType end-to-end)' -Tag ' Set-ItResult -Skipped -Because 'WSL shell integration not installed; autofix has no WSL shell context to read' return } - # bash-typos whose correct form is unmistakably Linux (ls/grep/cat), so the AI - # oracle can tell a bash fix from a PowerShell one (Get-ChildItem etc.). The - # whole point of the fix under test: with shell=wsl: in the prompt the - # agent must NOT fall back to PowerShell syntax. - $typos = @('sl -la', 'lll', 'grpe root /etc/hostname', 'caat /etc/hostname') - $gotCard = $false - foreach ($cmd in $typos) { - $listener = Start-WtEventListener -App $script:app - try { - Start-Sleep -Milliseconds 400 - Invoke-FailingCommand -App $script:app -SessionId $script:wslSid -Command $cmd | Out-Null - Wait-Autofix -Listener $listener -TimeoutSec 45 | Out-Null - } catch { } finally { Stop-WtEventListener -Listener $listener } - if (Test-Until -TimeoutSec 18 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match (Get-RecommendationCardRegex) }) { $gotCard = $true; break } - } - if (-not $gotCard) { - Set-ItResult -Skipped -Because 'autofix returned explain (no runnable-fix card) for all typos this run (LLM variance)' - return - } + # An obvious bash typo ensures the direct proposal can be judged against + # the WSL shell context instead of PowerShell syntax. + $listener = Start-WtEventListener -App $script:app + try { + Start-Sleep -Milliseconds 400 + Invoke-FailingCommand -App $script:app -SessionId $script:wslSid -Command 'sl -la' | Out-Null + Wait-Autofix -Listener $listener -TimeoutSec 45 | Out-Null + } finally { Stop-WtEventListener -Listener $listener } + (Test-Until -TimeoutSec 30 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match (Get-RecommendationCardRegex) }) | + Should -BeTrue -Because 'WSL Autofix must submit a Direct Helper Proposal' Assert-AI -Claim 'The suggested fix command uses Linux/bash shell syntax (e.g. ls, grep, cat, forward-slash paths). It is NOT a Windows PowerShell command (no Get-ChildItem / Select-String / cmdlet-style Verb-Noun).' -Context (Get-AgentPaneText -App $script:app -MaxLines 60) } } - - - - + + + diff --git a/tools/wta/prompts/auto-fix.md b/tools/wta/prompts/auto-fix.md index b6f75f32c1..49d596be92 100644 --- a/tools/wta/prompts/auto-fix.md +++ b/tools/wta/prompts/auto-fix.md @@ -4,28 +4,21 @@ A command failed. Diagnose the error from the terminal output and shell context --- -## Direct submission (when you can execute commands) +## Decision -If, in THIS session, you can execute shell commands directly AND the runtime context above includes an `[intellterm.wta proposal]` block with a `--channel `, you may submit your `fix` decision directly instead of relying on the fenced JSON block below being parsed out of your reply: +### `fix` — submit one deterministic command -1. For a `fix` decision only (never `explain` — there is nothing to submit), build one JSON object: `{"schema_version": 1, "origin": "autofix", "choices": [{"choice": 1, "title": "<≤6 word summary>", "rationale": "", "actions": [{"type": "send", "input": ""}]}]}`. Exactly one choice, exactly one `send` action, no `parent` (autofix always binds the real failing pane itself and ignores/strips any `parent` you supply). -2. Run exactly the command form shown in the `[intellterm.wta proposal]` block, replacing only `` with that object. Keep it compact and PowerShell single-quoted (double any literal apostrophe). Do not use stdin, a pipeline, here-string, redirection, temporary file, alternate executable spelling, or extra arguments. -3. Read both JSON response phases. Validation is immediate. If accepted, wait for the final user decision. If validation reports `retryable:true`, correct the payload and retry at most twice; never retry cancellation, supersession, timeout, or unavailability. -4. Do not also emit the fenced `json` block below after a direct attempt (it would risk a duplicate card). This proposal call never runs the fix itself — the user still confirms the card exactly as today. +Use when a high-confidence, non-destructive single shell command (including in-place file edits) is likely to fix the error: typos, wrong flags, made-up commands with obvious intent (`listdir` → shell-native equivalent), source edits the compiler pinpoints, single-file renames, missing imports. -If you cannot execute commands in this session, or no `[intellterm.wta proposal]` block is present, ignore this section and use the fenced ```json``` block below as normal — that fallback is unchanged. +When the runtime includes an `[intellterm.wta proposal]` block, immediately submit this compact payload through the exact command shown there: -## Output +`{"schema_version":1,"origin":"autofix","choices":[{"choice":1,"title":"<≤6 word summary>","rationale":"","actions":[{"type":"send","input":""}]}]}` -Return exactly one JSON object in a fenced ```json block. No prose around it. +Exactly one choice and one `send` action are allowed. Omit `parent`; the Helper binds the recorded failing pane. Replace only ``, keep the payload PowerShell single-quoted, and double literal apostrophes. Use no stdin, pipeline, here-string, redirection, temporary file, alternate executable spelling, or extra argument. -### `fix` — one deterministic command resolves it +Read both JSON response phases. Validation is immediate. If accepted, wait for the final user decision. Correct `retryable:true` failures at most twice; never retry cancellation, supersession, timeout, or unavailability. The proposal command presents a card; the user confirmation dispatches the fix. -Use when you can write a high-confidence, non-destructive single shell command (including in-place file edits) that is likely to fix the error: typos, wrong flags, made-up commands with obvious intent (`listdir` → shell-native equivalent), source edits the compiler pinpoints, single-file renames, missing imports. - -```json -{"action": "fix", "title": "<≤6 word summary>", "command": "", "rationale": ""} -``` +When the runtime has no proposal block or command execution is unavailable, return a concise prose explanation. Action cards are created only through direct submission; never encode a fix in Assistant text. - The `command` is injected and run **directly in the user's current shell session** — `Shell Context.shell` is that shell's executable (`pwsh.exe`/`powershell.exe` → PowerShell, `cmd.exe` → Command Prompt, `bash.exe`/`wsl.exe` → Bash/WSL). It MUST be a single valid command for that exact shell, as-is: match its syntax and built-ins (`Get-ChildItem` vs `ls`, `Set-Location` vs `cd`), and do NOT wrap it in, or assume, a different shell. When `shell` is missing, default to PowerShell. - Resolve file paths against `Shell Context.cwd`. Compiler/build-tool diagnostics print paths relative to the project root — if the cwd is already inside one of those leading segments, strip it (e.g. cwd `…\app\src` + tool path `src\main.rs` → use `main.rs`). @@ -35,44 +28,30 @@ Use when you can write a high-confidence, non-destructive single shell command ( Use when an auto-fix would be wrong, ambiguous, or destructive: tool not installed (needs package-manager choice / elevation), auth/credential issues, multi-step refactors, destructive ops (`rm -rf`, force-push, schema migrations), genuinely unclear user intent, or output that isn't a real error. -```json -{"action": "explain", "title": "<≤6 word headline>", "explanation": ""} -``` - -`explanation` (Markdown) must include: what the error means, why no auto-fix, and concrete next steps (commands in backticks; bullet the alternatives when multiple are plausible). +Return concise Markdown that includes what the error means, why no auto-fix is appropriate, and concrete next steps. Put commands in backticks and bullet alternatives when several are plausible. Assistant prose is displayed as chat and never converted into an action card. ### Command not found When the failure is an unrecognized / not-found command (in any language), never imply the command exists or fall back to generic "check the spelling / use `help`" advice. Be honest that it isn't on the user's machine. - If a `### Near Matches` section is present, it lists real commands that **do** exist in this shell (resolved from the live environment — PATH programs, scripts, functions, aliases, cmdlets), closest first. Treat it as the source of truth for "did you mean": - - If the top near-match is an obvious correction of what the user typed (a typo / transposition), return a `fix` that runs that real command, keeping the user's original arguments. Name the correction in the `rationale`. - - If several are plausible, or none is an obvious fit, return an `explain` that states the command wasn't found and offers the near-matches as candidates. + - If the top near-match is an obvious correction of what the user typed (a typo / transposition), submit a `fix` proposal that runs that real command, keeping the user's original arguments. Name the correction in the `rationale`. + - If several are plausible, or none is an obvious fit, explain that the command wasn't found and offer the near-matches as candidates. - If there is **no** `### Near Matches` section, automatic lookup may simply be unavailable for this shell. Infer the user's intent semantically from the failed command name, its arguments, `Shell Context.shell`, `Shell Context.cwd`, and nearby terminal output: - - Return `fix` when one shell-native command is the clear conventional equivalent or an obvious typo, even if it was not verified by a near-match search. Examples: `listdir` → `ls` in Bash/WSL, `getdate` → `date` in Bash/WSL. + - Submit `fix` when one shell-native command is the clear conventional equivalent or an obvious typo, even if it was not verified by a near-match search. Examples: `listdir` → `ls` in Bash/WSL, `getdate` → `date` in Bash/WSL. - Preserve compatible original arguments. When flags or arguments differ, translate them to the replacement command's equivalent syntax or omit only those that are clearly inapplicable; argument incompatibility alone is not a reason to withhold a useful fix. - Prefer the target shell's built-ins and ubiquitous commands. Never substitute syntax from another shell. - - Use `explain` only when the intent is genuinely too ambiguous to choose one likely correction, or when running the correction could be destructive. Otherwise, return the best semantic `fix`. + - Explain only when the intent is genuinely too ambiguous to choose one likely correction, or when running the correction could be destructive. Otherwise, submit the best semantic `fix`. - In the `rationale`, state that the replacement is a semantic inference rather than a verified near match. ### Examples -```json -{"action": "fix", "title": "Fix: dotnet test", "command": "dotnet test", "rationale": "Typo: 'dotent' should be 'dotnet'."} -``` +Direct payload: `{"schema_version":1,"origin":"autofix","choices":[{"choice":1,"title":"Fix: dotnet test","rationale":"Typo: 'dotent' should be 'dotnet'.","actions":[{"type":"send","input":"dotnet test"}]}]}` -```json -{"action": "fix", "title": "Run deploy-it", "command": "deploy-it -Target prod", "rationale": "No 'deploit' command in this shell; nearest match is the local script 'deploy-it'."} -``` +Direct payload: `{"schema_version":1,"origin":"autofix","choices":[{"choice":1,"title":"Run deploy-it","rationale":"No 'deploit' command in this shell; nearest match is the local script 'deploy-it'.","actions":[{"type":"send","input":"deploy-it -Target prod"}]}]}` -```json -{"action": "explain", "title": "No such command: frobnicate", "explanation": "`frobnicate` isn't recognized — there's no command by that name in this shell, and no near-match was found.\n\n**Why no auto-fix:** there's no obvious intended command to run.\n\n**Next steps:** double-check the name, or run `Get-Command *frob*` to search for something similar."} -``` +Explanation: `frobnicate` isn't recognized, and no near-match was found. There is no clear command to run automatically. Double-check the name, or use `Get-Command *frob*` to search for a similar command. -```json -{"action": "fix", "title": "Use println! instead of printf!", "command": "(Get-Content src\\main.rs) -replace 'printf!', 'println!' | Set-Content src\\main.rs", "rationale": "Rust uses println!; compiler suggested the same."} -``` +Direct payload: `{"schema_version":1,"origin":"autofix","choices":[{"choice":1,"title":"Use println! instead of printf!","rationale":"Rust uses println!; compiler suggested the same.","actions":[{"type":"send","input":"(Get-Content src\\main.rs) -replace 'printf!', 'println!' | Set-Content src\\main.rs"}]}]}` -```json -{"action": "explain", "title": "claude is not installed", "explanation": "The `claude` command isn't on PATH (Anthropic Claude Code CLI).\n\n**Why no auto-fix:** install requires a package-manager choice and may need elevation.\n\n**Install:** `npm install -g @anthropic-ai/claude-code` or download from https://claude.com/code. Restart the shell after."} -``` +Explanation: The `claude` command isn't on PATH. Installation requires a package-manager choice and may need elevation. Use `npm install -g @anthropic-ai/claude-code` or download Claude Code from its official site, then restart the shell. diff --git a/tools/wta/prompts/terminal-agent.md b/tools/wta/prompts/terminal-agent.md index b76ff651bb..2b65490650 100644 --- a/tools/wta/prompts/terminal-agent.md +++ b/tools/wta/prompts/terminal-agent.md @@ -1,185 +1,43 @@ # Terminal Agent -You are Terminal Agent, a capable terminal-native assistant inside Windows Terminal. The user opened you to get something done in their terminal. Your job is to pick the smallest, most direct path to actually finish their task — not to produce the most elaborate answer. +You are a terminal-native assistant inside Windows Terminal. Runtime context is authoritative. Choose the first matching mode and do not mix modes: -## Mode Decision (do this first, in order) +1. **Chat**: General knowledge independent of this machine/repo. Answer in prose, without JSON. For an unfamiliar local command, investigate read-only first: prefer `wta resolve-command --json`, then inspect help or source without executing it. +2. **Recommend**: One or a short sequence of active-pane shell commands satisfies the request, including inspection such as list/status/pwd. Present a card; do not run those commands in your own tool shell first. +3. **Self-execute**: A bounded answer requires reading files, parsing output, or reasoning across tool results. Use tools, then answer in prose without JSON. +4. **Delegate**: The task is long-running, multi-file, or explicitly requested in another agent/tab. Present a card with a delegated `open_and_send` action and a self-contained task. -Read the runtime context (cwd, shell, activeTarget, buffer, supported delegate agents) and the user's input. Then walk this decision tree top-to-bottom and stop at the FIRST match: +Prefer Recommend over Self-execute, and Self-execute over Delegate. Use the active pane's shell syntax. -1. **Chat mode** — The user is asking a general / conceptual question that does not depend on their cwd, repo, shell history, or files. Examples: "is the sky blue", "what does git rebase do", "explain Rayleigh scattering", "who are you". - → Answer in prose. No JSON. Usually no tool calls — but a question about a *specific command on this machine* ("how do I use X", "what is X") is the exception: it's still a chat answer, yet you must look before you speak. See *Chat answers that need investigation* below. +When Recommend mode has an `[intellterm.wta proposal]` block, invoke its canonical proposal command immediately. Do not emit prose, a plan, or reasoning, and do not call any other tool before that command. -2. **Mode A — Shell Recommendation (preferred)** — The user's intent is clear from context AND can be satisfied by running one (or a short sequence of) shell command(s) in the active pane. The user benefits from seeing the command land in *their* shell — it stays in their scrollback, in their cwd, with their shell state. - Examples: "run the tests", "git status", "build the project", "show me the files here", "what's my cwd", "cd into the worktree", "start the dev server", "kill that process", "open a new tab in D:\\repo". - → Emit a recommendation card (JSON below). Do NOT call tools yourself first — the active pane already has what's needed. +## Recommendation cards -3. **Mode B — Self-Execute** — Mode A doesn't fit because answering / completing the task requires reading multiple files, parsing structured output, reasoning across context, or stitching together intermediate results — but the work is still bounded (a few minutes, no large refactors, no long-running watchers). - Examples: "figure out what this project does", "why is this test failing", "summarize the diff", "what does this error mean", "find where X is defined", "fix this typo". - → Use tools yourself (`view` / `read_text_file` / `list_directory` / `execute_command` / `write_file`). When done, answer in prose. No JSON. - → **Read the "Self-Execute Rules" section below before you touch any tool.** +Recommend and Delegate return 1-3 numbered choices with 1-3 actions each. Keep titles short and non-empty and rationales to one sentence. -4. **Mode C — Delegate to a tab** — The task is too large, too long-running, or benefits from a sustained agent session of its own. Examples: "fix all the failing tests", "add feature X", "refactor module Y", "investigate this crash dump end-to-end". Also use C when the user explicitly says "let Copilot do it" / "open in a new tab" / "delegate". - → Emit a recommendation card with an `open_and_send` action targeting a delegate agent in a new tab. +### Direct submission -Once you have picked a mode, follow only that mode's rules. Do not mix them — chat answers never include JSON; Mode B answers never include JSON; Modes A and C always include exactly one JSON block. +If you can execute shell commands and runtime contains `[intellterm.wta proposal]`, submit one compact object: -### Direct submission (Modes A and C, when you can execute commands) +`{"schema_version":1,"origin":"terminal_agent","recommended_choice":1,"choices":[{"choice":1,"title":"...","rationale":"...","actions":[...]}]}` -If, in THIS session, you can execute shell commands directly (the same capability Mode B's Self-Execute Rules use — e.g. an `execute_command` tool) AND the runtime context below includes an `[intellterm.wta proposal]` block with a `--channel `, submit the recommendation directly instead of relying on the fenced JSON block being parsed out of your reply: +Actions are `{"type":"send","input":"..."}`, `{"type":"open","target":"tab|panel",...}`, or `{"type":"open_and_send","target":"tab|panel","input":"...","delegate":true|false,...}`. Open actions may include `cwd`, `title`, `profile`, and panel-only `direction`. Set `delegate:true` only for Delegate mode. -1. Build the same recommendation as one JSON object matching this wire shape (NOT the fenced-block schema below — this is the direct-submission schema): `{"schema_version": 1, "origin": "terminal_agent", "recommended_choice": , "choices": [{"choice": , "title": "...", "rationale": "...", "actions": [...]}]}`. Actions use `{"type":"send","input":"..."}`, `{"type":"open","target":"tab|panel",...}`, or `{"type":"open_and_send","target":"tab|panel","input":"...","delegate":true|false,...}`. Do not include `parent`, `agent`, or any session/window/tab/pane id: the helper injects the active pane and resolves `delegate:true` to the configured delegate. -2. Run exactly the command form shown in the `[intellterm.wta proposal]` block, replacing only `` with that object. Keep it compact and PowerShell single-quoted (double any literal apostrophe). Do not use stdin, a pipeline, here-string, redirection, temporary file, alternate executable spelling, or extra arguments. -3. Read the JSON Lines output. `phase:"validation"` is immediate. On `accepted`, keep waiting for `phase:"final"` (`confirmed`, `cancelled`, `superseded`, `session_replaced`, `timed_out`, or `unavailable`). `confirmed` means the card action was dispatched, not that a shell command finished successfully. -4. If validation reports `retryable:true`, correct the payload and retry at most twice; the corrected command will request a fresh one-time permission. Never retry lifecycle/final outcomes. Do not also emit the fenced JSON block after a direct attempt because that could show a duplicate card. -5. This never executes anything by itself — the user must still confirm the card exactly as today. It only gets the recommendation onto the card faster/more reliably than parsing your final text. +Never include `parent`, `agent`, or session/window/tab/pane/helper ids; the Helper supplies routing and the configured delegate. Run the exact runtime command, replacing only ``. Keep it compact and PowerShell single-quoted (double literal apostrophes). No stdin, pipelines, here-strings, redirection, temporary files, alternate executable spelling, or extra arguments. -If you cannot execute commands in this session, or no `[intellterm.wta proposal]` block is present in the runtime context, ignore this section entirely and use the fenced ```json``` block as described below — that fallback is unchanged and always works. +Read validation and, when accepted, wait for the final user decision. `confirmed` means dispatch, not command completion. Correct `retryable:true` failures at most twice; never retry final/lifecycle outcomes. -### Tie-breakers +Recommendation cards are available only through the direct proposal command. If the runtime has no `[intellterm.wta proposal]` block, explain in prose that an action card is unavailable; never encode actions in Assistant text. -- If A and B both seem to fit, pick **A**. The shell command in the user's pane is cheaper, more transparent, and leaves the user with state they can build on. -- If B and C both seem to fit, pick **B** unless the task is genuinely long-running or multi-file. "Read 2 files and summarize" is B, not C. -- "Inspection" requests where the user just wants to *see* output (`git status`, `ls`, `pwd`, `cat foo`) are always A, never B. -- "Understanding" requests where the user wants *you* to read and *explain* are always B, never A. +## Self-execute rules -## Chat answers that need investigation +- Treat runtime `cwd` as authoritative. File tools use absolute paths rooted there; anchor shell commands there when location matters. +- Match runtime `shell`: PowerShell uses PowerShell syntax, cmd uses cmd syntax, and bash/WSL uses POSIX syntax. +- Diagnose paths, cwd, shell, or arguments after tool failure. Never fabricate output. +- Stay bounded; switch to Delegate for substantial implementation. Finish in prose, not with a card. -Most chat questions are pure knowledge and need no tools. But some can't be answered honestly without looking at *this* machine first — the canonical case is **a command the user names** that you don't recognize. Don't fall back to generic "use `help` / `Get-Command`" boilerplate, and don't assume it exists. This is the one chat case where you DO call tools: investigate read-only, then reply in prose. Do NOT emit a recommendation card asking the user to run the probe; do it yourself. This stays a chat answer — no JSON card. +## Runtime context -**Sample — the user asks: "How do I use `deploy-it`?"** (you don't recognize `deploy-it`) - -1. **Identify what the name resolves to first.** Run `wta resolve-command deploy-it --json` — **prefer it over your own `Get-Command`/`Get-Alias` probe**: it loads the user's profile, so it reports profile-defined **aliases and functions** your own `execute_command` probe misses (a plain probe usually runs without the user's profile). It returns a `status`: `exists` (with the command's type + resolved target, e.g. an alias → `where.exe`), `not_found` (with the closest real commands), `indeterminate` (couldn't verify — don't assume it's missing), or `unsupported` (the selected shell is not PowerShell). If `wta resolve-command` is unavailable, probe yourself with `execute_command`: `Get-Command deploy-it -All` — and describe it by its actual type (Application / ExternalScript / Cmdlet / Function / Alias); don't assume "on PATH" when it might be a function or alias. -2. **Learn its usage without running it.** Say it resolves to a script at `C:\tools\deploy-it.ps1` — read usage from a source of truth that does NOT execute the command: prefer `Get-Help deploy-it`, and read the script's `param(...)` block directly with `view` / `read_text_file`. Only fall back to the command's own help flag (`deploy-it -?`, bash/WSL `deploy-it --help`) when you know it handles that flag early and is side-effect-free — a plain script may run its body before any help check. -3. **Tell the user, grounded in what you found:** "`deploy-it` is a PowerShell script at `C:\tools\deploy-it.ps1`. It takes `-Target` and `-DryRun` — e.g. `deploy-it -Target prod`." If it resolved to an alias, say so: "`which` is an alias for `where.exe` (set in your profile)." - -If step 1 returns `not_found`, the command isn't installed under that name — say so plainly; never imply it exists. (If it returns `indeterminate`, do NOT say it's missing — fall back to your own probe.) Then offer a useful "did you mean" grounded in what's *actually* on this machine: `wta resolve-command`'s `not_found` status already carries the closest real commands (`matches`, closest first). If the CLI is unavailable, search the real command list yourself for similar names (you judge a likely typo well — do NOT rely on `Get-Command -UseFuzzyMatching`, its ranking buries PATH programs and scripts): a stem wildcard `Get-Command -Name "*depl*"` (bash/WSL: `compgen -c | grep -i depl`) and pick the nearest. Either way: "There's no `deploit` command in this shell; did you mean `deploy-it`?" - -The point of the sample is the *shape*, not the exact commands: recognize you don't know → investigate the live environment → try the real usage → answer from evidence. Adapt the commands to the pane's shell. - -## Self-Execute Rules (Mode B) - -These rules exist because cwd can be ambiguous across tool calls. When Terminal Context includes a `cwd`, the spawned tool process may already be pinned to the user's active pane working directory. But do not infer cwd from tool behavior alone: when WT is not connected, when `cwd` is missing, or when a tool/session starts elsewhere, commands like `Get-ChildItem` or `ls` with no path can still hit the WRONG directory. - -**Authoritative cwd**: the `cwd` field in the injected Terminal Context JSON, when present. That is the user's active pane's working directory and should be treated as the source of truth. If `cwd` is absent, do not assume the tool process matches the user's pane; first establish location explicitly or use absolute paths once you have one. - -1. **For file-reading tools** (`view`, `read_text_file`, `list_directory`): always pass an **absolute path** rooted at the context `cwd`. Never pass a bare filename or a relative path. - -2. **For `execute_command`**: your shell may already be in the user's cwd, but you should still anchor commands to the context `cwd` whenever correctness matters. Two acceptable patterns: - - **Prepend cd**: PowerShell → `Set-Location ''; `. Bash/WSL → `cd '' && `. - - **Or use absolute paths inside the command**: `Get-ChildItem '' -Force`, `cargo build --manifest-path '\Cargo.toml'`, etc. - Pick whichever fits the command. If you run more than one related command, prefer the `Set-Location` prefix once on the first call rather than repeating absolute paths. If `cwd` is missing or you are not confident the session is rooted correctly, establish location explicitly before relying on pathless commands. - -3. **Match the active pane's shell** when choosing shell syntax for `execute_command`: use `shell` — the actual executable (`pwsh.exe`/`powershell.exe`, `cmd.exe`, `bash.exe`/`wsl.exe`). PowerShell uses `Set-Location` / `Get-ChildItem` / `Get-Content`; Bash/WSL uses `cd` / `ls` / `cat`. Default to PowerShell if `shell` is missing. - -4. **Do not bail out to a recommendation card just because one tool call failed or returned unexpected output.** Diagnose: was the cwd wrong? Was the path wrong? Retry with the fix. Only emit a card if you genuinely conclude the task is shell-command shaped after all (which means you should have picked A originally — go back and re-decide). - -5. **Finish with a prose answer.** When your tools have gathered what you need, write the answer directly to the user. Do not emit JSON. Do not push the work back into the user's pane unless they specifically asked you to leave evidence there. - -6. **Stay bounded.** If during Mode B you discover the task is actually large (lots of files to change, will take minutes of agent reasoning), switch to **Mode C** at that point — emit a delegation card explaining what you found and what you propose to delegate. - -## Recommendation Card Schema (Modes A and C) - -Action types: - -- `send` — type `input` plus Enter into an existing pane identified by `parent`. Used in Mode A. -- `open_and_send` — create a new shell or agent destination, then type `input` plus Enter into it. Used in Mode C, or in Mode A when the user explicitly asked for a new tab/panel. -- `open` — create a new empty shell tab or panel and do NOT send any input. Use only when the user explicitly asked for a new tab/panel with no command (e.g. "open a new tab here", "split a pane right"). - -Rules: - -- Return 1 to 3 ranked choices. `recommended_choice` is the choice number (1-indexed) you suggest. -- Every choice must contain a non-empty `actions` array. There is no `wait` / `noop` / `observe` action — convert "wait" ideas into a real executable step. -- Keep `title` short. Keep `rationale` to one sentence. - -`send` rules (Mode A): -- `parent` MUST be the literal `activeTarget` value from the Terminal Context JSON. Never invent pane IDs. -- `input` must match the active pane's shell — determine it from `shell` (the actual executable). PowerShell/pwsh → `Get-ChildItem`, `Get-Location`, `Set-Location`, `Get-Content`, `Remove-Item`. cmd → `dir`, `cd`, `type`, `del`. bash/WSL → `ls`, `pwd`, `cd`, `cat`, `rm`. Default to PowerShell when `shell` is missing. -- For Mode A inspection commands, prefer a single `send` choice on the active pane unless the user explicitly asked for isolation. - -`open_and_send` rules (Mode C, or new-destination A): -- Must include `target` (`"tab"` or `"panel"`) and `input`. -- Must include `cwd` so the new shell starts in the right directory (use the runtime cwd unless the user named a different directory). -- For `target: "panel"`: set `parent` to `activeTarget`. You may include `direction` (`"right"` / `"left"` / `"up"` / `"down"` / `"auto"`). -- For `target: "tab"`: omit `parent`. `direction` is invalid. -- When delegating (Mode C), set `agent` to an ID from the supported delegate agent JSON. WTA will launch that agent in the new destination and send `input` as the agent's first prompt. -- The delegated `input` should be a self-contained briefing: tell the delegate agent the cwd, the goal, the constraints, and what "done" looks like. - -`open` rules: -- Must include `target` (`"tab"` or `"panel"`). MUST NOT include `input` or `agent`. -- Should include `cwd`. May include `title`. For panels, set `parent` to `activeTarget`, optionally `direction`. - -`activeTarget` rules: -- If `activeTarget` is missing from the Terminal Context, do NOT emit `send` or any `target: "panel"` action — there is no pane to attach to. Fall back to `target: "tab"` or to a Mode B / chat answer. - -## Response Format - -**Chat mode** — prose only, no JSON. General-knowledge answers need no tools; a question about a *specific local command* needs a quick read-only investigation first (see *Chat answers that need investigation*). - -**Mode A and Mode C** — one short sentence of plain-prose framing (optional, ≤2 lines), then exactly ONE fenced ```json``` block with the schema below. Do not include additional JSON blocks. Do not append a trailing summary after the JSON. - -**Mode B** — call tools as you work (each tool call generates its own permission prompt; the user sees them). Stream short prose between calls if useful for the user to follow your reasoning. When done, end with a direct prose answer. No JSON block. - -### JSON example - -```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Run tests in the active pane", - "rationale": "Fast local verification in the shell the user is already using.", - "actions": [ - { - "type": "send", - "parent": "10", - "input": "cargo test" - } - ] - }, - { - "choice": 2, - "title": "Delegate to Copilot in a new tab", - "rationale": "Hand off a longer investigation that should stay isolated.", - "actions": [ - { - "type": "open_and_send", - "target": "tab", - "agent": "copilot", - "cwd": "D:\\repo", - "input": "You are working in D:\\repo. Investigate the failing test in tests/integration.rs, identify the root cause, fix it, and summarize the change.", - "title": "Copilot delegate" - } - ] - }, - { - "choice": 3, - "title": "Open an empty tab in the repo root", - "rationale": "User asked to just open a workspace — no command to run yet.", - "actions": [ - { - "type": "open", - "target": "tab", - "cwd": "D:\\repo" - } - ] - } - ] -} -``` - -## General behavior - -- Do not fabricate command output you did not actually receive. Either call a tool to get the real value, or say what you don't know. -- Do not invent capabilities outside the action list. Do not invent pane IDs, agent IDs, or shell features. -- Keep titles concise and rationales short. The user reads them at a glance. -- The runtime sections below are authoritative for the current pane, supported agents, and terminal state. Use them. Do not guess. - -## Runtime Context - -The following sections are injected by WTA at runtime: - -- supported delegate agents -- terminal context JSON (fields: `activeTarget`, `window_title`, `cwd`, `shell`, `locale`, `buffer`) +The injected sections describe supported delegate agents and the active terminal (`activeTarget`, title, cwd, shell, locale, and buffer). diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index db7e30df42..d9615269b0 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -107,9 +107,7 @@ use crate::commands::{ self, CommandKind, CommandSpec, MovePositionSpec, ParseOutcome, ParsedCommand, }; use crate::coordinator::{ - parse_autofix_response, parse_recommendation_set, recommended_choice_index, - validate_recommendation_set_for_coordinator_target, AutofixDecision, RecommendationChoice, - RecommendationSet, + recommended_choice_index, RecommendationChoice, RecommendationSet, }; use crate::pane_context::PaneContext; @@ -1252,6 +1250,10 @@ pub enum AppEvent { id: String, status: String, }, + HideToolCall { + session_id: String, + id: String, + }, Plan { session_id: String, entries: Vec, @@ -2693,7 +2695,6 @@ impl App { let recovery_agent_id = self.current_agent_id.clone(); let event_tx_for_pipe = event_tx.clone(); let proposal_channels = Arc::clone(&self.proposal_channels); - let direct_proposals_enabled = self.current_agent_id == "copilot"; tokio::task::spawn_local(async move { if let Err(e) = crate::protocol::acp::client::run_acp_client_over_pipe( pipe_name, @@ -2717,7 +2718,6 @@ impl App { wt_connected, post_login_auth, // only true on genuine LoginComplete reconnects proposal_channels, - direct_proposals_enabled, ) .await { @@ -4827,6 +4827,7 @@ impl App { AppEvent::TimingMetric { .. } => "timing_metric", AppEvent::ToolCall { .. } => "tool_call", AppEvent::ToolCallUpdate { .. } => "tool_call_update", + AppEvent::HideToolCall { .. } => "hide_tool_call", AppEvent::Plan { .. } => "plan", AppEvent::PermissionRequest { .. } => "permission_request", AppEvent::SystemMessage(_) => "system_message", @@ -5164,8 +5165,8 @@ impl App { continue; }; if tab.reveal_chars >= len { - // Clamp down if the visible text shrank (e.g. a fenced JSON - // block replaced the streamed prose). + // Clamp down if a turn/state replacement shortened the + // visible streaming text. tab.reveal_chars = len; continue; } diff --git a/tools/wta/src/app_events.rs b/tools/wta/src/app_events.rs index 119a67f7e7..bc32b079e2 100644 --- a/tools/wta/src/app_events.rs +++ b/tools/wta/src/app_events.rs @@ -539,16 +539,8 @@ impl App { tab.pending_agent_response.push_str(&text); // Append to the streaming buffer. The state machine drops - // late chunks and handles the stale-autofix generation check - // before returning whether the buffer actually grew. - let advanced = self.turn_observe_chunk(&session_id, ChunkKind::Message, &text); - - // Surface the card the moment the streamed JSON parses, - // instead of waiting for AgentMessageEnd (gated behind - // Copilot's Stop/SessionEnd hooks, ~8s on Windows). - if advanced { - self.turn_try_eager_surface(&session_id); - } + // late chunks and handles the stale-autofix generation check. + self.turn_observe_chunk(&session_id, ChunkKind::Message, &text); } AppEvent::UserMessageReplayChunk { session_id, text } => { // Replayed historical user prompt from a `session/load` @@ -629,6 +621,13 @@ impl App { } } } + AppEvent::HideToolCall { session_id, id } => { + let tab = self.session_tab_mut(&session_id); + tab.tool_calls.remove(&id); + tab.messages.retain( + |message| !matches!(message, ChatMessage::ToolCall { id: message_id, .. } if message_id == &id), + ); + } AppEvent::Plan { session_id, entries, diff --git a/tools/wta/src/app_tests.rs b/tools/wta/src/app_tests.rs index 008a9f644a..75d0e9ebb4 100644 --- a/tools/wta/src/app_tests.rs +++ b/tools/wta/src/app_tests.rs @@ -4737,16 +4737,15 @@ fn perm_option_kind_matching_is_case_insensitive() { /// but `AgentMessageEnd` has not yet arrived (turn is /// `Surfaced{end_pending:true}`), the thinking/activity indicator must /// remain visible. Previously `spinner_label()` returned `None` for any -/// `Surfaced` variant, making the pane look frozen between the eager surface -/// and the permission card appearing. +/// `Surfaced` variant, making the pane look frozen between the direct proposal +/// surface and the permission card appearing. #[test] fn thinking_indicator_visible_while_permission_pending_and_end_pending() { let mut app = test_app(); // Put the tab in `Surfaced{end_pending:true}` — the state that exists - // between an eager surface (recommendation / chat turn visible) and the - // `AgentMessageEnd` event that releases the UI gate. A permission - // request can arrive in this window. + // between a direct proposal surface and the `AgentMessageEnd` event that + // releases the UI gate. A permission request can arrive in this window. let prompt = SubmittedPrompt { id: 1, text: "test".into(), @@ -4989,6 +4988,36 @@ async fn tool_call_completion_updates_card_status() { .await; } +#[test] +fn hide_tool_call_removes_internal_proposal_card() { + let mut app = test_app(); + submit_test_prompt(&mut app, "list files"); + + app.handle_event(AppEvent::ToolCall { + session_id: "0".to_string(), + id: "proposal-tool".to_string(), + title: "Propose listing active directory".to_string(), + status: "Pending".to_string(), + }); + assert!(app + .current_tab() + .messages + .iter() + .any(|message| matches!(message, ChatMessage::ToolCall { id, .. } if id == "proposal-tool"))); + + app.handle_event(AppEvent::HideToolCall { + session_id: "0".to_string(), + id: "proposal-tool".to_string(), + }); + + assert!(!app.current_tab().tool_calls.contains_key("proposal-tool")); + assert!(!app + .current_tab() + .messages + .iter() + .any(|message| matches!(message, ChatMessage::ToolCall { id, .. } if id == "proposal-tool"))); +} + /// Plan: a `Plan` notification must surface as a plan card with its entries. #[tokio::test] async fn plan_surfaces_card_in_chat() { @@ -6255,10 +6284,9 @@ fn thought_chunk_first_transitions_with_empty_buf() { } #[test] -fn end_with_no_eager_chat_fallback_commits_completed_turn() { +fn assistant_prose_commits_completed_chat_turn() { let mut app = test_app(); submit_test_prompt(&mut app, "why blue?"); - // Pure prose — won't parse as a RecommendationSet, falls to chat. app.turn_observe_chunk( DEFAULT_TAB_ID, ChunkKind::Message, @@ -6280,7 +6308,7 @@ fn end_with_no_eager_chat_fallback_commits_completed_turn() { ); assert!( tab.turn.accepts_new_prompt(), - "chat fallback unblocks input" + "completed chat turn unblocks input" ); assert_eq!(tab.completed_turns.len(), 1); assert_eq!(tab.completed_turns[0].prompt, "why blue?"); @@ -6401,19 +6429,11 @@ fn cancel_mid_stream_preserves_visible_prose_with_canceled_marker() { } #[test] -fn cancel_mid_stream_records_canceled_marker_even_without_visible_prose() { - // A buffer that's pure JSON (no `explanation` field, no prose - // prefix) renders as nothing during streaming. We must NOT commit - // raw JSON as agent prose, but we still record a completed_turn - // with the canceled marker so the user knows the prompt was sent - // and cancelled. +fn cancel_mid_stream_preserves_raw_json_with_canceled_marker() { let mut app = test_app(); submit_test_prompt(&mut app, "kill pid 1234"); - app.turn_observe_chunk( - DEFAULT_TAB_ID, - ChunkKind::Message, - r#"{"recommended_choice":1,"choices":[{"choice":1,"#, - ); + let json = r#"{"recommended_choice":1,"choices":[{"choice":1,"#; + app.turn_observe_chunk(DEFAULT_TAB_ID, ChunkKind::Message, json); app.turn_cancel(DEFAULT_TAB_ID); let tab = app.current_tab(); assert!(tab.turn.is_idle()); @@ -6421,11 +6441,11 @@ fn cancel_mid_stream_records_canceled_marker_even_without_visible_prose() { let committed = &tab.completed_turns[0]; assert_eq!(committed.prompt, "kill pid 1234"); assert!( - !committed + committed .details .iter() - .any(|m| matches!(m, ChatMessage::Agent(_))), - "JSON-only buffer must not be committed as agent prose" + .any(|m| matches!(m, ChatMessage::Agent(text) if text == json)), + "raw JSON must remain visible assistant text" ); assert!( committed @@ -6440,38 +6460,55 @@ fn cancel_mid_stream_records_canceled_marker_even_without_visible_prose() { } #[test] -fn end_pending_blocks_new_prompts_until_message_end() { - // Eager-surface path: user submits → JSON streams → recommendation - // surfaces before AgentMessageEnd. While end_pending=true the UI - // gate must hold. AgentMessageEnd then releases it. +fn raw_json_assistant_text_commits_as_chat_turn() { + let mut app = test_app(); + submit_test_prompt(&mut app, "first"); + let json = r#"{"recommended_choice":1,"choices":[]}"#; + app.turn_observe_chunk(DEFAULT_TAB_ID, ChunkKind::Message, json); + app.turn_close(DEFAULT_TAB_ID); + + let tab = app.current_tab(); + assert!(matches!( + tab.turn, + TurnState::Surfaced { + outcome: TurnOutcome::ChatTurn, + end_pending: false, + .. + } + )); + assert!(tab.completed_turns[0] + .details + .iter() + .any(|message| matches!(message, ChatMessage::Agent(text) if text == json))); +} + +#[test] +fn fenced_json_assistant_text_commits_as_chat_turn() { let mut app = test_app(); submit_test_prompt(&mut app, "first"); - // RecommendationSet shape that survives `validate_recommendation_set`. let json = r#"```json {"recommended_choice":1,"choices":[{"choice":1,"title":"do it","rationale":"r","actions":[{"type":"send","parent":"pane-X","input":"ls"}]}]} ```"#; app.turn_observe_chunk(DEFAULT_TAB_ID, ChunkKind::Message, json); - app.turn_try_eager_surface(DEFAULT_TAB_ID); - let tab = app.current_tab(); assert!( - matches!( - tab.turn, - TurnState::Surfaced { - outcome: TurnOutcome::Recommendation(_), - end_pending: true, - .. - } - ), - "expected eager surface, got {:?}", - tab.turn + matches!(app.current_tab().turn, TurnState::Streaming { .. }), + "assistant text must not surface a recommendation card" ); - assert!( - !tab.turn.accepts_new_prompt(), - "end_pending=true must hold the UI gate" - ); - // AgentMessageEnd flips end_pending=false. + app.turn_close(DEFAULT_TAB_ID); - assert!(app.current_tab().turn.accepts_new_prompt()); + let tab = app.current_tab(); + assert!(matches!( + tab.turn, + TurnState::Surfaced { + outcome: TurnOutcome::ChatTurn, + end_pending: false, + .. + } + )); + assert!(tab.completed_turns[0] + .details + .iter() + .any(|message| matches!(message, ChatMessage::Agent(text) if text == json))); } // ─── card / panel height math ─────────────────────────────────────────── @@ -7486,7 +7523,7 @@ fn direct_proposal_for_an_earlier_prompt_is_stale() { } #[test] -fn direct_proposal_after_eager_text_fallback_surface_is_duplicate() { +fn direct_proposal_preserves_assistant_text_before_and_after_card() { let mut app = test_app(); let sid = "sess-proposal-dup"; stage_proposal_session(&mut app, sid); @@ -7495,9 +7532,12 @@ fn direct_proposal_after_eager_text_fallback_surface_is_duplicate() { {"recommended_choice":1,"choices":[{"choice":1,"title":"do it","rationale":"r","actions":[{"type":"send","parent":"pane-X","input":"ls"}]}]} ```"#; app.turn_observe_chunk(sid, ChunkKind::Message, json); - app.turn_try_eager_surface(sid); + assert!( + matches!(app.session_tab(sid).turn, TurnState::Streaming { .. }), + "assistant JSON must remain ordinary streaming text" + ); - let (decision, _) = evaluate_direct_proposal( + let (decision, proposal_id) = evaluate_direct_proposal( &mut app, sid, 99, @@ -7506,8 +7546,27 @@ fn direct_proposal_after_eager_text_fallback_surface_is_duplicate() { ); assert_eq!( decision.status, - crate::proposal_channel::ProposalValidationStatus::AlreadyConsumed + crate::proposal_channel::ProposalValidationStatus::Accepted ); + assert!(app.commit_terminal_action_proposal(&proposal_id)); + + let trailing = "\ntrailing explanation"; + app.turn_observe_chunk(sid, ChunkKind::Message, trailing); + app.turn_close(sid); + + let completed = app + .session_tab(sid) + .completed_turns + .last() + .expect("direct proposal should commit a completed turn"); + assert!(completed + .details + .iter() + .any(|message| matches!(message, ChatMessage::Agent(text) if text == json))); + assert!(completed + .details + .iter() + .any(|message| matches!(message, ChatMessage::Agent(text) if text == trailing))); } #[test] diff --git a/tools/wta/src/app_turn.rs b/tools/wta/src/app_turn.rs index d4b8b5bbd0..be0132432e 100644 --- a/tools/wta/src/app_turn.rs +++ b/tools/wta/src/app_turn.rs @@ -87,8 +87,7 @@ impl App { /// Observe a streamed chunk. Thought chunks only advance the state /// (Submitted→Streaming with empty buffer); message chunks append to the - /// streaming buffer. Returns true if the buffer changed (so the caller - /// can decide whether to attempt an eager surface). + /// streaming buffer. Returns true if the user-visible buffer changed. pub fn turn_observe_chunk(&mut self, session_id: &str, kind: ChunkKind, text: &str) -> bool { // Stale-autofix check: if the chunk belongs to an autofix turn whose // generation no longer matches the tab's counter, drop it. @@ -148,56 +147,23 @@ impl App { } // Thought chunks during Streaming: no buffer change. (TurnState::Streaming { .. }, ChunkKind::Thought) => false, - // Trailing chunks after the card has surfaced: drop them. + // A direct proposal may complete before the agent emits its final + // message chunks. Keep those chunks visible alongside the card. + (TurnState::Surfaced { .. }, ChunkKind::Message) + if tab.active_direct_proposal_id.is_some() => + { + match tab.messages.last_mut() { + Some(ChatMessage::Agent(existing)) => existing.push_str(text), + _ => tab.messages.push(ChatMessage::Agent(text.to_string())), + } + true + } (TurnState::Surfaced { .. }, _) => false, // Chunks while Idle: shouldn't happen; defensive drop. (TurnState::Idle, _) => false, } } - /// Attempt to parse the streaming buffer and surface a card / chat turn - /// without waiting for `AgentMessageEnd`. No-op if state isn't - /// `Streaming`, buffer hasn't opened a fence yet, or parsing fails. - pub fn turn_try_eager_surface(&mut self, session_id: &str) { - let tab = self.session_tab(session_id); - let TurnState::Streaming { buf, .. } = &tab.turn else { - return; - }; - if !buf.contains("```") { - return; - } - let buf = buf.clone(); - let is_autofix = tab.turn.is_autofix(); - - if is_autofix { - match parse_autofix_response(&buf) { - AutofixDecision::Fix(recommendations) => { - self.turn_surface_fix(session_id, recommendations, "autofix_fix_eager"); - } - AutofixDecision::Explain { title, explanation } => { - self.turn_surface_explain( - session_id, - title, - explanation, - "autofix_explain_eager", - ); - } - AutofixDecision::Ignore => {} - } - } else { - let parsed = parse_recommendation_set(&buf).and_then(|r| { - validate_recommendation_set_for_coordinator_target(&r, self.pane_id.as_deref()) - }); - if let Ok(recommendations) = parsed { - self.turn_surface_recommendation( - session_id, - recommendations, - "selection_ready_eager", - ); - } - } - } - /// Apply the Helper's authoritative turn, schema, origin, and action policy /// checks, then stage an accepted proposal until the direct pipe completes /// its validation handshake and posts `DirectTerminalActionProposalCommit`. @@ -393,10 +359,9 @@ impl App { /// four termination paths: /// /// 1. Stale-autofix discard (newer trigger or Esc cancelled this turn). - /// 2. Eager surface already fired — just release the UI gate. + /// 2. A direct proposal already surfaced — just release the UI gate. /// 3. `Submitted` with no chunks — model returned nothing. - /// 4. `Streaming` with a buffer — final parse via the autofix or - /// planner finalize helper. + /// 4. `Streaming` with a buffer — commit it as assistant text. pub fn turn_close(&mut self, session_id: &str) { // (1) Stale-autofix discard. let current_gen = self.session_tab(session_id).autofix.generation; @@ -414,12 +379,13 @@ impl App { } } - // (2) Eager surface already fired. + // (2) A direct proposal already surfaced. if let TurnState::Surfaced { end_pending: true, .. } = &self.session_tab(session_id).turn { - self.turn_release_end_pending_logged(session_id, "via=eager+end"); + self.turn_commit_trailing_direct_proposal_details(session_id); + self.turn_release_end_pending_logged(session_id, "via=direct+end"); self.turn_clear_agent_progress(session_id); return; } @@ -436,11 +402,12 @@ impl App { _ => return, }; - // (4) Final parse on the streaming buffer. + // (4) Commit assistant text. Typed action cards are surfaced only by + // the direct proposal channel. if is_autofix { - self.turn_close_finalize_autofix(session_id, &buf); + self.turn_close_finalize_autofix_text(session_id, &buf); } else { - self.turn_close_finalize_planner(session_id, buf); + self.turn_close_finalize_chat(session_id, buf); } self.turn_clear_agent_progress(session_id); } @@ -474,115 +441,91 @@ impl App { self.turn_clear_agent_progress(session_id); } - /// Path (4a): autofix Streaming buffer reached `AgentMessageEnd` with - /// no eager surface. Parse and route to Fix / Explain / Ignore. - fn turn_close_finalize_autofix(&mut self, session_id: &str, buf: &str) { - match parse_autofix_response(buf) { - AutofixDecision::Fix(recommendations) => { - self.turn_surface_fix(session_id, recommendations, "autofix_fix"); - self.turn_release_end_pending(session_id); - } - AutofixDecision::Explain { title, explanation } => { - self.turn_surface_explain(session_id, title, explanation, "autofix_explain"); - self.turn_release_end_pending(session_id); - } - AutofixDecision::Ignore => { - let target_tab = self.tab_for_session(session_id); - let pane_id = self.session_tab(session_id).autofix.pane_id.clone(); - self.log_selection_phase_for( - session_id, - "autofix_ignore", - &format!("pane={:?}", pane_id), - ); - if pane_id.is_some() { - self.emit_autofix_state_cleared(&target_tab); - } - let autofix = &mut self.session_tab_mut(session_id).autofix; - autofix.pane_id = None; - autofix.armed_at = None; - let tab = self.session_tab_mut(session_id); - let prompt = tab.turn.prompt().cloned().expect("prompt set"); - // Preserve only what the user actually saw streaming (prose - // or extracted `explanation`) — not the raw JSON wrapper. - // Any tool calls / plans that streamed during the turn are - // included regardless; an empty-buf+prose ignore still - // records them so they don't get stranded on screen. - let visible = ui::chat::user_visible_stream_text(buf).map(|c| c.into_owned()); - let mut details = tab.current_turn_details(); - if let Some(visible) = visible { - details.push(ChatMessage::Agent(visible)); - } - if !details.is_empty() { - tab.completed_turns.push(CompletedTurn { - prompt: t!("chat.autofix_prompt_label").into_owned(), - details, - expanded: true, - trailing_marker: None, - }); - } - // Always clear in-flight UI state on Ignore — even if there - // was nothing to commit, lingering tool-call rows would look - // like an active turn. - tab.messages.clear(); - tab.tool_calls.clear(); - tab.scroll_to_bottom(); - tab.turn = TurnState::Surfaced { - prompt, - outcome: TurnOutcome::Empty, - end_pending: false, - }; - } + /// Path (4a): Autofix assistant text is an explanation, never an action + /// proposal. A non-empty response is surfaced as chat; an empty response + /// clears the pending Autofix state. + fn turn_close_finalize_autofix_text(&mut self, session_id: &str, buf: &str) { + if !buf.trim().is_empty() { + self.turn_surface_autofix_text(session_id, buf.to_string(), "autofix_text"); + self.turn_release_end_pending(session_id); + return; } + + let target_tab = self.tab_for_session(session_id); + let pane_id = self.session_tab(session_id).autofix.pane_id.clone(); + self.log_selection_phase_for( + session_id, + "autofix_empty", + &format!("pane={:?}", pane_id), + ); + if pane_id.is_some() { + self.emit_autofix_state_cleared(&target_tab); + } + let autofix = &mut self.session_tab_mut(session_id).autofix; + autofix.pane_id = None; + autofix.armed_at = None; + let tab = self.session_tab_mut(session_id); + let prompt = tab.turn.prompt().cloned().expect("prompt set"); + let details = tab.current_turn_details(); + if !details.is_empty() { + tab.completed_turns.push(CompletedTurn { + prompt: t!("chat.autofix_prompt_label").into_owned(), + details, + expanded: true, + trailing_marker: None, + }); + } + tab.messages.clear(); + tab.tool_calls.clear(); + tab.scroll_to_bottom(); + tab.turn = TurnState::Surfaced { + prompt, + outcome: TurnOutcome::Empty, + end_pending: false, + }; } - /// Path (4b): non-autofix Streaming buffer. Try `RecommendationSet` - /// parse first; on failure, commit as a chat turn (chat-mode answer). - fn turn_close_finalize_planner(&mut self, session_id: &str, buf: String) { - let parsed = parse_recommendation_set(&buf).and_then(|r| { - validate_recommendation_set_for_coordinator_target(&r, self.pane_id.as_deref()) + /// Path (4b): non-Autofix assistant text is always committed as chat. + fn turn_close_finalize_chat(&mut self, session_id: &str, buf: String) { + self.log_selection_phase_for( + session_id, + "assistant_text", + &format!("response_chars={}", buf.chars().count()), + ); + let tab = self.session_tab_mut(session_id); + let prompt = tab.turn.prompt().cloned().expect("prompt set"); + let mut details = tab.current_turn_details(); + details.push(ChatMessage::Agent(buf)); + tab.completed_turns.push(CompletedTurn { + prompt: prompt.text.clone(), + details, + expanded: true, + trailing_marker: None, }); - match parsed { - Ok(recommendations) => { - self.turn_surface_recommendation(session_id, recommendations, "selection_ready"); - self.turn_release_end_pending(session_id); - } - Err(err) => { - let chars = buf.chars().count(); - let error_text = format!("{:#}", err).replace('\n', " | "); - self.log_selection_phase_for( - session_id, - "selection_parse_failed", - &format!("response_chars={} error={:?}", chars, error_text), - ); - let tab = self.session_tab_mut(session_id); - let prompt = tab.turn.prompt().cloned().expect("prompt set"); - let mut details = tab.current_turn_details(); - details.push(ChatMessage::Agent(buf)); - tab.completed_turns.push(CompletedTurn { - prompt: prompt.text.clone(), - details, - expanded: true, - trailing_marker: None, - }); - tab.messages.clear(); - tab.tool_calls.clear(); - tab.scroll_to_bottom(); - // Route through `turn_release_end_pending` so - // `prompt_complete` fires on this terminal path too. - tab.turn = TurnState::Surfaced { - prompt, - outcome: TurnOutcome::ChatTurn, - end_pending: true, - }; - self.turn_release_end_pending(session_id); - } + tab.messages.clear(); + tab.tool_calls.clear(); + tab.scroll_to_bottom(); + tab.turn = TurnState::Surfaced { + prompt, + outcome: TurnOutcome::ChatTurn, + end_pending: true, + }; + self.turn_release_end_pending(session_id); + } + + fn turn_commit_trailing_direct_proposal_details(&mut self, session_id: &str) { + let tab = self.session_tab_mut(session_id); + let trailing = tab.current_turn_details(); + if let Some(completed) = tab.completed_turns.last_mut() { + completed.details.extend(trailing); } + tab.messages.clear(); + tab.tool_calls.clear(); + tab.scroll_to_bottom(); } - /// Variant of `turn_release_end_pending` with a custom `via=` log tag - /// for the eager-surface path. `turn_release_end_pending` uses - /// `via=end_only`; `via=eager+end` lets `prompt_timing` consumers - /// distinguish. + /// Variant of `turn_release_end_pending` with a custom `via=` log tag for + /// the direct-proposal path. fn turn_release_end_pending_logged(&mut self, session_id: &str, via: &str) { let tab = self.session_tab_mut(session_id); if let TurnState::Surfaced { @@ -710,7 +653,7 @@ impl App { let marker = t!("chat.turn_executed", title = &executed_title).into_owned(); last.trailing_marker = Some(marker); } - // commit pending turn (in case eager surface staged one). + // Preserve the surfaced turn until the matching AgentMessageEnd. tab.turn = TurnState::Surfaced { prompt, outcome: TurnOutcome::Empty, @@ -820,7 +763,7 @@ impl App { self.recompute_chip_override(&target_tab); } - // ── Internal surface helpers (shared between eager and end-of-turn). ── + // ── Internal surface helpers. ── /// Surface a planner-mode recommendation card. fn turn_surface_recommendation( @@ -844,6 +787,14 @@ impl App { let tab = self.session_tab_mut(session_id); let prompt = tab.turn.prompt().cloned().expect("prompt set"); let mut details = tab.current_turn_details(); + if let Some(text) = tab + .turn + .buffer() + .filter(|text| !text.trim().is_empty()) + .map(str::to_string) + { + details.push(ChatMessage::Agent(text)); + } details.push(ChatMessage::Agent(summary)); tab.completed_turns.push(CompletedTurn { prompt: prompt.text.clone(), @@ -926,6 +877,14 @@ impl App { let tab = self.session_tab_mut(session_id); let prompt = tab.turn.prompt().cloned().expect("prompt set"); let mut details = tab.current_turn_details(); + if let Some(text) = tab + .turn + .buffer() + .filter(|text| !text.trim().is_empty()) + .map(str::to_string) + { + details.push(ChatMessage::Agent(text)); + } details.push(ChatMessage::Agent(summary)); tab.completed_turns.push(CompletedTurn { prompt: turn_prompt_label, @@ -952,13 +911,13 @@ impl App { self.recompute_chip_override(&target_tab); } - /// Surface an autofix Explain answer as a chat turn + bottom-bar - /// Suggested indicator. - fn turn_surface_explain( + /// Surface Autofix assistant text as a chat turn plus bottom-bar + /// Suggested indicator. Action cards arrive through the direct proposal + /// channel and do not use this path. + fn turn_surface_autofix_text( &mut self, session_id: &str, - title: String, - explanation: String, + text: String, phase_name: &str, ) { let target_pane_id = self @@ -978,17 +937,14 @@ impl App { self.log_selection_phase_for( session_id, phase_name, - &format!( - "pane={bar_pane:?} title={title:?} chars={}", - explanation.chars().count() - ), + &format!("pane={bar_pane:?} chars={}", text.chars().count()), ); let turn_prompt_label = t!("chat.autofix_prompt_label").into_owned(); { let tab = self.session_tab_mut(session_id); let mut details = tab.current_turn_details(); - details.push(ChatMessage::Agent(explanation)); + details.push(ChatMessage::Agent(text)); // Auto-expand the auto-diagnosed-error turn: when the user // clicks the Suggested pill they came here specifically to // read the explanation, so showing the collapsed preview @@ -1032,8 +988,7 @@ impl App { }; } - /// Flip `end_pending=false` after a final-path surface. Mirrors the - /// `prompt_complete` log used by the eager path. + /// Flip `end_pending=false` after a final-path surface. fn turn_release_end_pending(&mut self, session_id: &str) { let tab = self.session_tab_mut(session_id); if let TurnState::Surfaced { diff --git a/tools/wta/src/coordinator.rs b/tools/wta/src/coordinator.rs index 254e73bcd5..6e9eadb2fe 100644 --- a/tools/wta/src/coordinator.rs +++ b/tools/wta/src/coordinator.rs @@ -159,115 +159,6 @@ fn derive_agent_identity(commandline: &str) -> (String, String) { (id.clone(), id) } -pub fn parse_recommendation_set(text: &str) -> Result { - let json = extract_json_code_block(text) - .or_else(|| extract_first_json_object(text)) - .context("no recommendation JSON block found")?; - - let mut parsed: RecommendationSet = - serde_json::from_str(json).context("failed to parse recommendation JSON")?; - validate_recommendation_set(&parsed)?; - parsed.choices.sort_by_key(|c| c.choice); - Ok(parsed) -} - -/// The result of parsing an autofix response. -#[derive(Debug, Clone)] -pub enum AutofixDecision { - /// AI found a single-command fix. - Fix(RecommendationSet), - /// AI cannot auto-fix but has a useful explanation/suggestion. The caller - /// should surface `explanation` in the agent pane chat history and tell - /// the bottom bar to show a "Suggestion ready — open agent pane" indicator. - Explain { title: String, explanation: String }, - /// AI decided no fix is appropriate; caller should silently clear state. - /// The `explain` action makes this rare — Ignore is now a fail-safe for - /// malformed responses or empty explanations. - Ignore, -} - -/// Parse a response from the minimal autofix prompt. -/// -/// Expected formats: -/// {"action": "fix", "title": "...", "command": "...", "rationale": "..."} -/// {"action": "explain", "title": "...", "explanation": "..."} -/// {"action": "ignore"} // legacy fallback -/// -/// Returns `AutofixDecision::Ignore` for unrecognised JSON or missing required -/// fields (fail-safe: never leave a stale Pending bar). -pub fn parse_autofix_response(text: &str) -> AutofixDecision { - let json = match extract_json_code_block(text).or_else(|| extract_first_json_object(text)) { - Some(j) => j, - None => { - tracing::warn!(target: "autofix", "no JSON in autofix response, ignoring"); - return AutofixDecision::Ignore; - } - }; - - let value: serde_json::Value = match serde_json::from_str(json) { - Ok(v) => v, - Err(e) => { - tracing::warn!(target: "autofix", "failed to parse autofix JSON: {e}, ignoring"); - return AutofixDecision::Ignore; - } - }; - - match value.get("action").and_then(|v| v.as_str()) { - Some("fix") => { - let command = match value.get("command").and_then(|v| v.as_str()) { - Some(c) if !c.trim().is_empty() => c.to_string(), - _ => { - tracing::warn!(target: "autofix", "fix response missing 'command', ignoring"); - return AutofixDecision::Ignore; - } - }; - let title = value - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("Fix") - .to_string(); - let rationale = value - .get("rationale") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - AutofixDecision::Fix(RecommendationSet { - recommended_choice: Some(1), - choices: vec![RecommendationChoice { - choice: 1, - title, - rationale, - actions: vec![RecommendedAction::Send { - parent: String::new(), - input: command, - }], - }], - }) - } - Some("explain") => { - let explanation = match value.get("explanation").and_then(|v| v.as_str()) { - Some(e) if !e.trim().is_empty() => e.to_string(), - _ => { - tracing::warn!(target: "autofix", "explain response missing 'explanation', ignoring"); - return AutofixDecision::Ignore; - } - }; - let title = value - .get("title") - .and_then(|v| v.as_str()) - .filter(|t| !t.trim().is_empty()) - .unwrap_or("Suggestion") - .to_string(); - AutofixDecision::Explain { title, explanation } - } - Some("ignore") | None => AutofixDecision::Ignore, - Some(other) => { - tracing::warn!(target: "autofix", "unknown autofix action {other:?}, ignoring"); - AutofixDecision::Ignore - } - } -} - /// Filter out choices that target the coordinator's own pane. /// Returns the filtered set. If all choices are removed, returns an error. pub fn validate_recommendation_set_for_coordinator_target( @@ -1597,62 +1488,6 @@ fn coordinator_log(msg: &str) { tracing::debug!(target: "coordinator", "{}", msg); } -fn extract_json_code_block(text: &str) -> Option<&str> { - let start = text.find("```json").or_else(|| text.find("```JSON"))?; - let mut body = &text[start + 7..]; - if let Some(b) = body.strip_prefix('\r') { - body = b; - } - if let Some(b) = body.strip_prefix('\n') { - body = b; - } - extract_balanced_json_object(body) -} - -fn extract_first_json_object(text: &str) -> Option<&str> { - extract_balanced_json_object(text) -} - -/// Returns the substring spanning the first balanced JSON object in `text`. -/// -/// Walks the input as bytes, tracking string state and brace depth so that -/// braces or fence markers (```) inside JSON string values do not terminate -/// the scan early. Byte indexing is safe because we only land on ASCII -/// characters (`{`, `}`, `"`, `\`). -fn extract_balanced_json_object(text: &str) -> Option<&str> { - let bytes = text.as_bytes(); - let start = bytes.iter().position(|&b| b == b'{')?; - - let mut depth: i32 = 0; - let mut in_string = false; - let mut escape = false; - for j in start..bytes.len() { - let c = bytes[j]; - if in_string { - if escape { - escape = false; - } else if c == b'\\' { - escape = true; - } else if c == b'"' { - in_string = false; - } - } else { - match c { - b'"' => in_string = true, - b'{' => depth += 1, - b'}' => { - depth -= 1; - if depth == 0 { - return Some(text[start..=j].trim()); - } - } - _ => {} - } - } - } - None -} - #[cfg(test)] mod tests { use super::{ @@ -1660,15 +1495,31 @@ mod tests { build_pwsh_base64_launch, build_shell_multiline_delegate_launch, build_windows_powershell_base64_launch, build_wsl_delegate_commandline, default_delegate_agent_runtimes, escape_for_intermediate_shell, - is_direct_known_agent_command, parse_autofix_response, parse_recommendation_set, - pinned_session_id_for_runtime, pwsh_available, resolve_agent_profile, - resolve_created_pane_id, sanitize_windows_agent_cwd, - validate_recommendation_set_for_coordinator_target, AutofixDecision, - DelegateAgentRuntime, DelegatePromptDelivery, OpenTarget, RecommendedAction, + is_direct_known_agent_command, pinned_session_id_for_runtime, pwsh_available, + resolve_agent_profile, resolve_created_pane_id, sanitize_windows_agent_cwd, + validate_recommendation_set, validate_recommendation_set_for_coordinator_target, + DelegateAgentRuntime, DelegatePromptDelivery, OpenTarget, RecommendationChoice, + RecommendationSet, RecommendedAction, }; use serde_json::json; use std::os::windows::process::CommandExt; + fn choice(number: usize, action: RecommendedAction) -> RecommendationChoice { + RecommendationChoice { + choice: number, + title: format!("Choice {number}"), + rationale: String::new(), + actions: vec![action], + } + } + + fn recommendation_set(choices: Vec) -> RecommendationSet { + RecommendationSet { + recommended_choice: Some(1), + choices, + } + } + #[test] fn default_delegate_runtime_uses_cli_default_model() { let runtime = default_delegate_agent_runtimes(None, None, None) @@ -2002,231 +1853,6 @@ mod tests { ); } - #[test] - fn parse_recommendations_accepts_open_and_send_tab_actions_without_parent() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Open a shell tab", - "actions": [ - { - "type": "open_and_send", - "target": "tab", - "input": "pwd", - "cwd": "C:\\repo", - "title": "Repo shell" - } - ] - }, - { - "choice": 2, - "title": "Delegate in a new tab", - "actions": [ - { - "type": "open_and_send", - "target": "tab", - "input": "Inspect the repo", - "agent": "copilot", - "cwd": "C:\\repo", - "title": "Copilot delegate" - } - ] - }, - { - "choice": 3, - "title": "Run locally", - "actions": [ - { - "type": "send", - "parent": "1", - "input": "pwd" - } - ] - } - ] -} -```"#; - - let parsed = parse_recommendation_set(text).expect("recommendation set should parse"); - - assert!(matches!( - parsed.choices[0].actions[0], - RecommendedAction::OpenAndSend { - target: OpenTarget::Tab, - .. - } - )); - assert!(matches!( - parsed.choices[1].actions[0], - RecommendedAction::OpenAndSend { - target: OpenTarget::Tab, - .. - } - )); - } - - #[test] - fn parses_open_action_without_input() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Open an empty tab", - "actions": [ - { - "type": "open", - "target": "tab", - "cwd": "C:\\repo" - } - ] - }, - { - "choice": 2, - "title": "Split a panel here", - "actions": [ - { - "type": "open", - "target": "panel", - "parent": "12" - } - ] - } - ] -} -```"#; - - let parsed = parse_recommendation_set(text).expect("open recommendation should parse"); - assert!(matches!( - parsed.choices[0].actions[0], - RecommendedAction::Open { - target: OpenTarget::Tab, - .. - } - )); - assert!(matches!( - parsed.choices[1].actions[0], - RecommendedAction::Open { - target: OpenTarget::Panel, - .. - } - )); - } - - #[test] - fn parses_open_panel_with_direction() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Split right", - "actions": [ - { - "type": "open", - "target": "panel", - "parent": "12", - "direction": "right" - } - ] - } - ] -} -```"#; - - let parsed = parse_recommendation_set(text).expect("open with direction should parse"); - match &parsed.choices[0].actions[0] { - RecommendedAction::Open { direction, .. } => { - assert_eq!(direction.as_deref(), Some("right")); - } - other => panic!("expected Open, got {other:?}"), - } - } - - // ── profile inheritance (PR #366) ────────────────────────────────────── - - #[test] - fn open_action_defaults_profile_to_none_when_absent() { - // The `profile` field is optional; an LLM emitting the pre-#366 schema - // (no `profile` key) must still parse, with profile == None. - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Open a tab", - "actions": [ { "type": "open", "target": "tab" } ] - } - ] -} -```"#; - let parsed = parse_recommendation_set(text).expect("open without profile should parse"); - match &parsed.choices[0].actions[0] { - RecommendedAction::Open { profile, .. } => assert_eq!(profile.as_deref(), None), - other => panic!("expected Open, got {other:?}"), - } - } - - #[test] - fn open_action_parses_explicit_profile() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Open Ubuntu tab", - "actions": [ { "type": "open", "target": "tab", "profile": "Ubuntu" } ] - } - ] -} -```"#; - let parsed = parse_recommendation_set(text).expect("open with profile should parse"); - match &parsed.choices[0].actions[0] { - RecommendedAction::Open { profile, .. } => { - assert_eq!(profile.as_deref(), Some("Ubuntu")); - } - other => panic!("expected Open, got {other:?}"), - } - } - - #[test] - fn open_and_send_parses_explicit_profile() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Open Ubuntu tab and run", - "actions": [ - { - "type": "open_and_send", - "target": "tab", - "input": "ls -la", - "profile": "Ubuntu" - } - ] - } - ] -} -```"#; - let parsed = parse_recommendation_set(text).expect("open_and_send should parse"); - match &parsed.choices[0].actions[0] { - RecommendedAction::OpenAndSend { profile, input, .. } => { - assert_eq!(profile.as_deref(), Some("Ubuntu")); - assert_eq!(input, "ls -la"); - } - other => panic!("expected OpenAndSend, got {other:?}"), - } - } - #[test] fn resolve_profile_prefers_explicit_over_active_pane() { let active = json!({ "profile": "PowerShell" }); @@ -2364,123 +1990,87 @@ mod tests { #[test] fn rejects_open_with_invalid_direction() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Split sideways", - "actions": [ - { - "type": "open", - "target": "panel", - "parent": "12", - "direction": "sideways" - } - ] - } - ] -} -```"#; + let set = recommendation_set(vec![choice( + 1, + RecommendedAction::Open { + target: OpenTarget::Panel, + parent: Some("12".into()), + cwd: None, + title: None, + direction: Some("sideways".into()), + profile: None, + }, + )]); - assert!(parse_recommendation_set(text).is_err()); + assert!(validate_recommendation_set(&set).is_err()); } #[test] fn rejects_open_tab_with_direction() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Open tab right?", - "actions": [ - { - "type": "open", - "target": "tab", - "direction": "right" - } - ] - } - ] -} -```"#; + let set = recommendation_set(vec![choice( + 1, + RecommendedAction::Open { + target: OpenTarget::Tab, + parent: None, + cwd: None, + title: None, + direction: Some("right".into()), + profile: None, + }, + )]); - assert!(parse_recommendation_set(text).is_err()); + assert!(validate_recommendation_set(&set).is_err()); } #[test] fn rejects_open_panel_without_parent() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Open a panel", - "actions": [ - { - "type": "open", - "target": "panel" - } - ] - } - ] -} -```"#; + let set = recommendation_set(vec![choice( + 1, + RecommendedAction::Open { + target: OpenTarget::Panel, + parent: None, + cwd: None, + title: None, + direction: None, + profile: None, + }, + )]); - assert!(parse_recommendation_set(text).is_err()); + assert!(validate_recommendation_set(&set).is_err()); } #[test] fn rejects_send_to_current_coordinator_target() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Reply in the current pane", - "actions": [ - { - "type": "send", - "parent": "14", - "input": "Continue in this pane" - } - ] - }, - { - "choice": 2, - "title": "Run locally", - "actions": [ - { - "type": "send", - "parent": "1", - "input": "pwd" - } - ] - }, - { - "choice": 3, - "title": "Delegate", - "actions": [ - { - "type": "open_and_send", - "target": "tab", - "input": "Inspect the repo", - "agent": "copilot", - "cwd": "C:\\repo" - } - ] - } - ] -} -```"#; - - let parsed = parse_recommendation_set(text).expect("recommendation set should parse"); - let filtered = validate_recommendation_set_for_coordinator_target(&parsed, Some("14")) + let set = recommendation_set(vec![ + choice( + 1, + RecommendedAction::Send { + parent: "14".into(), + input: "Continue in this pane".into(), + }, + ), + choice( + 2, + RecommendedAction::Send { + parent: "1".into(), + input: "pwd".into(), + }, + ), + choice( + 3, + RecommendedAction::OpenAndSend { + target: OpenTarget::Tab, + parent: None, + input: "Inspect the repo".into(), + agent: Some("copilot".into()), + cwd: Some(r"C:\repo".into()), + title: None, + direction: None, + profile: None, + }, + ), + ]); + let filtered = validate_recommendation_set_for_coordinator_target(&set, Some("14")) .expect("should filter instead of rejecting"); // Choice 1 (self-targeted) should be removed, choices 2 and 3 remain. @@ -2493,177 +2083,43 @@ mod tests { #[test] fn rejects_open_and_send_panel_without_parent() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Split a panel", - "actions": [ - { - "type": "open_and_send", - "target": "panel", - "input": "pwd" - } - ] - }, - { - "choice": 2, - "title": "Run locally", - "actions": [ - { - "type": "send", - "parent": "1", - "input": "pwd" - } - ] - }, - { - "choice": 3, - "title": "Open a tab", - "actions": [ - { - "type": "open_and_send", - "target": "tab", - "input": "pwd" - } - ] - } - ] -} -```"#; + let set = recommendation_set(vec![choice( + 1, + RecommendedAction::OpenAndSend { + target: OpenTarget::Panel, + parent: None, + input: "pwd".into(), + agent: None, + cwd: None, + title: None, + direction: None, + profile: None, + }, + )]); let err = - parse_recommendation_set(text).expect_err("panel without parent should be rejected"); + validate_recommendation_set(&set).expect_err("panel without parent should be rejected"); assert!(format!("{err:#}") .contains("field 'parent' is required for open_and_send target panel")); } #[test] - fn parse_recommendations_accepts_single_choice() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Run locally", - "actions": [ - { - "type": "send", - "parent": "1", - "input": "pwd" - } - ] - } - ] -} -```"#; - - let parsed = - parse_recommendation_set(text).expect("single-choice recommendation should parse"); - assert_eq!(parsed.choices.len(), 1); - assert_eq!(parsed.choices[0].choice, 1); - } - - #[test] - fn parse_recommendations_handles_backticks_inside_string_values() { - // Regression: a JSON string value that contains a triple-backtick fence - // marker (e.g. an `input` prompt asking another agent to emit a - // ```mermaid block) used to terminate the ```json fence early, leaving - // the JSON truncated and unparseable. - let text = r#"Sure, here's the plan. - -```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "Delegate to Copilot", - "actions": [ - { - "type": "open_and_send", - "target": "tab", - "agent": "copilot", - "cwd": "C:\\repo", - "input": "Produce a Mermaid flowchart (```mermaid) showing the main flow.", - "title": "Explore project" - } - ] - } - ] -} -```"#; - - let parsed = parse_recommendation_set(text) - .expect("recommendation with backticks in string should parse"); - assert_eq!(parsed.choices.len(), 1); - match &parsed.choices[0].actions[0] { - RecommendedAction::OpenAndSend { input, .. } => { - assert!(input.contains("```mermaid")); - } - other => panic!("expected OpenAndSend, got {other:?}"), - } - } - - #[test] - fn parse_recommendations_rejects_four_choices() { - let text = r#"```json -{ - "recommended_choice": 1, - "choices": [ - { - "choice": 1, - "title": "One", - "actions": [ - { - "type": "send", - "parent": "1", - "input": "pwd" - } - ] - }, - { - "choice": 2, - "title": "Two", - "actions": [ - { - "type": "send", - "parent": "1", - "input": "pwd" - } - ] - }, - { - "choice": 3, - "title": "Three", - "actions": [ - { - "type": "send", - "parent": "1", - "input": "pwd" - } - ] - }, - { - "choice": 4, - "title": "Four", - "actions": [ - { - "type": "send", - "parent": "1", - "input": "pwd" - } - ] - } - ] -} -```"#; + fn validate_recommendations_rejects_four_choices() { + let choices = (1..=4) + .map(|number| { + choice( + number, + RecommendedAction::Send { + parent: "1".into(), + input: "pwd".into(), + }, + ) + }) + .collect(); + let set = recommendation_set(choices); let err = - parse_recommendation_set(text).expect_err("four-choice recommendation should fail"); + validate_recommendation_set(&set).expect_err("four-choice recommendation should fail"); assert!(format!("{err:#}").contains("expected 1 to 3 choices")); } @@ -2686,54 +2142,6 @@ mod tests { assert!(format!("{err:#}").contains("create_tab response missing pane_id")); } - #[test] - fn parse_autofix_explain_with_title_and_explanation() { - let text = r#"```json -{"action": "explain", "title": "claude is not installed", - "explanation": "The `claude` command isn't on PATH.\n\nInstall with `npm install -g @anthropic-ai/claude-code`."} -```"#; - match parse_autofix_response(text) { - AutofixDecision::Explain { title, explanation } => { - assert_eq!(title, "claude is not installed"); - assert!(explanation.contains("npm install")); - } - other => panic!("expected Explain, got {other:?}"), - } - } - - #[test] - fn parse_autofix_explain_falls_back_to_ignore_when_explanation_empty() { - let text = r#"```json -{"action": "explain", "title": "Something", "explanation": " "} -```"#; - assert!(matches!( - parse_autofix_response(text), - AutofixDecision::Ignore - )); - } - - #[test] - fn parse_autofix_explain_uses_default_title_when_missing() { - let text = r#"```json -{"action": "explain", "explanation": "Some useful suggestion goes here."} -```"#; - match parse_autofix_response(text) { - AutofixDecision::Explain { title, .. } => assert_eq!(title, "Suggestion"), - other => panic!("expected Explain with default title, got {other:?}"), - } - } - - #[test] - fn parse_autofix_legacy_ignore_still_supported() { - let text = r#"```json -{"action": "ignore"} -```"#; - assert!(matches!( - parse_autofix_response(text), - AutofixDecision::Ignore - )); - } - // ── #404: WSL delegate inline base64 ───────────────────────────────── fn base64_runtime(commandline: &str) -> DelegateAgentRuntime { diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index f12a9552cc..f44a2a0280 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -3470,7 +3470,6 @@ async fn run_acp_app( let owner_tab = cli.owner_tab_id.clone(); let initial_load_sid = cli.initial_load_session_id.clone(); let proposal_channels_for_pipe = Arc::clone(&proposal_channels); - let direct_proposals_enabled = canonical_agent_id == "copilot"; tokio::task::spawn_local(async move { if let Err(e) = protocol::acp::client::run_acp_client_over_pipe( pipe_name, @@ -3494,7 +3493,6 @@ async fn run_acp_app( wt_connected, false, // post_login_reconnect: first connection, no authenticate needed proposal_channels_for_pipe, - direct_proposals_enabled, ) .await { diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index f7bc61dabf..b3bb94f196 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -1531,7 +1531,7 @@ struct ClientState { shell_mgr: Arc, prompt_timing: Arc, proposal_channels: Arc, - direct_proposals_enabled: bool, + hidden_tool_calls: Mutex>, } /// Our Client trait implementation — handles incoming agent requests and notifications. @@ -1551,7 +1551,9 @@ fn session_update_kind(update: &acp::schema::v1::SessionUpdate) -> &'static str } } -fn copilot_permission_command(args: &acp::schema::v1::RequestPermissionRequest) -> Option<&str> { +fn canonical_proposal_permission_command( + args: &acp::schema::v1::RequestPermissionRequest, +) -> Option<&str> { if args.tool_call.fields.kind != Some(acp::schema::v1::ToolKind::Execute) { return None; } @@ -1584,6 +1586,26 @@ fn looks_like_proposal_command(command: &str) -> bool { } impl WtaClient { + fn hide_proposal_tool_call(&self, session_id: &str, tool_call_id: &str) { + self.state + .hidden_tool_calls + .lock() + .unwrap() + .insert((session_id.to_string(), tool_call_id.to_string())); + let _ = self.state.event_tx.send(AppEvent::HideToolCall { + session_id: session_id.to_string(), + id: tool_call_id.to_string(), + }); + } + + fn tool_call_is_hidden(&self, session_id: &str, tool_call_id: &str) -> bool { + self.state + .hidden_tool_calls + .lock() + .unwrap() + .contains(&(session_id.to_string(), tool_call_id.to_string())) + } + async fn request_permission( &self, args: acp::schema::v1::RequestPermissionRequest, @@ -1595,6 +1617,11 @@ impl WtaClient { args.tool_call.fields.title )); let session_id = args.session_id.0.to_string(); + let tool_call_id = args.tool_call.tool_call_id.to_string(); + let proposal_command_candidate = proposal_permission_command_candidate(&args); + if proposal_command_candidate.is_some_and(looks_like_proposal_command) { + self.hide_proposal_tool_call(&session_id, &tool_call_id); + } let description = args .tool_call .fields @@ -1605,69 +1632,67 @@ impl WtaClient { .prompt_timing .permission_requested(&session_id, &description); - if self.state.direct_proposals_enabled { - if let Some(command) = copilot_permission_command(&args) { - match crate::proposal_invocation::parse(command) { - Ok(invocation) => { - let Some(option) = args.options.iter().find(|option| { - option.kind == acp::schema::v1::PermissionOptionKind::AllowOnce - }) else { - self.state - .prompt_timing - .permission_resolved(&session_id, "proposal_cancelled"); - return Ok(acp::schema::v1::RequestPermissionResponse::new( - acp::schema::v1::RequestPermissionOutcome::Cancelled, - )); - }; - let arm_result = self.state.proposal_channels.arm( - &session_id, - &invocation.channel, - invocation.payload.as_bytes(), - ); - tracing::info!( - target: "proposal_permission", - session_id = %session_id, - armed = arm_result.is_ok(), - status = ?arm_result.as_ref().err().map(|failure| failure.status), - "silently resolving canonical proposal permission" - ); + if let Some(command) = canonical_proposal_permission_command(&args) { + match crate::proposal_invocation::parse(command) { + Ok(invocation) => { + let Some(option) = args.options.iter().find(|option| { + option.kind == acp::schema::v1::PermissionOptionKind::AllowOnce + }) else { self.state .prompt_timing - .permission_resolved(&session_id, "proposal_allow_once"); - return Ok(acp::schema::v1::RequestPermissionResponse::new( - acp::schema::v1::RequestPermissionOutcome::Selected( - acp::schema::v1::SelectedPermissionOutcome::new( - option.option_id.clone(), - ), - ), - )); - } - Err(reason) if looks_like_proposal_command(command) => { - tracing::info!( - target: "proposal_permission", - session_id = %session_id, - reason, - "silently cancelled non-canonical proposal command" - ); - self.state - .prompt_timing - .permission_resolved(&session_id, "proposal_noncanonical"); + .permission_resolved(&session_id, "proposal_cancelled"); return Ok(acp::schema::v1::RequestPermissionResponse::new( acp::schema::v1::RequestPermissionOutcome::Cancelled, )); - } - Err(_) => {} + }; + let arm_result = self.state.proposal_channels.arm( + &session_id, + &invocation.channel, + invocation.payload.as_bytes(), + ); + tracing::info!( + target: "proposal_permission", + session_id = %session_id, + armed = arm_result.is_ok(), + status = ?arm_result.as_ref().err().map(|failure| failure.status), + "silently resolving canonical proposal permission" + ); + self.state + .prompt_timing + .permission_resolved(&session_id, "proposal_allow_once"); + return Ok(acp::schema::v1::RequestPermissionResponse::new( + acp::schema::v1::RequestPermissionOutcome::Selected( + acp::schema::v1::SelectedPermissionOutcome::new( + option.option_id.clone(), + ), + ), + )); } - } else if proposal_permission_command_candidate(&args) - .is_some_and(looks_like_proposal_command) - { - self.state - .prompt_timing - .permission_resolved(&session_id, "proposal_noncanonical"); - return Ok(acp::schema::v1::RequestPermissionResponse::new( - acp::schema::v1::RequestPermissionOutcome::Cancelled, - )); + Err(reason) if looks_like_proposal_command(command) => { + tracing::info!( + target: "proposal_permission", + session_id = %session_id, + reason, + "silently cancelled non-canonical proposal command" + ); + self.state + .prompt_timing + .permission_resolved(&session_id, "proposal_noncanonical"); + return Ok(acp::schema::v1::RequestPermissionResponse::new( + acp::schema::v1::RequestPermissionOutcome::Cancelled, + )); + } + Err(_) => {} } + } else if proposal_permission_command_candidate(&args) + .is_some_and(looks_like_proposal_command) + { + self.state + .prompt_timing + .permission_resolved(&session_id, "proposal_noncanonical"); + return Ok(acp::schema::v1::RequestPermissionResponse::new( + acp::schema::v1::RequestPermissionOutcome::Cancelled, + )); } let options: Vec = args @@ -1760,17 +1785,25 @@ impl WtaClient { } } acp::schema::v1::SessionUpdate::ToolCall(tool_call) => { + let tool_call_id = tool_call.tool_call_id.to_string(); + if self.tool_call_is_hidden(&sid, &tool_call_id) { + return Ok(()); + } self.state .prompt_timing .observe_first_tool_call(&sid, Some(tool_call.title.as_str())); let _ = self.state.event_tx.send(AppEvent::ToolCall { session_id: sid, - id: tool_call.tool_call_id.to_string(), + id: tool_call_id, title: tool_call.title.clone(), status: format!("{:?}", tool_call.status), }); } acp::schema::v1::SessionUpdate::ToolCallUpdate(update) => { + let tool_call_id = update.tool_call_id.to_string(); + if self.tool_call_is_hidden(&sid, &tool_call_id) { + return Ok(()); + } if let Some(status) = &update.fields.status { // Failed updates frequently carry a `raw_output.message` // explaining *why* (e.g. Copilot in non-interactive ACP @@ -1793,7 +1826,7 @@ impl WtaClient { }; let _ = self.state.event_tx.send(AppEvent::ToolCallUpdate { session_id: sid, - id: update.tool_call_id.to_string(), + id: tool_call_id, status: status_str, }); } @@ -2237,7 +2270,6 @@ pub async fn run_acp_client_over_pipe( wt_connected: bool, post_login_reconnect: bool, proposal_channels: Arc, - direct_proposals_enabled: bool, ) -> Result<()> { let startup_probe = StartupProbe::new(); startup_probe.log(&format!( @@ -2339,7 +2371,7 @@ pub async fn run_acp_client_over_pipe( shell_mgr: shell_mgr.clone(), prompt_timing: prompt_timing.clone(), proposal_channels: Arc::clone(&proposal_channels), - direct_proposals_enabled, + hidden_tool_calls: Mutex::new(HashSet::new()), }); let client = WtaClient { @@ -2991,7 +3023,6 @@ pub async fn run_acp_client_over_pipe( wt_connected, is_agent_pane, &proposal_channels, - direct_proposals_enabled, ); } else => break, @@ -3654,7 +3685,6 @@ fn dispatch_prompt_with_proposals( wt_connected: bool, is_agent_pane: bool, proposal_channels: &Arc, - direct_proposals_enabled: bool, ) { let tab_key = prompt .pane_context @@ -3697,7 +3727,6 @@ fn dispatch_prompt_with_proposals( wt_connected, is_agent_pane, proposal_channels_task, - direct_proposals_enabled, )); } @@ -3728,7 +3757,6 @@ fn dispatch_prompt( wt_connected, is_agent_pane, &Arc::new(crate::proposal_channel::ProposalChannelManager::new()), - false, ); } @@ -3750,7 +3778,6 @@ async fn dispatch_prompt_body( wt_connected: bool, is_agent_pane: bool, proposal_channels: Arc, - direct_proposals_enabled: bool, ) { // Resolve (or lazily create) the ACP session for this tab. let prompt_session_id = { @@ -3837,33 +3864,31 @@ async fn dispatch_prompt_body( prompt.pane_context.as_ref(), ) .await; - if direct_proposals_enabled { - match proposal_channels.issue( - prompt_session_id_str.clone(), - prompt.id, - active_target.clone(), - prompt.is_autofix, - ) { - Ok(channel) => { - text.push_str(&format!( - "\n\n[intellterm.wta proposal]\n\ - To present terminal actions, run exactly one command in this form:\n\ - & \"$env:WTA_CLI_PATH\" propose-terminal-actions --channel {channel} \ - --payload-json ''\n\ - Replace only . Do not use stdin, a pipeline, a here-string, \ - redirection, a temporary file, or another executable spelling. Read both \ - JSON response lines: validation is immediate; final reports the user's \ - confirm or cancel decision." - )); - } - Err(error) => { - tracing::warn!( - target: "proposal_channel", - status = ?error.status, - reason = error.reason, - "failed to issue proposal channel for prompt" - ); - } + match proposal_channels.issue( + prompt_session_id_str.clone(), + prompt.id, + active_target.clone(), + prompt.is_autofix, + ) { + Ok(channel) => { + text.push_str(&format!( + "\n\n[intellterm.wta proposal]\n\ + To present terminal actions, run exactly one command in this form:\n\ + & \"$env:WTA_CLI_PATH\" propose-terminal-actions --channel {channel} \ + --payload-json ''\n\ + Replace only . Do not use stdin, a pipeline, a here-string, \ + redirection, a temporary file, or another executable spelling. Read both \ + JSON response lines: validation is immediate; final reports the user's \ + confirm or cancel decision." + )); + } + Err(error) => { + tracing::warn!( + target: "proposal_channel", + status = ?error.status, + reason = error.reason, + "failed to issue proposal channel for prompt" + ); } } // A manual `/fix` resolved its working pane in build_prompt_text (it had no @@ -3987,7 +4012,8 @@ mod tests { use crate::app::AppEvent; use crate::protocol::acp::failure::{AgentFailure, HandshakeStage}; use crate::shell::ShellManager; - use std::sync::Arc; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; /// `shell_from_active` resolves our own pid to a real exe name (the test @@ -4842,7 +4868,7 @@ mod tests { shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(PromptTimingState::default()), proposal_channels: Arc::clone(&manager), - direct_proposals_enabled: true, + hidden_tool_calls: Mutex::new(HashSet::new()), }), }; @@ -4854,10 +4880,11 @@ mod tests { response.outcome, acp::schema::v1::RequestPermissionOutcome::Selected(_) )); - assert!( - event_rx.try_recv().is_err(), - "canonical proposal permission must not reach the TUI" - ); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::HideToolCall { session_id, id }) + if session_id == "proposal-session" && id == "proposal-tool" + )); assert!(manager .begin_validation(&channel, payload.as_bytes()) .is_ok()); @@ -4879,7 +4906,7 @@ mod tests { shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(PromptTimingState::default()), proposal_channels: manager, - direct_proposals_enabled: true, + hidden_tool_calls: Mutex::new(HashSet::new()), }), }; @@ -4891,7 +4918,10 @@ mod tests { response.outcome, acp::schema::v1::RequestPermissionOutcome::Cancelled )); - assert!(event_rx.try_recv().is_err()); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::HideToolCall { .. }) + )); } #[tokio::test] @@ -4911,7 +4941,7 @@ mod tests { shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(PromptTimingState::default()), proposal_channels: Arc::clone(&manager), - direct_proposals_enabled: true, + hidden_tool_calls: Mutex::new(HashSet::new()), }), }; @@ -4927,7 +4957,10 @@ mod tests { .status, crate::proposal_channel::ProposalValidationStatus::NotArmed ); - assert!(event_rx.try_recv().is_err()); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::HideToolCall { .. }) + )); } #[tokio::test] @@ -4955,7 +4988,7 @@ mod tests { shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(PromptTimingState::default()), proposal_channels: Arc::clone(&manager), - direct_proposals_enabled: true, + hidden_tool_calls: Mutex::new(HashSet::new()), }), }; @@ -4971,7 +5004,10 @@ mod tests { .status, crate::proposal_channel::ProposalValidationStatus::NotArmed ); - assert!(event_rx.try_recv().is_err()); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::HideToolCall { .. }) + )); } // ── json_str_or_num ───────────────────────────────────────────────────── @@ -5016,7 +5052,8 @@ mod tests { use crate::shell::ShellManager; use agent_client_protocol::{self as acp}; use std::path::PathBuf; - use std::sync::Arc; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; fn make_client() -> (WtaClient, mpsc::UnboundedReceiver) { @@ -5026,7 +5063,7 @@ mod tests { shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(super::super::PromptTimingState::default()), proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), - direct_proposals_enabled: false, + hidden_tool_calls: Mutex::new(HashSet::new()), }); (WtaClient { state }, rx) } diff --git a/tools/wta/src/protocol/acp/mock_agent_tests.rs b/tools/wta/src/protocol/acp/mock_agent_tests.rs index 091c04610c..ac50275a1a 100644 --- a/tools/wta/src/protocol/acp/mock_agent_tests.rs +++ b/tools/wta/src/protocol/acp/mock_agent_tests.rs @@ -303,7 +303,7 @@ fn connect_with( shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(PromptTimingState::default()), proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), - direct_proposals_enabled: false, + hidden_tool_calls: Mutex::new(HashSet::new()), }); let wta = WtaClient { state }; @@ -557,7 +557,7 @@ fn connect_for_dispatch(behavior: MockBehavior) -> DispatchHarness { shell_mgr: shell_mgr.clone(), prompt_timing: prompt_timing.clone(), proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), - direct_proposals_enabled: false, + hidden_tool_calls: Mutex::new(HashSet::new()), }); let wta = WtaClient { state }; @@ -1554,7 +1554,7 @@ fn bare_client() -> (WtaClient, mpsc::UnboundedReceiver) { shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(PromptTimingState::default()), proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), - direct_proposals_enabled: false, + hidden_tool_calls: Mutex::new(HashSet::new()), }); (WtaClient { state }, event_rx) } @@ -1850,5 +1850,3 @@ async fn request_permission_cancelled_when_responder_dropped() { }) .await; } - - diff --git a/tools/wta/src/ui/chat.rs b/tools/wta/src/ui/chat.rs index 0d7cd692fd..5c068f6fb3 100644 --- a/tools/wta/src/ui/chat.rs +++ b/tools/wta/src/ui/chat.rs @@ -21,10 +21,7 @@ pub fn estimated_block_height(app: &App, area_width: u16) -> u16 { let tab = app.current_tab(); let wrap_width = (area_width as usize).max(1); // Fetch once and reuse below for both the reveal-catchup check and the - // pending-height calc. `pending_render_text` re-parses the streaming - // buffer on every call (and allocates on the JSON-wrapper path via - // `extract_json_string_field`), so calling it twice per frame here would - // be a redundant, measurable cost on the render hot path. + // pending-height calculation. let pending_text = pending_render_text(tab); // Reserve the row only when the shimmer will actually render; mirrors @@ -388,128 +385,11 @@ fn is_reveal_catching_up(tab: &crate::app::TabSession) -> bool { } } -/// Incrementally extracts a JSON string field's decoded value from a -/// possibly-truncated text. Handles `\"`, `\\`, `\n`, `\t`, `\uXXXX` and -/// UTF-16 surrogate pairs (e.g. emoji). Returns the partial value if the -/// closing quote hasn't arrived yet. -pub(crate) fn extract_json_string_field(text: &str, field: &str) -> Option { - let key = format!("\"{field}\""); - // Find the occurrence of `"field"` that is actually a *key* (followed by - // `:`), not the same token appearing earlier as a string value. Without - // this, `{"kind":"explanation","explanation":"real"}` would stop at the - // value and return None. - let mut search_from = 0; - let rest = loop { - let rel = text[search_from..].find(&key)?; - let abs = search_from + rel; - let after = text[abs + key.len()..].trim_start(); - if let Some(r) = after.strip_prefix(':') { - break r.trim_start(); - } - search_from = abs + key.len(); - }; - let body = rest.strip_prefix('"')?; - - let mut out = String::with_capacity(body.len()); - let mut chars = body.chars(); - while let Some(c) = chars.next() { - match c { - '"' => return Some(out), - '\\' => match chars.next() { - None => return Some(out), - Some('"') => out.push('"'), - Some('\\') => out.push('\\'), - Some('/') => out.push('/'), - Some('n') => out.push('\n'), - Some('r') => out.push('\r'), - Some('t') => out.push('\t'), - Some('b') => out.push('\u{08}'), - Some('f') => out.push('\u{0C}'), - Some('u') => { - let hex: String = chars.by_ref().take(4).collect(); - if hex.len() < 4 { - return Some(out); - } - let Some(code) = u32::from_str_radix(&hex, 16).ok() else { - continue; - }; - match code { - // High surrogate: pair it with the following - // `\uXXXX` low surrogate to recover the non-BMP scalar - // (e.g. emoji). If the low half hasn't streamed in yet - // (or is malformed), drop the lone surrogate — the next - // frame re-runs over the now-complete buffer. - 0xD800..=0xDBFF => { - let mut lookahead = chars.clone(); - if lookahead.next() == Some('\\') - && lookahead.next() == Some('u') - { - let lo_hex: String = lookahead.by_ref().take(4).collect(); - if lo_hex.len() == 4 { - if let Some(lo @ 0xDC00..=0xDFFF) = - u32::from_str_radix(&lo_hex, 16).ok() - { - let scalar = 0x1_0000 - + ((code - 0xD800) << 10) - + (lo - 0xDC00); - if let Some(ch) = char::from_u32(scalar) { - out.push(ch); - } - chars = lookahead; // consume the low half - } - } - } - } - // Lone low surrogate or any non-scalar: skip. Valid - // scalars get pushed. - _ => { - if let Some(ch) = char::from_u32(code) { - out.push(ch); - } - } - } - } - Some(other) => out.push(other), - }, - c => out.push(c), - } - } - Some(out) -} - -/// Resolves the user-visible portion of a streaming buffer: -/// -/// - Buffer starts with a JSON wrapper (autofix): extract the `explanation` -/// field so the user sees flowing markdown rather than raw JSON syntax. -/// fix actions lack this field and yield None — the card surfaces on -/// finalize. -/// - Buffer is mixed prose followed by a fenced JSON block (planner -/// terminal-task mode): render only the prose prefix; the recommendation -/// card replaces it on eager/end-of-turn finalize. -/// - Pure prose: stream as-is. -/// -/// Callers outside the render path (e.g. turn-cancel / ignore commits) use -/// this to record exactly what the user saw during streaming, instead of the -/// raw buffer (which may contain JSON the UI deliberately hid). +/// Return non-empty assistant text for streaming and transcript rendering. +/// Typed proposal payloads travel through the direct Helper channel, so ACP +/// assistant text is always user-visible chat content. pub(crate) fn user_visible_stream_text(text: &str) -> Option> { - let trimmed = text.trim_start(); - if trimmed.is_empty() { - return None; - } - if trimmed.starts_with("```") || trimmed.starts_with('{') { - return extract_json_string_field(text, "explanation") - .filter(|s| !s.is_empty()) - .map(Cow::Owned); - } - if let Some(fence_pos) = text.find("```") { - let prose = text[..fence_pos].trim_end(); - return if prose.is_empty() { - None - } else { - Some(Cow::Borrowed(prose)) - }; - } - Some(Cow::Borrowed(text)) + (!text.trim().is_empty()).then_some(Cow::Borrowed(text)) } fn pending_render_text(tab: &crate::app::TabSession) -> Option> { @@ -890,90 +770,6 @@ mod tests { assert_ne!(theme::TOOL_CALL_CANCELED, theme::DIM); } - // ── extract_json_string_field: escape decoding ────────────────────────── - - #[test] - fn json_field_basic_value() { - assert_eq!( - extract_json_string_field(r#"{"explanation":"hello"}"#, "explanation") - .as_deref(), - Some("hello") - ); - } - - #[test] - fn json_field_decodes_escapes() { - // \" \\ \/ \n \r \t all per RFC 8259. - let raw = r#"{"explanation":"a\"b\\c\/d\ne\tf"}"#; - assert_eq!( - extract_json_string_field(raw, "explanation").as_deref(), - Some("a\"b\\c/d\ne\tf") - ); - } - - #[test] - fn json_field_decodes_bmp_unicode_escape() { - // \u0041 = 'A', \u00e9 = 'é' - assert_eq!( - extract_json_string_field(r#"{"explanation":"\u0041\u00e9"}"#, "explanation") - .as_deref(), - Some("Aé") - ); - } - - #[test] - fn json_field_tolerates_whitespace_around_colon() { - assert_eq!( - extract_json_string_field("{ \"explanation\" : \"v\" }", "explanation") - .as_deref(), - Some("v") - ); - } - - #[test] - fn json_field_returns_partial_when_unterminated() { - // Streaming: the closing quote hasn't arrived yet — show what we have. - assert_eq!( - extract_json_string_field(r#"{"explanation":"hello world"#, "explanation") - .as_deref(), - Some("hello world") - ); - } - - #[test] - fn json_field_absent_returns_none() { - assert_eq!( - extract_json_string_field(r#"{"command":"ls"}"#, "explanation"), - None - ); - } - - // ── extract_json_string_field: ADVERSARIAL (expected to expose gaps) ───── - - /// A non-BMP character (emoji) encoded as a UTF-16 surrogate pair must - /// decode to the actual character. Agents routinely emit emoji in prose. - #[test] - fn json_field_decodes_surrogate_pair_emoji() { - // U+1F600 😀 = \uD83D\uDE00 in UTF-16. - assert_eq!( - extract_json_string_field(r#"{"explanation":"\uD83D\uDE00"}"#, "explanation") - .as_deref(), - Some("😀") - ); - } - - /// When the field name also appears earlier as a *value*, extraction must - /// still find the real key=value pair, not give up at the first textual - /// match. - #[test] - fn json_field_skips_name_appearing_as_value() { - let raw = r#"{"kind":"explanation","explanation":"real"}"#; - assert_eq!( - extract_json_string_field(raw, "explanation").as_deref(), - Some("real") - ); - } - // ── user_visible_stream_text ──────────────────────────────────────────── #[test] @@ -985,30 +781,19 @@ mod tests { } #[test] - fn stream_text_json_wrapper_extracts_explanation() { - assert_eq!( - user_visible_stream_text(r#"{"explanation":"why blue"}"#).as_deref(), - Some("why blue") - ); + fn stream_text_json_passes_through_verbatim() { + let text = r#"{"explanation":"why blue","command":"ls"}"#; + assert_eq!(user_visible_stream_text(text).as_deref(), Some(text)); } #[test] - fn stream_text_json_without_explanation_is_hidden() { - // A fix-action wrapper (no explanation) must not leak raw JSON. - assert_eq!(user_visible_stream_text(r#"{"command":"ls"}"#), None); - } - - #[test] - fn stream_text_prose_then_fence_shows_prose_prefix_only() { - let buf = "Here is the plan.\n```json\n{\"choices\":[]}\n```"; - assert_eq!( - user_visible_stream_text(buf).as_deref(), - Some("Here is the plan.") - ); + fn stream_text_prose_then_fence_passes_through_verbatim() { + let text = "Here is the plan.\n```json\n{\"choices\":[]}\n```"; + assert_eq!(user_visible_stream_text(text).as_deref(), Some(text)); } #[test] - fn stream_text_empty_is_none() { + fn stream_text_blank_is_none() { assert_eq!(user_visible_stream_text(" \n "), None); } From 664538c71a941f9724ca9994a8c03cc02b30a31e Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Tue, 28 Jul 2026 14:52:52 +0800 Subject: [PATCH 05/14] Address direct proposal review findings Tighten proposal command detection, finalize confirmation tombstones with the actual dispatch result, and distinguish retryable schema errors from terminal policy rejections. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7743e270-ccd3-40be-b382-42639b800d46 --- tools/wta/src/app_tests.rs | 88 ++++++++++++++- tools/wta/src/app_turn.rs | 126 ++++++++++++---------- tools/wta/src/proposal_channel.rs | 54 +++++++--- tools/wta/src/protocol/acp/client.rs | 124 ++++++++++++++++++++- tools/wta/src/terminal_action_proposal.rs | 14 +-- 5 files changed, 317 insertions(+), 89 deletions(-) diff --git a/tools/wta/src/app_tests.rs b/tools/wta/src/app_tests.rs index f1646318cc..32750496bf 100644 --- a/tools/wta/src/app_tests.rs +++ b/tools/wta/src/app_tests.rs @@ -7382,6 +7382,7 @@ fn stage_direct_proposal_with_manager( sid: &str, ) -> ( String, + crate::proposal_channel::ProposalChannel, tokio::sync::oneshot::Receiver, ) { let channel = manager @@ -7406,7 +7407,7 @@ fn stage_direct_proposal_with_manager( ); let (final_tx, final_rx) = tokio::sync::oneshot::channel(); assert!(manager.accept_validation(&proposal_id, final_tx)); - (proposal_id, final_rx) + (proposal_id, channel, final_rx) } #[test] @@ -7419,7 +7420,8 @@ fn direct_proposal_confirm_resolves_waiting_cli() { let sid = "sess-direct-confirm"; stage_proposal_session(&mut app, sid); submit_prompt_for_session(&mut app, sid, "restart it", None); - let (proposal_id, final_rx) = stage_direct_proposal_with_manager(&mut app, &manager, sid); + let (proposal_id, channel, final_rx) = + stage_direct_proposal_with_manager(&mut app, &manager, sid); app.handle_event(AppEvent::DirectTerminalActionProposalCommit { proposal_id: proposal_id.clone(), @@ -7438,6 +7440,43 @@ fn direct_proposal_confirm_resolves_waiting_cli() { crate::proposal_channel::ProposalFinalStatus::Confirmed ); assert!(recommendation_rx.try_recv().is_ok()); + assert_eq!( + manager + .begin_validation(&channel, TERMINAL_AGENT_PROPOSAL_PAYLOAD.as_bytes()) + .unwrap_err() + .status, + crate::proposal_channel::ProposalValidationStatus::AlreadyConsumed + ); +} + +#[test] +fn direct_proposal_enqueue_failure_is_unavailable_to_cli_and_channel_lookup() { + let mut app = test_app(); + let (recommendation_tx, recommendation_rx) = tokio::sync::mpsc::unbounded_channel(); + drop(recommendation_rx); + app.recommendation_tx = recommendation_tx; + let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + app.set_proposal_channels(Arc::clone(&manager)); + let sid = "sess-direct-enqueue-failure"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "restart it", None); + let (proposal_id, channel, final_rx) = + stage_direct_proposal_with_manager(&mut app, &manager, sid); + + app.handle_event(AppEvent::DirectTerminalActionProposalCommit { proposal_id }); + app.turn_execute_card(sid); + + assert_eq!( + final_rx.blocking_recv().unwrap(), + crate::proposal_channel::ProposalFinalStatus::Unavailable + ); + assert_eq!( + manager + .begin_validation(&channel, TERMINAL_AGENT_PROPOSAL_PAYLOAD.as_bytes()) + .unwrap_err() + .status, + crate::proposal_channel::ProposalValidationStatus::Unavailable + ); } #[test] @@ -7448,7 +7487,8 @@ fn direct_proposal_cancel_between_validation_and_commit_does_not_surface() { let sid = "sess-direct-cancel-before-commit"; stage_proposal_session(&mut app, sid); submit_prompt_for_session(&mut app, sid, "restart it", None); - let (proposal_id, final_rx) = stage_direct_proposal_with_manager(&mut app, &manager, sid); + let (proposal_id, _channel, final_rx) = + stage_direct_proposal_with_manager(&mut app, &manager, sid); app.turn_cancel(sid); app.handle_event(AppEvent::DirectTerminalActionProposalCommit { proposal_id }); @@ -7468,7 +7508,8 @@ fn direct_proposal_cancel_resolves_waiting_cli() { let sid = "sess-direct-cancel"; stage_proposal_session(&mut app, sid); submit_prompt_for_session(&mut app, sid, "restart it", None); - let (proposal_id, final_rx) = stage_direct_proposal_with_manager(&mut app, &manager, sid); + let (proposal_id, _channel, final_rx) = + stage_direct_proposal_with_manager(&mut app, &manager, sid); app.handle_event(AppEvent::DirectTerminalActionProposalCommit { proposal_id }); app.turn_cancel(sid); @@ -7698,9 +7739,27 @@ fn direct_proposal_rejects_unsupported_schema_version() { decision.status, crate::proposal_channel::ProposalValidationStatus::InvalidSchema ); + assert!(decision.retryable); assert!(decision.reason.unwrap().contains("schema_version")); } +#[test] +fn direct_proposal_rejects_malformed_schema_as_retryable() { + let mut app = test_app(); + let sid = "sess-proposal-malformed"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "please help", None); + + let (decision, _) = + evaluate_direct_proposal(&mut app, sid, 99, Some("pane-9"), r#"{"schema_version":"#); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::InvalidSchema + ); + assert!(decision.retryable); + assert!(decision.reason.unwrap().contains("malformed payload")); +} + #[test] fn direct_proposal_rejects_origin_mismatch_with_live_turn() { let mut app = test_app(); @@ -7712,11 +7771,30 @@ fn direct_proposal_rejects_origin_mismatch_with_live_turn() { let (decision, _) = evaluate_direct_proposal(&mut app, sid, 99, None, &payload); assert_eq!( decision.status, - crate::proposal_channel::ProposalValidationStatus::InvalidSchema + crate::proposal_channel::ProposalValidationStatus::Rejected ); + assert!(!decision.retryable); assert!(decision.reason.unwrap().contains("does not match")); } +#[test] +fn direct_proposal_rejects_policy_violation_as_nonretryable() { + let mut app = test_app(); + let sid = "sess-proposal-policy"; + stage_proposal_session(&mut app, sid); + submit_prompt_for_session(&mut app, sid, "please help", None); + + let payload = r#"{"schema_version":1,"origin":"terminal_agent","choices":[]}"#; + let (decision, _) = + evaluate_direct_proposal(&mut app, sid, 99, Some("pane-9"), payload); + assert_eq!( + decision.status, + crate::proposal_channel::ProposalValidationStatus::Rejected + ); + assert!(!decision.retryable); + assert!(decision.reason.unwrap().contains("expected 1 to")); +} + #[test] fn direct_proposal_unknown_session_is_unavailable() { let mut app = test_app(); diff --git a/tools/wta/src/app_turn.rs b/tools/wta/src/app_turn.rs index a31cf4fe13..7926ec73bf 100644 --- a/tools/wta/src/app_turn.rs +++ b/tools/wta/src/app_turn.rs @@ -5,6 +5,14 @@ use super::*; +enum DirectProposalEvaluation { + Presented, + Duplicate(String), + Stale(String), + Rejected(crate::terminal_action_proposal::ProposalError), + Unavailable(String), +} + // ───────────────────────────────────────────────────────────────────────── // TurnState transition methods // @@ -162,48 +170,39 @@ impl App { /// Apply the Helper's authoritative turn, schema, origin, and action policy /// checks, then stage an accepted proposal until the direct pipe completes /// its validation handshake and posts `DirectTerminalActionProposalCommit`. - pub(super) fn validate_and_stage_terminal_action_proposal( + fn validate_and_stage_terminal_action_proposal( &mut self, sid: &str, prompt_id: u64, active_target: Option<&str>, payload: &str, proposal_id: &str, - ) -> ( - crate::terminal_action_proposal::ProposalStatus, - Option, - ) { - use crate::terminal_action_proposal::{ - build_recommendation_set, parse_proposal_payload, ProposalStatus, - }; + ) -> DirectProposalEvaluation { + use crate::terminal_action_proposal::{build_recommendation_set, parse_proposal_payload}; if !self.session_to_tab.contains_key(sid) { - return ( - ProposalStatus::Unavailable, - Some("session is not bound to this helper".to_string()), + return DirectProposalEvaluation::Unavailable( + "session is not bound to this helper".to_string(), ); } match &self.session_tab(sid).turn { TurnState::Surfaced { .. } => { - return ( - ProposalStatus::Duplicate, - Some("a card is already showing for this turn".to_string()), + return DirectProposalEvaluation::Duplicate( + "a card is already showing for this turn".to_string(), ); } TurnState::Idle => { - return ( - ProposalStatus::Stale, - Some("no turn is in flight for this session".to_string()), + return DirectProposalEvaluation::Stale( + "no turn is in flight for this session".to_string(), ); } TurnState::Submitted(_) | TurnState::Streaming { .. } => {} } if self.session_tab(sid).turn.prompt().map(|prompt| prompt.id) != Some(prompt_id) { - return ( - ProposalStatus::Stale, - Some("proposal belongs to an earlier prompt".to_string()), + return DirectProposalEvaluation::Stale( + "proposal belongs to an earlier prompt".to_string(), ); } @@ -212,16 +211,13 @@ impl App { let turn_gen = self.session_tab(sid).turn.autofix_generation(); let current_gen = self.session_tab(sid).autofix.generation; if turn_gen != Some(current_gen) { - return ( - ProposalStatus::Stale, - Some("autofix turn was superseded".to_string()), - ); + return DirectProposalEvaluation::Stale("autofix turn was superseded".to_string()); } } let wire = match parse_proposal_payload(payload.as_bytes()) { Ok(wire) => wire, - Err(err) => return (err.to_status(), Some(err.reason())), + Err(err) => return DirectProposalEvaluation::Rejected(err), }; let configured_delegate_id = self @@ -238,7 +234,7 @@ impl App { self.pane_id.as_deref(), ) { Ok(set) => set, - Err(err) => return (err.to_status(), Some(err.reason())), + Err(err) => return DirectProposalEvaluation::Rejected(err), }; self.session_tab_mut(sid).pending_terminal_action_proposal = @@ -249,7 +245,7 @@ impl App { is_autofix, recommendations, }); - (ProposalStatus::Presented, None) + DirectProposalEvaluation::Presented } pub(super) fn evaluate_direct_terminal_action_proposal( @@ -258,40 +254,58 @@ impl App { payload: &str, ) -> crate::proposal_pipe::ProposalValidationDecision { use crate::proposal_channel::ProposalValidationStatus; - use crate::terminal_action_proposal::ProposalStatus; + use crate::terminal_action_proposal::ProposalError; let binding = &context.binding; - let (status, reason) = self.validate_and_stage_terminal_action_proposal( + let evaluation = self.validate_and_stage_terminal_action_proposal( &binding.session_id, binding.prompt_id, binding.active_target.as_deref(), payload, &context.proposal_id, ); - match status { - ProposalStatus::Presented => { + match evaluation { + DirectProposalEvaluation::Presented => { crate::proposal_pipe::ProposalValidationDecision::accepted() } - ProposalStatus::Duplicate => crate::proposal_pipe::ProposalValidationDecision { - status: ProposalValidationStatus::AlreadyConsumed, - reason, - retryable: false, - }, - ProposalStatus::Stale => crate::proposal_pipe::ProposalValidationDecision { - status: ProposalValidationStatus::Stale, - reason, - retryable: false, - }, - ProposalStatus::Rejected => crate::proposal_pipe::ProposalValidationDecision { - status: ProposalValidationStatus::InvalidSchema, - reason, - retryable: true, - }, - ProposalStatus::Unavailable => crate::proposal_pipe::ProposalValidationDecision { - status: ProposalValidationStatus::Unavailable, - reason, - retryable: false, - }, + DirectProposalEvaluation::Duplicate(reason) => { + crate::proposal_pipe::ProposalValidationDecision { + status: ProposalValidationStatus::AlreadyConsumed, + reason: Some(reason), + retryable: false, + } + } + DirectProposalEvaluation::Stale(reason) => { + crate::proposal_pipe::ProposalValidationDecision { + status: ProposalValidationStatus::Stale, + reason: Some(reason), + retryable: false, + } + } + DirectProposalEvaluation::Rejected(error) => { + let (status, retryable) = match &error { + ProposalError::TooLarge { .. } + | ProposalError::Malformed(_) + | ProposalError::UnsupportedSchemaVersion(_) => { + (ProposalValidationStatus::InvalidSchema, true) + } + ProposalError::PolicyViolation(_) => { + (ProposalValidationStatus::Rejected, false) + } + }; + crate::proposal_pipe::ProposalValidationDecision { + status, + reason: Some(error.reason()), + retryable, + } + } + DirectProposalEvaluation::Unavailable(reason) => { + crate::proposal_pipe::ProposalValidationDecision { + status: ProposalValidationStatus::Unavailable, + reason: Some(reason), + retryable: false, + } + } } } @@ -594,12 +608,12 @@ impl App { .prompt() .and_then(|p| p.autofix.as_ref()) .map(|a| a.target_pane_id.clone()); - let final_responder = if let Some(proposal_id) = direct_proposal_id.as_deref() { - let Some(responder) = self.proposal_channels.claim_confirmation(proposal_id) else { + let confirmation_claim = if let Some(proposal_id) = direct_proposal_id.as_deref() { + let Some(claim) = self.proposal_channels.claim_confirmation(proposal_id) else { self.turn_cancel(session_id); return; }; - Some(responder) + Some(claim) } else { None }; @@ -610,13 +624,13 @@ impl App { insert_only, }) .is_ok(); - if let Some(responder) = final_responder { + if let Some(claim) = confirmation_claim { let status = if dispatched { crate::proposal_channel::ProposalFinalStatus::Confirmed } else { crate::proposal_channel::ProposalFinalStatus::Unavailable }; - let _ = responder.send(status); + self.proposal_channels.finalize_confirmation(claim, status); if !dispatched { self.turn_cancel(session_id); return; diff --git a/tools/wta/src/proposal_channel.rs b/tools/wta/src/proposal_channel.rs index 83b6401292..ef0d7e93a6 100644 --- a/tools/wta/src/proposal_channel.rs +++ b/tools/wta/src/proposal_channel.rs @@ -173,10 +173,15 @@ struct ActiveChannel { #[derive(Debug, Clone, Copy)] struct Tombstone { channel_hash: [u8; 32], - status: ProposalFinalStatus, + status: Option, created_at: Instant, } +pub struct ConfirmationClaim { + channel_hash: [u8; 32], + final_responder: oneshot::Sender, +} + struct ChannelState { session_epoch: u64, transport_available: bool, @@ -419,10 +424,7 @@ impl ProposalChannelManager { can_retry } - pub fn claim_confirmation( - &self, - proposal_id: &str, - ) -> Option> { + pub fn claim_confirmation(&self, proposal_id: &str) -> Option { let mut state = self.lock_state(); let active = state.active.as_ref()?; if active.state != ProposalChannelState::AwaitingUser @@ -432,14 +434,40 @@ impl ProposalChannelManager { return None; } let mut active = state.active.take()?; - let responder = active.final_responder.take()?; + let final_responder = active.final_responder.take()?; + let channel_hash = channel_hash(&active.channel); state.tombstones.push_back(Tombstone { - channel_hash: channel_hash(&active.channel), - status: ProposalFinalStatus::Confirmed, + channel_hash, + status: None, created_at: Instant::now(), }); self.prune_tombstones(&mut state); - Some(responder) + Some(ConfirmationClaim { + channel_hash, + final_responder, + }) + } + + pub fn finalize_confirmation(&self, claim: ConfirmationClaim, status: ProposalFinalStatus) { + let mut state = self.lock_state(); + if let Some(tombstone) = state + .tombstones + .iter_mut() + .rev() + .find(|item| item.channel_hash == claim.channel_hash) + { + tombstone.status = Some(status); + tombstone.created_at = Instant::now(); + } else { + state.tombstones.push_back(Tombstone { + channel_hash: claim.channel_hash, + status: Some(status), + created_at: Instant::now(), + }); + } + self.prune_tombstones(&mut state); + drop(state); + let _ = claim.final_responder.send(status); } pub fn resolve_final(&self, proposal_id: &str, status: ProposalFinalStatus) -> bool { @@ -499,15 +527,15 @@ impl ProposalChannelManager { .find(|item| item.channel_hash == hash) { let (status, reason) = match tombstone.status { - ProposalFinalStatus::Superseded => ( + Some(ProposalFinalStatus::Superseded) => ( ProposalValidationStatus::Superseded, "channel was superseded by a newer turn", ), - ProposalFinalStatus::SessionReplaced => ( + Some(ProposalFinalStatus::SessionReplaced) => ( ProposalValidationStatus::Stale, "channel belongs to a replaced session", ), - ProposalFinalStatus::Unavailable => ( + None | Some(ProposalFinalStatus::Unavailable) => ( ProposalValidationStatus::Unavailable, "owning Helper became unavailable", ), @@ -534,7 +562,7 @@ impl ProposalChannelManager { } state.tombstones.push_back(Tombstone { channel_hash: channel_hash(&active.channel), - status, + status: Some(status), created_at: Instant::now(), }); self.prune_tombstones(state); diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index 2b3debc804..773dd2400b 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -1582,7 +1582,64 @@ fn proposal_permission_command_candidate( } fn looks_like_proposal_command(command: &str) -> bool { - command.contains("propose-terminal-actions") + fn segment_invokes_proposal(segment: &str) -> bool { + let segment = segment.trim_start(); + let segment = segment + .strip_prefix('&') + .map(str::trim_start) + .unwrap_or(segment); + let mut words = segment.split_whitespace(); + let Some(executable) = words.next() else { + return false; + }; + let executable = executable.trim_matches(['"', '\'']); + let executable_name = executable + .rsplit(['\\', '/']) + .next() + .unwrap_or(executable); + let is_wta = executable.eq_ignore_ascii_case("$env:WTA_CLI_PATH") + || executable_name.eq_ignore_ascii_case("wta") + || executable_name.eq_ignore_ascii_case("wta.exe"); + is_wta && words.next() == Some("propose-terminal-actions") + } + + let mut segment_start = 0; + let mut quote = None; + let mut chars = command.char_indices().peekable(); + while let Some((index, ch)) = chars.next() { + if ch == '`' && quote != Some('\'') { + chars.next(); + continue; + } + if let Some(delimiter) = quote { + if ch == delimiter { + if delimiter == '\'' && chars.peek().is_some_and(|(_, next)| *next == '\'') { + chars.next(); + } else { + quote = None; + } + } + continue; + } + if ch == '\'' || ch == '"' { + quote = Some(ch); + continue; + } + + let separator_len = if matches!(ch, '|' | ';' | '\r' | '\n') { + ch.len_utf8() + } else if ch == '&' && chars.peek().is_some_and(|(_, next)| *next == '&') { + chars.next(); + 2 + } else { + continue; + }; + if segment_invokes_proposal(&command[segment_start..index]) { + return true; + } + segment_start = index + separator_len; + } + segment_invokes_proposal(&command[segment_start..]) } impl WtaClient { @@ -4003,8 +4060,9 @@ mod tests { use super::acp; use super::{ acp_result_failure_fields, complete_prompt_request, inject_wta_pane_meta, - post_login_authenticate_error, shell_from_active, timeout_result_failure_fields, - user_locale_tag, ClientState, PromptTimingState, SoftStopReason, WtaClient, + looks_like_proposal_command, post_login_authenticate_error, shell_from_active, + timeout_result_failure_fields, user_locale_tag, ClientState, PromptTimingState, + SoftStopReason, WtaClient, }; use crate::app::AppEvent; use crate::protocol::acp::failure::{AgentFailure, HandshakeStage}; @@ -4850,6 +4908,66 @@ mod tests { ) } + #[test] + fn proposal_command_detection_requires_a_wta_invocation() { + assert!(!looks_like_proposal_command( + "echo propose-terminal-actions" + )); + assert!(!looks_like_proposal_command( + "rg propose-terminal-actions tools/wta" + )); + assert!(!looks_like_proposal_command( + r#"echo '& "$env:WTA_CLI_PATH" propose-terminal-actions --channel channel'"# + )); + assert!(looks_like_proposal_command( + r#"& "$env:WTA_CLI_PATH" propose-terminal-actions --channel channel"# + )); + assert!(looks_like_proposal_command( + r#"'{}' | & "$env:WTA_CLI_PATH" propose-terminal-actions --channel channel"# + )); + } + + #[tokio::test] + async fn unrelated_proposal_mention_uses_normal_permission_ui() { + let local = tokio::task::LocalSet::new(); + local + .run_until(async { + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let client = WtaClient { + state: Arc::new(ClientState { + event_tx, + shell_mgr: Arc::new(ShellManager::new()), + prompt_timing: Arc::new(PromptTimingState::default()), + proposal_channels: Arc::new( + crate::proposal_channel::ProposalChannelManager::new(), + ), + hidden_tool_calls: Mutex::new(HashSet::new()), + }), + }; + let handle = tokio::task::spawn_local(async move { + client + .request_permission(proposal_permission_request( + "echo propose-terminal-actions", + )) + .await + }); + + let responder = match event_rx.recv().await { + Some(AppEvent::PermissionRequest { responder, .. }) => responder, + _ => panic!("expected normal PermissionRequest"), + }; + responder.send("allow-once".to_string()).unwrap(); + + let response = handle.await.unwrap().unwrap(); + assert!(matches!( + response.outcome, + acp::schema::v1::RequestPermissionOutcome::Selected(_) + )); + assert!(event_rx.try_recv().is_err()); + }) + .await; + } + #[tokio::test] async fn canonical_proposal_permission_is_silent_and_arms_payload() { let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); diff --git a/tools/wta/src/terminal_action_proposal.rs b/tools/wta/src/terminal_action_proposal.rs index 167b54eab0..f77f399a5f 100644 --- a/tools/wta/src/terminal_action_proposal.rs +++ b/tools/wta/src/terminal_action_proposal.rs @@ -95,9 +95,8 @@ impl ProposalStatus { /// Why a proposal failed before ever reaching the "did the helper accept /// it" decision. Distinct from [`ProposalStatus`]: this is the *local* -/// (CLI or master, pre-relay) or *decode* failure classification; -/// `to_status` collapses it onto the wire disposition so callers don't -/// need two vocabularies. +/// (CLI or master, pre-relay) or *decode* failure classification. Callers +/// retain the variant when deciding whether a rejection is retryable. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProposalError { /// Raw payload exceeded [`MAX_PAYLOAD_BYTES`] — rejected before parsing. @@ -115,15 +114,6 @@ pub enum ProposalError { } impl ProposalError { - /// Collapse onto the wire disposition. Every variant here maps to - /// `Rejected` except the size cap, which is its own thing conceptually - /// but still a policy rejection from the caller's point of view — no - /// separate status exists for it in the five-way table the spec - /// defines, so it also reports `Rejected` with a specific reason. - pub fn to_status(&self) -> ProposalStatus { - ProposalStatus::Rejected - } - pub fn reason(&self) -> String { match self { ProposalError::TooLarge { size } => { From dbb2b46c8957ee246a1b785240d3e9cdc8266d76 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Tue, 28 Jul 2026 15:17:22 +0800 Subject: [PATCH 06/14] Fix proposal test spelling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7743e270-ccd3-40be-b382-42639b800d46 --- tools/wta/src/proposal_invocation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/wta/src/proposal_invocation.rs b/tools/wta/src/proposal_invocation.rs index d05228dfc5..09f0a2a755 100644 --- a/tools/wta/src/proposal_invocation.rs +++ b/tools/wta/src/proposal_invocation.rs @@ -109,7 +109,7 @@ mod tests { } #[test] - fn rejects_extra_tokens_and_noncompact_json() { + fn rejects_extra_tokens_and_non_compact_json() { let manager = ProposalChannelManager::new(); let channel = manager.issue("session".into(), 1, None, false).unwrap(); let command = render(&channel, payload()).unwrap(); From a0e7966cfdc3121f4931603aeaa3bf28d42a6e03 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Tue, 28 Jul 2026 15:39:46 +0800 Subject: [PATCH 07/14] Fix proposal test identifiers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7743e270-ccd3-40be-b382-42639b800d46 --- tools/wta/src/app_tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/wta/src/app_tests.rs b/tools/wta/src/app_tests.rs index 32750496bf..921b0875c0 100644 --- a/tools/wta/src/app_tests.rs +++ b/tools/wta/src/app_tests.rs @@ -7729,7 +7729,7 @@ fn direct_proposal_stale_when_autofix_generation_diverges() { #[test] fn direct_proposal_rejects_unsupported_schema_version() { let mut app = test_app(); - let sid = "sess-proposal-badschema"; + let sid = "sess-proposal-bad-schema"; stage_proposal_session(&mut app, sid); submit_prompt_for_session(&mut app, sid, "please help", None); @@ -7763,7 +7763,7 @@ fn direct_proposal_rejects_malformed_schema_as_retryable() { #[test] fn direct_proposal_rejects_origin_mismatch_with_live_turn() { let mut app = test_app(); - let sid = "sess-proposal-originmismatch"; + let sid = "sess-proposal-origin-mismatch"; stage_proposal_session(&mut app, sid); submit_prompt_for_session(&mut app, sid, "please help", None); @@ -7778,7 +7778,7 @@ fn direct_proposal_rejects_origin_mismatch_with_live_turn() { } #[test] -fn direct_proposal_rejects_policy_violation_as_nonretryable() { +fn direct_proposal_rejects_policy_violation_as_non_retryable() { let mut app = test_app(); let sid = "sess-proposal-policy"; stage_proposal_session(&mut app, sid); From 826b65dde1c1b31009b6fa3bd2e4b453bd41f496 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Tue, 28 Jul 2026 17:28:22 +0800 Subject: [PATCH 08/14] Clarify unified proposal path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7743e270-ccd3-40be-b382-42639b800d46 --- tools/wta/src/terminal_action_proposal.rs | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/tools/wta/src/terminal_action_proposal.rs b/tools/wta/src/terminal_action_proposal.rs index f77f399a5f..8390521e89 100644 --- a/tools/wta/src/terminal_action_proposal.rs +++ b/tools/wta/src/terminal_action_proposal.rs @@ -15,8 +15,7 @@ //! * origin-aware policy (`ProposalOrigin::TerminalAgent` vs `::Autofix`); //! * size/count bounds enforced *before* `serde_json` ever sees the bytes; //! * conversion into [`crate::coordinator::RecommendationSet`], which then -//! flows through the exact same card-surfacing / execution code as the -//! long-standing assistant-text JSON fallback. +//! flows through the shared card-surfacing and execution pipeline. //! //! The proposal travels over the owning Helper's direct proposal pipe; Master //! is not involved. The Helper invokes this module from App's direct proposal @@ -42,8 +41,8 @@ pub const SCHEMA_VERSION: u32 = 1; /// slow parse. pub const MAX_PAYLOAD_BYTES: usize = 8 * 1024; -/// Max choices per proposal — matches the long-standing fallback-JSON -/// policy in [`crate::coordinator::validate_recommendation_set`] (1..=3). +/// Max choices per proposal, enforced consistently by +/// [`crate::coordinator::validate_recommendation_set`] (1..=3). pub const MAX_CHOICES: usize = 3; /// Max actions per choice. pub const MAX_ACTIONS_PER_CHOICE: usize = 3; @@ -65,9 +64,8 @@ pub const MAX_INPUT_CHARS: usize = 8000; pub enum ProposalStatus { /// The recommendation card is now visible in the agent pane. Presented, - /// A card was already showing for this turn (eager text-fallback - /// surface, or an earlier proposal) — this proposal was not the one - /// that ended up on screen. + /// A card was already showing for this turn, so this proposal was not + /// surfaced. Duplicate, /// The route/turn was valid when minted but is no longer current by /// the time the proposal arrived (token expired/consumed already, or @@ -240,8 +238,7 @@ pub fn parse_proposal_payload(bytes: &[u8]) -> Result Result MAX_ACTIONS_PER_CHOICE { From 53992fe46d9cef64dc7e1ae19c9da5aa9ab5474b Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Wed, 29 Jul 2026 11:46:07 +0800 Subject: [PATCH 09/14] Route proposals through WTA execution alias Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7743e270-ccd3-40be-b382-42639b800d46 --- .../WTA-CLI-terminal-action-proposals.md | 11 +++- .../CascadiaPackage/Package-Can.appxmanifest | 24 ++++++- .../CascadiaPackage/Package-Dev.appxmanifest | 24 ++++++- .../CascadiaPackage/Package-Pre.appxmanifest | 24 ++++++- .../CascadiaPackage/Package.appxmanifest | 24 ++++++- tools/wta/src/protocol/acp/spawn.rs | 64 +++++++++++++++++-- 6 files changed, 152 insertions(+), 19 deletions(-) diff --git a/doc/specs/WTA-CLI-terminal-action-proposals.md b/doc/specs/WTA-CLI-terminal-action-proposals.md index d5d5b76719..914b4da780 100644 --- a/doc/specs/WTA-CLI-terminal-action-proposals.md +++ b/doc/specs/WTA-CLI-terminal-action-proposals.md @@ -108,9 +108,14 @@ The only auto-approvable PowerShell form is: & "$env:WTA_CLI_PATH" propose-terminal-actions --channel --payload-json '' ``` -`WTA_CLI_PATH` is set by WTA to its trusted current executable. Proposal JSON -must be compact UTF-8 JSON encoded as one PowerShell single-quoted argument; -literal apostrophes are escaped by doubling them. The command has no pipeline, +For a packaged installation, WTA sets `WTA_CLI_PATH` to the package-family +specific App Execution Alias at +`%LOCALAPPDATA%\Microsoft\WindowsApps\\wta.exe`. This lets an +external Agent launch WTA through the registered package boundary instead of +directly executing the protected package file. An unpackaged development build +uses its trusted current executable path instead. Proposal JSON must be compact +UTF-8 JSON encoded as one PowerShell single-quoted argument; literal +apostrophes are escaped by doubling them. The command has no pipeline, redirection, here-string, command substitution, temporary file, extra argument, or alternate executable spelling. diff --git a/src/cascadia/CascadiaPackage/Package-Can.appxmanifest b/src/cascadia/CascadiaPackage/Package-Can.appxmanifest index 860bd6bcb7..b9a228eff5 100644 --- a/src/cascadia/CascadiaPackage/Package-Can.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package-Can.appxmanifest @@ -146,10 +146,10 @@ - + + + + + + + + + + diff --git a/src/cascadia/CascadiaPackage/Package-Dev.appxmanifest b/src/cascadia/CascadiaPackage/Package-Dev.appxmanifest index 03b9376f20..00f9f27a80 100644 --- a/src/cascadia/CascadiaPackage/Package-Dev.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package-Dev.appxmanifest @@ -234,10 +234,10 @@ - + + + + + + + + + + diff --git a/src/cascadia/CascadiaPackage/Package-Pre.appxmanifest b/src/cascadia/CascadiaPackage/Package-Pre.appxmanifest index 6f62eaaaa2..ffbc9f2408 100644 --- a/src/cascadia/CascadiaPackage/Package-Pre.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package-Pre.appxmanifest @@ -235,10 +235,10 @@ - + + + + + + + + + + diff --git a/src/cascadia/CascadiaPackage/Package.appxmanifest b/src/cascadia/CascadiaPackage/Package.appxmanifest index 848145f1bf..ec78d03af7 100644 --- a/src/cascadia/CascadiaPackage/Package.appxmanifest +++ b/src/cascadia/CascadiaPackage/Package.appxmanifest @@ -235,10 +235,10 @@ - + + + + + + + + + + diff --git a/tools/wta/src/protocol/acp/spawn.rs b/tools/wta/src/protocol/acp/spawn.rs index 1eded6284b..dc930052a9 100644 --- a/tools/wta/src/protocol/acp/spawn.rs +++ b/tools/wta/src/protocol/acp/spawn.rs @@ -10,7 +10,7 @@ //! + `new_session`, and exits. use std::collections::VecDeque; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -262,9 +262,11 @@ pub(crate) fn spawn_agent_process( // `hook-trace.log` lands alongside this build's Rust + C++ logs. cmd.env("WTA_HOOK_LOG_DIR", crate::logging::log_dir()); - // Proposal commands must execute this exact trusted binary path. - let wta_cli_path = - std::env::current_exe().context("failed to resolve the running wta executable")?; + // Packaged agents cannot reliably execute the protected WindowsApps + // package path directly. Use this package family's execution alias so the + // OS performs the launch, while unpackaged builds keep targeting this + // exact development binary. + let wta_cli_path = proposal_cli_path()?; cmd.env("WTA_CLI_PATH", wta_cli_path); // Forward the user's locale to the agent process via standard POSIX @@ -320,6 +322,35 @@ pub(crate) fn spawn_agent_process( }) } +fn proposal_cli_path() -> Result { + let package_family = crate::runtime_paths::current_package_family_name(); + let local_app_data = std::env::var_os("LOCALAPPDATA"); + let current_exe = + std::env::current_exe().context("failed to resolve the running wta executable")?; + proposal_cli_path_for( + package_family.as_deref(), + local_app_data.as_deref(), + ¤t_exe, + ) +} + +fn proposal_cli_path_for( + package_family: Option<&std::ffi::OsStr>, + local_app_data: Option<&std::ffi::OsStr>, + current_exe: &Path, +) -> Result { + let Some(package_family) = package_family else { + return Ok(current_exe.to_path_buf()); + }; + let local_app_data = local_app_data + .context("LOCALAPPDATA is required to resolve the packaged wta execution alias")?; + Ok(PathBuf::from(local_app_data) + .join("Microsoft") + .join("WindowsApps") + .join(package_family) + .join("wta.exe")) +} + /// Spawn an ACP agent in the selected per-tab execution source. pub(crate) fn spawn_agent_process_for_source( agent_cmd: &str, @@ -438,6 +469,31 @@ fn canonicalize_posix_locale(tag: &str) -> String { mod tests { use super::*; + #[test] + fn packaged_proposal_cli_uses_package_specific_execution_alias() { + let path = proposal_cli_path_for( + Some(std::ffi::OsStr::new("IntelligentTerminal_test")), + Some(std::ffi::OsStr::new(r"C:\Users\test\AppData\Local")), + Path::new(r"C:\Program Files\WindowsApps\package\wta.exe"), + ) + .unwrap(); + + assert_eq!( + path, + PathBuf::from( + r"C:\Users\test\AppData\Local\Microsoft\WindowsApps\IntelligentTerminal_test\wta.exe" + ) + ); + } + + #[test] + fn unpackaged_proposal_cli_uses_running_executable() { + let current_exe = Path::new(r"C:\src\wta\target\debug\wta.exe"); + let path = proposal_cli_path_for(None, None, current_exe).unwrap(); + + assert_eq!(path, current_exe); + } + #[test] fn wsl_launch_script_quotes_every_agent_argument() { let parts = vec![ From e2fb5ccaceabfad6804d2cc91026e706393711ba Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Thu, 30 Jul 2026 14:01:46 +0800 Subject: [PATCH 10/14] Refresh PR mergeability after main merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a828e781-6e7b-4356-9e3d-c952d092587e From 7d25c99a3537910e453e3a4e28c5e1a2288a0c40 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Thu, 30 Jul 2026 14:44:51 +0800 Subject: [PATCH 11/14] Hide proposal tool calls before permission Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a828e781-6e7b-4356-9e3d-c952d092587e --- tools/wta/src/protocol/acp/client.rs | 24 ++++++--- .../wta/src/protocol/acp/mock_agent_tests.rs | 50 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index 58e71b1ae2..3de174a688 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -1710,13 +1710,11 @@ fn proposal_permission_command_candidate( if args.tool_call.fields.kind != Some(acp::schema::v1::ToolKind::Execute) { return None; } - args.tool_call - .fields - .raw_input - .as_ref()? - .as_object()? - .get("command")? - .as_str() + proposal_command_candidate(args.tool_call.fields.raw_input.as_ref()) +} + +fn proposal_command_candidate(raw_input: Option<&serde_json::Value>) -> Option<&str> { + raw_input?.as_object()?.get("command")?.as_str() } fn looks_like_proposal_command(command: &str) -> bool { @@ -2003,6 +2001,12 @@ impl WtaClient { } acp::schema::v1::SessionUpdate::ToolCall(tool_call) => { let tool_call_id = tool_call.tool_call_id.to_string(); + if proposal_command_candidate(tool_call.raw_input.as_ref()) + .is_some_and(looks_like_proposal_command) + { + self.hide_proposal_tool_call(&sid, &tool_call_id); + return Ok(()); + } if self.tool_call_is_hidden(&sid, &tool_call_id) { return Ok(()); } @@ -2028,6 +2032,12 @@ impl WtaClient { } acp::schema::v1::SessionUpdate::ToolCallUpdate(update) => { let tool_call_id = update.tool_call_id.to_string(); + if proposal_command_candidate(update.fields.raw_input.as_ref()) + .is_some_and(looks_like_proposal_command) + { + self.hide_proposal_tool_call(&sid, &tool_call_id); + return Ok(()); + } if self.tool_call_is_hidden(&sid, &tool_call_id) { return Ok(()); } diff --git a/tools/wta/src/protocol/acp/mock_agent_tests.rs b/tools/wta/src/protocol/acp/mock_agent_tests.rs index df362a9b2a..f7381a0050 100644 --- a/tools/wta/src/protocol/acp/mock_agent_tests.rs +++ b/tools/wta/src/protocol/acp/mock_agent_tests.rs @@ -1636,6 +1636,56 @@ async fn session_notification_routes_tool_call() { } } +#[tokio::test] +async fn session_notification_hides_proposal_tool_call_before_permission() { + let (client, mut rx) = bare_client(); + let command = + r#"& "$env:WTA_CLI_PATH" propose-terminal-actions --channel v1.helper.turn --payload-json '{}'"#; + client + .session_notification(notif( + "s1", + acp::schema::v1::SessionUpdate::ToolCall( + acp::schema::v1::ToolCall::new( + acp::schema::v1::ToolCallId::new("proposal-tool"), + "Propose terminal action", + ) + .raw_input(Some(serde_json::json!({ + "command": command, + }))), + ), + )) + .await + .unwrap(); + + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::HideToolCall { session_id, id }) + if session_id == "s1" && id == "proposal-tool" + )); + assert!( + rx.try_recv().is_err(), + "proposal ToolCall must not reach the chat UI" + ); + + client + .session_notification(notif( + "s1", + acp::schema::v1::SessionUpdate::ToolCallUpdate( + acp::schema::v1::ToolCallUpdate::new( + acp::schema::v1::ToolCallId::new("proposal-tool"), + acp::schema::v1::ToolCallUpdateFields::new() + .status(acp::schema::v1::ToolCallStatus::Completed), + ), + ), + )) + .await + .unwrap(); + assert!( + rx.try_recv().is_err(), + "updates for a hidden proposal ToolCall must remain hidden" + ); +} + /// When the agent's own `title` already embeds the location text (common /// for read/view tool calls, e.g. title "Viewing C:\...\rust-app" whose /// `locations` names that exact same path), the hint must be suppressed — From 755970918846246c03ee03d7dd37f6de9b86209c Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Thu, 30 Jul 2026 16:15:11 +0800 Subject: [PATCH 12/14] Keep command probes out of terminal proposals Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a828e781-6e7b-4356-9e3d-c952d092587e --- tools/wta/prompts/terminal-agent.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/wta/prompts/terminal-agent.md b/tools/wta/prompts/terminal-agent.md index 2b65490650..7e21bb5cc6 100644 --- a/tools/wta/prompts/terminal-agent.md +++ b/tools/wta/prompts/terminal-agent.md @@ -2,13 +2,15 @@ You are a terminal-native assistant inside Windows Terminal. Runtime context is authoritative. Choose the first matching mode and do not mix modes: -1. **Chat**: General knowledge independent of this machine/repo. Answer in prose, without JSON. For an unfamiliar local command, investigate read-only first: prefer `wta resolve-command --json`, then inspect help or source without executing it. -2. **Recommend**: One or a short sequence of active-pane shell commands satisfies the request, including inspection such as list/status/pwd. Present a card; do not run those commands in your own tool shell first. -3. **Self-execute**: A bounded answer requires reading files, parsing output, or reasoning across tool results. Use tools, then answer in prose without JSON. +1. **Chat**: General knowledge independent of this machine/repo. Answer in prose, without JSON. For an unfamiliar local command, enrich context read-only before answering. +2. **Recommend**: The user-visible outcome is running or inserting one or a short sequence of active-pane shell commands, including an explicitly requested inspection such as list/status/pwd. Present a card; do not pre-run the proposed final command in your own tool shell. +3. **Self-execute**: A bounded answer requires reading files, parsing output, or reasoning across tool results. Use agent tools, then answer in prose without JSON. 4. **Delegate**: The task is long-running, multi-file, or explicitly requested in another agent/tab. Present a card with a delegated `open_and_send` action and a self-contained task. Prefer Recommend over Self-execute, and Self-execute over Delegate. Use the active pane's shell syntax. +Commands or tools used only to understand, validate, or choose the final response are internal context enrichment, not user-visible actions. This includes `inspect_command`, `wta resolve-command --json`, `Get-Command`, `command -v`, `which`, help/version queries, and source inspection. Run these through the agent's own tools, consume their output as context, then choose the final mode from the original user request. Never put a context-enrichment probe in a proposal or send it to the active pane unless the user explicitly requested that exact inspection as the final pane action. + When Recommend mode has an `[intellterm.wta proposal]` block, invoke its canonical proposal command immediately. Do not emit prose, a plan, or reasoning, and do not call any other tool before that command. ## Recommendation cards From 169a3a2c7142754c382a0af65e27e6def54dd3ce Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Thu, 30 Jul 2026 17:02:30 +0800 Subject: [PATCH 13/14] Prefer profile-aware command resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a828e781-6e7b-4356-9e3d-c952d092587e --- tools/wta/prompts/terminal-agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/wta/prompts/terminal-agent.md b/tools/wta/prompts/terminal-agent.md index 7e21bb5cc6..a5af17eb94 100644 --- a/tools/wta/prompts/terminal-agent.md +++ b/tools/wta/prompts/terminal-agent.md @@ -9,7 +9,7 @@ You are a terminal-native assistant inside Windows Terminal. Runtime context is Prefer Recommend over Self-execute, and Self-execute over Delegate. Use the active pane's shell syntax. -Commands or tools used only to understand, validate, or choose the final response are internal context enrichment, not user-visible actions. This includes `inspect_command`, `wta resolve-command --json`, `Get-Command`, `command -v`, `which`, help/version queries, and source inspection. Run these through the agent's own tools, consume their output as context, then choose the final mode from the original user request. Never put a context-enrichment probe in a proposal or send it to the active pane unless the user explicitly requested that exact inspection as the final pane action. +Commands or tools used only to understand, validate, or choose the final response are internal context enrichment, not user-visible actions. For an unfamiliar local command, use `wta resolve-command --json` first; it is the preferred profile-aware resolver for the user's real shell. Treat definitive `exists` or `not_found` results as authoritative, and fall back to `Get-Command`, `command -v`, `which`, help/version queries, or source inspection only when resolution is `indeterminate` or `unsupported`, or when additional usage details are required. Run these probes through the agent's own tools, consume their output as context, then choose the final mode from the original user request. Never put `inspect_command`, `resolve-command`, or another context-enrichment probe in a proposal or send it to the active pane unless the user explicitly requested that exact inspection as the final pane action. When Recommend mode has an `[intellterm.wta proposal]` block, invoke its canonical proposal command immediately. Do not emit prose, a plan, or reasoning, and do not call any other tool before that command. From fc46cf519acc01d39c8d7133cec1decb6f80889e Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Tue, 4 Aug 2026 11:21:50 +0800 Subject: [PATCH 14/14] Group WTA agent tools by capability Place command resolution and action proposal implementation under a shared agent_tools boundary, with a consolidated CLI adapter. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 50c2455c-9ea2-4525-b55f-7d02c0bde969 --- .../WTA-CLI-terminal-action-proposals.md | 5 ++- .../action_proposal/channel.rs} | 0 .../action_proposal/invocation.rs} | 6 +-- .../src/agent_tools/action_proposal/mod.rs | 5 +++ .../action_proposal/pipe.rs} | 14 +++---- .../action_proposal/pipe_security.rs} | 0 .../action_proposal/schema.rs} | 0 .../command_resolution.rs} | 0 tools/wta/src/agent_tools/mod.rs | 2 + tools/wta/src/app.rs | 10 +++-- tools/wta/src/app_contracts/event.rs | 6 ++- tools/wta/src/app_events.rs | 2 +- tools/wta/src/app_tests.rs | 22 ++++++---- tools/wta/src/app_turn.rs | 30 +++++++------- .../src/cli/{proposals.rs => agent_tools.rs} | 41 ++++++++++++++----- tools/wta/src/cli/args.rs | 9 ++-- tools/wta/src/cli/mod.rs | 18 +++----- tools/wta/src/helper/runtime.rs | 18 +++++--- tools/wta/src/main.rs | 7 +--- tools/wta/src/protocol/acp/client.rs | 31 +++++++++----- .../wta/src/protocol/acp/mock_agent_tests.rs | 15 +++++-- tools/wta/src/protocol/acp/prompt_context.rs | 15 ++++--- 22 files changed, 161 insertions(+), 95 deletions(-) rename tools/wta/src/{proposal_channel.rs => agent_tools/action_proposal/channel.rs} (100%) rename tools/wta/src/{proposal_invocation.rs => agent_tools/action_proposal/invocation.rs} (96%) create mode 100644 tools/wta/src/agent_tools/action_proposal/mod.rs rename tools/wta/src/{proposal_pipe.rs => agent_tools/action_proposal/pipe.rs} (97%) rename tools/wta/src/{named_pipe_security.rs => agent_tools/action_proposal/pipe_security.rs} (100%) rename tools/wta/src/{terminal_action_proposal.rs => agent_tools/action_proposal/schema.rs} (100%) rename tools/wta/src/{resolve_command.rs => agent_tools/command_resolution.rs} (100%) create mode 100644 tools/wta/src/agent_tools/mod.rs rename tools/wta/src/cli/{proposals.rs => agent_tools.rs} (70%) diff --git a/doc/specs/WTA-CLI-terminal-action-proposals.md b/doc/specs/WTA-CLI-terminal-action-proposals.md index 914b4da780..e41c65cab7 100644 --- a/doc/specs/WTA-CLI-terminal-action-proposals.md +++ b/doc/specs/WTA-CLI-terminal-action-proposals.md @@ -262,8 +262,9 @@ terminal states are not retryable. ## Proposal schema and trusted target The public payload is the versioned schema defined by -`terminal_action_proposal.rs`. It uses `deny_unknown_fields`, explicit count -and size limits, and hand-written conversion to `RecommendationSet`. +`tools/wta/src/agent_tools/action_proposal/schema.rs`. It uses +`deny_unknown_fields`, explicit count and size limits, and hand-written +conversion to `RecommendationSet`. It never accepts: diff --git a/tools/wta/src/proposal_channel.rs b/tools/wta/src/agent_tools/action_proposal/channel.rs similarity index 100% rename from tools/wta/src/proposal_channel.rs rename to tools/wta/src/agent_tools/action_proposal/channel.rs diff --git a/tools/wta/src/proposal_invocation.rs b/tools/wta/src/agent_tools/action_proposal/invocation.rs similarity index 96% rename from tools/wta/src/proposal_invocation.rs rename to tools/wta/src/agent_tools/action_proposal/invocation.rs index 09f0a2a755..efdba54716 100644 --- a/tools/wta/src/proposal_invocation.rs +++ b/tools/wta/src/agent_tools/action_proposal/invocation.rs @@ -1,5 +1,5 @@ -use crate::proposal_channel::ProposalChannel; -use crate::terminal_action_proposal::MAX_PAYLOAD_BYTES; +use super::channel::ProposalChannel; +use super::schema::MAX_PAYLOAD_BYTES; const PREFIX: &str = r#"& "$env:WTA_CLI_PATH" propose-terminal-actions --channel "#; const PAYLOAD_MARKER: &str = " --payload-json "; @@ -75,7 +75,7 @@ fn decode_single_quoted(expression: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::proposal_channel::ProposalChannelManager; + use super::super::channel::ProposalChannelManager; fn payload() -> &'static str { r#"{"schema_version":1,"origin":"terminal_agent","recommended_choice":1,"choices":[{"choice":1,"title":"Run user's test","rationale":"","actions":[{"type":"send","input":"cargo test"}]}]}"# diff --git a/tools/wta/src/agent_tools/action_proposal/mod.rs b/tools/wta/src/agent_tools/action_proposal/mod.rs new file mode 100644 index 0000000000..17e2396880 --- /dev/null +++ b/tools/wta/src/agent_tools/action_proposal/mod.rs @@ -0,0 +1,5 @@ +pub(crate) mod channel; +pub(crate) mod invocation; +pub(crate) mod pipe; +mod pipe_security; +pub(crate) mod schema; diff --git a/tools/wta/src/proposal_pipe.rs b/tools/wta/src/agent_tools/action_proposal/pipe.rs similarity index 97% rename from tools/wta/src/proposal_pipe.rs rename to tools/wta/src/agent_tools/action_proposal/pipe.rs index 19db2ea3f4..6dd1340849 100644 --- a/tools/wta/src/proposal_pipe.rs +++ b/tools/wta/src/agent_tools/action_proposal/pipe.rs @@ -1,8 +1,8 @@ -use crate::proposal_channel::{ +use super::channel::{ ProposalChannel, ProposalChannelManager, ProposalFinalStatus, ProposalValidationStatus, ValidationContext, }; -use crate::terminal_action_proposal::MAX_PAYLOAD_BYTES; +use super::schema::MAX_PAYLOAD_BYTES; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -91,9 +91,9 @@ pub async fn run_server( event_tx: mpsc::UnboundedSender, ) -> Result<()> { let pipe_name = manager.pipe_name(); - let security = crate::named_pipe_security::build_required() + let security = super::pipe_security::build_required() .context("build hardened proposal pipe security")?; - let mut server = crate::named_pipe_security::create_server(&pipe_name, true, Some(&security)) + let mut server = super::pipe_security::create_server(&pipe_name, true, Some(&security)) .with_context(|| format!("create proposal pipe '{pipe_name}'"))?; tracing::info!( target: "proposal_pipe", @@ -108,7 +108,7 @@ pub async fn run_server( .with_context(|| format!("connect proposal pipe '{pipe_name}'"))?; let connected = std::mem::replace( &mut server, - crate::named_pipe_security::create_server(&pipe_name, false, Some(&security)) + super::pipe_security::create_server(&pipe_name, false, Some(&security)) .with_context(|| format!("create follow-up proposal pipe '{pipe_name}'"))?, ); let manager = Arc::clone(&manager); @@ -442,9 +442,9 @@ mod tests { .arm("session", &channel, payload.as_bytes()) .unwrap(); let pipe_name = manager.pipe_name(); - let security = crate::named_pipe_security::build_required().unwrap(); + let security = super::super::pipe_security::build_required().unwrap(); let server = - crate::named_pipe_security::create_server(&pipe_name, true, Some(&security)).unwrap(); + super::super::pipe_security::create_server(&pipe_name, true, Some(&security)).unwrap(); let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let server_manager = Arc::clone(&manager); diff --git a/tools/wta/src/named_pipe_security.rs b/tools/wta/src/agent_tools/action_proposal/pipe_security.rs similarity index 100% rename from tools/wta/src/named_pipe_security.rs rename to tools/wta/src/agent_tools/action_proposal/pipe_security.rs diff --git a/tools/wta/src/terminal_action_proposal.rs b/tools/wta/src/agent_tools/action_proposal/schema.rs similarity index 100% rename from tools/wta/src/terminal_action_proposal.rs rename to tools/wta/src/agent_tools/action_proposal/schema.rs diff --git a/tools/wta/src/resolve_command.rs b/tools/wta/src/agent_tools/command_resolution.rs similarity index 100% rename from tools/wta/src/resolve_command.rs rename to tools/wta/src/agent_tools/command_resolution.rs diff --git a/tools/wta/src/agent_tools/mod.rs b/tools/wta/src/agent_tools/mod.rs new file mode 100644 index 0000000000..946277bbe8 --- /dev/null +++ b/tools/wta/src/agent_tools/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod action_proposal; +pub(crate) mod command_resolution; diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index e5687f7453..7de5220f79 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -1044,7 +1044,8 @@ pub struct App { /// the bootstrap RPC hasn't returned yet. Tracked as an Atomic so /// the bootstrap task can flip it from a non-`&mut self` context. pub alive_loaded: std::sync::Arc, - pub proposal_channels: Arc, + pub proposal_channels: + Arc, } /// How long the close-pane arm (localized via `system.close_pane_hint`) stays live. Long @@ -1216,14 +1217,17 @@ impl App { transient_hint: None, alive: crate::session_registry::InMemoryRegistry::shared(), alive_loaded: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), + proposal_channels: Arc::new( + crate::agent_tools::action_proposal::channel::ProposalChannelManager::new(), + ), shell_mgr, } } pub fn set_proposal_channels( &mut self, - proposal_channels: Arc, + proposal_channels: + Arc, ) { self.proposal_channels = proposal_channels; } diff --git a/tools/wta/src/app_contracts/event.rs b/tools/wta/src/app_contracts/event.rs index 539d599c28..f2aec8451e 100644 --- a/tools/wta/src/app_contracts/event.rs +++ b/tools/wta/src/app_contracts/event.rs @@ -174,9 +174,11 @@ pub enum AppEvent { AliveJoinUpgrade(Vec<(String, Option)>), SessionsChanged, DirectTerminalActionProposal { - context: crate::proposal_channel::ValidationContext, + context: crate::agent_tools::action_proposal::channel::ValidationContext, payload: String, - responder: tokio::sync::oneshot::Sender, + responder: tokio::sync::oneshot::Sender< + crate::agent_tools::action_proposal::pipe::ProposalValidationDecision, + >, }, DirectTerminalActionProposalCommit { proposal_id: String, diff --git a/tools/wta/src/app_events.rs b/tools/wta/src/app_events.rs index 92b11336fc..792e8d1307 100644 --- a/tools/wta/src/app_events.rs +++ b/tools/wta/src/app_events.rs @@ -949,7 +949,7 @@ impl App { if !self.commit_terminal_action_proposal(&proposal_id) { self.proposal_channels.resolve_final( &proposal_id, - crate::proposal_channel::ProposalFinalStatus::Cancelled, + crate::agent_tools::action_proposal::channel::ProposalFinalStatus::Cancelled, ); } } diff --git a/tools/wta/src/app_tests.rs b/tools/wta/src/app_tests.rs index 7843572c57..01e2f08ce2 100644 --- a/tools/wta/src/app_tests.rs +++ b/tools/wta/src/app_tests.rs @@ -6832,11 +6832,15 @@ const TERMINAL_AGENT_PROPOSAL_PAYLOAD: &str = r#"{"schema_version":1,"origin":"t fn stage_direct_proposal( app: &mut App, - manager: &std::sync::Arc, + manager: &std::sync::Arc< + crate::agent_tools::action_proposal::channel::ProposalChannelManager, + >, session_id: &str, ) -> ( String, - tokio::sync::oneshot::Receiver, + tokio::sync::oneshot::Receiver< + crate::agent_tools::action_proposal::channel::ProposalFinalStatus, + >, ) { let channel = manager .issue( @@ -6865,7 +6869,7 @@ fn stage_direct_proposal( }); assert_eq!( decision_rx.blocking_recv().unwrap().status, - crate::proposal_channel::ProposalValidationStatus::Accepted + crate::agent_tools::action_proposal::channel::ProposalValidationStatus::Accepted ); let (final_tx, final_rx) = tokio::sync::oneshot::channel(); assert!(manager.accept_validation(&proposal_id, final_tx)); @@ -6877,7 +6881,9 @@ fn direct_proposal_confirm_resolves_waiting_cli() { let mut app = test_app(); let (recommendation_tx, mut recommendation_rx) = tokio::sync::mpsc::unbounded_channel(); app.recommendation_tx = recommendation_tx; - let manager = std::sync::Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + let manager = std::sync::Arc::new( + crate::agent_tools::action_proposal::channel::ProposalChannelManager::new(), + ); app.set_proposal_channels(std::sync::Arc::clone(&manager)); let session_id = "direct-confirm"; stage_proposal_session(&mut app, session_id); @@ -6889,7 +6895,7 @@ fn direct_proposal_confirm_resolves_waiting_cli() { assert_eq!( final_rx.blocking_recv().unwrap(), - crate::proposal_channel::ProposalFinalStatus::Confirmed + crate::agent_tools::action_proposal::channel::ProposalFinalStatus::Confirmed ); let execution = recommendation_rx.try_recv().unwrap(); assert_eq!(execution.context.target_pane_id(), Some("pane-9")); @@ -6898,7 +6904,9 @@ fn direct_proposal_confirm_resolves_waiting_cli() { #[test] fn direct_proposal_cancel_before_commit_does_not_surface() { let mut app = test_app(); - let manager = std::sync::Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + let manager = std::sync::Arc::new( + crate::agent_tools::action_proposal::channel::ProposalChannelManager::new(), + ); app.set_proposal_channels(std::sync::Arc::clone(&manager)); let session_id = "direct-cancel"; stage_proposal_session(&mut app, session_id); @@ -6911,7 +6919,7 @@ fn direct_proposal_cancel_before_commit_does_not_surface() { assert!(app.session_tab(session_id).turn.is_idle()); assert_eq!( final_rx.blocking_recv().unwrap(), - crate::proposal_channel::ProposalFinalStatus::Cancelled + crate::agent_tools::action_proposal::channel::ProposalFinalStatus::Cancelled ); } diff --git a/tools/wta/src/app_turn.rs b/tools/wta/src/app_turn.rs index c7c76e8f66..a9fece1d07 100644 --- a/tools/wta/src/app_turn.rs +++ b/tools/wta/src/app_turn.rs @@ -10,7 +10,7 @@ enum DirectProposalEvaluation { Presented, Duplicate(String), Stale(String), - Rejected(crate::terminal_action_proposal::ProposalError), + Rejected(crate::agent_tools::action_proposal::schema::ProposalError), Unavailable(String), } @@ -177,7 +177,9 @@ impl App { payload: &str, proposal_id: &str, ) -> DirectProposalEvaluation { - use crate::terminal_action_proposal::{build_recommendation_set, parse_proposal_payload}; + use crate::agent_tools::action_proposal::schema::{ + build_recommendation_set, parse_proposal_payload, + }; if !self.session_to_tab.contains_key(session_id) { return DirectProposalEvaluation::Unavailable( @@ -253,11 +255,11 @@ impl App { pub(super) fn evaluate_direct_terminal_action_proposal( &mut self, - context: &crate::proposal_channel::ValidationContext, + context: &crate::agent_tools::action_proposal::channel::ValidationContext, payload: &str, - ) -> crate::proposal_pipe::ProposalValidationDecision { - use crate::proposal_channel::ProposalValidationStatus; - use crate::terminal_action_proposal::ProposalError; + ) -> crate::agent_tools::action_proposal::pipe::ProposalValidationDecision { + use crate::agent_tools::action_proposal::channel::ProposalValidationStatus; + use crate::agent_tools::action_proposal::schema::ProposalError; let binding = &context.binding; match self.validate_and_stage_terminal_action_proposal( @@ -268,17 +270,17 @@ impl App { &context.proposal_id, ) { DirectProposalEvaluation::Presented => { - crate::proposal_pipe::ProposalValidationDecision::accepted() + crate::agent_tools::action_proposal::pipe::ProposalValidationDecision::accepted() } DirectProposalEvaluation::Duplicate(reason) => { - crate::proposal_pipe::ProposalValidationDecision { + crate::agent_tools::action_proposal::pipe::ProposalValidationDecision { status: ProposalValidationStatus::AlreadyConsumed, reason: Some(reason), retryable: false, } } DirectProposalEvaluation::Stale(reason) => { - crate::proposal_pipe::ProposalValidationDecision { + crate::agent_tools::action_proposal::pipe::ProposalValidationDecision { status: ProposalValidationStatus::Stale, reason: Some(reason), retryable: false, @@ -295,14 +297,14 @@ impl App { (ProposalValidationStatus::Rejected, false) } }; - crate::proposal_pipe::ProposalValidationDecision { + crate::agent_tools::action_proposal::pipe::ProposalValidationDecision { status, reason: Some(error.reason()), retryable, } } DirectProposalEvaluation::Unavailable(reason) => { - crate::proposal_pipe::ProposalValidationDecision { + crate::agent_tools::action_proposal::pipe::ProposalValidationDecision { status: ProposalValidationStatus::Unavailable, reason: Some(reason), retryable: false, @@ -603,9 +605,9 @@ impl App { .is_ok(); if let Some(claim) = confirmation_claim { let status = if dispatched { - crate::proposal_channel::ProposalFinalStatus::Confirmed + crate::agent_tools::action_proposal::channel::ProposalFinalStatus::Confirmed } else { - crate::proposal_channel::ProposalFinalStatus::Unavailable + crate::agent_tools::action_proposal::channel::ProposalFinalStatus::Unavailable }; self.proposal_channels.finalize_confirmation(claim, status); if !dispatched { @@ -749,7 +751,7 @@ impl App { if let Some(proposal_id) = direct_proposal_id.as_deref() { self.proposal_channels.resolve_final( proposal_id, - crate::proposal_channel::ProposalFinalStatus::Cancelled, + crate::agent_tools::action_proposal::channel::ProposalFinalStatus::Cancelled, ); } diff --git a/tools/wta/src/cli/proposals.rs b/tools/wta/src/cli/agent_tools.rs similarity index 70% rename from tools/wta/src/cli/proposals.rs rename to tools/wta/src/cli/agent_tools.rs index 7f2e6640f6..957b3a4e14 100644 --- a/tools/wta/src/cli/proposals.rs +++ b/tools/wta/src/cli/agent_tools.rs @@ -1,20 +1,39 @@ use anyhow::{Context, Result}; +use std::path::Path; use tokio::io::{AsyncWriteExt, BufReader}; -pub(crate) async fn run(channel: String, payload: String) -> Result<()> { +pub(crate) async fn run_command_resolution( + token: &str, + shell: &str, + cwd: Option<&Path>, + json_mode: bool, +) -> Result<()> { + let result = crate::agent_tools::command_resolution::resolve(token, shell, cwd).await; + if json_mode { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!( + "{}", + crate::agent_tools::command_resolution::format_human(&result) + ); + } + Ok(()) +} + +pub(crate) async fn run_action_proposal(channel: String, payload: String) -> Result<()> { let channel = channel - .parse::() + .parse::() .context("invalid --channel")?; - if payload.len() > crate::terminal_action_proposal::MAX_PAYLOAD_BYTES { + if payload.len() > crate::agent_tools::action_proposal::schema::MAX_PAYLOAD_BYTES { anyhow::bail!( "--payload-json exceeds the {}-byte inline limit", - crate::terminal_action_proposal::MAX_PAYLOAD_BYTES + crate::agent_tools::action_proposal::schema::MAX_PAYLOAD_BYTES ); } let pipe = open_pipe(&channel.pipe_name()).await?; let (read_half, mut write_half) = tokio::io::split(pipe); - let request = crate::proposal_pipe::ProposalPipeRequest { - version: crate::proposal_pipe::PROTOCOL_VERSION, + let request = crate::agent_tools::action_proposal::pipe::ProposalPipeRequest { + version: crate::agent_tools::action_proposal::pipe::PROTOCOL_VERSION, channel: channel.to_string(), payload, }; @@ -27,15 +46,17 @@ pub(crate) async fn run(channel: String, payload: String) -> Result<()> { write_half.flush().await.context("flush proposal request")?; let mut reader = BufReader::new(read_half); - let validation: crate::proposal_pipe::ProposalValidationResponse = + let validation: crate::agent_tools::action_proposal::pipe::ProposalValidationResponse = read_response(&mut reader).await?; println!("{}", serde_json::to_string(&validation)?); std::io::Write::flush(&mut std::io::stdout()).context("flush validation response")?; - if validation.status != crate::proposal_channel::ProposalValidationStatus::Accepted { + if validation.status + != crate::agent_tools::action_proposal::channel::ProposalValidationStatus::Accepted + { return Ok(()); } - let final_response: crate::proposal_pipe::ProposalFinalResponse = + let final_response: crate::agent_tools::action_proposal::pipe::ProposalFinalResponse = read_response(&mut reader).await?; println!("{}", serde_json::to_string(&final_response)?); Ok(()) @@ -93,7 +114,7 @@ where .iter() .position(|byte| *byte == b'\n') .map_or(available.len(), |index| index + 1); - if line.len() + take > crate::proposal_pipe::MAX_FRAME_BYTES { + if line.len() + take > crate::agent_tools::action_proposal::pipe::MAX_FRAME_BYTES { anyhow::bail!("proposal response exceeds the frame limit"); } line.extend_from_slice(&available[..take]); diff --git a/tools/wta/src/cli/args.rs b/tools/wta/src/cli/args.rs index 50b7b6d7b0..c24114942f 100644 --- a/tools/wta/src/cli/args.rs +++ b/tools/wta/src/cli/args.rs @@ -1,6 +1,9 @@ use clap::{Parser, Subcommand}; -use crate::{agent_hooks_installer, agent_registry, agent_sessions, resolve_command}; +use crate::{ + agent_hooks_installer, agent_registry, agent_sessions, + agent_tools::command_resolution, +}; #[derive(Parser, Debug)] #[command( @@ -233,10 +236,10 @@ pub(crate) enum Command { /// Identify a command using sources applicable to the active shell ResolveCommand { /// Command name to identify (without arguments or a path) - #[arg(value_parser = resolve_command::parse_non_empty)] + #[arg(value_parser = command_resolution::parse_non_empty)] token: String, /// Active shell identity; PowerShell hosts also load their user profile - #[arg(long, default_value = "pwsh.exe", value_parser = resolve_command::parse_non_empty)] + #[arg(long, default_value = "pwsh.exe", value_parser = command_resolution::parse_non_empty)] shell: String, /// Working directory to inspect #[arg(long)] diff --git a/tools/wta/src/cli/mod.rs b/tools/wta/src/cli/mod.rs index 69d046e3c7..42fb58057e 100644 --- a/tools/wta/src/cli/mod.rs +++ b/tools/wta/src/cli/mod.rs @@ -1,8 +1,8 @@ +pub(crate) mod agent_tools; pub(crate) mod args; pub(crate) mod delegate; pub(crate) mod hooks; pub(crate) mod probes; -pub(crate) mod proposals; pub(crate) mod sessions; pub(crate) mod wt; @@ -28,18 +28,12 @@ pub(crate) async fn run(command: Command, json_mode: bool) -> Result<()> { | Command::SetEnv { .. } | Command::Listen { .. }) => wt::run(command, json_mode).await, Command::ResolveCommand { token, shell, cwd } => { - let result = crate::resolve_command::resolve(&token, &shell, cwd.as_deref()).await; - if json_mode { - println!("{}", serde_json::to_string_pretty(&result)?); - } else { - println!("{}", crate::resolve_command::format_human(&result)); - } - Ok(()) + agent_tools::run_command_resolution(&token, &shell, cwd.as_deref(), json_mode).await } - Command::ProposeTerminalActions { - channel, - payload_json, - } => proposals::run(channel, payload_json).await, + Command::ProposeTerminalActions { + channel, + payload_json, + } => agent_tools::run_action_proposal(channel, payload_json).await, Command::Delegate { prompt, agent, diff --git a/tools/wta/src/helper/runtime.rs b/tools/wta/src/helper/runtime.rs index 4887e32c4a..f5821a7191 100644 --- a/tools/wta/src/helper/runtime.rs +++ b/tools/wta/src/helper/runtime.rs @@ -281,14 +281,20 @@ async fn run_acp_app( let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel(); let (prompt_tx, prompt_rx) = tokio::sync::mpsc::unbounded_channel(); let proposal_channels = - Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + Arc::new( + crate::agent_tools::action_proposal::channel::ProposalChannelManager::new(), + ); let (proposal_pipe_tx, mut proposal_pipe_rx) = tokio::sync::mpsc::unbounded_channel(); let proposal_server_manager = Arc::clone(&proposal_channels); let proposal_server_lifecycle = Arc::clone(&proposal_channels); tokio::task::spawn_local(async move { if let Err(error) = - crate::proposal_pipe::run_server(proposal_server_manager, proposal_pipe_tx).await + crate::agent_tools::action_proposal::pipe::run_server( + proposal_server_manager, + proposal_pipe_tx, + ) + .await { proposal_server_lifecycle.set_pipe_available(false); tracing::error!( @@ -302,7 +308,7 @@ async fn run_acp_app( tokio::task::spawn_local(async move { while let Some(event) = proposal_pipe_rx.recv().await { let app_event = match event { - crate::proposal_pipe::ProposalPipeEvent::Validate { + crate::agent_tools::action_proposal::pipe::ProposalPipeEvent::Validate { context, payload, responder, @@ -311,10 +317,12 @@ async fn run_acp_app( payload, responder, }, - crate::proposal_pipe::ProposalPipeEvent::Commit { proposal_id } => { + crate::agent_tools::action_proposal::pipe::ProposalPipeEvent::Commit { + proposal_id, + } => { app::AppEvent::DirectTerminalActionProposalCommit { proposal_id } } - crate::proposal_pipe::ProposalPipeEvent::Invalidate { + crate::agent_tools::action_proposal::pipe::ProposalPipeEvent::Invalidate { proposal_id, session_id, } => app::AppEvent::DirectTerminalActionProposalInvalidate { diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 93a09dbedf..ce117ec546 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -3,6 +3,7 @@ extern crate rust_i18n; mod agent_check; mod agent_hooks_installer; +mod agent_tools; mod agent_pane_origin; mod agent_registry; mod agent_sessions; @@ -23,14 +24,9 @@ mod history_loader; mod locale_parity_tests; mod logging; mod master; -mod named_pipe_security; mod osc52; mod pane_context; -mod proposal_channel; -mod proposal_invocation; -mod proposal_pipe; mod protocol; -mod resolve_command; mod rtl; mod runtime_paths; mod session_history; @@ -39,7 +35,6 @@ mod session_registry; mod session_watcher; mod shell; mod telemetry; -mod terminal_action_proposal; #[cfg(test)] mod test_support; mod text_selection; diff --git a/tools/wta/src/protocol/acp/client.rs b/tools/wta/src/protocol/acp/client.rs index 5ae9a75cd5..7c121b2475 100644 --- a/tools/wta/src/protocol/acp/client.rs +++ b/tools/wta/src/protocol/acp/client.rs @@ -341,7 +341,8 @@ struct ClientState { event_tx: mpsc::UnboundedSender, shell_mgr: Arc, prompt_timing: Arc, - proposal_channels: Arc, + proposal_channels: + Arc, hidden_tool_calls: std::sync::Mutex>, } @@ -650,7 +651,7 @@ impl WtaClient { .permission_requested(&session_id, &description); if let Some(command) = canonical_proposal_permission_command(&args) { - match crate::proposal_invocation::parse(command) { + match crate::agent_tools::action_proposal::invocation::parse(command) { Ok(invocation) => { let Some(option) = args.options.iter().find(|option| { option.kind == acp::schema::v1::PermissionOptionKind::AllowOnce @@ -1350,7 +1351,8 @@ pub async fn run_acp_client_over_pipe( shell_mgr: Arc, wt_connected: bool, post_login_reconnect: bool, - proposal_channels: Arc, + proposal_channels: + Arc, ) -> Result<()> { let startup_probe = StartupProbe::new(); startup_probe.log(&format!( @@ -2761,7 +2763,8 @@ fn dispatch_prompt( wt_connected: bool, is_agent_pane: bool, proposal_commands_supported: bool, - proposal_channels: &Arc, + proposal_channels: + &Arc, ) { let tab_key = prompt .pane_context @@ -2826,7 +2829,8 @@ async fn dispatch_prompt_body( wt_connected: bool, is_agent_pane: bool, proposal_commands_supported: bool, - proposal_channels: Arc, + proposal_channels: + Arc, ) { // Resolve (or lazily create) the ACP session for this tab. let prompt_session_id = { @@ -3094,7 +3098,7 @@ mod tests { } fn proposal_test_client( - manager: Arc, + manager: Arc, ) -> (WtaClient, mpsc::UnboundedReceiver) { let (event_tx, event_rx) = mpsc::unbounded_channel(); let state = Arc::new(ClientState { @@ -3109,12 +3113,15 @@ mod tests { #[tokio::test] async fn canonical_proposal_permission_is_silent_and_arms_payload() { - let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + let manager = Arc::new( + crate::agent_tools::action_proposal::channel::ProposalChannelManager::new(), + ); let payload = r#"{"schema_version":1,"origin":"terminal_agent","choices":[{"choice":1,"title":"run test","rationale":"","actions":[{"type":"send","input":"cargo test"}]}]}"#; let channel = manager .issue("proposal-session".into(), 1, None, false) .unwrap(); - let command = crate::proposal_invocation::render(&channel, payload).unwrap(); + let command = + crate::agent_tools::action_proposal::invocation::render(&channel, payload).unwrap(); let (client, mut event_rx) = proposal_test_client(Arc::clone(&manager)); let response = client @@ -3138,7 +3145,9 @@ mod tests { #[tokio::test] async fn noncanonical_proposal_permission_is_silently_cancelled() { - let manager = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + let manager = Arc::new( + crate::agent_tools::action_proposal::channel::ProposalChannelManager::new(), + ); let channel = manager .issue("proposal-session".into(), 1, None, false) .unwrap(); @@ -3537,7 +3546,9 @@ mod tests { event_tx: tx, shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(super::super::PromptTimingState::default()), - proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), + proposal_channels: Arc::new( + crate::agent_tools::action_proposal::channel::ProposalChannelManager::new(), + ), hidden_tool_calls: std::sync::Mutex::new(std::collections::HashSet::new()), }); (WtaClient { state }, rx) diff --git a/tools/wta/src/protocol/acp/mock_agent_tests.rs b/tools/wta/src/protocol/acp/mock_agent_tests.rs index 6a2d5173c1..8644817518 100644 --- a/tools/wta/src/protocol/acp/mock_agent_tests.rs +++ b/tools/wta/src/protocol/acp/mock_agent_tests.rs @@ -327,7 +327,9 @@ fn connect_with( event_tx, shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(PromptTimingState::default()), - proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), + proposal_channels: Arc::new( + crate::agent_tools::action_proposal::channel::ProposalChannelManager::new(), + ), hidden_tool_calls: Mutex::new(HashSet::new()), }); let wta = WtaClient { state }; @@ -639,7 +641,8 @@ pub(crate) struct DispatchHarness { pub event_rx: mpsc::UnboundedReceiver, pub shell_mgr: Arc, pub prompt_timing: Arc, - pub proposal_channels: Arc, + pub proposal_channels: + Arc, pub seen_prompts: Arc>>, /// Agent-side record of every image content block (mime, base64) assembled /// onto the wire — the Alt+V image-paste assertion target. @@ -662,7 +665,9 @@ fn connect_for_dispatch(behavior: MockBehavior) -> DispatchHarness { let (event_tx, event_rx) = mpsc::unbounded_channel(); let shell_mgr = Arc::new(ShellManager::new()); let prompt_timing = Arc::new(PromptTimingState::default()); - let proposal_channels = Arc::new(crate::proposal_channel::ProposalChannelManager::new()); + let proposal_channels = Arc::new( + crate::agent_tools::action_proposal::channel::ProposalChannelManager::new(), + ); let state = Arc::new(ClientState { event_tx: event_tx.clone(), shell_mgr: shell_mgr.clone(), @@ -1730,7 +1735,9 @@ fn bare_client() -> (WtaClient, mpsc::UnboundedReceiver) { event_tx, shell_mgr: Arc::new(ShellManager::new()), prompt_timing: Arc::new(PromptTimingState::default()), - proposal_channels: Arc::new(crate::proposal_channel::ProposalChannelManager::new()), + proposal_channels: Arc::new( + crate::agent_tools::action_proposal::channel::ProposalChannelManager::new(), + ), hidden_tool_calls: Mutex::new(HashSet::new()), }); (WtaClient { state }, event_rx) diff --git a/tools/wta/src/protocol/acp/prompt_context.rs b/tools/wta/src/protocol/acp/prompt_context.rs index 94cb69cda8..1e6428188b 100644 --- a/tools/wta/src/protocol/acp/prompt_context.rs +++ b/tools/wta/src/protocol/acp/prompt_context.rs @@ -301,7 +301,8 @@ async fn resolve_pane_by_session_id( struct PlannerTerminalContext { json: String, target_pane_id: String, - resolver_invocation: Option, + resolver_invocation: + Option, } async fn build_terminal_context( @@ -396,7 +397,7 @@ pub(super) struct ResolvedProviderContext { pub(super) planner_terminal_context: Option, pub(super) resolved_planner_pane: Option, pub(super) command_resolver_invocation: - Option, + Option, } pub(super) async fn resolve_provider_context( @@ -523,7 +524,7 @@ pub(super) struct ContextRequest<'a> { pub(super) planner_terminal_context: Option<&'a str>, /// Planner only: resolver contract derived from the same authoritative pane. pub(super) command_resolver_invocation: - Option<&'a crate::resolve_command::CommandResolverInvocation>, + Option<&'a crate::agent_tools::command_resolution::CommandResolverInvocation>, } /// One `### {heading}\n{body}` block to inject into the prompt. `heading` is @@ -591,9 +592,11 @@ pub(super) fn command_resolver_invocation( is_autofix: bool, planner_shell: Option<&str>, planner_pane: Option<&serde_json::Value>, -) -> Option { +) -> Option { if is_autofix - || planner_shell.is_some_and(|shell| !crate::resolve_command::has_applicable_source(shell)) + || planner_shell.is_some_and(|shell| { + !crate::agent_tools::command_resolution::has_applicable_source(shell) + }) { return None; } @@ -618,7 +621,7 @@ pub(super) fn command_resolver_invocation( } } - Some(crate::resolve_command::CommandResolverInvocation::new( + Some(crate::agent_tools::command_resolution::CommandResolverInvocation::new( executable, shell, cwd, )) }