diff --git a/doc/release-check-list.md b/doc/release-check-list.md index f04438e7bf..417931a8c6 100644 --- a/doc/release-check-list.md +++ b/doc/release-check-list.md @@ -156,11 +156,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 @@ -188,7 +188,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 @@ -217,8 +217,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 @@ -406,4 +406,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 new file mode 100644 index 0000000000..914b4da780 --- /dev/null +++ b/doc/specs/WTA-CLI-terminal-action-proposals.md @@ -0,0 +1,357 @@ +# WTA CLI terminal-action proposals + +## Status + +Implemented direct-only design. Terminal-action proposals travel from a +short-lived WTA CLI to the owning Helper. + +## 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. + +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 +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. + +## 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. +- 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 '' +``` + +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. + +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 | +|---|---| +| 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. + +## Validation + +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: + +- 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 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. + +## 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/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/test/e2e/README.md b/test/e2e/README.md index a0491c7e63..cd3c17ca77 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 | |---|---|---| @@ -19,12 +20,12 @@ environment. Current status (run on the Store package): | `Feature.AgentPaneInteraction.Tests.ps1` | open/hide/focus, input/rendering, slash, Copilot chat | 14 | | `Feature.AgentMouse.Tests.ps1` | PR #506: chat wheel scrolling, draft preservation, text selection/copy, and stale-selection suppression | 2 | | `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/Cargo.lock b/tools/wta/Cargo.lock index 8e002f15c2..71f9b2b21c 100644 --- a/tools/wta/Cargo.lock +++ b/tools/wta/Cargo.lock @@ -3353,6 +3353,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 e4e70819b2..7185a3484f 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..49d596be92 100644 --- a/tools/wta/prompts/auto-fix.md +++ b/tools/wta/prompts/auto-fix.md @@ -4,17 +4,21 @@ A command failed. Diagnose the error from the terminal output and shell context --- -## Output +## Decision -Return exactly one JSON object in a fenced ```json block. No prose around it. +### `fix` — submit one deterministic command -### `fix` — one deterministic command resolves it +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. -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. +When the runtime includes an `[intellterm.wta proposal]` block, immediately submit this compact payload through the exact command shown there: -```json -{"action": "fix", "title": "<≤6 word summary>", "command": "", "rationale": ""} -``` +`{"schema_version":1,"origin":"autofix","choices":[{"choice":1,"title":"<≤6 word summary>","rationale":"","actions":[{"type":"send","input":""}]}]}` + +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. + +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. + +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`). @@ -24,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 e30aac115f..a5af17eb94 100644 --- a/tools/wta/prompts/terminal-agent.md +++ b/tools/wta/prompts/terminal-agent.md @@ -1,173 +1,45 @@ # 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, 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. -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. +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. -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. +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. -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.** +## Recommendation cards -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. +Recommend and Delegate return 1-3 numbered choices with 1-3 actions each. Keep titles short and non-empty and rationales to one sentence. -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 -### Tie-breakers +If you can execute shell commands and runtime contains `[intellterm.wta proposal]`, submit one compact object: -- 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. +`{"schema_version":1,"origin":"terminal_agent","recommended_choice":1,"choices":[{"choice":1,"title":"...","rationale":"...","actions":[...]}]}` -## Chat answers that need investigation +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. -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. +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. -**Sample — the user asks: "How do I use `deploy-it`?"** (you don't recognize `deploy-it`) +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. -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)." +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 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`?" +## Self-execute rules -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. +- 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. -## Self-Execute Rules (Mode B) +## Runtime context -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 0958436581..5b8cfce3f9 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -39,10 +39,7 @@ struct DeferredAcpParams { owner_tab_id: Option, } -fn agent_command_on_enter( - input: &str, - selected: Option<&AvailableAgent>, -) -> Option { +fn agent_command_on_enter(input: &str, selected: Option<&AvailableAgent>) -> Option { commands::agent_id_prefix(input)?; Some(ParsedCommand { kind: CommandKind::Agent, @@ -59,6 +56,7 @@ use autofix::*; #[cfg(test)] use input_edit::{next_word_boundary, prev_word_boundary, INPUT_HISTORY_MAX_ENTRIES}; +pub(crate) use tab_state::PendingTerminalActionProposal; pub(crate) use tab_state::DEFAULT_TAB_ID; pub use tab_state::{ collapsed_prompt_preview, AgentsViewState, ChatMessage, CompletedTurn, PermissionState, Scroll, @@ -106,15 +104,14 @@ pub fn resolve_sessions_origin_filter() -> crate::agent_sessions::OriginFilter { } } -use crate::commands::{self, CommandKind, ParseOutcome, ParsedCommand}; pub use crate::app_contracts::{ AcpModelInfo, AppEvent, AvailableAgent, CheckStatus, DebugDir, DebugMessage, PermOption, PlanEntry, PlanEntryStatus, PreflightResult, }; +use crate::commands::{self, CommandKind, 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, validate_recommendation_set_for_coordinator_target, + RecommendationChoice, RecommendationSet, }; use crate::pane_context::PaneContext; @@ -537,13 +534,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(); @@ -687,7 +690,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, @@ -988,8 +991,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, @@ -1043,6 +1045,7 @@ 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, } /// How long the close-pane arm (localized via `system.close_pane_hint`) stays live. Long @@ -1064,10 +1067,10 @@ pub const SELECTION_COPIED_HINT_WINDOW: std::time::Duration = 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, } @@ -1214,10 +1217,18 @@ 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()), shell_mgr, } } + 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). @@ -1381,31 +1392,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); 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, - agent_source, - source_cwd, - 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, + agent_source, + source_cwd, + 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, + ) + .await { tracing::error!( target: "helper", @@ -1618,11 +1630,7 @@ impl App { let source = crate::agent_source::AgentSource::Host; AvailableAgent { id: profile.id.to_string(), - display_name: format!( - "{} — {}", - profile.display_name, - source.display_suffix() - ), + display_name: format!("{} — {}", profile.display_name, source.display_suffix()), source, } }) @@ -1641,8 +1649,8 @@ impl App { let allowlist_present = self.host_agent_allowlist_present; tokio::task::spawn_local(async move { let active_pane = shell_mgr.wt_get_active_pane().await.ok(); - let Some(distro) = - crate::agent_source::active_pane_wsl_distro(active_pane.as_ref()).map(str::to_string) + let Some(distro) = crate::agent_source::active_pane_wsl_distro(active_pane.as_ref()) + .map(str::to_string) else { let _ = event_tx.send(AppEvent::AgentSourcesDiscovered { generation, @@ -1655,8 +1663,7 @@ impl App { let candidates = crate::agent_registry::KNOWN_AGENTS .iter() .filter(|profile| { - !allowlist_present - || allowed_agent_ids.iter().any(|id| id == profile.id) + !allowlist_present || allowed_agent_ids.iter().any(|id| id == profile.id) }) .map(|profile| (profile.id, profile.display_name)); let wsl_sources = futures::stream::iter(candidates) @@ -1666,8 +1673,7 @@ impl App { crate::agent_check::wsl_agent_available(&distro, id) .await .then(|| { - let source = - crate::agent_source::AgentSource::Wsl { distro }; + let source = crate::agent_source::AgentSource::Wsl { distro }; AvailableAgent { id: id.to_string(), display_name: format!( @@ -1716,8 +1722,7 @@ impl App { } self.refresh_available_agents(); - let selected = - Self::find_host_agent_for_command(&self.available_agents, arg).cloned(); + let selected = Self::find_host_agent_for_command(&self.available_agents, arg).cloned(); match selected { Some(agent) => self.apply_agent_pick(agent), None => { @@ -2107,8 +2112,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() { @@ -2355,8 +2359,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(), @@ -2374,7 +2377,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); @@ -2510,15 +2514,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", @@ -2765,8 +2776,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) { @@ -3065,7 +3077,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, @@ -3511,6 +3526,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", @@ -3533,6 +3549,13 @@ impl App { AppEvent::AgentsSnapshotFailed { .. } => "agents_snapshot_failed", AppEvent::RegisterBornBoundSession { .. } => "register_born_bound_session", AppEvent::MasterMutationCompleted { .. } => "master_mutation_completed", + AppEvent::DirectTerminalActionProposal { .. } => "direct_terminal_action_proposal", + AppEvent::DirectTerminalActionProposalCommit { .. } => { + "direct_terminal_action_proposal_commit" + } + AppEvent::DirectTerminalActionProposalInvalidate { .. } => { + "direct_terminal_action_proposal_invalidate" + } AppEvent::RevealTick => "reveal_tick", } } @@ -3604,9 +3627,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(), @@ -3617,11 +3638,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(); @@ -3669,7 +3688,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, @@ -3692,7 +3712,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(""); @@ -3787,14 +3810,12 @@ impl App { "inserted pasted text into agent input" ); } - } #[path = "app_events.rs"] mod app_events; impl App { - fn event_requires_redraw(&self, event: &AppEvent) -> bool { match event { AppEvent::Tick => self.has_activity_indicator() || self.show_notification_banner, @@ -3853,14 +3874,12 @@ impl App { tab.reveal_chars = (tab.reveal_chars + step).min(len); } } - } #[path = "app_keys.rs"] mod app_keys; impl App { - fn scroll_to_bottom(&mut self) { self.current_tab_mut().scroll_to_bottom(); } @@ -4070,16 +4089,14 @@ impl App { } else { commands::agent_id_prefix(&self.current_tab().input) }; - self.available_agents - .iter() - .filter(move |agent| { - prefix.is_some_and(|prefix| { - agent - .id - .get(..prefix.len()) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix)) - }) + self.available_agents.iter().filter(move |agent| { + prefix.is_some_and(|prefix| { + agent + .id + .get(..prefix.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix)) }) + }) } fn selected_agent_command_candidate(&self) -> Option<&AvailableAgent> { @@ -4133,7 +4150,10 @@ impl App { } let prefix = commands::agent_id_prefix(&tab.input)?; let candidate = self.selected_agent_command_candidate()?; - candidate.id.get(prefix.len()..).filter(|suffix| !suffix.is_empty()) + candidate + .id + .get(prefix.len()..) + .filter(|suffix| !suffix.is_empty()) } /// Per-frame state for the `/model` picker modal, or `None` when it's not @@ -4283,7 +4303,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 @@ -4368,9 +4390,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; } @@ -4378,12 +4399,10 @@ impl App { .tab_id .clone() .unwrap_or_else(|| DEFAULT_TAB_ID.to_string()); - let _ = self - .new_session_tx - .send(NewSessionForTab { - tab_id, - cwd: self.source_cwd.clone(), - }); + let _ = self.new_session_tx.send(NewSessionForTab { + tab_id, + cwd: self.source_cwd.clone(), + }); let tab = self.current_tab_mut(); tab.clear_chat_history(); tab.completed_turns.clear(); @@ -5025,7 +5044,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 @@ -5057,7 +5075,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, @@ -5342,7 +5359,6 @@ fn build_switch_agent_event( .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 diff --git a/tools/wta/src/app/tab_state.rs b/tools/wta/src/app/tab_state.rs index cae7235e04..568d7da19c 100644 --- a/tools/wta/src/app/tab_state.rs +++ b/tools/wta/src/app/tab_state.rs @@ -166,6 +166,14 @@ impl Scroll { } } +pub(crate) struct PendingTerminalActionProposal { + pub proposal_id: String, + pub session_id: String, + pub prompt_id: u64, + pub is_autofix: bool, + pub recommendations: super::RecommendationSet, +} + /// 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. @@ -178,6 +186,8 @@ impl Scroll { pub struct TabSession { /// Per-tab autofix state machine (see `TabAutofixState`). pub autofix: TabAutofixState, + pub(crate) pending_terminal_action_proposal: Option, + pub(crate) active_direct_proposal_id: Option, // Conversation history pub messages: Vec, diff --git a/tools/wta/src/app_contracts/event.rs b/tools/wta/src/app_contracts/event.rs index 10bf73816b..6ad7f8c429 100644 --- a/tools/wta/src/app_contracts/event.rs +++ b/tools/wta/src/app_contracts/event.rs @@ -112,6 +112,10 @@ pub enum AppEvent { /// meaningful when `location.is_some()`. location_is_command: bool, }, + HideToolCall { + session_id: String, + id: String, + }, Plan { session_id: String, entries: Vec, @@ -169,6 +173,18 @@ pub enum AppEvent { AliveSessionRemoved(agent_client_protocol::schema::v1::SessionId), 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, diff --git a/tools/wta/src/app_events.rs b/tools/wta/src/app_events.rs index 71e8b76003..1c21b555fb 100644 --- a/tools/wta/src/app_events.rs +++ b/tools/wta/src/app_events.rs @@ -38,34 +38,32 @@ impl App { self.text_selection.clear(); self.handle_key(key); } - AppEvent::Mouse(mouse) => { - match mouse.kind { - crossterm::event::MouseEventKind::ScrollUp - | crossterm::event::MouseEventKind::ScrollDown - if self.mode == AppMode::Chat - && self.current_tab().current_view == View::Chat => - { - self.text_selection.clear(); - let lines = if mouse.modifiers.contains(KeyModifiers::ALT) { - 1 - } else { - 3 - }; - match mouse.kind { - crossterm::event::MouseEventKind::ScrollUp => { - self.current_tab_mut().chat_scroll.by(lines); - } - crossterm::event::MouseEventKind::ScrollDown => { - self.current_tab_mut().chat_scroll.by(-lines); - } - _ => {} + AppEvent::Mouse(mouse) => match mouse.kind { + crossterm::event::MouseEventKind::ScrollUp + | crossterm::event::MouseEventKind::ScrollDown + if self.mode == AppMode::Chat + && self.current_tab().current_view == View::Chat => + { + self.text_selection.clear(); + let lines = if mouse.modifiers.contains(KeyModifiers::ALT) { + 1 + } else { + 3 + }; + match mouse.kind { + crossterm::event::MouseEventKind::ScrollUp => { + self.current_tab_mut().chat_scroll.by(lines); } - } - _ => { - self.text_selection.handle_mouse(mouse); + crossterm::event::MouseEventKind::ScrollDown => { + self.current_tab_mut().chat_scroll.by(-lines); + } + _ => {} } } - } + _ => { + self.text_selection.handle_mouse(mouse); + } + }, AppEvent::AgentPasteTextReady { tab_id, generation, @@ -277,6 +275,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(); } @@ -341,6 +340,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(); @@ -582,16 +582,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` @@ -690,6 +682,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, @@ -935,6 +934,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, @@ -948,9 +969,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() { @@ -1036,10 +1055,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); @@ -1061,9 +1077,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, @@ -1497,9 +1511,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(), }; @@ -1578,7 +1590,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", @@ -1602,14 +1615,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!( @@ -1633,11 +1642,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); } } @@ -1699,9 +1704,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 @@ -1727,11 +1731,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( @@ -1908,8 +1909,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 @@ -1940,7 +1949,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, diff --git a/tools/wta/src/app_tests.rs b/tools/wta/src/app_tests.rs index a9c4880d1a..396a76a491 100644 --- a/tools/wta/src/app_tests.rs +++ b/tools/wta/src/app_tests.rs @@ -111,7 +111,10 @@ fn agent_paste_text_inserts_into_owner_chat_input_without_submitting() { 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] @@ -143,8 +146,14 @@ fn agent_paste_text_ignores_wrong_window_and_non_owner_helpers() { 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!( @@ -161,8 +170,15 @@ fn agent_paste_text_ignores_missing_owner_or_window() { 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" }); @@ -268,7 +284,10 @@ fn stale_agent_paste_completion_is_ignored() { 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] @@ -285,7 +304,11 @@ fn agent_paste_text_ignores_auth_and_setup_modes_before_reading_clipboard() { 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 { @@ -294,7 +317,11 @@ fn agent_paste_text_ignores_auth_and_setup_modes_before_reading_clipboard() { 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] @@ -317,12 +344,18 @@ fn copilot_sidekick_hook_session_is_ignored() { |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 @@ -341,9 +374,7 @@ fn copilot_sidekick_hook_session_is_ignored() { /// `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 { @@ -367,15 +398,14 @@ fn sessionless_notification_falls_back_to_recent_live_cli_session() { }); 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, @@ -410,9 +440,7 @@ fn sessionless_notification_falls_back_to_recent_live_cli_session() { /// 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"; @@ -437,13 +465,19 @@ fn copilot_tool_finished_keeps_working_only_agent_stop_idles() { // 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, @@ -472,9 +506,7 @@ fn copilot_tool_finished_keeps_working_only_agent_stop_idles() { /// 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. @@ -502,9 +534,7 @@ fn notification_with_real_session_id_skips_fallback() { "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, @@ -524,9 +554,7 @@ fn notification_with_real_session_id_skips_fallback() { /// 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(), @@ -779,7 +807,10 @@ fn helper_agent_event_with_real_agent_session_id_still_publishes_to_master() { 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() -> ( @@ -1446,7 +1477,9 @@ fn pack_replayed_messages_groups_into_collapsed_turns() { 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()); @@ -1531,10 +1564,14 @@ fn session_attached_for_load_target_packs_replayed_history() { }); // 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(), @@ -1918,9 +1955,9 @@ fn born_bound_registration_uses_current_master_request_sender() { .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:?}"), } } @@ -2133,7 +2170,8 @@ fn shell_only_filter_applies_to_registry_fallback_path() { 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); @@ -2160,7 +2198,9 @@ fn agents_rows_snapshot_preserves_wsl_location() { 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]); @@ -2174,7 +2214,9 @@ fn agents_rows_snapshot_preserves_wsl_location() { ); 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" ); } @@ -2198,7 +2240,9 @@ fn render_sessions_view_paints_wsl_distro_tag() { 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]); @@ -2226,17 +2270,29 @@ fn resolve_sessions_origin_filter_respects_env_override() { 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"); } @@ -2500,7 +2556,10 @@ fn agents_view_loading_shows_during_f5_rescan() { // 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. @@ -2607,24 +2666,21 @@ fn session_search_filters_navigation_and_enter_dispatch() { 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); @@ -2692,7 +2748,6 @@ fn session_search_is_hidden_until_slash_and_escape_clears_it() { View::Agents, "the first Esc dismisses search instead of closing session management" ); - } // Esc out of the session-management (Agents) view restores the pane @@ -2731,7 +2786,8 @@ fn esc_from_session_view_refolds_when_entered_from_folded_pane() { "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" ); } @@ -2873,7 +2929,9 @@ fn enter_on_history_row_dispatches_new_tab_with_resume() { 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 ); @@ -2888,9 +2946,7 @@ fn enter_on_history_row_dispatches_new_tab_with_resume() { // 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 ); @@ -3567,19 +3623,11 @@ fn post_login_recovery_route_covers_pipe_connect_without_external_auth_gate() { 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" ); @@ -3766,8 +3814,7 @@ fn auth_error_routes_to_signin_not_connection_lost() { 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, @@ -3955,7 +4002,10 @@ fn ghost_agent_binding_does_not_suppress_shell_failure() { 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(), @@ -4342,8 +4392,7 @@ fn osc133_prompt_start_in_agent_pane_origin_is_ignored() { .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); @@ -4369,8 +4418,7 @@ fn osc133_prompt_start_in_agent_pane_origin_is_ignored() { assert!( matches!( after.status, - crate::agent_sessions::AgentStatus::Idle - | crate::agent_sessions::AgentStatus::Working + crate::agent_sessions::AgentStatus::Idle | crate::agent_sessions::AgentStatus::Working ), "agent-pane row must stay Live on OSC 133;A; got {:?}", after.status, @@ -4411,7 +4459,6 @@ fn submit_test_prompt(app: &mut App, text: &str) { 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 @@ -4419,9 +4466,11 @@ async fn mock_agent_reply_streams_into_app_chat() { // 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 @@ -4456,7 +4505,10 @@ async fn mock_agent_reply_streams_into_app_chat() { } }) .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. @@ -4479,12 +4531,13 @@ async fn mock_agent_reply_streams_into_app_chat() { 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 @@ -4516,7 +4569,10 @@ async fn run_permission_scenario(expected_keys: &[KeyCode], want: &str) { } }) .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. @@ -4589,10 +4645,7 @@ async fn permission_reject_round_trips_to_agent() { 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; } @@ -4669,8 +4722,16 @@ fn permission_request_keeps_thinking_until_turn_ends() { target: None, target_is_command: false, 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(), + }, ], responder, }); @@ -4693,15 +4754,16 @@ fn permission_request_keeps_thinking_until_turn_ends() { 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 @@ -4734,9 +4796,9 @@ async fn tool_call_surfaces_card_in_chat() { 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 {:?}", @@ -4775,14 +4837,14 @@ async fn pump_until( /// 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 @@ -4858,13 +4920,48 @@ async fn tool_call_completion_updates_card_status() { _ => 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; } +#[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(), + location: None, + location_is_command: false, + }); + 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() { @@ -4879,7 +4976,10 @@ async fn plan_surfaces_card_in_chat() { 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()), @@ -5527,7 +5627,10 @@ fn begin_auth_checking_clears_stale_status() { 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 \ @@ -5554,9 +5657,16 @@ fn esc_collapse_clears_enterprise_failure_status() { 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" @@ -5631,11 +5741,22 @@ fn auth_enterprise_domain_entry_via_keys() { .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(), @@ -5690,7 +5811,10 @@ fn show_copilot_auth_screen_sets_expected_state() { 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"); @@ -5839,7 +5963,6 @@ fn input_box_titles_queued_images() { ); } - /// 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 @@ -5895,9 +6018,12 @@ fn render_chat_all_message_variants() { { 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![ @@ -6122,7 +6248,11 @@ fn fix_target_pane_is_late_bound_by_prompt_id() { // 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] @@ -6178,7 +6308,7 @@ fn thought_chunk_first_transitions_with_empty_buf() { } #[test] -fn structured_stream_keeps_thinking_after_explanation_is_visible() { +fn assistant_json_is_visible_activity() { let mut app = test_app(); submit_test_prompt(&mut app, "hi"); app.turn_observe_chunk( @@ -6187,15 +6317,10 @@ fn structured_stream_keeps_thinking_after_explanation_is_visible() { r#"{"kind":"explanation""#, ); app.advance_reveal(); - assert!(app.current_tab().should_show_thinking()); - - app.turn_observe_chunk( - DEFAULT_TAB_ID, - ChunkKind::Message, - r#","explanation":"Visible answer"}"#, + assert!( + app.current_tab().should_show_thinking(), + "Thinking persists throughout the in-flight turn (direct-only architecture)" ); - app.advance_reveal(); - assert!(app.current_tab().should_show_thinking()); } #[test] @@ -6234,8 +6359,11 @@ fn thinking_is_pinned_one_row_above_input() { app.state = ConnectionState::Connected; submit_test_prompt(&mut app, "inspect"); - let input_height = - crate::ui::input_height(&app.current_tab().input, app.current_tab().cursor_pos, WIDTH); + let input_height = crate::ui::input_height( + &app.current_tab().input, + app.current_tab().cursor_pos, + WIDTH, + ); let text = render_to_text(&mut app, WIDTH, HEIGHT); let label = t!("chat.activity_thinking").into_owned(); let row = text @@ -6244,14 +6372,16 @@ fn thinking_is_pinned_one_row_above_input() { .expect("Thinking row must render"); let expected_row = usize::from(HEIGHT - input_height - 1); - assert_eq!(row, expected_row, "Thinking must sit directly above the input box"); + assert_eq!( + row, expected_row, + "Thinking must sit directly above the input box" + ); } #[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, @@ -6273,7 +6403,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?"); @@ -6394,19 +6524,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()); @@ -6414,11 +6536,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 @@ -6433,46 +6555,61 @@ 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 - ); assert!( - !tab.turn.accepts_new_prompt(), - "end_pending=true must hold the UI gate" + matches!(app.current_tab().turn, TurnState::Streaming { .. }), + "assistant text must not surface a recommendation card" ); - // 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 ─────────────────────────────────────────── use crate::app::turn_state::{SubmittedPrompt, TurnOutcome, TurnState}; -use crate::coordinator::{ - OpenTarget, RecommendationChoice, RecommendationSet, RecommendedAction, -}; +use crate::coordinator::{OpenTarget, RecommendationChoice, RecommendationSet, RecommendedAction}; use crate::ui::card::{card_content_width, CARD_H_CHROME, CARD_MIN_SIZE}; fn perm_with(desc: &str) -> PermissionState { @@ -6704,7 +6841,8 @@ fn rec_card_height_matches_predict_and_render_paths() { fn mouse_wheel_scrolls_chat_without_changing_input_history() { use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind}; let mut app = test_app(); - app.current_tab_mut().record_input_history("previous prompt"); + app.current_tab_mut() + .record_input_history("previous prompt"); app.current_tab_mut().chat_scroll.set_max(20); app.handle_event(AppEvent::Mouse(MouseEvent { @@ -6889,11 +7027,10 @@ fn submitting_prompt_records_only_that_tab_history() { 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] @@ -7041,7 +7178,8 @@ fn typing_returns_to_input_after_clearing_selection() { 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(); @@ -7134,12 +7272,7 @@ fn chip_target_returns_none_when_idle() { #[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()), @@ -7170,12 +7303,7 @@ fn chip_target_filters_empty_autofix_target() { // 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); } @@ -7213,12 +7341,7 @@ fn chip_recompute_dedupes_and_releases_on_idle() { // 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, @@ -7229,26 +7352,26 @@ fn chip_recompute_dedupes_and_releases_on_idle() { // 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] @@ -7256,22 +7379,24 @@ fn enter_on_wsl_history_row_resumes_inside_distro() { 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]); @@ -7285,7 +7410,9 @@ fn enter_on_wsl_history_row_resumes_inside_distro() { 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 @@ -7295,5 +7422,506 @@ fn enter_on_wsl_history_row_resumes_inside_distro() { "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" + ); +} + +// ─── Direct Helper proposal validation and staging ──────────────────── + +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, +) { + app.turn_submit_prompt( + sid, + SubmittedPrompt { + id: 99, + text: text.into(), + submitted_at_unix_s: 0.0, + autofix, + }, + ); +} + +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, + ) +} + +fn stage_direct_proposal_with_manager( + app: &mut App, + manager: &Arc, + sid: &str, +) -> ( + String, + crate::proposal_channel::ProposalChannel, + tokio::sync::oneshot::Receiver, +) { + let channel = manager + .issue(sid.to_string(), 99, Some("pane-9".to_string()), false) + .expect("issue direct proposal channel"); + manager + .arm(sid, &channel, TERMINAL_AGENT_PROPOSAL_PAYLOAD.as_bytes()) + .expect("arm direct proposal channel"); + let context = manager + .begin_validation(&channel, TERMINAL_AGENT_PROPOSAL_PAYLOAD.as_bytes()) + .expect("begin direct proposal validation"); + 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)); + (proposal_id, channel, final_rx) +} + +#[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 (proposal_id, channel, final_rx) = + stage_direct_proposal_with_manager(&mut app, &manager, sid); + + 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()); + 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] +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 (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 }); + + 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 (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); + 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()); + assert!(app.commit_terminal_action_proposal(&proposal_id)); + assert!(matches!( + app.session_tab(sid).turn, + TurnState::Surfaced { + outcome: TurnOutcome::Recommendation(_), + end_pending: true, + .. + } + )); +} + +#[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!(app.commit_terminal_action_proposal(&proposal_id)); + let TurnState::Surfaced { + outcome: TurnOutcome::Recommendation(set), + .. + } = &app.session_tab(sid).turn + else { + panic!("expected a surfaced recommendation"); + }; + assert_eq!(set.choices.len(), 1); +} + +#[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") + ); +} + +#[test] +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); + submit_prompt_for_session(&mut app, sid, "please help", None); + 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); + assert!( + matches!(app.session_tab(sid).turn, TurnState::Streaming { .. }), + "assistant JSON must remain ordinary streaming text" + ); + + 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!(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] +fn direct_proposal_card_survives_turn_close_without_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 + ); + assert!(app.commit_terminal_action_proposal(&proposal_id)); + + app.turn_close(sid); + let tab = app.session_tab(sid); + assert_eq!(tab.completed_turns.len(), 1); + assert!(matches!( + tab.turn, + TurnState::Surfaced { + end_pending: false, + outcome: TurnOutcome::Recommendation(_), + .. + } + )); +} + +#[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); + 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 + ); +} + +#[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, + }), + ); + 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 + ); +} + +#[test] +fn direct_proposal_rejects_unsupported_schema_version() { + let mut app = test_app(); + let sid = "sess-proposal-bad-schema"; + 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.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(); + let sid = "sess-proposal-origin-mismatch"; + stage_proposal_session(&mut app, sid); + 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::Rejected + ); + assert!(!decision.retryable); + assert!(decision.reason.unwrap().contains("does not match")); +} + +#[test] +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); + 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(); + 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 + ); } diff --git a/tools/wta/src/app_turn.rs b/tools/wta/src/app_turn.rs index 9674e19534..8a1e6ba5b7 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 // @@ -69,6 +77,8 @@ impl App { tab.scroll_to_bottom(); 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 @@ -84,8 +94,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. @@ -140,64 +149,227 @@ 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; + /// 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, + ) -> DirectProposalEvaluation { + use crate::terminal_action_proposal::{build_recommendation_set, parse_proposal_payload}; + + if !self.session_to_tab.contains_key(sid) { + return DirectProposalEvaluation::Unavailable( + "session is not bound to this helper".to_string(), + ); + } + + match &self.session_tab(sid).turn { + TurnState::Surfaced { .. } => { + return DirectProposalEvaluation::Duplicate( + "a card is already showing for this turn".to_string(), + ); + } + TurnState::Idle => { + 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 DirectProposalEvaluation::Stale( + "proposal belongs to an earlier prompt".to_string(), + ); } - let buf = buf.clone(); - let is_autofix = tab.turn.is_autofix(); + let is_autofix = self.session_tab(sid).turn.is_autofix(); if is_autofix { - match parse_autofix_response(&buf) { - AutofixDecision::Fix(recommendations) => { - self.turn_surface_fix(session_id, recommendations, "autofix_fix_eager"); + 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 DirectProposalEvaluation::Stale("autofix turn was superseded".to_string()); + } + } + + let wire = match parse_proposal_payload(payload.as_bytes()) { + Ok(wire) => wire, + Err(err) => return DirectProposalEvaluation::Rejected(err), + }; + + 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 DirectProposalEvaluation::Rejected(err), + }; + + 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, + }); + DirectProposalEvaluation::Presented + } + + pub(super) 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::ProposalError; + + let binding = &context.binding; + 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 evaluation { + DirectProposalEvaluation::Presented => { + crate::proposal_pipe::ProposalValidationDecision::accepted() + } + DirectProposalEvaluation::Duplicate(reason) => { + crate::proposal_pipe::ProposalValidationDecision { + status: ProposalValidationStatus::AlreadyConsumed, + reason: Some(reason), + retryable: false, } - AutofixDecision::Explain { title, explanation } => { - self.turn_surface_explain( - session_id, - title, - explanation, - "autofix_explain_eager", - ); + } + DirectProposalEvaluation::Stale(reason) => { + crate::proposal_pipe::ProposalValidationDecision { + status: ProposalValidationStatus::Stale, + reason: Some(reason), + retryable: false, } - 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", - ); + 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, + } } } } + pub(super) 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 + } + + pub(super) 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: /// /// 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; @@ -215,12 +387,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_activity(session_id); return; } @@ -237,11 +410,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_activity(session_id); } @@ -275,115 +449,91 @@ impl App { self.turn_clear_agent_activity(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 { @@ -427,6 +577,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. @@ -452,12 +606,34 @@ impl App { .prompt() .and_then(|p| p.autofix.as_ref()) .map(|a| a.target_pane_id.clone()); - let _ = self + 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(claim) + } else { + None + }; + let dispatched = self .recommendation_tx .send(crate::coordinator::ChoiceExecution { choice, insert_only, - }); + }) + .is_ok(); + if let Some(claim) = confirmation_claim { + let status = if dispatched { + crate::proposal_channel::ProposalFinalStatus::Confirmed + } else { + crate::proposal_channel::ProposalFinalStatus::Unavailable + }; + self.proposal_channels.finalize_confirmation(claim, status); + if !dispatched { + self.turn_cancel(session_id); + return; + } + } if armed_pane.is_some() { self.emit_autofix_state_cleared(&target_tab); } @@ -475,6 +651,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. @@ -482,7 +659,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, @@ -499,6 +676,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); @@ -572,6 +753,14 @@ impl App { tab.rec_scroll.reset(); 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 @@ -579,7 +768,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( @@ -603,6 +792,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(), @@ -684,6 +881,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, @@ -709,13 +914,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 @@ -735,17 +940,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 @@ -788,8 +990,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/cli/args.rs b/tools/wta/src/cli/args.rs index aa61c92957..bc04b1bbd0 100644 --- a/tools/wta/src/cli/args.rs +++ b/tools/wta/src/cli/args.rs @@ -416,6 +416,19 @@ pub(crate) 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. + #[command(hide = true)] + 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`. diff --git a/tools/wta/src/cli/mod.rs b/tools/wta/src/cli/mod.rs index cc747eb89c..9beb1145d7 100644 --- a/tools/wta/src/cli/mod.rs +++ b/tools/wta/src/cli/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod args; pub(crate) mod hooks; pub(crate) mod probes; +pub(crate) mod proposals; pub(crate) mod sessions; diff --git a/tools/wta/src/cli/proposals.rs b/tools/wta/src/cli/proposals.rs new file mode 100644 index 0000000000..7f2e6640f6 --- /dev/null +++ b/tools/wta/src/cli/proposals.rs @@ -0,0 +1,106 @@ +use anyhow::{Context, Result}; +use tokio::io::{AsyncWriteExt, BufReader}; + +pub(crate) async fn run(channel: String, payload: String) -> Result<()> { + let channel = channel + .parse::() + .context("invalid --channel")?; + if payload.len() > crate::terminal_action_proposal::MAX_PAYLOAD_BYTES { + anyhow::bail!( + "--payload-json exceeds the {}-byte inline limit", + crate::terminal_action_proposal::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, + 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: crate::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 { + return Ok(()); + } + + let final_response: crate::proposal_pipe::ProposalFinalResponse = + read_response(&mut reader).await?; + println!("{}", serde_json::to_string(&final_response)?); + Ok(()) +} + +async fn open_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_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 > crate::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") +} diff --git a/tools/wta/src/cli_tests.rs b/tools/wta/src/cli_tests.rs index 74fabbaf7e..3e72b2d2b5 100644 --- a/tools/wta/src/cli_tests.rs +++ b/tools/wta/src/cli_tests.rs @@ -391,3 +391,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 c358e1fc40..24c3d9a93d 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( @@ -632,7 +523,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()); } @@ -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/helper/runtime.rs b/tools/wta/src/helper/runtime.rs index 24aec3bf04..945a930e1e 100644 --- a/tools/wta/src/helper/runtime.rs +++ b/tools/wta/src/helper/runtime.rs @@ -280,6 +280,53 @@ 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(crate::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 + { + 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 { + crate::proposal_pipe::ProposalPipeEvent::Validate { + context, + payload, + responder, + } => app::AppEvent::DirectTerminalActionProposal { + context, + payload, + responder, + }, + crate::proposal_pipe::ProposalPipeEvent::Commit { proposal_id } => { + app::AppEvent::DirectTerminalActionProposalCommit { proposal_id } + } + crate::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)); @@ -616,6 +663,7 @@ async fn run_acp_app( let source_cwd = agent_source_cwd.clone(); let owner_tab = config.owner_tab_id.clone(); let initial_load_sid = config.initial_load_session_id.clone(); + let proposal_channels_for_pipe = Arc::clone(&proposal_channels); tokio::task::spawn_local(async move { if let Err(e) = protocol::acp::client::run_acp_client_over_pipe( pipe_name, @@ -638,6 +686,7 @@ async fn run_acp_app( shell_mgr_for_pipe, wt_connected, false, // post_login_reconnect: first connection, no authenticate needed + proposal_channels_for_pipe, ) .await { @@ -693,6 +742,7 @@ async fn run_acp_app( let autofix_enabled = !config.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(config.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/main.rs b/tools/wta/src/main.rs index 7f793f529e..13438b860e 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -23,8 +23,12 @@ 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; @@ -35,6 +39,7 @@ mod session_registry; mod session_watcher; mod shell; mod telemetry; +mod terminal_action_proposal; #[cfg(test)] mod test_support; mod text_selection; @@ -488,6 +493,12 @@ async fn main() -> Result<()> { cli::probes::run_wsl_sessions(cli.as_deref()).await } + // ── Direct terminal-action proposal (agent session -> Helper) ── + Some(Command::ProposeTerminalActions { + channel, + payload_json, + }) => cli::proposals::run(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 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..ef0d7e93a6 --- /dev/null +++ b/tools/wta/src/proposal_channel.rs @@ -0,0 +1,738 @@ +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: Option, + created_at: Instant, +} + +pub struct ConfirmationClaim { + channel_hash: [u8; 32], + final_responder: oneshot::Sender, +} + +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 final_responder = active.final_responder.take()?; + let channel_hash = channel_hash(&active.channel); + state.tombstones.push_back(Tombstone { + channel_hash, + status: None, + created_at: Instant::now(), + }); + self.prune_tombstones(&mut state); + 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 { + 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 { + Some(ProposalFinalStatus::Superseded) => ( + ProposalValidationStatus::Superseded, + "channel was superseded by a newer turn", + ), + Some(ProposalFinalStatus::SessionReplaced) => ( + ProposalValidationStatus::Stale, + "channel belongs to a replaced session", + ), + None | Some(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: Some(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..09f0a2a755 --- /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_non_compact_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 49f79bb727..3de174a688 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, + hidden_tool_calls: Mutex>, } /// Our Client trait implementation — handles incoming agent requests and notifications. @@ -1667,7 +1689,113 @@ fn session_update_kind(update: &acp::schema::v1::SessionUpdate) -> &'static 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; + } + 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; + } + 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 { + 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 { + 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, @@ -1680,6 +1808,10 @@ impl WtaClient { )); 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 title = args .tool_call .fields @@ -1706,6 +1838,69 @@ impl WtaClient { .prompt_timing .permission_requested(&session_id, &description); + 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_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() @@ -1741,9 +1936,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(_) => { @@ -1757,7 +1952,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. @@ -1766,9 +1964,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`. @@ -1804,6 +2000,16 @@ 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(()); + } self.state .prompt_timing .observe_first_tool_call(&sid, Some(tool_call.title.as_str())); @@ -1817,7 +2023,7 @@ impl WtaClient { }; 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), location, @@ -1825,6 +2031,16 @@ 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(()); + } if let Some(status) = &update.fields.status { // Failed updates frequently carry a `raw_output.message` // explaining *why* (e.g. Copilot in non-interactive ACP @@ -1866,7 +2082,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, location, location_is_command, @@ -1880,8 +2096,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, }, }) @@ -1965,7 +2185,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) } @@ -2321,6 +2542,7 @@ pub async fn run_acp_client_over_pipe( shell_mgr: Arc, wt_connected: bool, post_login_reconnect: bool, + proposal_channels: Arc, ) -> Result<()> { let startup_probe = StartupProbe::new(); startup_probe.log(&format!( @@ -2421,6 +2643,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), + hidden_tool_calls: Mutex::new(HashSet::new()), }); let client = WtaClient { @@ -2430,30 +2654,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(); @@ -2498,8 +2765,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")) @@ -2526,9 +2792,7 @@ 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()), agent_source: Some(agent_source.kind().to_string()), wsl_distro: agent_source.distro().map(str::to_string), ..Default::default() @@ -2537,8 +2801,7 @@ pub async fn run_acp_client_over_pipe( 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(|_| { @@ -2592,10 +2855,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", @@ -2674,7 +2934,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 @@ -2720,44 +2983,45 @@ pub async fn run_acp_client_over_pipe( .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::PathBuf::from("/")), }; - 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 @@ -2799,28 +3063,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 @@ -2843,11 +3107,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)", @@ -2885,6 +3145,8 @@ 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 // bootstrap session — otherwise the `load_session_rx` arm would @@ -2955,6 +3217,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 @@ -2982,9 +3245,11 @@ pub async fn run_acp_client_over_pipe( crate::wt_protocol_events::send(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, @@ -2998,6 +3263,7 @@ pub async fn run_acp_client_over_pipe( ); } Some(req) = load_session_rx.recv() => { + proposal_channels.replace_session(); dispatch_load_session( req, &conn, @@ -3010,13 +3276,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, @@ -3028,12 +3295,14 @@ pub async fn run_acp_client_over_pipe( &prompt_timing, wt_connected, is_agent_pane, + &proposal_channels, ); } else => break, } } + proposal_channels.set_transport_available(false); startup_probe.log("run_acp_client_over_pipe loop ended"); Ok(()) } @@ -3129,8 +3398,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!( @@ -3188,9 +3456,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(), } }; @@ -3299,7 +3565,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 @@ -3671,15 +3938,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>>, @@ -3691,6 +3957,7 @@ fn dispatch_prompt( prompt_timing: &Arc, wt_connected: bool, is_agent_pane: bool, + proposal_channels: &Arc, ) { let tab_key = prompt .pane_context @@ -3716,6 +3983,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( @@ -3731,9 +3999,40 @@ fn dispatch_prompt( tab_key_task, wt_connected, is_agent_pane, + proposal_channels_task, )); } +#[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()), + ); +} + /// 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. @@ -3751,6 +4050,7 @@ async fn dispatch_prompt_body( tab_key_task: String, wt_connected: bool, is_agent_pane: bool, + proposal_channels: Arc, ) { // Resolve (or lazily create) the ACP session for this tab. let prompt_session_id = { @@ -3825,26 +4125,51 @@ 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( + 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; + match proposal_channels.issue( + prompt_session_id_str.clone(), prompt.id, - prompt.submitted_at_unix_s, - &prompt.text, + active_target.clone(), prompt.is_autofix, - include_template, - &shell_mgr_task, - wt_connected, - prompt.pane_context.as_ref(), - ) - .await; + ) { + 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, }); @@ -3894,10 +4219,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! { @@ -3949,14 +4272,18 @@ 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, tool_call_kind_label, user_locale_tag, - PromptTimingState, SoftStopReason, + acp_result_failure_fields, complete_prompt_request, inject_wta_pane_meta, + looks_like_proposal_command, post_login_authenticate_error, shell_from_active, + timeout_result_failure_fields, tool_call_kind_label, user_locale_tag, ClientState, + PromptTimingState, SoftStopReason, WtaClient, }; - use super::acp; - use crate::protocol::acp::failure::{AgentFailure, HandshakeStage}; use crate::app_contracts::AppEvent; + use crate::protocol::acp::failure::{AgentFailure, HandshakeStage}; + use crate::shell::ShellManager; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; /// Each `ToolKind` that has a visual cue maps to a distinct, stable @@ -4021,10 +4348,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:?}" @@ -4033,14 +4358,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, @@ -4355,15 +4675,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)); } @@ -4480,7 +4800,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] @@ -4490,7 +4810,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" ); } @@ -4504,9 +4824,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"); @@ -4534,7 +4855,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!( @@ -4555,7 +4876,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!( @@ -4578,7 +4899,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"), @@ -4597,7 +4918,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()), @@ -4618,7 +4939,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(), @@ -4645,7 +4966,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(), @@ -4685,7 +5006,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… @@ -4757,14 +5078,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] @@ -4794,6 +5121,248 @@ 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, + )], + ) + } + + #[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()); + 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), + hidden_tool_calls: Mutex::new(HashSet::new()), + }), + }; + + let response = client + .request_permission(proposal_permission_request(&command)) + .await + .unwrap(); + assert!(matches!( + response.outcome, + acp::schema::v1::RequestPermissionOutcome::Selected(_) + )); + 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()); + } + + #[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, + hidden_tool_calls: Mutex::new(HashSet::new()), + }), + }; + + let response = client + .request_permission(proposal_permission_request(&command)) + .await + .unwrap(); + assert!(matches!( + response.outcome, + acp::schema::v1::RequestPermissionOutcome::Cancelled + )); + assert!(matches!( + event_rx.try_recv(), + Ok(AppEvent::HideToolCall { .. }) + )); + } + + #[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), + hidden_tool_calls: Mutex::new(HashSet::new()), + }), + }; + + 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!(matches!( + event_rx.try_recv(), + Ok(AppEvent::HideToolCall { .. }) + )); + } + + #[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), + hidden_tool_calls: Mutex::new(HashSet::new()), + }), + }; + + 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!(matches!( + event_rx.try_recv(), + Ok(AppEvent::HideToolCall { .. }) + )); + } + // ── json_str_or_num ───────────────────────────────────────────────────── #[test] @@ -4835,8 +5404,9 @@ mod tests { }; use crate::shell::ShellManager; use agent_client_protocol::{self as acp}; + use std::collections::HashSet; use std::path::PathBuf; - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use tokio::sync::mpsc; fn make_client() -> (WtaClient, mpsc::UnboundedReceiver) { @@ -4845,6 +5415,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()), + 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 7bfbd367e5..f7381a0050 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()), + hidden_tool_calls: Mutex::new(HashSet::new()), }); 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()), + hidden_tool_calls: Mutex::new(HashSet::new()), }); 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()), + hidden_tool_calls: Mutex::new(HashSet::new()), }); (WtaClient { state }, event_rx) } @@ -1630,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 — 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 2f34f60a36..dc930052a9 100644 --- a/tools/wta/src/protocol/acp/spawn.rs +++ b/tools/wta/src/protocol/acp/spawn.rs @@ -10,11 +10,11 @@ //! + `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; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context, Result}; use tokio::io::{AsyncBufReadExt, BufReader}; const STARTUP_STDERR_MAX_LINES: usize = 32; @@ -186,9 +186,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) } } @@ -199,7 +197,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() @@ -220,7 +221,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); @@ -257,6 +262,13 @@ 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()); + // 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 // environment variables. Many agent CLIs (and the large language models // they speak to) honor `LANG` / `LC_ALL` to choose their response @@ -310,6 +322,35 @@ pub(crate) fn spawn_agent_process(agent_cmd: &str, cwd: Option<&Path>) -> Result }) } +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, @@ -428,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![ @@ -496,17 +562,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)) ); } @@ -515,16 +575,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..8390521e89 --- /dev/null +++ b/tools/wta/src/terminal_action_proposal.rs @@ -0,0 +1,724 @@ +//! 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 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 +//! 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, 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; +/// 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, 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 + /// 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. 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. + 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 { + 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 shared count, length, and coordinator-target validation. +/// +/// * `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 by [`crate::coordinator::validate_recommendation_set_for_coordinator_target`]. +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: 1..=3 choices, 1..=3 actions, and the + // Send+Open+OpenAndSend shape consumed by the shared card pipeline. + 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"); + } +} diff --git a/tools/wta/src/ui/chat.rs b/tools/wta/src/ui/chat.rs index 02c7c8b9ea..3cf085491b 100644 --- a/tools/wta/src/ui/chat.rs +++ b/tools/wta/src/ui/chat.rs @@ -20,9 +20,7 @@ const MAX_RENDER_LINE_CHARS: usize = 4096; 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 for the pending-height calculation. `pending_render_text` - // re-parses the streaming buffer on every call (and allocates on the - // JSON-wrapper path via `extract_json_string_field`). + // Fetch once for the pending-height calculation. let pending_text = pending_render_text(tab); let messages: usize = tab.messages.iter().map(|m| message_height(m, wrap_width)).sum(); @@ -392,128 +390,11 @@ pub fn render_activity(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Paragraph::new(line), area); } -/// 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> { @@ -1055,90 +936,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] @@ -1150,30 +947,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") - ); - } - - #[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); + 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_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); }