From fd31564fb0434af4b15ecef2bcc9b4a54688c5ab Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Sun, 23 Aug 2026 10:42:32 +0800 Subject: [PATCH 1/7] Expand agent integration test coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 786d81b1-3d9e-49fa-8d4f-8b375389dff9 --- doc/release-check-list.md | 7 + test/e2e/README.md | 1 + .../e2e/fixtures/Mock-AcpInteractionAgent.ps1 | 314 ++++++++++++++++++ .../e2e/fixtures/Mock-AcpModelSwitchAgent.ps1 | 22 +- test/e2e/fixtures/Mock-OpenAIChatServer.ps1 | 15 +- .../Feature.AgentModelLifecycle.Tests.ps1 | 32 +- .../tests/Feature.AgentModelSync.Tests.ps1 | 4 +- test/e2e/tests/Feature.AgentPopup.Tests.ps1 | 13 + .../Feature.AgentProtocolExperience.Tests.ps1 | 154 +++++++++ test/e2e/tests/Feature.AutofixPane.Tests.ps1 | 17 +- test/e2e/tests/Feature.ByokProvider.Tests.ps1 | 91 ++++- .../e2e/tests/Feature.PromptHistory.Tests.ps1 | 6 +- 12 files changed, 625 insertions(+), 51 deletions(-) create mode 100644 test/e2e/fixtures/Mock-AcpInteractionAgent.ps1 create mode 100644 test/e2e/tests/Feature.AgentProtocolExperience.Tests.ps1 diff --git a/doc/release-check-list.md b/doc/release-check-list.md index b6002eb004..7af88af96f 100644 --- a/doc/release-check-list.md +++ b/doc/release-check-list.md @@ -136,6 +136,7 @@ Net effect: UT shrinks the manual matrix to "did the wiring and UI connect", not - [ ] `C055` `[E2E]` **Stash preserves chat:** Hiding and restoring the pane preserves helper process, connection state, and chat history. - [ ] `C056` `[E2E]` **Tab close cleans up:** Closing the owning tab physically closes its ACP session, cleans up the helper, and does not leave a broken pane. - [ ] `C247` `[new]` `[E2E]` **Closing a tab mid-turn leaves sibling agent tabs working:** When one tab closes with a prompt in flight, only its ACP session is closed; the shared agent CLI remains alive and another tab can continue chatting without a restart. _(#419/#425; E2E: `Feature.SharedAgentLifecycle`.)_ +- [ ] `C278` `[new]` `[E2E]` **`/new` physically closes the replaced ACP session before creating another:** Replacing a conversation releases the prior ACP session before creating its successor and does not replay the old session. _(#610; E2E: `Feature.AgentProtocolExperience`.)_ - [ ] `C215` `[new]` `[E2E]` **Agent panes are not persisted into saved layout:** Saving and restoring a window layout does not resurrect a previously-open agent pane; restored windows come back without an unexpected agent pane. _(#360/#275.)_ ### Built-in agent chat matrix @@ -174,6 +175,8 @@ Net effect: UT shrinks the manual matrix to "did the wiring and UI connect", not - [ ] `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, 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.)_ +- [ ] `C279` `[new]` `[UT✓]` `[E2E]` **ACP tool details and transcript order survive the real process boundary:** Execute commands and output, plans, and trailing prose retain ACP arrival order through the stdio agent, master, Helper, and rendered pane. _(#601/#611/#612; E2E: `Feature.AgentProtocolExperience`.)_ +- [ ] `C280` `[new]` `[UT✓]` `[E2E]` **Clarification modal returns the selected answer to the requesting ACP session:** A blocking agent question renders its choices and returns the exact selected answer and index to the owning ACP session. _(#606; E2E: `Feature.AgentProtocolExperience`.)_ - [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:** A validated Direct Helper Proposal can be inserted into the target terminal pane without running. - [ ] `C253` `[new]` `[E2E]` **Insert returns keyboard focus to the target shell pane:** After Insert delivers a command without running it, normal window keyboard input continues in that target pane. _(#533; E2E: `Feature.AgentProposalFocus`.)_ @@ -190,6 +193,9 @@ Net effect: UT shrinks the manual matrix to "did the wiring and UI connect", not - [x] `C078` `[UT✓]` **`/stop` works:** Stops/cancels an in-progress turn. - [x] `C079` `[UT✓]` **`/sessions` works:** Switches to session-management view. _(UT: `slash_sessions_opens_agents_view`.)_ - [ ] `C080` `[UT✓]` `[E2E]` **`/model` works:** Opens/selects model where supported; unsupported agents fail gracefully. _(UT: `slash_model_*`; picker render covered by `render_model_picker_lists_models`, full UI flow still E2E.)_ +- [ ] `C281` `[new]` `[UT✓]` `[E2E]` **Slash command search matches substrings and ranks the match:** Typing a middle substring filters the command popup and selects the matching command. _(#615; E2E: `Feature.AgentPopup`.)_ +- [ ] `C282` `[new]` `[UT✓]` `[E2E]` **ACP session config picker preserves order and hot-applies a selection:** `/config` renders Agent-provided options in order, sends the selected value to the live ACP session, and reflects its update without restarting. _(#616; E2E: `Feature.AgentProtocolExperience`.)_ +- [ ] `C283` `[new]` `[UT✓]` `[E2E]` **Agent pane title reflects the confirmed active model:** After ACP confirms the session model, the XAML agent-pane title displays that model rather than stale configured state. _(#634; E2E: `Feature.AgentProtocolExperience`.)_ - [ ] `C255` `[new]` `[UT✓]` `[E2E]` **ACP model updates refresh the active picker:** A model reported by a later ACP `config_option_update` replaces stale `session/new` selection state in the active session's `/model` picker. _(UT: `session_notification_routes_model_config_update`, `model_config_update_refreshes_active_session_picker`; #538; E2E: `Feature.AgentModelSync`.)_ - [ ] `C256` `[new]` `[UT✓]` `[E2E]` **`/model` hot-applies without restarting the agent:** Selecting another model sends the live ACP session update while preserving the current helper and agent process. _(UT: `slash_model_hot_applies_cloud_model_to_live_session`; #554; E2E: `Feature.AgentModelLifecycle`.)_ - [ ] `C257` `[new]` `[E2E]` **Settings model changes restart and reconnect the agent:** Changing the configured cloud model rebuilds the shared agent stack, reconnects, and applies the new model to the fresh session. _(#554; E2E: `Feature.AgentModelLifecycle`.)_ @@ -197,6 +203,7 @@ Net effect: UT shrinks the manual matrix to "did the wiring and UI connect", not - [ ] `C260` `[new]` `[UT✓]` `[E2E]` **Leaving BYOK restarts and restores cloud models:** Clearing the custom model selection rebuilds the shared agent stack, reconnects Copilot without provider overrides, and restores its native cloud catalog. _(UT: clean cloud discovery and settings rebuild tests; #447; E2E: `Feature.ByokProvider`.)_ - [ ] `C261` `[new]` `[UT✓]` `[E2E]` **Compact height keeps the recommendation and input usable:** At the real Agent Pane splitter minimum, the selected recommendation summary, Run/Insert actions, and editable input remain visible, and Insert still targets the owning shell without discarding its draft. _(UT: compact action-panel planning, recommendation rendering, and minimum-size tests; #580; E2E: `Feature.AgentCompactLayout`.)_ - [ ] `C258` `[new]` `[UT✓]` `[E2E]` **Proposal MCP routing is isolated per tab:** Each ACP session receives a distinct proposal MCP server identity, and tool calls route only to that session's owning Helper even after another tab connects. _(UT: `server_configs_isolate_session_identity_and_capability`, proposal MCP capability routing tests; #560; E2E: `Feature.ProposalMcpRouting`.)_ +- [ ] `C284` `[new]` `[UT✓]` `[E2E]` **Tab-targeted terminal actions accept a direction hint across Session MCP:** A validated `open_and_send` request may retain a direction hint when its destination is a new tab. _(#599; E2E: `Feature.AgentProtocolExperience`.)_ - [x] `C081` `[UT✓]` **Unknown slash command is safe:** Unknown `/command` does not lose user input or crash. - [ ] `C225` `[E2E]` **`/agent` picker works:** `/agent` opens a keyboard-operable picker containing the current installed/allowed agents, and selecting the current agent is a safe no-op. - [ ] `C241` `[new]` `[E2E]` **`/agent` completion selection is safe:** Enter activates the highlighted matching agent without rebuilding the pane or changing the global default when it is already selected. _(#487; E2E: `Feature.PerTabAgent`.)_ diff --git a/test/e2e/README.md b/test/e2e/README.md index 5d8412c840..0c60a5993c 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -19,6 +19,7 @@ authenticated ACP agents. Current status (run on the Store package): | `Feature.FreFlow.Tests.ps1` | §0 FRE overlay click-through (Next→Save, privacy link, close-safety) | 5 | | `Feature.FreExecutionPolicy.Tests.ps1` | §0 FRE execution-policy verdict (deterministic via registry; **Dev**, auto-skips) | 3 (1 conditional skip) | | `Feature.AgentPaneInteraction.Tests.ps1` | open/hide/focus, input/rendering, slash, Copilot chat | 14 | +| `Feature.AgentProtocolExperience.Tests.ps1` | PRs #599/#601/#606/#610/#611/#612/#616/#634: terminal actions, ACP tool/transcript rendering, clarification input, session configuration, model title, and replacement cleanup across the deployed helper/master boundary | 6 | | `Feature.AgentImageAttachmentEditing.Tests.ps1` | PR #536: inline image tokens move and delete atomically while preserving adjacent prompt text | 1 | | `Feature.AgentModelSync.Tests.ps1` | PR #538: ACP config-option updates replace stale session model state in the active picker | 1 | | `Feature.AgentModelLifecycle.Tests.ps1` | PR #554: `/model` hot-apply and Settings-driven model restart/reconnect lifecycle | 2 | diff --git a/test/e2e/fixtures/Mock-AcpInteractionAgent.ps1 b/test/e2e/fixtures/Mock-AcpInteractionAgent.ps1 new file mode 100644 index 0000000000..9da7c6158a --- /dev/null +++ b/test/e2e/fixtures/Mock-AcpInteractionAgent.ps1 @@ -0,0 +1,314 @@ +param( + [Parameter(Mandatory)][string]$LogPath +) + +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$sessionCounter = 0 +$currentMode = 'ask' +$sessionMcpServers = @{} + +function Send-AcpMessage { + param([Parameter(Mandatory)][hashtable]$Message) + + [Console]::Out.WriteLine(($Message | ConvertTo-Json -Depth 30 -Compress)) + [Console]::Out.Flush() +} + +function Write-FixtureLog { + param([Parameter(Mandatory)][string]$Message) + + Add-Content -LiteralPath $LogPath -Value "$PID|$Message" -Encoding utf8 +} + +function Get-SessionConfigOptions { + @( + @{ + id = 'mode' + name = 'Mode' + category = 'mode' + type = 'select' + currentValue = $currentMode + options = @( + @{ value = 'ask'; name = 'Ask'; description = 'Ask before editing' } + @{ value = 'code'; name = 'Code'; description = 'Edit files directly' } + ) + } + @{ + id = 'reasoning' + name = 'Reasoning' + category = 'thought_level' + type = 'select' + currentValue = 'medium' + options = @( + @{ value = 'medium'; name = 'Medium' } + @{ value = 'high'; name = 'High' } + ) + } + @{ + id = 'model' + name = 'Model' + category = 'model' + type = 'select' + currentValue = 'fixture-model' + options = @( + @{ value = 'fixture-model'; name = 'Fixture Model' } + ) + } + ) +} + +function Send-TextUpdate { + param( + [Parameter(Mandatory)][string]$SessionId, + [Parameter(Mandatory)][string]$Text + ) + + Send-AcpMessage @{ + jsonrpc = '2.0' + method = 'session/update' + params = @{ + sessionId = $SessionId + update = @{ + sessionUpdate = 'agent_message_chunk' + content = @{ type = 'text'; text = $Text } + } + } + } +} + +function Invoke-UserInputTool { + param( + [Parameter(Mandatory)]$Server + ) + + $headers = @{ 'mcp-protocol-version' = '2025-06-18' } + foreach ($header in @($Server.headers)) { + if ($header.name -and $header.value) { + $headers[[string]$header.name] = [string]$header.value + } + } + $body = @{ + jsonrpc = '2.0' + id = 1 + method = 'tools/call' + params = @{ + name = 'request_user_input' + arguments = @{ + question = 'Choose the deterministic answer' + choices = @('Alpha', 'Beta') + allow_freeform = $true + } + } + } | ConvertTo-Json -Depth 12 -Compress + Invoke-RestMethod -Method Post -Uri $Server.url -Headers $headers -ContentType 'application/json' -Body $body +} + +function Invoke-TerminalActionTool { + param( + [Parameter(Mandatory)]$Server, + [Parameter(Mandatory)][string]$Marker + ) + + $headers = @{ 'mcp-protocol-version' = '2025-06-18' } + foreach ($header in @($Server.headers)) { + if ($header.name -and $header.value) { + $headers[[string]$header.name] = [string]$header.value + } + } + $body = @{ + jsonrpc = '2.0' + id = 2 + method = 'tools/call' + params = @{ + name = 'request_terminal_actions' + arguments = @{ + type = 'open_and_send' + title = "Direction $Marker" + input = "echo $Marker" + target = 'tab' + direction = 'auto' + } + } + } | ConvertTo-Json -Depth 12 -Compress + Invoke-RestMethod -Method Post -Uri $Server.url -Headers $headers -ContentType 'application/json' -Body $body +} + +while ($null -ne ($line = [Console]::In.ReadLine())) { + $request = $line | ConvertFrom-Json + switch ($request.method) { + 'initialize' { + Write-FixtureLog -Message 'initialize' + Send-AcpMessage @{ + jsonrpc = '2.0' + id = $request.id + result = @{ + protocolVersion = 1 + agentCapabilities = @{ + mcpCapabilities = @{ http = $true; sse = $false } + sessionCapabilities = @{ close = @{} } + } + agentInfo = @{ + name = 'Interaction Fixture' + version = '1.0.0' + } + } + } + } + 'session/new' { + $sessionCounter++ + $sessionId = "interaction-$PID-$sessionCounter" + $sessionMcpServers[$sessionId] = @($request.params.mcpServers) | Select-Object -First 1 + Write-FixtureLog -Message "session/new|$sessionId" + Send-AcpMessage @{ + jsonrpc = '2.0' + id = $request.id + result = @{ + sessionId = $sessionId + configOptions = @(Get-SessionConfigOptions) + } + } + } + 'session/close' { + Write-FixtureLog -Message "session/close|$($request.params.sessionId)" + Send-AcpMessage @{ + jsonrpc = '2.0' + id = $request.id + result = @{ configOptions = @(Get-SessionConfigOptions) } + } + } + 'session/set_config_option' { + $currentMode = [string]$request.params.value + Write-FixtureLog -Message "session/set_config_option|$($request.params.configId)|$currentMode" + Send-AcpMessage @{ + jsonrpc = '2.0' + method = 'session/update' + params = @{ + sessionId = [string]$request.params.sessionId + update = @{ + sessionUpdate = 'config_option_update' + configOptions = @(Get-SessionConfigOptions) + } + } + } + Send-AcpMessage @{ + jsonrpc = '2.0' + id = $request.id + result = @{} + } + } + 'session/prompt' { + $sessionId = [string]$request.params.sessionId + $promptText = (@($request.params.prompt) | ForEach-Object text) -join "`n" + Write-FixtureLog -Message "session/prompt|$sessionId|$promptText" + + if ($promptText -match 'TOOL_FLOW') { + Send-TextUpdate -SessionId $sessionId -Text 'BEFORE_TOOL_MARKER' + Send-AcpMessage @{ + jsonrpc = '2.0' + method = 'session/update' + params = @{ + sessionId = $sessionId + update = @{ + sessionUpdate = 'tool_call' + toolCallId = 'ite2e-tool' + title = 'Run integration command' + kind = 'execute' + status = 'in_progress' + rawInput = @{ + command = 'echo TOOL_DETAIL_MARKER' + cwd = 'C:\ite2e-work' + } + content = @() + locations = @() + } + } + } + Send-AcpMessage @{ + jsonrpc = '2.0' + method = 'session/update' + params = @{ + sessionId = $sessionId + update = @{ + sessionUpdate = 'tool_call_update' + toolCallId = 'ite2e-tool' + status = 'completed' + rawOutput = @{ + stdout = 'TOOL_OUTPUT_MARKER' + exitCode = 7 + } + } + } + } + Send-AcpMessage @{ + jsonrpc = '2.0' + method = 'session/update' + params = @{ + sessionId = $sessionId + update = @{ + sessionUpdate = 'plan' + entries = @( + @{ content = 'PLAN_MARKER'; priority = 'medium'; status = 'completed' } + ) + } + } + } + Send-TextUpdate -SessionId $sessionId -Text 'AFTER_TOOL_MARKER' + Start-Sleep -Seconds 10 + Send-AcpMessage @{ + jsonrpc = '2.0' + id = $request.id + result = @{ stopReason = 'end_turn' } + } + Write-FixtureLog -Message 'tool-flow-complete' + } + elseif ($promptText -match 'ASK_INPUT') { + $server = $sessionMcpServers[$sessionId] + if (-not $server) { + throw 'session/new did not provide a Session MCP server' + } + $response = Invoke-UserInputTool -Server $server + $result = $response.result.structuredContent | ConvertTo-Json -Depth 20 -Compress + Write-FixtureLog -Message "user-input-result|$result" + Send-TextUpdate -SessionId $sessionId -Text "INPUT_RESULT:$result" + Send-AcpMessage @{ + jsonrpc = '2.0' + id = $request.id + result = @{ stopReason = 'end_turn' } + } + } + elseif ($promptText -match 'TAB_DIRECTION_(?[A-F0-9]+)') { + $server = $sessionMcpServers[$sessionId] + if (-not $server) { + throw 'session/new did not provide a Session MCP server' + } + $marker = $Matches.marker + $response = Invoke-TerminalActionTool -Server $server -Marker $marker + $result = $response.result.structuredContent | ConvertTo-Json -Depth 20 -Compress + Write-FixtureLog -Message "tab-direction-result|$result" + Send-AcpMessage @{ + jsonrpc = '2.0' + id = $request.id + result = @{ stopReason = 'end_turn' } + } + } + else { + Send-TextUpdate -SessionId $sessionId -Text "ACK:$promptText" + Send-AcpMessage @{ + jsonrpc = '2.0' + id = $request.id + result = @{ stopReason = 'end_turn' } + } + } + } + default { + if ($null -ne $request.id) { + Send-AcpMessage @{ + jsonrpc = '2.0' + id = $request.id + error = @{ code = -32601; message = 'Method not found' } + } + } + } + } +} diff --git a/test/e2e/fixtures/Mock-AcpModelSwitchAgent.ps1 b/test/e2e/fixtures/Mock-AcpModelSwitchAgent.ps1 index b2dd104c00..4513bd8a14 100644 --- a/test/e2e/fixtures/Mock-AcpModelSwitchAgent.ps1 +++ b/test/e2e/fixtures/Mock-AcpModelSwitchAgent.ps1 @@ -23,6 +23,7 @@ $models = @( @{ value = 'initial-model'; name = 'Initial Model' } @{ value = 'effective-model'; name = 'Effective Model' } ) +$currentModel = 'initial-model' while ($null -ne ($line = [Console]::In.ReadLine())) { $request = $line | ConvertFrom-Json @@ -55,7 +56,7 @@ while ($null -ne ($line = [Console]::In.ReadLine())) { name = 'Model' category = 'model' type = 'select' - currentValue = 'initial-model' + currentValue = $currentModel options = $models } ) @@ -63,10 +64,27 @@ while ($null -ne ($line = [Console]::In.ReadLine())) { } } { $_ -in @('session/set_config_option', 'session/set_model') } { + $currentModel = if ($request.params.value) { + [string]$request.params.value + } + else { + [string]$request.params.modelId + } Send-AcpMessage @{ jsonrpc = '2.0' id = $request.id - result = @{} + result = @{ + configOptions = @( + @{ + id = 'model' + name = 'Model' + category = 'model' + type = 'select' + currentValue = $currentModel + options = $models + } + ) + } } } } diff --git a/test/e2e/fixtures/Mock-OpenAIChatServer.ps1 b/test/e2e/fixtures/Mock-OpenAIChatServer.ps1 index 5f0142d601..0b882fa640 100644 --- a/test/e2e/fixtures/Mock-OpenAIChatServer.ps1 +++ b/test/e2e/fixtures/Mock-OpenAIChatServer.ps1 @@ -1,6 +1,7 @@ param( [Parameter(Mandatory)][int]$Port, - [Parameter(Mandatory)][string]$LogPath + [Parameter(Mandatory)][string]$LogPath, + [string]$ExpectedApiKey ) $ErrorActionPreference = 'Stop' @@ -49,7 +50,17 @@ try { else { '' } - $request = @{ path = $path; body = $body } | ConvertTo-Json -Compress + $authorizationMatch = if ($ExpectedApiKey) { + $headerText -match "(?im)^Authorization:\s*Bearer\s+$([regex]::Escape($ExpectedApiKey))\s*$" + } + else { + $null + } + $request = @{ + path = $path + body = $body + authorizationMatch = $authorizationMatch + } | ConvertTo-Json -Compress Add-Content -LiteralPath $LogPath -Value "REQUEST|$request" -Encoding utf8 $first = @{ diff --git a/test/e2e/tests/Feature.AgentModelLifecycle.Tests.ps1 b/test/e2e/tests/Feature.AgentModelLifecycle.Tests.ps1 index 9384cb1c4c..5ca2d0798f 100644 --- a/test/e2e/tests/Feature.AgentModelLifecycle.Tests.ps1 +++ b/test/e2e/tests/Feature.AgentModelLifecycle.Tests.ps1 @@ -70,36 +70,9 @@ Describe 'Feature: agent model switching lifecycle' -Tag 'Feature' -Skip:(-not $ } It 'Settings model changes restart and reconnect the agent' { - Stop-Terminal -App $script:app - $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ - acpAgent = 'copilot' - acpModel = '' - } - Open-AgentPane -App $script:app | Out-Null - Wait-AgentReady -App $script:app -TimeoutSec 60 | Should -BeTrue -Because 'Copilot must connect before changing its configured model' - - Clear-AgentInput -App $script:app | Out-Null - Invoke-AgentMenuItem -App $script:app -Name '/model' - $picker = Get-AgentPaneText -App $script:app -MaxLines 50 - $targetRows = [regex]::Matches( - $picker, - '(?m)^\s*[│║|]\s{2}(?\S.*?)\s*[│║|]\s*$' - ) - $targetRow = $targetRows | - Where-Object { $_.Groups['name'].Value.Trim() -ne 'Auto' } | - Select-Object -First 1 - if (-not $targetRow) { - Set-ItResult -Skipped -Because 'the installed Copilot agent did not advertise multiple selectable cloud models' - return - } - $targetName = $targetRow.Groups['name'].Value.Trim() - $targetId = (($targetName.ToLowerInvariant() -replace '[^a-z0-9.]+', '-').Trim('-')) - Send-AgentKey -App $script:app -Key Escape | Out-Null - Clear-AgentInput -App $script:app | Out-Null - $beforeSession = (Get-AgentPaneSession -App $script:app).PaneSessionId Initialize-LogOffsets -App $script:app | Out-Null - Set-WtSetting -App $script:app -Key 'acpModel' -Value $targetId | Out-Null + Set-WtSetting -App $script:app -Key 'acpModel' -Value 'effective-model' | Out-Null Assert-Log -App $script:app -Name 'terminal-agent-pane.log' -Pattern '_RebuildAgentStack: agent settings changed, rebuilding' -TimeoutSec 20 $sessionChanged = Test-Until -TimeoutSec 30 -IntervalSec 1 -Condition { @@ -111,10 +84,9 @@ Describe 'Feature: agent model switching lifecycle' -Tag 'Feature' -Skip:(-not $ Clear-AgentInput -App $script:app | Out-Null Invoke-AgentMenuItem -App $script:app -Name '/model' - $selectedModel = [regex]::Escape($targetName) $newModelApplied = Test-Until -TimeoutSec 15 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 40) -match - "(?m)^\s*[│║|]\s*>\s+$selectedModel\s*[│║|]\s*$" + '(?m)^\s*[│║|]\s*>\s+Effective Model\s*[│║|]\s*$' } $newModelApplied | Should -BeTrue -Because 'the rebuilt stack must select the newly configured model' } diff --git a/test/e2e/tests/Feature.AgentModelSync.Tests.ps1 b/test/e2e/tests/Feature.AgentModelSync.Tests.ps1 index 91bf1558ab..25efd44c25 100644 --- a/test/e2e/tests/Feature.AgentModelSync.Tests.ps1 +++ b/test/e2e/tests/Feature.AgentModelSync.Tests.ps1 @@ -34,9 +34,7 @@ Describe 'Feature: ACP model synchronization' -Tag 'Feature' -Skip:(-not $script Clear-AgentInput -App $script:app | Out-Null Invoke-AgentMenuItem -App $script:app -Name '/model' - $titleRe = Get-WtaLocalizedTextRegex -Key 'model_picker.title' - if (-not $titleRe) { $titleRe = '(?i)Select model' } - Assert-AgentPaneText -App $script:app -Pattern $titleRe -TimeoutSec 10 + Assert-AgentPaneText -App $script:app -Pattern '(?m)^┌Model' -TimeoutSec 10 $picker = Get-AgentPaneText -App $script:app -MaxLines 40 $picker | Should -Match '(?m)^\s*[│║|]\s*>\s+Effective Model\s*[│║|]\s*$' -Because 'the config_option_update model must replace the stale session/new selection' diff --git a/test/e2e/tests/Feature.AgentPopup.Tests.ps1 b/test/e2e/tests/Feature.AgentPopup.Tests.ps1 index 6b527d0b21..a353216062 100644 --- a/test/e2e/tests/Feature.AgentPopup.Tests.ps1 +++ b/test/e2e/tests/Feature.AgentPopup.Tests.ps1 @@ -35,6 +35,19 @@ Describe 'Feature: agent pane popup + menu' -Tag 'Feature' -Skip:(-not $script:R Send-AgentKey -App $script:app -Key Escape | Out-Null } + It 'Slash command search matches substrings and ranks the match' { + Open-AgentCommandMenu -App $script:app | Out-Null + Send-AgentPrompt -App $script:app -Text 'lear' -NoSubmit | Out-Null + + $filtered = Wait-Until -TimeoutSec 10 -Because 'substring filtering to select /clear' -Condition { + $text = Get-AgentPaneText -App $script:app -MaxLines 40 + if ($text -match '>\s*/clear\b') { $text } + } + $filtered | Should -Match '>\s*/clear\b' + $filtered | Should -Not -Match '/help\b' -Because 'nonmatching commands must be filtered out' + Send-AgentKey -App $script:app -Key Escape | Out-Null + } + It 'TRIGGERS the selected option (/clear) and the popup closes' { Invoke-AgentMenuItem -App $script:app -Name '/clear' # After triggering /clear, the popup is gone (input returns to the prompt hint). diff --git a/test/e2e/tests/Feature.AgentProtocolExperience.Tests.ps1 b/test/e2e/tests/Feature.AgentProtocolExperience.Tests.ps1 new file mode 100644 index 0000000000..7a62853813 --- /dev/null +++ b/test/e2e/tests/Feature.AgentProtocolExperience.Tests.ps1 @@ -0,0 +1,154 @@ +#Requires -Modules @{ ModuleName='Pester'; ModuleVersion='5.0.0' } +# PRs #601, #606, #610, #611, #612, #616, and #634: exercise ACP +# notifications, client requests, session configuration, and replacement +# lifecycle through the deployed Terminal, helper, master, and a real stdio agent. + +BeforeDiscovery { + $script:Ready = [bool]( + (Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and + (Get-Command pwsh -ErrorAction SilentlyContinue) -and + (Get-Command winapp -ErrorAction SilentlyContinue) + ) +} + +Describe 'Feature: ACP agent-pane protocol experience' -Tag 'Feature' -Skip:(-not $script:Ready) { + BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force + $script:fixture = (Resolve-Path (Join-Path $PSScriptRoot '..\fixtures\Mock-AcpInteractionAgent.ps1')).Path + } + BeforeEach { + $script:requestLog = Join-Path $env:TEMP "ite2e-agent-protocol-$([guid]::NewGuid().ToString('N')).log" + $command = "pwsh -NoProfile -File $script:fixture -LogPath $script:requestLog" + $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ + acpAgent = 'custom:interaction-fixture' + acpCustomCommand = $command + acpModel = '' + } + Open-AgentPane -App $script:app | Out-Null + Wait-AgentReady -App $script:app -TimeoutSec 30 | + Should -BeTrue -Because 'the deterministic ACP interaction fixture must connect' + $script:agentPane = (Get-AgentPaneSession -App $script:app).PaneSessionId + } + AfterEach { + if ($script:app) { + Stop-Terminal -App $script:app + $script:app = $null + } + if ($script:requestLog -and (Test-Path -LiteralPath $script:requestLog)) { + Remove-Item -LiteralPath $script:requestLog -Force + } + } + + It 'ACP tool details and transcript order survive the real process boundary' { + Send-AgentPrompt -App $script:app -PaneSessionId $script:agentPane -Text 'TOOL_FLOW' | Out-Null + + $rendered = Wait-Until -TimeoutSec 15 -IntervalSec 0.2 -Because 'the ordered ACP tool transcript to render' -Condition { + $text = Get-AgentPaneText -App $script:app -PaneSessionId $script:agentPane -MaxLines 100 + if ($text -match 'TOOL_DETAIL_MARKER' -and + $text -match 'TOOL_OUTPUT_MARKER' -and + $text -match 'PLAN_MARKER' -and + $text -match 'AFTER_TOOL_MARKER') { + $text + } + } + + $tool = $rendered.IndexOf('TOOL_DETAIL_MARKER', [System.StringComparison]::Ordinal) + $plan = $rendered.IndexOf('PLAN_MARKER', [System.StringComparison]::Ordinal) + $after = $rendered.IndexOf('AFTER_TOOL_MARKER', [System.StringComparison]::Ordinal) + $tool | Should -BeLessThan $plan + $plan | Should -BeLessThan $after + } + + It 'Clarification modal returns the selected answer to the requesting ACP session' { + Send-AgentPrompt -App $script:app -PaneSessionId $script:agentPane -Text 'ASK_INPUT' | Out-Null + Assert-AgentPaneText -App $script:app -PaneSessionId $script:agentPane ` + -Pattern 'Choose the deterministic answer' -TimeoutSec 15 + $modal = Get-AgentPaneText -App $script:app -PaneSessionId $script:agentPane -MaxLines 60 + $modal | Should -Match 'Alpha' + $modal | Should -Match 'Beta' + + Send-AgentKey -App $script:app -PaneSessionId $script:agentPane -Key Down | Out-Null + Send-AgentKey -App $script:app -PaneSessionId $script:agentPane -Key Enter | Out-Null + + $answered = Wait-Until -TimeoutSec 15 -Because 'the selected answer to round-trip to the ACP agent' -Condition { + Get-Content -LiteralPath $script:requestLog -ErrorAction SilentlyContinue | + Where-Object { $_ -match 'user-input-result\|.*"outcome":"answered".*"answer":"Beta".*"selected_index":1' } | + Select-Object -First 1 + } + $answered | Should -Not -BeNullOrEmpty + Assert-AgentPaneText -App $script:app -PaneSessionId $script:agentPane ` + -Pattern 'INPUT_RESULT:.*answered.*Beta' -TimeoutSec 15 + } + + It 'ACP session config picker preserves order and hot-applies a selection' { + Clear-AgentInput -App $script:app -PaneSessionId $script:agentPane | Out-Null + Invoke-AgentMenuItem -App $script:app -PaneSessionId $script:agentPane -Name '/config' + + $picker = Get-AgentPaneText -App $script:app -PaneSessionId $script:agentPane -MaxLines 50 + $mode = $picker.IndexOf('Mode', [System.StringComparison]::Ordinal) + $reasoning = $picker.IndexOf('Reasoning', [System.StringComparison]::Ordinal) + $mode | Should -BeGreaterOrEqual 0 + $mode | Should -BeLessThan $reasoning + + Send-AgentKey -App $script:app -PaneSessionId $script:agentPane -Key Enter | Out-Null + Assert-AgentPaneText -App $script:app -PaneSessionId $script:agentPane -Pattern '\bAsk\b' -TimeoutSec 10 + Send-AgentKey -App $script:app -PaneSessionId $script:agentPane -Key Down | Out-Null + Send-AgentKey -App $script:app -PaneSessionId $script:agentPane -Key Enter | Out-Null + + $applied = Wait-Until -TimeoutSec 15 -Because 'the config selection to reach the ACP agent' -Condition { + Get-Content -LiteralPath $script:requestLog -ErrorAction SilentlyContinue | + Where-Object { $_ -match 'session/set_config_option\|mode\|code' } | + Select-Object -First 1 + } + $applied | Should -Not -BeNullOrEmpty + + Clear-AgentInput -App $script:app -PaneSessionId $script:agentPane | Out-Null + Invoke-AgentMenuItem -App $script:app -PaneSessionId $script:agentPane -Name '/config' + (Get-AgentPaneText -App $script:app -PaneSessionId $script:agentPane -MaxLines 50) | + Should -Match '(?m)Mode.*Code' -Because 'the live config_option_update must replace the old value' + } + + It 'Agent pane title reflects the confirmed active model' { + $title = Wait-Until -TimeoutSec 15 -Because 'the XAML pane title to publish the confirmed ACP model' -Condition { + $value = Get-UiValue -App $script:app -Selector 'AgentLabelText' + if ($value -match 'Fixture Model') { $value } + } + $title | Should -Match 'Fixture Model' + } + + It 'Tab-targeted terminal actions accept a direction hint across Session MCP' { + $marker = [guid]::NewGuid().ToString('N').Substring(0, 12).ToUpperInvariant() + Send-AgentPrompt -App $script:app -PaneSessionId $script:agentPane -Text "TAB_DIRECTION_$marker" | Out-Null + + $accepted = Wait-Until -TimeoutSec 15 -Because 'the tab action with direction=auto to pass MCP validation' -Condition { + Get-Content -LiteralPath $script:requestLog -ErrorAction SilentlyContinue | + Where-Object { $_ -match 'tab-direction-result\|.*"status":"accepted"' } | + Select-Object -First 1 + } + $accepted | Should -Not -BeNullOrEmpty + $card = Get-AgentPaneText -App $script:app -PaneSessionId $script:agentPane -MaxLines 60 + $card | Should -Match "echo $marker" + $card | Should -Match '(?i)Open in New Tab' + } + + It '/new physically closes the replaced ACP session before creating another' { + $before = Get-AgentPaneSession -App $script:app -PaneSessionId $script:agentPane + $before.AcpSessionId | Should -Not -BeNullOrEmpty + + Clear-AgentInput -App $script:app -PaneSessionId $script:agentPane | Out-Null + Invoke-AgentMenuItem -App $script:app -PaneSessionId $script:agentPane -Name '/new' + + $after = Wait-Until -TimeoutSec 20 -Because '/new to attach a different ACP session' -Condition { + $session = Get-AgentPaneSession -App $script:app -PaneSessionId $script:agentPane + if ($session -and $session.AcpSessionId -ne $before.AcpSessionId) { $session } + } + $after.AcpSessionId | Should -Not -Be $before.AcpSessionId + + $events = Get-Content -LiteralPath $script:requestLog -ErrorAction SilentlyContinue + $closeIndex = [array]::FindIndex([string[]]$events, [Predicate[string]]{ param($line) $line -match "session/close\|$([regex]::Escape($before.AcpSessionId))" }) + $newIndex = [array]::FindLastIndex([string[]]$events, [Predicate[string]]{ param($line) $line -match 'session/new\|' }) + $closeIndex | Should -BeGreaterOrEqual 0 -Because 'the replaced session must be physically released' + $closeIndex | Should -BeLessThan $newIndex -Because 'session/close must finish before replacement session/new' + $events | Should -Not -Match 'session/load' -Because '/new must not replay the replaced session' + } +} diff --git a/test/e2e/tests/Feature.AutofixPane.Tests.ps1 b/test/e2e/tests/Feature.AutofixPane.Tests.ps1 index 9dc70d4a37..96e32001a8 100644 --- a/test/e2e/tests/Feature.AutofixPane.Tests.ps1 +++ b/test/e2e/tests/Feature.AutofixPane.Tests.ps1 @@ -41,8 +41,7 @@ Describe 'Feature: autofix card render + reject + AI correctness' -Tag 'Feature' 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 } - Wait-TerminalActionProposal -App $script:app -TimeoutSec 45 | Out-Null - (Test-Until -TimeoutSec 10 -IntervalSec 1 -Condition { & $script:CardShown }) | + (Test-Until -TimeoutSec 60 -IntervalSec 1 -Condition { & $script:CardShown }) | Should -BeTrue -Because 'Autofix must submit a valid Direct Helper Proposal for an obvious typo' } It 'Autofix suggests a runnable fix (AI oracle on the card)' { @@ -50,8 +49,12 @@ Describe 'Feature: autofix card render + reject + AI correctness' -Tag 'Feature' 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)' { - for ($i = 0; $i -lt 5 -and (& $script:CardShown); $i++) { Send-AgentKey -App $script:app -Key Escape | Out-Null; Start-Sleep -Milliseconds 800 } - (& $script:CardShown) | Should -BeFalse + $dismissed = Test-Until -TimeoutSec 12 -IntervalSec 1 -Condition { + if (-not (& $script:CardShown)) { return $true } + Send-AgentKey -App $script:app -Key Escape | Out-Null + return $false + } + $dismissed | Should -BeTrue -Because 'Esc must eventually dismiss the rendered recommendation card' } It 'Autofix target pane is correct (active shell pane is the fix target)' { (Get-ActivePane -App $script:app).session_id | Should -Match '[0-9A-Fa-f-]{36}' @@ -80,8 +83,7 @@ Describe 'Feature: autofix Insert action' -Tag 'Feature' -Skip:(-not $script:Rea 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 } - Wait-TerminalActionProposal -App $script:app -TimeoutSec 45 | Out-Null - (Test-Until -TimeoutSec 10 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match (Get-RecommendationCardRegex) }) | + (Test-Until -TimeoutSec 60 -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 @@ -109,8 +111,7 @@ Describe 'Feature: autofix Run action' -Tag 'Feature' -Skip:(-not $script:Ready) 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 } - Wait-TerminalActionProposal -App $script:app -TimeoutSec 45 | Out-Null - (Test-Until -TimeoutSec 10 -IntervalSec 1 -Condition { (Get-AgentPaneText -App $script:app -MaxLines 60) -match (Get-RecommendationCardRegex) }) | + (Test-Until -TimeoutSec 60 -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 diff --git a/test/e2e/tests/Feature.ByokProvider.Tests.ps1 b/test/e2e/tests/Feature.ByokProvider.Tests.ps1 index e5d9e0bdd5..9276d0ef96 100644 --- a/test/e2e/tests/Feature.ByokProvider.Tests.ps1 +++ b/test/e2e/tests/Feature.ByokProvider.Tests.ps1 @@ -17,6 +17,79 @@ Describe 'Feature: BYOK provider lifecycle' -Tag 'Feature' -Skip:(-not $script:R BeforeAll { Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force $script:fixture = (Resolve-Path (Join-Path $PSScriptRoot '..\fixtures\Mock-OpenAIChatServer.ps1')).Path + if (-not ('ItE2ECredential' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +public static class ItE2ECredential +{ + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct Credential + { + public uint Flags; + public uint Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public uint CredentialBlobSize; + public IntPtr CredentialBlob; + public uint Persist; + public uint AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + + [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWrite(ref Credential credential, uint flags); + + [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredDelete(string target, uint type, uint flags); + + public static void Write(string target, string value) + { + var bytes = Encoding.UTF8.GetBytes(value); + var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); + try + { + var credential = new Credential + { + Type = 1, + TargetName = target, + CredentialBlobSize = (uint)bytes.Length, + CredentialBlob = handle.AddrOfPinnedObject(), + Persist = 2, + UserName = "Intelligent Terminal" + }; + if (!CredWrite(ref credential, 0)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + finally + { + Array.Clear(bytes, 0, bytes.Length); + handle.Free(); + } + } + + public static void Delete(string target) + { + if (!CredDelete(target, 1, 0)) + { + var error = Marshal.GetLastWin32Error(); + if (error != 1168) + { + throw new Win32Exception(error); + } + } + } +} +'@ + } } BeforeEach { $portProbe = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) @@ -25,11 +98,16 @@ Describe 'Feature: BYOK provider lifecycle' -Tag 'Feature' -Skip:(-not $script:R $portProbe.Stop() $script:requestLog = Join-Path $env:TEMP "ite2e-byok-$([guid]::NewGuid().ToString('N')).log" + $script:apiKey = "ite2e-key-$([guid]::NewGuid().ToString('N'))" + $script:credentialId = "{$([guid]::NewGuid())}" + $script:credentialTarget = "IntelligentTerminal.LocalModelProvider/$script:credentialId" + [ItE2ECredential]::Write($script:credentialTarget, $script:apiKey) $script:fixtureProcess = Start-Process pwsh -ArgumentList @( '-NoProfile', '-File', "`"$script:fixture`"", '-Port', $script:port, - '-LogPath', "`"$script:requestLog`"" + '-LogPath', "`"$script:requestLog`"", + '-ExpectedApiKey', $script:apiKey ) -WindowStyle Hidden -PassThru Wait-Until -TimeoutSec 10 -Because 'the local OpenAI-compatible fixture to listen' -Condition { (Get-Content -LiteralPath $script:requestLog -ErrorAction SilentlyContinue) -match '^READY\|' @@ -41,7 +119,8 @@ Describe 'Feature: BYOK provider lifecycle' -Tag 'Feature' -Skip:(-not $script:R baseUrl = "http://127.0.0.1:$script:port/v1" apiContract = 'openai-compatible' location = 'auto' - apiKeyRequired = $false + apiKeyCredential = $script:credentialId + apiKeyRequired = $true models = @(@{ id = 'ite2e-model'; name = 'ItE2E Model' }) } $script:app = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true -Settings @{ @@ -64,6 +143,11 @@ Describe 'Feature: BYOK provider lifecycle' -Tag 'Feature' -Skip:(-not $script:R $script:fixtureProcess.WaitForExit() } $script:fixtureProcess = $null + if ($script:credentialTarget) { + [ItE2ECredential]::Delete($script:credentialTarget) + $script:credentialTarget = $null + } + $script:apiKey = $null if ($script:requestLog -and (Test-Path -LiteralPath $script:requestLog)) { Remove-Item -LiteralPath $script:requestLog -Force } @@ -89,6 +173,7 @@ Describe 'Feature: BYOK provider lifecycle' -Tag 'Feature' -Skip:(-not $script:R } $request.path | Should -Be '/v1/chat/completions' ($request.body | ConvertFrom-Json).model | Should -Be 'ite2e-model' + $request.authorizationMatch | Should -BeTrue -Because 'WTA must resolve the saved Credential Manager key into the Copilot provider environment' Assert-AgentPaneText -App $script:app -Pattern 'BYOK fixture response' -TimeoutSec 30 } @@ -106,7 +191,7 @@ Describe 'Feature: BYOK provider lifecycle' -Tag 'Feature' -Skip:(-not $script:R Clear-AgentInput -App $script:app | Out-Null Invoke-AgentMenuItem -App $script:app -Name '/model' $picker = Get-AgentPaneText -App $script:app -MaxLines 50 - $picker | Should -Match '(?m)^\s*[│║|]\s*>?\s*Auto\s*[│║|]\s*$' + $picker | Should -Match '(?m)^\s*[│║|]\s*>?\s*Auto(?:\s+-[^│║|]*)?\s*[│║|]\s*$' $picker | Should -Not -Match 'BYOK' } } diff --git a/test/e2e/tests/Feature.PromptHistory.Tests.ps1 b/test/e2e/tests/Feature.PromptHistory.Tests.ps1 index bcdd7ee089..b7b902da79 100644 --- a/test/e2e/tests/Feature.PromptHistory.Tests.ps1 +++ b/test/e2e/tests/Feature.PromptHistory.Tests.ps1 @@ -66,7 +66,7 @@ Describe 'Feature agent prompt input history' -Tag 'Feature' -Skip:(-not $script It 'Prompt history recall works' { $tab = New-HistoryTestTab -Title 'prompt-history-order' - $id = [guid]::NewGuid().ToString('N') + $id = [guid]::NewGuid().ToString('N').Substring(0, 8) $older = "HISTORY_OLDER_$id reply OK" $newer = "HISTORY_NEWER_$id reply OK" @@ -89,7 +89,7 @@ Describe 'Feature agent prompt input history' -Tag 'Feature' -Skip:(-not $script It 'Prompt history preserves drafts and multiline prompts' { $tab = New-HistoryTestTab -Title 'prompt-history-multiline' - $id = [guid]::NewGuid().ToString('N') + $id = [guid]::NewGuid().ToString('N').Substring(0, 8) $lineOne = "HISTORY_LINE_ONE_$id" $lineTwo = "HISTORY_LINE_TWO_$id reply OK" $draft = "HISTORY_DRAFT_$id" @@ -119,7 +119,7 @@ Describe 'Feature agent prompt input history' -Tag 'Feature' -Skip:(-not $script It 'Completed turns restore multiline prompts when expanded' { $tab = New-HistoryTestTab -Title 'completed-turn-multiline' - $id = [guid]::NewGuid().ToString('N') + $id = [guid]::NewGuid().ToString('N').Substring(0, 8) $lineOne = "COMPLETED_LINE_ONE_$id" $lineTwo = "COMPLETED_LINE_TWO_$id" $paneId = $tab.Pane.PaneSessionId From 5ab2312866452563bcdbeef1fbd68b87d3c9afb9 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Sun, 23 Aug 2026 11:17:11 +0800 Subject: [PATCH 2/7] Stabilize WSL autofix integration test Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 786d81b1-3d9e-49fa-8d4f-8b375389dff9 --- test/e2e/tests/Feature.AutofixPane.Tests.ps1 | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/e2e/tests/Feature.AutofixPane.Tests.ps1 b/test/e2e/tests/Feature.AutofixPane.Tests.ps1 index 96e32001a8..38c9d8885c 100644 --- a/test/e2e/tests/Feature.AutofixPane.Tests.ps1 +++ b/test/e2e/tests/Feature.AutofixPane.Tests.ps1 @@ -242,9 +242,7 @@ Describe 'Feature: autofix in a WSL pane (OSC 9001;ShellType end-to-end)' -Tag ' 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 } - $pending = Wait-TerminalActionProposal -App $script:app -TimeoutSec 30 - $pending | Should -Not -BeNullOrEmpty -Because 'WSL Autofix must submit a Direct Helper Proposal' - $cardText = Wait-Until -TimeoutSec 10 -IntervalSec 1 -Because 'a visible WSL Autofix recommendation card' -Condition { + $cardText = Wait-Until -TimeoutSec 60 -IntervalSec 1 -Because 'a visible WSL Autofix recommendation card' -Condition { $text = Get-AgentPaneText -App $script:app -MaxLines 60 if ($text -match (Get-RecommendationCardRegex)) { $text } } From 532ddb3ad8f1727e815b594d0bca15eaaf553ad7 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Sun, 23 Aug 2026 21:12:06 +0800 Subject: [PATCH 3/7] Handle locked Copilot hook plugins Preserve and restore plugin registration when an active Copilot process locks the installed plugin directory. Keep FRE and OpenCode integration coverage aligned with the product installer, and make full-suite report capture robust to leaked Pester output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 786d81b1-3d9e-49fa-8d4f-8b375389dff9 --- test/e2e/Invoke-ItE2EReport.ps1 | 8 +- test/e2e/tests/Feature.FreHooks.Tests.ps1 | 13 +- .../e2e/tests/Feature.OpenCodeHooks.Tests.ps1 | 1 - tools/wta/src/agent_hooks_installer.rs | 227 +++++++++++++++++- tools/wta/src/agent_hooks_installer_tests.rs | 86 +++++++ 5 files changed, 324 insertions(+), 11 deletions(-) diff --git a/test/e2e/Invoke-ItE2EReport.ps1 b/test/e2e/Invoke-ItE2EReport.ps1 index 15b723a42c..8203bcfa38 100644 --- a/test/e2e/Invoke-ItE2EReport.ps1 +++ b/test/e2e/Invoke-ItE2EReport.ps1 @@ -51,7 +51,13 @@ $cfg.TestResult.Enabled = $true $cfg.TestResult.OutputFormat = 'NUnitXml' $cfg.TestResult.OutputPath = (Join-Path $OutDir 'results.xml') -$result = Invoke-Pester -Configuration $cfg +$pesterOutput = @(Invoke-Pester -Configuration $cfg) +$result = $pesterOutput | + Where-Object { $_.PSObject.Properties.Name -contains 'Tests' -and $_.PSObject.Properties.Name -contains 'FailedCount' } | + Select-Object -Last 1 +if (-not $result) { + throw 'Pester did not return a test result object.' +} # ── Shared helpers ────────────────────────────────────────────────────────── function Get-FailureWhere($err) { diff --git a/test/e2e/tests/Feature.FreHooks.Tests.ps1 b/test/e2e/tests/Feature.FreHooks.Tests.ps1 index ea012d323e..bf1e0ab3da 100644 --- a/test/e2e/tests/Feature.FreHooks.Tests.ps1 +++ b/test/e2e/tests/Feature.FreHooks.Tests.ps1 @@ -20,6 +20,15 @@ Describe 'Feature §0 FRE session-management hook install' -Tag 'Feature' -Skip: Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force # Snapshot the real copilot config so install/uninstall during these tests is reverted. $script:cfgBackup = Backup-CopilotConfig + $script:RemoveHooksThroughProduct = { + $setupApp = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true + try { + Invoke-Wta -App $setupApp -Arguments @('hooks', 'uninstall', '--cli', 'copilot', '--json') -TimeoutSec 30 -Raw | Out-Null + } + finally { + Stop-Terminal -App $setupApp + } + } } AfterAll { if ($script:cfgBackup) { Restore-CopilotConfig -State $script:cfgBackup } @@ -27,7 +36,7 @@ Describe 'Feature §0 FRE session-management hook install' -Tag 'Feature' -Skip: It 'Session management on installs agent hooks (FRE Save)' { # Start from a deterministic not-installed baseline. - Remove-CopilotHooksEntry + & $script:RemoveHooksThroughProduct Get-CopilotHooksInstalled | Should -BeFalse -Because 'the baseline must be not-installed so a later true proves the FRE installed it' $app = Start-TerminalFre -Package (Get-ItTestPackage) @@ -57,7 +66,7 @@ Describe 'Feature §0 FRE session-management hook install' -Tag 'Feature' -Skip: It 'Session management off does not install hooks and leaves a usable terminal' { # Not-installed baseline; with the toggle OFF, Save must NOT install hooks. - Remove-CopilotHooksEntry + & $script:RemoveHooksThroughProduct Get-CopilotHooksInstalled | Should -BeFalse $app = Start-TerminalFre -Package (Get-ItTestPackage) diff --git a/test/e2e/tests/Feature.OpenCodeHooks.Tests.ps1 b/test/e2e/tests/Feature.OpenCodeHooks.Tests.ps1 index 2b41d16f49..daec56403b 100644 --- a/test/e2e/tests/Feature.OpenCodeHooks.Tests.ps1 +++ b/test/e2e/tests/Feature.OpenCodeHooks.Tests.ps1 @@ -28,7 +28,6 @@ Describe 'Feature: OpenCode session tracking hooks' -Tag 'Feature' -Skip:(-not $ $script:supportDir = Join-Path $script:pluginsDir 'wt-agent-hooks' $script:managedPaths = @( (Join-Path $script:pluginsDir 'wt-agent-hooks.js'), - (Join-Path $script:supportDir 'send-event.ps1'), (Join-Path $script:supportDir 'plugin.json') ) $script:pluginsDirExisted = Test-Path $script:pluginsDir diff --git a/tools/wta/src/agent_hooks_installer.rs b/tools/wta/src/agent_hooks_installer.rs index 4309c12e5f..70aacd1fd6 100644 --- a/tools/wta/src/agent_hooks_installer.rs +++ b/tools/wta/src/agent_hooks_installer.rs @@ -1054,12 +1054,7 @@ fn install_for_copilot(home: &Path) -> InstallOutcome { } let plugin_ref = format!("{}@{}", PLUGIN_NAME, MARKETPLACE_NAME); - if let Err(e) = run_plugin_cli( - "copilot", - &["plugin", "install", &plugin_ref], - "copilot_hooks", - &[], - ) { + if let Err(e) = install_copilot_plugin_with_locked_dir_retry(home, &plugin_ref) { tracing::warn!( target: "copilot_hooks", err = %e, @@ -1092,6 +1087,63 @@ fn install_for_copilot(home: &Path) -> InstallOutcome { InstallOutcome::Installed } +fn install_copilot_plugin_with_locked_dir_retry( + home: &Path, + plugin_ref: &str, +) -> std::io::Result<()> { + let args = ["plugin", "install", plugin_ref]; + let outcome = run_plugin_cli_capture("copilot", &args)?; + if outcome.success { + return Ok(()); + } + if !matches_idempotency_substring( + &outcome.stdout, + &outcome.stderr, + &["access is denied", "os error 5"], + ) { + return Err(std::io::Error::other(format!( + "copilot {} exited {}", + args.join(" "), + outcome + .status_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "?".into()) + ))); + } + if matches!( + read_installed_copilot(home), + Ok(Some(InstalledInfo { enabled: true, .. })) + ) && copilot_plugin_dir(home).is_dir() + { + tracing::info!( + target: "copilot_hooks", + "Copilot plugin files are locked but the plugin is already registered and enabled", + ); + return Ok(()); + } + + let mut messages = Vec::new(); + let detached = detach_locked_copilot_plugin(Some(home), &mut messages); + if !cleanup_copilot_plugin_config(Some(home), &mut messages) { + messages.push("failed to clean Copilot plugin registration before retry".into()); + } + let restored = !detached + && restore_deferred_copilot_registration(home, &mut messages) + && copilot_plugin_dir(home).is_dir(); + for message in messages { + tracing::info!( + target: "copilot_hooks", + message, + "preparing locked plugin install retry", + ); + } + if restored { + return Ok(()); + } + + run_plugin_cli("copilot", &args, "copilot_hooks", &[]) +} + /// Install hooks for Gemini CLI by spawning `gemini extensions install`. fn install_for_gemini(_home: &Path) -> InstallOutcome { if !cli_binary_on_path(CliKind::Gemini) { @@ -2228,6 +2280,10 @@ fn copilot_uninstall(home: Option<&Path>) -> CliUninstallResult { if which::which("copilot").is_ok() { out.attempted = true; + let detached = detach_locked_copilot_plugin(home, &mut out.messages); + if !detached { + preserve_copilot_registration(home, &mut out.messages); + } let cli_removed = spawn_step( &mut out.messages, "copilot", @@ -2235,7 +2291,11 @@ fn copilot_uninstall(home: Option<&Path>) -> CliUninstallResult { &["is not installed"], ); let config_clean = cleanup_copilot_plugin_config(home, &mut out.messages); - out.plugin_uninstalled = Some(cli_removed && config_clean); + // When an active Copilot process holds the plugin directory without + // delete sharing, removing its registration is the only possible + // uninstall until that process exits. The preserved entry lets a + // later install re-enable the still-valid locked copy. + out.plugin_uninstalled = Some((cli_removed || !detached) && config_clean); // `--force`: marketplace removal would otherwise refuse if // anything is still installed under it (e.g. previous step // failed). Belt-and-braces. @@ -2260,6 +2320,159 @@ fn copilot_uninstall(home: Option<&Path>) -> CliUninstallResult { out } +fn copilot_plugin_dir(home: &Path) -> PathBuf { + home.join(".copilot") + .join("installed-plugins") + .join(MARKETPLACE_NAME) + .join(PLUGIN_NAME) +} + +fn copilot_deferred_registration_path(home: &Path) -> PathBuf { + home.join(".copilot") + .join(format!(".{PLUGIN_NAME}.wta-deferred.json")) +} + +fn detach_locked_copilot_plugin(home: Option<&Path>, messages: &mut Vec) -> bool { + let Some(home) = home else { + return false; + }; + let plugin_dir = copilot_plugin_dir(home); + if !plugin_dir.is_dir() { + return true; + } + + for suffix in 0..100 { + let stale_dir = plugin_dir.with_file_name(format!( + "{PLUGIN_NAME}.wta-stale-{}-{suffix}", + std::process::id() + )); + if stale_dir.exists() { + continue; + } + match fs::rename(&plugin_dir, &stale_dir) { + Ok(()) => { + messages.push(format!( + "detached active Copilot plugin directory to {} before plugin update", + stale_dir.display() + )); + return true; + } + Err(error) => { + messages.push(format!( + "failed to detach active Copilot plugin directory {}: {}", + plugin_dir.display(), + error + )); + return false; + } + } + } + + messages.push(format!( + "failed to detach active Copilot plugin directory {}: no stale name available", + plugin_dir.display() + )); + false +} + +fn preserve_copilot_registration(home: Option<&Path>, messages: &mut Vec) -> bool { + let Some(home) = home else { + return false; + }; + let config_path = home.join(".copilot").join("config.json"); + let Ok(text) = fs::read_to_string(&config_path) else { + return false; + }; + let Ok(config) = serde_json::from_str::(&strip_jsonc_line_comments(&text)) else { + return false; + }; + let Some(entry) = config + .get("installedPlugins") + .and_then(Value::as_array) + .and_then(|entries| { + entries.iter().find(|entry| { + entry.get("name").and_then(Value::as_str) == Some(PLUGIN_NAME) + && entry.get("marketplace").and_then(Value::as_str) + == Some(MARKETPLACE_NAME) + }) + }) + else { + return false; + }; + let path = copilot_deferred_registration_path(home); + let serialized = match serde_json::to_string_pretty(entry) { + Ok(serialized) => serialized, + Err(error) => { + messages.push(format!( + "failed to preserve locked Copilot plugin registration: {}", + error + )); + return false; + } + }; + match fs::write(&path, serialized) { + Ok(()) => { + messages.push(format!( + "preserved locked Copilot plugin registration in {}", + path.display() + )); + true + } + Err(error) => { + messages.push(format!( + "failed to preserve locked Copilot plugin registration: {}", + error + )); + false + } + } +} + +fn restore_deferred_copilot_registration(home: &Path, messages: &mut Vec) -> bool { + let deferred_path = copilot_deferred_registration_path(home); + let Ok(deferred_text) = fs::read_to_string(&deferred_path) else { + return false; + }; + let Ok(entry) = serde_json::from_str::(&deferred_text) else { + return false; + }; + let config_path = home.join(".copilot").join("config.json"); + let mut config = match fs::read_to_string(&config_path) { + Ok(text) => match serde_json::from_str::(&strip_jsonc_line_comments(&text)) { + Ok(config) => config, + Err(_) => return false, + }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + serde_json::json!({ "installedPlugins": [] }) + } + Err(_) => return false, + }; + let Some(entries) = config + .get_mut("installedPlugins") + .and_then(Value::as_array_mut) + else { + return false; + }; + entries.retain(|current| { + current.get("name").and_then(Value::as_str) != Some(PLUGIN_NAME) + || current.get("marketplace").and_then(Value::as_str) != Some(MARKETPLACE_NAME) + }); + entries.push(entry); + let Ok(serialized) = serde_json::to_string_pretty(&config) else { + return false; + }; + if let Err(error) = fs::write(&config_path, serialized) { + messages.push(format!( + "failed to restore locked Copilot plugin registration: {}", + error + )); + return false; + } + let _ = fs::remove_file(&deferred_path); + messages.push("restored locked Copilot plugin registration without replacing its files".into()); + true +} + fn cleanup_copilot_plugin_config(home: Option<&Path>, messages: &mut Vec) -> bool { let Some(home) = home else { messages.push("copilot config cleanup skipped: home directory unavailable".into()); diff --git a/tools/wta/src/agent_hooks_installer_tests.rs b/tools/wta/src/agent_hooks_installer_tests.rs index 3d2e3b8640..e2f96f0d58 100644 --- a/tools/wta/src/agent_hooks_installer_tests.rs +++ b/tools/wta/src/agent_hooks_installer_tests.rs @@ -19,6 +19,92 @@ fn unique_dir(label: &str) -> PathBuf { p } +#[test] +fn copilot_plugin_update_detaches_a_directory_that_may_be_locked() { + let home = unique_dir("copilot-detach"); + let plugin_dir = home + .join(".copilot") + .join("installed-plugins") + .join(MARKETPLACE_NAME) + .join(PLUGIN_NAME); + fs::create_dir_all(&plugin_dir).unwrap(); + fs::write(plugin_dir.join("plugin.json"), "{}").unwrap(); + + let mut messages = Vec::new(); + assert!(detach_locked_copilot_plugin(Some(&home), &mut messages)); + + assert!(!plugin_dir.exists()); + let parent = plugin_dir.parent().unwrap(); + let detached = fs::read_dir(parent) + .unwrap() + .flatten() + .map(|entry| entry.path()) + .find(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("wt-agent-hooks.wta-stale-")) + }) + .expect("detached plugin directory"); + assert!(detached.join("plugin.json").is_file()); + assert!(messages + .iter() + .any(|message| message.contains("before plugin update"))); + + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn copilot_registration_can_be_deferred_across_a_locked_uninstall() { + let home = unique_dir("copilot-deferred"); + let copilot_dir = home.join(".copilot"); + fs::create_dir_all(&copilot_dir).unwrap(); + fs::write( + copilot_dir.join("config.json"), + r#"{ + "installedPlugins": [ + { + "name": "wt-agent-hooks", + "marketplace": "wt-local", + "version": "0.1.6", + "cache_path": "locked" + }, + { + "name": "other", + "marketplace": "example", + "version": "1.0.0" + } + ] + }"#, + ) + .unwrap(); + + let mut messages = Vec::new(); + assert!(preserve_copilot_registration( + Some(&home), + &mut messages + )); + assert!(cleanup_copilot_plugin_config( + Some(&home), + &mut messages + )); + assert!(restore_deferred_copilot_registration( + &home, + &mut messages + )); + + let config: Value = + serde_json::from_str(&fs::read_to_string(copilot_dir.join("config.json")).unwrap()) + .unwrap(); + let entries = config["installedPlugins"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert!(entries.iter().any(|entry| { + entry["name"] == PLUGIN_NAME && entry["marketplace"] == MARKETPLACE_NAME + })); + assert!(!copilot_deferred_registration_path(&home).exists()); + + fs::remove_dir_all(home).unwrap(); +} + // ---- bundle resolver ------------------------------------------------- /// `bundle::find_loose_dir` returns the per-CLI subdirectory when it From 12714e3dc3374a61728a6432b75707c4e92077a3 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Sun, 23 Aug 2026 21:36:28 +0800 Subject: [PATCH 4/7] Fix agent input mouse selection Preserve click history when focusing the input so double-click word selection works. Harden physical-input integration tests against foreground loss, stale cross-test focus state, text-rectangle overshoot, and ambiguous redock actions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 786d81b1-3d9e-49fa-8d4f-8b375389dff9 --- test/e2e/ItE2E/Public/Ui.ps1 | 3 ++ test/e2e/tests/Feature.AgentMouse.Tests.ps1 | 7 ++-- test/e2e/tests/Feature.MultiWindow.Tests.ps1 | 7 ++++ test/e2e/tests/Feature.Paste.Tests.ps1 | 4 +++ tools/wta/src/app_events.rs | 1 - tools/wta/src/app_tests.rs | 38 ++++++++++++++++++++ 6 files changed, 54 insertions(+), 6 deletions(-) diff --git a/test/e2e/ItE2E/Public/Ui.ps1 b/test/e2e/ItE2E/Public/Ui.ps1 index a7f3b06ea9..660760940e 100644 --- a/test/e2e/ItE2E/Public/Ui.ps1 +++ b/test/e2e/ItE2E/Public/Ui.ps1 @@ -393,6 +393,9 @@ function Invoke-UiMouseDrag { [int]$HoldMs = 50 ) process { + if (-not (Set-WtWindowForeground -App $App -Attempts 3 -DelayMs 150)) { + throw 'No interactive desktop is available for physical mouse injection.' + } $args = @('drag', "$FromX,$FromY", "$ToX,$ToY", '--hold-ms', $HoldMs) if ($Right) { $args += '--right' } $result = Invoke-WinAppUi -App $App -UiArgs $args diff --git a/test/e2e/tests/Feature.AgentMouse.Tests.ps1 b/test/e2e/tests/Feature.AgentMouse.Tests.ps1 index abbe4bf196..e6f23b9f0a 100644 --- a/test/e2e/tests/Feature.AgentMouse.Tests.ps1 +++ b/test/e2e/tests/Feature.AgentMouse.Tests.ps1 @@ -105,9 +105,6 @@ Describe 'Feature: agent pane mouse interactions' -Tag 'Feature' -Skip:(-not $sc Send-AgentWin32Key -App $script:app -PaneSessionId $session.PaneSessionId -Vk 0x43 -Sc 0x2E -Uc 3 -Modifiers 0x08 | Out-Null (Get-Clipboard -Raw) | Should -Be $marker -Because 'Ctrl+C must copy the WTA mouse selection through the OS clipboard' - $copiedPattern = Get-WtaLocalizedTextRegex -Key 'system.selection_copied' - if (-not $copiedPattern) { $copiedPattern = '(?i)Copied' } - Assert-AgentPaneText -App $script:app -PaneSessionId $session.PaneSessionId -Pattern $copiedPattern -TimeoutSec 5 $sentinel = "MOUSE_COPY_CLEARED_$([guid]::NewGuid().ToString('N'))" Set-Clipboard -Value $sentinel @@ -424,8 +421,8 @@ Describe 'Feature: completed-turn triangle mouse click' -Tag 'CompletedTurnMouse $rectangles.Count | Should -Be 1 -Because 'the unique reply marker must expose one UIA text rectangle' $rect = $rectangles[0] $cellWidth = $rect.Width / $reply.Length - $fromX = [Math]::Round($rect.Right - 1) - $toX = [Math]::Round($rect.Left - (2 * $cellWidth) + 1) + $fromX = [Math]::Round($rect.Right - ($cellWidth / 2)) + $toX = [Math]::Round($rect.Left + ($cellWidth / 2)) $y = [Math]::Round($rect.Top + ($rect.Height / 2)) Save-UiScreenshot -App $script:app -Path (Join-Path $rightClickEvidenceDir 'before-right-click-selection.png') | Out-Null diff --git a/test/e2e/tests/Feature.MultiWindow.Tests.ps1 b/test/e2e/tests/Feature.MultiWindow.Tests.ps1 index a0863a915c..246d4a64ff 100644 --- a/test/e2e/tests/Feature.MultiWindow.Tests.ps1 +++ b/test/e2e/tests/Feature.MultiWindow.Tests.ps1 @@ -210,6 +210,13 @@ Describe 'Feature: agent tab undock and redock lifecycle' -Tag 'Feature' -Skip:( Start-Sleep -Milliseconds 1000 & winapp ui invoke 'Move tab to window' -w ([string]$temporaryApp.Hwnd) 2>&1 | Out-Null + $redockedWindow = Wait-Until -TimeoutSec 15 -IntervalSec 1 -Quiet -Because 'agent pane redocked into a source window' -Condition { + foreach ($windowId in $sourceWindows) { + $paneIds = @(Get-WtPanes -App $script:redockApp -WindowId $windowId | ForEach-Object session_id) + if ($paneIds -contains $agentSid) { return $windowId } + } + } + $redockedWindow | Should -Not -BeNullOrEmpty -Because 'the move action must transfer the original agent pane before window-close behavior can be evaluated' (Test-Until -TimeoutSec 15 -IntervalSec 1 -Condition { @(Get-WtWindows -App $script:redockApp).window_id -notcontains $temporaryWindow }) | Should -BeTrue -Because 'redocking the only tab should close the temporary window' diff --git a/test/e2e/tests/Feature.Paste.Tests.ps1 b/test/e2e/tests/Feature.Paste.Tests.ps1 index d3206b252b..7d5ca42d8f 100644 --- a/test/e2e/tests/Feature.Paste.Tests.ps1 +++ b/test/e2e/tests/Feature.Paste.Tests.ps1 @@ -87,6 +87,10 @@ Describe 'Feature §2 agent pane paste' -Tag 'Feature' -Skip:(-not $script:Ready ($lines[$inputStart..($lines.Count - 1)] -join "`n") } } + BeforeEach { + Send-AgentKey -App $script:app -PaneSessionId $script:agentPane -Key Escape | Out-Null + & $script:clearPasteDraft + } AfterAll { if ($script:app) { Stop-Terminal -App $script:app } if ($null -ne $script:originalClipboard) { Set-Clipboard -Value $script:originalClipboard } diff --git a/tools/wta/src/app_events.rs b/tools/wta/src/app_events.rs index 6de7e248db..039f8e6db0 100644 --- a/tools/wta/src/app_events.rs +++ b/tools/wta/src/app_events.rs @@ -234,7 +234,6 @@ impl App { { self.pressed_completed_turn = None; self.last_completed_turn_click = None; - self.text_selection.clear(); self.current_tab_mut().clear_completed_turn_selection(); return; } diff --git a/tools/wta/src/app_tests.rs b/tools/wta/src/app_tests.rs index fe39631ede..9af920a6de 100644 --- a/tools/wta/src/app_tests.rs +++ b/tools/wta/src/app_tests.rs @@ -8251,6 +8251,44 @@ fn clicking_input_dialog_restores_input_navigation_after_mouse_turn_selection() assert_eq!(app.current_tab().input, "x"); } +#[test] +fn double_click_in_input_dialog_preserves_word_selection() { + use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; + + let mut app = test_app(); + app.state = ConnectionState::Connected; + app.current_tab_mut().input = "INPUT_DOUBLE_CLICK_MARKER".into(); + let rendered = render_to_text(&mut app, 80, 16); + let (row, column) = rendered + .lines() + .enumerate() + .find_map(|(row, line)| { + line.find("INPUT_DOUBLE_CLICK_MARKER") + .map(|column| (row as u16, column as u16 + 2)) + }) + .expect("input marker must be visible"); + + for _ in 0..2 { + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + app.handle_event(AppEvent::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + })); + } + render_to_text(&mut app, 80, 16); + } + + assert_eq!( + app.text_selection.selected_text().as_deref(), + Some("INPUT_DOUBLE_CLICK_MARKER") + ); +} + #[test] fn completed_turn_user_input_multi_click_preserves_turn_state_and_text_selection() { use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; From 5357aa52945caf1a4214c5dc083e702dc822ae7b Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Mon, 24 Aug 2026 09:59:26 +0800 Subject: [PATCH 5/7] Address integration test review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 786d81b1-3d9e-49fa-8d4f-8b375389dff9 --- .../Feature.AgentProtocolExperience.Tests.ps1 | 5 ++- test/e2e/tests/Feature.ByokProvider.Tests.ps1 | 2 +- tools/wta/src/agent_hooks_installer_tests.rs | 3 +- tools/wta/src/app_events.rs | 1 + tools/wta/src/app_tests.rs | 38 ++++++++++++------- 5 files changed, 33 insertions(+), 16 deletions(-) diff --git a/test/e2e/tests/Feature.AgentProtocolExperience.Tests.ps1 b/test/e2e/tests/Feature.AgentProtocolExperience.Tests.ps1 index 7a62853813..80ef716234 100644 --- a/test/e2e/tests/Feature.AgentProtocolExperience.Tests.ps1 +++ b/test/e2e/tests/Feature.AgentProtocolExperience.Tests.ps1 @@ -4,10 +4,13 @@ # lifecycle through the deployed Terminal, helper, master, and a real stdio agent. BeforeDiscovery { + $fixturePath = (Resolve-Path (Join-Path $PSScriptRoot '..\fixtures\Mock-AcpInteractionAgent.ps1')).Path $script:Ready = [bool]( (Get-AppxPackage | Where-Object { $_.Name -like '*IntelligentTerminal*' }) -and (Get-Command pwsh -ErrorAction SilentlyContinue) -and - (Get-Command winapp -ErrorAction SilentlyContinue) + (Get-Command winapp -ErrorAction SilentlyContinue) -and + ($fixturePath -notmatch '\s') -and + ($env:TEMP -notmatch '\s') ) } diff --git a/test/e2e/tests/Feature.ByokProvider.Tests.ps1 b/test/e2e/tests/Feature.ByokProvider.Tests.ps1 index 9276d0ef96..fdaf5c8788 100644 --- a/test/e2e/tests/Feature.ByokProvider.Tests.ps1 +++ b/test/e2e/tests/Feature.ByokProvider.Tests.ps1 @@ -61,7 +61,7 @@ public static class ItE2ECredential TargetName = target, CredentialBlobSize = (uint)bytes.Length, CredentialBlob = handle.AddrOfPinnedObject(), - Persist = 2, + Persist = 1, UserName = "Intelligent Terminal" }; if (!CredWrite(ref credential, 0)) diff --git a/tools/wta/src/agent_hooks_installer_tests.rs b/tools/wta/src/agent_hooks_installer_tests.rs index e2f96f0d58..ffc56f2e53 100644 --- a/tools/wta/src/agent_hooks_installer_tests.rs +++ b/tools/wta/src/agent_hooks_installer_tests.rs @@ -35,6 +35,7 @@ fn copilot_plugin_update_detaches_a_directory_that_may_be_locked() { assert!(!plugin_dir.exists()); let parent = plugin_dir.parent().unwrap(); + let stale_prefix = format!("{PLUGIN_NAME}.wta-stale-"); let detached = fs::read_dir(parent) .unwrap() .flatten() @@ -42,7 +43,7 @@ fn copilot_plugin_update_detaches_a_directory_that_may_be_locked() { .find(|path| { path.file_name() .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with("wt-agent-hooks.wta-stale-")) + .is_some_and(|name| name.starts_with(&stale_prefix)) }) .expect("detached plugin directory"); assert!(detached.join("plugin.json").is_file()); diff --git a/tools/wta/src/app_events.rs b/tools/wta/src/app_events.rs index 039f8e6db0..966a340247 100644 --- a/tools/wta/src/app_events.rs +++ b/tools/wta/src/app_events.rs @@ -232,6 +232,7 @@ impl App { if input_pressed.as_deref() == Some(active_tab_id.as_str()) && self.input_dialog_at(mouse.column, mouse.row) { + self.text_selection.handle_mouse(mouse); self.pressed_completed_turn = None; self.last_completed_turn_click = None; self.current_tab_mut().clear_completed_turn_selection(); diff --git a/tools/wta/src/app_tests.rs b/tools/wta/src/app_tests.rs index 9af920a6de..812bd65d8f 100644 --- a/tools/wta/src/app_tests.rs +++ b/tools/wta/src/app_tests.rs @@ -8268,20 +8268,32 @@ fn double_click_in_input_dialog_preserves_word_selection() { }) .expect("input marker must be visible"); - for _ in 0..2 { - for kind in [ - MouseEventKind::Down(MouseButton::Left), - MouseEventKind::Up(MouseButton::Left), - ] { - app.handle_event(AppEvent::Mouse(MouseEvent { - kind, - column, - row, - modifiers: KeyModifiers::NONE, - })); - } - render_to_text(&mut app, 80, 16); + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + app.handle_event(AppEvent::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + })); } + assert_eq!(app.text_selection.selected_text(), None); + render_to_text(&mut app, 80, 16); + + for kind in [ + MouseEventKind::Down(MouseButton::Left), + MouseEventKind::Up(MouseButton::Left), + ] { + app.handle_event(AppEvent::Mouse(MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + })); + } + render_to_text(&mut app, 80, 16); assert_eq!( app.text_selection.selected_text().as_deref(), From e68949dc5ef000dad82f8fef18b3af677c3e9a63 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Mon, 24 Aug 2026 10:43:54 +0800 Subject: [PATCH 6/7] Preserve locked Copilot registration during retry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 786d81b1-3d9e-49fa-8d4f-8b375389dff9 --- tools/wta/src/agent_hooks_installer.rs | 16 +++-- tools/wta/src/agent_hooks_installer_tests.rs | 71 +++++++++++++++++++- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/tools/wta/src/agent_hooks_installer.rs b/tools/wta/src/agent_hooks_installer.rs index d376f97830..22a173c825 100644 --- a/tools/wta/src/agent_hooks_installer.rs +++ b/tools/wta/src/agent_hooks_installer.rs @@ -1171,12 +1171,16 @@ fn install_copilot_plugin_with_locked_dir_retry( let mut messages = Vec::new(); let detached = detach_locked_copilot_plugin(Some(home), &mut messages); - if !cleanup_copilot_plugin_config(Some(home), &mut messages) { - messages.push("failed to clean Copilot plugin registration before retry".into()); - } - let restored = !detached - && restore_deferred_copilot_registration(home, &mut messages) - && copilot_plugin_dir(home).is_dir(); + let restored = if detached { + if !cleanup_copilot_plugin_config(Some(home), &mut messages) { + messages.push("failed to clean Copilot plugin registration before retry".into()); + } + false + } else { + preserve_copilot_registration(Some(home), &mut messages); + restore_deferred_copilot_registration(home, &mut messages) + && copilot_plugin_dir(home).is_dir() + }; for message in messages { tracing::info!( target: "copilot_hooks", diff --git a/tools/wta/src/agent_hooks_installer_tests.rs b/tools/wta/src/agent_hooks_installer_tests.rs index dcf048e9c6..720059b19f 100644 --- a/tools/wta/src/agent_hooks_installer_tests.rs +++ b/tools/wta/src/agent_hooks_installer_tests.rs @@ -55,7 +55,7 @@ fn copilot_plugin_update_detaches_a_directory_that_may_be_locked() { } #[test] -fn copilot_registration_can_be_deferred_across_a_locked_uninstall() { +fn copilot_registration_restore_replaces_matching_entries_in_place() { let home = unique_dir("copilot-deferred"); let copilot_dir = home.join(".copilot"); fs::create_dir_all(&copilot_dir).unwrap(); @@ -73,6 +73,11 @@ fn copilot_registration_can_be_deferred_across_a_locked_uninstall() { "name": "other", "marketplace": "example", "version": "1.0.0" + }, + { + "name": "wt-agent-hooks", + "marketplace": "wt-local", + "version": "stale" } ] }"#, @@ -81,7 +86,6 @@ fn copilot_registration_can_be_deferred_across_a_locked_uninstall() { let mut messages = Vec::new(); assert!(preserve_copilot_registration(Some(&home), &mut messages)); - assert!(cleanup_copilot_plugin_config(Some(&home), &mut messages)); assert!(restore_deferred_copilot_registration(&home, &mut messages)); let config: Value = @@ -89,9 +93,70 @@ fn copilot_registration_can_be_deferred_across_a_locked_uninstall() { .unwrap(); let entries = config["installedPlugins"].as_array().unwrap(); assert_eq!(entries.len(), 2); + let matching: Vec<_> = entries + .iter() + .filter(|entry| entry["name"] == PLUGIN_NAME && entry["marketplace"] == MARKETPLACE_NAME) + .collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0]["version"], "0.1.6"); + assert!(entries + .iter() + .any(|entry| entry["name"] == "other" && entry["marketplace"] == "example")); + assert!(!copilot_deferred_registration_path(&home).exists()); + + fs::remove_dir_all(home).unwrap(); +} + +#[test] +fn copilot_registration_restore_uses_existing_snapshot_when_current_is_absent() { + let home = unique_dir("copilot-existing-deferred"); + let copilot_dir = home.join(".copilot"); + fs::create_dir_all(&copilot_dir).unwrap(); + fs::write( + copilot_dir.join("config.json"), + r#"{ + "installedPlugins": [ + { + "name": "other", + "marketplace": "example", + "version": "1.0.0" + } + ] + }"#, + ) + .unwrap(); + fs::write( + copilot_deferred_registration_path(&home), + r#"{ + "name": "wt-agent-hooks", + "marketplace": "wt-local", + "version": "0.1.6", + "cache_path": "locked" + }"#, + ) + .unwrap(); + + let mut messages = Vec::new(); + assert!(!preserve_copilot_registration(Some(&home), &mut messages)); + assert!(restore_deferred_copilot_registration(&home, &mut messages)); + + let config: Value = + serde_json::from_str(&fs::read_to_string(copilot_dir.join("config.json")).unwrap()) + .unwrap(); + let entries = config["installedPlugins"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!( + entries + .iter() + .filter(|entry| { + entry["name"] == PLUGIN_NAME && entry["marketplace"] == MARKETPLACE_NAME + }) + .count(), + 1 + ); assert!(entries .iter() - .any(|entry| { entry["name"] == PLUGIN_NAME && entry["marketplace"] == MARKETPLACE_NAME })); + .any(|entry| entry["name"] == "other" && entry["marketplace"] == "example")); assert!(!copilot_deferred_registration_path(&home).exists()); fs::remove_dir_all(home).unwrap(); From eeeeed13a76a5e0f2022b749badb7755bb209202 Mon Sep 17 00:00:00 2001 From: "Kai Tao (from Dev Box)" Date: Mon, 24 Aug 2026 11:58:14 +0800 Subject: [PATCH 7/7] Remove hook changes from integration test PR Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 786d81b1-3d9e-49fa-8d4f-8b375389dff9 --- test/e2e/tests/Feature.FreHooks.Tests.ps1 | 13 +- .../e2e/tests/Feature.OpenCodeHooks.Tests.ps1 | 1 + tools/wta/src/agent_hooks_installer.rs | 230 +----------------- tools/wta/src/agent_hooks_installer_tests.rs | 143 ----------- 4 files changed, 10 insertions(+), 377 deletions(-) diff --git a/test/e2e/tests/Feature.FreHooks.Tests.ps1 b/test/e2e/tests/Feature.FreHooks.Tests.ps1 index bf1e0ab3da..ea012d323e 100644 --- a/test/e2e/tests/Feature.FreHooks.Tests.ps1 +++ b/test/e2e/tests/Feature.FreHooks.Tests.ps1 @@ -20,15 +20,6 @@ Describe 'Feature §0 FRE session-management hook install' -Tag 'Feature' -Skip: Import-Module (Join-Path $PSScriptRoot '..\ItE2E\ItE2E.psd1') -Force # Snapshot the real copilot config so install/uninstall during these tests is reverted. $script:cfgBackup = Backup-CopilotConfig - $script:RemoveHooksThroughProduct = { - $setupApp = Start-Terminal -Package (Get-ItTestPackage) -PassFre $true - try { - Invoke-Wta -App $setupApp -Arguments @('hooks', 'uninstall', '--cli', 'copilot', '--json') -TimeoutSec 30 -Raw | Out-Null - } - finally { - Stop-Terminal -App $setupApp - } - } } AfterAll { if ($script:cfgBackup) { Restore-CopilotConfig -State $script:cfgBackup } @@ -36,7 +27,7 @@ Describe 'Feature §0 FRE session-management hook install' -Tag 'Feature' -Skip: It 'Session management on installs agent hooks (FRE Save)' { # Start from a deterministic not-installed baseline. - & $script:RemoveHooksThroughProduct + Remove-CopilotHooksEntry Get-CopilotHooksInstalled | Should -BeFalse -Because 'the baseline must be not-installed so a later true proves the FRE installed it' $app = Start-TerminalFre -Package (Get-ItTestPackage) @@ -66,7 +57,7 @@ Describe 'Feature §0 FRE session-management hook install' -Tag 'Feature' -Skip: It 'Session management off does not install hooks and leaves a usable terminal' { # Not-installed baseline; with the toggle OFF, Save must NOT install hooks. - & $script:RemoveHooksThroughProduct + Remove-CopilotHooksEntry Get-CopilotHooksInstalled | Should -BeFalse $app = Start-TerminalFre -Package (Get-ItTestPackage) diff --git a/test/e2e/tests/Feature.OpenCodeHooks.Tests.ps1 b/test/e2e/tests/Feature.OpenCodeHooks.Tests.ps1 index daec56403b..2b41d16f49 100644 --- a/test/e2e/tests/Feature.OpenCodeHooks.Tests.ps1 +++ b/test/e2e/tests/Feature.OpenCodeHooks.Tests.ps1 @@ -28,6 +28,7 @@ Describe 'Feature: OpenCode session tracking hooks' -Tag 'Feature' -Skip:(-not $ $script:supportDir = Join-Path $script:pluginsDir 'wt-agent-hooks' $script:managedPaths = @( (Join-Path $script:pluginsDir 'wt-agent-hooks.js'), + (Join-Path $script:supportDir 'send-event.ps1'), (Join-Path $script:supportDir 'plugin.json') ) $script:pluginsDirExisted = Test-Path $script:pluginsDir diff --git a/tools/wta/src/agent_hooks_installer.rs b/tools/wta/src/agent_hooks_installer.rs index 22a173c825..cecac1bf84 100644 --- a/tools/wta/src/agent_hooks_installer.rs +++ b/tools/wta/src/agent_hooks_installer.rs @@ -1101,7 +1101,12 @@ fn install_for_copilot(home: &Path) -> InstallOutcome { } let plugin_ref = format!("{}@{}", PLUGIN_NAME, MARKETPLACE_NAME); - if let Err(e) = install_copilot_plugin_with_locked_dir_retry(home, &plugin_ref) { + if let Err(e) = run_plugin_cli( + "copilot", + &["plugin", "install", &plugin_ref], + "copilot_hooks", + &[], + ) { tracing::warn!( target: "copilot_hooks", err = %e, @@ -1134,67 +1139,6 @@ fn install_for_copilot(home: &Path) -> InstallOutcome { InstallOutcome::Installed } -fn install_copilot_plugin_with_locked_dir_retry( - home: &Path, - plugin_ref: &str, -) -> std::io::Result<()> { - let args = ["plugin", "install", plugin_ref]; - let outcome = run_plugin_cli_capture("copilot", &args)?; - if outcome.success { - return Ok(()); - } - if !matches_idempotency_substring( - &outcome.stdout, - &outcome.stderr, - &["access is denied", "os error 5"], - ) { - return Err(std::io::Error::other(format!( - "copilot {} exited {}", - args.join(" "), - outcome - .status_code - .map(|code| code.to_string()) - .unwrap_or_else(|| "?".into()) - ))); - } - if matches!( - read_installed_copilot(home), - Ok(Some(InstalledInfo { enabled: true, .. })) - ) && copilot_plugin_dir(home).is_dir() - { - tracing::info!( - target: "copilot_hooks", - "Copilot plugin files are locked but the plugin is already registered and enabled", - ); - return Ok(()); - } - - let mut messages = Vec::new(); - let detached = detach_locked_copilot_plugin(Some(home), &mut messages); - let restored = if detached { - if !cleanup_copilot_plugin_config(Some(home), &mut messages) { - messages.push("failed to clean Copilot plugin registration before retry".into()); - } - false - } else { - preserve_copilot_registration(Some(home), &mut messages); - restore_deferred_copilot_registration(home, &mut messages) - && copilot_plugin_dir(home).is_dir() - }; - for message in messages { - tracing::info!( - target: "copilot_hooks", - message, - "preparing locked plugin install retry", - ); - } - if restored { - return Ok(()); - } - - run_plugin_cli("copilot", &args, "copilot_hooks", &[]) -} - /// Install hooks for Gemini CLI by spawning `gemini extensions install`. fn install_for_gemini(_home: &Path) -> InstallOutcome { if !cli_binary_on_path(CliKind::Gemini) { @@ -2330,10 +2274,6 @@ fn copilot_uninstall(home: Option<&Path>) -> CliUninstallResult { if which::which("copilot").is_ok() { out.attempted = true; - let detached = detach_locked_copilot_plugin(home, &mut out.messages); - if !detached { - preserve_copilot_registration(home, &mut out.messages); - } let cli_removed = spawn_step( &mut out.messages, "copilot", @@ -2341,11 +2281,7 @@ fn copilot_uninstall(home: Option<&Path>) -> CliUninstallResult { &["is not installed"], ); let config_clean = cleanup_copilot_plugin_config(home, &mut out.messages); - // When an active Copilot process holds the plugin directory without - // delete sharing, removing its registration is the only possible - // uninstall until that process exits. The preserved entry lets a - // later install re-enable the still-valid locked copy. - out.plugin_uninstalled = Some((cli_removed || !detached) && config_clean); + out.plugin_uninstalled = Some(cli_removed && config_clean); // `--force`: marketplace removal would otherwise refuse if // anything is still installed under it (e.g. previous step // failed). Belt-and-braces. @@ -2370,158 +2306,6 @@ fn copilot_uninstall(home: Option<&Path>) -> CliUninstallResult { out } -fn copilot_plugin_dir(home: &Path) -> PathBuf { - home.join(".copilot") - .join("installed-plugins") - .join(MARKETPLACE_NAME) - .join(PLUGIN_NAME) -} - -fn copilot_deferred_registration_path(home: &Path) -> PathBuf { - home.join(".copilot") - .join(format!(".{PLUGIN_NAME}.wta-deferred.json")) -} - -fn detach_locked_copilot_plugin(home: Option<&Path>, messages: &mut Vec) -> bool { - let Some(home) = home else { - return false; - }; - let plugin_dir = copilot_plugin_dir(home); - if !plugin_dir.is_dir() { - return true; - } - - for suffix in 0..100 { - let stale_dir = plugin_dir.with_file_name(format!( - "{PLUGIN_NAME}.wta-stale-{}-{suffix}", - std::process::id() - )); - if stale_dir.exists() { - continue; - } - match fs::rename(&plugin_dir, &stale_dir) { - Ok(()) => { - messages.push(format!( - "detached active Copilot plugin directory to {} before plugin update", - stale_dir.display() - )); - return true; - } - Err(error) => { - messages.push(format!( - "failed to detach active Copilot plugin directory {}: {}", - plugin_dir.display(), - error - )); - return false; - } - } - } - - messages.push(format!( - "failed to detach active Copilot plugin directory {}: no stale name available", - plugin_dir.display() - )); - false -} - -fn preserve_copilot_registration(home: Option<&Path>, messages: &mut Vec) -> bool { - let Some(home) = home else { - return false; - }; - let config_path = home.join(".copilot").join("config.json"); - let Ok(text) = fs::read_to_string(&config_path) else { - return false; - }; - let Ok(config) = serde_json::from_str::(&strip_jsonc_line_comments(&text)) else { - return false; - }; - let Some(entry) = config - .get("installedPlugins") - .and_then(Value::as_array) - .and_then(|entries| { - entries.iter().find(|entry| { - entry.get("name").and_then(Value::as_str) == Some(PLUGIN_NAME) - && entry.get("marketplace").and_then(Value::as_str) == Some(MARKETPLACE_NAME) - }) - }) - else { - return false; - }; - let path = copilot_deferred_registration_path(home); - let serialized = match serde_json::to_string_pretty(entry) { - Ok(serialized) => serialized, - Err(error) => { - messages.push(format!( - "failed to preserve locked Copilot plugin registration: {}", - error - )); - return false; - } - }; - match fs::write(&path, serialized) { - Ok(()) => { - messages.push(format!( - "preserved locked Copilot plugin registration in {}", - path.display() - )); - true - } - Err(error) => { - messages.push(format!( - "failed to preserve locked Copilot plugin registration: {}", - error - )); - false - } - } -} - -fn restore_deferred_copilot_registration(home: &Path, messages: &mut Vec) -> bool { - let deferred_path = copilot_deferred_registration_path(home); - let Ok(deferred_text) = fs::read_to_string(&deferred_path) else { - return false; - }; - let Ok(entry) = serde_json::from_str::(&deferred_text) else { - return false; - }; - let config_path = home.join(".copilot").join("config.json"); - let mut config = match fs::read_to_string(&config_path) { - Ok(text) => match serde_json::from_str::(&strip_jsonc_line_comments(&text)) { - Ok(config) => config, - Err(_) => return false, - }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - serde_json::json!({ "installedPlugins": [] }) - } - Err(_) => return false, - }; - let Some(entries) = config - .get_mut("installedPlugins") - .and_then(Value::as_array_mut) - else { - return false; - }; - entries.retain(|current| { - current.get("name").and_then(Value::as_str) != Some(PLUGIN_NAME) - || current.get("marketplace").and_then(Value::as_str) != Some(MARKETPLACE_NAME) - }); - entries.push(entry); - let Ok(serialized) = serde_json::to_string_pretty(&config) else { - return false; - }; - if let Err(error) = fs::write(&config_path, serialized) { - messages.push(format!( - "failed to restore locked Copilot plugin registration: {}", - error - )); - return false; - } - let _ = fs::remove_file(&deferred_path); - messages.push("restored locked Copilot plugin registration without replacing its files".into()); - true -} - fn cleanup_copilot_plugin_config(home: Option<&Path>, messages: &mut Vec) -> bool { let Some(home) = home else { messages.push("copilot config cleanup skipped: home directory unavailable".into()); diff --git a/tools/wta/src/agent_hooks_installer_tests.rs b/tools/wta/src/agent_hooks_installer_tests.rs index 720059b19f..2d04f7716a 100644 --- a/tools/wta/src/agent_hooks_installer_tests.rs +++ b/tools/wta/src/agent_hooks_installer_tests.rs @@ -19,149 +19,6 @@ fn unique_dir(label: &str) -> PathBuf { p } -#[test] -fn copilot_plugin_update_detaches_a_directory_that_may_be_locked() { - let home = unique_dir("copilot-detach"); - let plugin_dir = home - .join(".copilot") - .join("installed-plugins") - .join(MARKETPLACE_NAME) - .join(PLUGIN_NAME); - fs::create_dir_all(&plugin_dir).unwrap(); - fs::write(plugin_dir.join("plugin.json"), "{}").unwrap(); - - let mut messages = Vec::new(); - assert!(detach_locked_copilot_plugin(Some(&home), &mut messages)); - - assert!(!plugin_dir.exists()); - let parent = plugin_dir.parent().unwrap(); - let stale_prefix = format!("{PLUGIN_NAME}.wta-stale-"); - let detached = fs::read_dir(parent) - .unwrap() - .flatten() - .map(|entry| entry.path()) - .find(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with(&stale_prefix)) - }) - .expect("detached plugin directory"); - assert!(detached.join("plugin.json").is_file()); - assert!(messages - .iter() - .any(|message| message.contains("before plugin update"))); - - fs::remove_dir_all(home).unwrap(); -} - -#[test] -fn copilot_registration_restore_replaces_matching_entries_in_place() { - let home = unique_dir("copilot-deferred"); - let copilot_dir = home.join(".copilot"); - fs::create_dir_all(&copilot_dir).unwrap(); - fs::write( - copilot_dir.join("config.json"), - r#"{ - "installedPlugins": [ - { - "name": "wt-agent-hooks", - "marketplace": "wt-local", - "version": "0.1.6", - "cache_path": "locked" - }, - { - "name": "other", - "marketplace": "example", - "version": "1.0.0" - }, - { - "name": "wt-agent-hooks", - "marketplace": "wt-local", - "version": "stale" - } - ] - }"#, - ) - .unwrap(); - - let mut messages = Vec::new(); - assert!(preserve_copilot_registration(Some(&home), &mut messages)); - assert!(restore_deferred_copilot_registration(&home, &mut messages)); - - let config: Value = - serde_json::from_str(&fs::read_to_string(copilot_dir.join("config.json")).unwrap()) - .unwrap(); - let entries = config["installedPlugins"].as_array().unwrap(); - assert_eq!(entries.len(), 2); - let matching: Vec<_> = entries - .iter() - .filter(|entry| entry["name"] == PLUGIN_NAME && entry["marketplace"] == MARKETPLACE_NAME) - .collect(); - assert_eq!(matching.len(), 1); - assert_eq!(matching[0]["version"], "0.1.6"); - assert!(entries - .iter() - .any(|entry| entry["name"] == "other" && entry["marketplace"] == "example")); - assert!(!copilot_deferred_registration_path(&home).exists()); - - fs::remove_dir_all(home).unwrap(); -} - -#[test] -fn copilot_registration_restore_uses_existing_snapshot_when_current_is_absent() { - let home = unique_dir("copilot-existing-deferred"); - let copilot_dir = home.join(".copilot"); - fs::create_dir_all(&copilot_dir).unwrap(); - fs::write( - copilot_dir.join("config.json"), - r#"{ - "installedPlugins": [ - { - "name": "other", - "marketplace": "example", - "version": "1.0.0" - } - ] - }"#, - ) - .unwrap(); - fs::write( - copilot_deferred_registration_path(&home), - r#"{ - "name": "wt-agent-hooks", - "marketplace": "wt-local", - "version": "0.1.6", - "cache_path": "locked" - }"#, - ) - .unwrap(); - - let mut messages = Vec::new(); - assert!(!preserve_copilot_registration(Some(&home), &mut messages)); - assert!(restore_deferred_copilot_registration(&home, &mut messages)); - - let config: Value = - serde_json::from_str(&fs::read_to_string(copilot_dir.join("config.json")).unwrap()) - .unwrap(); - let entries = config["installedPlugins"].as_array().unwrap(); - assert_eq!(entries.len(), 2); - assert_eq!( - entries - .iter() - .filter(|entry| { - entry["name"] == PLUGIN_NAME && entry["marketplace"] == MARKETPLACE_NAME - }) - .count(), - 1 - ); - assert!(entries - .iter() - .any(|entry| entry["name"] == "other" && entry["marketplace"] == "example")); - assert!(!copilot_deferred_registration_path(&home).exists()); - - fs::remove_dir_all(home).unwrap(); -} - // ---- bundle resolver ------------------------------------------------- /// `bundle::find_loose_dir` returns the per-CLI subdirectory when it