diff --git a/doc/release-check-list.md b/doc/release-check-list.md index 8c2269f92..3ea1504b7 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/Invoke-ItE2EReport.ps1 b/test/e2e/Invoke-ItE2EReport.ps1 index 15b723a42..8203bcfa3 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/ItE2E/Public/Ui.ps1 b/test/e2e/ItE2E/Public/Ui.ps1 index a7f3b06ea..660760940 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/README.md b/test/e2e/README.md index 5d8412c84..0c60a5993 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 000000000..9da7c6158 --- /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 b2dd104c0..4513bd8a1 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 5f0142d60..0b882fa64 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 9384cb1c4..5ca2d0798 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 91bf1558a..25efd44c2 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.AgentMouse.Tests.ps1 b/test/e2e/tests/Feature.AgentMouse.Tests.ps1 index abbe4bf19..e6f23b9f0 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.AgentPopup.Tests.ps1 b/test/e2e/tests/Feature.AgentPopup.Tests.ps1 index 6b527d0b2..a35321606 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 000000000..80ef71623 --- /dev/null +++ b/test/e2e/tests/Feature.AgentProtocolExperience.Tests.ps1 @@ -0,0 +1,157 @@ +#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 { + $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) -and + ($fixturePath -notmatch '\s') -and + ($env:TEMP -notmatch '\s') + ) +} + +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 9dc70d4a3..38c9d8885 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 @@ -241,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 } } diff --git a/test/e2e/tests/Feature.ByokProvider.Tests.ps1 b/test/e2e/tests/Feature.ByokProvider.Tests.ps1 index e5d9e0bdd..fdaf5c878 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 = 1, + 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.MultiWindow.Tests.ps1 b/test/e2e/tests/Feature.MultiWindow.Tests.ps1 index a0863a915..246d4a64f 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 d3206b252..7d5ca42d8 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/test/e2e/tests/Feature.PromptHistory.Tests.ps1 b/test/e2e/tests/Feature.PromptHistory.Tests.ps1 index bcdd7ee08..b7b902da7 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 diff --git a/tools/wta/src/app_events.rs b/tools/wta/src/app_events.rs index 5cf333075..13eef4c47 100644 --- a/tools/wta/src/app_events.rs +++ b/tools/wta/src/app_events.rs @@ -404,9 +404,9 @@ 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.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 0a8dfb7aa..1b48f208d 100644 --- a/tools/wta/src/app_tests.rs +++ b/tools/wta/src/app_tests.rs @@ -9362,6 +9362,56 @@ 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 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(), + 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};