mirror of
https://github.com/microsoft/vscode.git
synced 2026-06-29 19:06:00 +01:00
Merge remote-tracking branch 'origin/main' into connor/agent-host-connection-unification
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+4
-4
@@ -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",
|
||||
|
||||
+2
-2
@@ -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"] }
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ pub async fn agent_ps(ctx: CommandContext, args: AgentPsArgs) -> Result<i32, Any
|
||||
/// A session is "active" if it is in-progress, needs input, or errored
|
||||
/// (i.e. not just idle/archived).
|
||||
fn is_active(status: u32) -> 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<String> {
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<i32, AnyError> {
|
||||
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<i32,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let turn_id = match result.snapshot.map(|s| s.state) {
|
||||
Some(SnapshotState::Session(session)) => 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<String> = 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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, ISdkQuotaSnapshot>): QuotaSnapshots {
|
||||
const result: Record<string, QuotaSnapshot> = {};
|
||||
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;
|
||||
|
||||
+103
-1
@@ -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<SessionOptions['authInfo']>;
|
||||
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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, unknown>).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')
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>): Record<string, unknown> | 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<string, unknown> = 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<string, unknown>);
|
||||
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(', ')})` };
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Generated
+4
-4
@@ -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"
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
@@ -64,7 +64,7 @@ export const generateUuid = (function (): () => string {
|
||||
};
|
||||
})();
|
||||
|
||||
/** Namespace should be 3 letter. */
|
||||
/** Namespace should be 3 letters, e.g. `abc-<uuid>`. */
|
||||
export function prefixedUuid(namespace: string): string {
|
||||
return `${namespace}-${generateUuid()}`;
|
||||
}
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<unknown>;
|
||||
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<number, { deferred: DeferredPromise<unknown>; sentAt: number }>();
|
||||
private readonly _pendingRequests = new Map<number, IPendingRequest>();
|
||||
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<unknown>();
|
||||
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<TResult>;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>).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<UsageInfoMeta['quotaSnapshots']> = {};
|
||||
let hasAny = false;
|
||||
for (const [quotaType, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const v = value as Record<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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;
|
||||
|
||||
@@ -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<Record<string, boolean>> = {
|
||||
'**/*-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<string> {
|
||||
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<void> {
|
||||
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<ToolCallConfirmationReason | undefined> {
|
||||
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<boolean> {
|
||||
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<string[] | undefined> {
|
||||
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)) {
|
||||
|
||||
+44
-3
@@ -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();
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<T>(manager: AgentHostStateManager, match: () => T | undefined): Promise<T> {
|
||||
return new Promise<T>((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 },
|
||||
]);
|
||||
|
||||
@@ -520,6 +520,19 @@ async function disposeAgent(agent: CopilotAgent): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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<string, Error>();
|
||||
readonly readErrors = new Map<string, Error>();
|
||||
readonly listedSessions: IAgentSessionMetadata[] = [];
|
||||
readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = [];
|
||||
|
||||
@@ -155,8 +164,12 @@ class MockAgentService implements IAgentService {
|
||||
],
|
||||
};
|
||||
}
|
||||
async resourceRead(_uri: URI): Promise<ResourceReadResult> {
|
||||
throw new Error('Not implemented');
|
||||
async resourceRead(uri: URI): Promise<ResourceReadResult> {
|
||||
const error = this.readErrors.get(uri.toString());
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
return { data: '', encoding: ContentEncoding.Utf8 };
|
||||
}
|
||||
async resourceCopy(_params: ResourceCopyParams): Promise<ResourceCopyResult> { 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 () => {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
|
||||
@@ -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<void>;
|
||||
runTask(task: ITaskEntry, session: ISession): Promise<IDisposable | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<void>;
|
||||
runTask(task: ITaskEntry, session: ISession): Promise<IDisposable | undefined>;
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
async runTask(task: ITaskEntry, session: ISession): Promise<IDisposable | undefined> {
|
||||
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<string | undefined> {
|
||||
|
||||
@@ -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<void> {
|
||||
async runTask(task: ITaskEntry, session: ISession): Promise<IDisposable | undefined> {
|
||||
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) {
|
||||
|
||||
@@ -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<void> {
|
||||
private async _dispatchWorktreeCreatedTasks(session: ISession, taskHandles: DisposableStore): Promise<void> {
|
||||
if (isAgentHostProviderId(session.providerId) && !this._configurationService.getValue<boolean>(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}`);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ suite('WorkbenchSessionTaskRunner', () => {
|
||||
const store = new DisposableStore();
|
||||
let runner: WorkbenchSessionTaskRunner;
|
||||
let ranTasks: { label: string }[];
|
||||
let terminatedTasks: { label: string }[];
|
||||
let tasksByLabel: Map<string, Task>;
|
||||
let workspaceFoldersByUri: Map<string, IWorkspaceFolder>;
|
||||
|
||||
@@ -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<IWorkspaceContextService>() {
|
||||
@@ -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' }]);
|
||||
});
|
||||
|
||||
@@ -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<typeof observableValue<boolean>>;
|
||||
readonly status: ReturnType<typeof observableValue<SessionStatus>>;
|
||||
readonly workspace: ReturnType<typeof observableValue<ISessionWorkspace | undefined>>;
|
||||
readonly isArchived: ReturnType<typeof observableValue<boolean>>;
|
||||
}
|
||||
|
||||
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<ISessionWorkspace | undefined>('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<ISessionsTasksService> {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
readonly ranTasks: { label: string; sessionId: string }[] = [];
|
||||
readonly stoppedTasks: { label: string; sessionId: string }[] = [];
|
||||
private readonly _tasks = new Map<string, readonly ISessionTaskWithTarget[]>();
|
||||
runTaskFails = false;
|
||||
|
||||
@@ -102,11 +105,12 @@ class FakeSessionsTasksService implements Partial<ISessionsTasksService> {
|
||||
return this._tasks.get(session.sessionId) ?? [];
|
||||
}
|
||||
|
||||
async runTask(task: ITaskEntry, session: ISession): Promise<void> {
|
||||
async runTask(task: ITaskEntry, session: ISession): Promise<IDisposable | undefined> {
|
||||
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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
async runTask(task: ITaskEntry, session: ISession): Promise<IDisposable | undefined> {
|
||||
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 {
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
|
||||
@@ -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<unknown>();
|
||||
// `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<IChatResponsePart | IChatResponsePart[]>();
|
||||
|
||||
try {
|
||||
|
||||
@@ -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<boolean>('agentsVoiceWindowVisible', false);
|
||||
export const AGENTS_VOICE_WIDGET_FOCUSED = new RawContextKey<boolean>('agentsVoiceWidgetFocused', false);
|
||||
const AGENTS_VOICE_CONNECTED = new RawContextKey<boolean>('agentsVoiceConnected', false);
|
||||
const AGENTS_VOICE_CONNECTING = new RawContextKey<boolean>('agentsVoiceConnecting', false);
|
||||
const AGENTS_VOICE_LISTENING = new RawContextKey<boolean>('agentsVoiceListening', false);
|
||||
const AGENTS_VOICE_ACTIVE = new RawContextKey<boolean>('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<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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'],
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
const disposables = new DisposableStore();
|
||||
const picker = disposables.add(this.quickInputService.createQuickPick<IVoiceSessionPickItem>({ 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<VoiceWidgetOptions> = {
|
||||
@@ -109,6 +122,7 @@ const DEFAULT_OPTIONS: Required<VoiceWidgetOptions> = {
|
||||
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<VoiceWidgetOptions>;
|
||||
|
||||
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: '<angle>'; 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%)`;
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<void> {
|
||||
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<typeof setTimeout> | 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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)'; });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ChatStopCancellationNoopEvent, ChatStopCancellationNoopClassification>(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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-75
@@ -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<IAgentSessionMetadata[]> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly _delegate: IAgentHostService,
|
||||
) { }
|
||||
|
||||
get onDidNotification(): IAgentHostSessionListConnection['onDidNotification'] {
|
||||
return this._delegate.onDidNotification;
|
||||
}
|
||||
|
||||
disposeSession(session: URI): Promise<void> {
|
||||
return this._delegate.disposeSession(session);
|
||||
}
|
||||
|
||||
listSessions(): Promise<IAgentSessionMetadata[]> {
|
||||
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<AgentProvider, DisposableStore>());
|
||||
/** Model providers keyed by agent provider, for pushing model updates. */
|
||||
private readonly _modelProviders = new Map<AgentProvider, AgentHostLanguageModelProvider>();
|
||||
/** List controllers keyed by agent provider, for cache resets on reconnect. */
|
||||
private readonly _listControllers = new Map<AgentProvider, AgentHostSessionListController>();
|
||||
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<boolean>(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));
|
||||
|
||||
+14
-6
@@ -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<readonly IActionListItem<IConfigPickerItem>[]>(200));
|
||||
private readonly _subRef = this._register(new MutableDisposable<IDisposable & { readonly sub: IAgentSubscription<SessionState>; readonly backendSession: URI }>());
|
||||
private _container: HTMLElement | undefined;
|
||||
|
||||
private _initialResolved: { readonly sessionResource: URI; readonly result: ResolveSessionConfigResult } | undefined;
|
||||
private readonly _initialResolveCts = this._register(new MutableDisposable<CancellationTokenSource>());
|
||||
|
||||
constructor(
|
||||
private readonly _widget: IChatWidget,
|
||||
@@ -257,6 +256,15 @@ export class AgentHostChatInputPicker extends Disposable {
|
||||
this._reattach();
|
||||
}
|
||||
|
||||
private _registerInitialResolveCts(): MutableDisposable<CancellationTokenSource> {
|
||||
const cts = new MutableDisposable<CancellationTokenSource>();
|
||||
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();
|
||||
|
||||
+27
-1
@@ -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,
|
||||
|
||||
+149
@@ -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<IAgentSessionMetadata[]> | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly _delegate: IAgentHostService,
|
||||
) { }
|
||||
|
||||
get onDidNotification(): IAgentHostSessionListConnection['onDidNotification'] {
|
||||
return this._delegate.onDidNotification;
|
||||
}
|
||||
|
||||
disposeSession(session: URI): Promise<void> {
|
||||
return this._delegate.disposeSession(session);
|
||||
}
|
||||
|
||||
listSessions(): Promise<IAgentSessionMetadata[]> {
|
||||
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<AgentProvider, DisposableStore>());
|
||||
private readonly _listControllers = new Map<AgentProvider, AgentHostSessionListController>();
|
||||
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<boolean>(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)));
|
||||
}
|
||||
}
|
||||
+90
-1
@@ -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<NonNullable<UsageInfoMeta['quotaSnapshots']>[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<IAgentHostQuotaUpdate> = {};
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<IVoiceSessionController>('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<void> {
|
||||
const target = this._targetSession.get();
|
||||
if (target) {
|
||||
// Try switching to the session via the workbench chat pane first
|
||||
const switched = await this.commandService.executeCommand<boolean>('_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<string | undefined>('_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<boolean>('_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<boolean>('_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 } : {}),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Ch
|
||||
const isSystemInitiatedRequest = isRequestVM(element) && !!element.isSystemInitiated;
|
||||
|
||||
templateData.username.textContent = element.username;
|
||||
templateData.username.classList.toggle('hidden', element.username === COPILOT_USERNAME || this.environmentService.isSessionsWindow || isSystemInitiatedRequest);
|
||||
templateData.avatarContainer.classList.toggle('hidden', element.username === COPILOT_USERNAME || this.environmentService.isSessionsWindow || isSystemInitiatedRequest);
|
||||
const hideChatUserIdentity = shouldHideChatUserIdentity(element.username, element.sessionResource, isResponseVM(element), this.environmentService.isSessionsWindow, isSystemInitiatedRequest);
|
||||
templateData.username.classList.toggle('hidden', hideChatUserIdentity);
|
||||
templateData.avatarContainer.classList.toggle('hidden', hideChatUserIdentity);
|
||||
|
||||
this.hoverHidden(templateData.requestHover);
|
||||
dom.clearNode(templateData.detail);
|
||||
|
||||
@@ -4440,3 +4440,22 @@ have to be updated for changes to the rules above, or to support more deeply nes
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.monaco-workbench .chat-model-hover-configurable > .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;
|
||||
}
|
||||
|
||||
@@ -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<IChatModelInputState> {
|
||||
@@ -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<boolean>('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<boolean>('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<ArrayBuffer>);
|
||||
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<VoiceOnboardingCompletedEvent, VoiceOnboardingCompletedClassification>('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<boolean>('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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+56
-7
@@ -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<IChatWidgetService>() {
|
||||
|
||||
// ---- Helpers ----------------------------------------------------------------
|
||||
|
||||
function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial<IAuthenticationService>, languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>, provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService>) {
|
||||
function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial<IAuthenticationService>, languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>, provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService>, 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<IOpenerService> = {
|
||||
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<IWorkbenchEnvironmentService>);
|
||||
instantiationService.stub(IWorkbenchEnvironmentService, { isSessionsWindow } as Partial<IWorkbenchEnvironmentService>);
|
||||
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<IAuthenticationService>; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>; provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService> }) {
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
+94
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof createMockEntitlementService>[0], modelOpts?: { vendor?: string }) {
|
||||
function createContribution(entitlementOpts?: Parameters<typeof createMockEntitlementService>[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', () => {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<DeleteHiddenChatTerminalsEvent, DeleteHiddenChatTerminalsClassification>('terminal.chatDeleteHiddenTerminals', {
|
||||
count: hiddenTerminals.length,
|
||||
});
|
||||
|
||||
await Promise.all(hiddenTerminals.map(terminal => this._terminalService.safeDisposeTerminal(terminal)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<ITerminalInstance>([...groupService.instances, ...editorService.instances]);
|
||||
const toolInstances = terminalChatService.getToolSessionTerminalInstances();
|
||||
@@ -364,12 +375,17 @@ registerAction2(class ShowChatTerminalsAction extends Action2 {
|
||||
return;
|
||||
}
|
||||
|
||||
telemetryService.publicLog2<ViewHiddenChatTerminalsEvent, ViewHiddenChatTerminalsClassification>('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<RevealHiddenChatTerminalEvent, RevealHiddenChatTerminalClassification>('terminal.chatRevealHiddenTerminal', { via });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
|
||||
+25
@@ -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' });
|
||||
|
||||
@@ -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<void> {
|
||||
async sendFollowUpMessage(prompt: string, sendButtonRetryCount: number = 600, expectedActiveLabel?: string, activeRowMatch?: string | string[]): Promise<void> {
|
||||
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<void> {
|
||||
async activateSessionByLabel(rowMatch: string | string[], responseLabel?: string, timeoutMs: number = 30_000): Promise<void> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user