Merge remote-tracking branch 'origin/main' into connor/agent-host-connection-unification

This commit is contained in:
Connor Peet
2026-06-20 08:05:14 -07:00
90 changed files with 3627 additions and 721 deletions
+22 -17
View File
@@ -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
+25 -18
View File
@@ -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
+36 -31
View File
@@ -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
+27
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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"] }
+17 -16
View File
@@ -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));
}
}
+9 -8
View File
@@ -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()))
}
}
+56 -28
View File
@@ -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;
@@ -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 } : {}),
+4 -4
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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()}`;
}
+13
View File
@@ -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;
}
+11 -3
View File
@@ -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)) {
@@ -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
@@ -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();
}
}
}
@@ -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));
@@ -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();
@@ -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,
@@ -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)));
}
}
@@ -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);
@@ -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');
@@ -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 });
}
});
@@ -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);
}
@@ -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' });
+38 -17
View File
@@ -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;