diff --git a/.github/workflows/pr-darwin-test.yml b/.github/workflows/pr-darwin-test.yml index c36da018996..971fda8191f 100644 --- a/.github/workflows/pr-darwin-test.yml +++ b/.github/workflows/pr-darwin-test.yml @@ -13,13 +13,19 @@ on: remote_tests: type: boolean default: false + unit_and_integration_tests: + type: boolean + default: true + smoke_tests: + type: boolean + default: true jobs: macOS-test: name: ${{ inputs.job_name }} runs-on: macos-14-xlarge env: - ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} + ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }}${{ (!inputs.unit_and_integration_tests && inputs.smoke_tests) && '-smoke' || '' }} NPM_ARCH: arm64 VSCODE_ARCH: arm64 steps: @@ -113,23 +119,23 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: πŸ§ͺ Run unit tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 15 run: ./scripts/test.sh --tfs "Unit Tests" - name: πŸ§ͺ Run unit tests (node.js) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 15 run: npm run test-node - name: πŸ§ͺ Run unit tests (Browser, Webkit) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} timeout-minutes: 30 run: npm run test-browser-no-install -- --browser webkit --tfs "Browser Unit Tests" env: DEBUG: "*browser*" - - name: Build integration tests + - name: Compile extensions for integration tests & smoke tests run: | set -e npm run gulp \ @@ -152,55 +158,54 @@ jobs: compile-extension:vscode-test-resolver - name: πŸ§ͺ Run integration tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-integration.sh --tfs "Integration Tests" - name: πŸ§ͺ Run integration tests (Browser, Webkit) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-web-integration.sh --browser webkit - name: πŸ§ͺ Run integration tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-remote-integration.sh - name: Compile smoke tests + if: ${{ inputs.smoke_tests }} working-directory: test/smoke run: npm run compile - - name: Compile extensions for smoke tests - run: npm run gulp compile-extension-media - - - name: Build Copilot Chat extension for smoke tests + - name: Compile Copilot Chat extension for smoke tests + if: ${{ inputs.smoke_tests }} working-directory: extensions/copilot run: npm run compile - name: Diagnostics before smoke test run + if: ${{ inputs.smoke_tests && always() }} run: ps -ef continue-on-error: true - if: always() - name: πŸ§ͺ Run smoke tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --tracing - name: πŸ§ͺ Run smoke tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --web --tracing --headless - name: πŸ§ͺ Run smoke tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --remote --tracing - name: Diagnostics after smoke test run + if: ${{ inputs.smoke_tests && always() }} run: ps -ef continue-on-error: true - if: always() - name: Publish Crash Reports uses: actions/upload-artifact@v7 diff --git a/.github/workflows/pr-linux-test.yml b/.github/workflows/pr-linux-test.yml index 452f974f802..113d41cdda3 100644 --- a/.github/workflows/pr-linux-test.yml +++ b/.github/workflows/pr-linux-test.yml @@ -13,13 +13,19 @@ on: remote_tests: type: boolean default: false + unit_and_integration_tests: + type: boolean + default: true + smoke_tests: + type: boolean + default: true jobs: linux-test: name: ${{ inputs.job_name }} runs-on: ubuntu-24.04 env: - ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} + ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }}${{ (!inputs.unit_and_integration_tests && inputs.smoke_tests) && '-smoke' || '' }} NPM_ARCH: x64 VSCODE_ARCH: x64 steps: @@ -265,25 +271,25 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: πŸ§ͺ Run unit tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 15 run: ./scripts/test.sh --tfs "Unit Tests" env: DISPLAY: ":10" - name: πŸ§ͺ Run unit tests (node.js) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 15 run: npm run test-node - name: πŸ§ͺ Run unit tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} timeout-minutes: 30 run: npm run test-browser-no-install -- --browser chromium --tfs "Browser Unit Tests" env: DEBUG: "*browser*" - - name: Build integration tests + - name: Compile extensions for integration tests & smoke tests run: | set -e npm run gulp \ @@ -305,34 +311,34 @@ jobs: compile-extension:vscode-colorize-perf-tests \ compile-extension:vscode-test-resolver - - name: Compile Copilot extension - run: npm --prefix extensions/copilot run compile - - name: πŸ§ͺ Run integration tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-integration.sh --tfs "Integration Tests" env: DISPLAY: ":10" - name: πŸ§ͺ Run integration tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-web-integration.sh --browser chromium - name: πŸ§ͺ Run integration tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.unit_and_integration_tests }} timeout-minutes: 20 run: ./scripts/test-remote-integration.sh env: DISPLAY: ":10" - name: Compile smoke tests + if: ${{ inputs.smoke_tests }} working-directory: test/smoke run: npm run compile - - name: Compile extensions for smoke tests - run: npm run gulp compile-extension-media + - name: Compile Copilot Chat extension for smoke tests + if: ${{ inputs.smoke_tests }} + working-directory: extensions/copilot + run: npm run compile # Remove the musl-libc Claude Code native binary so the bundled # @anthropic-ai/claude-agent-sdk in extensions/copilot falls through to the @@ -347,6 +353,7 @@ jobs: # https://github.com/anthropics/claude-agent-sdk-typescript for the SDK # resolution order. - name: Remove musl Claude binary on glibc Linux + if: ${{ inputs.smoke_tests }} run: rm -rf node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl - name: Diagnostics before smoke test run (processes, max_user_watches, number of opened file handles) @@ -356,35 +363,35 @@ jobs: cat /proc/sys/fs/inotify/max_user_watches lsof | wc -l continue-on-error: true - if: always() + if: ${{ inputs.smoke_tests && always() }} - name: πŸ§ͺ Run smoke tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --tracing env: DISPLAY: ":10" - name: πŸ§ͺ Run smoke tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --web --tracing --headless - name: πŸ§ͺ Run smoke tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.smoke_tests }} timeout-minutes: 20 run: npm run smoketest-no-compile -- --remote --tracing env: DISPLAY: ":10" - name: Diagnostics after smoke test run (processes, max_user_watches, number of opened file handles) + if: ${{ inputs.smoke_tests && always() }} run: | set -e ps -ef cat /proc/sys/fs/inotify/max_user_watches lsof | wc -l continue-on-error: true - if: always() - name: Publish Crash Reports uses: actions/upload-artifact@v7 diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index a1255348f21..45681aff707 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -13,13 +13,19 @@ on: remote_tests: type: boolean default: false + unit_and_integration_tests: + type: boolean + default: true + smoke_tests: + type: boolean + default: true jobs: windows-test: name: ${{ inputs.job_name }} runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=windows-test-${{ inputs.job_name }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] env: - ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} + ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }}${{ (!inputs.unit_and_integration_tests && inputs.smoke_tests) && '-smoke' || '' }} NPM_ARCH: x64 VSCODE_ARCH: x64 steps: @@ -120,26 +126,26 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: πŸ§ͺ Run unit tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 15 shell: pwsh run: .\scripts\test.bat --tfs "Unit Tests" - timeout-minutes: 15 - name: πŸ§ͺ Run unit tests (node.js) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 15 shell: pwsh run: npm run test-node - timeout-minutes: 15 - name: πŸ§ͺ Run unit tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 20 shell: pwsh run: node test/unit/browser/index.js --browser chromium --tfs "Browser Unit Tests" env: DEBUG: "*browser*" - timeout-minutes: 20 - - name: Build integration tests + - name: Compile extensions for integration tests & smoke tests shell: pwsh run: | . build/azure-pipelines/win32/exec.ps1 @@ -164,78 +170,77 @@ jobs: compile-extension:vscode-test-resolver ` } - - name: Compile Copilot extension - shell: pwsh - run: npm --prefix extensions/copilot run compile - - name: Diagnostics before integration test runs + if: ${{ inputs.unit_and_integration_tests && always() }} shell: pwsh run: .\build\azure-pipelines\win32\listprocesses.bat continue-on-error: true - if: always() - name: πŸ§ͺ Run integration tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 20 shell: pwsh run: .\scripts\test-integration.bat --tfs "Integration Tests" - timeout-minutes: 20 - name: πŸ§ͺ Run integration tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 20 shell: pwsh run: .\scripts\test-web-integration.bat --browser chromium - timeout-minutes: 20 - name: πŸ§ͺ Run integration tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.unit_and_integration_tests }} + timeout-minutes: 20 shell: pwsh run: .\scripts\test-remote-integration.bat - timeout-minutes: 20 - name: Diagnostics after integration test runs + if: ${{ inputs.unit_and_integration_tests && always() }} shell: pwsh run: .\build\azure-pipelines\win32\listprocesses.bat continue-on-error: true - if: always() - - name: Diagnostics before smoke test run - shell: pwsh - run: .\build\azure-pipelines\win32\listprocesses.bat - continue-on-error: true - if: always() - name: Compile smoke tests + if: ${{ inputs.smoke_tests }} working-directory: test/smoke shell: pwsh run: npm run compile - - name: Compile extensions for smoke tests + - name: Compile Copilot Chat extension for smoke tests + if: ${{ inputs.smoke_tests }} + working-directory: extensions/copilot + run: npm run compile + + - name: Diagnostics before smoke test run + if: ${{ inputs.smoke_tests && always() }} shell: pwsh - run: npm run gulp compile-extension-media + run: .\build\azure-pipelines\win32\listprocesses.bat + continue-on-error: true - name: πŸ§ͺ Run smoke tests (Electron) - if: ${{ inputs.electron_tests }} + if: ${{ inputs.electron_tests && inputs.smoke_tests }} timeout-minutes: 20 shell: pwsh run: npm run smoketest-no-compile -- --tracing - name: πŸ§ͺ Run smoke tests (Browser, Chromium) - if: ${{ inputs.browser_tests }} + if: ${{ inputs.browser_tests && inputs.smoke_tests }} timeout-minutes: 20 shell: pwsh run: npm run smoketest-no-compile -- --web --tracing --headless - name: πŸ§ͺ Run smoke tests (Remote) - if: ${{ inputs.remote_tests }} + if: ${{ inputs.remote_tests && inputs.smoke_tests }} timeout-minutes: 20 shell: pwsh run: npm run smoketest-no-compile -- --remote --tracing - name: Diagnostics after smoke test run + if: ${{ inputs.smoke_tests && always() }} shell: pwsh run: .\build\azure-pipelines\win32\listprocesses.bat continue-on-error: true - if: always() - name: Publish Crash Reports uses: actions/upload-artifact@v7 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 96fb9a707d1..1d8675086a6 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -103,6 +103,15 @@ jobs: with: job_name: Electron electron_tests: true + smoke_tests: false + + linux-electron-smoke-tests: + name: Linux + uses: ./.github/workflows/pr-linux-test.yml + with: + job_name: Electron-Smoke + electron_tests: true + unit_and_integration_tests: false linux-browser-tests: name: Linux @@ -124,6 +133,15 @@ jobs: with: job_name: Electron electron_tests: true + smoke_tests: false + + macos-electron-smoke-tests: + name: macOS + uses: ./.github/workflows/pr-darwin-test.yml + with: + job_name: Electron-Smoke + electron_tests: true + unit_and_integration_tests: false macos-browser-tests: name: macOS @@ -145,6 +163,15 @@ jobs: with: job_name: Electron electron_tests: true + smoke_tests: false + + windows-electron-smoke-tests: + name: Windows + uses: ./.github/workflows/pr-win32-test.yml + with: + job_name: Electron-Smoke + electron_tests: true + unit_and_integration_tests: false windows-browser-tests: name: Windows diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 0d90b4474f1..710abb9e76f 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -19,9 +19,9 @@ dependencies = [ [[package]] name = "ahp" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff1c5fceb94cf9e8a852eb84dd7f835827a7a2a07b8c23916d192075345e0ea" +checksum = "8fa7af01c6ff90f8b54fa169a71de21cc26e5a98621249ad882a5b2e137f57c0" dependencies = [ "ahp-types", "serde", @@ -33,9 +33,9 @@ dependencies = [ [[package]] name = "ahp-types" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b556a5958c99e73aee87d8e638baf648bc902ff0dabc00393522f8e6e88830b2" +checksum = "1632209b0398b17c4a9928d71eaad0ced329d3617ffcbe32be789f59b1d65c70" dependencies = [ "serde", "serde_json", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 2fa40b9b4a6..e939680aae6 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -56,8 +56,8 @@ console = "0.15.7" bytes = "1.11.1" tar = "0.4.46" local-ip-address = "0.6" -ahp = "0.3" -ahp-types = "0.3" +ahp = "0.4" +ahp-types = "0.4" [build-dependencies] serde = { version="1.0.163", features = ["derive"] } diff --git a/cli/src/commands/agent_logs.rs b/cli/src/commands/agent_logs.rs index 2116ed7e175..528598e9e8c 100644 --- a/cli/src/commands/agent_logs.rs +++ b/cli/src/commands/agent_logs.rs @@ -6,7 +6,7 @@ use ahp::SubscriptionEvent; use ahp_types::actions::StateAction; use ahp_types::commands::{SubscribeParams, SubscribeResult}; -use ahp_types::state::{SnapshotState, TurnState}; +use ahp_types::state::{SessionStatus, SnapshotState}; use console::Style; use crate::tunnels::shutdown_signal::ShutdownRequest; @@ -100,23 +100,24 @@ fn print_initial_state(uri: &str, result: &SubscribeResult) { println!(" {} {}", label.apply_to("activity:"), activity); } } - println!(" {} {}", label.apply_to("turns:"), session.turns.len()); + println!(" {} {}", label.apply_to("chats:"), session.chats.len()); - // Print a brief summary of past turns. - for turn in &session.turns { - let state_str = match turn.state { - TurnState::Complete => Styles::success().apply_to("βœ“"), - TurnState::Cancelled => Styles::warning().apply_to("⊘"), - TurnState::Error => Styles::error().apply_to("βœ—"), + // Print a brief summary of the chats in this session. + for chat in &session.chats { + let status = SessionStatus::from_bits(chat.status); + let marker = if status.contains(SessionStatus::InProgress) { + Style::new().green().bold().apply_to("β–Ί") + } else if status.contains(SessionStatus::Error) { + Styles::error().apply_to("βœ—") + } else { + Styles::muted().apply_to("β—‹") }; - let msg = truncate(&turn.message.text, 80); - println!(" {} {}", state_str, Styles::muted().apply_to(msg)); - } - - // Print active turn if any. - if let Some(ref active) = session.active_turn { - let msg = truncate(&active.message.text, 80); - println!(" {} {}", Style::new().green().bold().apply_to("β–Ί"), msg); + let title = if chat.title.is_empty() { + "(untitled)".to_string() + } else { + truncate(&chat.title, 80) + }; + println!(" {} {}", marker, Styles::muted().apply_to(title)); } } diff --git a/cli/src/commands/agent_ps.rs b/cli/src/commands/agent_ps.rs index 95a0e3157e6..32afc2be451 100644 --- a/cli/src/commands/agent_ps.rs +++ b/cli/src/commands/agent_ps.rs @@ -61,9 +61,9 @@ pub async fn agent_ps(ctx: CommandContext, args: AgentPsArgs) -> Result bool { - let dominated = SessionStatus::IsRead as u32 - | SessionStatus::IsArchived as u32 - | SessionStatus::Idle as u32; + let dominated = SessionStatus::IsRead.bits() + | SessionStatus::IsArchived.bits() + | SessionStatus::Idle.bits(); status & !dominated != 0 } @@ -118,15 +118,16 @@ fn format_sessions_list(sessions: &[&SessionSummary]) -> String { } fn status_styled(status: u32) -> console::StyledObject { - if status & (SessionStatus::InputNeeded as u32) == (SessionStatus::InputNeeded as u32) { + let status = SessionStatus::from_bits(status); + if status.contains(SessionStatus::InputNeeded) { Styles::warning().apply_to("● input needed".to_string()) - } else if status & (SessionStatus::InProgress as u32) != 0 { + } else if status.contains(SessionStatus::InProgress) { Styles::success().apply_to("● in progress".to_string()) - } else if status & (SessionStatus::Error as u32) != 0 { + } else if status.contains(SessionStatus::Error) { Styles::error().apply_to("● error".to_string()) - } else if status & (SessionStatus::Idle as u32) != 0 { + } else if status.contains(SessionStatus::Idle) { Styles::muted().apply_to("β—‹ idle".to_string()) } else { - Styles::muted().apply_to(format!("? unknown ({status})")) + Styles::muted().apply_to(format!("? unknown ({})", status.bits())) } } diff --git a/cli/src/commands/agent_stop.rs b/cli/src/commands/agent_stop.rs index c18c3123269..58e5915ead3 100644 --- a/cli/src/commands/agent_stop.rs +++ b/cli/src/commands/agent_stop.rs @@ -3,9 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -use ahp_types::actions::{SessionTurnCancelledAction, StateAction}; +use ahp_types::actions::{ChatTurnCancelledAction, StateAction}; use ahp_types::commands::{SubscribeParams, SubscribeResult}; -use ahp_types::state::SnapshotState; +use ahp_types::state::{SessionStatus, SnapshotState}; use crate::log; use crate::util::errors::{wrap, AnyError}; @@ -14,11 +14,12 @@ use super::agent; use super::args::AgentStopArgs; use super::CommandContext; -/// Cancels the active turn of a session on a running agent host. +/// Cancels the active turn of every in-progress chat in a session on a running +/// agent host. pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result { let client = agent::connect(&ctx, args.address.as_deref(), args.tunnel.as_deref()).await?; - // Subscribe to the session to get its current state. + // Subscribe to the session to get its catalog of chats. let result: SubscribeResult = agent::request_with_auth( &ctx, &client, @@ -29,34 +30,61 @@ pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result session.active_turn.map(|t| t.id), - _ => None, + // Turns live on individual chats now, so collect the chats that look active + // from the session catalog before drilling into each one. + let chat_uris: Vec = match result.snapshot.map(|s| s.state) { + Some(SnapshotState::Session(session)) => session + .chats + .into_iter() + .filter(|c| SessionStatus::from_bits(c.status).contains(SessionStatus::InProgress)) + .map(|c| c.resource) + .collect(), + _ => Vec::new(), }; - let turn_id = match turn_id { - Some(id) => id, - None => { - ctx.log.result("No active turn to cancel."); - client.shutdown().await; - return Ok(0); - } - }; - - debug!(ctx.log, "Cancelling turn {} on {}", turn_id, args.session); - - client - .dispatch( - args.session.clone(), - StateAction::SessionTurnCancelled(SessionTurnCancelledAction { - turn_id: turn_id.clone(), - }), + let mut cancelled = 0; + for chat_uri in chat_uris { + // Subscribe to the chat to find its active turn, if any. + let chat_result: SubscribeResult = agent::request_with_auth( + &ctx, + &client, + "subscribe", + SubscribeParams { + channel: chat_uri.clone(), + }, ) - .await - .map_err(|e| wrap(e, "Failed to dispatch turn cancellation"))?; + .await?; - ctx.log - .result(format!("Cancelled turn {turn_id} on {}", args.session)); + let turn_id = match chat_result.snapshot.map(|s| s.state) { + Some(SnapshotState::Chat(chat)) => chat.active_turn.map(|t| t.id), + _ => None, + }; + + let Some(turn_id) = turn_id else { + continue; + }; + + debug!(ctx.log, "Cancelling turn {} on {}", turn_id, chat_uri); + + client + .dispatch( + chat_uri.clone(), + StateAction::ChatTurnCancelled(ChatTurnCancelledAction { + turn_id: turn_id.clone(), + meta: None, + }), + ) + .await + .map_err(|e| wrap(e, "Failed to dispatch turn cancellation"))?; + + ctx.log + .result(format!("Cancelled turn {turn_id} on {chat_uri}")); + cancelled += 1; + } + + if cancelled == 0 { + ctx.log.result("No active turn to cancel."); + } client.shutdown().await; Ok(0) diff --git a/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts b/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts index a35158b37d2..b6b9501a871 100644 --- a/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts +++ b/extensions/copilot/src/extension/chat/vscode-node/chatHookService.ts @@ -501,6 +501,16 @@ export class ChatHookService implements IChatHookService { token ); + // Running the hook can take a long time because it spawns external, user-configured + // commands. If the request was cancelled while the hook ran, the response stream is + // already closed and writing hook progress to it throws "Response stream has been + // closed". The caller only checks cancellation before invoking the hook, so re-check + // here after the await and skip result processing - a cancelled turn never consumes + // the result anyway. + if (token?.isCancellationRequested) { + return undefined; + } + if (results.length === 0) { return undefined; } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 57ae4040537..6e0634f28de 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -10,7 +10,7 @@ import * as crypto from 'crypto'; import type * as vscode from 'vscode'; import type { ChatParticipantToolToken } from 'vscode'; import { IAuthenticationService } from '../../../../platform/authentication/common/authentication'; -import { IChatQuotaService } from '../../../../platform/chat/common/chatQuotaService'; +import { IChatQuotaService, QuotaSnapshot, QuotaSnapshots } from '../../../../platform/chat/common/chatQuotaService'; import { getQuotaMessageForPlan } from '../../../../platform/chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; import { IGitService } from '../../../../platform/git/common/gitService'; @@ -1444,6 +1444,12 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes this._chatQuotaService.setLastCopilotUsage(totalNanoAiu, request.id); } } + // Sync the live per-category quota state the SDK reports (internal-only field) so the + // quota UI stays current without a separate `copilot_internal/user` fetch. This mirrors + // the extension-host chat path, which processes `copilot_quota_snapshots` from CAPI. + if (event.data.quotaSnapshots) { + this._chatQuotaService.processQuotaSnapshots(toChatQuotaSnapshots(event.data.quotaSnapshots)); + } // Record this model turn so we can synthesize a `chat` span for it at request completion. modelTurnUsages.push({ model: event.data.model, @@ -3150,6 +3156,54 @@ interface IModelTurnUsage { readonly parentToolCallId?: string; } +/** + * Shape of a single quota snapshot on the SDK's `assistant.usage` event (`quotaSnapshots`). The + * field is marked internal-only by the SDK, so although the published types say `entitlementRequests` + * is a number and `resetDate` is a `Date`, the runtime shape can drift (e.g. a sibling SDK delivers + * `resetDate` as an ISO string). Mark the fields optional and validate at runtime below. + */ +interface ISdkQuotaSnapshot { + readonly isUnlimitedEntitlement?: boolean; + readonly entitlementRequests?: number; + readonly overage?: number; + readonly overageAllowedWithExhaustedQuota?: boolean; + readonly remainingPercentage?: number; + readonly resetDate?: Date | string; +} + +/** Maps the SDK `assistant.usage` quota snapshots to the shared {@link QuotaSnapshots} shape. */ +function toChatQuotaSnapshots(snapshots: Record): QuotaSnapshots { + const result: Record = {}; + for (const [key, snapshot] of Object.entries(snapshots)) { + if (!snapshot || typeof snapshot !== 'object') { + continue; + } + const unlimited = snapshot.isUnlimitedEntitlement === true; + const entitlement = unlimited + ? '-1' + : typeof snapshot.entitlementRequests === 'number' ? String(snapshot.entitlementRequests) : undefined; + if (entitlement === undefined || typeof snapshot.remainingPercentage !== 'number') { + continue; + } + result[key] = { + entitlement, + percent_remaining: snapshot.remainingPercentage, + overage_permitted: snapshot.overageAllowedWithExhaustedQuota ?? false, + overage_count: typeof snapshot.overage === 'number' ? snapshot.overage : 0, + reset_date: toResetDateIsoString(snapshot.resetDate), + }; + } + return result; +} + +/** Coerces an SDK `resetDate` (a `Date` per the published type, but possibly an ISO string at runtime) to an ISO string. */ +function toResetDateIsoString(resetDate: Date | string | undefined): string | undefined { + if (resetDate instanceof Date) { + return resetDate.toISOString(); + } + return typeof resetDate === 'string' ? resetDate : undefined; +} + function buildPromptTokenDetails(usageInfo: UsageInfoData | undefined): { category: string; label: string; percentageOfPrompt: number }[] | undefined { if (!usageInfo || usageInfo.currentTokens <= 0) { return undefined; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts index 28f8e4ebc6f..27de7188f4c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts @@ -7,6 +7,7 @@ import type { SessionOptions } from '@github/copilot/sdk'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ChatParticipantToolToken, ChatResponseStream } from 'vscode'; import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService'; +import { QuotaSnapshots } from '../../../../../platform/chat/common/chatQuotaService'; import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService'; import { ILogService } from '../../../../../platform/log/common/logService'; import { GenAiAttr, IOTelService, NoopOTelService, resolveOTelConfig, SpanKind } from '../../../../../platform/otel/common/index'; @@ -225,6 +226,7 @@ describe('CopilotCLISession', () => { let authInfo: NonNullable; let userQuestionAnswer: IQuestionAnswer | undefined; let telemetryService: ITelemetryService; + let processedQuotaSnapshots: QuotaSnapshots[]; beforeEach(async () => { const services = disposables.add(createExtensionUnitTestingServices()); const accessor = services.createTestingAccessor(); @@ -246,6 +248,7 @@ describe('CopilotCLISession', () => { toolsService = new FakeToolsService(); userQuestionAnswer = undefined; telemetryService = new NullTelemetryService(); + processedQuotaSnapshots = []; }); afterEach(() => { @@ -283,7 +286,7 @@ describe('CopilotCLISession', () => { otelService, new MockGitService(), { _serviceBrand: undefined } as any, - { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { } } as any, + { _serviceBrand: undefined, resetTurnCredits() { }, getCreditsForTurn() { return undefined; }, setLastCopilotUsage() { }, processQuotaSnapshots(snapshots: QuotaSnapshots) { processedQuotaSnapshots.push(snapshots); } } as any, telemetryService )); } @@ -2511,6 +2514,105 @@ describe('CopilotCLISession', () => { expect(session.getLastResponseModelId()).toBe('claude-opus-4.7'); }); + it('syncs quota snapshots from assistant.usage event into the quota service', async () => { + sdkSession.send = async (options: any) => { + sdkSession.emit('user.message', { content: options.prompt }); + sdkSession.emit('assistant.usage', { + model: 'claude-opus-4.7', + inputTokens: 200, + outputTokens: 80, + quotaSnapshots: { + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 300, + usedRequests: 75, + usageAllowedWithExhaustedQuota: true, + overage: 1.5, + overageAllowedWithExhaustedQuota: true, + remainingPercentage: 75, + resetDate: new Date('2026-07-01T00:00:00.000Z'), + }, + chat: { + isUnlimitedEntitlement: true, + entitlementRequests: -1, + usedRequests: 10, + usageAllowedWithExhaustedQuota: false, + overage: 0, + overageAllowedWithExhaustedQuota: false, + remainingPercentage: 100, + }, + }, + }); + sdkSession.emit('assistant.turn_end', {}); + }; + + const session = await createSession(); + session.attachStream(new UsageCapturingStream()); + + await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None); + + expect(processedQuotaSnapshots).toEqual([{ + premium_interactions: { + entitlement: '300', + percent_remaining: 75, + overage_permitted: true, + overage_count: 1.5, + reset_date: '2026-07-01T00:00:00.000Z', + }, + chat: { + entitlement: '-1', + percent_remaining: 100, + overage_permitted: false, + overage_count: 0, + reset_date: undefined, + }, + }]); + }); + + it('tolerates a string resetDate and skips malformed snapshots from assistant.usage', async () => { + sdkSession.send = async (options: any) => { + sdkSession.emit('user.message', { content: options.prompt }); + sdkSession.emit('assistant.usage', { + model: 'claude-opus-4.7', + inputTokens: 200, + outputTokens: 80, + quotaSnapshots: { + // The internal field can drift from the published type: `resetDate` may arrive as an + // ISO string and a snapshot may be missing `remainingPercentage` entirely. + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 300, + overage: 1.5, + overageAllowedWithExhaustedQuota: true, + remainingPercentage: 75, + resetDate: '2026-07-01T00:00:00.000Z', + }, + completions: { + isUnlimitedEntitlement: false, + entitlementRequests: 50, + // remainingPercentage absent β€” snapshot must be skipped rather than producing "undefined". + }, + }, + }); + sdkSession.emit('assistant.turn_end', {}); + }; + + const session = await createSession(); + session.attachStream(new UsageCapturingStream()); + + await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None); + + expect(processedQuotaSnapshots).toEqual([{ + premium_interactions: { + entitlement: '300', + percent_remaining: 75, + overage_permitted: true, + overage_count: 1.5, + reset_date: '2026-07-01T00:00:00.000Z', + }, + }]); + }); + it('reports usage from session.usage_info event immediately', async () => { sdkSession.send = async (options: any) => { sdkSession.emit('user.message', { content: options.prompt }); diff --git a/extensions/copilot/src/extension/extension/vscode-node/disableProcessReport.ts b/extensions/copilot/src/extension/extension/vscode-node/disableProcessReport.ts new file mode 100644 index 00000000000..4f5c5c48c81 --- /dev/null +++ b/extensions/copilot/src/extension/extension/vscode-node/disableProcessReport.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +if (process.report) { + try { + Object.defineProperty(process.report, 'getReport', { + value: () => undefined, + writable: true, + configurable: true, + enumerable: true + }); + } catch (err) { + + } +} diff --git a/extensions/copilot/src/extension/extension/vscode-node/extension.ts b/extensions/copilot/src/extension/extension/vscode-node/extension.ts index 053b2a4a4e8..8b802b6db7a 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/extension.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/extension.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +// Must be the first import to ensure it evaluates before other imports. +import './disableProcessReport'; + import { ExtensionContext } from 'vscode'; import { resolve } from '../../../util/vs/base/common/path'; import { baseActivate } from '../vscode/extension'; diff --git a/extensions/copilot/src/extension/tools/common/test/toolService.spec.ts b/extensions/copilot/src/extension/tools/common/test/toolService.spec.ts index 844fb0b74cc..ad59dee1b60 100644 --- a/extensions/copilot/src/extension/tools/common/test/toolService.spec.ts +++ b/extensions/copilot/src/extension/tools/common/test/toolService.spec.ts @@ -230,5 +230,183 @@ describe('Tool Service', () => { error: expect.stringContaining('ERROR: Your input to the tool was invalid') }); }); + + test('should reconstruct flattened path keys', () => { + const askQuestionsTool: vscode.LanguageModelToolInformation = { + name: 'askQuestionsTool', + description: 'A tool that expects an array of nested question objects', + inputSchema: { + type: 'object', + properties: { + questions: { + type: 'array', + items: { + type: 'object', + properties: { + header: { type: 'string' }, + question: { type: 'string' }, + allowFreeformInput: { type: 'boolean' }, + options: { + type: 'array', + items: { + type: 'object', + properties: { + label: { type: 'string' }, + description: { type: 'string' }, + recommended: { type: 'boolean' } + }, + required: ['label'] + } + } + }, + required: ['header', 'question'] + } + } + }, + required: ['questions'] + }, + tags: [], + source: undefined + }; + + (toolsService.tools as vscode.LanguageModelToolInformation[]).push(askQuestionsTool); + + // Gemini-style flattened path keys instead of a nested object/array. + const flattenedInput = JSON.stringify({ + 'questions[0].allowFreeformInput': true, + 'questions[0].header': 'repro_question_1', + 'questions[0].options[0].description': 'First option description', + 'questions[0].options[0].label': 'Option A', + 'questions[0].options[0].recommended': true, + 'questions[0].options[1].description': 'Second option description', + 'questions[0].options[1].label': 'Option B', + 'questions[0].question': 'Which option do you prefer?', + 'questions[1].allowFreeformInput': false, + 'questions[1].header': 'repro_question_2', + 'questions[1].options[0].label': 'Yes', + 'questions[1].options[1].label': 'No', + 'questions[1].question': 'Do you want to continue?' + }); + + const result = toolsService.validateToolInput('askQuestionsTool', flattenedInput); + expect(result).toEqual({ + inputObj: { + questions: [ + { + allowFreeformInput: true, + header: 'repro_question_1', + question: 'Which option do you prefer?', + options: [ + { description: 'First option description', label: 'Option A', recommended: true }, + { description: 'Second option description', label: 'Option B' } + ] + }, + { + allowFreeformInput: false, + header: 'repro_question_2', + question: 'Do you want to continue?', + options: [ + { label: 'Yes' }, + { label: 'No' } + ] + } + ] + } + }); + }); + + test('should not pollute prototype when reconstructing flattened keys', () => { + const pollutionTool: vscode.LanguageModelToolInformation = { + name: 'pollutionTool', + description: 'A tool whose flattened input contains unsafe property names', + inputSchema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { value: { type: 'string' } } + } + }, + required: ['data'] + }, + tags: [], + source: undefined + }; + + (toolsService.tools as vscode.LanguageModelToolInformation[]).push(pollutionTool); + + const malicious = JSON.stringify({ + '__proto__.polluted': 'yes', + 'data.value': 'ok' + }); + + const result = toolsService.validateToolInput('pollutionTool', malicious); + + // The unsafe key makes reconstruction bail out, so validation fails + // rather than mutating Object.prototype. + expect(result).toMatchObject({ + error: expect.stringContaining('ERROR: Your input to the tool was invalid') + }); + expect(({} as Record).polluted).toBeUndefined(); + }); + + test('should bail out on conflicting flattened keys', () => { + const conflictTool: vscode.LanguageModelToolInformation = { + name: 'conflictTool', + description: 'A tool whose flattened input has conflicting paths', + inputSchema: { + type: 'object', + properties: { + a: { type: 'object' } + }, + required: ['a'] + }, + tags: [], + source: undefined + }; + + (toolsService.tools as vscode.LanguageModelToolInformation[]).push(conflictTool); + + // `a` is both a primitive and a parent of `a.b` β€” unresolvable. + const conflicting = JSON.stringify({ + 'a': 'primitive', + 'a.b': 'nested' + }); + + const result = toolsService.validateToolInput('conflictTool', conflicting); + expect(result).toMatchObject({ + error: expect.stringContaining('ERROR: Your input to the tool was invalid') + }); + }); + + test('should reject out-of-range array indices in flattened keys', () => { + const indexTool: vscode.LanguageModelToolInformation = { + name: 'indexTool', + description: 'A tool whose flattened input has an enormous array index', + inputSchema: { + type: 'object', + properties: { + items: { + type: 'array', + items: { type: 'string' } + } + }, + required: ['items'] + }, + tags: [], + source: undefined + }; + + (toolsService.tools as vscode.LanguageModelToolInformation[]).push(indexTool); + + // A huge index would create a massive sparse array; reconstruction + // must bail rather than produce one. + const huge = JSON.stringify({ 'items[999999999999]': 'value' }); + + const result = toolsService.validateToolInput('indexTool', huge); + expect(result).toMatchObject({ + error: expect.stringContaining('ERROR: Your input to the tool was invalid') + }); + }); }); }); diff --git a/extensions/copilot/src/extension/tools/common/toolsService.ts b/extensions/copilot/src/extension/tools/common/toolsService.ts index 4e66b020e33..2eb611f9b5b 100644 --- a/extensions/copilot/src/extension/tools/common/toolsService.ts +++ b/extensions/copilot/src/extension/tools/common/toolsService.ts @@ -130,6 +130,109 @@ function getObjectPropertyByPath(obj: any, jsonPointerPath: string): { parent: a return null; } +/** + * Property names that must never be used as path segments when reconstructing + * objects from untrusted tool input, to avoid prototype pollution. + */ +const UNSAFE_PROPERTY_NAMES = new Set(['__proto__', 'constructor', 'prototype']); + +/** + * Upper bound for array indices accepted when reconstructing flattened tool + * input. Caps the reconstructed array length to avoid huge sparse arrays from + * untrusted input (e.g. `items[999999999999]`) that would make subsequent Ajv + * validation pathologically slow. + */ +const MAX_FLATTENED_ARRAY_INDEX = 1000; + +/** + * Parses a flattened path key (e.g. `questions[0].options[1].label`) into an + * ordered list of segments (`['questions', 0, 'options', 1, 'label']`). Object + * properties are returned as strings and array indices as numbers. Returns + * `undefined` if the key is not a well-formed, contiguous path expression, if + * it contains an unsafe property name (e.g. `__proto__`), or if an array index + * exceeds {@link MAX_FLATTENED_ARRAY_INDEX}. + */ +function parseFlattenedPath(key: string): (string | number)[] | undefined { + const segments: (string | number)[] = []; + const re = /\.?([^.[\]]+)|\[(\d+)\]/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = re.exec(key)) !== null) { + // Bail if there is an unexpected character between tokens (e.g. `a..b`). + if (match.index !== lastIndex) { + return undefined; + } + if (match[2] !== undefined) { + const index = Number(match[2]); + // Reject out-of-range indices to avoid huge sparse arrays. + if (!Number.isSafeInteger(index) || index > MAX_FLATTENED_ARRAY_INDEX) { + return undefined; + } + segments.push(index); + } else { + // Reject prototype-pollution keys from untrusted tool input. + if (UNSAFE_PROPERTY_NAMES.has(match[1])) { + return undefined; + } + segments.push(match[1]); + } + lastIndex = re.lastIndex; + } + if (lastIndex !== key.length || segments.length === 0) { + return undefined; + } + return segments; +} + +/** + * Reconstructs a nested object/array structure from an object whose keys are + * flattened path expressions. Some models (notably Gemini) serialize nested + * tool-call arguments as flat keys like `questions[0].header` instead of a + * proper nested object. Returns `undefined` when none of the keys use path + * notation (so normal inputs are left untouched), when a key is malformed, or + * when keys conflict (e.g. both `a` and `a.b`). + */ +function tryUnflattenObject(obj: Record): Record | undefined { + const keys = Object.keys(obj); + if (!keys.some(key => /\.|\[\d+\]/.test(key))) { + return undefined; + } + + // Use null-prototype containers so untrusted keys cannot reach Object.prototype. + const result: Record = Object.create(null); + for (const key of keys) { + const path = parseFlattenedPath(key); + if (!path) { + return undefined; + } + + let current: any = result; + for (let i = 0; i < path.length - 1; i++) { + const segment = path[i]; + const nextSegment = path[i + 1]; + const child = current[segment]; + if (child === undefined) { + current[segment] = typeof nextSegment === 'number' ? [] : Object.create(null); + } else if (typeof child !== 'object' || child === null) { + // Conflicting keys (e.g. both `a` and `a.b`) would require + // overwriting a primitive with a container; bail out instead. + return undefined; + } + current = current[segment]; + } + + const leaf = path[path.length - 1]; + if (typeof current[leaf] === 'object' && current[leaf] !== null) { + // A container already exists at this leaf (e.g. both `a` and `a.b` + // where `a` is assigned last); refuse to clobber it. + return undefined; + } + current[leaf] = obj[key]; + } + + return result; +} + function ajvValidateForTool(toolName: string, fn: ValidateFunction, inputObj: unknown): IToolValidationResult { // Empty output can be valid when the schema only has optional properties if (fn(inputObj ?? {})) { @@ -168,6 +271,16 @@ function ajvValidateForTool(toolName: string, fn: ValidateFunction, inputObj: un } } + // Recovery: some models (notably Gemini) serialize nested arguments as + // flattened path keys like `questions[0].header` instead of nested + // objects/arrays. Reconstruct the nested structure and re-validate. + if (typeof inputObj === 'object' && inputObj !== null && !Array.isArray(inputObj)) { + const unflattened = tryUnflattenObject(inputObj as Record); + if (unflattened) { + return ajvValidateForTool(toolName, fn, unflattened); + } + } + const errors = fn.errors!.map(e => e.message || `${e.instancePath} is invalid}`); return { error: `ERROR: Your input to the tool was invalid (${errors.join(', ')})` }; } diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index 5660d746d55..fba5bbff3f7 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -27,7 +27,7 @@ import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManage import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { TelemetryData } from '../../telemetry/common/telemetryData'; -import { getVerbosityForModelSync, isHiddenModelM } from '../common/chatModelCapabilities'; +import { getVerbosityForModelSync, isGpt54, isGpt55, isHiddenModelM } from '../common/chatModelCapabilities'; import { rawPartAsCompactionData } from '../common/compactionDataContainer'; import { rawPartAsPhaseData } from '../common/phaseDataContainer'; import { getIndexOfStatefulMarker, getStatefulMarkerAndIndex } from '../common/statefulMarkerContainer'; @@ -164,7 +164,7 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: : undefined; const summary = summaryConfig === 'off' || shouldDisableReasoningSummary ? undefined : summaryConfig; const persistentCoTEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, expService) - && isHiddenModelM(endpoint); + && (isGpt54(endpoint) || isGpt55(endpoint) || isHiddenModelM(endpoint)); if (effort || summary || persistentCoTEnabled) { body.reasoning = { ...(effort ? { effort } : {}), diff --git a/package-lock.json b/package-lock.json index 084ff5e1eb2..22a8c783058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,7 @@ "playwright-core": "1.61.0-alpha-2026-06-04", "ssh2": "^1.16.0", "tas-client": "0.3.1", - "undici": "^7.24.0", + "undici": "^7.28.0", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "^9.3.2", @@ -18989,9 +18989,9 @@ "dev": true }, "node_modules/undici": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", - "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 7fbdd2f74f8..0918965b084 100644 --- a/package.json +++ b/package.json @@ -22,8 +22,8 @@ "compile": "npm-run-all2 -lp compile-client compile-copilot", "compile-client": "npm run gulp compile", "compile-copilot": "npm --prefix extensions/copilot run compile", - "transpile": "npm-run-all2 -lp transpile-client transpile-extensions compile-copilot", - "transpile-extensions": "npm run gulp transpile-extensions compile-extension-media", + "build-fast": "npm-run-all2 -lp transpile-client build-fast-extensions compile-copilot", + "build-fast-extensions": "npm run gulp copy-codicons compile-extensions compile-extension-media", "typecheck-client": "tsgo --project ./src/tsconfig.json --noEmit --skipLibCheck", "codex:gen-protocol": "node build/codex/generate-protocol.mjs", "watch": "npm-run-all2 -lp watch-client-transpile watch-client watch-extensions watch-copilot", @@ -150,7 +150,7 @@ "playwright-core": "1.61.0-alpha-2026-06-04", "ssh2": "^1.16.0", "tas-client": "0.3.1", - "undici": "^7.24.0", + "undici": "^7.28.0", "vscode-oniguruma": "1.7.0", "vscode-regexpp": "^3.1.0", "vscode-textmate": "^9.3.2", diff --git a/src/vs/base/common/uuid.ts b/src/vs/base/common/uuid.ts index f63fc232872..6174efbc99b 100644 --- a/src/vs/base/common/uuid.ts +++ b/src/vs/base/common/uuid.ts @@ -64,7 +64,7 @@ export const generateUuid = (function (): () => string { }; })(); -/** Namespace should be 3 letter. */ +/** Namespace should be 3 letters, e.g. `abc-`. */ export function prefixedUuid(namespace: string): string { return `${namespace}-${generateUuid()}`; } diff --git a/src/vs/base/test/common/uuid.test.ts b/src/vs/base/test/common/uuid.test.ts index d1787f2d028..13985946aa9 100644 --- a/src/vs/base/test/common/uuid.test.ts +++ b/src/vs/base/test/common/uuid.test.ts @@ -23,4 +23,17 @@ suite('UUID', () => { assert.ok(uuid.isUUID(value)); } }); + + test('prefixedUuid', () => { + const namespace = 'abc'; + const result = uuid.prefixedUuid(namespace); + + assert.ok(result.startsWith(`${namespace}-`), `Expected "${result}" to start with "${namespace}-"`); + + const expectedLength = namespace.length + 1 + 36; + assert.strictEqual(result.length, expectedLength); + + const uuidPart = result.slice(namespace.length + 1); + assert.ok(uuid.isUUID(uuidPart), `Expected "${uuidPart}" to be a valid UUID`); + }); }); diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 73d6d8ba16b..f89891f13e3 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -42,6 +42,7 @@ import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otl import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; import type { InitializeResult } from '../common/state/protocol/common/commands.js'; import { dirname } from '../../../base/common/resources.js'; +import { isFileResourceRead } from '../common/resourceReadLogging.js'; const AHP_CLIENT_CONNECTION_CLOSED = -32000; @@ -95,6 +96,12 @@ interface IRemoteAgentHostExtensionCommandMap { 'shutdown': { params: undefined; result: void }; } +interface IPendingRequest { + readonly deferred: DeferredPromise; + readonly suppressNotFoundWarning: boolean; + readonly sentAt: number; +} + /** * High-level connection state of a {@link RemoteAgentHostProtocolClient}. * Exposed via {@link RemoteAgentHostProtocolClient.onDidChangeConnectionState} @@ -215,7 +222,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC private _state: ClientState = { kind: AgentHostClientState.Connecting }; /** Pending JSON-RPC requests keyed by request id. */ - private readonly _pendingRequests = new Map; sentAt: number }>(); + private readonly _pendingRequests = new Map(); private _nextRequestId = 1; /** @@ -1029,7 +1036,9 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC if (pending) { this._pendingRequests.delete(msg.id); if (hasKey(msg, { error: true })) { - this._logService.warn(`[RemoteAgentHostProtocol] Request ${msg.id} failed:`, msg.error); + if (this._shouldLogFailedRequest(pending, msg.error)) { + this._logService.warn(`[RemoteAgentHostProtocol] Request ${msg.id} failed:`, msg.error); + } pending.deferred.error(this._toProtocolError(msg.error)); } else { pending.deferred.complete(msg.result); @@ -1326,12 +1335,19 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC const id = this._nextRequestId++; const deferred = new DeferredPromise(); - this._pendingRequests.set(id, { deferred, sentAt: Date.now() }); + this._pendingRequests.set(id, { deferred, suppressNotFoundWarning: isFileResourceRead(method, params), sentAt: Date.now() }); const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params }; this._transport.send(request); return deferred.p as Promise; } + private _shouldLogFailedRequest(request: IPendingRequest, error: JsonRpcErrorResponse['error']): boolean { + if (error.code === AhpErrorCodes.NotFound && request.suppressNotFoundWarning) { + return false; + } + return true; + } + private _toProtocolError(error: JsonRpcErrorResponse['error']): ProtocolError { return new ProtocolError(error.code, error.message, error.data); } diff --git a/src/vs/platform/agentHost/common/resourceReadLogging.ts b/src/vs/platform/agentHost/common/resourceReadLogging.ts new file mode 100644 index 00000000000..fc596806973 --- /dev/null +++ b/src/vs/platform/agentHost/common/resourceReadLogging.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Schemas } from '../../../base/common/network.js'; +import { hasKey } from '../../../base/common/types.js'; +import { URI } from '../../../base/common/uri.js'; + +export function isFileResourceRead(method: string, params: unknown): boolean { + if (method !== 'resourceRead' || !hasUriParam(params)) { + return false; + } + const uri = params.uri; + if (typeof uri !== 'string') { + return false; + } + try { + return URI.parse(uri).scheme === Schemas.file; + } catch { + return false; + } +} + +function hasUriParam(params: unknown): params is { readonly uri: unknown } { + return typeof params === 'object' && params !== null && hasKey(params, { uri: true }); +} diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 2d8e4acfcef..2470806750f 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -101,6 +101,24 @@ export interface UsageInfoMeta { totalNanoAiu?: number; [key: string]: unknown; }; + /** + * Per-category account quota snapshots reported by the backend on the + * model-call usage event, keyed by quota type (e.g. `chat`, + * `premium_interactions`). Clients MAY use these to keep the account quota + * UI current without a separate quota fetch. + */ + quotaSnapshots?: { + [quotaType: string]: { + readonly isUnlimitedEntitlement?: boolean; + readonly entitlementRequests?: number; + readonly usedRequests?: number; + readonly remainingPercentage?: number; + readonly overage?: number; + readonly overageAllowedWithExhaustedQuota?: boolean; + /** ISO 8601 date when the quota resets, if applicable. */ + readonly resetDate?: string; + } | undefined; + }; [key: string]: unknown; } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index e6065a8e801..cddc187f5ed 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -16,7 +16,7 @@ import { extname as resourcesExtname, isEqual, isEqualOrParent, joinPath } from import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { localize } from '../../../nls.js'; -import { FileChangeType, FileOperationError, FileOperationResult, FileSystemProviderErrorCode, IFileChange, IFileService, toFileSystemProviderErrorCode, type FileChangesEvent } from '../../files/common/files.js'; +import { FileChangeType, FileOperationError, FileOperationResult, FileSystemProviderErrorCode, IFileChange, IFileService, toFileOperationResult, toFileSystemProviderErrorCode, type FileChangesEvent } from '../../files/common/files.js'; import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; @@ -1696,8 +1696,16 @@ export class AgentService extends Disposable implements IAgentService { encoding: ContentEncoding.Utf8, contentType: 'text/plain', }; - } catch (_e) { - throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri.toString()}`); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + const result = toFileOperationResult(error); + if (result === FileOperationResult.FILE_NOT_FOUND) { + throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri.toString()}`); + } + if (result === FileOperationResult.FILE_PERMISSION_DENIED) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${uri.toString()}`); + } + throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to read content: ${uri.toString()}: ${toErrorMessage(error)}`); } } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index b2005626cb7..ab004a86019 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -349,7 +349,9 @@ export class AgentSideEffects extends Disposable { if (subagentSession) { const subTurnId = this._stateManager.getActiveTurnId(subagentSession); if (subTurnId) { - this._handleToolReady(signal, subagentSession, subTurnId, agent); + void this._handleToolReady(signal, subagentSession, subTurnId, agent).catch(err => { + this._logService.error('[AgentSideEffects] _handleToolReady failed', err); + }); } return; } @@ -381,7 +383,9 @@ export class AgentSideEffects extends Disposable { private _dispatchActionForSession(signal: AgentSignal, sessionKey: ProtocolURI, turnId: string, agent?: IAgent): void { if (signal.kind === 'pending_confirmation') { if (agent) { - this._handleToolReady(signal, sessionKey, turnId, agent); + void this._handleToolReady(signal, sessionKey, turnId, agent).catch(err => { + this._logService.error('[AgentSideEffects] _handleToolReady failed', err); + }); } return; } @@ -734,7 +738,7 @@ export class AgentSideEffects extends Disposable { * dispatches the `ChatToolCallReady` action with confirmation options * for the client. */ - private _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): void { + private async _handleToolReady(e: IAgentToolPendingConfirmationSignal, sessionKey: ProtocolURI, turnId: string, agent: IAgent): Promise { const approvalEvent = { toolCallId: e.state.toolCallId, session: e.session, @@ -742,7 +746,7 @@ export class AgentSideEffects extends Disposable { permissionPath: e.permissionPath, toolInput: e.state.toolInput, }; - const autoApproval = this._permissionManager.getAutoApproval(approvalEvent, sessionKey); + const autoApproval = await this._permissionManager.getAutoApproval(approvalEvent, sessionKey); const part = this._stateManager.getSessionState(sessionKey)?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === e.state.toolCallId); const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; const contributor = e.state.contributor ?? toolCall?.contributor; diff --git a/src/vs/platform/agentHost/node/claude/CONTEXT.md b/src/vs/platform/agentHost/node/claude/CONTEXT.md index c392fc26c9f..ceb6c8f54bc 100644 --- a/src/vs/platform/agentHost/node/claude/CONTEXT.md +++ b/src/vs/platform/agentHost/node/claude/CONTEXT.md @@ -1890,7 +1890,7 @@ platform-shared properties). | | Shape | |---|---| | Returns | `IAgentDescriptor { provider, displayName, description }` ([agentService.ts:160-165](../../common/agentService.ts#L160-L165)) | -| CopilotAgent | Hardcoded literal `{ provider: 'copilotcli', displayName: 'Copilot CLI', description: '…' }` ([copilotAgent.ts:256-262](../copilot/copilotAgent.ts#L256-L262)) | +| CopilotAgent | Hardcoded literal `{ provider: 'copilotcli', displayName: 'Copilot', description: '…' }` ([copilotAgent.ts:256-262](../copilot/copilotAgent.ts#L256-L262)) | | Claude provider | Hardcoded literal `{ provider: 'claude', displayName: 'Claude', description: '…' }` | `AgentProvider` is `type AgentProvider = string` ([agentService.ts:158](../../common/agentService.ts#L158)) diff --git a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts index bdcad9e0345..2802007a05a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts +++ b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts @@ -61,7 +61,7 @@ export interface IClaudeCanUseToolOptions { * * Note: protocol-level auto-approve for write tools lives in * `agentSideEffects.ts:_handleToolReady`, which subscribes to the - * `pending_confirmation` signal and synchronously calls + * `pending_confirmation` signal and calls * `respondToPermissionRequest`. The atomic register-then-fire * invariant lives inside {@link ClaudeAgentSession.requestPermission} * (via `PendingRequestRegistry.registerAndFire`). diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index b83cf537cae..e56d69da4e9 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -422,7 +422,7 @@ export class CopilotAgent extends Disposable implements IAgent { getDescriptor(): IAgentDescriptor { return { provider: 'copilotcli', - displayName: 'Copilot CLI', + displayName: 'Copilot', description: 'Copilot SDK agent running in a dedicated process', }; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index a0e1185e43f..c4cd3c43048 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -2418,7 +2418,13 @@ export class CopilotAgentSession extends Disposable { totalNanoAiu: this._turnCopilotUsageTotalNanoAiu, }; } - this._logService.trace(`[Copilot:${sessionId}] Usage: model=${e.data.model}, in=${e.data.inputTokens ?? '?'}, out=${e.data.outputTokens ?? '?'}, cacheRead=${e.data.cacheReadTokens ?? '?'}, cost=${e.data.cost ?? '?'}, totalNanoAiu=${metadata.copilotUsage ? this._turnCopilotUsageTotalNanoAiu : '?'}`); + // `quotaSnapshots` is likewise `asInternal` in the SDK schema (not on the generated type) but is + // present at runtime. Forward the per-category snapshots on `_meta` so the client can keep the + // account quota UI current. Mirrors the extension-host CLI path, which feeds these into its quota service. + const quotaSnapshots = normalizeQuotaSnapshots((e.data as unknown as Record).quotaSnapshots); + if (quotaSnapshots) { + metadata.quotaSnapshots = quotaSnapshots; + } if (typeof e.data.model === 'string' && e.data.model) { this._lastSeenModelId = e.data.model; } @@ -2975,3 +2981,39 @@ function countUnifiedDiffLines(diff: string): { added: number; removed: number } } return { added, removed }; } + +/** + * Normalizes the SDK's internal `quotaSnapshots` field β€” present on the `assistant.usage` event at + * runtime but absent from the generated `AssistantUsageData` type β€” into the serializable shape + * carried on {@link UsageInfoMeta.quotaSnapshots}. Returns `undefined` when no usable snapshot is present. + */ +function normalizeQuotaSnapshots(raw: unknown): UsageInfoMeta['quotaSnapshots'] | undefined { + if (!raw || typeof raw !== 'object') { + return undefined; + } + const result: NonNullable = {}; + let hasAny = false; + for (const [quotaType, value] of Object.entries(raw as Record)) { + if (!value || typeof value !== 'object') { + continue; + } + const v = value as Record; + const resetDateRaw = v.resetDate; + const resetDate = typeof resetDateRaw === 'string' + ? resetDateRaw + : resetDateRaw instanceof Date + ? resetDateRaw.toISOString() + : undefined; + result[quotaType] = { + isUnlimitedEntitlement: typeof v.isUnlimitedEntitlement === 'boolean' ? v.isUnlimitedEntitlement : undefined, + entitlementRequests: typeof v.entitlementRequests === 'number' ? v.entitlementRequests : undefined, + usedRequests: typeof v.usedRequests === 'number' ? v.usedRequests : undefined, + remainingPercentage: typeof v.remainingPercentage === 'number' ? v.remainingPercentage : undefined, + overage: typeof v.overage === 'number' ? v.overage : undefined, + overageAllowedWithExhaustedQuota: typeof v.overageAllowedWithExhaustedQuota === 'boolean' ? v.overageAllowedWithExhaustedQuota : undefined, + resetDate, + }; + hasAny = true; + } + return hasAny ? result : undefined; +} diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 0ad18cbc11b..1ced2fb12c7 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -20,6 +20,7 @@ import { VSCODE_UPGRADE_METHOD, type UnsupportedProtocolVersionErrorDataEx } fro import { getAgentHostManagementSocketPath, requestAgentHostUpgrade } from './agentHostUpgradeChannel.js'; import { AHP_AUTH_REQUIRED, + AhpErrorCodes, AHP_PROVIDER_NOT_FOUND, AHP_SESSION_NOT_FOUND, AHP_UNSUPPORTED_PROTOCOL_VERSION, @@ -50,6 +51,7 @@ import { type IOtlpLogRecord, type OtlpLogLevelName, } from '../common/otlp/otlpLogEmitter.js'; +import { isFileResourceRead } from '../common/resourceReadLogging.js'; /** Default capacity of the server-side action replay buffer. */ const REPLAY_BUFFER_CAPACITY = 1000; @@ -82,6 +84,13 @@ function jsonRpcErrorFrom(id: number, err: unknown): JsonRpcResponse { return jsonRpcError(id, JSON_RPC_INTERNAL_ERROR, message); } +function shouldLogFailedRequest(method: string, params: unknown, err: unknown): boolean { + if (!(err instanceof ProtocolError) || err.code !== AhpErrorCodes.NotFound || !isFileResourceRead(method, params)) { + return true; + } + return false; +} + /** True when `value` is a non-null params object (as opposed to an array or primitive). */ function isParamsObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -1204,7 +1213,9 @@ export class ProtocolServerHandler extends Disposable { this._logService.trace(`[ProtocolServer] Request '${method}' id=${id} succeeded`); client.transport.send(jsonRpcSuccess(id, result ?? null)); }).catch(err => { - this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); + if (shouldLogFailedRequest(method, params, err)) { + this._logService.error(`[ProtocolServer] Request '${method}' failed`, err); + } client.transport.send(jsonRpcErrorFrom(id, err)); }); return; diff --git a/src/vs/platform/agentHost/node/sessionPermissions.ts b/src/vs/platform/agentHost/node/sessionPermissions.ts index 3172525d58b..ac205701f79 100644 --- a/src/vs/platform/agentHost/node/sessionPermissions.ts +++ b/src/vs/platform/agentHost/node/sessionPermissions.ts @@ -3,12 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { realpath } from 'fs/promises'; import { homedir } from 'os'; -import { match as globMatch } from '../../../base/common/glob.js'; +import { match as globMatch, parse as globParse, type ParsedPattern } from '../../../base/common/glob.js'; import { untildify } from '../../../base/common/labels.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import * as path from '../../../base/common/path.js'; +import { isMacintosh, isWindows } from '../../../base/common/platform.js'; import { extUriBiasedIgnorePathCase, normalizePath } from '../../../base/common/resources.js'; +import { isDefined } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ILogService } from '../../log/common/log.js'; @@ -58,6 +61,124 @@ const DEFAULT_EDIT_AUTO_APPROVE_PATTERNS: Readonly> = { '**/*-lock.{yaml,json}': false, }; +/** + * Glob patterns matching dotfiles directly under the user's home directory + * (e.g. `~/.ssh`, `~/.aws`). Writes to these always require confirmation, + * even when the working directory sits inside the home directory. + */ +const HOME_DOTFILE_PATTERNS: readonly ParsedPattern[] = [ + globParse(homedir() + '/.*'), + globParse(homedir() + '/.*/**'), +]; + +/** + * Absolute directory prefixes whose contents are platform configuration data + * (e.g. `~/Library`, `%APPDATA%`). Writes under these require confirmation + * unless the working directory itself lives inside the restricted directory. + */ +const PLATFORM_RESTRICTED_DIRS: readonly string[] = ( + isWindows + ? [process.env.APPDATA, process.env.LOCALAPPDATA] + : isMacintosh + ? [homedir() + '/Library'] + : [] +).filter(isDefined); + +/** + * Validates that a path doesn't contain suspicious characters that could be + * used to bypass security checks on Windows (e.g. NTFS Alternate Data Streams, + * invalid characters, reserved device names). Throws if the path is suspicious. + */ +function assertPathIsSafe(fsPath: string, _isWindows = isWindows): void { + if (fsPath.includes('\0')) { + throw new Error(`Path contains null bytes: ${fsPath}`); + } + + if (!_isWindows) { + return; + } + + // Check for NTFS Alternate Data Streams (ADS) + const colonIndex = fsPath.indexOf(':', 2); + if (colonIndex !== -1) { + throw new Error(`Path contains invalid characters (alternate data stream): ${fsPath}`); + } + + // Check for invalid Windows filename characters + const invalidChars = /[<>"|?*]/; + const pathAfterDrive = fsPath.length > 2 ? fsPath.substring(2) : fsPath; + if (invalidChars.test(pathAfterDrive)) { + throw new Error(`Path contains invalid characters: ${fsPath}`); + } + + // Check for named pipes or device paths + if (fsPath.startsWith('\\\\.') || fsPath.startsWith('\\\\?')) { + throw new Error(`Path is a reserved device path: ${fsPath}`); + } + + const reserved = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i; + + // Check for trailing dots and spaces on path components (Windows quirk) + const parts = fsPath.split('\\'); + for (const part of parts) { + if (part.length === 0) { + continue; + } + + if (reserved.test(part)) { + throw new Error(`Reserved device name in path: ${fsPath}`); + } + + if (part.endsWith('.') || part.endsWith(' ')) { + throw new Error(`Path contains invalid trailing characters: ${fsPath}`); + } + + const tildeIndex = part.indexOf('~'); + if (tildeIndex !== -1) { + const afterTilde = part.substring(tildeIndex + 1); + if (afterTilde.length > 0 && /^\d/.test(afterTilde)) { + throw new Error(`Path appears to use short filename format (8.3 names): ${fsPath}. Please use the full path.`); + } + } + } +} + +/** + * Resolves the real path of `fsPath`, walking up the parent chain when the path + * (or its ancestors) does not yet exist on disk. This ensures a symlink at any + * ancestor is followed even for files that are about to be created. + */ +async function resolveRealPathForNonexistent(fsPath: string): Promise { + try { + return await realpath(fsPath); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + } + + const tail: string[] = [path.basename(fsPath)]; + let current = path.dirname(fsPath); + while (true) { + const parent = path.dirname(current); + if (parent === current) { + // Reached the filesystem root without finding an existing ancestor. + return fsPath; + } + try { + const resolved = await realpath(current); + return path.join(resolved, ...tail); + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') { + throw e; + } + } + tail.unshift(path.basename(current)); + current = parent; + } +} + /** * Single entry point for all tool-call approval logic in the agent host. * @@ -96,8 +217,8 @@ export class SessionPermissionManager extends Disposable { /** * Initializes async resources (tree-sitter WASM) used for shell command - * auto-approval. Await this before any session events can arrive to - * guarantee that {@link getAutoApproval} is fully synchronous. + * auto-approval. Await this before any session events can arrive so that + * shell command parsing within {@link getAutoApproval} is synchronous. */ initialize(): Promise { return this._commandAutoApprover.initialize(); @@ -106,10 +227,10 @@ export class SessionPermissionManager extends Disposable { // ---- Auto-approval (analogous to getPreConfirmAction) ------------------- /** - * Synchronously checks whether a `tool_ready` event should be - * auto-approved. Returns a {@link ToolCallConfirmationReason} when the - * tool call should proceed without user interaction, or `undefined` - * when user confirmation is required. + * Checks whether a `tool_ready` event should be auto-approved. Returns a + * {@link ToolCallConfirmationReason} when the tool call should proceed + * without user interaction, or `undefined` when user confirmation is + * required. * * Checks are evaluated in order: * 1. Session-level bypass (`autoApprove` config) @@ -118,7 +239,7 @@ export class SessionPermissionManager extends Disposable { * 4. Write path rules (within working directory + glob patterns) * 5. Shell command rules (tree-sitter parsed, default allow/deny) */ - getAutoApproval(e: IToolApprovalEvent, sessionKey: ProtocolURI): ToolCallConfirmationReason | undefined { + async getAutoApproval(e: IToolApprovalEvent, sessionKey: ProtocolURI): Promise { const workDir = this._configService.getEffectiveWorkingDirectory(sessionKey); // 1. Session-level auto-approve @@ -142,7 +263,7 @@ export class SessionPermissionManager extends Disposable { // 4. Write auto-approval if (e.permissionKind === 'write' && e.permissionPath) { - if (this._isPathInWorkingDirectory(e.permissionPath, workDir) && this._isEditAutoApproved(e.permissionPath)) { + if (await this._isEditAutoApproved(e.permissionPath, workDir)) { this._logService.trace(`[SessionPermissionManager] Auto-approving write to ${e.permissionPath}`); return ToolCallConfirmationReason.NotNeeded; } @@ -247,7 +368,7 @@ export class SessionPermissionManager extends Disposable { if (!resolved) { return false; } - return this._isPathInWorkingDirectory(resolved, workDir) && this._isEditAutoApproved(resolved); + return this._checkWritePath(resolved, workDir); } /** @@ -271,7 +392,90 @@ export class SessionPermissionManager extends Disposable { return path.resolve(URI.parse(workDir).fsPath, trimmed); } - private _isEditAutoApproved(filePath: string): boolean { + /** + * Determines whether a write to `filePath` can be auto-approved. Mirrors the + * checks performed by the workbench edit-confirmation pipeline: + * + * 1. The path is resolved through any symlinks (following ancestors that do + * not yet exist) so a link can't redirect an edit outside the working + * directory. Both the literal and resolved paths must pass every check. + * 2. The path must be free of suspicious characters (see {@link assertPathIsSafe}). + * 3. The path must live inside the working directory. + * 4. The path must not target a platform-restricted location (home dotfiles, + * `~/Library`, `%APPDATA%`, ...). + * 5. The path must match the edit auto-approve glob rules. + */ + private async _isEditAutoApproved(filePath: string, workDir: string | undefined): Promise { + const pathsToCheck = await this._resolveWritePaths(filePath); + return pathsToCheck !== undefined && pathsToCheck.every(p => this._checkWritePath(p, workDir)); + } + + /** + * Returns the set of paths that must each pass the write checks: the literal + * path plus, for absolute paths, the symlink-resolved real path. Returns + * `undefined` when the path cannot be resolved due to missing permissions, + * signalling that confirmation is required. + */ + private async _resolveWritePaths(filePath: string): Promise { + const pathsToCheck = [filePath]; + if (path.isAbsolute(filePath)) { + try { + const linked = await resolveRealPathForNonexistent(filePath); + if (linked !== filePath) { + pathsToCheck.push(linked); + } + } catch (e) { + const code = (e as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES') { + // No permission to resolve the path β€” require confirmation. + return undefined; + } + // Otherwise fall back to checking the literal path only. + } + } + return pathsToCheck; + } + + /** Runs the per-path write checks for a single (already symlink-resolved) path. */ + private _checkWritePath(filePath: string, workDir: string | undefined): boolean { + try { + assertPathIsSafe(filePath); + } catch { + return false; + } + if (!this._isPathInWorkingDirectory(filePath, workDir)) { + return false; + } + if (this._isPlatformRestrictedPath(filePath, workDir)) { + return false; + } + return this._matchesEditAutoApprovePatterns(filePath); + } + + /** + * Returns whether `filePath` targets a platform-restricted location that + * should always require confirmation. Edits within home-directory dotfiles + * are never auto-approved. Edits within platform config directories are + * allowed only when the working directory itself lives inside them. + */ + private _isPlatformRestrictedPath(filePath: string, workDir: string | undefined): boolean { + if (HOME_DOTFILE_PATTERNS.some(pattern => pattern(filePath))) { + return true; + } + + const uri = URI.file(filePath); + const workspaceFolder = workDir ? URI.parse(workDir) : undefined; + for (const restricted of PLATFORM_RESTRICTED_DIRS) { + const parentURI = URI.file(restricted); + if (extUriBiasedIgnorePathCase.isEqualOrParent(uri, parentURI)) { + // Allow edits when the working directory is opened inside the restricted area. + return !(workspaceFolder && extUriBiasedIgnorePathCase.isEqualOrParent(workspaceFolder, parentURI)); + } + } + return false; + } + + private _matchesEditAutoApprovePatterns(filePath: string): boolean { let approved = true; for (const [pattern, isApproved] of Object.entries(DEFAULT_EDIT_AUTO_APPROVE_PATTERNS)) { if (isApproved !== approved && globMatch(pattern, filePath)) { diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 1dfd2442f0b..7dc3039be81 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -12,7 +12,7 @@ import { observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../log/common/log.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentHostClientState, RemoteAgentHostProtocolClient } from '../../browser/remoteAgentHostProtocolClient.js'; import { AgentHostPermissionMode, AgentHostResourcePermissionError, IAgentHostResourceService } from '../../common/agentHostResourceService.js'; import { ContentEncoding, ReconnectResultType } from '../../common/state/protocol/commands.js'; @@ -71,6 +71,14 @@ class CloseOnDisposeProtocolTransport extends TestProtocolTransport { } } +class CountingLogService extends NullLogService { + warnCount = 0; + + override warn(_message: string, ..._args: unknown[]): void { + this.warnCount++; + } +} + suite('RemoteAgentHostProtocolClient', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -122,8 +130,8 @@ suite('RemoteAgentHostProtocolClient', () => { }; } - function createClient(transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport } { - const client = disposables.add(new RemoteAgentHostProtocolClient('test.example:1234', transport, loadEstimator, new NullLogService(), permissionService, new TestConfigurationService())); + function createClient(transport = disposables.add(new TestProtocolTransport()), permissionService = createPermissionService(), loadEstimator?: { hasHighLoad(): boolean }, logService: ILogService = new NullLogService()): { client: RemoteAgentHostProtocolClient; transport: TestProtocolTransport } { + const client = disposables.add(new RemoteAgentHostProtocolClient('test.example:1234', transport, loadEstimator, logService, permissionService, new TestConfigurationService())); return { client, transport }; } @@ -169,6 +177,39 @@ suite('RemoteAgentHostProtocolClient', () => { await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.NotFound, message: 'Missing resource', data }); }); + test('does not warn for missing file resource reads', async () => { + const logService = new CountingLogService(); + const { client, transport } = createClient(undefined, undefined, undefined, logService); + const resultPromise = client.resourceRead(URI.file('/workspace/src/missing.ts')); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, error: { code: AhpErrorCodes.NotFound, message: 'Content not found' } }); + + await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.NotFound, message: 'Content not found' }); + assert.strictEqual(logService.warnCount, 0); + }); + + test('warns for non-file resource read NotFound errors', async () => { + const logService = new CountingLogService(); + const { client, transport } = createClient(undefined, undefined, undefined, logService); + const resultPromise = client.resourceRead(URI.parse('session-db:/missing')); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, error: { code: AhpErrorCodes.NotFound, message: 'Missing snapshot' } }); + + await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.NotFound, message: 'Missing snapshot' }); + assert.strictEqual(logService.warnCount, 1); + }); + + test('warns for non-read NotFound errors', async () => { + const logService = new CountingLogService(); + const { client, transport } = createClient(undefined, undefined, undefined, logService); + const resultPromise = client.resourceResolve({ channel: ROOT_STATE_URI, uri: URI.file('/workspace/src/missing.ts').toString() }); + + transport.fireMessage({ jsonrpc: '2.0', id: 1, error: { code: AhpErrorCodes.NotFound, message: 'Missing resource' } }); + + await assertRemoteProtocolError(resultPromise, { code: AhpErrorCodes.NotFound, message: 'Missing resource' }); + assert.strictEqual(logService.warnCount, 1); + }); + test('ignores response for unknown request id', () => { const { transport } = createClient(); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index e5f34def2d9..b197cc5ba31 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -37,6 +37,7 @@ import { createNoopGitService, createSessionDataService, TestSessionDatabase } f import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { buildSessionChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; +import { AhpErrorCodes, JSON_RPC_INTERNAL_ERROR, ProtocolError } from '../../common/state/sessionProtocol.js'; /** * Loads a JSONL fixture of raw Copilot SDK events, runs them through @@ -164,6 +165,39 @@ suite('AgentService (node dispatcher)', () => { }); }); + suite('resourceRead', () => { + + test('maps missing files to NotFound', async () => { + const uri = URI.from({ scheme: Schemas.inMemory, path: '/missing.txt' }); + + await assert.rejects( + () => service.resourceRead(uri), + (error: unknown) => error instanceof ProtocolError + && error.code === AhpErrorCodes.NotFound + && error.message === `Content not found: ${uri.toString()}` + ); + }); + + test('does not map all read failures to NotFound', async () => { + const uri = URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' }); + const originalReadFile = fileService.readFile.bind(fileService); + fileService.readFile = async resource => { + if (resource.toString() === uri.toString()) { + return Promise.reject('Injected unknown read failure'); + } + return originalReadFile(resource); + }; + disposables.add(toDisposable(() => fileService.readFile = originalReadFile)); + + await assert.rejects( + () => service.resourceRead(uri), + (error: unknown) => error instanceof ProtocolError + && error.code === JSON_RPC_INTERNAL_ERROR + && error.message === `Failed to read content: ${uri.toString()}: Injected unknown read failure` + ); + }); + }); + // ---- createSession -------------------------------------------------- suite('dispatchAction', () => { diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 8c5b02cf535..5359c4a0c72 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -164,6 +164,36 @@ suite('AgentSideEffects', () => { ); } + /** + * Resolves with the first non-`undefined` value returned by `match`, + * re-evaluating it immediately and after every envelope emitted by the + * state manager. Used to await the async tool-approval pipeline + * (`_handleToolReady` -> `getAutoApproval` -> `realpath`) deterministically + * instead of depending on a fixed settle delay. + */ + function waitForState(manager: AgentHostStateManager, match: () => T | undefined): Promise { + return new Promise((resolve, reject) => { + const initial = match(); + if (initial !== undefined) { + resolve(initial); + return; + } + const store = new DisposableStore(); + const timer = setTimeout(() => { + store.dispose(); + reject(new Error('waitForState: condition was not met')); + }, 5000); + store.add(toDisposable(() => clearTimeout(timer))); + store.add(manager.onDidEmitEnvelope(() => { + const value = match(); + if (value !== undefined) { + store.dispose(); + resolve(value); + } + })); + }); + } + setup(async () => { fileService = disposables.add(new FileService(new NullLogService())); const memFs = disposables.add(new InMemoryFileSystemProvider()); @@ -1389,7 +1419,7 @@ suite('AgentSideEffects', () => { suite('tool_ready dispatches progress actions to advance tool call state', () => { - test('tool_ready for a non-permission tool dispatches ChatToolCallReady and advances state from Streaming to Running', () => { + test('tool_ready for a non-permission tool dispatches ChatToolCallReady and advances state from Streaming to Running', async () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1422,14 +1452,18 @@ suite('AgentSideEffects', () => { permissionKind: undefined, permissionPath: undefined, }); - const stateAfterReady = stateManager.getSessionState(sessionUri.toString()); + const stateAfterReady = await waitForState(stateManager, () => { + const s = stateManager.getSessionState(sessionUri.toString()); + const p = s?.activeTurn?.responseParts[0]; + return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.Running ? s : undefined; + }); const partAfterReady = stateAfterReady?.activeTurn?.responseParts[0]; assert.strictEqual(partAfterReady?.kind, ResponsePartKind.ToolCall); assert.strictEqual(partAfterReady?.kind === ResponsePartKind.ToolCall ? partAfterReady.toolCall.status : undefined, ToolCallStatus.Running, 'tool call should advance from Streaming to Running after tool_ready'); }); - test('tool_ready for a permission-gated tool dispatches ChatToolCallReady and advances state to PendingConfirmation', () => { + test('tool_ready for a permission-gated tool dispatches ChatToolCallReady and advances state to PendingConfirmation', async () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1456,14 +1490,18 @@ suite('AgentSideEffects', () => { permissionKind: undefined, permissionPath: undefined, }); - const state = stateManager.getSessionState(sessionUri.toString()); + const state = await waitForState(stateManager, () => { + const s = stateManager.getSessionState(sessionUri.toString()); + const p = s?.activeTurn?.responseParts[0]; + return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined; + }); const part = state?.activeTurn?.responseParts[0]; assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.PendingConfirmation, 'tool call should advance to PendingConfirmation for permission-gated tool_ready'); }); - test('pending_confirmation for a tool inside a subagent routes to the subagent session', () => { + test('pending_confirmation for a tool inside a subagent routes to the subagent session', async () => { // Regression: a `pending_confirmation` signal for a client tool // inside a subagent must dispatch ChatToolCallReady against // the subagent session, not the parent. Otherwise the parent @@ -1519,7 +1557,13 @@ suite('AgentSideEffects', () => { // The subagent session must contain the ChatToolCallReady. const subagentUri = buildSubagentSessionUri(sessionUri.toString(), 'tc-parent'); - const subState = stateManager.getSessionState(subagentUri); + const subState = await waitForState(stateManager, () => { + const s = stateManager.getSessionState(subagentUri); + const inner = s?.activeTurn?.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner' + ); + return inner?.kind === ResponsePartKind.ToolCall && inner.toolCall.status === ToolCallStatus.Running ? s : undefined; + }); const innerPart = subState?.activeTurn?.responseParts.find( rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-inner' ); @@ -1565,7 +1609,7 @@ suite('AgentSideEffects', () => { }); } - test('auto-approves all writes when autoApprove is set to bypass', () => { + test('auto-approves all writes when autoApprove is set to bypass', async () => { setupSessionWithConfig('autoApprove'); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1598,13 +1642,14 @@ suite('AgentSideEffects', () => { permissionKind: 'write', permissionPath: '/workspace/.env', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); // .env would normally be blocked, but session-level auto-approve overrides assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-bypass-1', approved: true }, ]); }); - test('auto-approves shell commands when autoApprove is set to bypass', () => { + test('auto-approves shell commands when autoApprove is set to bypass', async () => { setupSessionWithConfig('autoApprove'); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1637,6 +1682,7 @@ suite('AgentSideEffects', () => { permissionKind: 'shell', permissionPath: undefined, }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); // Dangerous command would normally be blocked, but session-level // bypass auto-approve overrides. assert.deepStrictEqual(agent.respondToPermissionCalls, [ @@ -1644,7 +1690,7 @@ suite('AgentSideEffects', () => { ]); }); - test('marks pending client tool approval for client-side auto-approval in bypass mode', () => { + test('marks pending client tool approval for client-side auto-approval in bypass mode', async () => { setupSessionWithConfig('autoApprove'); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1669,7 +1715,11 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); - const state = stateManager.getSessionState(sessionUri.toString()); + const state = await waitForState(stateManager, () => { + const s = stateManager.getSessionState(sessionUri.toString()); + const p = s?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-client-approve-1'); + return p?.kind === ResponsePartKind.ToolCall && p.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined; + }); const part = state?.activeTurn?.responseParts.find(part => part.kind === ResponsePartKind.ToolCall && part.toolCall.toolCallId === 'tc-client-approve-1'); assert.ok(part?.kind === ResponsePartKind.ToolCall); assert.deepStrictEqual({ @@ -1732,7 +1782,7 @@ suite('AgentSideEffects', () => { assert.strictEqual(agent.respondToPermissionCalls.length, 0); }); - test('respects mid-session config change via SessionConfigChanged', () => { + test('respects mid-session config change via SessionConfigChanged', async () => { setupSessionWithConfig('default'); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -1771,6 +1821,7 @@ suite('AgentSideEffects', () => { permissionKind: 'write', permissionPath: '/workspace/.env', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); // Should now be auto-approved after config change assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-mid-1', approved: true }, @@ -1815,6 +1866,7 @@ suite('AgentSideEffects', () => { permissionKind: 'write', permissionPath: '/workspace/src/app.ts', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); // Auto-approved writes call respondToPermissionRequest directly assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-auto-1', approved: true }, @@ -1978,7 +2030,7 @@ suite('AgentSideEffects', () => { suite('read auto-approve', () => { - test('auto-approves reads inside working directory', () => { + test('auto-approves reads inside working directory', async () => { setupSession(URI.file('/workspace').toString()); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -2011,6 +2063,7 @@ suite('AgentSideEffects', () => { permissionKind: 'read', permissionPath: '/workspace/src/app.ts', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-read-1', approved: true }, ]); @@ -2547,7 +2600,7 @@ suite('AgentSideEffects', () => { assert.strictEqual(parentInnerTool, undefined, 'inner tool must not leak into parent session'); }); - test('reads inside parent working directory are auto-approved for tools in subagent sessions', () => { + test('reads inside parent working directory are auto-approved for tools in subagent sessions', async () => { // Subagent sessions don't carry their own workingDirectory or // autoApprove config. Without inheritance from the parent, every // tool call inside a subagent (even a read in the workspace) would @@ -2590,12 +2643,13 @@ suite('AgentSideEffects', () => { permissionKind: 'read', permissionPath: '/workspace/src/app.ts', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'inner-read-1', approved: true }, ]); }); - test('session-level autoApprove on the parent is inherited by tools in subagent sessions', () => { + test('session-level autoApprove on the parent is inherited by tools in subagent sessions', async () => { setupSession(URI.file('/workspace').toString()); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -2650,6 +2704,7 @@ suite('AgentSideEffects', () => { permissionKind: 'write', permissionPath: '/tmp/foo', }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'inner-write-1', approved: true }, ]); @@ -2660,7 +2715,7 @@ suite('AgentSideEffects', () => { suite('session permissions', () => { - test('tool_ready action includes confirmation options when confirmation is needed', () => { + test('tool_ready action includes confirmation options when confirmation is needed', async () => { setupSession(); startTurn('turn-1'); disposables.add(sideEffects.registerProgressListener(agent)); @@ -2693,7 +2748,13 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); - const state = stateManager.getSessionState(sessionUri.toString()); + const state = await waitForState(stateManager, () => { + const s = stateManager.getSessionState(sessionUri.toString()); + const found = s?.activeTurn?.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-perm-1' + ); + return found?.kind === ResponsePartKind.ToolCall && found.toolCall.status === ToolCallStatus.PendingConfirmation ? s : undefined; + }); const tc = state!.activeTurn!.responseParts.find( rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'tc-perm-1' ); @@ -2756,7 +2817,7 @@ suite('AgentSideEffects', () => { ); }); - test('subsequent tool_ready for same tool is auto-approved after allow-session permission', () => { + test('subsequent tool_ready for same tool is auto-approved after allow-session permission', async () => { setupSession(); stateManager.setSessionConfig(sessionUri.toString(), { schema: { type: 'object', properties: {} }, @@ -2793,12 +2854,13 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'tc-perm-3', approved: true }, ]); }); - test('subagent tool calls inherit parent session permissions', () => { + test('subagent tool calls inherit parent session permissions', async () => { setupSession(); stateManager.setSessionConfig(sessionUri.toString(), { schema: { type: 'object', properties: {} }, @@ -2859,6 +2921,7 @@ suite('AgentSideEffects', () => { permissionKind: 'custom-tool', permissionPath: undefined, }); + await waitForState(stateManager, () => agent.respondToPermissionCalls.length > 0 || undefined); assert.deepStrictEqual(agent.respondToPermissionCalls, [ { requestId: 'inner-perm-1', approved: true }, ]); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 3b25179cc37..bd411161fca 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -520,6 +520,19 @@ async function disposeAgent(agent: CopilotAgent): Promise { suite('CopilotAgent', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('advertises Copilot as its display name', async () => { + const agent = createTestAgent(disposables); + try { + assert.deepStrictEqual(agent.getDescriptor(), { + provider: 'copilotcli', + displayName: 'Copilot', + description: 'Copilot SDK agent running in a dedicated process', + }); + } finally { + await disposeAgent(agent); + } + }); + test('uses the Copilot CLI sibling worktrees root convention', () => { assert.strictEqual( getCopilotWorktreesRoot(URI.file('/Users/me/src/vscode')).fsPath, diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index b2c5c143fe7..85b6f10eaec 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -901,6 +901,49 @@ suite('CopilotAgentSession', () => { ]); }); + test('forwards account quota snapshots on usage metadata', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + + session.resetTurnState('turn-quota'); + mockSession.fire('assistant.usage', { + model: 'claude-sonnet-4.6', + inputTokens: 10, + outputTokens: 20, + // `quotaSnapshots` is marked `asInternal` in the SDK schema so it is not on the public type, but is present at runtime. + quotaSnapshots: { + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 300, + usedRequests: 75, + usageAllowedWithExhaustedQuota: true, + remainingPercentage: 75, + overage: 1.5, + overageAllowedWithExhaustedQuota: true, + resetDate: '2026-07-01T00:00:00.000Z', + }, + }, + } as unknown as SessionEventPayload<'assistant.usage'>['data']); + + const usageActions = signals + .filter((s): s is IAgentActionSignal => s.kind === 'action') + .map(s => s.action) + .filter(a => a.type === ActionType.ChatUsage); + + assert.deepStrictEqual(usageActions.map(a => a.usage._meta?.quotaSnapshots), [ + { + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 300, + usedRequests: 75, + remainingPercentage: 75, + overage: 1.5, + overageAllowedWithExhaustedQuota: true, + resetDate: '2026-07-01T00:00:00.000Z', + }, + }, + ]); + }); + test('extracts selected text from file contents for different line endings and bounds', async () => { const testCases = [ { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index dcae0709357..41c52f5eaec 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -12,10 +12,10 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { FileType } from '../../../files/common/files.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; -import { CompletionsParams, CompletionsResult, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; +import { CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; -import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; +import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, type SessionSummary } from '../../common/state/sessionState.js'; import type { SessionAddedParams } from '../../common/state/protocol/notifications.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; @@ -70,11 +70,20 @@ class MockProtocolServer implements IProtocolServer { } } +class CountingLogService extends NullLogService { + errorCount = 0; + + override error(_message: string, ..._args: unknown[]): void { + this.errorCount++; + } +} + class MockAgentService implements IAgentService { declare readonly _serviceBrand: undefined; readonly handledActions: (SessionAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction)[] = []; readonly browsedUris: URI[] = []; readonly browseErrors = new Map(); + readonly readErrors = new Map(); readonly listedSessions: IAgentSessionMetadata[] = []; readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; @@ -155,8 +164,12 @@ class MockAgentService implements IAgentService { ], }; } - async resourceRead(_uri: URI): Promise { - throw new Error('Not implemented'); + async resourceRead(uri: URI): Promise { + const error = this.readErrors.get(uri.toString()); + if (error) { + throw error; + } + return { data: '', encoding: ContentEncoding.Utf8 }; } async resourceCopy(_params: ResourceCopyParams): Promise { return {}; } async resourceDelete(): Promise<{}> { return {}; } @@ -222,6 +235,7 @@ suite('ProtocolServerHandler', () => { let agentService: MockAgentService; let handler: ProtocolServerHandler; let fileSystemProvider: AgentHostFileSystemProvider; + let logService: CountingLogService; const sessionUri = URI.from({ scheme: 'copilot', path: '/test-session' }).toString(); @@ -254,6 +268,7 @@ suite('ProtocolServerHandler', () => { server = disposables.add(new MockProtocolServer()); agentService = new MockAgentService(); agentService.setStateManager(stateManager); + logService = new CountingLogService(); disposables.add(agentService); disposables.add(handler = new ProtocolServerHandler( agentService, @@ -261,7 +276,7 @@ suite('ProtocolServerHandler', () => { server, { defaultDirectory: URI.file('/home/testuser').toString() }, disposables.add(fileSystemProvider = new AgentHostFileSystemProvider()), - new NullLogService(), + logService, )); }); @@ -1327,6 +1342,44 @@ suite('ProtocolServerHandler', () => { assert.match(resp.error!.message, /Directory not found/); }); + test('resourceRead does not log missing file reads', async () => { + const transport = connectClient('client-read-missing-file'); + transport.sent.length = 0; + + const fileUri = URI.file('/missing').toString(); + agentService.readErrors.set(fileUri, new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${fileUri}`)); + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'resourceRead', { uri: fileUri })); + const resp = await responsePromise as { error?: { code: number; message: string } }; + + assert.deepStrictEqual({ + errorCode: resp.error?.code, + errorCount: logService.errorCount, + }, { + errorCode: AhpErrorCodes.NotFound, + errorCount: 0, + }); + }); + + test('resourceRead logs missing non-file reads', async () => { + const transport = connectClient('client-read-missing-session-db'); + transport.sent.length = 0; + + const resource = 'session-db:/missing'; + agentService.readErrors.set(resource, new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${resource}`)); + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'resourceRead', { uri: resource })); + const resp = await responsePromise as { error?: { code: number; message: string } }; + + assert.deepStrictEqual({ + errorCode: resp.error?.code, + errorCount: logService.errorCount, + }, { + errorCode: AhpErrorCodes.NotFound, + errorCount: 1, + }); + }); + // ---- Extension methods: auth ---------------------------------------- test('authenticate returns result via typed request', async () => { diff --git a/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts new file mode 100644 index 00000000000..7c4b3d0ff82 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/sessionPermissions.test.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'fs'; +import { homedir, tmpdir } from 'os'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { join } from '../../../../base/common/path.js'; +import { isWindows } from '../../../../base/common/platform.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { platformSessionSchema } from '../../common/agentHostSchema.js'; +import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { SessionStatus, ToolCallConfirmationReason, type SessionSummary } from '../../common/state/sessionState.js'; +import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { SessionPermissionManager, type IToolApprovalEvent } from '../../node/sessionPermissions.js'; + +suite('SessionPermissionManager', () => { + + const disposables = new DisposableStore(); + let manager: AgentHostStateManager; + let permissions: SessionPermissionManager; + + // Real (symlink-resolved) temp directories so that the symlink-resolution + // checks compare like-for-like (e.g. macOS `/var` -> `/private/var`). + let workDir: string; + let outsideDir: string; + + const sessionUri = URI.from({ scheme: 'copilot', path: '/s' }).toString(); + + function makeSummary(resource: string, workingDirectory?: string): SessionSummary { + return { + resource, + provider: 'copilot', + title: 't', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + project: { uri: 'file:///project', displayName: 'Project' }, + workingDirectory, + }; + } + + function writeEvent(permissionPath: string): IToolApprovalEvent { + return { toolCallId: 'tc-1', session: URI.parse(sessionUri), permissionKind: 'write', permissionPath }; + } + + setup(async () => { + // Prefer the CI runner temp dir (a plain long path) over `os.tmpdir()`, + // which on Windows CI is an 8.3 short path (`C:\Users\RUNNER~1\...`) that + // `assertPathIsSafe` rejects for its `~1` segment β€” which would make every + // auto-approval fail. `realpathSync` keeps macOS `/var` -> `/private/var` + // consistent so the symlink-resolution checks compare like-for-like. + const baseTmp = process.env.RUNNER_TEMP || tmpdir(); + workDir = realpathSync(mkdtempSync(join(baseTmp, 'sesperm-work-'))); + outsideDir = realpathSync(mkdtempSync(join(baseTmp, 'sesperm-out-'))); + + manager = disposables.add(new AgentHostStateManager(new NullLogService())); + const configService = disposables.add(new AgentConfigurationService(manager, new NullLogService())); + permissions = disposables.add(new SessionPermissionManager(manager, configService, new NullLogService())); + await permissions.initialize(); + + manager.createSession(makeSummary(sessionUri, URI.file(workDir).toString())); + }); + + teardown(() => { + disposables.clear(); + rmSync(workDir, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('auto-approves a normal file inside the working directory', async () => { + const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'src', 'app.ts')), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.NotNeeded); + }); + + test('requires confirmation for writes outside the working directory', async () => { + const result = await permissions.getAutoApproval(writeEvent(join(outsideDir, 'app.ts')), sessionUri); + assert.strictEqual(result, undefined); + }); + + test('requires confirmation for protected files inside the working directory', async () => { + const files = ['.env', 'package.json', join('.git', 'config'), 'deps.lock', join('.vscode', 'settings.json')]; + const results: (ToolCallConfirmationReason | undefined)[] = []; + for (const file of files) { + results.push(await permissions.getAutoApproval(writeEvent(join(workDir, file)), sessionUri)); + } + assert.deepStrictEqual(results, files.map(() => undefined)); + }); + + test('requires confirmation for paths containing null bytes', async () => { + const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'a\u0000b.txt')), sessionUri); + assert.strictEqual(result, undefined); + }); + + (isWindows ? test.skip : test)('requires confirmation when a symlink redirects outside the working directory', async () => { + symlinkSync(outsideDir, join(workDir, 'link'), 'dir'); + const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'link', 'secret.txt')), sessionUri); + assert.strictEqual(result, undefined); + }); + + (isWindows ? test.skip : test)('auto-approves when a symlink stays inside the working directory', async () => { + mkdirSync(join(workDir, 'real')); + symlinkSync(join(workDir, 'real'), join(workDir, 'link-in'), 'dir'); + const result = await permissions.getAutoApproval(writeEvent(join(workDir, 'link-in', 'note.txt')), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.NotNeeded); + }); + + test('requires confirmation for home-directory dotfiles', async () => { + const homeSession = URI.from({ scheme: 'copilot', path: '/home' }).toString(); + manager.createSession(makeSummary(homeSession, URI.file(homedir()).toString())); + const result = await permissions.getAutoApproval(writeEvent(join(homedir(), '.sesperm-config-xyz')), homeSession); + assert.strictEqual(result, undefined); + }); + + test('auto-approves any write when session bypass is enabled', async () => { + manager.setSessionConfig(sessionUri, { + schema: platformSessionSchema.toProtocol(), + values: { [SessionConfigKey.AutoApprove]: 'autoApprove' }, + }); + const result = await permissions.getAutoApproval(writeEvent(join(outsideDir, 'anything.txt')), sessionUri); + assert.strictEqual(result, ToolCallConfirmationReason.Setting); + }); + + test('auto-approves reads inside but requires confirmation outside the working directory', async () => { + const inside = await permissions.getAutoApproval( + { toolCallId: 'r', session: URI.parse(sessionUri), permissionKind: 'read', permissionPath: join(workDir, 'a.txt') }, + sessionUri, + ); + const outside = await permissions.getAutoApproval( + { toolCallId: 'r', session: URI.parse(sessionUri), permissionKind: 'read', permissionPath: join(outsideDir, 'a.txt') }, + sessionUri, + ); + assert.deepStrictEqual([inside, outside], [ToolCallConfirmationReason.NotNeeded, undefined]); + }); +}); diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index ff35a90d0a7..2d3710f5dee 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -906,28 +906,34 @@ export class QuickInputController extends Disposable { const isElement = dom.isHTMLElement(target); const anchorWindow = isElement ? dom.getWindow(target) : dom.getActiveWindow(); const container = this.layoutService.getContainer(anchorWindow).getBoundingClientRect(); - let anchor = getAnchorRect(target); + const verticalPadding = 6 + 26 + 16; // Accounts for input box and padding + let anchor = getAnchorRect(target); + let preferredAnchorPosition = AnchorPosition.ABOVE; let listHeightRatio = 0.2; + let maxListHeight = 200; + if (this.controller.anchorPosition === 'overlay') { width = anchor.width + 12; listHeightRatio = 0.4; anchor = { - ...anchor, - top: anchor.top - 7 - anchor.height, + top: anchor.top - 7, left: anchor.left - 7, + width: anchor.width, + height: 0 }; + maxListHeight = Math.min(400, container.bottom - anchor.top - verticalPadding); + preferredAnchorPosition = AnchorPosition.BELOW; } else { width = 380; } - const maxListHeight = listHeightRatio * 1000; listHeight = this.dimension ? Math.min(this.dimension.height * listHeightRatio, maxListHeight) : maxListHeight; // Beware: // We need to add some extra pixels to the height to account for the input and padding. - const containerHeight = Math.floor(listHeight) + 6 + 26 + 16; - const { top, left, right, bottom, anchorAlignment, anchorPosition } = layout2d(container, { width, height: containerHeight }, anchor, { anchorPosition: AnchorPosition.ABOVE }); + const containerHeight = Math.floor(listHeight) + verticalPadding; + const { top, left, right, bottom, anchorAlignment, anchorPosition } = layout2d(container, { width, height: containerHeight }, anchor, { anchorPosition: preferredAnchorPosition }); if (anchorAlignment === AnchorAlignment.RIGHT) { style.right = `${right}px`; diff --git a/src/vs/sessions/contrib/chat/browser/sessionTaskRunner.ts b/src/vs/sessions/contrib/chat/browser/sessionTaskRunner.ts index 38e0c40e9a7..f456b6ddf49 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTaskRunner.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTaskRunner.ts @@ -30,8 +30,14 @@ export interface ISessionTaskRunner { /** * Executes the given task in the session's runtime. The returned promise * resolves once the task has been launched (not when it has finished). + * + * May resolve to an {@link IDisposable} that stops the launched task (e.g. + * kills its terminal/process). Callers that auto-dispatch tasks (such as + * {@link WorktreeCreatedTaskDispatcher}) use it to stop long-running setup + * processes when a session is marked done. Resolves to `undefined` when the + * runner has nothing to stop. */ - runTask(task: ITaskEntry, session: ISession): Promise; + runTask(task: ITaskEntry, session: ISession): Promise; } /** diff --git a/src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts b/src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts index 080b4c5a9e3..187f8b2596d 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsTasksService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; import { joinPath, dirname, isEqual } from '../../../../base/common/resources.js'; import { parse } from '../../../../base/common/jsonc.js'; @@ -146,8 +146,11 @@ export interface ISessionsTasksService { /** * Runs a task via the task service, looking it up by label in the * workspace folder corresponding to the session worktree. + * + * May resolve to an {@link IDisposable} that stops the launched task; see + * {@link ISessionTaskRunner.runTask}. */ - runTask(task: ITaskEntry, session: ISession): Promise; + runTask(task: ITaskEntry, session: ISession): Promise; /** * Observable label of the pinned task for the given repository. @@ -385,13 +388,14 @@ export class SessionsTasksService extends Disposable implements ISessionsTasksSe } } - async runTask(task: ITaskEntry, session: ISession): Promise { + async runTask(task: ITaskEntry, session: ISession): Promise { const runner = this._taskRunnerRegistry.getRunner(session); if (!runner) { - return; + return undefined; } - await runner.runTask(task, session); + const handle = await runner.runTask(task, session); this._onDidRunTask.fire({ task, session }); + return handle; } getPinnedTaskLabel(repository: URI | undefined): IObservable { diff --git a/src/vs/sessions/contrib/chat/browser/workbenchSessionTaskRunner.ts b/src/vs/sessions/contrib/chat/browser/workbenchSessionTaskRunner.ts index 295d2acc8b9..59a3401701a 100644 --- a/src/vs/sessions/contrib/chat/browser/workbenchSessionTaskRunner.ts +++ b/src/vs/sessions/contrib/chat/browser/workbenchSessionTaskRunner.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Schemas } from '../../../../base/common/network.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { TaskRunSource } from '../../../../workbench/contrib/tasks/common/tasks.js'; import { ITaskService } from '../../../../workbench/contrib/tasks/common/taskService.js'; @@ -40,20 +41,26 @@ export class WorkbenchSessionTaskRunner implements ISessionTaskRunner { return !!this._workspaceContextService.getWorkspaceFolder(cwd); } - async runTask(task: ITaskEntry, session: ISession): Promise { + async runTask(task: ITaskEntry, session: ISession): Promise { const cwd = this._getCwd(session); if (!cwd) { - return; + return undefined; } const workspaceFolder = this._workspaceContextService.getWorkspaceFolder(cwd); if (!workspaceFolder) { - return; + return undefined; } const resolved = await this._taskService.getTask(workspaceFolder, task.label); if (!resolved) { - return; + return undefined; } await this._taskService.run(resolved, undefined, TaskRunSource.User); + + // Hand back a stop handle so auto-dispatched setup/build tasks can be + // terminated when the session is marked done. See #321021. + return toDisposable(() => { + this._taskService.terminate(resolved); + }); } private _getCwd(session: ISession) { diff --git a/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts b/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts index 13a4903735c..475aabd946e 100644 --- a/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts +++ b/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { registerAutorunSelfDisposable } from '../../../../base/common/observable.js'; +import { autorun, registerAutorunSelfDisposable } from '../../../../base/common/observable.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; @@ -31,6 +31,10 @@ export const AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING = 'chat.agentHost.run * {@link ISessionCapabilities.runsWorktreeCreatedTasks}) are skipped to avoid * double-execution. * + * The stop handles returned by the dispatched tasks are tracked per session and + * disposed when the session is marked done (archived) or removed, so the + * long-running setup/build processes don't leak. See #321021. + * * We deliberately ignore sessions that predate this contribution so restored * sessions don't re-run setup tasks when the agents window opens. */ @@ -72,6 +76,8 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe const store = new DisposableStore(); this._sessionDisposables.set(session.sessionId, store); + const taskHandles = store.add(new DisposableStore()); + registerAutorunSelfDisposable(store, reader => { if (session.loading.read(reader)) { return; @@ -83,11 +89,17 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe return; } reader.dispose(); - this._dispatchWorktreeCreatedTasks(session); + this._dispatchWorktreeCreatedTasks(session, taskHandles); }); + + store.add(autorun(reader => { + if (session.isArchived.read(reader)) { + taskHandles.clear(); + } + })); } - private async _dispatchWorktreeCreatedTasks(session: ISession): Promise { + private async _dispatchWorktreeCreatedTasks(session: ISession, taskHandles: DisposableStore): Promise { if (isAgentHostProviderId(session.providerId) && !this._configurationService.getValue(AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING)) { this._logService.trace(`${LOG_PREFIX} Skipping worktreeCreated tasks for agent host session '${session.sessionId}' β€” '${AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING}' is disabled.`); return; @@ -107,7 +119,14 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe } this._logService.trace(`${LOG_PREFIX} Running worktreeCreated task '${task.label}' for session '${session.sessionId}'`); try { - await this._sessionsTasksService.runTask(task, session); + const handle = await this._sessionsTasksService.runTask(task, session); + if (handle) { + if (session.isArchived.get()) { + handle.dispose(); + } else { + taskHandles.add(handle); + } + } } catch (err) { this._logService.warn(`${LOG_PREFIX} Failed to run task '${task.label}' for session '${session.sessionId}': ${err}`); } diff --git a/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts b/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts index 92a48923f70..ccabedbfbb9 100644 --- a/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts @@ -69,6 +69,7 @@ suite('WorkbenchSessionTaskRunner', () => { const store = new DisposableStore(); let runner: WorkbenchSessionTaskRunner; let ranTasks: { label: string }[]; + let terminatedTasks: { label: string }[]; let tasksByLabel: Map; let workspaceFoldersByUri: Map; @@ -77,6 +78,7 @@ suite('WorkbenchSessionTaskRunner', () => { setup(() => { ranTasks = []; + terminatedTasks = []; tasksByLabel = new Map(); workspaceFoldersByUri = new Map(); @@ -93,6 +95,10 @@ suite('WorkbenchSessionTaskRunner', () => { } return undefined; } + override async terminate(task: Task) { + terminatedTasks.push({ label: task._label }); + return { success: true, task }; + } }); instantiationService.stub(IWorkspaceContextService, new class extends mock() { @@ -137,11 +143,23 @@ suite('WorkbenchSessionTaskRunner', () => { registerMockTask('build', worktreeUri); const session = makeSession({ worktree: worktreeUri, repository: repoUri }); - await runner.runTask(makeTask('build'), session); + (await runner.runTask(makeTask('build'), session))?.dispose(); assert.deepStrictEqual(ranTasks, [{ label: 'build' }]); }); + test('returned handle terminates the task via ITaskService', async () => { + registerMockTask('build', worktreeUri); + const session = makeSession({ worktree: worktreeUri, repository: repoUri }); + + const handle = await runner.runTask(makeTask('build'), session); + assert.deepStrictEqual(terminatedTasks, []); + + handle?.dispose(); + + assert.deepStrictEqual(terminatedTasks, [{ label: 'build' }]); + }); + test('runTask is a no-op when task is not registered', async () => { workspaceFoldersByUri.set(worktreeUri.toString(), { uri: worktreeUri, name: 'folder', index: 0, toResource: () => worktreeUri } as IWorkspaceFolder); const session = makeSession({ worktree: worktreeUri, repository: repoUri }); @@ -155,7 +173,7 @@ suite('WorkbenchSessionTaskRunner', () => { registerMockTask('build', repoUri); const session = makeSession({ repository: repoUri }); - await runner.runTask(makeTask('build'), session); + (await runner.runTask(makeTask('build'), session))?.dispose(); assert.deepStrictEqual(ranTasks, [{ label: 'build' }]); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts b/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts index 88b88658a7c..30c4f850b5e 100644 --- a/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { Emitter } from '../../../../../base/common/event.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { Codicon } from '../../../../../base/common/codicons.js'; @@ -25,6 +25,7 @@ interface ITestSession { readonly loading: ReturnType>; readonly status: ReturnType>; readonly workspace: ReturnType>; + readonly isArchived: ReturnType>; } function makeWorkspace(hasWorktree: boolean): ISessionWorkspace { @@ -50,6 +51,7 @@ function makeSession(opts: { id?: string; providerId?: string; runsWorktreeCreat const loading = observableValue('loading', opts.loading ?? false); const status = observableValue('status', opts.status ?? SessionStatus.InProgress); const workspace = observableValue('workspace', makeWorkspace(opts.hasWorktree ?? true)); + const isArchived = observableValue('isArchived', false); const chat = { resource: URI.parse('file:///session') } as IChat; const session: ISession = { sessionId: opts.id ?? 'test:session', @@ -67,7 +69,7 @@ function makeSession(opts: { id?: string; providerId?: string; runsWorktreeCreat modelId: observableValue('modelId', undefined), mode: observableValue('mode', undefined), loading, - isArchived: observableValue('isArchived', false), + isArchived, isRead: observableValue('isRead', true), lastTurnEnd: observableValue('lastTurnEnd', undefined), description: observableValue('description', undefined), @@ -75,7 +77,7 @@ function makeSession(opts: { id?: string; providerId?: string; runsWorktreeCreat mainChat: constObservable(chat), capabilities: { supportsMultipleChats: false, runsWorktreeCreatedTasks: opts.runsWorktreeCreatedTasks }, }; - return { session, loading, status, workspace }; + return { session, loading, status, workspace, isArchived }; } function entry(label: string, runOn?: 'worktreeCreated' | 'folderOpen' | 'default'): ISessionTaskWithTarget { @@ -91,6 +93,7 @@ function entry(label: string, runOn?: 'worktreeCreated' | 'folderOpen' | 'defaul class FakeSessionsTasksService implements Partial { declare readonly _serviceBrand: undefined; readonly ranTasks: { label: string; sessionId: string }[] = []; + readonly stoppedTasks: { label: string; sessionId: string }[] = []; private readonly _tasks = new Map(); runTaskFails = false; @@ -102,11 +105,12 @@ class FakeSessionsTasksService implements Partial { return this._tasks.get(session.sessionId) ?? []; } - async runTask(task: ITaskEntry, session: ISession): Promise { + async runTask(task: ITaskEntry, session: ISession): Promise { this.ranTasks.push({ label: task.label, sessionId: session.sessionId }); if (this.runTaskFails) { throw new Error('simulated launch failure'); } + return toDisposable(() => this.stoppedTasks.push({ label: task.label, sessionId: session.sessionId })); } } @@ -292,4 +296,52 @@ suite('WorktreeCreatedTaskDispatcher', () => { assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'a' }]); }); + + test('stops dispatched tasks when the session is marked done (archived)', async () => { + createDispatcher(); + const { session, workspace, isArchived } = makeSession({ id: 'a', hasWorktree: false }); + tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); + + mgmt.sessionStartedEmitter.fire(session); + workspace.set(makeWorkspace(true), undefined); + await settle(); + assert.deepStrictEqual(tasks.stoppedTasks, []); + + isArchived.set(true, undefined); + await settle(); + + assert.deepStrictEqual(tasks.stoppedTasks, [{ label: 'setup', sessionId: 'a' }]); + }); + + test('stops dispatched tasks when a started session is removed', async () => { + createDispatcher(); + const { session, workspace } = makeSession({ id: 'a', hasWorktree: false }); + tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); + + mgmt.sessionStartedEmitter.fire(session); + workspace.set(makeWorkspace(true), undefined); + await settle(); + assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'a' }]); + + mgmt.sessionsChangedEmitter.fire({ added: [], removed: [session], changed: [] }); + await settle(); + + assert.deepStrictEqual(tasks.stoppedTasks, [{ label: 'setup', sessionId: 'a' }]); + }); + + test('stops a task that finishes launching after the session is archived', async () => { + createDispatcher(); + const { session, workspace, isArchived } = makeSession({ id: 'a', hasWorktree: false }); + tasks.setTasks(session.sessionId, [entry('setup', 'worktreeCreated')]); + + mgmt.sessionStartedEmitter.fire(session); + // Archive before the worktree appears so the task is launched against an + // already-archived session. + isArchived.set(true, undefined); + workspace.set(makeWorkspace(true), undefined); + await settle(); + + assert.deepStrictEqual(tasks.ranTasks, [{ label: 'setup', sessionId: 'a' }]); + assert.deepStrictEqual(tasks.stoppedTasks, [{ label: 'setup', sessionId: 'a' }]); + }); }); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index a97fe663b56..5d4cc783353 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -2,7 +2,7 @@ **Folder:** `src/vs/sessions/contrib/providers/agentHost/` -The agent host provider family backs sessions run by an **agent host** β€” an out-of-process (or in-process) agent runtime that exposes one or more agents (Copilot CLI, Codex, Claude, …) over the agent host protocol (`platform/agentHost`). It is the largest provider in the Agents window and is shared between the local window and remote hosts: +The agent host provider family backs sessions run by an **agent host** β€” an out-of-process (or in-process) agent runtime that exposes one or more agents (Copilot, Codex, Claude, …) over the agent host protocol (`platform/agentHost`). It is the largest provider in the Agents window and is shared between the local window and remote hosts: | Class | File | Purpose | |-------|------|---------| @@ -31,6 +31,7 @@ Registered by `LocalAgentHostContribution` in `browser/localAgentHost.contributi - The same module also wires the heavy lifting from the workbench chat layer at `WorkbenchPhase.AfterRestored`: - `AgentHostContribution` β€” agent discovery, session-handler registration, language-model providers, customization harness (via `IChatSessionsService`). - `AgentHostTerminalContribution` β€” terminal integration for agent host sessions. + - The classic chat sidebar item controller is registered separately in the editor window only; the Agents window does not load or register `AgentHostSessionListController`. - Registers the experimental `chat.agentHost.defaultSessionsProvider` setting (`LocalAgentHostDefaultProviderSettingId`, default `false`, startup experiment). The Electron-only `electron-browser/agentHost.contribution.ts` adds desktop-only wiring on top. @@ -82,7 +83,7 @@ controller and the chat-content path are two unrelated APIs: | API | Responsibility | Used by the Agents window? | |-----|----------------|----------------------------| -| `IChatSessionItemController` (`registerChatSessionItemController`) | Enumerate session **items** (`.items`, `onDidChangeChatSessionItems`) for the **classic** chat sidebar list. | **No.** The agent host `ISessionsProvider` builds its own list via `getSessions()` straight from the connection (`listSessions()` / `notify/sessionAdded` / `rootState`). The workbench `AgentHostSessionListController` still implements this for the classic chat surfaces, but the Agents window never consumes it. | +| `IChatSessionItemController` (`registerChatSessionItemController`) | Enumerate session **items** (`.items`, `onDidChangeChatSessionItems`) for the **classic** chat sidebar list. | **No.** The agent host `ISessionsProvider` builds its own list via `getSessions()` straight from the connection (`listSessions()` / `notify/sessionAdded` / `rootState`). The workbench `AgentHostSessionListController` is registered only for classic chat surfaces in the editor window; the Agents window neither loads nor consumes it. | | `IChatSessionContentProvider` (`registerChatSessionContentProvider`) | Load a session's **chat content** (history/turns) for a resource, provide input completions, and handle the request stream. | **Yes β€” this is the only API on the chat path.** | The classic `ChatWidget` is generic: it renders whatever `IChatModel` it is diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index bfea62dee75..ca876e4ceba 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -1485,7 +1485,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ protected abstract resourceSchemeForProvider(provider: string): string; - /** Format the human-readable label for a session type entry (e.g. `Copilot CLI`). */ + /** Format the human-readable label for a session type entry (e.g. `Copilot`). */ protected abstract _formatSessionTypeLabel(agentLabel: string): string; /** diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 8a9654ac644..a655d3f390f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -198,11 +198,6 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide } protected _formatSessionTypeLabel(agentLabel: string): string { - // Use the unadorned agent label (e.g. "Copilot") rather than tagging it - // with `[Agent Host]`. The session type id is shared with the extension-host - // Copilot CLI provider, so the filter menu / new-session picker entry - // covers both sets of sessions; the `[Agent Host]` tag belongs on the - // per-session workspace label, not the type label. return agentLabel; } diff --git a/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts b/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts index f09179e8e8d..9c9f7d8afd8 100644 --- a/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts +++ b/src/vs/sessions/contrib/terminal/browser/agentHostSessionTaskRunner.ts @@ -5,9 +5,11 @@ import { localize } from '../../../../nls.js'; import { Schemas } from '../../../../base/common/network.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { AGENT_HOST_SCHEME, fromAgentHostUri } from '../../../../platform/agentHost/common/agentHostUri.js'; +import { TerminalExitReason } from '../../../../platform/terminal/common/terminal.js'; import { IAgentHostTerminalService } from '../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; import { ITerminalGroupService, ITerminalService } from '../../../../workbench/contrib/terminal/browser/terminal.js'; import { isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; @@ -43,10 +45,10 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { return this._getAddress(session) !== undefined; } - async runTask(task: ITaskEntry, session: ISession): Promise { + async runTask(task: ITaskEntry, session: ISession): Promise { const address = this._getAddress(session); if (!address) { - return; + return undefined; } const allTasks = await this._sessionsTasksService.getAllTasks(session); @@ -58,7 +60,7 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { const command = resolveTaskCommand(task, { lookup: label => byLabel.get(label) }); if (!command) { this._logService.trace(`${LOG_PREFIX} Skipping task '${task.label}' β€” no command could be resolved.`); - return; + return undefined; } const cwd = this._getCwd(session); @@ -68,12 +70,16 @@ export class AgentHostSessionTaskRunner implements ISessionTaskRunner { }); if (!instance) { this._logService.warn(`${LOG_PREFIX} Failed to create terminal for task '${task.label}' on '${address}'.`); - return; + return undefined; } this._terminalService.setActiveInstance(instance); await this._terminalGroupService.showPanel(true); await instance.sendText(command, /*shouldExecute*/ true); + + return toDisposable(() => { + instance.dispose(TerminalExitReason.User); + }); } private _getAddress(session: ISession): string | undefined { diff --git a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts index 2c1c17a3811..df23ac90b79 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts @@ -72,12 +72,17 @@ suite('AgentHostSessionTaskRunner', () => { let runner: AgentHostSessionTaskRunner; let createdTerminals: { address: string; options?: IAgentHostTerminalCreateOptions }[]; let sentText: { text: string; shouldExecute: boolean }[]; + let disposedTerminals: ITerminalInstance[]; let allTasks: ISessionTaskWithTarget[]; - const fakeInstance = { sendText: async (text: string, shouldExecute: boolean) => { sentText.push({ text, shouldExecute }); } } as ITerminalInstance; + const fakeInstance = { + sendText: async (text: string, shouldExecute: boolean) => { sentText.push({ text, shouldExecute }); }, + dispose: () => { disposedTerminals.push(fakeInstance); }, + } as unknown as ITerminalInstance; setup(() => { createdTerminals = []; sentText = []; + disposedTerminals = []; allTasks = []; const instantiationService = store.add(new TestInstantiationService()); @@ -146,7 +151,7 @@ suite('AgentHostSessionTaskRunner', () => { const cwd = URI.parse('file:///path/to/worktree'); const session = makeSession({ providerId: LOCAL_AGENT_HOST_PROVIDER_ID, cwd }); - await runner.runTask(shellTask(), session); + (await runner.runTask(shellTask(), session))?.dispose(); assert.strictEqual(createdTerminals.length, 1); assert.strictEqual(createdTerminals[0].address, '__local__'); @@ -154,13 +159,24 @@ suite('AgentHostSessionTaskRunner', () => { assert.deepStrictEqual(sentText, [{ text: 'echo hi', shouldExecute: true }]); }); + test('returned handle stops the task by disposing its terminal', async () => { + const session = makeSession({ providerId: LOCAL_AGENT_HOST_PROVIDER_ID, cwd: URI.parse('file:///x') }); + + const handle = await runner.runTask(shellTask(), session); + assert.deepStrictEqual(disposedTerminals, []); + + handle?.dispose(); + + assert.deepStrictEqual(disposedTerminals, [fakeInstance]); + }); + test('agent-host scheme cwds are unwrapped to their original URI', async () => { const innerCwd = URI.parse('file:///remote/path'); const wrapped = toAgentHostUri(innerCwd, 'remote'); assert.strictEqual(wrapped.scheme, AGENT_HOST_SCHEME, 'precondition: wrapped uri'); const session = makeSession({ providerId: 'agenthost-myhost', cwd: wrapped }); - await runner.runTask(shellTask(), session); + (await runner.runTask(shellTask(), session))?.dispose(); assert.strictEqual(createdTerminals.length, 1); assert.strictEqual(createdTerminals[0].options?.cwd?.toString(), innerCwd.toString()); @@ -169,7 +185,7 @@ suite('AgentHostSessionTaskRunner', () => { test('unknown scheme cwds are omitted (host uses default)', async () => { const session = makeSession({ providerId: 'agenthost-myhost', cwd: URI.parse('vscode-vfs://github/owner/repo') }); - await runner.runTask(shellTask(), session); + (await runner.runTask(shellTask(), session))?.dispose(); assert.strictEqual(createdTerminals.length, 1); assert.strictEqual(createdTerminals[0].options?.cwd, undefined); @@ -177,7 +193,7 @@ suite('AgentHostSessionTaskRunner', () => { test('skips when no command can be resolved from the task', async () => { const session = makeSession({ providerId: LOCAL_AGENT_HOST_PROVIDER_ID, cwd: URI.parse('file:///x') }); - await runner.runTask({ label: 'empty' }, session); + (await runner.runTask({ label: 'empty' }, session))?.dispose(); assert.deepStrictEqual(createdTerminals, []); }); @@ -197,7 +213,7 @@ suite('AgentHostSessionTaskRunner', () => { { task: top, target: 'workspace' }, ]; - await runner.runTask(top, session); + (await runner.runTask(top, session))?.dispose(); assert.deepStrictEqual(sentText, [{ text: 'npm run transpile && npm run dev', shouldExecute: true }]); }); diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts index 2e600244cec..fc615a11915 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts @@ -86,6 +86,12 @@ export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { sendChatRequest: async (modelId, messages, from, options, token) => { const requestId = (Math.random() * 1e6) | 0; const defer = new DeferredPromise(); + // `result` mirrors the stream's terminal status and is rejected together with the + // stream on error (see `$reportResponseDone`). Consumers that read the stream let the + // for-await throw and never reach `await response.result`, leaving its rejection (e.g. + // an expected `ChatQuotaExceeded`) unobserved. Attach a no-op handler so it cannot + // surface as an unhandled rejection; real awaiters of `result` still see the error. + defer.p.catch(() => { }); const stream = new AsyncIterableSource(); try { diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index 6d070f141f0..d3283240a1c 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -21,9 +21,11 @@ import '../common/voiceTranscriptStore.js'; import './transcriptsView/voiceTranscripts.contribution.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import * as nls from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -31,6 +33,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { Registry } from '../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + import { IAgentsVoiceWindowService, AgentsVoiceStorageKeys } from '../common/agentsVoice.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; @@ -39,11 +42,18 @@ import { VoiceDisabledClassification, VoiceDisabledEvent, } from '../../chat/browser/voiceClient/voiceTelemetry.js'; import { mainWindow } from '../../../../base/browser/window.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; +import { ChatAgentLocation } from '../../chat/common/constants.js'; // --- Context Keys --- const AGENTS_VOICE_WINDOW_VISIBLE = new RawContextKey('agentsVoiceWindowVisible', false); export const AGENTS_VOICE_WIDGET_FOCUSED = new RawContextKey('agentsVoiceWidgetFocused', false); +const AGENTS_VOICE_CONNECTED = new RawContextKey('agentsVoiceConnected', false); +const AGENTS_VOICE_CONNECTING = new RawContextKey('agentsVoiceConnecting', false); +const AGENTS_VOICE_LISTENING = new RawContextKey('agentsVoiceListening', false); +const AGENTS_VOICE_ACTIVE = new RawContextKey('agentsVoiceActive', false); // --- Context Key Binding --- @@ -57,17 +67,45 @@ class AgentsVoiceContextKeyContribution extends Disposable implements IWorkbench ) { super(); - const contextKey = AGENTS_VOICE_WINDOW_VISIBLE.bindTo(contextKeyService); - contextKey.set(this.agentsVoiceWindowService.isOpen); + const windowKey = AGENTS_VOICE_WINDOW_VISIBLE.bindTo(contextKeyService); + windowKey.set(this.agentsVoiceWindowService.isOpen); this._register(this.agentsVoiceWindowService.onDidChangeOpen(isOpen => { - contextKey.set(isOpen); + windowKey.set(isOpen); })); } } registerWorkbenchContribution2(AgentsVoiceContextKeyContribution.ID, AgentsVoiceContextKeyContribution, WorkbenchPhase.AfterRestored); +// Separate contribution for voice connected state β€” runs later to avoid +// forcing IVoiceSessionController instantiation too early. +class AgentsVoiceConnectedKeyContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentsVoiceConnectedKey'; + + constructor( + @IVoiceSessionController voiceSessionController: IVoiceSessionController, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + const connectedKey = AGENTS_VOICE_CONNECTED.bindTo(contextKeyService); + const connectingKey = AGENTS_VOICE_CONNECTING.bindTo(contextKeyService); + const listeningKey = AGENTS_VOICE_LISTENING.bindTo(contextKeyService); + const activeKey = AGENTS_VOICE_ACTIVE.bindTo(contextKeyService); + this._register(autorun(reader => { + connectedKey.set(voiceSessionController.isConnected.read(reader)); + connectingKey.set(voiceSessionController.isConnecting.read(reader)); + const state = voiceSessionController.voiceState.read(reader); + listeningKey.set(state === 'listening'); + activeKey.set(state === 'listening' || state === 'speaking'); + })); + } +} + +registerWorkbenchContribution2(AgentsVoiceConnectedKeyContribution.ID, AgentsVoiceConnectedKeyContribution, WorkbenchPhase.Eventually); + // --- Telemetry: track enable/disable --- class AgentsVoiceTelemetryContribution extends Disposable implements IWorkbenchContribution { @@ -108,12 +146,22 @@ registerAction2(class extends Action2 { super({ id: 'agentsVoice.toggleWindow', title: nls.localize2('toggleAgentsVoiceWindow', "Voice Mode"), - menu: { + icon: Codicon.openInWindow, + menu: [{ id: MenuId.MenubarViewMenu, group: '5_copilot', order: 1, when: ContextKeyExpr.equals('config.agents.voice.enabled', true), - }, + }, { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + AGENTS_VOICE_CONNECTED.isEqualTo(true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ), + group: 'navigation', + order: 6 + }], toggled: AGENTS_VOICE_WINDOW_VISIBLE.isEqualTo(true), }); } @@ -123,6 +171,172 @@ registerAction2(class extends Action2 { } }); +// Internal command: open the floating window without toggling (used by voice +// controller to surface responses for non-visible sessions). +CommandsRegistry.registerCommand('_agentsVoice.openWindow', async (accessor) => { + const service = accessor.get(IAgentsVoiceWindowService); + if (!service.isOpen) { + await service.openWindow(); + } +}); + +// --- Mic button in Chat toolbar --- +// Shows mic (unfilled) normally, mic-filled when actively listening. +// Click to connect if disconnected, or toggle PTT if connected. +// The disconnect button (shown when connected) indicates active voice mode. + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.connecting', + title: nls.localize2('agentsVoice.connecting', "Connecting..."), + icon: Codicon.loading, + precondition: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + AGENTS_VOICE_CONNECTING.isEqualTo(true), + ), + menu: { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + AGENTS_VOICE_CONNECTING.isEqualTo(true), + ), + group: 'navigation', + order: 4 + } + }); + } + async run(): Promise { + // No-op β€” just a visual indicator + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.startVoiceInChat', + title: nls.localize2('agentsVoice.startVoiceInChat', "Voice Mode"), + icon: Codicon.mic, + precondition: ContextKeyExpr.equals('config.agents.voice.enabled', true), + menu: { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + AGENTS_VOICE_ACTIVE.negate(), + AGENTS_VOICE_CONNECTING.negate(), + ), + group: 'navigation', + order: 4 + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Space, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.inChatInput, + ), + }, + }); + } + async run(accessor: ServicesAccessor): Promise { + const voiceController = accessor.get(IVoiceSessionController); + if (!voiceController.isConnected.get()) { + await voiceController.connect(mainWindow); + } else { + voiceController.pttDown(); + voiceController.pttUp(); + } + } +}); + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.pttStopInChat', + title: nls.localize2('agentsVoice.pttStopInChat', "Voice Mode: Stop Recording"), + icon: Codicon.micFilled, + precondition: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + AGENTS_VOICE_ACTIVE.isEqualTo(true), + ), + menu: { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + AGENTS_VOICE_ACTIVE.isEqualTo(true), + ), + group: 'navigation', + order: 4 + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Space, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.inChatInput, + AGENTS_VOICE_ACTIVE.isEqualTo(true), + ), + }, + }); + } + async run(accessor: ServicesAccessor): Promise { + const voiceController = accessor.get(IVoiceSessionController); + // Stop recording and send + voiceController.pttDown(); + voiceController.pttUp(); + } +}); + +// --- Disconnect Voice (command palette + separate toolbar button when connected) --- + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.disconnect', + title: nls.localize2('agentsVoice.disconnect', "Disconnect Voice Mode"), + icon: Codicon.debugDisconnect, + f1: true, + precondition: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + AGENTS_VOICE_CONNECTED.isEqualTo(true), + ), + menu: { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + AGENTS_VOICE_CONNECTED.isEqualTo(true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ), + group: 'navigation', + order: 5 + }, + }); + } + async run(accessor: ServicesAccessor): Promise { + const voiceController = accessor.get(IVoiceSessionController); + voiceController.disconnect(); + } +}); + +// --- Simulate Voice Connection (dev utility, backend down) --- + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.simulateConnection', + title: nls.localize2('agentsVoice.simulateConnection', "Voice: Simulate Connection (Dev)"), + f1: true, + }); + } + async run(accessor: ServicesAccessor): Promise { + const voiceController = accessor.get(IVoiceSessionController); + voiceController.simulateConnection(); + } +}); + // --- Reset Onboarding Command (dev utility) --- registerAction2(class extends Action2 { @@ -150,7 +364,7 @@ registerAction2(class extends Action2 { precondition: ContextKeyExpr.equals('config.agents.voice.enabled', true), keybinding: { weight: KeybindingWeight.WorkbenchContrib, - primary: KeyCode.Space, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Space, when: ContextKeyExpr.and( AGENTS_VOICE_WIDGET_FOCUSED, ContextKeyExpr.not('inputFocus'), @@ -208,5 +422,13 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.APPLICATION, included: false, }, + 'agents.voice.showTranscript': { + type: 'boolean', + description: nls.localize('agents.voice.showTranscript', "Show the voice transcript overlay in the chat input area while voice mode is active."), + default: true, + scope: ConfigurationScope.APPLICATION, + included: false, + tags: ['advanced'], + }, } }); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts new file mode 100644 index 00000000000..4415a525765 --- /dev/null +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceSessionsPicker.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { IAgentSession } from '../../chat/browser/agentSessions/agentSessionsModel.js'; +import { IAgentSessionsService } from '../../chat/browser/agentSessions/agentSessionsService.js'; +import { AgentSessionsSorter, groupAgentSessionsByDate } from '../../chat/browser/agentSessions/agentSessionsViewer.js'; +import { getSessionDescription, shouldShowSessionInPicker } from '../../chat/browser/agentSessions/agentSessionsPicker.js'; +import { AgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsFilter.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { URI } from '../../../../base/common/uri.js'; + +interface IVoiceSessionPickItem extends IQuickPickItem { + readonly session: IAgentSession; +} + +const setTargetButton: IQuickInputButton = { + iconClass: ThemeIcon.asClassName(Codicon.mic), + tooltip: localize('voiceSessions.setTarget', "Set as voice target") +}; + +/** + * A quickpick that lists agent sessions and allows the user to select one + * as the voice transcription target. Mirrors the pattern of AgentSessionsPicker + * but with a voice-specific action. + */ +export class AgentsVoiceSessionsPicker { + + private readonly sorter = new AgentSessionsSorter(); + + constructor( + private readonly onSelectTarget: (resource: URI) => void, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + @IQuickInputService private readonly quickInputService: IQuickInputService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + async show(): Promise { + const disposables = new DisposableStore(); + const picker = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); + const filter = disposables.add(this.instantiationService.createInstance(AgentSessionsFilter, {})); + + picker.items = this.createPickerItems(filter); + picker.placeholder = localize('voiceSessions.placeholder', "Select a session for voice input"); + + disposables.add(picker.onDidAccept(() => { + const pick = picker.selectedItems[0]; + if (pick) { + this.onSelectTarget(pick.session.resource); + } + picker.hide(); + })); + + disposables.add(picker.onDidTriggerItemButton(e => { + if (e.button === setTargetButton) { + this.onSelectTarget(e.item.session.resource); + picker.hide(); + } + })); + + disposables.add(picker.onDidHide(() => disposables.dispose())); + picker.show(); + } + + private createPickerItems(filter: AgentSessionsFilter): (IVoiceSessionPickItem | IQuickPickSeparator)[] { + const sessions = this.agentSessionsService.model.sessions + .filter(session => shouldShowSessionInPicker(session, filter)) + .sort(this.sorter.compare.bind(this.sorter)); + const items: (IVoiceSessionPickItem | IQuickPickSeparator)[] = []; + + const groupedSessions = groupAgentSessionsByDate(sessions); + for (const group of groupedSessions.values()) { + if (group.sessions.length > 0) { + items.push({ type: 'separator', label: group.label }); + items.push(...group.sessions.map(session => this.toPickItem(session))); + } + } + + return items; + } + + private toPickItem(session: IAgentSession): IVoiceSessionPickItem { + const description = getSessionDescription(session); + + return { + id: session.resource.toString(), + label: session.label, + tooltip: session.tooltip, + description, + iconClass: ThemeIcon.asClassName(session.icon), + buttons: [setTargetButton], + session + }; + } +} diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts index 48cbd17e8a9..6bb8ca63c1d 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWidget.ts @@ -17,7 +17,7 @@ import { createSessionList, type SessionRowData, type SessionGroupData } from '. import { createFeedbackDialog, type FeedbackDialogState } from './components/feedbackDialog.js'; import { createOnboarding } from './components/onboardingComponent.js'; import { createVoiceBar } from './components/voiceBarComponent.js'; -import { FONT_SIZE } from './components/tokens.js'; +import { FONT_SIZE, addKeyboardActivation } from './components/tokens.js'; import type { VoiceState, IPendingToolConfirmation, ITranscriptTurn } from '../../chat/browser/voiceClient/voiceSessionController.js'; export interface VoiceWidgetCallbacks { @@ -44,6 +44,12 @@ export interface VoiceWidgetCallbacks { submitFeedback(feedbackText: string): Promise<{ ok: boolean; error?: string }>; /** Called when the user dismisses the onboarding card. */ onOnboardingCompleted?(): void; + /** + * Optional β€” when provided, the expand chevron opens this picker instead of + * the inline session list. Used by the floating window to show the agent + * sessions quickpick with a "set as voice target" action. + */ + showSessionsPicker?(): void; } /** @@ -92,6 +98,13 @@ export interface VoiceWidgetOptions { * (collapsed) to match the legacy floating aux-window behavior. */ readonly defaultExpanded?: boolean; + /** + * When true, renders the widget in a chat-input-box style layout: + * a rounded bordered container for transcript/placeholder text with a + * toolbar row below for action icons. Matches the chat panel input box + * appearance. + */ + readonly inputBoxLayout?: boolean; } const DEFAULT_OPTIONS: Required = { @@ -109,6 +122,7 @@ const DEFAULT_OPTIONS: Required = { showOnboarding: false, reshowOnboardingOnDisconnect: false, defaultExpanded: false, + inputBoxLayout: false, }; export class AgentsVoiceWidget extends Disposable { @@ -159,6 +173,17 @@ export class AgentsVoiceWidget extends Disposable { private readonly _chevronWrapper: HTMLElement; private readonly _chevronIcon: HTMLElement; + // --- Input box layout elements (created only when inputBoxLayout=true) --- + private readonly _inputBoxContainer: HTMLElement | undefined; + private readonly _inputBoxPlaceholder: HTMLElement | undefined; + private readonly _inputBoxToolbar: HTMLElement | undefined; + private readonly _inputBoxMicBtn: HTMLElement | undefined; + private readonly _inputBoxGearBtn: HTMLElement | undefined; + private readonly _inputBoxConnIndicator: HTMLElement | undefined; + private readonly _inputBoxFeedbackBtn: HTMLElement | undefined; + private readonly _inputBoxSessionsBtn: HTMLElement | undefined; + private readonly _inputBoxCloseBtn: HTMLElement | undefined; + private readonly _options: Required; constructor( @@ -176,10 +201,10 @@ export class AgentsVoiceWidget extends Disposable { const opts = this._options; const widthStyle = opts.width === 'auto' ? 'width:100%;position:relative;' - : `position:absolute;top:0;left:0;width:${opts.width}px;min-height:${AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT}px;`; + : `position:absolute;top:0;left:0;width:${opts.width}px;${opts.inputBoxLayout ? '' : `min-height:${AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT}px;`}`; this._rootDiv = dom.$('div'); - this._rootDiv.style.cssText = `${widthStyle}display:flex;flex-direction:column;user-select:none;font-family:inherit;font-size:${FONT_SIZE.base};color:var(--vscode-foreground);box-sizing:border-box;margin:0;`; + this._rootDiv.style.cssText = `${widthStyle}display:flex;flex-direction:column;user-select:none;font-family:inherit;font-size:${FONT_SIZE.base};color:var(--vscode-foreground);box-sizing:border-box;margin:0;${opts.inputBoxLayout && opts.draggable ? '-webkit-app-region:drag;' : ''}`; this._glowDiv = dom.$('div'); this._glowDiv.style.cssText = 'position:absolute;top:0;left:0;right:0;height:50px;pointer-events:none;z-index:0;'; @@ -206,7 +231,7 @@ export class AgentsVoiceWidget extends Disposable { this._statusTextDiv.style.cssText = `text-align:center;font-size:${FONT_SIZE.body};font-weight:500;color:var(--vscode-foreground);padding:2px 0;`; this._sessionListWrapper = dom.$('div'); - this._sessionListWrapper.style.cssText = 'display:flex;flex-direction:column;'; + this._sessionListWrapper.style.cssText = 'display:flex;flex-direction:column;-webkit-app-region:no-drag;overflow:hidden;'; this._sessionListWrapper.append(this._sessionListComponent.element); this._expandSpacer = dom.$('div'); @@ -218,27 +243,155 @@ export class AgentsVoiceWidget extends Disposable { this._chevronWrapper.style.cssText = 'display:flex;justify-content:center;cursor:pointer;-webkit-app-region:no-drag;'; this._chevronIcon = dom.$('span.codicon'); this._chevronIcon.style.cssText = `font-size:${FONT_SIZE.iconSm};color:var(--vscode-descriptionForeground);`; - this._chevronIcon.addEventListener('mouseenter', () => { this._chevronIcon.style.color = 'var(--vscode-foreground)'; }); - this._chevronIcon.addEventListener('mouseleave', () => { this._chevronIcon.style.color = 'var(--vscode-descriptionForeground)'; }); + this._register(dom.addDisposableListener(this._chevronIcon, 'mouseenter', () => { this._chevronIcon.style.color = 'var(--vscode-foreground)'; })); + this._register(dom.addDisposableListener(this._chevronIcon, 'mouseleave', () => { this._chevronIcon.style.color = 'var(--vscode-descriptionForeground)'; })); this._chevronWrapper.append(this._chevronIcon); - this._chevronWrapper.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); this._expanded.set(!this._expanded.get(), undefined); }); - this._chevronWrapper.addEventListener('keydown', (e) => { + this._register(dom.addDisposableListener(this._chevronWrapper, 'click', (e) => { + e.preventDefault(); e.stopPropagation(); + if (this.callbacks.showSessionsPicker) { + this.callbacks.showSessionsPicker(); + } else { + this._expanded.set(!this._expanded.get(), undefined); + } + })); + this._register(dom.addDisposableListener(this._chevronWrapper, 'keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this._chevronWrapper.click(); } - }); + })); + + // --- Input box layout elements --- + if (opts.inputBoxLayout) { + // Inject processing animation CSS into the document head + // (@property must be at document level to work) + const styleEl = dom.$('style'); + styleEl.textContent = ` + @property --voice-processing-angle { syntax: ''; inherits: false; initial-value: 135deg; } + @keyframes voice-processing-spin { from { --voice-processing-angle: 135deg; } to { --voice-processing-angle: 495deg; } } + .processing { overflow: visible !important; } + .processing::before { + content: ''; position: absolute; inset: -1px; border-radius: inherit; padding: 1px; + background: conic-gradient(from var(--voice-processing-angle), + transparent 0deg, rgba(88,166,255,0.9) 20deg, rgba(88,166,255,1) 30deg, + rgba(88,166,255,0.6) 50deg, transparent 90deg, transparent 360deg); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; mask-composite: exclude; + animation: voice-processing-spin 3s linear infinite; + pointer-events: none; z-index: 2; + } + .processing::after { + content: ''; position: absolute; inset: -1px; border-radius: inherit; padding: 2px; + background: conic-gradient(from var(--voice-processing-angle), + transparent 0deg, rgba(88,166,255,0.5) 25deg, rgba(88,166,255,0.3) 50deg, transparent 90deg, transparent 360deg); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; mask-composite: exclude; + filter: blur(1.5px); animation: voice-processing-spin 3s linear infinite; + pointer-events: none; z-index: 1; + } + `; + getWindow(this.container).document.head.append(styleEl); + + // Rounded bordered container for transcript/placeholder (matches chat-input-container) + this._inputBoxContainer = dom.$('div'); + this._inputBoxContainer.style.cssText = 'box-sizing:border-box;background-color:var(--vscode-input-background);border:1px solid var(--vscode-input-border, transparent);border-radius:var(--vscode-cornerRadius-large, 8px);padding:10px 12px;width:100%;position:relative;min-height:32px;display:flex;align-items:center;-webkit-app-region:no-drag;'; + + this._inputBoxPlaceholder = dom.$('span'); + this._inputBoxPlaceholder.style.cssText = `font-size:${FONT_SIZE.body};color:var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground));user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;`; + this._inputBoxContainer.append(this._inputBoxPlaceholder); + + // Toolbar row below the input box + this._inputBoxToolbar = dom.$('div'); + this._inputBoxToolbar.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 4px 2px;-webkit-app-region:no-drag;'; + + const toolbarBtn = (className: string, ariaLabel: string, title: string): HTMLElement => { + const el = dom.$(`span.codicon.${className}`); + el.role = 'button'; + el.tabIndex = 0; + el.ariaLabel = ariaLabel; + el.title = title; + el.style.cssText = `font-size:${FONT_SIZE.iconSm};color:var(--vscode-descriptionForeground);cursor:pointer;-webkit-app-region:no-drag;padding:2px;`; + this._register(dom.addDisposableListener(el, 'mouseenter', () => { el.style.color = 'var(--vscode-foreground)'; })); + this._register(dom.addDisposableListener(el, 'mouseleave', () => { el.style.color = 'var(--vscode-descriptionForeground)'; })); + addKeyboardActivation(el); + return el; + }; + + // Mic button + this._inputBoxMicBtn = dom.$('span.codicon.codicon-mic'); + this._inputBoxMicBtn.role = 'button'; + this._inputBoxMicBtn.tabIndex = 0; + this._inputBoxMicBtn.ariaLabel = localize('agentsVoice.pushToTalkSpace', "Push to talk (Space)"); + this._inputBoxMicBtn.title = localize('agentsVoice.pushToTalkSpace', "Push to talk (Space)"); + this._inputBoxMicBtn.style.cssText = `font-size:${FONT_SIZE.iconMd};cursor:pointer;-webkit-app-region:no-drag;border-radius:4px;padding:2px;`; + + // Connection indicator + this._inputBoxConnIndicator = toolbarBtn('codicon-debug-connected', + localize('agentsVoice.disconnect', "Disconnect"), + localize('agentsVoice.disconnect', "Disconnect")); + + // Gear button + this._inputBoxGearBtn = toolbarBtn('codicon-gear', + localize('agentsVoice.configureKeybinding', "Configure keybinding"), + localize('agentsVoice.configureKeybinding', "Configure keybinding")); + + // Feedback button + this._inputBoxFeedbackBtn = toolbarBtn('codicon-feedback', + localize('agentsVoice.sendFeedback', "Send feedback"), + localize('agentsVoice.sendFeedback', "Send feedback")); + + // Sessions dropdown button + this._inputBoxSessionsBtn = toolbarBtn('codicon-list-tree', + localize('agentsVoice.sessions', "Sessions"), + localize('agentsVoice.sessions', "Sessions")); + this._register(dom.addDisposableListener(this._inputBoxSessionsBtn, 'click', (e) => { + e.preventDefault(); e.stopPropagation(); + this._expanded.set(!this._expanded.get(), undefined); + })); + + // Close button + this._inputBoxCloseBtn = toolbarBtn('codicon-chrome-minimize', + localize('agentsVoice.minimize', "Minimize"), + localize('agentsVoice.minimize', "Minimize")); + + const toolbarSpacer = dom.$('span'); + toolbarSpacer.style.flex = '1'; + + this._inputBoxToolbar.append( + this._inputBoxMicBtn, + this._inputBoxConnIndicator, + this._inputBoxGearBtn, + toolbarSpacer, + this._inputBoxFeedbackBtn, + this._inputBoxSessionsBtn, + this._inputBoxCloseBtn + ); + } // Assemble: all children are in the DOM; visibility is toggled via display - this._contentDiv.append( - this._onboardingComponent.element, - this._headerComponent.element, - this._voiceBarComponent.element, - this._feedbackDialogComponent.element, - this._statusTextDiv, - this._transcriptComponent.element, - this._statusRowsComponent.element, - this._sessionListWrapper, - this._expandSpacer, - this._chevronWrapper - ); + if (opts.inputBoxLayout) { + this._contentDiv.append( + this._onboardingComponent.element, + this._feedbackDialogComponent.element, + this._inputBoxToolbar!, + this._transcriptComponent.element, + this._sessionListWrapper, + this._statusRowsComponent.element, + this._inputBoxContainer!, + ); + } else { + this._contentDiv.append( + this._onboardingComponent.element, + this._headerComponent.element, + this._voiceBarComponent.element, + this._feedbackDialogComponent.element, + this._statusTextDiv, + this._transcriptComponent.element, + this._statusRowsComponent.element, + this._sessionListWrapper, + this._expandSpacer, + this._chevronWrapper + ); + } this._rootDiv.append(this._glowDiv, this._titleRow, this._contentDiv); this.container.append(this._rootDiv); @@ -256,20 +409,20 @@ export class AgentsVoiceWidget extends Disposable { win.document.addEventListener('keydown', onDocKeydown, true); this._register(toDisposable(() => win.document.removeEventListener('keydown', onDocKeydown, true))); - this.container.addEventListener('keydown', (e: KeyboardEvent) => { + this._register(dom.addDisposableListener(this.container, 'keydown', (e: KeyboardEvent) => { if (!_isTextInput(e.target) && pttKeyCode && e.code === pttKeyCode) { // Prevent repeat keydowns from activating focused child // buttons (role="button" elements fire click on Space). e.preventDefault(); } - }); - this.container.addEventListener('keyup', (e: KeyboardEvent) => { + })); + this._register(dom.addDisposableListener(this.container, 'keyup', (e: KeyboardEvent) => { if (!_isTextInput(e.target) && pttKeyCode && e.code === pttKeyCode) { e.preventDefault(); pttKeyCode = undefined; this.callbacks.pttUp(); } - }); + })); // Hook into pttDown to snapshot which key started PTT. const origPttDown = this.callbacks.pttDown; @@ -369,6 +522,184 @@ export class AgentsVoiceWidget extends Disposable { } private _updateDOM(reader: IReader): void { + if (this._options.inputBoxLayout) { + this._updateDOMInputBoxLayout(reader); + } else { + this._updateDOMClassicLayout(reader); + } + } + + private _updateDOMInputBoxLayout(reader: IReader): void { + const onboarding = this._showOnboarding.read(reader); + const voiceState = this._voiceState.read(reader); + const isConnected = this._isConnected.read(reader); + const isConnecting = this._isConnecting.read(reader); + const isReconnecting = this._isReconnecting.read(reader); + const showConnected = isConnected || isReconnecting; + const opts = this._options; + const showExpanded = this._shouldShowExpanded.read(reader) && opts.showExpandChevron; + + // Adjust root width when sessions are expanded + const baseWidth = typeof opts.width === 'number' ? opts.width : AGENTS_VOICE_WINDOW_DEFAULT_WIDTH; + this._rootDiv.style.width = `${baseWidth}px`; + + // Title row: hidden during onboarding + this._titleRow.style.display = (onboarding || !opts.title) ? 'none' : 'flex'; + + if (onboarding) { + this._onboardingComponent.element.style.display = ''; + this._feedbackDialogComponent.element.style.display = 'none'; + this._inputBoxContainer!.style.display = 'none'; + this._transcriptComponent.element.style.display = 'none'; + this._statusRowsComponent.element.style.display = 'none'; + this._sessionListWrapper.style.display = 'none'; + this._inputBoxToolbar!.style.display = 'none'; + + this._onboardingComponent.update({ + pttKeyLabel: this._pttKeyLabel.read(reader), + isConnecting: this._onboardingPendingConnect.read(reader) || isConnecting, + onGetStarted: (e) => { e.preventDefault(); e.stopPropagation(); this._dismissOnboarding(true); }, + onOpenPttKeySettings: (e) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPttKeySettings(); }, + onOpenPopout: this.callbacks.openPopout ? (e) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPopout?.(); } : undefined, + }); + return; + } + + this._onboardingComponent.element.style.display = 'none'; + + const feedbackState = this._feedbackDialogState.read(reader); + if (feedbackState) { + this._feedbackDialogComponent.element.style.display = ''; + this._feedbackDialogComponent.update({ + onSubmit: (text) => this._submitFeedback(text), + onCancel: () => { this._feedbackDialogState.set(null, undefined); }, + }, feedbackState); + this._inputBoxContainer!.style.display = 'none'; + this._transcriptComponent.element.style.display = 'none'; + this._statusRowsComponent.element.style.display = 'none'; + this._sessionListWrapper.style.display = 'none'; + this._inputBoxToolbar!.style.display = 'none'; + return; + } + + this._feedbackDialogComponent.element.style.display = 'none'; + + // Input box container β€” show transcript inside or placeholder + this._inputBoxContainer!.style.display = 'flex'; + const transcriptTurns = this._transcriptTurns.read(reader); + const hasTranscript = transcriptTurns.some(t => t.text.length > 0 || (t.speaker === 'user' && t.isPartial)); + + // Toggle voice-active glow on the input container (base state; wave animation overrides dynamically) + if (!showConnected || (voiceState !== 'listening' && voiceState !== 'speaking')) { + this._inputBoxContainer!.style.borderColor = 'var(--vscode-input-border, transparent)'; + this._inputBoxContainer!.style.boxShadow = 'none'; + } + + // Toggle processing comet animation when agent is thinking + this._inputBoxContainer!.classList.toggle('processing', voiceState === 'processing'); + + if (hasTranscript) { + if (showExpanded) { + // When expanded, show full transcript component with chat-like styling + this._transcriptComponent.element.style.display = ''; + this._transcriptComponent.element.style.padding = '8px 12px'; + this._transcriptComponent.element.style.borderBottom = '1px solid var(--vscode-widget-border, var(--vscode-input-border, transparent))'; + this._transcriptComponent.update({ turns: transcriptTurns, chatStyle: true }); + // Hide the input box placeholder since transcript is shown above + this._inputBoxPlaceholder!.style.display = 'none'; + } else { + // Show transcript text inside the placeholder (no purple coloring) + this._inputBoxPlaceholder!.style.display = ''; + this._transcriptComponent.element.style.display = 'none'; + this._transcriptComponent.element.style.padding = ''; + this._transcriptComponent.element.style.borderBottom = ''; + const lastTurn = transcriptTurns[transcriptTurns.length - 1]; + this._inputBoxPlaceholder!.textContent = lastTurn?.text ?? ''; + } + } else { + // Show placeholder + this._inputBoxPlaceholder!.style.display = ''; + this._transcriptComponent.element.style.display = 'none'; + const keyLabel = this._pttKeyLabel.read(reader); + if (showConnected) { + this._inputBoxPlaceholder!.textContent = localize('agentsVoice.listening', "Listening"); + } else if (keyLabel) { + this._inputBoxPlaceholder!.textContent = localize('agentsVoice.holdToTalk', "Hold {0} to talk", keyLabel); + } else { + this._inputBoxPlaceholder!.textContent = localize('agentsVoice.clickMicToTalk', "Click mic to talk"); + } + } + + // Status rows β€” hide in inputBoxLayout (no "No active sessions" text needed) + if (!showExpanded) { + this._statusRowsComponent.element.style.display = 'none'; + this._sessionListWrapper.style.display = 'none'; + } else { + this._statusRowsComponent.element.style.display = 'none'; + this._sessionListWrapper.style.display = ''; + // Constrain session list height so toolbar and transcript always remain visible + this._sessionListWrapper.style.maxHeight = '200px'; + this._sessionListWrapper.style.overflowY = 'auto'; + this._sessionListWrapper.style.scrollbarWidth = 'none'; + this._sessionListComponent.update({ + sessions: this._sessions.read(reader), + groups: this._sessionGroups.read(reader), + selectedTarget: this._selectedTargetSession.read(reader), + onOpenSession: (r) => this.callbacks.openSession(r), + onStopSession: (r) => this.callbacks.stopSession(r), + onCancelSession: (r) => this.callbacks.cancelSession(r), + onSelectTarget: (r) => { this._selectedTargetSession.set(r, undefined); this.callbacks.selectTargetSession(r); }, + onNewSession: () => this.callbacks.newSessionAsTarget(), + }); + } + + // Toolbar β€” always visible + this._inputBoxToolbar!.style.display = 'flex'; + + // Mic button β€” always visible (primary action) + this._inputBoxMicBtn!.style.display = ''; + const keyLabel = this._pttKeyLabel.read(reader); + const micTooltip = keyLabel + ? localize('agentsVoice.pushToTalkKey', "Push to talk ({0})", keyLabel) + : localize('agentsVoice.pushToTalk', "Push to talk"); + this._inputBoxMicBtn!.title = micTooltip; + this._inputBoxMicBtn!.ariaLabel = micTooltip; + const micColor = voiceState === 'error' ? 'var(--vscode-editorError-foreground)' + : voiceState === 'listening' ? 'var(--vscode-editorInfo-foreground)' + : voiceState === 'speaking' ? 'var(--vscode-agentsVoice-speakingForeground)' + : 'var(--vscode-descriptionForeground)'; + this._inputBoxMicBtn!.style.color = micColor; + // Toggle filled state when actively listening or speaking + const micFilled = voiceState === 'listening' || voiceState === 'speaking'; + this._inputBoxMicBtn!.classList.toggle('codicon-mic', !micFilled); + this._inputBoxMicBtn!.classList.toggle('codicon-mic-filled', micFilled); + this._inputBoxMicBtn!.onmousedown = (e: MouseEvent) => { e.preventDefault(); this.callbacks.pttDown(); }; + this._inputBoxMicBtn!.onmouseup = () => { this.callbacks.pttUp(); }; + + // Connection indicator β€” visible when connected + this._inputBoxConnIndicator!.style.display = showConnected ? '' : 'none'; + this._inputBoxConnIndicator!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.disconnect(); }; + + // Gear button β€” always visible + this._inputBoxGearBtn!.style.display = ''; + this._inputBoxGearBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.openPttKeySettings(); }; + + // Feedback button β€” always visible + this._inputBoxFeedbackBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this._toggleFeedbackDialog(); }; + + // Sessions button β€” always visible, icon toggles with expanded state + this._inputBoxSessionsBtn!.style.display = ''; + this._inputBoxSessionsBtn!.className = `codicon codicon-${showExpanded ? 'chevron-up' : 'list-tree'}`; + this._inputBoxSessionsBtn!.title = showExpanded + ? localize('agentsVoice.collapseSessions', "Collapse sessions") + : localize('agentsVoice.sessions', "Sessions"); + + // Close button + this._inputBoxCloseBtn!.style.display = opts.showClose ? '' : 'none'; + this._inputBoxCloseBtn!.onclick = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); this.callbacks.closeWindow(); }; + } + + private _updateDOMClassicLayout(reader: IReader): void { const onboarding = this._showOnboarding.read(reader); const voiceState = this._voiceState.read(reader); const opts = this._options; @@ -645,16 +976,19 @@ export class AgentsVoiceWidget extends Disposable { if (!glowActive) { this._glowDiv.style.display = 'none'; + if (this._inputBoxContainer) { + this._inputBoxContainer.style.borderColor = 'var(--vscode-input-border, transparent)'; + this._inputBoxContainer.style.boxShadow = 'none'; + } return; } - this._glowDiv.style.display = ''; const analyser = this.callbacks.getAnalyserNode(); let intensity: number; if (onboarding) { intensity = 0.6; } else if (!analyser) { - intensity = 0; + intensity = 0.3; } else { const dataArray = new Uint8Array(analyser.frequencyBinCount); analyser.getByteFrequencyData(dataArray); @@ -665,6 +999,18 @@ export class AgentsVoiceWidget extends Disposable { intensity = Math.min(1, (sum / dataArray.length) / 80); } + // Animate input box container border/shadow (inputBoxLayout) + if (this._inputBoxContainer) { + const r = (voiceState === 'speaking') ? '163,113,247' : '88,166,255'; + const borderAlpha = 0.4 + intensity * 0.5; + const shadowSpread = 4 + intensity * 12; + const shadowAlpha = 0.15 + intensity * 0.35; + this._inputBoxContainer.style.borderColor = `rgba(${r},${borderAlpha})`; + this._inputBoxContainer.style.boxShadow = `0 0 ${shadowSpread}px rgba(${r},${shadowAlpha}), inset 0 0 ${shadowSpread * 0.4}px rgba(${r},${shadowAlpha * 0.3})`; + } + + // Classic layout glow div + this._glowDiv.style.display = ''; const baseOpacity = 0.15 + intensity * 0.4; const r = (onboarding || voiceState === 'speaking') ? '163,113,247' : '88,166,255'; this._glowDiv.style.background = `radial-gradient(ellipse 40% 70% at 50% 0%, rgba(${r},${baseOpacity}) 0%, transparent 100%), radial-gradient(ellipse 70% 100% at 50% 0%, rgba(${r},${baseOpacity * 0.4}) 0%, transparent 100%)`; diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts index ff464070b72..66d7cba2c13 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts @@ -7,7 +7,6 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../base import { Emitter, Event } from '../../../../base/common/event.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { disposableWindowInterval } from '../../../../base/browser/dom.js'; -import { getZoomFactor } from '../../../../base/browser/browser.js'; import { FileAccess } from '../../../../base/common/network.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -29,8 +28,11 @@ import { IWorkspaceContextService } from '../../../../platform/workspace/common/ import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { editorBackground } from '../../../../platform/theme/common/colorRegistry.js'; +import { inputBackground, inputBorder } from '../../../../platform/theme/common/colors/inputColors.js'; import { AgentsVoiceWidget } from './agentsVoiceWidget.js'; import { bindWidgetToController } from './agentsVoiceWidgetBinding.js'; +import { AgentsVoiceSessionsPicker } from './agentsVoiceSessionsPicker.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; export class AgentsVoiceWindowService extends Disposable implements IAgentsVoiceWindowService { @@ -43,6 +45,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice private _window: IAuxiliaryWindow | undefined; private readonly _windowDisposables = this._register(new DisposableStore()); private readonly _ownershipChannel: BroadcastChannel; + private _resizeTimeout: ReturnType | undefined; get isOpen(): boolean { return !!this._window; @@ -60,17 +63,6 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice } } - /** - * Minimizes a window via a registered command (Electron only). - */ - private async tryMinimizeWindow(targetWindowId: number): Promise { - try { - await this.commandService.executeCommand('_agentsVoice.minimizeWindow', targetWindowId); - } catch { - // Command not registered (e.g. web) β€” ignore - } - } - constructor( @IAuxiliaryWindowService private readonly auxiliaryWindowService: IAuxiliaryWindowService, @IStorageService private readonly storageService: IStorageService, @@ -88,6 +80,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IThemeService private readonly themeService: IThemeService, @IKeybindingService private readonly keybindingService: IKeybindingService, + @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); @@ -144,39 +137,31 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice this._window = auxiliaryWindow; this._auxiliaryWindowRef.value = auxiliaryWindow; - // Minimize the main VS Code window so the floating aux window is the - // primary surface the user interacts with. The aux window stays visible - // because it lives in a separate OS window. We minimize at three points - // to defeat any focus-restore behavior from Electron when the aux is - // shown: immediately, after styles load, and again after a short delay. - const minimizeMain = async () => { - try { - const mainWindowId = mainWindow.vscodeWindowId; - await this.tryMinimizeWindow(mainWindowId); - } catch { - // nativeHostService may not be available (e.g. web); ignore. - } - }; - void minimizeMain(); - auxiliaryWindow.whenStylesHaveLoaded.then(() => { - void minimizeMain(); - setTimeout(() => { void minimizeMain(); }, 250); - }); - const workspace = this.workspaceContextService.getWorkspace(); const projectName = workspace.folders.length > 0 ? workspace.folders[0].name : ''; auxiliaryWindow.window.document.title = projectName ? `Agents Voice β€” ${projectName}` : 'Agents Voice'; auxiliaryWindow.container.style.overflow = 'hidden'; - auxiliaryWindow.container.style.setProperty('--vscode-agents-background', this.themeService.getColorTheme().getColor(editorBackground)?.toString() ?? '#1e1e1e'); auxiliaryWindow.window.document.body.style.setProperty('margin', '0', 'important'); + // Resolve theme colors so the aux window matches the chat input box + const theme = this.themeService.getColorTheme(); + const bgColor = theme.getColor(editorBackground)?.toString() ?? '#1e1e1e'; + const inputBg = theme.getColor(inputBackground)?.toString() ?? '#3C3C3C'; + const inputBd = theme.getColor(inputBorder)?.toString() ?? 'transparent'; + + auxiliaryWindow.container.style.setProperty('--vscode-agents-background', bgColor); + auxiliaryWindow.container.style.backgroundColor = inputBg; + auxiliaryWindow.container.style.border = `1px solid ${inputBd}`; + auxiliaryWindow.container.style.boxSizing = 'border-box'; + auxiliaryWindow.window.document.body.style.setProperty('background-color', inputBg, 'important'); + this._windowDisposables.clear(); // Create the widget β€” aux window uses the default options (draggable, fixed // width, close button, expand chevron, status rows, no status-text label, - // no popout button), but starts in the expanded view by default so the - // user immediately sees the session list when popping out. + // no popout button). Sessions are collapsed by default; the user can + // expand them via the chevron. const widget = new AgentsVoiceWidget(auxiliaryWindow.container, { copilotIconSrc: FileAccess.asBrowserUri('vs/sessions/browser/media/sessions-icon.svg').toString(true), connect: () => { @@ -220,6 +205,10 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice }, selectTargetSession: (resource) => { this.voiceSessionController.setTargetSession(resource); + // Reveal the selected session in the chat panel + if (resource) { + this.commandService.executeCommand('_chat.voice.switchToSession', resource.toString()).catch(() => { /* ignore */ }); + } }, newSessionAsTarget: () => { this.voiceSessionController.newSessionAsTarget(); @@ -233,8 +222,16 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice onResize: () => this._resizeWindow(auxiliaryWindow), openPttKeySettings: () => this.commandService.executeCommand('workbench.action.openGlobalKeybindings', 'agentsVoice.pushToTalk'), submitFeedback: (text) => this.voiceSessionController.submitFeedback(text), + showSessionsPicker: () => { + const picker = this.instantiationService.createInstance( + AgentsVoiceSessionsPicker, + (resource) => this.voiceSessionController.setTargetSession(resource), + ); + picker.show(); + }, }, { - defaultExpanded: true, + defaultExpanded: false, + inputBoxLayout: true, }); this._windowDisposables.add(widget); @@ -255,43 +252,12 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice chatService: this.chatService, })); - // Re-resize when zoom level changes - let lastDpr = auxiliaryWindow.window.devicePixelRatio; - let zoomDebounce: ReturnType | undefined; - const checkZoom = () => { - const currentDpr = auxiliaryWindow.window.devicePixelRatio; - if (Math.abs(currentDpr - lastDpr) > 0.01) { - lastDpr = currentDpr; - if (zoomDebounce) { clearTimeout(zoomDebounce); } - zoomDebounce = setTimeout(() => { - this._resizeWindow(auxiliaryWindow); - }, 200); - } - }; - this._windowDisposables.add(disposableWindowInterval(auxiliaryWindow.window, checkZoom, 500)); - this._windowDisposables.add({ dispose: () => { if (zoomDebounce) { clearTimeout(zoomDebounce); } } }); - // Poll for session updates this.agentSessionsService.model.resolve(undefined); this._windowDisposables.add(disposableWindowInterval(auxiliaryWindow.window, () => { this.agentSessionsService.model.resolve(undefined); }, 3000)); - // Periodically save window bounds - let lastBoundsJson = ''; - this._windowDisposables.add(disposableWindowInterval(auxiliaryWindow.window, () => { - if (!this._window) { return; } - try { - const state = this._window.createState(); - if (state.bounds) { - const posJson = JSON.stringify({ x: state.bounds.x, y: state.bounds.y }); - if (posJson !== lastBoundsJson) { - lastBoundsJson = posJson; - this.storageService.store(AgentsVoiceStorageKeys.WindowBounds, posJson, StorageScope.WORKSPACE, StorageTarget.MACHINE); - } - } - } catch { /* window may have been disposed */ } - }, 1000)); // Clean up when user closes window via OS controls Event.once(auxiliaryWindow.onUnload)(() => { @@ -337,6 +303,17 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice // --- Window sizing --- private _resizeWindow(auxiliaryWindow: IAuxiliaryWindow): void { + // Debounce resize to avoid fighting user drag operations + if (this._resizeTimeout) { + clearTimeout(this._resizeTimeout); + } + this._resizeTimeout = setTimeout(() => { + this._resizeTimeout = undefined; + this._doResizeWindow(auxiliaryWindow); + }, 100); + } + + private _doResizeWindow(auxiliaryWindow: IAuxiliaryWindow): void { // eslint-disable-next-line no-restricted-syntax const pill = auxiliaryWindow.container.querySelector('div') as HTMLElement | null; if (!pill) { return; } @@ -344,28 +321,17 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice const pillWidth = pill.offsetWidth; const pillHeight = pill.offsetHeight; if (pillWidth <= 0 || pillHeight <= 0) { return; } - const zoomFactor = getZoomFactor(auxiliaryWindow.window); - const targetWidth = Math.ceil(pillWidth * zoomFactor); - const targetHeight = Math.ceil(pillHeight * zoomFactor); const currentWidth = auxiliaryWindow.window.outerWidth; const currentHeight = auxiliaryWindow.window.outerHeight; - // Only resize width unconditionally; for height, only grow (never - // shrink) so that manual vertical resizing by the user is preserved. - const newWidth = targetWidth !== currentWidth ? targetWidth : currentWidth; - const newHeight = targetHeight > currentHeight ? targetHeight : currentHeight; - if (newWidth !== currentWidth || newHeight !== currentHeight) { - // Capture position before resize β€” resizeTo can shift the window - // on some platforms (macOS), causing accumulated drift. - const preX = auxiliaryWindow.window.screenX; - const preY = auxiliaryWindow.window.screenY; + if (pillWidth !== currentWidth || pillHeight !== currentHeight) { try { - auxiliaryWindow.window.resizeTo(newWidth, newHeight); - // Restore position if it drifted - const postX = auxiliaryWindow.window.screenX; - const postY = auxiliaryWindow.window.screenY; - if (postX !== preX || postY !== preY) { - auxiliaryWindow.window.moveTo(preX, preY); - } + // Clamp height so window doesn't exceed available screen space. + const screenBottom = auxiliaryWindow.window.screen.availHeight; + const maxHeight = screenBottom - auxiliaryWindow.window.screenY; + const clampedHeight = Math.min(pillHeight, Math.max(maxHeight, AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT)); + // resizeTo only β€” no moveTo. On macOS this keeps top-left fixed, + // window grows/shrinks downward. No visible position change. + auxiliaryWindow.window.resizeTo(pillWidth, clampedHeight); } catch { /* resize may not be supported */ } } } @@ -373,57 +339,25 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice // --- Bounds persistence --- private _defaultBounds(): IRectangle { - const screenWidth = mainWindow.screen?.availWidth ?? 1920; + // Center horizontally within the main VS Code window, near bottom. + const x = Math.round(mainWindow.screenX + (mainWindow.outerWidth - AGENTS_VOICE_WINDOW_DEFAULT_WIDTH) / 2); + const y = mainWindow.screenY + mainWindow.outerHeight - AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT - 100; return { - x: screenWidth - AGENTS_VOICE_WINDOW_DEFAULT_WIDTH - 20, - y: 20, + x, + y, width: AGENTS_VOICE_WINDOW_DEFAULT_WIDTH, height: AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT, }; } - private _isOnScreen(bounds: IRectangle): boolean { - const screen = mainWindow.screen; - const screenLeft = (screen as unknown as { availLeft?: number }).availLeft ?? 0; - const screenTop = (screen as unknown as { availTop?: number }).availTop ?? 0; - const screenWidth = screen?.availWidth ?? 1920; - const screenHeight = screen?.availHeight ?? 1080; - - const minVisible = 50; - const visibleX = Math.min(bounds.x + bounds.width, screenLeft + screenWidth) - Math.max(bounds.x, screenLeft); - const visibleY = Math.min(bounds.y + bounds.height, screenTop + screenHeight) - Math.max(bounds.y, screenTop); - - return visibleX >= minVisible && visibleY >= minVisible; - } - private loadBounds(): IRectangle { - const defaults = this._defaultBounds(); - const raw = this.storageService.get(AgentsVoiceStorageKeys.WindowBounds, StorageScope.WORKSPACE); - if (raw) { - try { - const parsed = JSON.parse(raw); - if (typeof parsed.x === 'number' && typeof parsed.y === 'number') { - const bounds = { x: parsed.x, y: parsed.y, width: defaults.width, height: defaults.height }; - if (this._isOnScreen(bounds)) { - return bounds; - } - } - } catch { /* ignore invalid JSON */ } - } - - return defaults; + // Always compute fresh bounds from the current main window position. + // This ensures the aux window is always centered within VS Code. + return this._defaultBounds(); } - private saveBounds(window: IAuxiliaryWindow): void { - const state = window.createState(); - if (state.bounds) { - this.storageService.store( - AgentsVoiceStorageKeys.WindowBounds, - JSON.stringify({ x: state.bounds.x, y: state.bounds.y }), - StorageScope.WORKSPACE, - StorageTarget.MACHINE - ); - } + private saveBounds(_window: IAuxiliaryWindow): void { + // Bounds persistence disabled β€” always use fresh defaults for now. } } diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts index c78a7be835e..4ab2aee2f2d 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/headerComponent.ts @@ -90,6 +90,15 @@ export function createHeader(): HeaderComponent { connIndicator.append(connDot, connDisc); addKeyboardActivation(connIndicator); + // Placeholder text β€” clickable, shows PTT keybinding + const placeholderText = dom.$('span.voice-placeholder-text'); + placeholderText.role = 'button'; + placeholderText.tabIndex = 0; + placeholderText.style.cssText = `font-size:${FONT_SIZE.body};color:var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground));cursor:pointer;-webkit-app-region:no-drag;user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;`; + placeholderText.addEventListener('mouseenter', () => { placeholderText.style.color = 'var(--vscode-foreground)'; }); + placeholderText.addEventListener('mouseleave', () => { placeholderText.style.color = 'var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground))'; }); + addKeyboardActivation(placeholderText); + // Spacer const spacer = dom.$('span'); spacer.style.flex = '1'; @@ -100,7 +109,7 @@ export function createHeader(): HeaderComponent { localize('agentsVoice.sendFeedback', "Send feedback")); // Popout button - const popoutBtn = hoverButton('codicon-link-external', + const popoutBtn = hoverButton('codicon-open-in-window', localize('agentsVoice.openMiniView', "Open mini-view"), localize('agentsVoice.openMiniView', "Open mini-view")); @@ -116,7 +125,7 @@ export function createHeader(): HeaderComponent { .voice-conn-indicator:hover .voice-conn-disconnect { display: inline-block !important; color: var(--vscode-errorForeground, #f44) !important; } `; - container.append(copilotIcon, micBtn, gearBtn, connIndicator, spacer, feedbackBtn, popoutBtn, closeBtn, connStyle); + container.append(copilotIcon, micBtn, placeholderText, gearBtn, connIndicator, spacer, popoutBtn, closeBtn, connStyle); return { element: container, @@ -127,7 +136,10 @@ export function createHeader(): HeaderComponent { copilotIcon.style.display = props.showCopilotIcon ? '' : 'none'; copilotIcon.src = props.copilotIconSrc; - // Mic color + const showConnected = props.isConnected || props.isReconnecting; + + // Mic button β€” shown only when connected + micBtn.style.display = showConnected ? '' : 'none'; const micColor = props.voiceState === 'error' ? 'var(--vscode-editorError-foreground)' : props.voiceState === 'listening' ? 'var(--vscode-editorInfo-foreground)' : props.voiceState === 'speaking' ? 'var(--vscode-agentsVoice-speakingForeground)' @@ -138,8 +150,17 @@ export function createHeader(): HeaderComponent { micBtn.onmousedown = props.onMicDown; micBtn.onmouseup = () => props.onMicUp(); + // Placeholder text β€” shown when not connected, displays PTT keybinding + placeholderText.style.display = showConnected ? 'none' : ''; + const keyLabel = props.pttKeyLabel; + const holdText = keyLabel + ? localize('agentsVoice.holdToTalk', "Hold {0} to talk", keyLabel) + : localize('agentsVoice.clickMicToTalk', "Click mic to talk"); + placeholderText.textContent = holdText; + placeholderText.ariaLabel = holdText; + placeholderText.onclick = props.onConnectClick; + // Gear - const showConnected = props.isConnected || props.isReconnecting; gearBtn.style.display = props.isConnected ? '' : 'none'; gearBtn.onclick = props.onPttKeyClick; diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts index 0ef1f5831a1..4b5bb407685 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/sessionListComponent.ts @@ -145,10 +145,6 @@ function createSessionRow(session: SessionRowData, props: SessionListProps): HTM actions.setAttribute('data-role', 'actions'); actions.style.cssText = 'display:none;gap:4px;align-items:center;'; - const openBtn = hoverIcon('codicon-link-external', localize('agentsVoice.openSessionAction', "Open session")); - openBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); props.onOpenSession(session.resource); }); - actions.append(openBtn); - if (!session.isIdle) { const stopBtn = hoverIcon('codicon-debug-stop', localize('agentsVoice.stopSessionAction', "Stop session")); stopBtn.addEventListener('mouseenter', () => { stopBtn.style.color = 'var(--vscode-editorError-foreground)'; }); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts b/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts index e21232d1551..7e9ba6b89e1 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/components/transcriptComponent.ts @@ -37,28 +37,30 @@ const TRANSCRIPT_CSS = ` export interface TranscriptProps { readonly turns: readonly ITranscriptTurn[]; + readonly chatStyle?: boolean; } -function createUserTurn(turn: ITranscriptTurn): HTMLElement { +function createUserTurn(turn: ITranscriptTurn, chatStyle?: boolean): HTMLElement { const wrapper = dom.$('div.voice-user-transcript'); wrapper.style.cssText = USER_CONTAINER_STYLE; + const userColor = chatStyle ? 'var(--vscode-foreground)' : COLOR.userTranscript; const inner = dom.$('div'); if (!turn.isPartial) { const span = dom.$('span'); - span.style.color = COLOR.userTranscript; + span.style.color = userColor; span.textContent = turn.text; inner.append(span); } else { const unsure = turn.committed ? turn.text.slice(turn.committed.length) : turn.text; if (turn.committed) { const committedSpan = dom.$('span'); - committedSpan.style.color = COLOR.userTranscript; + committedSpan.style.color = userColor; committedSpan.textContent = turn.committed; inner.append(committedSpan); } const unsureSpan = dom.$('span'); - unsureSpan.style.cssText = `color:${COLOR.userTranscript};opacity:0.6;font-style:italic;animation:textPulse 1.5s ease-in-out infinite;`; + unsureSpan.style.cssText = `color:${userColor};opacity:0.6;font-style:italic;animation:textPulse 1.5s ease-in-out infinite;`; unsureSpan.textContent = unsure; const cursor = dom.$('span'); cursor.style.fontStyle = 'normal'; @@ -71,9 +73,13 @@ function createUserTurn(turn: ITranscriptTurn): HTMLElement { return wrapper; } -function createAssistantTurn(turn: ITranscriptTurn): HTMLElement { +function createAssistantTurn(turn: ITranscriptTurn, chatStyle?: boolean): HTMLElement { const el = dom.$('div'); - el.style.cssText = ASSISTANT_STYLE; + if (chatStyle) { + el.style.cssText = ASSISTANT_STYLE.replace(`color:${COLOR.assistantTranscript}`, 'color:var(--vscode-descriptionForeground)'); + } else { + el.style.cssText = ASSISTANT_STYLE; + } el.textContent = turn.text; return el; } @@ -101,6 +107,10 @@ export function createTranscript(): TranscriptComponent { visible[visible.length - 2].speaker === 'assistant') { visible = [visible[visible.length - 1]]; } + // In chat style, only show the most recent turn (matches collapsed behavior) + if (props.chatStyle && visible.length > 0) { + visible = [visible[visible.length - 1]]; + } dom.clearNode(container); if (visible.length === 0) { container.style.display = 'none'; @@ -108,7 +118,7 @@ export function createTranscript(): TranscriptComponent { } container.style.display = 'flex'; for (const turn of visible) { - container.append(turn.speaker === 'user' ? createUserTurn(turn) : createAssistantTurn(turn)); + container.append(turn.speaker === 'user' ? createUserTurn(turn, props.chatStyle) : createAssistantTurn(turn, props.chatStyle)); } } }; diff --git a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts index 4b1fc76d3d1..6b1d6339366 100644 --- a/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts +++ b/src/vs/workbench/contrib/agentsVoice/common/agentsVoice.ts @@ -11,8 +11,8 @@ import './agentsVoiceColors.js'; // Register custom voice theme colors /** * Default dimensions for the Agents Voice floating window. */ -export const AGENTS_VOICE_WINDOW_DEFAULT_WIDTH = 220; -export const AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT = 100; +export const AGENTS_VOICE_WINDOW_DEFAULT_WIDTH = 400; +export const AGENTS_VOICE_WINDOW_DEFAULT_HEIGHT = 70; /** * Storage keys for persisting window state across restarts. diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index a3d4b57adcd..d48676bc038 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -38,6 +38,7 @@ import { getAgentSessionProvider, AgentSessionProviders, AgentSessionTarget } fr import { getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; import { ctxHasEditorModification, ctxHasRequestInProgress, ctxIsGlobalEditingSession } from '../chatEditing/chatEditingEditorContextKeys.js'; import { ACTION_ID_NEW_CHAT, CHAT_CATEGORY, clearChatSessionPreservingType, handleCurrentEditingSession, handleModeSwitch } from './chatActions.js'; +import { IVoiceSessionController } from '../voiceClient/voiceSessionController.js'; import { CreateRemoteAgentJobAction } from './chatContinueInAction.js'; export interface IVoiceChatExecuteActionContext { @@ -979,6 +980,7 @@ export class CancelAction extends Action2 { const logService = accessor.get(ILogService); const telemetryService = accessor.get(ITelemetryService); const widget = context?.widget ?? widgetService.lastFocusedWidget; + const voiceController = accessor.get(IVoiceSessionController); if (!widget) { telemetryService.publicLog2(ChatStopCancellationNoopEventName, { source: 'cancelAction', @@ -1002,6 +1004,11 @@ export class CancelAction extends Action2 { }); logService.info('ChatCancelAction#run: Canceled chat widget has no view model'); } + // Also disconnect voice session if active + + if (voiceController.isConnected.get()) { + voiceController.disconnect(); + } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 02a0f33e649..b6c080030a2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -7,9 +7,8 @@ import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; -import { type URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; -import { AgentHostEnabledSettingId, claudePreferAgentHostSettingId, IAgentHostService, shouldSurfaceLocalAgentHostProvider, type AgentProvider, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostEnabledSettingId, claudePreferAgentHostSettingId, IAgentHostService, shouldSurfaceLocalAgentHostProvider, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; import { type ProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type AgentInfo, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -30,7 +29,6 @@ import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthent import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from './agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from './agentHostSessionHandler.js'; import { IAgentHostActiveClientService } from './agentHostActiveClientService.js'; -import { AgentHostSessionListController, IAgentHostSessionListConnection } from './agentHostSessionListController.js'; import { AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; const LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX = 'agent-host-'; @@ -79,57 +77,12 @@ function getLocalAgentHostProviderForSessionType(sessionType: string): AgentProv return sessionType.slice(LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX.length) || undefined; } -/** - * Shared session-list connection used by all local agent-host list controllers. - * - * The agent host exposes a single provider-agnostic `listSessions()` RPC, while - * the workbench registers one {@link AgentHostSessionListController} per agent - * provider. Those controllers can refresh at the same time during startup, - * reconnect, or workspace changes. This wrapper keeps the controller coupled - * only to the minimal list-session surface and joins concurrent refreshes onto - * one in-flight `listSessions()` request so the agent host does not repeat the - * same session enumeration work for every provider. - */ -export class CoalescingAgentHostSessionListConnection implements IAgentHostSessionListConnection { - - private _listSessionsInFlight: Promise | undefined; - - constructor( - private readonly _delegate: IAgentHostService, - ) { } - - get onDidNotification(): IAgentHostSessionListConnection['onDidNotification'] { - return this._delegate.onDidNotification; - } - - disposeSession(session: URI): Promise { - return this._delegate.disposeSession(session); - } - - listSessions(): Promise { - if (this._listSessionsInFlight) { - return this._listSessionsInFlight; - } - - const request = this._delegate.listSessions(); - this._listSessionsInFlight = request; - const clear = () => { - if (this._listSessionsInFlight === request) { - this._listSessionsInFlight = undefined; - } - }; - request.then(clear, clear); - return request; - } -} - export { AgentHostSessionHandler } from './agentHostSessionHandler.js'; -export { AgentHostSessionListController } from './agentHostSessionListController.js'; /** * Discovers available agents from the agent host process and dynamically * registers each one as a chat session type with its own session handler, - * list controller, and language model provider. + * customization harness, and language model provider. * * Gated on the `chat.agentHost.enabled` setting. */ @@ -140,9 +93,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr private readonly _agentRegistrations = this._register(new DisposableMap()); /** Model providers keyed by agent provider, for pushing model updates. */ private readonly _modelProviders = new Map(); - /** List controllers keyed by agent provider, for cache resets on reconnect. */ - private readonly _listControllers = new Map(); - private readonly _sessionListConnection: CoalescingAgentHostSessionListConnection; /** Dedupes redundant `authenticate` RPCs when the resolved token hasn't changed. */ private readonly _authTokenCache = new AgentHostAuthTokenCache(); @@ -165,10 +115,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, ) { super(); - this._isSessionsWindow = environmentService.isSessionsWindow; this._enableSmokeTestDriver = !!environmentService.enableSmokeTestDriver; - this._sessionListConnection = new CoalescingAgentHostSessionListConnection(this._agentHostService); if (!this._configurationService.getValue(AgentHostEnabledSettingId)) { return; @@ -183,13 +131,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr // Clear the auth cache whenever the local agent host (re)starts so the // first post-restart authenticate RPC is never skipped as "unchanged". - // Also reset each list controller's session cache so the next refresh - // re-fetches via listSessions() rather than serving a stale in-memory list. this._register(this._agentHostService.onAgentHostStart(() => { this._authTokenCache.clear(); - for (const controller of this._listControllers.values()) { - controller.resetCache(); - } })); // Process initial root state if already available @@ -273,14 +216,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr const agentId = sessionType; const vendor = sessionType; - // In the Agents app, the agent-host displayName is unambiguous because - // only agent-host sessions exist there. In VS Code, the same picker - // also lists the extension-host harness with the same displayName - // (e.g. "Copilot CLI"), so suffix with "- Agent Host" to disambiguate. - const displayName = this._isSessionsWindow - ? agent.displayName - : localize('agentHost.displayName', "{0} - Agent Host", agent.displayName); - // Chat session contribution. // Keep the delegation picker available for local agent host sessions in // both VS Code and the Agents app so users can hand off (continue) their @@ -288,7 +223,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr store.add(this._chatSessionsService.registerChatSessionContribution({ type: sessionType, name: agentId, - displayName, + displayName: agent.displayName, description: agent.description, customAgentTarget: this._isSessionsWindow ? undefined : Target.GitHubCopilot, canDelegate: true, @@ -303,12 +238,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr }, })); - // Session list controller - const listController = store.add(this._instantiationService.createInstance(AgentHostSessionListController, sessionType, agent.provider, this._sessionListConnection, undefined, 'local')); - this._listControllers.set(agent.provider, listController); - store.add({ dispose: () => this._listControllers.delete(agent.provider) }); - store.add(this._chatSessionsService.registerChatSessionItemController(sessionType, listController)); - const agentRegistration = store.add(this._activeClientService.registerForAgent(sessionType)); const syncProvider = agentRegistration.syncProvider; @@ -334,7 +263,6 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr description: agent.description, connection: this._agentHostService, connectionAuthority: 'local', - isNewSession: sessionResource => listController.isNewSession(sessionResource), resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(resources), })); store.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index dd11c1b5e2f..27551174d06 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -11,7 +11,7 @@ import { BaseActionViewItem } from '../../../../../../base/browser/ui/actionbar/ import { Delayer } from '../../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; @@ -220,13 +220,12 @@ export function resolveConfigChipValue(isUntitled: boolean, serverValue: unknown */ export class AgentHostChatInputPicker extends Disposable { + private _container: HTMLElement | undefined; + private _initialResolved: { readonly sessionResource: URI; readonly result: ResolveSessionConfigResult } | undefined; + private readonly _initialResolveCts = this._registerInitialResolveCts(); private readonly _renderDisposables = this._register(new DisposableStore()); private readonly _filterDelayer = this._register(new Delayer[]>(200)); private readonly _subRef = this._register(new MutableDisposable; readonly backendSession: URI }>()); - private _container: HTMLElement | undefined; - - private _initialResolved: { readonly sessionResource: URI; readonly result: ResolveSessionConfigResult } | undefined; - private readonly _initialResolveCts = this._register(new MutableDisposable()); constructor( private readonly _widget: IChatWidget, @@ -257,6 +256,15 @@ export class AgentHostChatInputPicker extends Disposable { this._reattach(); } + private _registerInitialResolveCts(): MutableDisposable { + const cts = new MutableDisposable(); + this._register(toDisposable(() => { + this._container = undefined; + this._cancelInitialResolve(); + })); + return this._register(cts); + } + render(container: HTMLElement): void { this._container = container; container.classList.add('agent-host-chat-input-picker-host'); @@ -346,7 +354,7 @@ export class AgentHostChatInputPicker extends Disposable { } private _renderChip(): void { - if (!this._container) { + if (!this._container || this._renderDisposables.isDisposed) { return; } this._renderDisposables.clear(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 428c24f9561..c45e402d223 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -67,7 +67,7 @@ import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderSe import { AgentHostSnapshotController } from './agentHostSnapshotController.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; -import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, usageInfoToQuotas, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; export { toolDataToDefinition }; // ============================================================================= @@ -1577,6 +1577,32 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC opts.sink([usage]); })); + // Surface the account quota snapshots the agent host reports on each model-call usage event + // into the entitlement service, keeping the quota UI current for agent-host sessions (mirrors + // the extension-host CLI path). `acceptQuotas` replaces state, so shallow-merge the top-level + // container and deep-merge each per-category snapshot to preserve fields the usage event + // doesn't carry (e.g. `hasQuota`, `usageBasedBilling` from a prior full entitlement fetch). + let lastQuotaSignature: string | undefined; + store.add(autorun(reader => { + const quotaUpdate = usageInfoToQuotas(usage$.read(reader)); + if (!quotaUpdate) { + return; + } + const signature = JSON.stringify(quotaUpdate); + if (signature === lastQuotaSignature) { + return; + } + lastQuotaSignature = signature; + const existing = this._chatEntitlementService.quotas; + this._chatEntitlementService.acceptQuotas({ + ...existing, + ...quotaUpdate, + chat: quotaUpdate.chat ? { ...existing.chat, ...quotaUpdate.chat } : existing.chat, + completions: quotaUpdate.completions ? { ...existing.completions, ...quotaUpdate.completions } : existing.completions, + premiumChat: quotaUpdate.premiumChat ? { ...existing.premiumChat, ...quotaUpdate.premiumChat } : existing.premiumChat, + }); + })); + store.add(autorunPerKeyedItem( inputRequests$, ir => ir.id, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts new file mode 100644 index 00000000000..2f3265079d3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { type URI } from '../../../../../../base/common/uri.js'; +import { AgentHostEnabledSettingId, claudePreferAgentHostSettingId, IAgentHostService, shouldSurfaceLocalAgentHostProvider, type AgentProvider, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; +import { type AgentInfo, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution } from '../../../../../common/contributions.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; +import { IAgentHostSessionWorkingDirectoryResolver } from './agentHostSessionWorkingDirectoryResolver.js'; +import { AgentHostSessionListController, IAgentHostSessionListConnection } from './agentHostSessionListController.js'; + +/** + * Shared session-list connection used by all local agent-host list controllers. + * + * The agent host exposes a single provider-agnostic `listSessions()` RPC, while + * the workbench registers one {@link AgentHostSessionListController} per agent + * provider. Those controllers can refresh at the same time during startup, + * reconnect, or workspace changes. This wrapper keeps the controller coupled + * only to the minimal list-session surface and joins concurrent refreshes onto + * one in-flight `listSessions()` request so the agent host does not repeat the + * same session enumeration work for every provider. + */ +export class CoalescingAgentHostSessionListConnection implements IAgentHostSessionListConnection { + + private _listSessionsInFlight: Promise | undefined; + + constructor( + private readonly _delegate: IAgentHostService, + ) { } + + get onDidNotification(): IAgentHostSessionListConnection['onDidNotification'] { + return this._delegate.onDidNotification; + } + + disposeSession(session: URI): Promise { + return this._delegate.disposeSession(session); + } + + listSessions(): Promise { + if (this._listSessionsInFlight) { + return this._listSessionsInFlight; + } + + const request = this._delegate.listSessions(); + this._listSessionsInFlight = request; + const clear = () => { + if (this._listSessionsInFlight === request) { + this._listSessionsInFlight = undefined; + } + }; + request.then(clear, clear); + return request; + } +} + +export class AgentHostSessionListContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.agentHostSessionListContribution'; + + private readonly _agentRegistrations = this._register(new DisposableMap()); + private readonly _listControllers = new Map(); + private readonly _sessionListConnection: CoalescingAgentHostSessionListConnection; + + private readonly _isSessionsWindow: boolean; + + constructor( + @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, + @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, + ) { + super(); + + this._isSessionsWindow = environmentService.isSessionsWindow; + this._sessionListConnection = new CoalescingAgentHostSessionListConnection(this._agentHostService); + + if (this._isSessionsWindow || !this._configurationService.getValue(AgentHostEnabledSettingId)) { + return; + } + + this._register(this._agentHostService.rootState.onDidChange(rootState => { + this._handleRootStateChange(rootState); + })); + + this._register(this._agentHostService.onAgentHostStart(() => { + for (const controller of this._listControllers.values()) { + controller.resetCache(); + } + })); + + const initialRootState = this._agentHostService.rootState.value; + if (initialRootState && !(initialRootState instanceof Error)) { + this._handleRootStateChange(initialRootState); + } + + this._register(this._configurationService.onDidChangeConfiguration(e => { + const relevantSetting = claudePreferAgentHostSettingId(this._isSessionsWindow); + if (!e.affectsConfiguration(relevantSetting)) { + return; + } + const current = this._agentHostService.rootState.value; + if (current && !(current instanceof Error)) { + this._handleRootStateChange(current); + } + })); + } + + private _shouldRegisterAgent(provider: AgentProvider): boolean { + return shouldSurfaceLocalAgentHostProvider(provider, this._configurationService, this._isSessionsWindow); + } + + private _handleRootStateChange(rootState: RootState): void { + const allowed = rootState.agents.filter(agent => this._shouldRegisterAgent(agent.provider)); + const incoming = new Set(allowed.map(agent => agent.provider)); + + for (const [provider] of this._agentRegistrations) { + if (!incoming.has(provider)) { + this._agentRegistrations.deleteAndDispose(provider); + } + } + + for (const agent of allowed) { + if (!this._agentRegistrations.has(agent.provider)) { + this._registerAgent(agent); + } + } + } + + private _registerAgent(agent: AgentInfo): void { + const store = new DisposableStore(); + this._agentRegistrations.set(agent.provider, store); + + const sessionType = `agent-host-${agent.provider}`; + const listController = store.add(this._instantiationService.createInstance(AgentHostSessionListController, sessionType, agent.provider, this._sessionListConnection, undefined, 'local')); + this._listControllers.set(agent.provider, listController); + store.add(toDisposable(() => this._listControllers.delete(agent.provider))); + + store.add(this._chatSessionsService.registerChatSessionItemController(sessionType, listController)); + store.add(this._workingDirectoryResolver.registerResolver(sessionType, _sessionResource => undefined, sessionResource => listController.isNewSession(sessionResource))); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 9f130e129aa..9a8babb193c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -17,13 +17,14 @@ import { isViewUnreviewedCommentsTool } from '../../../../../../platform/agentHo import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type ChatExternalEditKind, type ChatMcpAppData, type IChatAgentFeedbackReviewConfirmationData, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; +import { type IQuotaSnapshot } from '../../../../../services/chat/common/chatEntitlementService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { type IChatRequestVariableData } from '../../../common/model/chatModel.js'; import { AgentHostCompletionReferenceKind, restorePasteVariableEntryFromAttachment, toAgentHostCompletionVariableEntryFromMetadata, type IAgentFeedbackVariableEntry, type IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { type IToolConfirmationMessages, type IToolData, type IToolResult, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; import { MCP } from '../../../../mcp/common/modelContextProtocol.js'; import { basename, isEqual } from '../../../../../../base/common/resources.js'; -import { hasKey } from '../../../../../../base/common/types.js'; +import { hasKey, type Mutable } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import type { IRange } from '../../../../../../editor/common/core/range.js'; @@ -193,6 +194,94 @@ function getCopilotCredits(usage: UsageInfo | undefined): number | undefined { : undefined; } +/** + * A partial quota update derived from a usage report's `_meta.quotaSnapshots`. Structurally a + * subset of the entitlement service's quota state, so callers merge it onto the existing quotas. + */ +export interface IAgentHostQuotaUpdate { + readonly chat?: IQuotaSnapshot; + readonly completions?: IQuotaSnapshot; + readonly premiumChat?: IQuotaSnapshot; + readonly additionalUsageEnabled?: boolean; + readonly additionalUsageCount?: number; + readonly resetDate?: string; +} + +type AccountQuotaSnapshot = NonNullable[string]>; + +function mapAccountQuotaSnapshot(snapshot: AccountQuotaSnapshot): IQuotaSnapshot | undefined { + const unlimited = snapshot.isUnlimitedEntitlement ?? false; + const entitlement = typeof snapshot.entitlementRequests === 'number' ? snapshot.entitlementRequests : undefined; + + // Skip categories with no allocated entitlement (e.g. free-tier premium with 0 credits), + // mirroring `parseQuotas` so we don't surface an empty premium bucket. + if (!unlimited && entitlement === 0) { + return undefined; + } + + // `remainingPercentage` is required to express a usable snapshot. Treat its absence as + // "no data" and skip the category rather than defaulting to 0, which would otherwise + // masquerade as an exhausted quota (matching `parseQuotas`, where `percent_remaining` is required). + if (typeof snapshot.remainingPercentage !== 'number') { + return undefined; + } + + const used = typeof snapshot.usedRequests === 'number' ? snapshot.usedRequests : undefined; + const resetAt = snapshot.resetDate ? Date.parse(snapshot.resetDate) : NaN; + return { + percentRemaining: Math.min(100, Math.max(0, snapshot.remainingPercentage)), + unlimited, + entitlement: !unlimited && entitlement !== undefined && entitlement >= 0 ? entitlement : undefined, + quotaRemaining: !unlimited && entitlement !== undefined && used !== undefined ? Math.max(0, entitlement - used) : undefined, + resetAt: Number.isFinite(resetAt) ? resetAt : undefined, + }; +} + +/** + * Maps the per-category quota snapshots carried on a usage report's `_meta.quotaSnapshots` + * (reported by the model-call usage event) into a partial quota update for the entitlement + * service. Returns `undefined` when no usable snapshot is present. + */ +export function usageInfoToQuotas(usage: UsageInfo | undefined): IAgentHostQuotaUpdate | undefined { + const meta = usage?._meta as UsageInfoMeta | undefined; + const snapshots = meta?.quotaSnapshots; + if (!snapshots) { + return undefined; + } + + const update: Mutable = {}; + let hasAny = false; + + const chat = snapshots['chat'] && mapAccountQuotaSnapshot(snapshots['chat']); + if (chat) { + update.chat = chat; + hasAny = true; + } + const completions = snapshots['completions'] && mapAccountQuotaSnapshot(snapshots['completions']); + if (completions) { + update.completions = completions; + hasAny = true; + } + const premiumRaw = snapshots['premium_interactions']; + const premiumChat = premiumRaw && mapAccountQuotaSnapshot(premiumRaw); + if (premiumChat) { + update.premiumChat = premiumChat; + hasAny = true; + } + if (premiumRaw) { + update.additionalUsageEnabled = premiumRaw.overageAllowedWithExhaustedQuota ?? false; + update.additionalUsageCount = typeof premiumRaw.overage === 'number' ? premiumRaw.overage : 0; + hasAny = true; + } + + const resetDate = premiumRaw?.resetDate ?? snapshots['chat']?.resetDate; + if (resetDate) { + update.resetDate = resetDate; + } + + return hasAny ? update : undefined; +} + /** * Converts completed turns from the protocol state into session history items. * diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 4ea1b6e841d..29ffd11e263 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -73,7 +73,7 @@ export function getAgentSessionProviderName(provider: AgentSessionTarget): strin case AgentSessionProviders.Growth: return 'Growth'; case AgentSessionProviders.AgentHostCopilot: - return localize('chat.session.providerLabel.agentHostCopilot', "Copilot CLI [Agent Host]"); + return localize('chat.session.providerLabel.agentHostCopilot', "Copilot"); default: return provider; } diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index 57d4bcd91da..995ec1fa9a6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -7,7 +7,7 @@ import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.j import { safeIntl } from '../../../../base/common/date.js'; import { localize } from '../../../../nls.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { ChatEntitlement, IChatEntitlementService, IQuotaSnapshot, IRateLimitSnapshot } from '../../../services/chat/common/chatEntitlementService.js'; import { isSelectedModelCopilot, SELECTED_MODEL_STORAGE_KEY_PREFIX } from '../common/chatSelectedModel.js'; @@ -17,6 +17,14 @@ import { ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotifi const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; const THRESHOLDS = [50, 75, 90, 95]; +/** + * Persisted flag remembering that the user dismissed the quota-exceeded + * notification. Kept until quota recovers (credit becomes available again) so + * the banner does not re-appear on every window reload while quota is still + * exhausted. + */ +const QUOTA_EXHAUSTED_DISMISSED_STORAGE_KEY = 'chat.quotaNotification.exhaustedDismissed'; + /** * Core-side workbench contribution that shows chat input notifications for * quota exhaustion and quota-approaching thresholds. @@ -69,6 +77,15 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } })); + // Remember when the user dismisses the quota-exceeded notification so it + // does not re-appear on the next window reload while quota is still + // exhausted. The flag is cleared from `_update` once quota recovers. + this._register(this._chatInputNotificationService.onDidDismiss(id => { + if (id === QUOTA_NOTIFICATION_ID && this._showingExhausted) { + this._setExhaustedDismissed(); + } + })); + // Check initial state in case quota is already exhausted at startup this._update(); } @@ -101,6 +118,16 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo const entitlement = this._chatEntitlementService.entitlement; const isCopilot = this._isCopilotModelSelected(); + // Once quota recovers (credit is positively available again) drop any + // persisted dismissal so the quota-exceeded notification can show the next + // time quota runs out. Done before the Copilot/BYOK gate so a recovery is + // always observed, even while a BYOK model is selected. Guarded on a + // present snapshot so the transient "no quota data yet" state at + // startup/reload does not wipe the flag. + if (this._isQuotaKnownAvailable()) { + this._clearExhaustedDismissed(); + } + // Defer new notifications when a BYOK model is selected or the model // selection hasn't loaded yet β€” quota only applies to Copilot models. // Already-shown notifications stay visible. @@ -115,7 +142,9 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo // authoritative signal that the org has exceeded its budget, regardless of // overages or remaining quota. if (this._isManagedPlan(entitlement) && this._isManagedPlanBlocked()) { - this._showManagedPlanBlockedNotification(); + if (!this._isExhaustedDismissed()) { + this._showManagedPlanBlockedNotification(); + } return; } @@ -126,14 +155,16 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo const wasAdditionalUsageEnabled = this._prevAdditionalUsageEnabled; this._prevAdditionalUsageEnabled = additionalUsageEnabled; - if (additionalUsageEnabled) { - // Show overage notification on a live transition to 100%, - // or when overages are enabled while already at 100%. - if (this._prevQuotaPercentUsed !== undefined || wasAdditionalUsageEnabled === false) { - this._showOverageActivationNotification(); + if (!this._isExhaustedDismissed()) { + if (additionalUsageEnabled) { + // Show overage notification on a live transition to 100%, + // or when overages are enabled while already at 100%. + if (this._prevQuotaPercentUsed !== undefined || wasAdditionalUsageEnabled === false) { + this._showOverageActivationNotification(); + } + } else { + this._showExhaustedNotification(); } - } else { - this._showExhaustedNotification(); } // Keep the baseline up-to-date so that recovery from exhaustion @@ -410,4 +441,28 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._showingExhausted = false; this._chatInputNotificationService.deleteNotification(QUOTA_NOTIFICATION_ID); } + + // --- Exhausted dismissal persistence ------------------------------------ + + /** + * Returns `true` only when there is an actual quota snapshot indicating that + * credit is available (i.e. quota is not used up). Returns `false` when no + * snapshot has loaded yet, so the transient "no data" state at startup/reload + * is not mistaken for recovery. + */ + private _isQuotaKnownAvailable(): boolean { + return !!this._getRelevantSnapshot() && !this._isQuotaUsedUp(); + } + + private _isExhaustedDismissed(): boolean { + return this._storageService.getBoolean(QUOTA_EXHAUSTED_DISMISSED_STORAGE_KEY, StorageScope.APPLICATION, false); + } + + private _setExhaustedDismissed(): void { + this._storageService.store(QUOTA_EXHAUSTED_DISMISSED_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.MACHINE); + } + + private _clearExhaustedDismissed(): void { + this._storageService.remove(QUOTA_EXHAUSTED_DISMISSED_STORAGE_KEY, StorageScope.APPLICATION); + } } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 230fbe451cf..d11a6054d45 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -376,7 +376,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const knownProvider = getAgentSessionProvider(type); if (knownProvider) { // Well-known provider β€” use hardcoded name - reader.store.add(registerNewSessionInPlaceAction(type, getAgentSessionProviderName(knownProvider))); + const label = getAgentSessionProviderName(knownProvider); + reader.store.add(registerNewSessionInPlaceAction(type, label)); } else { // Extension-contributed β€” use contribution metadata const contrib = this._contributions.get(type); diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.ts index b087eb29533..37c97273af3 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/ttsPlaybackService.ts @@ -86,6 +86,11 @@ export class TtsPlaybackService extends Disposable implements ITtsPlaybackServic if (!this._playbackCtx) { this._playbackCtx = new window.AudioContext({ sampleRate: PLAYBACK_SAMPLE_RATE }); } + // AudioContext may be suspended if no user gesture occurred on this window yet. + // Resume it to ensure playback works regardless of which window initiated the action. + if (this._playbackCtx.state === 'suspended') { + this._playbackCtx.resume().catch(() => { /* ignore - best effort */ }); + } return this._playbackCtx; } diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index af332deb36c..15a4084efeb 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -6,6 +6,7 @@ import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { IObservable, observableValue, autorun, transaction, observableSignalFromEvent } from '../../../../../base/common/observable.js'; import { disposableWindowInterval } from '../../../../../base/browser/dom.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; @@ -103,6 +104,9 @@ export interface IVoiceSessionController { * client state, environment info). Returns success/failure. */ submitFeedback(feedbackText: string): Promise<{ ok: boolean; error?: string }>; + + /** DEV ONLY: Simulate a connected session with fake transcript for UI testing. */ + simulateConnection(): void; } export const IVoiceSessionController = createDecorator('voiceSessionController'); @@ -837,7 +841,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._isReconnecting.set(false, undefined); this._voiceState.set('idle', undefined); this._statusText.set('Tap to start', undefined); - } else if (!this._isConnecting.get()) { + } else if (this._isConnecting.get()) { + // Connection failed during initial handshake (e.g. fatal WS close). + // Clear isConnecting so callers awaiting the state settle properly. + this._isConnecting.set(false, undefined); + this._voiceState.set('idle', undefined); + this._statusText.set('Tap to start', undefined); + } else { this._voiceState.set('idle', undefined); } })); @@ -1017,6 +1027,43 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._sessionAudioCache.clear(); } + /** DEV ONLY: Simulate a connected session with fake transcript for UI testing. */ + simulateConnection(): void { + this._isConnected.set(true, undefined); + this._isConnecting.set(false, undefined); + this._voiceState.set('idle', undefined); + this._statusText.set('Hold to speak...', undefined); + + // Simulate a user speaking after 1s + this._voiceEventDisposables.add(disposableTimeout(() => { + if (!this._isConnected.get()) { return; } + this._voiceState.set('listening', undefined); + this._transcriptTurns.set([{ speaker: 'user', text: 'Create a', committed: '', isPartial: true }], undefined); + }, 1000)); + + // Partial grows + this._voiceEventDisposables.add(disposableTimeout(() => { + if (!this._isConnected.get()) { return; } + this._transcriptTurns.set([{ speaker: 'user', text: 'Create a new React component', committed: 'Create a ', isPartial: true }], undefined); + }, 2000)); + + // Final user turn + this._voiceEventDisposables.add(disposableTimeout(() => { + if (!this._isConnected.get()) { return; } + this._transcriptTurns.set([{ speaker: 'user', text: 'Create a new React component for the dashboard', committed: 'Create a new React component for the dashboard', isPartial: false }], undefined); + this._voiceState.set('idle', undefined); + }, 3000)); + + // Assistant response + this._voiceEventDisposables.add(disposableTimeout(() => { + if (!this._isConnected.get()) { return; } + this._transcriptTurns.set([ + { speaker: 'user', text: 'Create a new React component for the dashboard', committed: 'Create a new React component for the dashboard', isPartial: false }, + { speaker: 'assistant', text: 'I\'ll create a Dashboard component with some widgets...', committed: '', isPartial: false }, + ], undefined); + }, 4500)); + } + private _onConnectionLost(): void { this.logService.warn('[voice] connection lost, preserving state for reconnect'); // Don't stop the mic here β€” keep the MediaStream alive across the @@ -1161,26 +1208,62 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC private async _sendTranscriptionToChat(text: string): Promise { const target = this._targetSession.get(); if (target) { - // Try switching to the session via the workbench chat pane first - const switched = await this.commandService.executeCommand('_chat.voice.switchToSession', target.toString()).catch(() => false); - if (switched) { - // Small delay to let the widget load the session model - await new Promise(resolve => setTimeout(resolve, 200)); + // Check if target is the currently visible session + const currentSession = await this.commandService.executeCommand('_chat.voice.getCurrentSession').catch(() => undefined); + const isTargetVisible = currentSession === target.toString(); + + if (isTargetVisible) { + // Target is visible β€” send via the chat pane directly await this.commandService.executeCommand('_chat.voice.acceptInput', text).catch(err => { - this.logService.warn('[voice] acceptInput failed after switch:', err); + this.logService.warn('[voice] acceptInput failed for visible target:', err); }); } else { - // Not in workbench chat β€” try agents window openAndSend - const handled = await this.commandService.executeCommand('_sessions.voice.openAndSend', target.toString(), text).catch(() => false); - if (!handled) { - // Last resort: try sendRequest directly - this.chatService.sendRequest(target, text).then(result => { - if (result.kind === 'rejected') { - this.logService.warn('[voice] Failed to send transcription to target session:', result.reason); + // Target is NOT visible β€” ensure session is loaded, then send + const cts = new CancellationTokenSource(); + const ref = await this.chatService.acquireOrLoadSession(target, ChatAgentLocation.Chat, cts.token, 'voice-send').catch(err => { + this.logService.warn('[voice] Failed to load target session:', err); + return undefined; + }); + cts.dispose(); + if (!ref) { + this.logService.warn('[voice] Could not load target session, falling back to switch'); + // Fallback: switch to the session and send via the UI + const switched = await this.commandService.executeCommand('_chat.voice.switchToSession', target.toString()).catch(() => false); + if (switched) { + await new Promise(resolve => setTimeout(resolve, 200)); + await this.commandService.executeCommand('_chat.voice.acceptInput', text).catch(() => { }); + } + return; + } + const result = await this.chatService.sendRequest(target, text).catch(err => { + this.logService.warn('[voice] Error sending transcription to target session:', err); + return undefined; + }); + if (result && result.kind !== 'rejected') { + // Surface response in floating window + this._watchResponseForFloatingWindow(target); + // Open the floating window so user can see the response + this.commandService.executeCommand('_agentsVoice.openWindow').catch(() => { /* ignore */ }); + // Keep the session model loaded until the response completes + // so the autorun can observe state transitions and trigger narration. + const model = this.chatService.getSession(target); + if (model) { + const lastReq = model.getRequests().at(-1); + if (lastReq?.response && !lastReq.response.isComplete && !lastReq.response.isCanceled) { + const responseDisposable = lastReq.response.onDidChange(() => { + if (lastReq.response!.isComplete || lastReq.response!.isCanceled) { + responseDisposable.dispose(); + ref.dispose(); + } + }); + } else { + ref.dispose(); } - }).catch(err => { - this.logService.warn('[voice] Error sending transcription to target session:', err); - }); + } else { + ref.dispose(); + } + } else { + ref.dispose(); } } } else { @@ -1224,10 +1307,77 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC }); } } + + // Ensure the chat view is visible so the user sees/hears the response + this.commandService.executeCommand('workbench.panel.chat.view.copilot.focus').catch(() => { /* ignore */ }); + } + } + + /** + * Watch a session's latest response and surface it in the floating window + * transcript. Called when voice sends to a non-visible session so the user + * can see the reply without switching the chat panel. + */ + private _watchResponseForFloatingWindow(sessionResource: URI): void { + const model = this.chatService.getSession(sessionResource); + if (!model) { + return; } - // Ensure the chat view is visible so the user sees/hears the response - this.commandService.executeCommand('workbench.panel.chat.view.copilot.focus').catch(() => { /* ignore */ }); + // Seed the state cache so the delta mechanism sees thinkingβ†’idle as a transition + // and includes last_response_summary in the patch. + this._prevSessionStates.set(sessionResource.toString(), { state: 'thinking', detail: '' }); + this._sendContext(); + + const disposables = new DisposableStore(); + let lastText = ''; + + const updateFromResponse = () => { + const lastReq = model.lastRequest; + const response = lastReq?.response; + if (!response) { + return; + } + + const markdown = response.response.getMarkdown(); + // Only first ~200 chars for the floating window transcript preview + const previewText = markdown.length > 200 ? markdown.slice(0, 200) + '…' : markdown; + if (previewText && previewText !== lastText) { + const isFirst = lastText === ''; + lastText = previewText; + this._setAssistantTurn(previewText, { startNewTurn: isFirst }); + } + + if (response.isComplete || response.isCanceled) { + // Notify the voice backend of the state transition so it can + // narrate the response for this non-focused session. + this._prevSessionStates.set(sessionResource.toString(), { state: 'idle', detail: '' }); + this._sendContext(); + this.voiceClientService.flushSessionContext(); + disposables.dispose(); + } + }; + + // Listen for response changes + const checkResponse = () => { + const lastReq = model.lastRequest; + if (lastReq?.response) { + disposables.add(lastReq.response.onDidChange(() => updateFromResponse())); + updateFromResponse(); + } + }; + + // The response may not exist yet β€” listen for model changes + disposables.add(model.onDidChange(e => { + if (e.kind === 'addResponse') { + checkResponse(); + } + })); + checkResponse(); + + // Safety: dispose after 5 minutes in case the response never completes + const timeout = setTimeout(() => disposables.dispose(), 5 * 60 * 1000); + disposables.add({ dispose: () => clearTimeout(timeout) }); } // --- Transcript buffer helpers --- @@ -1758,8 +1908,11 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC return false; }); + const targetSessionId = this._targetSession.get()?.toString(); + const sessionList = sessions.map(s => { const model = this.chatService.getSession(s.resource); + const isActive = s.resource.toString() === targetSessionId; if (!model) { const fallbackState = s.status === AgentSessionStatus.InProgress ? 'thinking' : s.status === AgentSessionStatus.NeedsInput ? 'waiting_for_confirmation' @@ -1767,14 +1920,14 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC : 'unknown'; return { id: s.resource.toString(), - is_active: false, + is_active: isActive, agent_state: fallbackState, }; } const stateInfo = this._getAgentStateInfo(model); return { id: s.resource.toString(), - is_active: false, + is_active: isActive, agent_state: stateInfo.state, ...(stateInfo.detail ? { agent_state_detail: stateInfo.detail } : {}), ...(stateInfo.last_response_summary ? { last_response_summary: stateInfo.last_response_summary } : {}), @@ -1796,7 +1949,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } sessionList.push({ id: key, - is_active: false, + is_active: key === targetSessionId, agent_state: stateInfo.state, ...(stateInfo.detail ? { agent_state_detail: stateInfo.detail } : {}), ...(stateInfo.last_response_summary ? { last_response_summary: stateInfo.last_response_summary } : {}), diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts index 41b66c489e3..066e2186999 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceToolDispatchService.ts @@ -15,6 +15,7 @@ import { IChatModel } from '../../common/model/chatModel.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { IVoiceToolCall } from '../../common/voiceClient/voiceClientService.js'; +import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; /** * Callbacks that require access to the chat widget or view state. @@ -206,6 +207,16 @@ export class VoiceToolDispatchService implements IVoiceToolDispatchService { } } } + // Session not loaded β€” acquire it so we can confirm the tool invocation + if (!model && agentSession) { + const cts = new CancellationTokenSource(); + const ref = await this.chatService.acquireOrLoadSession(agentSession.resource, ChatAgentLocation.Chat, cts.token, 'voice-confirm').catch(() => undefined); + cts.dispose(); + if (ref) { + model = this.chatService.getSession(agentSession.resource); + ref.dispose(); + } + } } if (!model) { // Last resort: use the currently focused session diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 31dd5d3dd32..f90f3a93a26 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -43,6 +43,7 @@ import { IMarkdownRenderer } from '../../../../../platform/markdown/browser/mark import { isDark } from '../../../../../platform/theme/common/theme.js'; import { IThemeService } from '../../../../../platform/theme/common/themeService.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { parseRemoteAgentHostSessionTypeAuthority } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { CodiconActionViewItem } from '../../../notebook/browser/view/cellParts/cellActionView.js'; import { annotateSpecialMarkdownContent, extractSubAgentInvocationIdFromText, hasCodeblockUriTag, hasEditCodeblockUriTag } from '../../common/widget/annotations.js'; @@ -54,7 +55,7 @@ import { chatSubcommandLeader } from '../../common/requestParser/chatParserTypes import { ChatAgentVoteDirection, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatExternalEdit, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPlanReview, IChatPlanReviewResult, IChatPullRequestContent, IChatQuestionAnswerValue, IChatQuestionAnswers, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; import { ChatPlanReviewData } from '../../common/model/chatProgressTypes/chatPlanReviewData.js'; import { ChatQuestionCarouselData } from '../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; -import { localChatSessionType } from '../../common/chatSessionsService.js'; +import { localChatSessionType, SessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { getExplicitFileOrImageAttachmentSummary, IChatRequestVariableEntry, isExplicitFileOrImageVariableEntry, isPasteVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWorkingProgress, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM } from '../../common/model/chatViewModel.js'; @@ -109,7 +110,7 @@ import { HookType } from '../../common/promptSyntax/hookTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { AccessibilityWorkbenchSettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; import { isMcpToolInvocation } from './chatContentParts/toolInvocationParts/chatToolPartUtilities.js'; -import { isAgentHostTarget } from '../agentSessions/agentSessions.js'; +import { AgentSessionProviders, isAgentHostTarget } from '../agentSessions/agentSessions.js'; const $ = dom.$; @@ -185,6 +186,19 @@ export interface IChatRendererDelegate { const mostRecentResponseClassName = 'chat-most-recent-response'; +export function shouldHideChatUserIdentity(username: string, sessionResource: URI, isResponse: boolean, isSessionsWindow: boolean, isSystemInitiatedRequest: boolean): boolean { + const sessionType = getChatSessionType(sessionResource); + return username === COPILOT_USERNAME || + (isResponse && isAgentHostCopilotSessionType(sessionType)) || + isSessionsWindow || + isSystemInitiatedRequest; +} + +function isAgentHostCopilotSessionType(sessionType: string): boolean { + return sessionType === AgentSessionProviders.AgentHostCopilot || + parseRemoteAgentHostSessionTypeAuthority(sessionType, SessionType.CopilotCLI) !== undefined; +} + function upvoteAnimationSettingToEnum(value: string | undefined): ClickAnimation | undefined { switch (value) { case 'confetti': return ClickAnimation.Confetti; @@ -808,8 +822,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer .monaco-button .codicon[class*='codicon-'] { + font-size: var(--vscode-codiconFontSize-compact); +} + +/* Voice mode: color the mic icon blue when listening, purple when speaking */ +/* Voice mode: color the mic icon when listening/speaking */ +.chat-input-container.voice-active.voice-listening .chat-input-toolbars .action-label.codicon-mic-filled { + color: var(--vscode-charts-blue, #58a6ff) !important; +} + +.chat-input-container.voice-active:not(.voice-listening) .chat-input-toolbars .action-label.codicon-mic-filled { + color: var(--vscode-charts-purple, #a371f7) !important; +} + +/* Voice mode: green disconnect button */ +.chat-input-container .chat-input-toolbars .codicon-debug-disconnect { + color: var(--vscode-charts-green, #3fb950) !important; +} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index d31e014e736..6b4b5cb8b4e 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -67,17 +67,12 @@ import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; import { ChatEntitlementContextKeys, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { toErrorMessage } from '../../../../../../base/common/errorMessage.js'; import { IHostService } from '../../../../../services/host/browser/host.js'; -import { FileAccess } from '../../../../../../base/common/network.js'; import { IMicCaptureService } from '../../voiceClient/micCaptureService.js'; import { ITtsPlaybackService } from '../../voiceClient/ttsPlaybackService.js'; import { IVoiceSessionController } from '../../voiceClient/voiceSessionController.js'; -import { IAgentsVoiceWindowService, AgentsVoiceStorageKeys } from '../../../../agentsVoice/common/agentsVoice.js'; -import { AgentsVoiceWidget } from '../../../../agentsVoice/browser/agentsVoiceWidget.js'; -import { AGENTS_VOICE_WIDGET_FOCUSED } from '../../../../agentsVoice/browser/agentsVoice.contribution.js'; -import { bindWidgetToController } from '../../../../agentsVoice/browser/agentsVoiceWidgetBinding.js'; +import { IAgentsVoiceWindowService } from '../../../../agentsVoice/common/agentsVoice.js'; import { IAgentTitleBarStatusService } from '../../agentSessions/experiments/agentTitleBarStatusService.js'; import { IVoicePlaybackService } from '../../../common/voicePlaybackService.js'; -import { VoiceOnboardingCompletedClassification, VoiceOnboardingCompletedEvent } from '../../voiceClient/voiceTelemetry.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; interface IChatViewPaneState extends Partial { @@ -116,7 +111,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { constructor( options: IViewPaneOptions, - @IKeybindingService private readonly keybindingService2: IKeybindingService, + @IKeybindingService keybindingService2: IKeybindingService, @IContextMenuService contextMenuService: IContextMenuService, @IConfigurationService configurationService: IConfigurationService, @IContextKeyService contextKeyService: IContextKeyService, @@ -144,9 +139,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { @ITtsPlaybackService private readonly ttsPlaybackService: ITtsPlaybackService, @IVoiceSessionController private readonly voiceSessionController: IVoiceSessionController, @IAgentsVoiceWindowService private readonly agentsVoiceWindowService: IAgentsVoiceWindowService, - @IAgentTitleBarStatusService private readonly agentTitleBarStatusService: IAgentTitleBarStatusService, - @IVoicePlaybackService private readonly voicePlaybackService: IVoicePlaybackService, - @IWorkbenchEnvironmentService private readonly workbenchEnvironmentService: IWorkbenchEnvironmentService, + @IAgentTitleBarStatusService _agentTitleBarStatusService: IAgentTitleBarStatusService, + @IVoicePlaybackService _voicePlaybackService: IVoicePlaybackService, + @IWorkbenchEnvironmentService _workbenchEnvironmentService: IWorkbenchEnvironmentService, ) { super(options, keybindingService2, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -332,28 +327,21 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const controlsWrapper = append(parent, $('.voice-agent-controls-wrapper')); this.createControls(controlsWrapper); - // Bottom area for voice panel β€” always present, populated when enabled - const bottomArea = append(parent, $('.voice-bottom-area')); - this._voiceBottomArea = bottomArea; - this._updateVoiceBar(bottomArea); + // Voice bar β€” hidden by default, voice is activated via mic button in toolbar. + // The widget is still created for PTT keybinding support and session binding. + this._voiceBarContainer = $('.voice-agent-bar-host'); + this._voiceBarContainer.style.display = 'none'; + this._updateVoiceBar(this._voiceBarContainer); - // Watch for size changes so we relayout when content changes - // (e.g. onboarding β†’ connected, confirmations added/removed) - const resizeObserver = new ResizeObserver(() => { - if (this.lastDimensions) { - this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); - } - }); - resizeObserver.observe(bottomArea); - this._voiceBarResizeObserver = resizeObserver; - this._register({ dispose: () => resizeObserver.disconnect() }); + // Transcript overlay β€” shown inside the input container when voice is active + const inputContainerEl = this._widget.inputPart.inputContainerElement; + if (inputContainerEl) { + this._setupVoiceTranscriptOverlay(inputContainerEl); + } this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('agents.voice.enabled')) { - this._updateVoiceBar(bottomArea); - if (this.lastDimensions) { - this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); - } + this._updateVoiceBar(this._voiceBarContainer!); } })); @@ -382,17 +370,19 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { //#region Voice Agent Bar - private _voiceBottomArea: HTMLElement | undefined; - private _voiceBarResizeObserver: ResizeObserver | undefined; + private _voiceBarContainer: HTMLElement | undefined; private readonly _voiceBarDisposables = this._register(new DisposableStore()); private _updateVoiceBar(container: HTMLElement): void { this._voiceBarDisposables.clear(); container.replaceChildren(); - if (this.configurationService.getValue('agents.voice.enabled')) { - container.style.display = ''; + // Always keep the container hidden β€” voice UI is now the mic toolbar + // button + transcript overlay. We still register the command bridges + // needed by VoiceSessionController. + container.style.display = 'none'; + if (this.configurationService.getValue('agents.voice.enabled')) { // Voice command bridge β€” lets the VoiceSessionController reach into the chat widget this._voiceBarDisposables.add(CommandsRegistry.registerCommand('_chat.voice.acceptInput', (_accessor, text: string) => { if (text && this._widget?.viewModel) { @@ -414,126 +404,172 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._voiceBarDisposables.add(CommandsRegistry.registerCommand('_chat.voice.getCurrentSession', (_accessor): string | undefined => { return this._widget?.viewModel?.sessionResource?.toString(); })); - - this.createVoiceAgentBar(container); - } else { - container.style.display = 'none'; } } - private createVoiceAgentBar(parent: HTMLElement): void { - const bar = append(parent, $('.voice-agent-bar')); - const win = getWindow(bar) as Window & typeof globalThis; + private _setupVoiceTranscriptOverlay(inputContainerEl: HTMLElement): void { + inputContainerEl.style.position = 'relative'; + const transcriptOverlay = $('.voice-transcript-overlay'); + // Leave bottom 36px for the toolbar (Agent, model picker, mic, send) + transcriptOverlay.style.cssText = 'display:none;position:absolute;top:0;left:0;right:0;bottom:36px;z-index:10;padding:8px 12px;font-size:13px;line-height:1.4;word-break:break-word;overflow:hidden;pointer-events:none;background:var(--vscode-input-background, transparent);border-radius:inherit;border-bottom-left-radius:0;border-bottom-right-radius:0;'; + inputContainerEl.append(transcriptOverlay); - // Also observe the inner bar β€” its content changes (onboarding β†’ - // connected) before the outer wrapper resizes. - this._voiceBarResizeObserver?.observe(bar); + const style = document.createElement('style'); + style.textContent = ` + @keyframes voiceTextPulse { 0%,100%{opacity:0.9} 50%{opacity:0.4} } + .voice-transcript-overlay .committed { color: var(--vscode-foreground); } + .voice-transcript-overlay .partial { color: var(--vscode-foreground); opacity:0.6; font-style:italic; animation: voiceTextPulse 1.5s ease-in-out infinite; } + .voice-transcript-overlay .assistant-text { color: var(--vscode-descriptionForeground); display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; } + `; + transcriptOverlay.append(style); - const widget = new AgentsVoiceWidget(bar, { - copilotIconSrc: FileAccess.asBrowserUri('vs/sessions/browser/media/sessions-icon.svg').toString(true), - connect: () => this.voiceSessionController.connect(win), - disconnect: () => this.voiceSessionController.disconnect(), - pttDown: () => { - if (!this.voiceSessionController.isConnected.get() && !this.voiceSessionController.isConnecting.get()) { - this.voiceSessionController.connect(win).then(() => { - if (this.voiceSessionController.isConnected.get()) { - this.voiceSessionController.pttDown(); - } - }); + // Dynamic audio-reactive glow animation (matches aux window behavior) + let animFrameId: number | undefined; + let glowDataArray: Uint8Array | undefined; + const win = getWindow(inputContainerEl); + const startGlowAnimation = () => { + if (animFrameId !== undefined) { return; } + const animate = () => { + animFrameId = win.requestAnimationFrame(animate); + const connected = this.voiceSessionController.isConnected.get(); + const voiceState = this.voiceSessionController.voiceState.get(); + const glowActive = connected && (voiceState === 'listening' || voiceState === 'speaking'); + + if (!glowActive) { + inputContainerEl.style.borderColor = ''; + inputContainerEl.style.boxShadow = ''; + inputContainerEl.classList.remove('voice-active', 'voice-listening'); return; } - this.voiceSessionController.pttDown(); - }, - pttUp: () => this.voiceSessionController.pttUp(), - closeWindow: () => { /* no-op: chat pane has no close button */ }, - stopPlayback: () => this.ttsPlaybackService.stopPlayback(), - openSession: (resource) => { - this.viewState.sessionResource = resource; - this.applyModel(); - }, - stopSession: (resource) => { - const model = this.chatService.getSession(resource); - if (model) { - const lastReq = model.getRequests().at(-1); - if (lastReq) { - this.voiceSessionController.markUserCancelled(resource.toString()); - this.chatService.cancelCurrentRequestForSession(resource); - } - } - }, - cancelSession: (resource) => { - this.voiceSessionController.markUserCancelled(resource.toString()); - this.chatService.cancelCurrentRequestForSession(resource); - }, - selectTargetSession: (resource) => { - this.voiceSessionController.setTargetSession(resource); - }, - newSessionAsTarget: () => { - this.voiceSessionController.newSessionAsTarget(); - }, - getAnalyserNode: () => { - const state = this.voiceSessionController.voiceState.get(); - return this.ttsPlaybackService.analyserNode - ?? (state === 'listening' ? this.micCaptureService.analyserNode : null) + + // Get audio intensity from analyser + const analyser = this.ttsPlaybackService.analyserNode + ?? (voiceState === 'listening' ? this.micCaptureService.analyserNode : null) ?? null; - }, - onResize: () => { - if (this.lastDimensions) { - this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); + let intensity: number; + if (!analyser) { + intensity = 0.3; + } else { + if (!glowDataArray || glowDataArray.length !== analyser.frequencyBinCount) { + glowDataArray = new Uint8Array(analyser.frequencyBinCount); + } + analyser.getByteFrequencyData(glowDataArray as Uint8Array); + let sum = 0; + for (let i = 0; i < glowDataArray.length; i++) { + sum += glowDataArray[i]; + } + intensity = Math.min(1, (sum / glowDataArray.length) / 80); } - }, - openPttKeySettings: () => this.commandService.executeCommand('workbench.action.openGlobalKeybindings', 'agentsVoice.pushToTalk'), - openPopout: () => this.commandService.executeCommand('agentsVoice.toggleWindow'), - submitFeedback: (text) => this.voiceSessionController.submitFeedback(text), - onOnboardingCompleted: () => { - this.storageService.store(AgentsVoiceStorageKeys.OnboardingCompleted, true, StorageScope.PROFILE, StorageTarget.USER); - this.telemetryService.publicLog2('voiceOnboardingCompleted', {}); - }, - }, { - width: 'auto', - draggable: false, - showClose: false, - showExpandChevron: false, - showStatusText: false, - showStatusCounters: false, - showCopilotIcon: false, - centerConnectButton: false, - title: localize('agentsVoice.voiceChatTitle', "Voice Mode"), - focusable: true, - reshowOnboardingOnDisconnect: false, - }); - this._voiceBarDisposables.add(widget); - // Set context key for voice widget focus (drives Space keybinding) - const widgetFocusedKey = AGENTS_VOICE_WIDGET_FOCUSED.bindTo(this.contextKeyService); - bar.addEventListener('focusin', () => widgetFocusedKey.set(true)); - bar.addEventListener('focusout', () => widgetFocusedKey.set(false)); - this._voiceBarDisposables.add({ dispose: () => widgetFocusedKey.reset() }); + // Blue when listening, purple when speaking + const rgb = voiceState === 'speaking' ? '163,113,247' : '88,166,255'; + const borderAlpha = 0.4 + intensity * 0.5; + const shadowSpread = 4 + intensity * 12; + const shadowAlpha = 0.15 + intensity * 0.35; + inputContainerEl.style.borderColor = `rgba(${rgb},${borderAlpha})`; + inputContainerEl.style.boxShadow = `0 0 ${shadowSpread}px rgba(${rgb},${shadowAlpha}), inset 0 0 ${shadowSpread * 0.4}px rgba(${rgb},${shadowAlpha * 0.3})`; + inputContainerEl.classList.add('voice-active'); + inputContainerEl.classList.toggle('voice-listening', voiceState === 'listening'); + }; + animFrameId = win.requestAnimationFrame(animate); + }; + const stopGlowAnimation = () => { + if (animFrameId !== undefined) { + win.cancelAnimationFrame(animFrameId); + animFrameId = undefined; + } + inputContainerEl.style.borderColor = ''; + inputContainerEl.style.boxShadow = ''; + inputContainerEl.classList.remove('voice-active', 'voice-listening'); + }; - // Hide the popout button when the floating window is already open. - widget.setPopoutAvailable(!this.agentsVoiceWindowService.isOpen); - this._voiceBarDisposables.add(this.agentsVoiceWindowService.onDidChangeOpen(isOpen => { - widget.setPopoutAvailable(!isOpen); + this._register(autorun(reader => { + const connected = this.voiceSessionController.isConnected.read(reader); + const voiceState = this.voiceSessionController.voiceState.read(reader); + if (connected && (voiceState === 'listening' || voiceState === 'speaking')) { + startGlowAnimation(); + } else { + stopGlowAnimation(); + } })); + this._register({ dispose: () => stopGlowAnimation() }); - // PTT key label from keybinding - const getPttLabel = () => this.keybindingService2.lookupKeybinding('agentsVoice.pushToTalk')?.getLabel() ?? undefined; - widget.setPttKeyLabel(getPttLabel()); - this._voiceBarDisposables.add(this.keybindingService2.onDidUpdateKeybindings(() => { - widget.setPttKeyLabel(getPttLabel()); + this._register(autorun(reader => { + const turns = this.voiceSessionController.transcriptTurns.read(reader); + const connected = this.voiceSessionController.isConnected.read(reader); + const voiceState = this.voiceSessionController.voiceState.read(reader); + const showTranscript = this.configurationService.getValue('agents.voice.showTranscript') !== false; + const visible = turns.filter(t => t.text.length > 0 || (t.speaker === 'user' && t.isPartial)); + + if (!connected) { + transcriptOverlay.style.display = 'none'; + return; + } + + // If aux window is open and voice is targeting a different session, + // don't show transcript in the chat input β€” it's shown in aux window instead. + const targetSession = this.voiceSessionController.targetSession.read(reader); + const currentSession = this._widget?.viewModel?.sessionResource; + if (this.agentsVoiceWindowService.isOpen && targetSession && currentSession && targetSession.toString() !== currentSession.toString()) { + transcriptOverlay.style.display = 'none'; + return; + } + + // Show hint when connected but no transcript yet + if (visible.length === 0 || !showTranscript) { + if (voiceState === 'idle' && visible.length === 0) { + transcriptOverlay.style.display = ''; + while (transcriptOverlay.childNodes.length > 1) { + transcriptOverlay.removeChild(transcriptOverlay.lastChild!); + } + const hint = $('span.partial'); + const kb = this.keybindingService.lookupKeybinding('agentsVoice.pushToTalk'); + const kbLabel = kb?.getLabel(); + hint.textContent = kbLabel + ? localize('voiceMode.pttHint', "Press {0} to talk", kbLabel) + : localize('voiceMode.clickMicHint', "Click mic to talk"); + transcriptOverlay.append(hint); + } else { + transcriptOverlay.style.display = 'none'; + } + return; + } + + transcriptOverlay.style.display = ''; + // Show only the latest turn: user question first, then assistant reply replaces it + const lastTurn = visible[visible.length - 1]; + const contentElements: HTMLElement[] = []; + if (lastTurn.speaker === 'user') { + const span = $('span'); + if (lastTurn.isPartial) { + const committedPart = lastTurn.committed || ''; + const unsurePart = lastTurn.text.slice(committedPart.length); + if (committedPart) { + const c = $('span.committed'); + c.textContent = committedPart; + span.append(c); + } + const u = $('span.partial'); + u.textContent = unsurePart + '\u2589'; + span.append(u); + } else { + span.className = 'committed'; + span.textContent = lastTurn.text; + } + contentElements.push(span); + } else { + const div = $('div.assistant-text'); + div.textContent = lastTurn.text; + contentElements.push(div); + } + // Keep the style element, replace content + while (transcriptOverlay.childNodes.length > 1) { + transcriptOverlay.removeChild(transcriptOverlay.lastChild!); + } + for (const el of contentElements) { + transcriptOverlay.append(el); + } })); - - // Shared controllerβ†’widget binding (also used by the floating window) - this._voiceBarDisposables.add(bindWidgetToController(widget, { - voiceSessionController: this.voiceSessionController, - agentSessionsService: this.agentSessionsService, - agentTitleBarStatusService: this.agentTitleBarStatusService, - voicePlaybackService: this.voicePlaybackService, - environmentService: this.workbenchEnvironmentService, - chatService: this.chatService, - })); - - this._voiceBarDisposables.add({ dispose: () => { this.voiceSessionController.disconnect(); } }); } //#endregion @@ -1203,9 +1239,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { let remainingHeight = height; const remainingWidth = width; - // Voice bottom area β€” read current height (ResizeObserver triggers - // relayout whenever the content changes size). - remainingHeight -= this._voiceBottomArea?.offsetHeight ?? 0; + // Voice bar is now inside the input container, no separate height deduction needed // Title Control const titleHeight = this.titleControl?.getHeight() ?? 0; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index edc68225a6a..c1415f8d2f0 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* Voice Agent Bar β€” wraps the shared AgentsVoiceWidget */ +/* Voice Agent Bar β€” wraps the shared AgentsVoiceWidget inside input container */ .voice-agent-bar { display: flex; flex-direction: column; flex-shrink: 0; - border-top: 1px solid var(--vscode-panel-border); + border-bottom: 1px solid var(--vscode-panel-border); position: relative; overflow: hidden; } @@ -18,7 +18,7 @@ display: flex; flex-direction: column; - /* wrapper that holds sessions + chat controls, voice bar sits below this */ + /* wrapper that holds sessions + chat controls */ > .voice-agent-controls-wrapper { display: flex; flex-direction: column; @@ -46,15 +46,6 @@ } } -/* Bottom area: tab bar + voice panel, pinned to bottom of viewpane */ -.chat-viewpane > .voice-bottom-area { - display: flex; - flex-direction: column; - flex-shrink: 0; - flex-grow: 0; - overflow: hidden; -} - /* Sessions control: either sidebar or stacked */ .chat-viewpane.has-sessions-control .agent-sessions-container { display: flex; diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts index bf4af290a4b..1af6ac2b281 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/voiceChatActions.ts @@ -26,12 +26,12 @@ import { IKeybindingService } from '../../../../../platform/keybinding/common/ke import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { contrastBorder, focusBorder } from '../../../../../platform/theme/common/colorRegistry.js'; +import { editorInfoForeground } from '../../../../../platform/theme/common/colors/editorColors.js'; import { spinningLoading, syncing } from '../../../../../platform/theme/common/iconRegistry.js'; import { isHighContrast } from '../../../../../platform/theme/common/theme.js'; import { registerThemingParticipant } from '../../../../../platform/theme/common/themeService.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; -import { ACTIVITY_BAR_FOREGROUND } from '../../../../common/theme.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IHostService } from '../../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../../services/layout/browser/layoutService.js'; @@ -1243,7 +1243,7 @@ registerThemingParticipant((theme, collector) => { let activeRecordingColor: Color | undefined; let activeRecordingDimmedColor: Color | undefined; if (!isHighContrast(theme.type)) { - activeRecordingColor = theme.getColor(ACTIVITY_BAR_FOREGROUND) ?? theme.getColor(focusBorder); + activeRecordingColor = theme.getColor(editorInfoForeground) ?? theme.getColor(focusBorder); activeRecordingDimmedColor = activeRecordingColor?.transparent(0.38); } else { activeRecordingColor = theme.getColor(contrastBorder); diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 306afeb3e55..8383329504e 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -32,6 +32,7 @@ import { IWorkbenchLayoutService } from '../../../services/layout/browser/layout import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { ACTION_ID_NEW_CHAT, CHAT_OPEN_ACTION_ID, IChatViewOpenOptions } from '../browser/actions/chatActions.js'; import { AgentHostContribution } from '../browser/agentSessions/agentHost/agentHostChatContribution.js'; +import { AgentHostSessionListContribution } from '../browser/agentSessions/agentHost/agentHostSessionListContribution.js'; import { AgentHostTerminalContribution } from '../browser/agentSessions/agentHost/agentHostTerminalContribution.js'; import { AgentSessionProviders, getAgentSessionProviderName } from '../browser/agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../browser/agentSessions/agentSessionsService.js'; @@ -260,6 +261,7 @@ registerWorkbenchContribution2(ChatCommandLineHandler.ID, ChatCommandLineHandler registerWorkbenchContribution2(ChatSuspendThrottlingHandler.ID, ChatSuspendThrottlingHandler, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatLifecycleHandler.ID, ChatLifecycleHandler, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentHostSessionListContribution.ID, AgentHostSessionListContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTerminalContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(OpenWorkspaceInAgentsContribution.ID, OpenWorkspaceInAgentsContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(AgentsHandoffInputTipContribution.ID, AgentsHandoffInputTipContribution, WorkbenchPhase.Eventually); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 0a5172de400..93aa3218ac4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -33,15 +33,17 @@ import { ChatAgentLocation } from '../../../common/constants.js'; import { ChatRequestQueueKind, ElicitationState, IChatService, IChatMarkdownContent, IChatProgress, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { IChatSessionsService, type IChatSessionRequestHistoryItem, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; +import { IChatSessionsService, type IChatSessionItemController, type IChatSessionRequestHistoryItem, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../common/languageModels.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IOutputService } from '../../../../../services/output/common/output.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; -import { AgentHostContribution, AgentHostSessionListController, AgentHostSessionHandler, CoalescingAgentHostSessionListConnection } from '../../../browser/agentSessions/agentHost/agentHostChatContribution.js'; +import { AgentHostContribution, AgentHostSessionHandler } from '../../../browser/agentSessions/agentHost/agentHostChatContribution.js'; import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; +import { AgentHostSessionListContribution, CoalescingAgentHostSessionListConnection } from '../../../browser/agentSessions/agentHost/agentHostSessionListContribution.js'; +import { AgentHostSessionListController } from '../../../browser/agentSessions/agentHost/agentHostSessionListController.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { TestFileService } from '../../../../../test/common/workbenchTestServices.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; @@ -456,7 +458,7 @@ class MockChatWidgetService extends mock() { // ---- Helpers ---------------------------------------------------------------- -function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial, languageModels?: ReadonlyMap, provisionalServiceOverride?: Partial) { +function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial, languageModels?: ReadonlyMap, provisionalServiceOverride?: Partial, isSessionsWindow = false) { const instantiationService = disposables.add(new TestInstantiationService()); const agentHostService = new MockAgentHostService(); @@ -465,6 +467,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv const chatAgentService = new MockChatAgentService(); const chatWidgetService = new MockChatWidgetService(); const chatSessionContributions: IChatSessionsExtensionPoint[] = []; + const chatSessionItemControllers: { type: string; controller: IChatSessionItemController }[] = []; const openerService: { openedUrls: (string | URI)[]; openShouldFail: boolean; openResult: boolean } & Partial = { openedUrls: [], openShouldFail: false, @@ -487,7 +490,16 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IFileService, TestFileService); instantiationService.stub(ILabelService, MockLabelService); instantiationService.stub(IChatSessionsService, { - registerChatSessionItemController: () => toDisposable(() => { }), + registerChatSessionItemController: (type, controller) => { + const entry = { type, controller }; + chatSessionItemControllers.push(entry); + return toDisposable(() => { + const index = chatSessionItemControllers.indexOf(entry); + if (index >= 0) { + chatSessionItemControllers.splice(index, 1); + } + }); + }, registerChatSessionContentProvider: () => toDisposable(() => { }), registerChatSessionContribution: contribution => { chatSessionContributions.push(contribution); @@ -570,7 +582,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv resolve: sessionResource => workingDirectoryResolver?.resolve(sessionResource), isNewSession: sessionResource => workingDirectoryResolver?.isNewSession?.(sessionResource) ?? sessionResource.path.substring(1).startsWith('new-'), }); - instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow: false } as Partial); + instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow } as Partial); instantiationService.stub(IAgentHostCustomizationService, new NullAgentHostCustomizationService()); instantiationService.stub(IAgentHostUntitledProvisionalSessionService, { onDidChange: Event.None, @@ -621,7 +633,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IAgentHostActiveClientService, activeClientService); instantiationService.stub(IOpenerService, openerService as IOpenerService); - return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, newSessionFolderService }; + return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService }; } function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap; provisionalServiceOverride?: Partial }) { @@ -4217,7 +4229,7 @@ suite('AgentHostChatContribution', () => { })); test('local agent contribution advertises image attachments', () => { - const { instantiationService, agentHostService, chatSessionContributions } = createTestServices(disposables); + const { instantiationService, agentHostService, chatSessionContributions, chatSessionItemControllers } = createTestServices(disposables); disposables.add(instantiationService.createInstance(AgentHostContribution)); agentHostService.setRootState({ @@ -4228,6 +4240,43 @@ suite('AgentHostChatContribution', () => { assert.deepStrictEqual(chatSessionContributions.map(c => ({ type: c.type, supportsImageAttachments: c.capabilities?.supportsImageAttachments })), [ { type: 'agent-host-copilot', supportsImageAttachments: true }, ]); + assert.deepStrictEqual(chatSessionItemControllers.map(c => c.type), []); + }); + + test('session list contribution registers item controller in editor window', () => { + const { instantiationService, agentHostService, chatSessionItemControllers } = createTestServices(disposables); + disposables.add(instantiationService.createInstance(AgentHostSessionListContribution)); + + agentHostService.setRootState({ + agents: [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', models: [] }], + activeSessions: 0, + }); + + assert.deepStrictEqual(chatSessionItemControllers.map(c => c.type), ['agent-host-copilot']); + }); + + test('session list contribution does not register item controller in sessions window', () => { + const { instantiationService, agentHostService, chatSessionItemControllers } = createTestServices(disposables, undefined, undefined, undefined, undefined, true); + disposables.add(instantiationService.createInstance(AgentHostSessionListContribution)); + + agentHostService.setRootState({ + agents: [{ provider: 'copilot' as const, displayName: 'Agent Host - Copilot', description: 'test', models: [] }], + activeSessions: 0, + }); + + assert.deepStrictEqual(chatSessionItemControllers.map(c => c.type), []); + }); + + test('local agent contribution uses advertised display name', () => { + const services = createTestServices(disposables); + disposables.add(services.instantiationService.createInstance(AgentHostContribution)); + + services.agentHostService.setRootState({ + agents: [{ provider: 'testagent', displayName: 'Test Agent', description: 'test', models: [] }], + activeSessions: 0, + }); + + assert.strictEqual(services.chatSessionContributions[0].displayName, 'Test Agent'); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index ffb22f957db..ff1ee861755 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -2174,6 +2174,11 @@ suite('AgentSessions', () => { assert.strictEqual(icon.id, Codicon.copilot.id); }); + test('should return simplified AgentHostCopilot name', () => { + const name = getAgentSessionProviderName(AgentSessionProviders.AgentHostCopilot); + assert.strictEqual(name, 'Copilot'); + }); + test('should return correct name for Growth provider', () => { const name = getAgentSessionProviderName(AgentSessionProviders.Growth); assert.strictEqual(name, 'Growth'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 22d2b8ad060..54b16ff4787 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { MessageKind, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type ActiveTurn, type ICompletedToolCall, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatProgressMessage, type IChatUsage } from '../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; -import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToQuotas } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; // ---- Helper factories ------------------------------------------------------- @@ -1481,4 +1481,97 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandOutput?.text, 'hi\r\n'); }); }); + + suite('usageInfoToQuotas', () => { + + test('returns undefined when no quota snapshots present', () => { + assert.strictEqual(usageInfoToQuotas(undefined), undefined); + assert.strictEqual(usageInfoToQuotas({ inputTokens: 10 }), undefined); + assert.strictEqual(usageInfoToQuotas({ _meta: { cost: 1 } }), undefined); + }); + + test('maps premium and chat snapshots, deriving additional usage and reset date', () => { + const result = usageInfoToQuotas({ + _meta: { + quotaSnapshots: { + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 300, + usedRequests: 75, + remainingPercentage: 75, + overage: 1.5, + overageAllowedWithExhaustedQuota: true, + resetDate: '2026-07-01T00:00:00.000Z', + }, + chat: { + isUnlimitedEntitlement: true, + entitlementRequests: -1, + usedRequests: 10, + remainingPercentage: 100, + }, + }, + }, + }); + + assert.deepStrictEqual(result, { + premiumChat: { + percentRemaining: 75, + unlimited: false, + entitlement: 300, + quotaRemaining: 225, + resetAt: Date.parse('2026-07-01T00:00:00.000Z'), + }, + chat: { + percentRemaining: 100, + unlimited: true, + entitlement: undefined, + quotaRemaining: undefined, + resetAt: undefined, + }, + additionalUsageEnabled: true, + additionalUsageCount: 1.5, + resetDate: '2026-07-01T00:00:00.000Z', + }); + }); + + test('skips categories with no allocated entitlement', () => { + const result = usageInfoToQuotas({ + _meta: { + quotaSnapshots: { + premium_interactions: { + isUnlimitedEntitlement: false, + entitlementRequests: 0, + usedRequests: 0, + remainingPercentage: 0, + overage: 0, + overageAllowedWithExhaustedQuota: false, + }, + }, + }, + }); + + // The 0-entitlement premium snapshot is skipped, but additional-usage fields are still derived. + assert.deepStrictEqual(result, { + additionalUsageEnabled: false, + additionalUsageCount: 0, + }); + }); + + test('skips a category whose remainingPercentage is missing', () => { + const result = usageInfoToQuotas({ + _meta: { + quotaSnapshots: { + chat: { + isUnlimitedEntitlement: false, + entitlementRequests: 100, + usedRequests: 10, + // remainingPercentage intentionally absent β€” must not masquerade as exhausted (0%). + }, + }, + }, + }); + + assert.strictEqual(result, undefined); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 81108bd53e2..99f9050e671 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -127,6 +127,7 @@ function createMockNotificationService() { getNotification(): IChatInputNotification | undefined { return deleted || dismissed ? undefined : lastNotification; }, get wasDeleted() { return deleted; }, get setCount() { return setCount; }, + dismiss(id: string) { service.dismissNotification(id); }, reset() { lastNotification = undefined; deleted = false; dismissed = false; setCount = 0; }, }; } @@ -156,11 +157,11 @@ suite('ChatQuotaNotificationContribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string }) { + function createContribution(entitlementOpts?: Parameters[0], modelOpts?: { vendor?: string }, sharedStorageService?: InMemoryStorageService) { const entitlementMock = createMockEntitlementService(entitlementOpts); const notificationMock = createMockNotificationService(); const contextKeyService = store.add(new MockContextKeyService()); - const storageService = store.add(new InMemoryStorageService()); + const storageService = sharedStorageService ?? store.add(new InMemoryStorageService()); const vendor = modelOpts?.vendor ?? 'copilot'; const isBYOK = vendor !== 'copilot'; // Persist model selection in storage (used by getSelectedModelVendor) @@ -273,6 +274,84 @@ suite('ChatQuotaNotificationContribution', () => { }); }); + // --- Exhausted dismissal persistence ------------------------------------ + + suite('exhausted dismissal persistence', () => { + test('does not re-show exhausted notification after reload when previously dismissed', () => { + const storageService = store.add(new InMemoryStorageService()); + + // First window: exhausted notification shown, then dismissed by the user. + const first = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + undefined, + storageService, + ); + const notification = first.notificationMock.getNotification(); + assert.ok(notification); + first.notificationMock.dismiss(notification!.id); + first.contribution.dispose(); + + // Reload: new contribution with the same (persisted) storage and still-exhausted quota. + const second = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + undefined, + storageService, + ); + assert.strictEqual(second.notificationMock.getNotification(), undefined); + }); + + test('re-shows exhausted notification after quota recovers and is exhausted again', () => { + const storageService = store.add(new InMemoryStorageService()); + + // Exhausted and dismissed. + const first = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + undefined, + storageService, + ); + first.notificationMock.dismiss(first.notificationMock.getNotification()!.id); + + // Quota recovers β€” persisted dismissal is cleared. + updateQuotas(first.entitlementMock, { premiumChat: makeQuotaSnapshot(50) }); + first.contribution.dispose(); + + // Reload while exhausted again β€” notification shows because the flag was cleared. + const second = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + undefined, + storageService, + ); + assert.ok(second.notificationMock.getNotification()); + assert.strictEqual(second.notificationMock.getNotification()!.message, 'Credit Limit Reached'); + }); + + test('keeps dismissal across reload when quota data is not loaded yet at startup', () => { + const storageService = store.add(new InMemoryStorageService()); + + // First window: exhausted notification shown, then dismissed by the user. + const first = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: makeQuotaSnapshot(0) } }, + undefined, + storageService, + ); + first.notificationMock.dismiss(first.notificationMock.getNotification()!.id); + first.contribution.dispose(); + + // Reload: quota snapshots have not been fetched yet (no relevant snapshot), + // so the dismissal must NOT be cleared by the transient "no data" state. + const second = createContribution( + { quotas: { usageBasedBilling: true, premiumChat: undefined } }, + undefined, + storageService, + ); + assert.strictEqual(second.notificationMock.getNotification(), undefined); + + // Quota data arrives showing it is still exhausted β€” banner stays suppressed. + updateQuotas(second.entitlementMock, { premiumChat: makeQuotaSnapshot(0) }); + assert.strictEqual(second.notificationMock.getNotification(), undefined); + }); + }); + // --- Exhausted notification descriptions -------------------------------- suite('exhausted notification descriptions', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index bc5c55f8398..2294d392b0e 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { shouldScheduleInitialHeightChange } from '../../../browser/widget/chatListRenderer.js'; +import { shouldHideChatUserIdentity, shouldScheduleInitialHeightChange } from '../../../browser/widget/chatListRenderer.js'; suite('ChatListRenderer', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -27,4 +28,30 @@ suite('ChatListRenderer', () => { ]); }); }); + + suite('shouldHideChatUserIdentity', () => { + test('hides local Copilot and Agent Host Copilot response identity', () => { + assert.deepStrictEqual([ + shouldHideChatUserIdentity('GitHub Copilot', URI.from({ scheme: 'vscode-chat-editor' }), true, false, false), + shouldHideChatUserIdentity('Copilot', URI.from({ scheme: 'agent-host-copilotcli' }), true, false, false), + shouldHideChatUserIdentity('Copilot', URI.from({ scheme: 'agent-host-copilotcli' }), false, false, false), + shouldHideChatUserIdentity('Copilot', URI.from({ scheme: 'remote-test-authority-copilotcli' }), true, false, false), + shouldHideChatUserIdentity('Copilot', URI.from({ scheme: 'remote-test-authority-copilotcli' }), false, false, false), + shouldHideChatUserIdentity('Claude', URI.from({ scheme: 'remote-test-authority-claude' }), true, false, false), + shouldHideChatUserIdentity('Claude', URI.from({ scheme: 'agent-host-claude' }), true, false, false), + shouldHideChatUserIdentity('Claude', URI.from({ scheme: 'agent-host-claude' }), true, true, false), + shouldHideChatUserIdentity('User', URI.from({ scheme: 'vscode-chat-editor' }), false, false, true), + ], [ + true, + true, + false, + true, + false, + false, + false, + true, + true, + ]); + }); + }); }); diff --git a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts index 7e6e39b2f31..3628fada486 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalTabsChatEntry.ts @@ -9,6 +9,7 @@ import { Disposable } from '../../../../base/common/lifecycle.js'; import { $ } from '../../../../base/browser/dom.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { ITerminalChatService, ITerminalService } from './terminal.js'; import * as dom from '../../../../base/browser/dom.js'; @@ -31,6 +32,7 @@ export class TerminalTabsChatEntry extends Disposable { @ICommandService private readonly _commandService: ICommandService, @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, @ITerminalService private readonly _terminalService: ITerminalService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(); @@ -90,6 +92,22 @@ export class TerminalTabsChatEntry extends Disposable { private async _deleteAllHiddenTerminals(): Promise { const hiddenTerminals = this._terminalChatService.getToolSessionTerminalInstances(true); + if (hiddenTerminals.length === 0) { + return; + } + + type DeleteHiddenChatTerminalsEvent = { + count: number; + }; + type DeleteHiddenChatTerminalsClassification = { + owner: 'anthonykim1'; + comment: 'Tracks when the user deletes all hidden chat terminals from the terminal tabs entry.'; + count: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of hidden chat terminals that were deleted.' }; + }; + this._telemetryService.publicLog2('terminal.chatDeleteHiddenTerminals', { + count: hiddenTerminals.length, + }); + await Promise.all(hiddenTerminals.map(terminal => this._terminalService.safeDisposeTerminal(terminal))); } diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish index 6cff7487a71..f87fc7623ff 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish @@ -127,10 +127,7 @@ end # Backslashes are doubled and non-alphanumeric characters are hex encoded. function __vsc_escape_value # Escape backslashes and semi-colons - echo $argv \ - | string replace --all '\\' '\\\\' \ - | string replace --all ';' '\\x3b' \ - ; + echo $argv | string replace --all '\\' '\\\\' | string replace --all ';' '\\x3b' end # Sent right after an interactive command has finished executing. diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts index 9a378d9ff82..fb1da4e5d6b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatActions.ts @@ -28,6 +28,7 @@ import { TerminalChatController } from './terminalChatController.js'; import { TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { isString } from '../../../../../base/common/types.js'; import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IPreferencesService, IOpenSettingsOptions } from '../../../../services/preferences/common/preferences.js'; import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; import { TerminalChatAgentToolsSettingId } from '../../chatAgentTools/common/terminalChatAgentToolsConfiguration.js'; @@ -317,6 +318,15 @@ registerActiveXtermAction({ } }); +type ViewHiddenChatTerminalsEvent = { + hiddenCount: number; +}; +type ViewHiddenChatTerminalsClassification = { + owner: 'anthonykim1'; + comment: 'Tracks when the user opens the hidden chat terminals UI to understand how often users need to reach into agent-owned terminals.'; + hiddenCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The number of hidden chat terminals that existed when the action was invoked. A value of 1 reveals the terminal directly, while more than 1 shows a quick pick.' }; +}; + registerAction2(class ShowChatTerminalsAction extends Action2 { constructor() { super({ @@ -343,6 +353,7 @@ registerAction2(class ShowChatTerminalsAction extends Action2 { const quickInputService = accessor.get(IQuickInputService); const instantiationService = accessor.get(IInstantiationService); const chatService = accessor.get(IChatService); + const telemetryService = accessor.get(ITelemetryService); const visible = new Set([...groupService.instances, ...editorService.instances]); const toolInstances = terminalChatService.getToolSessionTerminalInstances(); @@ -364,12 +375,17 @@ registerAction2(class ShowChatTerminalsAction extends Action2 { return; } + telemetryService.publicLog2('terminal.chatViewHiddenTerminals', { + hiddenCount: all.size, + }); + // If there's only one hidden terminal, show it directly without the quick pick if (all.size === 1) { const instance = Array.from(all.values())[0]; terminalService.setActiveInstance(instance); await terminalService.revealTerminal(instance); await terminalService.focusInstance(instance); + this._logRevealHiddenTerminal(telemetryService, 'single'); return; } @@ -457,6 +473,7 @@ registerAction2(class ShowChatTerminalsAction extends Action2 { await terminalService.revealTerminal(instance); qp.hide(); await terminalService.focusInstance(instance); + this._logRevealHiddenTerminal(telemetryService, 'quickPick'); } else { qp.hide(); } @@ -470,6 +487,18 @@ registerAction2(class ShowChatTerminalsAction extends Action2 { })); qp.show(); } + + private _logRevealHiddenTerminal(telemetryService: ITelemetryService, via: 'single' | 'quickPick'): void { + type RevealHiddenChatTerminalEvent = { + via: 'single' | 'quickPick'; + }; + type RevealHiddenChatTerminalClassification = { + owner: 'anthonykim1'; + comment: 'Tracks when the user reveals and focuses a specific hidden chat terminal, indicating they needed to interact directly with an agent-owned terminal.'; + via: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'How the terminal was revealed: single (only one hidden terminal) or quickPick (selected from the list).' }; + }; + telemetryService.publicLog2('terminal.chatRevealHiddenTerminal', { via }); + } }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 1f8a127c3c4..54268fecd56 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -2776,6 +2776,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._sessionTerminalInstances.delete(chatSessionResource); for (const terminal of terminalsToDispose) { + // Only dispose if the terminal is still hidden from the user. Once + // the user reveals it (via the terminal panel or the outputLocation + // setting), it joins foregroundInstances and should persist so they + // can inspect/interact with it. This prevents user-revealed + // terminals from being destroyed when switching between sessions. + if (this._terminalService.foregroundInstances.includes(terminal)) { + this._logService.debug(`RunInTerminalTool: Skipping disposal of user-revealed terminal ${terminal.instanceId} for session ${chatSessionResource}`); + continue; + } // Skip redundant map walks in onDidDispose since this session has already been removed. this._terminalsBeingDisposedBySessionCleanup.add(terminal); terminal.dispose(); @@ -2785,6 +2794,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const terminalToRemove: string[] = []; for (const [termId, execution] of RunInTerminalTool._activeExecutions.entries()) { if (terminalsToDispose.has(execution.instance)) { + // Skip active executions for terminals that were preserved above + if (this._terminalService.foregroundInstances.includes(execution.instance)) { + continue; + } execution.dispose(); terminalToRemove.push(termId); } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index d0db403c0a5..ba7a257091a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -2483,6 +2483,31 @@ suite('RunInTerminalTool', () => { ok(runInTerminalTool.sessionTerminalAssociations.has(sessionResource2), 'Session 2 terminal association should remain'); }); + test('should not dispose user-revealed terminals when chat session is disposed', () => { + const sessionId = 'test-session-revealed'; + const mockTerminal1 = createMockTerminal(11111); + const mockTerminal2 = createMockTerminal(22222); + + let terminal1Disposed = false; + let terminal2Disposed = false; + mockTerminal1.dispose = () => { terminal1Disposed = true; }; + mockTerminal2.dispose = () => { terminal2Disposed = true; }; + + const sessionResource = LocalChatSessionUri.forSession(sessionId); + runInTerminalTool.sessionTerminalInstances.set(sessionResource, new Set([mockTerminal1, mockTerminal2])); + + // Simulate that terminal2 was revealed by the user (it's in foregroundInstances) + (instantiationService.get(ITerminalService).foregroundInstances as ITerminalInstance[]).push(mockTerminal2); + + chatServiceDisposeEmitter.fire({ sessionResources: [sessionResource], reason: 'cleared' }); + + strictEqual(terminal1Disposed, true, 'Hidden terminal should have been disposed'); + strictEqual(terminal2Disposed, false, 'User-revealed terminal should NOT have been disposed'); + + // Clean up + (instantiationService.get(ITerminalService).foregroundInstances as ITerminalInstance[]).length = 0; + }); + test('should handle disposal of non-existent session gracefully', () => { strictEqual(runInTerminalTool.sessionTerminalAssociations.size, 0, 'No associations should exist initially'); chatServiceDisposeEmitter.fire({ sessionResources: [LocalChatSessionUri.forSession('non-existent-session')], reason: 'cleared' }); diff --git a/test/automation/src/agentsWindow.ts b/test/automation/src/agentsWindow.ts index 78ef507bc63..9070add0243 100644 --- a/test/automation/src/agentsWindow.ts +++ b/test/automation/src/agentsWindow.ts @@ -262,8 +262,13 @@ export class AgentsWindow { * would land in the untitled session and the follow-up never reaches * the intended conversation. When the check fails the active session is * re-activated and the prompt is re-typed before sending. + * + * `activeRowMatch` (defaulting to `expectedActiveLabel`) is forwarded to + * {@link activateSessionByLabel} to locate the row on re-activation; pass + * both the first prompt and the response so row matching is robust against + * the asynchronously generated session title (see that method's docs). */ - async sendFollowUpMessage(prompt: string, sendButtonRetryCount: number = 600, expectedActiveLabel?: string): Promise { + async sendFollowUpMessage(prompt: string, sendButtonRetryCount: number = 600, expectedActiveLabel?: string, activeRowMatch?: string | string[]): Promise { const typeAndSend = async () => { await this.code.waitForElement(ACTIVE_SESSION_INPUT_EDITOR); await this.code.waitAndClick(ACTIVE_SESSION_INPUT_EDITOR); @@ -278,7 +283,7 @@ export class AgentsWindow { if (!stillActive) { // The active slot swapped between activation and send. Re-bind // and re-type the prompt before sending. - await this.activateSessionByLabel(expectedActiveLabel); + await this.activateSessionByLabel(activeRowMatch ?? expectedActiveLabel, expectedActiveLabel); await typeAndSend(); } } @@ -302,11 +307,25 @@ export class AgentsWindow { * untitled session and spawn a brand new agent session instead of * continuing the existing conversation. * - * `label` should be a substring of the session row's text (typically the - * first response text from message 1, e.g. `MOCKED_COPILOT_RESPONSE`). - * We can't simply click the topmost row because the sessions list - * contains workspace folder group headers and historical sessions from - * prior runs. + * `rowMatch` is one (or several) substrings used to locate the row; a row + * matches when its text contains ANY of them. We can't simply click the + * topmost row because the sessions list contains workspace folder group + * headers and historical sessions from prior runs. + * + * Pass BOTH the user's first prompt and the expected response here. The + * row's text is the session title, which is auto-generated asynchronously + * by a utility model after the first turn: until that lands the title is + * the synchronous fallback (the user's prompt), and once it lands the + * title becomes the generated value (which, in the smoke mock, echoes the + * scenario reply because the title prompt embeds the tagged user message). + * Matching on the prompt alone is racy because the generated title can + * replace it; matching on the response alone is racy because the generated + * title may not have landed yet. Accepting either makes activation + * deterministic regardless of when the title generation completes. + * + * `responseLabel` (defaulting to the first `rowMatch` entry) is the text + * the just-completed conversation's response bubble must contain; it is + * verified in the active session view after the row is clicked. * * Returns once the active session has loaded and is ready for input. * @@ -330,37 +349,39 @@ export class AgentsWindow { * guarantees the chat widget has actually re-bound to the session we * intended to activate before the caller types a follow-up. */ - async activateSessionByLabel(label: string, timeoutMs: number = 30_000): Promise { + async activateSessionByLabel(rowMatch: string | string[], responseLabel?: string, timeoutMs: number = 30_000): Promise { const retryCount = Math.ceil(timeoutMs / 100); await this.code.waitForElement(SESSION_LIST_ROW, undefined, retryCount); const workingStatus = 'Working...'; const deadline = Date.now() + timeoutMs; - const needle = label.toLowerCase(); + const rowMatches = Array.isArray(rowMatch) ? rowMatch : [rowMatch]; + const rowNeedles = rowMatches.map(s => s.toLowerCase()); + const responseNeedle = (responseLabel ?? rowMatches[0]).toLowerCase(); const activeResponseSelector = `${ACTIVE_SESSION} .interactive-item-container.interactive-response .rendered-markdown`; let lastTexts: string[] = []; let lastActiveTexts: string[] = []; while (Date.now() < deadline) { const rows = await this.code.getElements(SESSION_LIST_ROW, /* recursive */ true); lastTexts = (rows ?? []).map(r => (r.textContent ?? '').trim()); - const matchIndex = lastTexts.findIndex(t => t.toLowerCase().includes(needle) && !t.includes(workingStatus)); + const matchIndex = lastTexts.findIndex(t => !t.includes(workingStatus) && rowNeedles.some(n => t.toLowerCase().includes(n))); if (matchIndex < 0) { await new Promise(r => setTimeout(r, 250)); continue; } const summary = lastTexts.map((t, i) => `[${i}] ${JSON.stringify(t.slice(0, 120))}`).join('\n'); - console.log(`[agentsWindow] activateSessionByLabel("${label}") clicking index ${matchIndex}; all rows:\n${summary}`); + console.log(`[agentsWindow] activateSessionByLabel(${JSON.stringify(rowMatches)}) clicking index ${matchIndex}; all rows:\n${summary}`); await this.code.waitAndClick(`${SESSION_LIST_ROW}[data-index="${matchIndex}"]`); await this.code.waitForElement(ACTIVE_SESSION_INPUT_EDITOR, undefined, retryCount); // Wait until the active session view's chat widget actually shows a - // response matching `label`. A bare `is-active` check is not enough - // because the workbench may auto-create a fresh untitled session - // and route it into the active slot between row-render and click. + // response matching `responseLabel`. A bare `is-active` check is not + // enough because the workbench may auto-create a fresh untitled + // session and route it into the active slot between row-render and click. while (Date.now() < deadline) { const responses = await this.code.getElements(activeResponseSelector, /* recursive */ true); lastActiveTexts = (responses ?? []).map(el => (el.textContent ?? '').trim()); - if (lastActiveTexts.some(t => t.toLowerCase().includes(needle))) { + if (lastActiveTexts.some(t => t.toLowerCase().includes(responseNeedle))) { return; } await new Promise(r => setTimeout(r, 250)); @@ -368,10 +389,10 @@ export class AgentsWindow { const activeSummary = lastActiveTexts.length ? lastActiveTexts.map((t, i) => ` [${i}] ${JSON.stringify(t.slice(0, 120))}`).join('\n') : ' (no response bubbles in active session view)'; - throw new Error(`Activated row index ${matchIndex} but the active session view never rendered a response containing "${label}". Active view responses:\n${activeSummary}`); + throw new Error(`Activated row index ${matchIndex} but the active session view never rendered a response containing "${responseLabel ?? rowMatches[0]}". Active view responses:\n${activeSummary}`); } const summary = lastTexts.map((t, i) => ` [${i}] ${JSON.stringify(t.slice(0, 120))}`).join('\n'); - throw new Error(`Timed out waiting for a settled session list row containing "${label}" (without "${workingStatus}"). Last-seen rows:\n${summary}`); + throw new Error(`Timed out waiting for a settled session list row containing any of ${JSON.stringify(rowMatches)} (without "${workingStatus}"). Last-seen rows:\n${summary}`); } /** diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index a9aa87a33c1..33d43d8df5e 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -215,8 +215,9 @@ export function setup(logger: Logger) { await app.workbench.agentsWindow.selectSessionType(session.name); const requestsBefore = mockServer.requestCount(); + const firstPrompt = `hello world [scenario:${session.scenarioId}]`; logger.log(`[Agents Window/${session.name}] submitting prompt; requestCount=${requestsBefore}`); - await app.workbench.agentsWindow.submitNewSessionPrompt(`hello world [scenario:${session.scenarioId}]`); + await app.workbench.agentsWindow.submitNewSessionPrompt(firstPrompt); logger.log(`[Agents Window/${session.name}] prompt submitted; waiting for assistant text '${session.reply}'; requestCount=${mockServer.requestCount()}`); const text = await app.workbench.agentsWindow.waitForAssistantText(session.reply); @@ -229,10 +230,15 @@ export function setup(logger: Logger) { // than continuing the existing one. Click back into the // just-completed session before sending message 2 so the // follow-up lands in the same session. Identify the row by - // its msg1 reply text since the sessions list also contains - // workspace folder group headers and historical sessions. + // EITHER the first prompt or the msg1 reply: the row text is + // the session title, which starts as the prompt (synchronous + // fallback) and is asynchronously replaced by a generated + // title (the reply, in the mock). Matching either avoids a + // race on when title generation lands. The sessions list also + // contains workspace folder group headers and historical + // sessions, so we can't just click the topmost row. if (session.name === 'Copilot CLI') { - await app.workbench.agentsWindow.activateSessionByLabel(session.reply); + await app.workbench.agentsWindow.activateSessionByLabel([firstPrompt, session.reply], session.reply); } if (!session.skipReply2) { @@ -244,10 +250,12 @@ export function setup(logger: Logger) { // a fresh untitled session between `activateSessionByLabel` // returning and the send-button click). const expectedActiveLabel = session.name === 'Copilot CLI' ? session.reply : undefined; + const activeRowMatch = session.name === 'Copilot CLI' ? [firstPrompt, session.reply] : undefined; await app.workbench.agentsWindow.sendFollowUpMessage( `hello again [scenario:${session.scenarioId2}]`, undefined, expectedActiveLabel, + activeRowMatch, ); const secondTurnTimeout = session.name === 'Copilot CLI' ? 180_000 : 60_000;