mirror of
https://github.com/microsoft/vscode.git
synced 2026-09-05 12:35:06 +01:00
Merge remote-tracking branch 'origin/main' into connor4312/agents-enablement-2
# Conflicts: # src/vs/platform/agentHost/node/copilot/copilotAgent.ts # src/vs/platform/agentHost/test/node/agentSideEffects.test.ts # src/vs/platform/agentHost/test/node/copilotAgent.test.ts # src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts
This commit is contained in:
@@ -203,6 +203,7 @@ Then read the relevant spec for the area you are changing (see table below). If
|
||||
- **Chat file pills classify files against the owning session, never the window-global workspace context**: multiple sessions can render concurrently, so `IChat.lastTurnChanges` carries `isOutsideWorkspace` derived from that session's workspace/worktree roots, and per-response file edits carry the same metadata. `AgentHostSessionAdapter` owns a generic session-output cache passed to output reducers; workspace classification uses the namespaced key `isOutsideWorkspace:${uri.toString()}`, and workspace changes clear the cache. Keep change counts/diffs workspace-only, preview only external markdown, and open resources through `chat.editorAssociations` rather than invoking `markdown.showPreview` directly.
|
||||
- **Agent-host-only exclusions for built-in client tools belong in `ClientToolSetsContribution`, not the global tool registration**: `AgentHostActiveClientService.getClientTools` advertises enabled members of every non-deprecated tool set, including extension-contributed sets. For `vscode-general`, the Agents window exposes only Tool Search; gate every other member on `!IAICustomizationWorkspaceService.isSessionsWindow` so editor-window Agent Host sessions and normal Copilot chat retain them.
|
||||
- **Non-interactive MCP authentication probes must not create dynamic authentication providers**: Provider creation can prompt for manual client registration when dynamic registration is unsupported. With `allowInteraction: false`, only inspect existing providers and sessions; defer metadata discovery and provider creation until the user invokes the `mcpAuthenticationRequired` action.
|
||||
- **A host auth failure does not prove the client's current token is stale**: on the first `auth/required`, re-resolve and force-forward the current token for that connection and exact protected resource. Escalate to the shared sign-in flow only when a later challenge rejects that completed resend and the client still resolves the same token; keep challenge and transformed-token state connection-scoped so multiple hosts cannot independently rotate shared authentication.
|
||||
- **Use structured maps for the state that is actually multi-keyed, not for an incidental cache**: If MCP tracking is addressed by session + server, model that source of truth directly with `NKeyMap`. Do not add a separate `NKeyMap` that merely caches serialized storage keys while leaving the real tracking state in nested or synchronized maps.
|
||||
- **Subagent activity rows must preserve rich tool presentation, stable height, tool identity, and protocol intent**: Do not flatten markdown invocation messages into text, omit the shared tool icon, or show a raw terminal command when `ToolCallBase.intention` exists. Render invocation markdown with the shared chat/file-widget path and the registered/inferred compact tool icon, keep it constrained to one line within a static minimum-height slot so text, code, and file chips do not shift surrounding content, and use terminal intention before invocation-message fallback.
|
||||
- **Subagent reasoning preserves the last tool activity**: Show "Working on it..." only during startup (before any tool is known) and while child markdown is streaming. Child reasoning must not replace the activity row; retain the most recent tool presentation, or keep the startup placeholder when no tool has run yet.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
jobs:
|
||||
- job: Quality
|
||||
displayName: Quality Checks
|
||||
timeoutInMinutes: 20
|
||||
# Leave enough time for NOTICE retries and the fallback path to finish.
|
||||
timeoutInMinutes: 30
|
||||
variables:
|
||||
- name: skipComponentGovernanceDetection
|
||||
value: true
|
||||
@@ -154,7 +155,7 @@ jobs:
|
||||
inputs:
|
||||
outputfile: $(Build.SourcesDirectory)/ThirdPartyNotices.generated.txt
|
||||
retryCountOnTaskFailure: 3
|
||||
timeoutInMinutes: 10
|
||||
timeoutInMinutes: 15
|
||||
continueOnError: true
|
||||
condition: and(succeeded(), eq(lower(variables['VSCODE_CIBUILD']), 'false'))
|
||||
|
||||
|
||||
@@ -993,6 +993,7 @@
|
||||
"--prompt-timeline-gutter-dot-size",
|
||||
"--prompt-timeline-gutter-dot-gap",
|
||||
"--prompt-timeline-gutter-more-height",
|
||||
"--chat-input-notice-severity",
|
||||
"--chat-editing-last-edit-shift",
|
||||
"--chat-voice-icon-glow-color",
|
||||
"--sessions-voice-icon-glow-color",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Raw } from '@vscode/prompt-tsx';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import type * as vscode from 'vscode';
|
||||
import { IChatHookService, type IPreToolUseHookResult } from '../../../../../platform/chat/common/chatHookService';
|
||||
@@ -457,6 +458,104 @@ describe('ChatToolCalls (toolCalling.tsx)', () => {
|
||||
expect(contentText).not.toContain('<PostToolUse-context>');
|
||||
});
|
||||
|
||||
test('synthesizes missing historical tool results for stateful Responses rounds', async () => {
|
||||
const toolName = 'testTool';
|
||||
const completedCallId = 'call-completed';
|
||||
const missingCallIds = ['call-missing-1', 'call-missing-2'];
|
||||
const toolInfo: vscode.LanguageModelToolInformation = {
|
||||
name: toolName,
|
||||
description: 'test tool',
|
||||
source: undefined,
|
||||
inputSchema: undefined,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const testingServiceCollection = createExtensionUnitTestingServices();
|
||||
testingServiceCollection.define(IToolsService, new CapturingToolsService(toolInfo));
|
||||
|
||||
const accessor = testingServiceCollection.createTestingAccessor();
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const endpointProvider = accessor.get(IEndpointProvider);
|
||||
const endpoint = await endpointProvider.getChatEndpoint('copilot-utility');
|
||||
const responsesEndpoint = Object.create(endpoint) as IChatEndpoint;
|
||||
Object.defineProperty(responsesEndpoint, 'apiType', { value: 'responses' });
|
||||
|
||||
const round: IToolCallRound = {
|
||||
id: 'round-stateful',
|
||||
response: 'calling tools',
|
||||
toolInputRetry: 0,
|
||||
statefulMarker: 'resp-stateful',
|
||||
toolCalls: [completedCallId, ...missingCallIds].map(id => ({ name: toolName, arguments: '{}', id })),
|
||||
};
|
||||
const toolCallResults: Record<string, vscode.LanguageModelToolResult> = {
|
||||
[completedCallId]: new LanguageModelToolResult([new LanguageModelTextPart('completed output')]),
|
||||
};
|
||||
const promptContext: IBuildPromptContext = {
|
||||
query: 'continue',
|
||||
history: [],
|
||||
chatVariables: new ChatVariablesCollection(),
|
||||
conversation: { sessionId: 'session-stateful' } as unknown as Conversation,
|
||||
request: {} as vscode.ChatRequest,
|
||||
tools: {
|
||||
toolReferences: [],
|
||||
toolInvocationToken: {} as vscode.ChatParticipantToolToken,
|
||||
availableTools: [toolInfo],
|
||||
},
|
||||
};
|
||||
|
||||
const { messages } = await renderPromptElement(instantiationService, responsesEndpoint, ChatToolCalls, {
|
||||
promptContext,
|
||||
toolCallRounds: [round],
|
||||
toolCallResults,
|
||||
isHistorical: true,
|
||||
});
|
||||
const assistantMessage = messages.find((message): message is Raw.AssistantChatMessage => message.role === Raw.ChatRole.Assistant);
|
||||
const toolMessages = messages.filter((message): message is Raw.ToolChatMessage => message.role === Raw.ChatRole.Tool);
|
||||
const toolOutputs = toolMessages.map(message => ({
|
||||
id: message.toolCallId,
|
||||
text: message.content
|
||||
.filter((part): part is Raw.ChatCompletionContentPartText => part.type === Raw.ChatCompletionContentPartKind.Text)
|
||||
.map(part => part.text)
|
||||
.join(''),
|
||||
}));
|
||||
|
||||
const { messages: nonResponsesMessages } = await renderPromptElement(instantiationService, endpoint, ChatToolCalls, {
|
||||
promptContext,
|
||||
toolCallRounds: [round],
|
||||
toolCallResults,
|
||||
isHistorical: true,
|
||||
});
|
||||
const nonResponsesAssistantMessage = nonResponsesMessages.find((message): message is Raw.AssistantChatMessage => message.role === Raw.ChatRole.Assistant);
|
||||
const { messages: markerlessResponsesMessages } = await renderPromptElement(instantiationService, responsesEndpoint, ChatToolCalls, {
|
||||
promptContext,
|
||||
toolCallRounds: [{ ...round, statefulMarker: undefined }],
|
||||
toolCallResults,
|
||||
isHistorical: true,
|
||||
});
|
||||
const markerlessResponsesAssistantMessage = markerlessResponsesMessages.find((message): message is Raw.AssistantChatMessage => message.role === Raw.ChatRole.Assistant);
|
||||
|
||||
expect({
|
||||
assistantToolCallIds: assistantMessage?.toolCalls?.map(call => call.id),
|
||||
toolOutputs,
|
||||
nonResponsesToolCallIds: nonResponsesAssistantMessage?.toolCalls?.map(call => call.id),
|
||||
markerlessResponsesToolCallIds: markerlessResponsesAssistantMessage?.toolCalls?.map(call => call.id),
|
||||
}).toEqual({
|
||||
assistantToolCallIds: [completedCallId, ...missingCallIds],
|
||||
toolOutputs: [
|
||||
{ id: completedCallId, text: 'completed output' },
|
||||
...missingCallIds.map(id => ({
|
||||
id,
|
||||
text: JSON.stringify({
|
||||
status: 'outcome_unknown',
|
||||
message: 'No tool output was recorded. Verify the current state before retrying this tool if its result is still needed.',
|
||||
}),
|
||||
})),
|
||||
],
|
||||
nonResponsesToolCallIds: [completedCallId],
|
||||
markerlessResponsesToolCallIds: [completedCallId],
|
||||
});
|
||||
});
|
||||
|
||||
test('replaces images with placeholders for historical turns', async () => {
|
||||
const toolName = 'viewImage';
|
||||
const toolCallId = 'call-img-1';
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CompactionDataContainer } from '../../../../platform/endpoint/common/co
|
||||
import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider';
|
||||
import { CacheType } from '../../../../platform/endpoint/common/endpointTypes';
|
||||
import { PhaseDataContainer } from '../../../../platform/endpoint/common/phaseDataContainer';
|
||||
import { StatefulMarkerContainer } from '../../../../platform/endpoint/common/statefulMarkerContainer';
|
||||
import { MISSING_STATEFUL_TOOL_RESULT, StatefulMarkerContainer } from '../../../../platform/endpoint/common/statefulMarkerContainer';
|
||||
import { ThinkingDataContainer } from '../../../../platform/endpoint/common/thinkingDataContainer';
|
||||
import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
|
||||
import { IIgnoreService } from '../../../../platform/ignore/common/ignoreService';
|
||||
@@ -104,8 +104,13 @@ export class ChatToolCalls extends PromptElement<ChatToolCallsProps, void> {
|
||||
*/
|
||||
private renderOneToolCallRound(round: IToolCallRound, index: number, total: number, hydratedInstantiationService: IInstantiationService, sharedImageBudget: SharedImageBudget, token?: CancellationToken): PromptElement[] {
|
||||
let fixedNameToolCalls = round.toolCalls.map(tc => ({ ...tc, name: this.toolsService.validateToolName(tc.name) ?? tc.name }));
|
||||
// A Responses marker retains every function call server-side. Close calls whose local
|
||||
// results were lost so the next request can safely reuse previous_response_id.
|
||||
const shouldSynthesizeMissingToolResults = this.props.isHistorical
|
||||
&& this.promptEndpoint.apiType === 'responses'
|
||||
&& !!round.statefulMarker;
|
||||
if (this.props.isHistorical) {
|
||||
fixedNameToolCalls = fixedNameToolCalls.filter(tc => tc.id && this.props.toolCallResults?.[tc.id]);
|
||||
fixedNameToolCalls = fixedNameToolCalls.filter(tc => tc.id && (this.props.toolCallResults?.[tc.id] || shouldSynthesizeMissingToolResults));
|
||||
}
|
||||
|
||||
if (round.toolCalls.length && !fixedNameToolCalls.length) {
|
||||
@@ -160,7 +165,8 @@ export class ChatToolCalls extends PromptElement<ChatToolCallsProps, void> {
|
||||
{hydratedInstantiationService.invokeFunction(buildToolResultElement, {
|
||||
toolCall: toolCall,
|
||||
toolInvocationToken: this.props.promptContext.tools!.toolInvocationToken,
|
||||
toolCallResult: this.props.toolCallResults?.[toolCall.id!],
|
||||
toolCallResult: this.props.toolCallResults?.[toolCall.id!]
|
||||
?? (shouldSynthesizeMissingToolResults ? textToolResult(MISSING_STATEFUL_TOOL_RESULT) : undefined),
|
||||
allowInvokingTool: !this.props.isHistorical,
|
||||
validateInput: round.toolInputRetry < MAX_INPUT_VALIDATION_RETRIES,
|
||||
requestId: this.props.promptContext.requestId,
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
import { BasePromptElementProps, PromptElement, Raw } from '@vscode/prompt-tsx';
|
||||
import { CustomDataPartMimeTypes } from './endpointTypes';
|
||||
|
||||
export const MISSING_STATEFUL_TOOL_RESULT = JSON.stringify({
|
||||
status: 'outcome_unknown',
|
||||
message: 'No tool output was recorded. Verify the current state before retrying this tool if its result is still needed.',
|
||||
});
|
||||
|
||||
/**
|
||||
* A type representing a stateful marker that can be stored in an opaque part in raw chat messages.
|
||||
*/
|
||||
|
||||
@@ -30,7 +30,7 @@ import { TelemetryData } from '../../telemetry/common/telemetryData';
|
||||
import { getVerbosityForModelSync, modelSupportCacheBreakPoints } from '../common/chatModelCapabilities';
|
||||
import { rawPartAsCompactionData } from '../common/compactionDataContainer';
|
||||
import { rawPartAsPhaseData } from '../common/phaseDataContainer';
|
||||
import { getIndexOfStatefulMarker, getStatefulMarkerAndIndex } from '../common/statefulMarkerContainer';
|
||||
import { getIndexOfStatefulMarker, getStatefulMarkerAndIndex, MISSING_STATEFUL_TOOL_RESULT } from '../common/statefulMarkerContainer';
|
||||
import { rawPartAsThinkingData } from '../common/thinkingDataContainer';
|
||||
import { createResponsesStreamDumper } from './responsesApiDebugDump';
|
||||
|
||||
@@ -338,6 +338,14 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe
|
||||
markerIndex = undefined;
|
||||
}
|
||||
|
||||
let statefulToolCalls: Array<{ id: string; name: string }> = [];
|
||||
if (markerIndex !== undefined) {
|
||||
const markerMessage = messages[markerIndex];
|
||||
if (markerMessage.role === Raw.ChatRole.Assistant && markerMessage.toolCalls?.length) {
|
||||
statefulToolCalls = markerMessage.toolCalls.map(toolCall => ({ id: toolCall.id, name: toolCall.function.name }));
|
||||
}
|
||||
}
|
||||
|
||||
const toolSearchCallIds = new Set<string>();
|
||||
const toolSearchLoadedTools = new Set<string>();
|
||||
// Only pre-scan when history will be sliced (matches the slicing block below);
|
||||
@@ -380,7 +388,33 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe
|
||||
messages = messages.slice(latestCompactionMessageIndex);
|
||||
}
|
||||
|
||||
// The server retains calls from previous_response_id even when prompt pruning removes
|
||||
// their local results. Close every call absent from the final post-marker message slice.
|
||||
const sentToolResultIds = new Set(messages
|
||||
.filter((message): message is Raw.ToolChatMessage => message.role === Raw.ChatRole.Tool)
|
||||
.map(message => message.toolCallId));
|
||||
statefulToolCalls = statefulToolCalls.filter(toolCall => !sentToolResultIds.has(toolCall.id));
|
||||
|
||||
const input: OpenAI.Responses.ResponseInputItem[] = [];
|
||||
for (const toolCall of statefulToolCalls) {
|
||||
if (toolCall.name === CUSTOM_TOOL_SEARCH_NAME) {
|
||||
input.push({
|
||||
type: 'tool_search_output',
|
||||
execution: 'client',
|
||||
call_id: toolCall.id,
|
||||
status: 'completed',
|
||||
tools: [],
|
||||
} satisfies ResponsesToolSearchOutputInput as unknown as OpenAI.Responses.ResponseInputItem);
|
||||
} else {
|
||||
input.push({
|
||||
type: 'function_call_output',
|
||||
call_id: toolCall.id,
|
||||
output: supportsCacheBreakpoints
|
||||
? [{ type: 'input_text', text: MISSING_STATEFUL_TOOL_RESULT }]
|
||||
: MISSING_STATEFUL_TOOL_RESULT,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const message of messages) {
|
||||
switch (message.role) {
|
||||
case Raw.ChatRole.Assistant:
|
||||
|
||||
@@ -22,6 +22,7 @@ import { createFakeStreamResponse } from '../../../test/node/fetcher';
|
||||
import { createPlatformServices } from '../../../test/node/services';
|
||||
import type { ThinkingData } from '../../../thinking/common/thinking';
|
||||
import { CacheType, CustomDataPartMimeTypes } from '../../common/endpointTypes';
|
||||
import { MISSING_STATEFUL_TOOL_RESULT } from '../../common/statefulMarkerContainer';
|
||||
import { createResponsesRequestBody, getResponsesApiCompactionThresholdFromBody, OpenAIResponsesProcessor, processResponseFromChatEndpoint, responseApiInputToRawMessagesForLogging } from '../responsesApi';
|
||||
|
||||
const testEndpoint: IChatEndpoint = {
|
||||
@@ -771,6 +772,56 @@ describe('createResponsesRequestBody', () => {
|
||||
services.dispose();
|
||||
});
|
||||
|
||||
it('synthesizes outputs for calls missing after a reused HTTP stateful marker', () => {
|
||||
const services = createPlatformServices();
|
||||
const accessor = services.createTestingAccessor();
|
||||
const instantiationService = accessor.get(IInstantiationService);
|
||||
const completedCallId = 'call-completed';
|
||||
const missingCallIds = ['call-missing-1', 'call-missing-2'];
|
||||
const markerMessage: Raw.AssistantChatMessage = {
|
||||
...createStatefulMarkerMessage(testEndpoint.model, 'resp-prev') as Raw.AssistantChatMessage,
|
||||
toolCalls: [completedCallId, ...missingCallIds].map(id => ({
|
||||
id,
|
||||
type: 'function',
|
||||
function: { name: 'test_tool', arguments: '{}' },
|
||||
})),
|
||||
};
|
||||
const messages: Raw.ChatMessage[] = [
|
||||
markerMessage,
|
||||
{
|
||||
role: Raw.ChatRole.Tool,
|
||||
toolCallId: completedCallId,
|
||||
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'completed output' }],
|
||||
},
|
||||
{
|
||||
role: Raw.ChatRole.User,
|
||||
content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'continue' }],
|
||||
},
|
||||
];
|
||||
|
||||
const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), testEndpoint.model, testEndpoint));
|
||||
const outputs = body.input
|
||||
?.filter(item => item.type === 'function_call_output')
|
||||
.map(item => {
|
||||
const output = item as OpenAI.Responses.ResponseInputItem.FunctionCallOutput;
|
||||
return { callId: output.call_id, output: output.output };
|
||||
});
|
||||
|
||||
expect({
|
||||
previousResponseId: body.previous_response_id,
|
||||
outputs,
|
||||
}).toEqual({
|
||||
previousResponseId: 'resp-prev',
|
||||
outputs: [
|
||||
...missingCallIds.map(callId => ({ callId, output: MISSING_STATEFUL_TOOL_RESULT })),
|
||||
{ callId: completedCallId, output: 'completed output' },
|
||||
],
|
||||
});
|
||||
|
||||
accessor.dispose();
|
||||
services.dispose();
|
||||
});
|
||||
|
||||
it('does not reuse an HTTP stateful marker when modeChanged is true', () => {
|
||||
const services = createPlatformServices();
|
||||
const accessor = services.createTestingAccessor();
|
||||
|
||||
@@ -424,6 +424,9 @@ export class TreeRenderer<T, TFilterData, TRef, TTemplateData> implements IListR
|
||||
}
|
||||
|
||||
renderTemplate(container: HTMLElement): ITreeListTemplateData<TTemplateData> {
|
||||
if (this.renderer.rowClassName) {
|
||||
container.classList.add(this.renderer.rowClassName);
|
||||
}
|
||||
const el = append(container, $('.monaco-tl-row'));
|
||||
const indent = append(el, $('.monaco-tl-indent'));
|
||||
const twistie = append(el, $('.monaco-tl-twistie'));
|
||||
|
||||
@@ -164,6 +164,8 @@ export interface ITreeElementRenderDetails extends IListElementRenderDetails {
|
||||
}
|
||||
|
||||
export interface ITreeRenderer<T, TFilterData = void, TTemplateData = void> extends IListRenderer<ITreeNode<T, TFilterData>, TTemplateData> {
|
||||
/** CSS class applied to list rows created for this renderer. */
|
||||
readonly rowClassName?: string;
|
||||
renderElement(element: ITreeNode<T, TFilterData>, index: number, templateData: TTemplateData, details?: ITreeElementRenderDetails): void;
|
||||
disposeElement?(element: ITreeNode<T, TFilterData>, index: number, templateData: TTemplateData, details?: ITreeElementRenderDetails): void;
|
||||
renderTwistie?(element: T, twistieElement: HTMLElement): boolean;
|
||||
|
||||
@@ -218,6 +218,25 @@ suite('ObjectTree', function () {
|
||||
disposeTemplate(): void { }
|
||||
}
|
||||
|
||||
test('applies renderer row class names', function () {
|
||||
const container = document.createElement('div');
|
||||
container.style.width = '200px';
|
||||
container.style.height = '200px';
|
||||
|
||||
const renderer = new class extends Renderer {
|
||||
readonly rowClassName = 'test-tree-row';
|
||||
};
|
||||
const tree = new ObjectTree<number>('test', container, new Delegate(), [renderer]);
|
||||
try {
|
||||
tree.layout(200);
|
||||
tree.setChildren(null, [{ element: 0 }, { element: 1 }]);
|
||||
|
||||
assert.strictEqual(container.querySelectorAll('.monaco-list-row.test-tree-row').length, 2);
|
||||
} finally {
|
||||
tree.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
class IdentityProvider implements IIdentityProvider<number> {
|
||||
getId(element: number): { toString(): string } {
|
||||
return `${element % 100}`;
|
||||
|
||||
@@ -1080,8 +1080,8 @@ export interface IAgent {
|
||||
/** Optional token consumer for provider-owned resources such as MCP servers. */
|
||||
handleAuthenticationToken?(params: AuthenticateParams): Promise<boolean>;
|
||||
|
||||
/** Optional push signal for providers that can require re-authentication after startup. */
|
||||
readonly onDidRequireAuth?: Event<Omit<AuthRequiredParams, 'channel'>>;
|
||||
/** Optional current authentication requirement for providers that can require re-authentication after startup. */
|
||||
readonly authenticationRequired?: IObservable<Omit<AuthRequiredParams, 'channel'> | undefined>;
|
||||
|
||||
/** Optional endpoint list when the provider owns probeable network traffic. */
|
||||
getNetworkDiagnosticsEndpoints?(): Promise<readonly IAgentHostNetworkEndpoint[]>;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// allow-any-unicode-comment-file
|
||||
// DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts
|
||||
|
||||
import type { URI } from './state.js';
|
||||
import type { ProtectedResourceMetadata, URI } from './state.js';
|
||||
|
||||
/**
|
||||
* Reason why authentication is required.
|
||||
@@ -28,8 +28,8 @@ export const enum AuthRequiredReason {
|
||||
* This notification MAY be associated with any channel — for example, an
|
||||
* agent advertised on the root channel, or a per-session resource. The
|
||||
* `channel` field identifies the subscription the auth requirement belongs
|
||||
* to; the `resource` field carries the OAuth-protected resource identifier
|
||||
* (per RFC 9728).
|
||||
* to; the `resource` field carries the complete OAuth protected resource
|
||||
* metadata (per RFC 9728).
|
||||
*
|
||||
* Clients should obtain a fresh token and push it via the `authenticate`
|
||||
* command.
|
||||
@@ -47,7 +47,11 @@ export const enum AuthRequiredReason {
|
||||
* "method": "auth/required",
|
||||
* "params": {
|
||||
* "channel": "ahp-root://",
|
||||
* "resource": "https://api.github.com",
|
||||
* "resource": {
|
||||
* "resource": "https://api.github.com",
|
||||
* "resource_name": "GitHub API",
|
||||
* "authorization_servers": ["https://github.com/login/oauth"]
|
||||
* },
|
||||
* "reason": "expired"
|
||||
* }
|
||||
* }
|
||||
@@ -56,8 +60,8 @@ export const enum AuthRequiredReason {
|
||||
export interface AuthRequiredParams {
|
||||
/** Channel URI this notification belongs to */
|
||||
channel: URI;
|
||||
/** The protected resource identifier that requires authentication */
|
||||
resource: string;
|
||||
/** Complete RFC 9728 metadata for the protected resource that requires authentication */
|
||||
resource: ProtectedResourceMetadata;
|
||||
/** Why authentication is required */
|
||||
reason?: AuthRequiredReason;
|
||||
}
|
||||
|
||||
@@ -558,7 +558,7 @@ export class AgentService extends Disposable implements IAgentService {
|
||||
// agents (the URI is host-level config).
|
||||
this._register(this._gitHubEndpointService.onDidChange(() => {
|
||||
this._stateManager.emitAuthRequired({
|
||||
resource: this._gitHubEndpointService.getCopilotResource().resource,
|
||||
resource: this._gitHubEndpointService.getCopilotResource(),
|
||||
reason: AuthRequiredReason.Required,
|
||||
});
|
||||
}));
|
||||
|
||||
@@ -778,8 +778,13 @@ export class AgentSideEffects extends Disposable {
|
||||
this._publishSessionCustomizationsForAgent(agent);
|
||||
}));
|
||||
}
|
||||
if (agent.onDidRequireAuth) {
|
||||
disposables.add(agent.onDidRequireAuth(e => this._stateManager.emitAuthRequired(e)));
|
||||
if (agent.authenticationRequired) {
|
||||
disposables.add(autorun(reader => {
|
||||
const requirement = agent.authenticationRequired?.read(reader);
|
||||
if (requirement) {
|
||||
this._stateManager.emitAuthRequired(requirement);
|
||||
}
|
||||
}));
|
||||
}
|
||||
return disposables;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import { createClaudeThinkingLevelSchema, isClaudeEffortLevel } from '../../comm
|
||||
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
|
||||
import { AgentProvider, AgentSession, AgentSignal, CLAUDE_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentChats, IAgentChatConfigCompletionsParams, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentDescriptor, IAgentMaterializeChatEvent, IAgentModelInfo, IAgentResolveChatConfigParams, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IAgentSpawnedChatParent, SubagentChatSignal, resolveAgentChatContext, resolveAgentHostCustomizations, resolveSubagentChatParent } from '../../common/agent.js';
|
||||
import { ensureWorkspacelessScratchDir } from '../workspacelessScratchDir.js';
|
||||
import { ActionType, type AuthRequiredParams } from '../../common/state/sessionActions.js';
|
||||
import { ActionType } from '../../common/state/sessionActions.js';
|
||||
import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
|
||||
import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js';
|
||||
import { PolicyState, ProtectedResourceMetadata, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
|
||||
@@ -339,9 +339,6 @@ export class ClaudeAgent extends Disposable implements IAgent {
|
||||
private readonly _onDidCustomizationsChange = this._register(new Emitter<void>());
|
||||
readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event;
|
||||
|
||||
private readonly _onDidRequireAuth = this._register(new Emitter<Omit<AuthRequiredParams, 'channel'>>());
|
||||
readonly onDidRequireAuth = this._onDidRequireAuth.event;
|
||||
|
||||
private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, []);
|
||||
readonly models: IObservable<readonly IAgentModelInfo[]> = this._models;
|
||||
/**
|
||||
|
||||
@@ -49,7 +49,6 @@ import { buildElicitationRequest, cancelledElicitationResponse, declinedElicitat
|
||||
import { McpAuthRequiredReason, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type McpServerState } from '../../common/state/protocol/channels-session/state.js';
|
||||
import { IAgentConfigurationService } from '../agentConfigurationService.js';
|
||||
import { AgentHostClientType } from '../../common/agentHostClientInfo.js';
|
||||
import type { AuthRequiredParams } from '../../common/state/protocol/common/notifications.js';
|
||||
import { FileOperationResult, IFileService, toFileOperationResult } from '../../../files/common/files.js';
|
||||
import { INativeEnvironmentService } from '../../../environment/common/environment.js';
|
||||
import { IAgentPluginManager, type ISyncedCustomization } from '../../common/agentPluginManager.js';
|
||||
@@ -901,9 +900,6 @@ export class CodexAgent extends Disposable implements IAgent {
|
||||
*/
|
||||
readonly onDidSpawnChat: Event<IAgentSpawnChatEvent> = Event.None;
|
||||
|
||||
private readonly _onDidRequireAuth = this._register(new Emitter<Omit<AuthRequiredParams, 'channel'>>());
|
||||
readonly onDidRequireAuth = this._onDidRequireAuth.event;
|
||||
|
||||
private readonly _onMcpNotification = this._register(new Emitter<IMcpNotification>());
|
||||
readonly onMcpNotification = this._onMcpNotification.event;
|
||||
|
||||
|
||||
@@ -177,6 +177,48 @@ const neverMatchRegex = /(?!.*)/;
|
||||
const transientEnvVarRegex = /^[A-Z_][A-Z0-9_]*=/i;
|
||||
const sedFileWriteParser = new SedFileWriteParser();
|
||||
|
||||
interface ITreeSitterResources {
|
||||
readonly parserClass: typeof Parser;
|
||||
readonly queryClass: typeof Query;
|
||||
readonly bashLanguage: PromiseSettledResult<Language>;
|
||||
readonly powershellLanguage: PromiseSettledResult<Language>;
|
||||
}
|
||||
|
||||
let treeSitterResourcesPromise: Promise<ITreeSitterResources> | undefined;
|
||||
|
||||
function getTreeSitterResources(): Promise<ITreeSitterResources> {
|
||||
// Parser.init and Language.load mutate process-global WASM state, so load them once.
|
||||
return treeSitterResourcesPromise ??= loadTreeSitterResources();
|
||||
}
|
||||
|
||||
async function loadTreeSitterResources(): Promise<ITreeSitterResources> {
|
||||
const { default: TreeSitter } = await import('@vscode/tree-sitter-wasm');
|
||||
const moduleRoot = URI.joinPath(FileAccess.asFileUri(getAppNodeModulesPath()), '@vscode', 'tree-sitter-wasm', 'wasm');
|
||||
const wasmPath = URI.joinPath(moduleRoot, 'tree-sitter.wasm').fsPath;
|
||||
|
||||
await TreeSitter.Parser.init({
|
||||
locateFile() {
|
||||
return wasmPath;
|
||||
}
|
||||
});
|
||||
|
||||
const loadGrammar = async (fileName: string) => {
|
||||
const grammarWasm = await fs.promises.readFile(URI.joinPath(moduleRoot, fileName).fsPath);
|
||||
return TreeSitter.Language.load(new Uint8Array(grammarWasm.buffer, grammarWasm.byteOffset, grammarWasm.byteLength));
|
||||
};
|
||||
const [bashLanguage, powershellLanguage] = await Promise.allSettled([
|
||||
loadGrammar('tree-sitter-bash.wasm'),
|
||||
loadGrammar('tree-sitter-powershell.wasm'),
|
||||
]);
|
||||
|
||||
return {
|
||||
parserClass: TreeSitter.Parser,
|
||||
queryClass: TreeSitter.Query,
|
||||
bashLanguage,
|
||||
powershellLanguage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-approves or denies shell commands based on terminal auto-approve rules.
|
||||
*
|
||||
@@ -391,30 +433,13 @@ export class CommandAutoApprover extends Disposable {
|
||||
|
||||
private async _initTreeSitter(): Promise<void> {
|
||||
try {
|
||||
const { default: TreeSitter } = (await import('@vscode/tree-sitter-wasm'));
|
||||
const resources = await getTreeSitterResources();
|
||||
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve WASM files from node_modules. In the desktop app the `.wasm`
|
||||
// files are unpacked next to the ASAR archive (`node_modules.asar.unpacked`),
|
||||
// while in dev and on the server (which has no ASAR) they live in a plain
|
||||
// `node_modules`.
|
||||
const moduleRoot = URI.joinPath(FileAccess.asFileUri(getAppNodeModulesPath()), '@vscode', 'tree-sitter-wasm', 'wasm');
|
||||
const wasmPath = URI.joinPath(moduleRoot, 'tree-sitter.wasm').fsPath;
|
||||
|
||||
await TreeSitter.Parser.init({
|
||||
locateFile() {
|
||||
return wasmPath;
|
||||
}
|
||||
});
|
||||
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parser = new TreeSitter.Parser();
|
||||
const parser = new resources.parserClass();
|
||||
this._register(toDisposable(() => {
|
||||
try {
|
||||
parser.delete();
|
||||
@@ -423,36 +448,20 @@ export class CommandAutoApprover extends Disposable {
|
||||
}
|
||||
}));
|
||||
|
||||
// Load the bash and PowerShell grammars. A failure to load one must
|
||||
// not disable auto-approval for the other, so each is settled
|
||||
// independently and assigned only if it resolved.
|
||||
const loadGrammar = async (fileName: string) => {
|
||||
const grammarWasm = await fs.promises.readFile(URI.joinPath(moduleRoot, fileName).fsPath);
|
||||
return TreeSitter.Language.load(new Uint8Array(grammarWasm.buffer, grammarWasm.byteOffset, grammarWasm.byteLength));
|
||||
};
|
||||
const [bashLanguage, powershellLanguage] = await Promise.allSettled([
|
||||
loadGrammar('tree-sitter-bash.wasm'),
|
||||
loadGrammar('tree-sitter-powershell.wasm'),
|
||||
]);
|
||||
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._parser = parser;
|
||||
this._queryClass = TreeSitter.Query;
|
||||
this._queryClass = resources.queryClass;
|
||||
// A grammar that fails to load leaves its language undefined, so
|
||||
// commands for that shell fall back to `noMatch` and require
|
||||
// confirmation rather than auto-approving.
|
||||
if (bashLanguage.status === 'fulfilled') {
|
||||
this._bashLanguage = bashLanguage.value;
|
||||
if (resources.bashLanguage.status === 'fulfilled') {
|
||||
this._bashLanguage = resources.bashLanguage.value;
|
||||
} else {
|
||||
this._logService.warn('[CommandAutoApprover] Failed to load the bash grammar; bash commands will require confirmation', bashLanguage.reason);
|
||||
this._logService.warn('[CommandAutoApprover] Failed to load the bash grammar; bash commands will require confirmation', resources.bashLanguage.reason);
|
||||
}
|
||||
if (powershellLanguage.status === 'fulfilled') {
|
||||
this._powershellLanguage = powershellLanguage.value;
|
||||
if (resources.powershellLanguage.status === 'fulfilled') {
|
||||
this._powershellLanguage = resources.powershellLanguage.value;
|
||||
} else {
|
||||
this._logService.warn('[CommandAutoApprover] Failed to load the PowerShell grammar; PowerShell commands will require confirmation', powershellLanguage.reason);
|
||||
this._logService.warn('[CommandAutoApprover] Failed to load the PowerShell grammar; PowerShell commands will require confirmation', resources.powershellLanguage.reason);
|
||||
}
|
||||
this._logService.info(`[CommandAutoApprover] Tree-sitter initialized (bash=${this._bashLanguage ? 'available' : 'unavailable'}, powershell=${this._powershellLanguage ? 'available' : 'unavailable'})`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -9,14 +9,15 @@ import * as os from 'os';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { CancelablePromise, createCancelablePromise, DeferredPromise, Delayer, disposableTimeout, Limiter, raceTimeout, Sequencer, SequencerByKey } from '../../../../base/common/async.js';
|
||||
import { type CancellationToken } from '../../../../base/common/cancellation.js';
|
||||
import { CancellationError } from '../../../../base/common/errors.js';
|
||||
import { structuralEquals } from '../../../../base/common/equals.js';
|
||||
import { CancellationError, getErrorMessage } from '../../../../base/common/errors.js';
|
||||
import { Emitter, Event } from '../../../../base/common/event.js';
|
||||
import { Disposable, DisposableMap, type IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { ResourceMap } from '../../../../base/common/map.js';
|
||||
import { FileAccess } from '../../../../base/common/network.js';
|
||||
import { formatTokenCount } from '../../../../base/common/numbers.js';
|
||||
import { equals } from '../../../../base/common/objects.js';
|
||||
import { autorun, observableValue, type ISettableObservable } from '../../../../base/common/observable.js';
|
||||
import { autorun, observableValue, observableValueOpts, type IObservable, type ISettableObservable } from '../../../../base/common/observable.js';
|
||||
import { delimiter, dirname, join } from '../../../../base/common/path.js';
|
||||
import { basename as resourceBasename, isEqual, isEqualOrParent, joinPath as resourceJoinPath, relativePath } from '../../../../base/common/resources.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
@@ -54,7 +55,7 @@ import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js';
|
||||
import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
|
||||
import type { ErrorInfo } from '../../common/state/protocol/common/state.js';
|
||||
import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type CustomizationEnablement, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
|
||||
import { ActionType, type SessionAction } from '../../common/state/sessionActions.js';
|
||||
import { ActionType, AuthRequiredReason, type AuthRequiredParams, type SessionAction } from '../../common/state/sessionActions.js';
|
||||
import { areAdditionalWorkingDirectoriesEqual } from '../../common/state/sessionWorkingDirectories.js';
|
||||
import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_READ_DB_KEY, withSessionEhcliAdoptable, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js';
|
||||
import { getByokLmAgentModelId } from '../../common/agentHostByokLm.js';
|
||||
@@ -479,10 +480,12 @@ class CopilotChatEntry extends Disposable {
|
||||
readonly chatSession: CopilotAgentSession,
|
||||
activeClient: ActiveClient,
|
||||
onMcpNotification: Emitter<IMcpNotification>,
|
||||
onDidRequireAuth: () => void,
|
||||
) {
|
||||
super();
|
||||
this._register(chatSession);
|
||||
this._register(chatSession.onMcpNotification(notification => onMcpNotification.fire(notification)));
|
||||
this._register(chatSession.onDidRequireAuth(onDidRequireAuth));
|
||||
this._register(autorun(reader => activeClient.pluginController.mcpServerStates.set(chatSession.mcpServerStates.read(reader), undefined)));
|
||||
}
|
||||
}
|
||||
@@ -528,6 +531,11 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
|
||||
private readonly _onDidChatProgress = this._register(new Emitter<AgentSignal>());
|
||||
readonly onDidChatProgress = this._onDidChatProgress.event;
|
||||
private readonly _authenticationRequired = observableValueOpts<Omit<AuthRequiredParams, 'channel'> | undefined>(
|
||||
{ owner: this, equalsFn: structuralEquals },
|
||||
undefined,
|
||||
);
|
||||
readonly authenticationRequired: IObservable<Omit<AuthRequiredParams, 'channel'> | undefined> = this._authenticationRequired;
|
||||
/**
|
||||
* Membership channel for chats the agent spawns itself — sub-agents
|
||||
* delegated by a tool call (the same fan-out the `subagent_started` /
|
||||
@@ -621,6 +629,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
private readonly _pendingClientRestartReasons = new Set<string>();
|
||||
private _closedConnectionRecovery: { readonly clientFailureId: string; readonly promise: Promise<ICopilotClosedConnectionRecoveryResult> } | undefined;
|
||||
private readonly _reportedClientFailures = new WeakSet<Error>();
|
||||
private readonly _authenticationSequencer = new Sequencer();
|
||||
private _githubToken: string | undefined;
|
||||
private _serverToolHost: IAgentServerToolHost | undefined;
|
||||
|
||||
@@ -916,10 +925,21 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
}
|
||||
|
||||
private async _requestClientRestart(reason: string): Promise<void> {
|
||||
if (this._shutdownPromise || !this._client) {
|
||||
if (this._shutdownPromise || (!this._client && !this._clientStarting)) {
|
||||
return;
|
||||
}
|
||||
this._pendingClientRestartReasons.add(reason);
|
||||
if (this._clientStarting) {
|
||||
try {
|
||||
await this._clientStarting;
|
||||
} catch {
|
||||
this._pendingClientRestartReasons.delete(reason);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!this._client) {
|
||||
return;
|
||||
}
|
||||
const busyChats = this._chatsWithActiveTurn();
|
||||
if (busyChats > 0) {
|
||||
this._logService.info(`[Copilot] Deferring CopilotClient restart (${reason}) until ${busyChats} in-flight turn(s) finish`);
|
||||
@@ -1272,18 +1292,56 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
if (resource !== this._gitHubEndpointService.getCopilotResource().resource) {
|
||||
return false;
|
||||
}
|
||||
const normalizedToken = token || undefined;
|
||||
const tokenChanged = this._githubToken !== normalizedToken;
|
||||
this._githubToken = normalizedToken;
|
||||
this._updateRestrictedTelemetry(normalizedToken);
|
||||
this._logService.info(`[Copilot] Auth token ${tokenChanged ? (normalizedToken ? 'updated' : 'cleared') : 'unchanged'}`);
|
||||
if (tokenChanged) {
|
||||
await this._restartClientIfProxyChanged();
|
||||
void this._scheduleModelRefresh();
|
||||
}
|
||||
await this._authenticationSequencer.queue(async () => {
|
||||
this._authenticationRequired.set(undefined, undefined);
|
||||
await this._applyGitHubToken(token || undefined);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _applyGitHubToken(token: string | undefined): Promise<void> {
|
||||
if (this._githubToken === token) {
|
||||
return;
|
||||
}
|
||||
this._logService.info(`[Copilot] Auth token ${token ? 'updated' : 'cleared'}`);
|
||||
this._githubToken = token;
|
||||
this._updateRestrictedTelemetry(token);
|
||||
if (!token) {
|
||||
await this._requestClientRestart('GitHub authentication cleared');
|
||||
void this._scheduleModelRefresh();
|
||||
return;
|
||||
}
|
||||
const host = this._gitHubEndpointService.getEnterpriseUri() ?? 'https://github.com';
|
||||
let restartRequired = false;
|
||||
for (const session of this._allLiveSessions()) {
|
||||
try {
|
||||
const result = await session.updateGitHubCredentials(host, token);
|
||||
if (!result.success) {
|
||||
restartRequired = true;
|
||||
this._logService.warn(`[Copilot:${session.sessionId}] GitHub credential update was rejected; scheduling a safe CopilotClient restart`);
|
||||
} else if (result.copilotUserResolved === false) {
|
||||
this._logService.warn(`[Copilot:${session.sessionId}] GitHub credentials were updated, but Copilot user metadata could not be resolved; plan, quota, and billing metadata may be degraded. Reauthenticate to restore it.`);
|
||||
}
|
||||
} catch (error) {
|
||||
restartRequired = true;
|
||||
this._logService.warn(`[Copilot:${session.sessionId}] Failed to update GitHub credentials; scheduling a safe CopilotClient restart: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
if (restartRequired) {
|
||||
await this._requestClientRestart('GitHub credential update failed');
|
||||
} else {
|
||||
await this._restartClientIfProxyChanged();
|
||||
}
|
||||
void this._scheduleModelRefresh();
|
||||
}
|
||||
|
||||
private _handleCopilotSessionAuthRequired(): void {
|
||||
this._authenticationRequired.set({
|
||||
resource: this._gitHubEndpointService.getCopilotResource(),
|
||||
reason: AuthRequiredReason.Expired,
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
async handleAuthenticationToken(params: AuthenticateParams): Promise<boolean> {
|
||||
let handled = false;
|
||||
for (const session of this._allLiveSessions()) {
|
||||
@@ -1478,6 +1536,9 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
if (this._githubToken !== tokenAtRefreshStart || this._modelCatalogGeneration !== generation || this._shutdownPromise) {
|
||||
return;
|
||||
}
|
||||
if (/\b401\b/.test(getErrorMessage(err))) {
|
||||
this._handleCopilotSessionAuthRequired();
|
||||
}
|
||||
await this._recoverFromClosedConnection(err, 'modelRefresh');
|
||||
if (attempt + 1 < this._modelRefreshMaxAttempts) {
|
||||
const delay = this._modelRefreshBackoff(attempt);
|
||||
@@ -3797,13 +3858,8 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
}
|
||||
|
||||
/**
|
||||
* When the GitHub token changes, the token-discovered CAPI endpoint (and so
|
||||
* the resolved proxy) can change. The proxy is baked into the SDK subprocess
|
||||
* env at client start, so if it would now differ we restart the running
|
||||
* client here (deferred while a turn is in flight, see
|
||||
* {@link _requestClientRestart}); the next `_ensureClient` re-resolves it
|
||||
* against the new token. No-op when no client is running/starting or the
|
||||
* proxy is unchanged.
|
||||
* Restarts the client when token-based CAPI endpoint discovery changes its
|
||||
* subprocess proxy. Session credential updates otherwise keep the process alive.
|
||||
*/
|
||||
private async _restartClientIfProxyChanged(): Promise<void> {
|
||||
if (!this._client && !this._clientStarting) {
|
||||
@@ -3814,21 +3870,18 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
if (newProxy === oldProxy) {
|
||||
return;
|
||||
}
|
||||
// Let any in-flight start finish so we stop a live client rather than
|
||||
// racing it (the start would otherwise come up with the stale proxy).
|
||||
if (this._clientStarting) {
|
||||
try {
|
||||
await this._clientStarting;
|
||||
} catch {
|
||||
// Start failed; nothing running to restart.
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!this._client) {
|
||||
return;
|
||||
}
|
||||
this._logService.info(`[Copilot] CAPI proxy changed after token update (${oldProxy ?? '(none)'} -> ${newProxy ?? '(none)'}); restarting CopilotClient`);
|
||||
this._chatEntriesBySdkId.clearAndDisposeAll();
|
||||
await this._stopClient();
|
||||
await this._requestClientRestart('CAPI proxy changed after GitHub token update');
|
||||
}
|
||||
|
||||
private _getOrCreateActiveClient(session: URI, directory: URI | undefined): ActiveClient {
|
||||
@@ -3897,7 +3950,7 @@ export class CopilotAgent extends Disposable implements IAgent {
|
||||
}
|
||||
|
||||
private _createChatEntry(session: CopilotAgentSession, activeClient: ActiveClient): CopilotChatEntry {
|
||||
return new CopilotChatEntry(session, activeClient, this._onMcpNotification);
|
||||
return new CopilotChatEntry(session, activeClient, this._onMcpNotification, () => this._handleCopilotSessionAuthRequired());
|
||||
}
|
||||
|
||||
private _registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: ActiveClient): void {
|
||||
|
||||
@@ -83,6 +83,7 @@ import { reportCopilotTodoStoreOperation } from './copilotTodoStoreTelemetry.js'
|
||||
type CopilotSdkAttachment = Required<MessageOptions>['attachments'][number];
|
||||
type CopilotCommandInvocationResult = Awaited<ReturnType<CopilotSession['rpc']['commands']['invoke']>>;
|
||||
type RuntimeSlashCommandInfo = Awaited<ReturnType<CopilotSession['rpc']['commands']['list']>>['commands'][number];
|
||||
type GitHubCredentialsUpdateResult = Awaited<ReturnType<CopilotSession['rpc']['gitHubAuth']['setCredentials']>>;
|
||||
type McpAuthHandler = NonNullable<SessionConfig['onMcpAuthRequest']>;
|
||||
type McpAuthRequest = Parameters<McpAuthHandler>[0];
|
||||
type McpAuthResult = Awaited<ReturnType<McpAuthHandler>>;
|
||||
@@ -90,6 +91,10 @@ interface CopilotExitPlanModeResponse extends ExitPlanModeResult {
|
||||
readonly autoApproveEdits?: ExitPlanModeCompletedData['autoApproveEdits'];
|
||||
}
|
||||
|
||||
function isCopilotSdkAuthRejection(error: { readonly errorType: string; readonly statusCode?: number }): boolean {
|
||||
return (error.errorType === 'authentication' || error.errorType === 'authorization') && error.statusCode === 401;
|
||||
}
|
||||
|
||||
interface IPendingMcpAuthRequest {
|
||||
readonly serverName: string;
|
||||
readonly resource: ProtectedResourceMetadata;
|
||||
@@ -846,6 +851,8 @@ export class CopilotAgentSession extends Disposable {
|
||||
*/
|
||||
private readonly _onMcpNotification = this._register(new Emitter<IMcpNotification>());
|
||||
readonly onMcpNotification = this._onMcpNotification.event;
|
||||
private readonly _onDidRequireAuth = this._register(new Emitter<void>());
|
||||
readonly onDidRequireAuth = this._onDidRequireAuth.event;
|
||||
|
||||
/**
|
||||
* Pending MCP `sampling/createMessage` requests received over the
|
||||
@@ -1833,6 +1840,13 @@ export class CopilotAgentSession extends Disposable {
|
||||
this._serverToolHost?.advertise(this._storageUri.toString());
|
||||
}
|
||||
|
||||
/** Updates the GitHub credentials used by this live SDK session. */
|
||||
async updateGitHubCredentials(host: string, token: string): Promise<GitHubCredentialsUpdateResult> {
|
||||
return this._wrapper.session.rpc.gitHubAuth.setCredentials({
|
||||
credentials: { type: 'token', host, token },
|
||||
});
|
||||
}
|
||||
|
||||
private _setPromptCacheState(promptCache: ISessionPromptCacheState | undefined): void {
|
||||
// `resourceUri` can be shared, so persist and re-read through the shared prompt-cache seam.
|
||||
this._promptCacheState = this._promptCache.write(this.resourceUri, promptCache);
|
||||
@@ -4302,6 +4316,9 @@ export class CopilotAgentSession extends Disposable {
|
||||
|
||||
this._register(wrapper.onSessionError(e => {
|
||||
this._logService.error(`[Copilot:${sessionId}] Session error: ${e.data.errorType} - ${e.data.message}`);
|
||||
if (isCopilotSdkAuthRejection(e.data)) {
|
||||
this._onDidRequireAuth.fire();
|
||||
}
|
||||
reportCopilotSdkSessionError(this._telemetryService, e, createCopilotFailureCorrelation(this.resourceUri, this._chatChannelUri, this._turnId, this.sessionId));
|
||||
if (this._currentTurn) {
|
||||
this._reportToolCallDetails(this._currentTurn, 'failed');
|
||||
|
||||
@@ -2244,6 +2244,10 @@ suite('AgentService (node dispatcher)', () => {
|
||||
prepareSessionDeletion: async () => undefined,
|
||||
removeSessionWorktree: async () => { removeWorktreeCalls++; },
|
||||
} as unknown as WorktreeIsolation);
|
||||
// Flush the provider backfill before injecting failures: its
|
||||
// registry write is fire-and-forget and would otherwise consume
|
||||
// part of the failure budget intended for the unregistration.
|
||||
await svc.listSessions();
|
||||
db.failRegistryWrites(2);
|
||||
|
||||
await assert.rejects(svc.disposeSession(session), /transient registry write failure/);
|
||||
|
||||
@@ -26,7 +26,7 @@ import { ISessionDataService } from '../../common/sessionDataService.js';
|
||||
import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
|
||||
import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js';
|
||||
import { ChangesSummary, ChatOriginKind, CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, SessionInputRequestKind } from '../../common/state/protocol/state.js';
|
||||
import { ActionType, ActionEnvelope, type ChatAction, type INotification, type SessionAction } from '../../common/state/sessionActions.js';
|
||||
import { ActionType, ActionEnvelope, AuthRequiredReason, type ChatAction, type INotification, type SessionAction } from '../../common/state/sessionActions.js';
|
||||
import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputRequestPurpose, ChatInteractivity, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ROOT_STATE_URI, SessionInputResponseKind, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ChatInputRequest, type ClientPluginCustomization, type Customization, type PluginCustomization, type Turn } from '../../common/state/sessionState.js';
|
||||
import { IProductService } from '../../../product/common/productService.js';
|
||||
import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js';
|
||||
@@ -2216,6 +2216,28 @@ suite('AgentSideEffects', () => {
|
||||
|
||||
suite('registerProgressListener', () => {
|
||||
|
||||
test('emits auth-required notifications when observable state becomes required', () => {
|
||||
const notifications: INotification[] = [];
|
||||
disposables.add(stateManager.onDidEmitNotification(notification => notifications.push(notification)));
|
||||
disposables.add(sideEffects.registerProgressListener(agent));
|
||||
const requirement = {
|
||||
resource: {
|
||||
resource: 'https://api.github.com',
|
||||
authorization_servers: ['https://github.com/login/oauth'],
|
||||
},
|
||||
reason: AuthRequiredReason.Expired,
|
||||
};
|
||||
|
||||
agent.setAuthenticationRequired(requirement);
|
||||
agent.setAuthenticationRequired(undefined);
|
||||
agent.setAuthenticationRequired(requirement);
|
||||
|
||||
assert.deepStrictEqual(notifications.filter(notification => notification.type === 'auth/required'), [
|
||||
{ type: 'auth/required', channel: ROOT_STATE_URI, ...requirement },
|
||||
{ type: 'auth/required', channel: ROOT_STATE_URI, ...requirement },
|
||||
]);
|
||||
});
|
||||
|
||||
test('maps agent progress events to state actions', () => {
|
||||
setupSession();
|
||||
startTurn('turn-1');
|
||||
|
||||
@@ -46,11 +46,11 @@ import { IFileService } from '../../../files/common/files.js';
|
||||
import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js';
|
||||
import { Schemas } from '../../../../base/common/network.js';
|
||||
import { INativeEnvironmentService } from '../../../environment/common/environment.js';
|
||||
import { IActiveClient, IAgentChatContext, IAgentChatDataChange, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentMaterializeChatEvent, IAgentSpawnChatEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agent.js';
|
||||
import { IActiveClient, IAgent, IAgentChatContext, IAgentChatDataChange, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentMaterializeChatEvent, IAgentSpawnChatEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agent.js';
|
||||
import { AgentHostClaudeMultiRootEnabledConfigKey } from '../../common/agentHostSchema.js';
|
||||
import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js';
|
||||
import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js';
|
||||
import { ActionType, type AuthRequiredParams } from '../../common/state/sessionActions.js';
|
||||
import { ActionType } from '../../common/state/sessionActions.js';
|
||||
import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputResponseKind, SessionStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js';
|
||||
import { McpServerStatus as McpCustomizationServerStatus, type ChildCustomization, type CustomizationEnablement, type McpServerCustomization } from '../../common/state/protocol/channels-session/state.js';
|
||||
import { ISessionDataService } from '../../common/sessionDataService.js';
|
||||
@@ -1594,24 +1594,19 @@ suite('ClaudeAgent', () => {
|
||||
rootConfig: { [AgentHostConfigKey.AllowSignedOutWhenUsable]: true },
|
||||
userHome,
|
||||
});
|
||||
const events: Omit<AuthRequiredParams, 'channel'>[] = [];
|
||||
disposables.add(agent.onDidRequireAuth(e => events.push(e)));
|
||||
|
||||
await agent.authenticate('https://api.github.com', 'tok');
|
||||
await tick();
|
||||
|
||||
assert.deepStrictEqual(events, []);
|
||||
assert.strictEqual((agent as IAgent).authenticationRequired, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
test('construction in proxy mode does not emit auth/required', async () => {
|
||||
const { agent } = createTestContext(disposables);
|
||||
const events: Omit<AuthRequiredParams, 'channel'>[] = [];
|
||||
disposables.add(agent.onDidRequireAuth(e => events.push(e)));
|
||||
|
||||
await tick();
|
||||
|
||||
assert.deepStrictEqual(events, []);
|
||||
assert.strictEqual((agent as IAgent).authenticationRequired, undefined);
|
||||
});
|
||||
|
||||
test('re-authenticating an unchanged token starts the proxy when a prior start left no handle', async () => {
|
||||
|
||||
@@ -8,6 +8,25 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c
|
||||
import { NullLogService } from '../../../log/common/log.js';
|
||||
import { CommandAutoApprover, type ICommandApprovalEvaluation } from '../../node/commandAutoApprover.js';
|
||||
|
||||
suite('CommandAutoApprover initialization', () => {
|
||||
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('initializes concurrent approvers', async () => {
|
||||
const approvers = Array.from({ length: 20 }, () => disposables.add(new CommandAutoApprover(new NullLogService())));
|
||||
|
||||
await Promise.all(approvers.map(approver => approver.initialize()));
|
||||
|
||||
assert.deepStrictEqual(
|
||||
approvers.map(approver => [
|
||||
approver.shouldAutoApprove('ls'),
|
||||
approver.shouldAutoApprove('Get-ChildItem', { language: 'powershell' }),
|
||||
]),
|
||||
Array.from({ length: approvers.length }, () => ['approved', 'approved']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
suite('CommandAutoApprover', () => {
|
||||
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
@@ -16,7 +16,7 @@ import { isCancellationError } from '../../../../base/common/errors.js';
|
||||
import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js';
|
||||
import { Emitter, Event } from '../../../../base/common/event.js';
|
||||
import { Schemas } from '../../../../base/common/network.js';
|
||||
import { observableValue, waitForState } from '../../../../base/common/observable.js';
|
||||
import { autorun, observableValue, waitForState } from '../../../../base/common/observable.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
||||
import { INativeEnvironmentService } from '../../../environment/common/environment.js';
|
||||
@@ -42,7 +42,7 @@ import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelatio
|
||||
import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentChatContext, type IAgentChatMetadata, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentMaterializeChatEvent, type IAgentSpawnChatEvent } from '../../common/agent.js';
|
||||
import { ISessionDataService } from '../../common/sessionDataService.js';
|
||||
import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, buildSubagentSessionUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, readSessionEhcliAdoptable, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, AH_META_IS_READ_DB_KEY, type ClientPluginCustomization, type Customization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js';
|
||||
import { ChatOriginKind, CustomizationEnablementKind, CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
|
||||
import { ChatOriginKind, CustomizationEnablementKind, CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ProtectedResourceMetadata, type ToolDefinition } from '../../common/state/protocol/state.js';
|
||||
import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js';
|
||||
|
||||
import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js';
|
||||
@@ -545,12 +545,21 @@ interface IFakeAgentSession {
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
interface ICredentialUpdateSession {
|
||||
readonly hasActiveTurn: boolean;
|
||||
updateGitHubCredentials(host: string, token: string): Promise<{ readonly success: boolean; readonly copilotUserResolved?: boolean }>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
class MockCopilotSession {
|
||||
readonly sessionId = 'test-session-1';
|
||||
readonly rpc = {
|
||||
options: {
|
||||
update: async () => ({ success: true }),
|
||||
},
|
||||
gitHubAuth: {
|
||||
setCredentials: async () => ({ success: true, copilotUserResolved: true }),
|
||||
},
|
||||
permissions: {
|
||||
setAllowAll: async ({ mode }: { mode: PermissionAllowAllMode }) => ({ success: true, mode }),
|
||||
},
|
||||
@@ -757,6 +766,7 @@ class TestableCopilotAgent extends CopilotAgent {
|
||||
getMessages: fake.getMessages,
|
||||
appliedSnapshot: undefined,
|
||||
dispose: fake.dispose,
|
||||
onDidRequireAuth: Event.None,
|
||||
resetTurnState: (newTurnId: string) => { turnId = newTurnId; },
|
||||
emitInitialMarkdown: (content: string) => {
|
||||
emitter.fire({
|
||||
@@ -848,13 +858,13 @@ function createTestAgentContext(disposables: Pick<DisposableStore, 'add'>, optio
|
||||
return { agent, instantiationService, configurationService: configService, managedSettingsService, fileService, stateManager };
|
||||
}
|
||||
|
||||
function createTestAgent(disposables: Pick<DisposableStore, 'add'>, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; byokBridgeRegistry?: IByokLmBridgeRegistry; otelService?: IAgentHostOTelService }): CopilotAgent {
|
||||
function createTestAgent(disposables: Pick<DisposableStore, 'add'>, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; otelService?: IAgentHostOTelService }): CopilotAgent {
|
||||
return createTestAgentContext(disposables, options).agent;
|
||||
}
|
||||
|
||||
type CopilotCreateSessionOptions = Parameters<CopilotClient['createSession']>[0];
|
||||
|
||||
function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot }): { readonly session: CopilotAgentSession; readonly createOptions: () => CopilotCreateSessionOptions | undefined } {
|
||||
function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot }): { readonly session: CopilotAgentSession; readonly activeClient: unknown; readonly createOptions: () => CopilotCreateSessionOptions | undefined } {
|
||||
const sessionUri = AgentSession.uri('copilotcli', 'test-session-1');
|
||||
const shellManager = instantiationService.createInstance(ShellManager, sessionUri, undefined);
|
||||
let createOptions: CopilotCreateSessionOptions | undefined;
|
||||
@@ -885,7 +895,7 @@ function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationServic
|
||||
githubToken: 'token',
|
||||
model: undefined,
|
||||
};
|
||||
return { session: agentInternals._createAgentSession(launchPlan, undefined, activeClient), createOptions: () => createOptions };
|
||||
return { session: agentInternals._createAgentSession(launchPlan, undefined, activeClient), activeClient, createOptions: () => createOptions };
|
||||
}
|
||||
|
||||
function withoutUndefinedProperties(metadata: IAgentChatMetadata): Record<string, unknown> {
|
||||
@@ -1690,37 +1700,218 @@ suite('CopilotAgent', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('does not stop the client when the auth token changes', async () => {
|
||||
test('updates every live session after a changed auth token without restarting an unchanged proxy', async () => {
|
||||
const client = new TestCopilotClient([], [{
|
||||
id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
}]);
|
||||
const agent = createTestAgent(disposables, { copilotClient: client });
|
||||
const first = {
|
||||
hasActiveTurn: false,
|
||||
updates: [] as Array<{ host: string; token: string }>,
|
||||
async updateGitHubCredentials(host: string, token: string) {
|
||||
this.updates.push({ host, token });
|
||||
return { success: true, copilotUserResolved: true };
|
||||
},
|
||||
dispose() { },
|
||||
} satisfies ICredentialUpdateSession & { updates: Array<{ host: string; token: string }> };
|
||||
const second = {
|
||||
hasActiveTurn: false,
|
||||
updates: [] as Array<{ host: string; token: string }>,
|
||||
async updateGitHubCredentials(host: string, token: string) {
|
||||
this.updates.push({ host, token });
|
||||
return { success: true, copilotUserResolved: true };
|
||||
},
|
||||
dispose() { },
|
||||
} satisfies ICredentialUpdateSession & { updates: Array<{ host: string; token: string }> };
|
||||
try {
|
||||
await agent.listLegacyChats();
|
||||
setDefaultSessionStub(agent, 'first', first);
|
||||
setDefaultSessionStub(agent, 'second', second);
|
||||
await agent.authenticate('https://api.github.com', 'model-token-a');
|
||||
await agent.authenticate('https://api.github.com', 'model-token-a');
|
||||
for (let i = 0; i < 200 && client.modelListRequests.length < 1; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
await agent.authenticate('https://api.github.com', 'model-token-b');
|
||||
for (let i = 0; i < 200 && client.modelListRequests.length < 2; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
assert.deepStrictEqual({
|
||||
starts: client.startCallCount,
|
||||
firstUpdates: first.updates,
|
||||
secondUpdates: second.updates,
|
||||
stops: client.stopCallCount,
|
||||
requests: client.modelListRequests,
|
||||
}, {
|
||||
starts: 1,
|
||||
firstUpdates: [{ host: 'https://github.com', token: 'model-token-a' }],
|
||||
secondUpdates: [{ host: 'https://github.com', token: 'model-token-a' }],
|
||||
stops: 0,
|
||||
requests: [{ gitHubToken: 'model-token-a' }, { gitHubToken: 'model-token-b' }],
|
||||
});
|
||||
} finally {
|
||||
await disposeAgent(agent);
|
||||
}
|
||||
});
|
||||
|
||||
test('defers a proxy-change restart until an active turn ends', async () => {
|
||||
const client = new TestCopilotClient([]);
|
||||
const proxyResolver = new TestProxyResolver();
|
||||
const agent = createTestAgent(disposables, { copilotClient: client, proxyResolver });
|
||||
const session = {
|
||||
hasActiveTurn: true as boolean,
|
||||
disposed: false,
|
||||
async updateGitHubCredentials() { return { success: true }; },
|
||||
dispose() { this.disposed = true; },
|
||||
} satisfies ICredentialUpdateSession & { disposed: boolean };
|
||||
try {
|
||||
await agent.listLegacyChats();
|
||||
setDefaultSessionStub(agent, 'proxy-change', session);
|
||||
proxyResolver.resolvedProxy = 'http://new-proxy:8080';
|
||||
await agent.authenticate('https://api.github.com', 'fresh-token');
|
||||
const duringTurn = { stops: client.stopCallCount, disposed: session.disposed, proxyResolutions: proxyResolver.resolveProxyCalls };
|
||||
|
||||
session.hasActiveTurn = false;
|
||||
(agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded();
|
||||
await timeout(0);
|
||||
assert.deepStrictEqual(duringTurn, { stops: 0, disposed: false, proxyResolutions: 2 });
|
||||
assert.deepStrictEqual({ stops: client.stopCallCount, disposed: session.disposed }, { stops: 1, disposed: true });
|
||||
} finally {
|
||||
await disposeAgent(agent);
|
||||
}
|
||||
});
|
||||
|
||||
test('serializes concurrent changed auth tokens so the final session credentials use the latest token', async () => {
|
||||
const client = new TestCopilotClient([]);
|
||||
const agent = createTestAgent(disposables, { copilotClient: client });
|
||||
const firstUpdateStarted = new DeferredPromise<void>();
|
||||
const firstUpdateGate = new DeferredPromise<void>();
|
||||
const session = {
|
||||
hasActiveTurn: false,
|
||||
appliedTokens: [] as string[],
|
||||
async updateGitHubCredentials(_host: string, token: string) {
|
||||
if (token === 'token-a') {
|
||||
firstUpdateStarted.complete();
|
||||
await firstUpdateGate.p;
|
||||
}
|
||||
this.appliedTokens.push(token);
|
||||
return { success: true };
|
||||
},
|
||||
dispose() { },
|
||||
} satisfies ICredentialUpdateSession & { appliedTokens: string[] };
|
||||
try {
|
||||
await agent.listLegacyChats();
|
||||
setDefaultSessionStub(agent, 'concurrent-auth', session);
|
||||
|
||||
const authA = agent.authenticate('https://api.github.com', 'token-a');
|
||||
await firstUpdateStarted.p;
|
||||
const authB = agent.authenticate('https://api.github.com', 'token-b');
|
||||
firstUpdateGate.complete();
|
||||
await Promise.all([authA, authB]);
|
||||
|
||||
assert.deepStrictEqual(session.appliedTokens, ['token-a', 'token-b']);
|
||||
} finally {
|
||||
await disposeAgent(agent);
|
||||
}
|
||||
});
|
||||
|
||||
test('defers a changed-token fallback restart until an active turn ends', async () => {
|
||||
const client = new TestCopilotClient([]);
|
||||
const agent = createTestAgent(disposables, { copilotClient: client });
|
||||
const rejected = {
|
||||
hasActiveTurn: true as boolean,
|
||||
disposed: false,
|
||||
dispose() { this.disposed = true; },
|
||||
async updateGitHubCredentials() { return { success: false }; },
|
||||
} satisfies ICredentialUpdateSession & { disposed: boolean };
|
||||
const failed = {
|
||||
hasActiveTurn: false,
|
||||
disposed: false,
|
||||
dispose() { this.disposed = true; },
|
||||
async updateGitHubCredentials() { throw new Error('runtime unavailable'); },
|
||||
} satisfies ICredentialUpdateSession & { disposed: boolean };
|
||||
try {
|
||||
await agent.listLegacyChats();
|
||||
setDefaultSessionStub(agent, 'rejected', rejected);
|
||||
setDefaultSessionStub(agent, 'failed', failed);
|
||||
|
||||
await agent.authenticate('https://api.github.com', 'fresh-token');
|
||||
const duringTurn = { stopCount: client.stopCallCount, rejectedDisposed: rejected.disposed, failedDisposed: failed.disposed };
|
||||
|
||||
rejected.hasActiveTurn = false;
|
||||
(agent as unknown as { _onChatTurnEnded(): void })._onChatTurnEnded();
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
duringTurn,
|
||||
afterTurn: { stopCount: client.stopCallCount, rejectedDisposed: rejected.disposed, failedDisposed: failed.disposed },
|
||||
}, {
|
||||
duringTurn: { stopCount: 0, rejectedDisposed: false, failedDisposed: false },
|
||||
afterTurn: { stopCount: 1, rejectedDisposed: true, failedDisposed: true },
|
||||
});
|
||||
} finally {
|
||||
await disposeAgent(agent);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps a session alive when credentials update without Copilot user metadata', async () => {
|
||||
const client = new TestCopilotClient([]);
|
||||
const agent = createTestAgent(disposables, { copilotClient: client });
|
||||
const session = {
|
||||
hasActiveTurn: false,
|
||||
updates: 0,
|
||||
async updateGitHubCredentials() {
|
||||
this.updates++;
|
||||
return { success: true, copilotUserResolved: false };
|
||||
},
|
||||
dispose() { },
|
||||
} satisfies ICredentialUpdateSession & { updates: number };
|
||||
try {
|
||||
await agent.listLegacyChats();
|
||||
setDefaultSessionStub(agent, 'degraded-metadata', session);
|
||||
await agent.authenticate('https://api.github.com', 'fresh-token');
|
||||
|
||||
assert.deepStrictEqual({ updates: session.updates, stops: client.stopCallCount }, { updates: 1, stops: 0 });
|
||||
} finally {
|
||||
await disposeAgent(agent);
|
||||
}
|
||||
});
|
||||
|
||||
test('rearms expired Copilot auth notifications after every authenticate call', async () => {
|
||||
const sessionDataService = disposables.add(new TestSessionDataService());
|
||||
const { agent, instantiationService } = createTestAgentContext(disposables, { sessionDataService });
|
||||
const mockSession = new MockCopilotSession();
|
||||
const createdSession = createAgentSessionThroughAgent(agent, instantiationService, { mockSession });
|
||||
const authRequests: Array<{ readonly resource: ProtectedResourceMetadata; readonly reason?: string }> = [];
|
||||
disposables.add(autorun(reader => {
|
||||
const requirement = agent.authenticationRequired.read(reader);
|
||||
if (requirement) {
|
||||
authRequests.push(requirement);
|
||||
}
|
||||
}));
|
||||
try {
|
||||
await createdSession.session.initializeSession();
|
||||
(agent as unknown as {
|
||||
_registerLiveChat(chat: URI, session: CopilotAgentSession, activeClient: unknown): void;
|
||||
})._registerLiveChat(createdSession.session.chatChannelUri, createdSession.session, createdSession.activeClient);
|
||||
|
||||
const authError = (errorType: 'authentication' | 'authorization', statusCode = 401) => mockSession.emit({
|
||||
type: 'session.error',
|
||||
data: { errorType, message: 'token rejected', statusCode },
|
||||
} as SessionEventPayload<'session.error'>);
|
||||
authError('authentication');
|
||||
authError('authorization');
|
||||
authError('authentication', 403);
|
||||
|
||||
await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'fresh-token');
|
||||
authError('authorization');
|
||||
await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'fresh-token');
|
||||
authError('authentication');
|
||||
await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'new-token');
|
||||
authError('authentication');
|
||||
|
||||
assert.deepStrictEqual(authRequests, [
|
||||
{ resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' },
|
||||
{ resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' },
|
||||
{ resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' },
|
||||
{ resource: GITHUB_COPILOT_PROTECTED_RESOURCE, reason: 'expired' },
|
||||
]);
|
||||
} finally {
|
||||
await disposeAgent(agent);
|
||||
}
|
||||
});
|
||||
|
||||
test('retries refreshing models after a transient failure', async () => {
|
||||
const client = new TestCopilotClient([], [{
|
||||
id: 'gpt-4o',
|
||||
@@ -1744,6 +1935,33 @@ suite('CopilotAgent', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('requests reauthentication when refreshing models returns unauthorized', async () => {
|
||||
const client = new TestCopilotClient([], [{
|
||||
id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
}]);
|
||||
client.modelListErrors.push(new Error('Failed to fetch Copilot user info: 401 Unauthorized: {"message":"Bad credentials"}'));
|
||||
const agent = createTestAgent(disposables, { copilotClient: client });
|
||||
const authRequests: Array<{ readonly resource: ProtectedResourceMetadata; readonly reason?: string }> = [];
|
||||
disposables.add(autorun(reader => {
|
||||
const requirement = agent.authenticationRequired.read(reader);
|
||||
if (requirement) {
|
||||
authRequests.push(requirement);
|
||||
}
|
||||
}));
|
||||
try {
|
||||
await agent.authenticate('https://api.github.com', 'token');
|
||||
await waitForState(agent.models, models => models.length > 0);
|
||||
|
||||
assert.deepStrictEqual(authRequests, [{
|
||||
resource: GITHUB_COPILOT_PROTECTED_RESOURCE,
|
||||
reason: 'expired',
|
||||
}]);
|
||||
} finally {
|
||||
await disposeAgent(agent);
|
||||
}
|
||||
});
|
||||
|
||||
test('recovers the client and reports telemetry when the SDK connection is closed', async () => {
|
||||
const client = new TestCopilotClient([], [{
|
||||
id: 'gpt-4o',
|
||||
@@ -2134,7 +2352,7 @@ suite('CopilotAgent', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the previously loaded models when a later refresh fails', async () => {
|
||||
test('retains the previous model catalog when a token refresh cannot update it', async () => {
|
||||
const client = new TestCopilotClient([], [{
|
||||
id: 'gpt-4o',
|
||||
name: 'GPT-4o',
|
||||
@@ -4041,6 +4259,7 @@ suite('CopilotAgent', () => {
|
||||
sessionId: launchPlan.sessionId,
|
||||
appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot,
|
||||
onMcpNotification: Event.None,
|
||||
onDidRequireAuth: Event.None,
|
||||
mcpServerStates: observableValue('test', []),
|
||||
async initializeSession(): Promise<void> { },
|
||||
async remapTurnIds(mapping: ReadonlyMap<string, string>): Promise<void> { remaps.push(mapping); },
|
||||
@@ -4956,6 +5175,7 @@ suite('CopilotAgent', () => {
|
||||
sessionId: launchPlan.sessionId,
|
||||
appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot,
|
||||
onMcpNotification: Event.None,
|
||||
onDidRequireAuth: Event.None,
|
||||
mcpServerStates: observableValue('test', []),
|
||||
async initializeSession(): Promise<void> {
|
||||
if (shouldFail) {
|
||||
@@ -6232,6 +6452,7 @@ suite('CopilotAgent', () => {
|
||||
sessionId: sdkSessionId,
|
||||
appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot,
|
||||
onMcpNotification: Event.None,
|
||||
onDidRequireAuth: Event.None,
|
||||
mcpServerStates: observableValue('test', []),
|
||||
async initializeSession(): Promise<void> { rec.initialized = true; },
|
||||
async remapTurnIds(mapping: ReadonlyMap<string, string>): Promise<void> { rec.remapCalls.push(mapping); },
|
||||
@@ -7321,6 +7542,7 @@ suite('CopilotAgent', () => {
|
||||
sessionId: launchPlan.sessionId,
|
||||
appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot,
|
||||
onMcpNotification: Event.None,
|
||||
onDidRequireAuth: Event.None,
|
||||
mcpServerStates: observableValue('test', []),
|
||||
async initializeSession(): Promise<void> { },
|
||||
async remapTurnIds(): Promise<void> { },
|
||||
|
||||
@@ -78,6 +78,9 @@ class MockCopilotSession {
|
||||
readonly modeSetCalls: Array<{ mode: 'interactive' | 'plan' | 'autopilot' }> = [];
|
||||
readonly permissionModeSetCalls: PermissionAllowAllMode[] = [];
|
||||
permissionModeSetSuccess = true;
|
||||
readonly gitHubCredentialUpdates: Array<{ credentials?: { type: 'token'; host: string; token: string } }> = [];
|
||||
gitHubCredentialUpdateResult = { success: true, copilotUserResolved: true };
|
||||
gitHubCredentialUpdateError: Error | undefined;
|
||||
readonly experimentalModeUpdates: boolean[] = [];
|
||||
experimentalModeUpdateSuccess = true;
|
||||
sandboxConfigUpdateSuccess = true;
|
||||
@@ -240,6 +243,15 @@ class MockCopilotSession {
|
||||
return { success: this.permissionModeSetSuccess, enabled: mode === 'on', mode };
|
||||
},
|
||||
},
|
||||
gitHubAuth: {
|
||||
setCredentials: async (params: { credentials?: { type: 'token'; host: string; token: string } }) => {
|
||||
this.gitHubCredentialUpdates.push(params);
|
||||
if (this.gitHubCredentialUpdateError) {
|
||||
throw this.gitHubCredentialUpdateError;
|
||||
}
|
||||
return this.gitHubCredentialUpdateResult;
|
||||
},
|
||||
},
|
||||
plan: {
|
||||
read: async () => this.planReadPromise ?? this.planReadResult,
|
||||
update: async (_params: { content: string }) => { /* no-op */ },
|
||||
@@ -916,6 +928,18 @@ suite('CopilotAgentSession', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('updates GitHub credentials through the SDK session RPC', async () => {
|
||||
const { session, mockSession } = await createAgentSession(disposables);
|
||||
await session.initializeSession();
|
||||
|
||||
const result = await session.updateGitHubCredentials('github.com', 'updated-token');
|
||||
|
||||
assert.deepStrictEqual({ result, updates: mockSession.gitHubCredentialUpdates }, {
|
||||
result: { success: true, copilotUserResolved: true },
|
||||
updates: [{ credentials: { type: 'token', host: 'github.com', token: 'updated-token' } }],
|
||||
});
|
||||
});
|
||||
|
||||
suite('CopilotSessionWrapper', () => {
|
||||
test('fires unhandled events when no wrapped listener is registered', () => {
|
||||
const mockSession = new MockCopilotSession();
|
||||
@@ -5976,6 +6000,24 @@ suite('CopilotAgentSession', () => {
|
||||
assert.strictEqual((completions[0] as ChatTurnCompleteAction).turnId, 'turn-next');
|
||||
});
|
||||
|
||||
test('emits auth-required only for Copilot auth rejections', async () => {
|
||||
const { session, mockSession } = await createAgentSession(disposables);
|
||||
let authRequiredCount = 0;
|
||||
disposables.add(session.onDidRequireAuth(() => authRequiredCount++));
|
||||
|
||||
for (const data of [
|
||||
{ errorType: 'authentication', message: 'expired', statusCode: 401 },
|
||||
{ errorType: 'authorization', message: 'unauthorized', statusCode: 401 },
|
||||
{ errorType: 'authentication', message: 'forbidden', statusCode: 403 },
|
||||
{ errorType: 'quota', message: 'quota exceeded', statusCode: 401 },
|
||||
{ errorType: 'rate_limit', message: 'too many requests', statusCode: 429 },
|
||||
]) {
|
||||
mockSession.fire('session.error', data as SessionEventPayload<'session.error'>['data']);
|
||||
}
|
||||
|
||||
assert.strictEqual(authRequiredCount, 2);
|
||||
});
|
||||
|
||||
test('error event is forwarded', async () => {
|
||||
const telemetryService = new CapturingTelemetryService();
|
||||
const { session, mockSession, signals } = await createAgentSession(disposables, { telemetryService });
|
||||
|
||||
@@ -16,6 +16,23 @@ When a valid E2E scenario exposes a gap:
|
||||
|
||||
Capability skips are tracked separately from suspected bugs. A provider that does not advertise a capability is expected to skip positive-path tests for that capability.
|
||||
|
||||
### Duplicate session creation is accepted
|
||||
|
||||
A client can retry session creation with a URI that already identifies a live session. The host accepts the duplicate request instead of reporting that the resource already exists, so clients cannot distinguish an idempotent retry from an accidental collision and a provider may be asked to create conflicting backing state.
|
||||
|
||||
- Test: `creating a duplicate session resource is rejected`.
|
||||
- Scope: conformance reference provider on all platforms.
|
||||
- Expected: the second AHP `createSession` request fails with `SessionAlreadyExists`.
|
||||
- Observed: the second request resolves successfully.
|
||||
- Gate: the scenario requires `AGENT_HOST_RUN_KNOWN_ISSUES=1`.
|
||||
- Reproduce:
|
||||
|
||||
```bash
|
||||
AGENT_HOST_RUN_KNOWN_ISSUES=1 ./scripts/test-integration.sh --run \
|
||||
src/vs/platform/agentHost/test/node/e2e/conformance/agentHostConformance.integrationTest.ts \
|
||||
--grep "creating a duplicate session resource is rejected"
|
||||
```
|
||||
|
||||
### Deleting a worktree session can race background Git work
|
||||
|
||||
A user can configure ignored files to be copied into an isolated worktree, complete an agent turn, and then delete the session. Session deletion can fail because background changeset or Git-state work is still using the worktree while Git removes it, leaving the session's worktree behind.
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
version: 1
|
||||
dialect: anthropic
|
||||
exchanges:
|
||||
- request:
|
||||
model: claude-opus-5
|
||||
system: ${system}
|
||||
messages:
|
||||
- role: user
|
||||
content: |-
|
||||
Reply with exactly this Markdown code block and nothing else:
|
||||
```text
|
||||
ALPHA
|
||||
BETA
|
||||
```
|
||||
response:
|
||||
content: |-
|
||||
```text
|
||||
ALPHA
|
||||
BETA
|
||||
```
|
||||
stopReason: end_turn
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
version: 1
|
||||
dialect: responses
|
||||
exchanges:
|
||||
- request:
|
||||
model: gpt-5.3-codex
|
||||
system: ${system}
|
||||
messages:
|
||||
- role: user
|
||||
content: |-
|
||||
Reply with exactly this Markdown code block and nothing else:
|
||||
```text
|
||||
ALPHA
|
||||
BETA
|
||||
```
|
||||
response:
|
||||
content: |-
|
||||
```text
|
||||
ALPHA
|
||||
BETA
|
||||
```
|
||||
stopReason: end_turn
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
version: 1
|
||||
dialect: anthropic
|
||||
exchanges:
|
||||
- request:
|
||||
model: claude-sonnet-5
|
||||
system: ${system}
|
||||
messages:
|
||||
- role: user
|
||||
content: |-
|
||||
Reply with exactly this Markdown code block and nothing else:
|
||||
```text
|
||||
ALPHA
|
||||
BETA
|
||||
```
|
||||
response:
|
||||
content: |-
|
||||
```text
|
||||
ALPHA
|
||||
BETA
|
||||
```
|
||||
stopReason: end_turn
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ const isLinux = process.platform === 'linux';
|
||||
const RECORD = process.env['AGENT_HOST_REPLAY_RECORD'] === '1' || process.env['AGENT_HOST_UPDATE_SNAPSHOTS'] === '1';
|
||||
const RUN_RECORD_ONLY_TESTS = process.env['AGENT_HOST_REPLAY_RECORD'] === '1';
|
||||
const RUN_KNOWN_ISSUE_TESTS = RECORD && process.env['AGENT_HOST_RUN_KNOWN_ISSUES'] === '1';
|
||||
const RUN_HOST_ONLY_KNOWN_ISSUE_TESTS = process.env['AGENT_HOST_RUN_KNOWN_ISSUES'] === '1';
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
interface IDefineOptions {
|
||||
@@ -57,6 +58,7 @@ function defineSuite(config: IAgentHostE2EProviderConfig, options: IDefineOption
|
||||
isWindows,
|
||||
runRecordOnlyTests: RUN_RECORD_ONLY_TESTS,
|
||||
runKnownIssueTests: RUN_KNOWN_ISSUE_TESTS,
|
||||
runHostOnlyKnownIssueTests: RUN_HOST_ONLY_KNOWN_ISSUE_TESTS,
|
||||
registerNoModelTrafficTest: title => noModelTrafficTestTitles.add(title),
|
||||
get observedModelRequestBodies() { return lease?.observedModelRequestBodies ?? []; },
|
||||
restartServer: async () => {
|
||||
|
||||
@@ -141,6 +141,19 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void {
|
||||
return `!node -e "require('fs').writeFileSync(process.argv[1],process.argv[2])" ${file} ${contents}`;
|
||||
}
|
||||
|
||||
function writeFileBase64Command(file: string, contents: string): string {
|
||||
const encodedFile = Buffer.from(file).toString('base64');
|
||||
const encodedContents = Buffer.from(contents).toString('base64');
|
||||
return `!node -e "const fs=require('fs');fs.writeFileSync(Buffer.from(process.argv[1],'base64').toString(),Buffer.from(process.argv[2],'base64'))" ${encodedFile} ${encodedContents}`;
|
||||
}
|
||||
|
||||
function writeFileTwiceBase64Command(file: string, first: string, second: string): string {
|
||||
const encodedFile = Buffer.from(file).toString('base64');
|
||||
const encodedFirst = Buffer.from(first).toString('base64');
|
||||
const encodedSecond = Buffer.from(second).toString('base64');
|
||||
return `!node -e "const fs=require('fs');const file=Buffer.from(process.argv[1],'base64').toString();fs.writeFileSync(file,Buffer.from(process.argv[2],'base64'));fs.writeFileSync(file,Buffer.from(process.argv[3],'base64'))" ${encodedFile} ${encodedFirst} ${encodedSecond}`;
|
||||
}
|
||||
|
||||
function deleteFileCommand(file: string): string {
|
||||
return `!node -e "require('fs').unlinkSync(process.argv[1])" ${file}`;
|
||||
}
|
||||
@@ -153,6 +166,10 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void {
|
||||
return file.edit.after?.uri ?? file.edit.before?.uri ?? '';
|
||||
}
|
||||
|
||||
function fileHasBasename(file: IObservedChangesetFile, basename: string): boolean {
|
||||
return URI.parse(fileUri(file)).path.endsWith(`/${basename}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a `changeset/contentChanged` on `channel` that reports
|
||||
* `basename`. Matched by basename because git resolves symlinks when
|
||||
@@ -165,10 +182,10 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void {
|
||||
return false;
|
||||
}
|
||||
const action = getActionEnvelope(n).action as IContentChangedAction;
|
||||
return action.files.some(file => fileUri(file).endsWith(`/${basename}`));
|
||||
return action.files.some(file => fileHasBasename(file, basename));
|
||||
}, timeout);
|
||||
const action = getActionEnvelope(notification).action as IContentChangedAction;
|
||||
return action.files.find(file => fileUri(file).endsWith(`/${basename}`))!;
|
||||
return action.files.find(file => fileHasBasename(file, basename))!;
|
||||
}
|
||||
|
||||
async function waitForTurnComplete(sessionUri: string, turnId: string): Promise<void> {
|
||||
@@ -201,7 +218,7 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void {
|
||||
const state = await changesetState(channel);
|
||||
const files: IObservedChangesetFile[] = [];
|
||||
for (const basename of basenames) {
|
||||
const file = state.files.find(file => fileUri(file).endsWith(`/${basename}`));
|
||||
const file = state.files.find(file => fileHasBasename(file, basename));
|
||||
if (file) {
|
||||
files.push(file);
|
||||
}
|
||||
@@ -489,6 +506,113 @@ export function defineChangesetTests(context: IAgentHostE2ETestContext): void {
|
||||
]);
|
||||
});
|
||||
|
||||
conformanceTest(context, 'ignored files do not appear in a branch changeset', async function () {
|
||||
const workspace = createGitWorkspace('ahp-changeset-ignored-');
|
||||
writeFileSync(join(workspace, '.gitignore'), 'ignored.log\n');
|
||||
execSync('git add .gitignore', { cwd: workspace });
|
||||
execSync('git commit -q -m "ignore generated log"', { cwd: workspace });
|
||||
const sessionUri = await createSessionIn(workspace, 'changeset-ignored');
|
||||
const branchUri = buildBranchChangesetUri(sessionUri);
|
||||
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
|
||||
await changesetState(branchUri);
|
||||
context.client.clearReceived();
|
||||
const changed = context.client.waitForNotification(n =>
|
||||
isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri,
|
||||
60_000,
|
||||
);
|
||||
|
||||
await runBangTurn(sessionUri, 'turn-changeset-ignored', writeFileCommand('ignored.log', 'ignored'), 1);
|
||||
await changed;
|
||||
const state = await changesetState(branchUri);
|
||||
|
||||
assert.deepStrictEqual(state.files, []);
|
||||
});
|
||||
|
||||
conformanceTest(context, 'a file created and deleted in one turn leaves no branch change', async function () {
|
||||
const workspace = createGitWorkspace('ahp-changeset-create-delete-');
|
||||
const sessionUri = await createSessionIn(workspace, 'changeset-create-delete');
|
||||
const branchUri = buildBranchChangesetUri(sessionUri);
|
||||
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
|
||||
await changesetState(branchUri);
|
||||
context.client.clearReceived();
|
||||
const changed = context.client.waitForNotification(n =>
|
||||
isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri,
|
||||
60_000,
|
||||
);
|
||||
|
||||
await runBangTurn(sessionUri, 'turn-changeset-create-delete', '!node -e "const fs=require(\'fs\');fs.writeFileSync(\'temporary.txt\',\'temporary\');fs.unlinkSync(\'temporary.txt\')"', 1);
|
||||
await changed;
|
||||
const state = await changesetState(branchUri);
|
||||
|
||||
assert.deepStrictEqual(state.files, []);
|
||||
});
|
||||
|
||||
conformanceTest(context, 'an edit restored in the same turn leaves no branch change', async function () {
|
||||
const workspace = createGitWorkspace('ahp-changeset-edit-restore-');
|
||||
const sessionUri = await createSessionIn(workspace, 'changeset-edit-restore');
|
||||
const branchUri = buildBranchChangesetUri(sessionUri);
|
||||
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
|
||||
await changesetState(branchUri);
|
||||
context.client.clearReceived();
|
||||
const changed = context.client.waitForNotification(n =>
|
||||
isActionNotification(n, 'changeset/contentChanged') && getActionEnvelope(n).channel === branchUri,
|
||||
60_000,
|
||||
);
|
||||
|
||||
await runBangTurn(sessionUri, 'turn-changeset-edit-restore', writeFileTwiceBase64Command('seed.txt', 'changed', 'seed\n'), 1);
|
||||
await changed;
|
||||
const state = await changesetState(branchUri);
|
||||
|
||||
assert.deepStrictEqual(state.files, []);
|
||||
});
|
||||
|
||||
conformanceTest(context, 'an added multiline file reports every added line', async function () {
|
||||
const workspace = createGitWorkspace('ahp-changeset-multiline-add-');
|
||||
const sessionUri = await createSessionIn(workspace, 'changeset-multiline-add');
|
||||
const branchUri = buildBranchChangesetUri(sessionUri);
|
||||
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
|
||||
|
||||
await runBangTurn(sessionUri, 'turn-changeset-multiline-add', writeFileBase64Command('lines.txt', 'one\ntwo\nthree\n'), 1);
|
||||
const [file] = await waitForChangesetFiles(branchUri, ['lines.txt']);
|
||||
|
||||
assert.deepStrictEqual(file.edit.diff, { added: 3, removed: 0 });
|
||||
});
|
||||
|
||||
conformanceTest(context, 'deleting a multiline tracked file reports every removed line', async function () {
|
||||
const workspace = createGitWorkspace('ahp-changeset-multiline-delete-');
|
||||
writeFileSync(join(workspace, 'lines.txt'), 'one\ntwo\nthree\n');
|
||||
execSync('git add lines.txt', { cwd: workspace });
|
||||
execSync('git commit -q -m "add multiline file"', { cwd: workspace });
|
||||
const sessionUri = await createSessionIn(workspace, 'changeset-multiline-delete');
|
||||
const branchUri = buildBranchChangesetUri(sessionUri);
|
||||
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
|
||||
|
||||
await runBangTurn(sessionUri, 'turn-changeset-multiline-delete', deleteFileCommand('lines.txt'), 1);
|
||||
const [file] = await waitForChangesetFiles(branchUri, ['lines.txt']);
|
||||
|
||||
assert.deepStrictEqual(file.edit.diff, { added: 0, removed: 3 });
|
||||
});
|
||||
|
||||
conformanceTest(context, 'a changed filename containing spaces remains addressable', async function () {
|
||||
const workspace = createGitWorkspace('ahp-changeset-spaced-file-');
|
||||
const sessionUri = await createSessionIn(workspace, 'changeset-spaced-file');
|
||||
const branchUri = buildBranchChangesetUri(sessionUri);
|
||||
await context.client.call<SubscribeResult>('subscribe', { channel: branchUri });
|
||||
|
||||
await runBangTurn(sessionUri, 'turn-changeset-spaced-file', writeFileBase64Command('spaced file.txt', 'content\n'), 1);
|
||||
const [file] = await waitForChangesetFiles(branchUri, ['spaced file.txt']);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
id: URI.parse(file.id).path.endsWith('/spaced file.txt'),
|
||||
after: file.edit.after?.uri.endsWith('/spaced%20file.txt') || file.edit.after?.uri.endsWith('/spaced file.txt'),
|
||||
exists: existsSync(join(workspace, 'spaced file.txt')),
|
||||
}, {
|
||||
id: true,
|
||||
after: true,
|
||||
exists: true,
|
||||
});
|
||||
});
|
||||
|
||||
conformanceTest(context, 'an empty repository reports an untracked file as added', async function () {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'ahp-changeset-empty-repo-'));
|
||||
tempDirs.push(workspace);
|
||||
|
||||
@@ -145,6 +145,43 @@ export function defineClientFilesystemTests(context: IAgentHostE2ETestContext):
|
||||
]);
|
||||
});
|
||||
|
||||
conformanceTest(context, 'resourceList returns an empty collection for an empty directory', async function () {
|
||||
await initializeClient('resource-list-empty');
|
||||
const root = createWorkspace('ahp-resource-list-empty-');
|
||||
|
||||
const result = await context.client.call<ResourceListResult>('resourceList', {
|
||||
channel: ROOT_STATE_URI,
|
||||
uri: URI.file(root).toString(),
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(result.entries, []);
|
||||
});
|
||||
|
||||
conformanceTest(context, 'resourceWrite truncates an existing file by default', async function () {
|
||||
await initializeClient('resource-write-default-truncate');
|
||||
const root = createWorkspace('ahp-resource-write-default-truncate-');
|
||||
const file = fileUri(root, 'replace.txt');
|
||||
writeFileSync(join(root, 'replace.txt'), 'LONGER_ORIGINAL');
|
||||
|
||||
await writeText(file, 'short');
|
||||
|
||||
assert.strictEqual(readFileSync(join(root, 'replace.txt'), 'utf8'), 'short');
|
||||
});
|
||||
|
||||
conformanceTest(context, 'resourceDelete removes an empty directory without recursive mode', async function () {
|
||||
await initializeClient('resource-delete-empty-directory');
|
||||
const root = createWorkspace('ahp-resource-delete-empty-directory-');
|
||||
const directory = join(root, 'empty');
|
||||
mkdirSync(directory);
|
||||
|
||||
await context.client.call('resourceDelete', {
|
||||
channel: ROOT_STATE_URI,
|
||||
uri: URI.file(directory).toString(),
|
||||
});
|
||||
|
||||
assert.strictEqual(existsSync(directory), false);
|
||||
});
|
||||
|
||||
conformanceTest(context, 'resourceCopy, resourceMove, and resourceDelete mutate the tree', async function () {
|
||||
await initializeClient('resource-mutate');
|
||||
const root = createWorkspace('ahp-resource-mutate-');
|
||||
|
||||
@@ -141,6 +141,30 @@ export function defineCoreTests(context: IAgentHostE2ETestContext): void {
|
||||
assert.ok(responseParts.length > 0, 'should have received at least one response part');
|
||||
});
|
||||
|
||||
test('preserves a fenced multiline markdown response', async function () {
|
||||
this.timeout(120_000);
|
||||
const workspaceDir = mkdtempSync(join(tmpdir(), 'ahp-markdown-response-'));
|
||||
tempDirs.push(workspaceDir);
|
||||
const sessionUri = await createRealSession(
|
||||
context.client,
|
||||
config,
|
||||
`markdown-response-${config.provider}`,
|
||||
createdSessions,
|
||||
URI.file(workspaceDir),
|
||||
);
|
||||
const expected = '```text\nALPHA\nBETA\n```';
|
||||
|
||||
const result = await driveTurnToCompletion(
|
||||
context.client,
|
||||
sessionUri,
|
||||
'turn-markdown-response',
|
||||
`Reply with exactly this Markdown code block and nothing else:\n${expected}`,
|
||||
1,
|
||||
);
|
||||
|
||||
assert.strictEqual(result.responseText, expected);
|
||||
});
|
||||
|
||||
test('listModels returns well-shaped model entries after authenticate', async function () {
|
||||
this.timeout(60_000);
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface IAgentHostE2ETestContext {
|
||||
readonly runRecordOnlyTests: boolean;
|
||||
/** Whether explicitly requested known-issue reproductions should run against live recording. */
|
||||
readonly runKnownIssueTests: boolean;
|
||||
/** Whether explicitly requested model-free known-issue reproductions should run in strict replay. */
|
||||
readonly runHostOnlyKnownIssueTests: boolean;
|
||||
readonly registerNoModelTrafficTest: (title: string) => void;
|
||||
readonly observedModelRequestBodies: readonly string[];
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,7 @@ import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from '../../../../../../base/common/path.js';
|
||||
import { URI } from '../../../../../../base/common/uri.js';
|
||||
import { generateUuid } from '../../../../../../base/common/uuid.js';
|
||||
import { ReconnectResultType, type FetchTurnsResult, type InitializeResult, type ListSessionsResult, type ReconnectResult, type SubscribeResult } from '../../../../common/state/protocol/commands.js';
|
||||
import type { SessionSummaryChangedParams } from '../../../../common/state/protocol/channels-root/notifications.js';
|
||||
import type { OtlpExportLogsParams } from '../../../../common/state/protocol/channels-otlp/notifications.js';
|
||||
@@ -23,7 +24,7 @@ import type { IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnostics
|
||||
import { ActionType, type StateAction } from '../../../../common/state/sessionActions.js';
|
||||
import { TerminalClaimKind } from '../../../../common/state/protocol/state.js';
|
||||
import { buildChatUri, buildDefaultChatUri, MessageKind, ROOT_STATE_URI, SessionStatus, type ChatState, type SessionState, type Turn } from '../../../../common/state/sessionState.js';
|
||||
import { createRealSession, dispatchTurn } from '../harness/agentHostE2ETestHarness.js';
|
||||
import { createRealSession, dispatchTurn, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js';
|
||||
import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/registry.js';
|
||||
import { AhpErrorCodes, JsonRpcErrorCodes } from '../../../../common/state/sessionProtocol.js';
|
||||
import { getActionEnvelope, isActionNotification, type TestProtocolClient } from '../../serverIntegrationTestHelpers.js';
|
||||
@@ -209,6 +210,41 @@ export function defineProtocolContractTests(context: IAgentHostE2ETestContext):
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'initialize reports the negotiated protocol and sequence', async function () {
|
||||
const client = await context.connectClient();
|
||||
try {
|
||||
const initialized = await client.call<InitializeResult>('initialize', {
|
||||
channel: ROOT_STATE_URI,
|
||||
protocolVersions: [PROTOCOL_VERSION],
|
||||
clientId: `server-identity-${config.provider}`,
|
||||
clientInfo: { name: 'agent-host-e2e', version: '1.0.0' },
|
||||
});
|
||||
|
||||
assert.deepStrictEqual({
|
||||
protocolVersion: initialized.protocolVersion,
|
||||
serverSeqIsNonNegative: initialized.serverSeq >= 0,
|
||||
}, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
serverSeqIsNonNegative: true,
|
||||
});
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'initialize cannot be repeated after the handshake', async function () {
|
||||
const client = await initializeAdditionalClient('repeat-initialize');
|
||||
try {
|
||||
await assert.rejects(client.call('initialize', {
|
||||
channel: ROOT_STATE_URI,
|
||||
protocolVersions: [PROTOCOL_VERSION],
|
||||
clientId: `repeat-initialize-again-${config.provider}`,
|
||||
}), { code: JsonRpcErrorCodes.MethodNotFound });
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'listSessions includes provider-backed session metadata', async function () {
|
||||
const { sessionUri, workspace } = await createSession('list-session-metadata');
|
||||
const chatUri = buildDefaultChatUri(sessionUri);
|
||||
@@ -668,6 +704,211 @@ export function defineProtocolContractTests(context: IAgentHostE2ETestContext):
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'resource requests before initialize are rejected', async function () {
|
||||
const client = await context.connectClient();
|
||||
try {
|
||||
await assert.rejects(client.call('resourceResolve', {
|
||||
channel: ROOT_STATE_URI,
|
||||
uri: URI.file(tmpdir()).toString(),
|
||||
}), { code: JsonRpcErrorCodes.MethodNotFound });
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'unknown requests after initialize are rejected', async function () {
|
||||
const client = await initializeAdditionalClient('unknown-request');
|
||||
try {
|
||||
await assert.rejects(client.call('agentHostE2E/unknownRequest', {
|
||||
channel: ROOT_STATE_URI,
|
||||
}), { code: JsonRpcErrorCodes.MethodNotFound });
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'reconnect rejects an unknown client', async function () {
|
||||
const client = await context.connectClient();
|
||||
try {
|
||||
await assert.rejects(client.call('reconnect', {
|
||||
channel: ROOT_STATE_URI,
|
||||
clientId: `unknown-reconnect-${config.provider}`,
|
||||
lastSeenServerSeq: 0,
|
||||
subscriptions: [],
|
||||
}), { code: AhpErrorCodes.NotFound });
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'creating a session with an unknown provider is rejected', async function () {
|
||||
const client = await initializeAdditionalClient('unknown-provider');
|
||||
try {
|
||||
await assert.rejects(client.call('createSession', {
|
||||
channel: 'missing-provider:/session',
|
||||
provider: 'missing-provider',
|
||||
}), { code: AhpErrorCodes.ProviderNotFound });
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'creating a duplicate session resource is rejected', async function () {
|
||||
const { sessionUri, workspace } = await createSession('duplicate-session');
|
||||
|
||||
await assert.rejects(context.client.call('createSession', {
|
||||
channel: sessionUri,
|
||||
provider: config.provider,
|
||||
workingDirectories: [URI.file(workspace).toString()],
|
||||
config: { isolation: 'folder' },
|
||||
}), { code: AhpErrorCodes.SessionAlreadyExists });
|
||||
}, context.runHostOnlyKnownIssueTests);
|
||||
|
||||
conformanceTest(context, 'a session cannot fork onto its own resource', async function () {
|
||||
const { sessionUri } = await createSession('self-fork');
|
||||
|
||||
await assert.rejects(context.client.call('createSession', {
|
||||
channel: sessionUri,
|
||||
provider: config.provider,
|
||||
fork: { session: sessionUri, turnId: 'irrelevant' },
|
||||
}), { code: AhpErrorCodes.SessionAlreadyExists });
|
||||
});
|
||||
|
||||
conformanceTest(context, 'forking from a missing session is rejected', async function () {
|
||||
const target = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString();
|
||||
const missingSource = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString();
|
||||
await context.client.call('initialize', {
|
||||
channel: ROOT_STATE_URI,
|
||||
protocolVersions: [PROTOCOL_VERSION],
|
||||
clientId: `missing-fork-source-${config.provider}`,
|
||||
});
|
||||
|
||||
await assert.rejects(context.client.call('createSession', {
|
||||
channel: target,
|
||||
provider: config.provider,
|
||||
fork: { session: missingSource, turnId: 'missing-turn' },
|
||||
}), { code: AhpErrorCodes.SessionNotFound });
|
||||
});
|
||||
|
||||
conformanceTest(context, 'createSession rejects an active client owned by another connection', async function () {
|
||||
const client = await context.connectClient();
|
||||
try {
|
||||
await client.call('initialize', {
|
||||
channel: ROOT_STATE_URI,
|
||||
protocolVersions: [PROTOCOL_VERSION],
|
||||
clientId: `active-client-owner-${config.provider}`,
|
||||
});
|
||||
await assert.rejects(client.call('createSession', {
|
||||
channel: URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(),
|
||||
provider: config.provider,
|
||||
activeClient: { clientId: 'different-client', displayName: 'Different Client', tools: [] },
|
||||
}), { code: JsonRpcErrorCodes.InvalidParams });
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'createSession seeds a matching active client into session state', async function () {
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'ahp-active-client-create-'));
|
||||
tempDirs.push(workspace);
|
||||
const clientId = `active-client-create-${config.provider}`;
|
||||
const client = await context.connectClient();
|
||||
const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString();
|
||||
let created = false;
|
||||
try {
|
||||
await client.call('initialize', {
|
||||
channel: ROOT_STATE_URI,
|
||||
protocolVersions: [PROTOCOL_VERSION],
|
||||
clientId,
|
||||
});
|
||||
await client.call('authenticate', {
|
||||
channel: ROOT_STATE_URI,
|
||||
resource: 'https://api.github.com',
|
||||
token: config.githubToken ?? resolveGitHubToken(),
|
||||
});
|
||||
await client.call('createSession', {
|
||||
channel: sessionUri,
|
||||
provider: config.provider,
|
||||
workingDirectories: [URI.file(workspace).toString()],
|
||||
config: { isolation: 'folder' },
|
||||
activeClient: { clientId, displayName: 'Creating Client', tools: [] },
|
||||
});
|
||||
created = true;
|
||||
|
||||
const subscribed = await client.call<SubscribeResult>('subscribe', { channel: sessionUri });
|
||||
const state = subscribed.snapshot!.state as SessionState;
|
||||
assert.deepStrictEqual(state.activeClients, [{
|
||||
clientId,
|
||||
displayName: 'Creating Client',
|
||||
tools: [],
|
||||
}]);
|
||||
} finally {
|
||||
if (created) {
|
||||
await client.call('disposeSession', { channel: sessionUri });
|
||||
}
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'creating a chat for a missing session is rejected', async function () {
|
||||
const client = await initializeAdditionalClient('missing-chat-session');
|
||||
const sessionUri = URI.from({ scheme: config.scheme, path: '/missing-chat-session' }).toString();
|
||||
try {
|
||||
await assert.rejects(client.call('createChat', {
|
||||
channel: sessionUri,
|
||||
chat: buildChatUri(sessionUri, 'peer'),
|
||||
}), { code: AhpErrorCodes.SessionNotFound });
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
});
|
||||
|
||||
conformanceTest(context, 'subscribing twice does not duplicate action delivery', async function () {
|
||||
const { sessionUri } = await createSession('duplicate-subscription');
|
||||
const chatUri = buildDefaultChatUri(sessionUri);
|
||||
await context.client.call<SubscribeResult>('subscribe', { channel: chatUri });
|
||||
await context.client.call<SubscribeResult>('subscribe', { channel: chatUri });
|
||||
context.client.clearReceived();
|
||||
|
||||
const clientSeq = nextClientSeq();
|
||||
const action = { type: ActionType.ChatDraftChanged, draft: { text: 'single delivery', origin: { kind: MessageKind.User } } } as const;
|
||||
context.client.dispatch({ channel: chatUri, clientSeq, action });
|
||||
await context.client.waitForNotification(n =>
|
||||
isActionNotification(n, action.type)
|
||||
&& getActionEnvelope(n).channel === chatUri
|
||||
&& getActionEnvelope(n).origin?.clientSeq === clientSeq,
|
||||
);
|
||||
await context.client.call('ping', { channel: ROOT_STATE_URI });
|
||||
const deliveries = context.client.receivedNotifications(n =>
|
||||
isActionNotification(n, action.type)
|
||||
&& getActionEnvelope(n).channel === chatUri
|
||||
&& getActionEnvelope(n).origin?.clientSeq === clientSeq,
|
||||
);
|
||||
|
||||
assert.strictEqual(deliveries.length, 1);
|
||||
});
|
||||
|
||||
conformanceTest(context, 'resubscribing receives state changed while unsubscribed', async function () {
|
||||
const { sessionUri } = await createSession('resubscribe-snapshot');
|
||||
const chatUri = buildDefaultChatUri(sessionUri);
|
||||
context.client.notify('unsubscribe', { channel: chatUri });
|
||||
const clientSeq = nextClientSeq();
|
||||
context.client.dispatch({
|
||||
channel: chatUri,
|
||||
clientSeq,
|
||||
action: {
|
||||
type: ActionType.ChatDraftChanged,
|
||||
draft: { text: 'changed while unsubscribed', origin: { kind: MessageKind.User } },
|
||||
},
|
||||
});
|
||||
await context.client.call('ping', { channel: ROOT_STATE_URI });
|
||||
|
||||
const subscribed = await context.client.call<SubscribeResult>('subscribe', { channel: chatUri });
|
||||
const state = subscribed.snapshot!.state as ChatState;
|
||||
|
||||
assert.strictEqual(state.draft?.text, 'changed while unsubscribed');
|
||||
});
|
||||
|
||||
// The protocol declares working-directory mutation on both the session and
|
||||
// chat channels, but the host rejects all four: applying one would change
|
||||
// the synchronized directory set without reconfiguring the agent's actual
|
||||
|
||||
@@ -14,7 +14,7 @@ import { AgentSession, type AgentProvider, type AgentSignal, type IActiveClient,
|
||||
import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryRecord } from './historyRecordFixtures.js';
|
||||
import { ProtectedResourceMetadata, ToolCallContributorKind, type AgentSelection, type MessageAttachment, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
|
||||
import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
|
||||
import { ActionType } from '../../common/state/sessionActions.js';
|
||||
import { ActionType, type AuthRequiredParams } from '../../common/state/sessionActions.js';
|
||||
import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, buildDefaultChatUri, isAhpChatChannel, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js';
|
||||
import { hasKey } from '../../../../base/common/types.js';
|
||||
|
||||
@@ -56,6 +56,8 @@ export class MockAgent implements IAgent {
|
||||
readonly onDidSendMessage = this._onDidSendMessage.event;
|
||||
private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, []);
|
||||
readonly models = this._models;
|
||||
private readonly _authenticationRequired = observableValue<Omit<AuthRequiredParams, 'channel'> | undefined>(this, undefined);
|
||||
readonly authenticationRequired = this._authenticationRequired;
|
||||
|
||||
private readonly _sessions = new Map<string, URI>();
|
||||
private readonly _initialChats = new Set<string>();
|
||||
@@ -114,6 +116,10 @@ export class MockAgent implements IAgent {
|
||||
|
||||
constructor(readonly id: AgentProvider = 'mock') { }
|
||||
|
||||
setAuthenticationRequired(requirement: Omit<AuthRequiredParams, 'channel'> | undefined): void {
|
||||
this._authenticationRequired.set(requirement, undefined);
|
||||
}
|
||||
|
||||
getDescriptor(): IAgentDescriptor {
|
||||
return { provider: this.id, displayName: `Agent ${this.id}`, description: `Test ${this.id} agent`, capabilities: { multipleChats: { fork: true } } };
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ The Custom View Grid (`CustomViewGridPart` in [browser/parts/customViewGridPart.
|
||||
|
||||
**Contract — it is mutually exclusive with the sessions surface.** While a custom view is shown, the Sessions Part, the Editor part *in the grid*, the Auxiliary Bar (side panel) and the Panel (terminal) are all hidden, and vice versa. Only the titlebar and the primary sidebar remain. The *modal* editor part is not affected and may still open over the custom view.
|
||||
|
||||
Which view is shown is owned by `ICustomViewService` ([services/customView/browser/customViewService.ts](src/vs/sessions/services/customView/browser/customViewService.ts)): contributions register an `ICustomViewDescriptor` (id, title, view constructor and optional header actions) and call `showCustomView(id)` / `hideCustomView()`. The workbench observes `activeCustomView` and applies the layout; it is not persisted, so a reload always starts on the sessions grid.
|
||||
Which view is shown is owned by `ICustomViewService` ([services/customView/browser/customViewService.ts](src/vs/sessions/services/customView/browser/customViewService.ts)): contributions register an `ICustomViewDescriptor` (id, title, view constructor and optional header actions) and call `showCustomView(id)` / `hideCustomView()`. The workbench observes `activeCustomView` and applies the layout. The desired custom-view id is persisted per workspace and restored when its descriptor registers, so reload returns to the same surface without activating an unavailable view.
|
||||
|
||||
**Desired vs. effective visibility.** The covered parts keep their *desired* visibility in `partVisibility` — showing a custom view only changes what the grid renders (`Workbench._effectiveVisible`). So a layout-controller change made while the custom view is shown (e.g. the user opened a different session in the background) is what gets restored when it is hidden, and `_savePartVisibility` never records the forced-hidden state. `IWorkbenchLayoutService.isVisible` reports the effective value and `onDidChangePartVisibility` fires for the parts whose effective visibility flips, so context keys stay truthful; the layout controller's per-session capture listeners skip those transitions (`_isCustomViewVisible`).
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ When `chat.omni.enabled` is enabled, the Sessions header includes a `Codicon.arr
|
||||
| `services/sessions/browser/sessionSectionOrderService.ts` | `ISessionSectionOrderService` — manual top-level order of groups + workspace sections and workspace promotion (UI-only) |
|
||||
| `contrib/sessions/browser/views/sessionsViewActions.ts` | All registered actions (sort, group, filter, pin, archive, rename, navigate) |
|
||||
|
||||
Renderers that need row-level styling declare `ITreeRenderer.rowClassName`; they must not traverse to tree-owned `.monaco-list-row` markup with `closest()`.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
@@ -84,6 +86,7 @@ When grouping by workspace, the list shows only **primary** workspace sections b
|
||||
- A workspace qualifies as primary if it has recent activity (last 4 days), matches the open window's folder, or contains the most recently updated session
|
||||
- Remaining workspaces collapse behind a "+N more workspaces" toggle
|
||||
- Within each workspace or user-created group, sessions beyond 5 (configurable by the same assignment treatment) also show a "Show more" toggle and then a "Show less" toggle once expanded
|
||||
- "Show more" rows use the same horizontal inset and corner-radius tier as session rows, including the phone layout
|
||||
- The find widget bypasses all capping
|
||||
|
||||
### Filtering
|
||||
|
||||
@@ -663,40 +663,12 @@
|
||||
|
||||
/* --- Chat input notification in the new-session homepage --- */
|
||||
|
||||
/* Hide the container when no notification is active */
|
||||
.new-chat-input-container > .chat-input-notification-container:not(.has-notification) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* The frame and severity tints come from `.chat-input-notice`, but the overlap does
|
||||
* not: this surface is a plain block with no row gap, and the workbench offset is
|
||||
* scoped to `.interactive-input-part`, which is not in this stack.
|
||||
*/
|
||||
.new-chat-input-container > .chat-input-notification-container.has-notification {
|
||||
/* Overlap the following input-stack surface to avoid a gap between their rounded edges. */
|
||||
margin-bottom: calc(-1 * var(--vscode-spacing-size80));
|
||||
}
|
||||
|
||||
.new-chat-input-container > .chat-input-notification-container .chat-input-notification {
|
||||
padding: 10px 16px 16px 16px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Severity variants */
|
||||
.new-chat-input-container > .chat-input-notification-container .chat-input-notification.severity-info {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
background-color: color-mix(in srgb, var(--vscode-focusBorder) 6%, var(--vscode-editorWidget-background));
|
||||
}
|
||||
|
||||
.new-chat-input-container > .chat-input-notification-container .chat-input-notification.severity-warning {
|
||||
border-color: var(--vscode-editorWarning-foreground);
|
||||
background-color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 6%, var(--vscode-editorWidget-background));
|
||||
}
|
||||
|
||||
.new-chat-input-container > .chat-input-notification-container .chat-input-notification.severity-error {
|
||||
border-color: var(--vscode-editorError-foreground);
|
||||
background-color: color-mix(in srgb, var(--vscode-editorError-foreground) 6%, var(--vscode-editorWidget-background));
|
||||
}
|
||||
|
||||
@@ -122,23 +122,10 @@
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.new-chat-in-session .sub-session-tip-widget {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
background-color: var(--vscode-editorWidget-background);
|
||||
border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 0;
|
||||
border: 1px solid var(--vscode-agentsChatInput-border, var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)));
|
||||
border-bottom: none;
|
||||
font-size: var(--vscode-chat-font-size-body-s);
|
||||
font-family: var(--vscode-chat-font-family, inherit);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/*
|
||||
* The frame, the row layout and the dismiss button all come from
|
||||
* `.chat-input-notice`; only the two content elements are styled here.
|
||||
*/
|
||||
.new-chat-in-session .sub-session-tip-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
@@ -150,29 +137,3 @@
|
||||
min-width: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.new-chat-in-session .sub-session-tip-dismiss {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
cursor: pointer;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.new-chat-in-session .sub-session-tip-dismiss:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.new-chat-in-session .sub-session-tip-dismiss:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import * as dom from '../../../../base/browser/dom.js';
|
||||
import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js';
|
||||
import { constObservable, derived, IObservable } from '../../../../base/common/observable.js';
|
||||
import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js';
|
||||
import { URI } from '../../../../base/common/uri.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
@@ -22,6 +21,7 @@ import { NewChatInputWidget } from './newChatInput.js';
|
||||
import { IChatViewOptions } from '../../../browser/parts/chatView.js';
|
||||
import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js';
|
||||
import { ChatInputNoticeLane } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeHost.js';
|
||||
import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNoticeWidget.js';
|
||||
|
||||
// #region --- New Chat In Session Widget ---
|
||||
|
||||
@@ -101,34 +101,33 @@ export class NewChatInSessionWidget extends Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
const store = new DisposableStore();
|
||||
const tipContainer = dom.append(container, dom.$('.sub-session-tip-container'));
|
||||
const tipWidget = dom.append(tipContainer, dom.$('.sub-session-tip-widget'));
|
||||
tipWidget.setAttribute('role', 'status');
|
||||
tipWidget.setAttribute('aria-label', localize('subSessionTip.ariaLabel', "New chat tip"));
|
||||
// Reachable by the notice focus command, like every other notice above an input.
|
||||
tipWidget.tabIndex = 0;
|
||||
|
||||
// Tip icon
|
||||
const iconEl = dom.append(tipWidget, renderIcon(Codicon.lightbulb));
|
||||
iconEl.classList.add('sub-session-tip-icon');
|
||||
|
||||
// Tip text
|
||||
const textEl = dom.append(tipWidget, dom.$('span.sub-session-tip-text'));
|
||||
textEl.textContent = localize(
|
||||
const message = localize(
|
||||
'subSessionTip.message',
|
||||
"Start a parallel conversation to build on all the changes made in this session."
|
||||
);
|
||||
|
||||
// Dismiss button
|
||||
const dismissBtn = dom.append(tipWidget, dom.$('button.sub-session-tip-dismiss')) as HTMLButtonElement;
|
||||
dismissBtn.type = 'button';
|
||||
dismissBtn.setAttribute('aria-label', localize('subSessionTip.dismiss', "Dismiss tip"));
|
||||
dom.append(dismissBtn, renderIcon(Codicon.close));
|
||||
// Named by what it says, like every other tip: the label is both what the
|
||||
// landmark is called and what is spoken when the tip first appears.
|
||||
const tip = store.add(new ChatInputNoticeWidget({
|
||||
container: tipContainer,
|
||||
variant: ChatInputNoticeVariant.Tip,
|
||||
ariaLabel: message,
|
||||
ariaRoleDescription: localize('subSessionTip.ariaLabel', "New chat tip"),
|
||||
}));
|
||||
|
||||
const iconEl = dom.append(tip.domNode, renderIcon(Codicon.lightbulb));
|
||||
iconEl.classList.add('sub-session-tip-icon');
|
||||
|
||||
const textEl = dom.append(tip.domNode, dom.$('span.sub-session-tip-text'));
|
||||
textEl.textContent = message;
|
||||
|
||||
const dismiss = () => {
|
||||
// Removing the banner would strand keyboard focus on <body>, which also
|
||||
// drops the context keys the chat keybindings depend on.
|
||||
const hadFocus = dom.isAncestorOfActiveElement(tipWidget);
|
||||
const hadFocus = tip.hasFocus();
|
||||
this.storageService.store(STORAGE_KEY_SUB_SESSION_TIP_DISMISSED, true, StorageScope.PROFILE, StorageTarget.USER);
|
||||
tipContainer.remove();
|
||||
this._tipDisposable.clear();
|
||||
@@ -137,32 +136,35 @@ export class NewChatInSessionWidget extends Disposable {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = (e: Event) => {
|
||||
dom.EventHelper.stop(e, true);
|
||||
dismiss();
|
||||
};
|
||||
|
||||
const store = new DisposableStore();
|
||||
store.add(Gesture.addTarget(dismissBtn));
|
||||
store.add(dom.addDisposableListener(dismissBtn, dom.EventType.CLICK, handleDismiss));
|
||||
store.add(dom.addDisposableListener(dismissBtn, TouchEventType.Tap, handleDismiss));
|
||||
tip.addDismissAction({
|
||||
ariaLabel: localize('subSessionTip.dismiss', "Dismiss tip"),
|
||||
onActivate: dismiss,
|
||||
});
|
||||
|
||||
// Claims the tip lane above this input, so the banner yields to a
|
||||
// notification or a first-run introduction instead of stacking with them.
|
||||
// Hidden until the claim leads, which it does immediately when nothing
|
||||
// else holds the space.
|
||||
let leading = false;
|
||||
let announced = false;
|
||||
dom.setVisibility(false, tipContainer);
|
||||
store.add(this._newChatInput.noticeHost.occupy(ChatInputNoticeLane.Tip, {
|
||||
focusTarget: {
|
||||
hasFocus: () => dom.isAncestorOfActiveElement(tipWidget),
|
||||
focus: () => tipWidget.focus(),
|
||||
hasFocus: () => tip.hasFocus(),
|
||||
focus: () => tip.focus(),
|
||||
canFocus: () => leading,
|
||||
},
|
||||
onDidChangeLeading: isLeading => {
|
||||
leading = isLeading;
|
||||
tipContainer.classList.toggle(SHOWING_SUB_SESSION_TIP_CLASS, isLeading);
|
||||
dom.setVisibility(isLeading, tipContainer);
|
||||
// Spoken once, the first time it actually reaches the screen. The
|
||||
// lane can hand back and forth as notifications come and go, and
|
||||
// re-announcing on every return would talk over the user.
|
||||
if (isLeading && !announced) {
|
||||
announced = true;
|
||||
tip.announce();
|
||||
}
|
||||
},
|
||||
}));
|
||||
this._tipDisposable.value = store;
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ Decoupling these allows copilot sessions from different providers (local CLI, re
|
||||
- `clearConnection()` — Clears the connection when the host disconnects
|
||||
- Handles session notifications (`notify/sessionAdded`, `notify/sessionRemoved`) and state changes
|
||||
- Fires `onDidChangeSessionTypes` when the host's agent list changes
|
||||
- Missing Copilot credentials open the standard product sign-in dialog before tokens are forwarded to the remote host. Authentication and transport failures propagate to the pending request; `false` is reserved for canceled or unavailable authentication.
|
||||
- Missing Copilot credentials open the standard product sign-in dialog when the user starts a session. On the first `auth/required` notification for an exact protected resource, the client silently re-resolves and force-forwards its current token. A later completed same-token challenge invokes the standard force-sign-in flow; silently rotated tokens are forwarded without prompting. Each connection keeps independent recovery state, while concurrent prompts share the existing Chat Setup operation so hosts cannot independently rotate shared authentication. Authentication and transport failures propagate to the pending request; `false` is reserved for canceled or unavailable authentication.
|
||||
- Remote-host management options do not expose an IPC output channel; remote diagnostics use the host's forwarded logs when available.
|
||||
- SSH connection progress notifications are closed when the connect promise settles; keyboard-interactive prompt cancellation rejects the connect promise as cancellation and does not show an error notification.
|
||||
- SSH config host connections use resolved `IdentityFile` and `IdentityAgent` values from `ssh -G`; encrypted private keys are prompted for a passphrase through the same quick-input bridge as keyboard-interactive auth.
|
||||
|
||||
+33
-10
@@ -21,6 +21,7 @@ import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state
|
||||
import { AgentHostLocalFilePermissionsSettingId } from '../../../../../platform/agentHost/common/agentHostResourceService.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 { NotificationType, type INotification } from '../../../../../platform/agentHost/common/state/sessionActions.js';
|
||||
import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
|
||||
import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js';
|
||||
import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';
|
||||
@@ -31,7 +32,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js';
|
||||
import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js';
|
||||
import { registerAction2 } from '../../../../../platform/actions/common/actions.js';
|
||||
import { OpenSessionEventsFileAction } from '../../agentHost/browser/openSessionEventsFileActions.js';
|
||||
import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js';
|
||||
import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js';
|
||||
import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js';
|
||||
import { AgentHostSessionHandler } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js';
|
||||
import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js';
|
||||
@@ -239,6 +240,7 @@ class ConnectionState extends Disposable {
|
||||
readonly modelProviders = new Map<AgentProvider, AgentHostLanguageModelProvider>();
|
||||
/** Dedupes redundant `authenticate` RPCs when the resolved token hasn't changed. */
|
||||
readonly authTokenCache = new AgentHostAuthTokenCache();
|
||||
readonly authRecovery = new AgentHostAuthenticationRecovery();
|
||||
|
||||
constructor(
|
||||
readonly name: string | undefined,
|
||||
@@ -851,6 +853,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
|
||||
store.add(connection.rootState.onDidChange(rootState => {
|
||||
this._handleRootStateChange(address, connection, rootState);
|
||||
}));
|
||||
store.add(connection.onDidNotification(notification => this._handleAuthenticationRequiredNotification(address, connection, notification)));
|
||||
|
||||
// If root state is already available, process it immediately
|
||||
const initialRootState = connection.rootState.value;
|
||||
@@ -1068,6 +1071,34 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
|
||||
}
|
||||
}
|
||||
|
||||
private _handleAuthenticationRequiredNotification(address: string, connection: IAgentConnection, notification: INotification): void {
|
||||
if (notification.type !== NotificationType.AuthRequired) {
|
||||
return;
|
||||
}
|
||||
this._authenticateNotificationResource(address, connection, notification.resource);
|
||||
}
|
||||
|
||||
private _authenticateNotificationResource(address: string, connection: IAgentConnection, protectedResource: ProtectedResourceMetadata): void {
|
||||
const connState = this._connections.get(address);
|
||||
if (!connState) {
|
||||
return;
|
||||
}
|
||||
const providerId = `agenthost-${agentHostAuthority(address)}`;
|
||||
const provider = this._sessionsProvidersService.getProvider<RemoteAgentHostSessionsProvider>(providerId);
|
||||
provider?.setAuthenticationPending(true);
|
||||
this._instantiationService.invokeFunction(accessor => connState.authRecovery.recover(accessor, protectedResource, {
|
||||
authTokenCache: connState.authTokenCache,
|
||||
logPrefix: '[RemoteAgentHost]',
|
||||
authenticate: this._authenticateCallback(address, connection),
|
||||
}))
|
||||
.catch(err => {
|
||||
this._logService.error(`[RemoteAgentHost] Failed to authenticate notified resource ${protectedResource.resource}`, err);
|
||||
})
|
||||
.finally(() => {
|
||||
provider?.setAuthenticationPending(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `authenticate` callback for a connection. Host-agnostic by default (forwards the
|
||||
* request unchanged); a connection kind may inject a token transform via
|
||||
@@ -1084,19 +1115,11 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactively prompt the user to authenticate when the server requires it.
|
||||
* Interactively prompt the user to authenticate when the user starts a session.
|
||||
* Returns true if authentication succeeded.
|
||||
*/
|
||||
private async _resolveAuthenticationInteractively(address: string, connection: IAgentConnection, protectedResources: readonly ProtectedResourceMetadata[]): Promise<boolean> {
|
||||
const authTokenCache = this._connections.get(address)?.authTokenCache;
|
||||
// When the connection transforms the outgoing token (e.g. sealing), the resolved plaintext
|
||||
// is not the identity that was actually sent, and the sealed envelope has its own lifetime.
|
||||
// A host-requested re-auth (this path) must therefore send a fresh transformed token, so drop
|
||||
// the plaintext-keyed dedupe first — otherwise an unchanged plaintext would be suppressed and
|
||||
// the host would never receive a fresh envelope.
|
||||
if (authTokenCache && this._connectionCustomizations.get(address)?.authenticate) {
|
||||
authTokenCache.clear();
|
||||
}
|
||||
return this._instantiationService.invokeFunction(resolveAuthenticationInteractively, protectedResources, {
|
||||
authTokenCache,
|
||||
logPrefix: '[RemoteAgentHost]',
|
||||
|
||||
+148
-1
@@ -6,12 +6,159 @@
|
||||
import assert from 'assert';
|
||||
import { DeferredPromise, timeout } from '../../../../../../base/common/async.js';
|
||||
import { CancellationError } from '../../../../../../base/common/errors.js';
|
||||
import { AgentHostAuthenticationRecovery, AgentHostAuthTokenCache } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js';
|
||||
import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
|
||||
import { type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js';
|
||||
import { ICommandService } from '../../../../../../platform/commands/common/commands.js';
|
||||
import { IRemoteAgentHostSSHConnection, RemoteAgentHostEntryType } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js';
|
||||
import { SSHHostKeyDeniedError } from '../../../../../../platform/agentHost/common/sshRemoteAgentHost.js';
|
||||
import { AuthRequiredReason, NotificationType, type INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
|
||||
import { type ProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
|
||||
import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js';
|
||||
import { IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js';
|
||||
import { categorizeSSHConnectError } from '../../../../../common/sessionsTelemetry.js';
|
||||
import { disconnectSSHEntry, shouldPauseSSHReconnectAfterFailure, sshConnectionKey, SSHReconnectState } from '../../browser/remoteAgentHost.contribution.js';
|
||||
import { disconnectSSHEntry, RemoteAgentHostContribution, shouldPauseSSHReconnectAfterFailure, sshConnectionKey, SSHReconnectState } from '../../browser/remoteAgentHost.contribution.js';
|
||||
|
||||
interface IRemoteAuthNotificationHarness {
|
||||
_connections: Map<string, { readonly authTokenCache: AgentHostAuthTokenCache; readonly authRecovery: AgentHostAuthenticationRecovery }>;
|
||||
_sessionsProvidersService: { getProvider(): undefined };
|
||||
_instantiationService: TestInstantiationService;
|
||||
_connectionCustomizations: { get(address: string): { readonly authenticate?: (request: { readonly resource: string; readonly scopes?: readonly string[]; readonly token: string }) => Promise<{ readonly resource: string; readonly scopes?: readonly string[]; readonly token: string }> } | undefined };
|
||||
_logService: NullLogService;
|
||||
_handleAuthenticationRequiredNotification(address: string, connection: Pick<IAgentConnection, 'authenticate'>, notification: INotification): void;
|
||||
}
|
||||
|
||||
suite('RemoteAgentHost auth notifications', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('resends the current token for an expired notification resource that is not advertised by root agents', async () => {
|
||||
const instantiationService = store.add(new TestInstantiationService());
|
||||
instantiationService.stub(IAuthenticationService, {
|
||||
getOrActivateProviderIdForServer: async () => 'test-provider',
|
||||
getSessions: async () => [{
|
||||
id: 'session-id',
|
||||
account: { id: 'account-id', label: 'Test Account' },
|
||||
scopes: ['session:read'],
|
||||
accessToken: 'session-token',
|
||||
}],
|
||||
});
|
||||
const logService = new NullLogService();
|
||||
instantiationService.stub(ILogService, logService);
|
||||
const authenticateCalls: Array<{ readonly resource: string; readonly scopes?: readonly string[]; readonly token: string }> = [];
|
||||
const connection = {
|
||||
authenticate: async (params: { readonly resource: string; readonly scopes?: readonly string[]; readonly token: string }) => {
|
||||
authenticateCalls.push(params);
|
||||
return { authenticated: true };
|
||||
},
|
||||
};
|
||||
const address = 'test-host';
|
||||
const contribution = Object.create(RemoteAgentHostContribution.prototype) as IRemoteAuthNotificationHarness;
|
||||
contribution._connections = new Map([[address, { authTokenCache: new AgentHostAuthTokenCache(), authRecovery: new AgentHostAuthenticationRecovery() }]]);
|
||||
contribution._sessionsProvidersService = { getProvider: () => undefined };
|
||||
contribution._instantiationService = instantiationService;
|
||||
contribution._connectionCustomizations = { get: () => undefined };
|
||||
contribution._logService = logService;
|
||||
const resource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.example.com/session',
|
||||
authorization_servers: ['https://auth.example.com'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
const notification: INotification = {
|
||||
type: NotificationType.AuthRequired,
|
||||
channel: 'ahp-root://',
|
||||
resource,
|
||||
reason: AuthRequiredReason.Expired,
|
||||
};
|
||||
|
||||
contribution._handleAuthenticationRequiredNotification(address, connection, notification);
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual(authenticateCalls, [{
|
||||
resource: 'https://api.example.com/session',
|
||||
scopes: ['session:read'],
|
||||
token: 'session-token',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('reauthenticates each host independently with the same current token', async () => {
|
||||
const instantiationService = store.add(new TestInstantiationService());
|
||||
instantiationService.stub(IAuthenticationService, {
|
||||
getOrActivateProviderIdForServer: async () => 'test-provider',
|
||||
getSessions: async () => [{ id: 'session-id', account: { id: 'account-id', label: 'Test Account' }, scopes: ['session:read'], accessToken: 'session-token' }],
|
||||
});
|
||||
instantiationService.stub(ILogService, new NullLogService());
|
||||
const calls: string[] = [];
|
||||
const contribution = Object.create(RemoteAgentHostContribution.prototype) as IRemoteAuthNotificationHarness;
|
||||
contribution._connections = new Map([
|
||||
['host-one', { authTokenCache: new AgentHostAuthTokenCache(), authRecovery: new AgentHostAuthenticationRecovery() }],
|
||||
['host-two', { authTokenCache: new AgentHostAuthTokenCache(), authRecovery: new AgentHostAuthenticationRecovery() }],
|
||||
]);
|
||||
contribution._sessionsProvidersService = { getProvider: () => undefined };
|
||||
contribution._instantiationService = instantiationService;
|
||||
contribution._connectionCustomizations = { get: () => undefined };
|
||||
contribution._logService = new NullLogService();
|
||||
const resource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.example.com/session',
|
||||
authorization_servers: ['https://auth.example.com'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
const notification: INotification = { type: NotificationType.AuthRequired, channel: 'ahp-root://', resource, reason: AuthRequiredReason.Required };
|
||||
|
||||
contribution._handleAuthenticationRequiredNotification('host-one', { authenticate: async request => { calls.push(`one:${request.token}`); return { authenticated: true }; } }, notification);
|
||||
contribution._handleAuthenticationRequiredNotification('host-two', { authenticate: async request => { calls.push(`two:${request.token}`); return { authenticated: true }; } }, notification);
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual(calls, ['one:session-token', 'two:session-token']);
|
||||
});
|
||||
|
||||
test('prompts on a second completed same-token challenge and creates a fresh transformed envelope', async () => {
|
||||
const instantiationService = store.add(new TestInstantiationService());
|
||||
instantiationService.stub(IAuthenticationService, {
|
||||
getOrActivateProviderIdForServer: async () => 'test-provider',
|
||||
getSessions: async () => [{ id: 'session-id', account: { id: 'account-id', label: 'Test Account' }, scopes: ['session:read'], accessToken: 'session-token' }],
|
||||
});
|
||||
instantiationService.stub(ILogService, new NullLogService());
|
||||
let promptCount = 0;
|
||||
instantiationService.stub(ICommandService, {
|
||||
executeCommand: async <R>() => {
|
||||
promptCount++;
|
||||
return { success: true } as R;
|
||||
},
|
||||
});
|
||||
const envelopes: string[] = [];
|
||||
let envelopeNumber = 0;
|
||||
const address = 'sealed-host';
|
||||
const contribution = Object.create(RemoteAgentHostContribution.prototype) as IRemoteAuthNotificationHarness;
|
||||
contribution._connections = new Map([[address, { authTokenCache: new AgentHostAuthTokenCache(), authRecovery: new AgentHostAuthenticationRecovery() }]]);
|
||||
contribution._sessionsProvidersService = { getProvider: () => undefined };
|
||||
contribution._instantiationService = instantiationService;
|
||||
contribution._connectionCustomizations = {
|
||||
get: () => ({
|
||||
authenticate: async request => ({ ...request, token: `${request.token}:sealed-${++envelopeNumber}` }),
|
||||
}),
|
||||
};
|
||||
contribution._logService = new NullLogService();
|
||||
const resource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.example.com/session',
|
||||
authorization_servers: ['https://auth.example.com'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
const notification: INotification = { type: NotificationType.AuthRequired, channel: 'ahp-root://', resource, reason: AuthRequiredReason.Expired };
|
||||
const connection: Pick<IAgentConnection, 'authenticate'> = { authenticate: async request => { envelopes.push(request.token); return { authenticated: true }; } };
|
||||
|
||||
contribution._handleAuthenticationRequiredNotification(address, connection, notification);
|
||||
await timeout(0);
|
||||
contribution._handleAuthenticationRequiredNotification(address, connection, notification);
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual({ envelopes, promptCount }, {
|
||||
envelopes: ['session-token:sealed-1', 'session-token:sealed-2'],
|
||||
promptCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('SSHReconnectState', () => {
|
||||
const store = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
.monaco-list-row:has(.session-item) {
|
||||
.monaco-list-row.session-list-inset-row {
|
||||
border-radius: var(--vscode-cornerRadius-medium);
|
||||
margin: 0 10px;
|
||||
width: calc(100% - 20px);
|
||||
@@ -736,7 +736,7 @@
|
||||
* keep these rules in sync if paddings/font sizes change here.
|
||||
*/
|
||||
.agent-sessions-workbench.phone-layout .sessions-list-control {
|
||||
.monaco-list-row:has(.session-item) {
|
||||
.monaco-list-row.session-list-inset-row {
|
||||
/* Horizontal-only margin: virtual list rows are absolutely
|
||||
* positioned with JS-set `top`/`height`, so vertical margins
|
||||
* here would not produce reliable inter-row spacing. Any gap
|
||||
|
||||
@@ -934,6 +934,8 @@ export class AutomationsCustomViewContribution extends Disposable {
|
||||
id: AUTOMATIONS_CUSTOM_VIEW_ID,
|
||||
ctor: new SyncDescriptor(AutomationsCustomView),
|
||||
actions: { style: 'buttonBar', menuId: Menus.CustomViewAutomations },
|
||||
}, {
|
||||
restore: contextKeyService.getContextKeyValue<boolean>(ChatAutomationsEnabledContext.key) === true,
|
||||
}));
|
||||
|
||||
const automationContextKeys = new Set([ChatAutomationsEnabledContext.key]);
|
||||
|
||||
@@ -370,6 +370,7 @@ export interface ISessionCIFixModel {
|
||||
class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, ISessionItemTemplate> {
|
||||
static readonly TEMPLATE_ID = 'session-item';
|
||||
readonly templateId = SessionItemRenderer.TEMPLATE_ID;
|
||||
readonly rowClassName = 'session-list-inset-row';
|
||||
|
||||
private static readonly _APPROVAL_ROW_LINE_HEIGHT = 18;
|
||||
private static readonly _APPROVAL_ROW_OVERHEAD = 14;
|
||||
@@ -1251,6 +1252,7 @@ class SessionGroupRenderer implements ITreeRenderer<SessionListItem, FuzzyScore,
|
||||
class SessionShowMoreRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, HTMLElement> {
|
||||
static readonly TEMPLATE_ID = 'session-show-more';
|
||||
readonly templateId = SessionShowMoreRenderer.TEMPLATE_ID;
|
||||
readonly rowClassName = 'session-list-inset-row';
|
||||
|
||||
renderTemplate(container: HTMLElement): HTMLElement {
|
||||
container.classList.add('session-show-more');
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ContextKeyService } from '../../../../../platform/contextkey/browser/co
|
||||
import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
|
||||
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
|
||||
import { NullLogService } from '../../../../../platform/log/common/log.js';
|
||||
import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js';
|
||||
import { IAutomationDescriptor, IAutomationRun } from '../../../../../workbench/contrib/chat/common/automations/automation.js';
|
||||
import { IAutomationDialogService } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js';
|
||||
import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js';
|
||||
@@ -136,7 +137,7 @@ function renderAutomations(ctx: ComponentFixtureContext, options: IAutomationsFi
|
||||
});
|
||||
const contextKeyService = new ContextKeyService(configurationService);
|
||||
const actionViewItemService = new FixtureActionViewItemService();
|
||||
const customViewService = new CustomViewService(new NullLogService());
|
||||
const customViewService = ctx.disposableStore.add(new CustomViewService(new NullLogService(), ctx.disposableStore.add(new InMemoryStorageService())));
|
||||
const automationService = new FixtureAutomationService(data.automations, data.runs);
|
||||
const sessionsManagementService = new FixtureSessionsManagementService(data.runs);
|
||||
ChatAutomationsEnabledContext.bindTo(contextKeyService).set(true);
|
||||
|
||||
@@ -24,8 +24,10 @@ import { MockContextKeyService } from '../../../../../platform/keybinding/test/c
|
||||
import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';
|
||||
import { IAutomationDescriptor, IAutomationRun, IAutomationSchedule, AutomationRunTrigger, AutomationTarget } from '../../../../../workbench/contrib/chat/common/automations/automation.js';
|
||||
import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../../workbench/contrib/chat/common/automations/automationDialogService.js';
|
||||
import { ChatAutomationsEnabledContext } from '../../../../../workbench/contrib/chat/common/automations/automationsEnabled.js';
|
||||
import { IAutomationRunDispatch, IAutomationRunner, IAutomationRunOperation } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js';
|
||||
import { AutomationMutationGuard, IAutomationRunClaim, IAutomationService, ICreateAutomationOptions, IGuardedAutomationUpdateResult, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../../../workbench/contrib/chat/common/automations/automationService.js';
|
||||
import { ICustomViewDescriptor } from '../../../../services/customView/browser/customView.js';
|
||||
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
|
||||
import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js';
|
||||
import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
|
||||
@@ -946,22 +948,27 @@ suite('AutomationsCardsWidget', () => {
|
||||
suite('AutomationsCustomViewContribution — context key', () => {
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
function setup() {
|
||||
function setup(automationsEnabled = true) {
|
||||
const automationService = new FakeAutomationService();
|
||||
const contextKeyService = new MockContextKeyService();
|
||||
ChatAutomationsEnabledContext.bindTo(contextKeyService).set(automationsEnabled);
|
||||
let restore: boolean | undefined;
|
||||
const instantiationService = disposables.add(new TestInstantiationService());
|
||||
instantiationService.stub(IAutomationService, automationService);
|
||||
instantiationService.stub(IContextKeyService, contextKeyService);
|
||||
instantiationService.stub(ICustomViewService, new class extends mock<ICustomViewService>() {
|
||||
override readonly activeCustomView = constObservable(undefined);
|
||||
override registerCustomView() { return { dispose() { } }; }
|
||||
override registerCustomView(_descriptor: ICustomViewDescriptor, options?: { readonly restore?: boolean }) {
|
||||
restore = options?.restore;
|
||||
return { dispose() { } };
|
||||
}
|
||||
override hideCustomView() { }
|
||||
}());
|
||||
instantiationService.stub(IActionViewItemService, new class extends mock<IActionViewItemService>() {
|
||||
override register() { return { dispose() { } }; }
|
||||
}());
|
||||
const contribution = disposables.add(instantiationService.createInstance(AutomationsCustomViewContribution));
|
||||
return { automationService, contextKeyService, contribution };
|
||||
return { automationService, contextKeyService, contribution, restore };
|
||||
}
|
||||
|
||||
test('AutomationsHasItemsContext follows the automations observable (empty → non-empty → empty)', () => {
|
||||
@@ -975,4 +982,14 @@ suite('AutomationsCustomViewContribution — context key', () => {
|
||||
automationService.setAutomations([]);
|
||||
assert.strictEqual(contextKeyService.getContextKeyValue(AutomationsHasItemsContext.key), false, 'false when empty again');
|
||||
});
|
||||
|
||||
test('restores the Automations view only when the feature is enabled', () => {
|
||||
assert.deepStrictEqual({
|
||||
enabled: setup(true).restore,
|
||||
disabled: setup(false).restore,
|
||||
}, {
|
||||
enabled: true,
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,11 @@ import { IObservable, observableValue } from '../../../../base/common/observable
|
||||
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
|
||||
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
|
||||
import { ILogService } from '../../../../platform/log/common/log.js';
|
||||
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
|
||||
import { ICustomViewDescriptor } from './customView.js';
|
||||
|
||||
export const ICustomViewService = createDecorator<ICustomViewService>('customViewService');
|
||||
const ACTIVE_CUSTOM_VIEW_STORAGE_KEY = 'sessions.activeCustomView';
|
||||
|
||||
/**
|
||||
* Owns which custom view (if any) should be rendered in place of the sessions
|
||||
@@ -25,7 +27,7 @@ export interface ICustomViewService {
|
||||
/** The view that should currently be rendered, or `undefined` for none. */
|
||||
readonly activeCustomView: IObservable<ICustomViewDescriptor | undefined>;
|
||||
|
||||
registerCustomView(descriptor: ICustomViewDescriptor): IDisposable;
|
||||
registerCustomView(descriptor: ICustomViewDescriptor, options?: { readonly restore?: boolean }): IDisposable;
|
||||
|
||||
/** Shows the registered view with the given id, replacing any shown view. */
|
||||
showCustomView(id: string): void;
|
||||
@@ -38,22 +40,33 @@ export class CustomViewService extends Disposable implements ICustomViewService
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private readonly _descriptors = new Map<string, ICustomViewDescriptor>();
|
||||
private _desiredCustomViewId: string | undefined;
|
||||
|
||||
private readonly _activeCustomView = observableValue<ICustomViewDescriptor | undefined>(this, undefined);
|
||||
readonly activeCustomView: IObservable<ICustomViewDescriptor | undefined> = this._activeCustomView;
|
||||
|
||||
constructor(
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@IStorageService private readonly _storageService: IStorageService,
|
||||
) {
|
||||
super();
|
||||
this._desiredCustomViewId = this._storageService.get(ACTIVE_CUSTOM_VIEW_STORAGE_KEY, StorageScope.WORKSPACE);
|
||||
}
|
||||
|
||||
registerCustomView(descriptor: ICustomViewDescriptor): IDisposable {
|
||||
registerCustomView(descriptor: ICustomViewDescriptor, options?: { readonly restore?: boolean }): IDisposable {
|
||||
if (this._descriptors.has(descriptor.id)) {
|
||||
throw new Error(`A custom view with id '${descriptor.id}' is already registered`);
|
||||
}
|
||||
|
||||
this._descriptors.set(descriptor.id, descriptor);
|
||||
if (this._desiredCustomViewId === descriptor.id) {
|
||||
if (options?.restore === false) {
|
||||
this._desiredCustomViewId = undefined;
|
||||
this._storageService.remove(ACTIVE_CUSTOM_VIEW_STORAGE_KEY, StorageScope.WORKSPACE);
|
||||
} else {
|
||||
this._activeCustomView.set(descriptor, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return toDisposable(() => {
|
||||
this._descriptors.delete(descriptor.id);
|
||||
@@ -70,10 +83,14 @@ export class CustomViewService extends Disposable implements ICustomViewService
|
||||
return;
|
||||
}
|
||||
|
||||
this._desiredCustomViewId = id;
|
||||
this._storageService.store(ACTIVE_CUSTOM_VIEW_STORAGE_KEY, id, StorageScope.WORKSPACE, StorageTarget.MACHINE);
|
||||
this._activeCustomView.set(descriptor, undefined);
|
||||
}
|
||||
|
||||
hideCustomView(): void {
|
||||
this._desiredCustomViewId = undefined;
|
||||
this._storageService.remove(ACTIVE_CUSTOM_VIEW_STORAGE_KEY, StorageScope.WORKSPACE);
|
||||
this._activeCustomView.set(undefined, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import assert from 'assert';
|
||||
import { constObservable, IObservable } from '../../../../../base/common/observable.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js';
|
||||
import { NullLogService } from '../../../../../platform/log/common/log.js';
|
||||
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
||||
import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';
|
||||
import { InMemoryStorageService, IStorageService } from '../../../../../platform/storage/common/storage.js';
|
||||
import { AbstractCustomView, ICustomViewDescriptor } from '../../browser/customView.js';
|
||||
import { CustomViewService } from '../../browser/customViewService.js';
|
||||
|
||||
@@ -21,7 +23,7 @@ suite('Sessions - CustomViewService', () => {
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
function createService(): CustomViewService {
|
||||
return disposables.add(new CustomViewService(new NullLogService()));
|
||||
return disposables.add(new CustomViewService(new NullLogService(), disposables.add(new InMemoryStorageService())));
|
||||
}
|
||||
|
||||
function descriptor(id: string): ICustomViewDescriptor {
|
||||
@@ -79,4 +81,82 @@ suite('Sessions - CustomViewService', () => {
|
||||
|
||||
assert.throws(() => service.registerCustomView(descriptor('first')));
|
||||
});
|
||||
|
||||
test('restores the active custom view after reload', () => {
|
||||
const instantiationService = disposables.add(new TestInstantiationService());
|
||||
instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService()));
|
||||
instantiationService.stub(ILogService, new NullLogService());
|
||||
|
||||
const firstService = disposables.add(instantiationService.createInstance(CustomViewService));
|
||||
disposables.add(firstService.registerCustomView(descriptor('automations')));
|
||||
firstService.showCustomView('automations');
|
||||
|
||||
const restoredService = disposables.add(instantiationService.createInstance(CustomViewService));
|
||||
const restoredDescriptor = descriptor('automations');
|
||||
disposables.add(restoredService.registerCustomView(restoredDescriptor));
|
||||
|
||||
assert.strictEqual(restoredService.activeCustomView.get(), restoredDescriptor);
|
||||
});
|
||||
|
||||
test('explicit hide prevents a pending view from restoring', () => {
|
||||
const storageService = disposables.add(new InMemoryStorageService());
|
||||
const firstService = disposables.add(new CustomViewService(new NullLogService(), storageService));
|
||||
disposables.add(firstService.registerCustomView(descriptor('automations')));
|
||||
firstService.showCustomView('automations');
|
||||
|
||||
const restoredService = disposables.add(new CustomViewService(new NullLogService(), storageService));
|
||||
restoredService.hideCustomView();
|
||||
disposables.add(restoredService.registerCustomView(descriptor('automations')));
|
||||
|
||||
assert.strictEqual(restoredService.activeCustomView.get(), undefined);
|
||||
});
|
||||
|
||||
test('ineligible registration clears only its matching restoration intent', () => {
|
||||
const storageService = disposables.add(new InMemoryStorageService());
|
||||
const firstService = disposables.add(new CustomViewService(new NullLogService(), storageService));
|
||||
disposables.add(firstService.registerCustomView(descriptor('automations')));
|
||||
firstService.showCustomView('automations');
|
||||
|
||||
const restoredService = disposables.add(new CustomViewService(new NullLogService(), storageService));
|
||||
disposables.add(restoredService.registerCustomView(descriptor('other'), { restore: false }));
|
||||
disposables.add(restoredService.registerCustomView(descriptor('automations'), { restore: false }));
|
||||
|
||||
const nextService = disposables.add(new CustomViewService(new NullLogService(), storageService));
|
||||
disposables.add(nextService.registerCustomView(descriptor('automations')));
|
||||
|
||||
assert.deepStrictEqual({
|
||||
restored: restoredService.activeCustomView.get(),
|
||||
nextReload: nextService.activeCustomView.get(),
|
||||
}, {
|
||||
restored: undefined,
|
||||
nextReload: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('unregistering clears the effective view but preserves restoration intent', () => {
|
||||
const storageService = disposables.add(new InMemoryStorageService());
|
||||
const service = disposables.add(new CustomViewService(new NullLogService(), storageService));
|
||||
const registration = service.registerCustomView(descriptor('automations'));
|
||||
service.showCustomView('automations');
|
||||
registration.dispose();
|
||||
|
||||
const restoredDescriptor = descriptor('automations');
|
||||
disposables.add(service.registerCustomView(restoredDescriptor));
|
||||
|
||||
assert.strictEqual(service.activeCustomView.get(), restoredDescriptor);
|
||||
});
|
||||
|
||||
test('showing an unknown view preserves the last valid restoration intent', () => {
|
||||
const storageService = disposables.add(new InMemoryStorageService());
|
||||
const firstService = disposables.add(new CustomViewService(new NullLogService(), storageService));
|
||||
disposables.add(firstService.registerCustomView(descriptor('automations')));
|
||||
firstService.showCustomView('automations');
|
||||
firstService.showCustomView('unknown');
|
||||
|
||||
const restoredService = disposables.add(new CustomViewService(new NullLogService(), storageService));
|
||||
const restoredDescriptor = descriptor('automations');
|
||||
disposables.add(restoredService.registerCustomView(restoredDescriptor));
|
||||
|
||||
assert.strictEqual(restoredService.activeCustomView.get(), restoredDescriptor);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,7 +254,7 @@ class TestSessionsPartService extends mock<ISessionsPartService>() {
|
||||
function createView(instantiationService: TestInstantiationService, service: ISessionsManagementService, disposables: ReturnType<typeof ensureNoDisposablesAreLeakedInTestSuite>): SessionsService {
|
||||
instantiationService.stub(ISessionsManagementService, service);
|
||||
instantiationService.stub(ISessionsPartService, new TestSessionsPartService());
|
||||
instantiationService.stub(ICustomViewService, disposables.add(new CustomViewService(new NullLogService())));
|
||||
instantiationService.stub(ICustomViewService, disposables.add(new CustomViewService(new NullLogService(), disposables.add(new InMemoryStorageService()))));
|
||||
instantiationService.stub(IConfigurationService, new TestConfigurationService());
|
||||
return disposables.add(instantiationService.createInstance(SessionsService));
|
||||
}
|
||||
|
||||
@@ -19,30 +19,19 @@
|
||||
*/
|
||||
|
||||
/* The host is a peer directly above the chat input, and stays out of the layout
|
||||
* entirely until the card is attached. */
|
||||
* entirely until the card is attached. The frame comes from `.chat-input-notice`;
|
||||
* the overlap with the input below is per-surface and is set here. */
|
||||
.voice-mode-onboarding-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-container.has-voice-mode-onboarding {
|
||||
display: block;
|
||||
/* Tuck the squared-off bottom edge behind the rounded top of the chat input
|
||||
* so the two surfaces read as one stack with no visible seam. */
|
||||
margin-bottom: -10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--vscode-spacing-size80);
|
||||
box-sizing: border-box;
|
||||
padding: var(--vscode-spacing-size120);
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent));
|
||||
border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-xSmall) var(--vscode-cornerRadius-xSmall);
|
||||
background-color: var(--vscode-agentsChatInput-background, var(--vscode-input-background));
|
||||
color: var(--vscode-foreground);
|
||||
/* Pull the input up over the card's squared bottom edge so the two read
|
||||
* as one stack with no visible seam. Kept next to the rule it overrides:
|
||||
* split across files these two tie on specificity. */
|
||||
margin-bottom: calc(-1 * var(--vscode-spacing-size100));
|
||||
padding-bottom: var(--vscode-spacing-size100);
|
||||
}
|
||||
|
||||
/* --- Copy --- */
|
||||
@@ -56,39 +45,6 @@
|
||||
padding-right: var(--vscode-spacing-size240);
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-title {
|
||||
font-size: var(--vscode-fontSize-label1);
|
||||
font-weight: var(--vscode-fontWeight-semiBold);
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-description {
|
||||
font-size: var(--vscode-fontSize-label2);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
/* Wraps rather than truncating: this sentence carries the promise, the ask
|
||||
* and the escape hatch, so it survives at every width. */
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* The settings link sits inside the sentence, so it takes its colour from the
|
||||
* link token and nothing else - no weight, no size, no box that would lift it
|
||||
* out of the prose it belongs to. */
|
||||
.voice-mode-onboarding-description a {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
cursor: pointer;
|
||||
border-radius: var(--vscode-cornerRadius-xSmall);
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-description a:hover,
|
||||
.voice-mode-onboarding-description a:active {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-description a:focus-visible {
|
||||
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- Waveform --- */
|
||||
|
||||
/*
|
||||
@@ -118,37 +74,6 @@
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/* --- Microphone picker --- */
|
||||
|
||||
.voice-mode-onboarding-microphone-picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--vscode-spacing-size60);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
height: var(--vscode-spacing-size280);
|
||||
padding: 0 var(--vscode-spacing-size80);
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-dropdown-border, var(--vscode-input-border, transparent));
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
background-color: var(--vscode-dropdown-background, var(--vscode-input-background));
|
||||
transition: border-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-microphone-picker[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-microphone-picker:hover {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-microphone-picker:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.monaco-workbench .voice-mode-onboarding-microphone-icon.codicon[class*='codicon-'] {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--vscode-codiconFontSize-compact);
|
||||
@@ -156,24 +81,6 @@
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-microphone-picker .monaco-select-box {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
background-color: transparent;
|
||||
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
|
||||
font-family: inherit;
|
||||
font-size: var(--vscode-fontSize-label2);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-microphone-picker .monaco-select-box:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* --- Voices and confirmation --- */
|
||||
|
||||
.voice-mode-onboarding-actions {
|
||||
@@ -260,7 +167,7 @@
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 0;
|
||||
height: var(--vscode-codiconFontSize-compact, 12px);
|
||||
height: var(--vscode-codiconFontSize-compact);
|
||||
overflow: hidden;
|
||||
transition: width 0.2s cubic-bezier(0.2, 0.9, 0.2, 1), margin-right 0.2s cubic-bezier(0.2, 0.9, 0.2, 1);
|
||||
}
|
||||
@@ -269,7 +176,7 @@
|
||||
.voice-mode-onboarding-voice:focus-visible .voice-mode-onboarding-voice-icon,
|
||||
.voice-mode-onboarding-voice.selected .voice-mode-onboarding-voice-icon,
|
||||
.voice-mode-onboarding-voice.playing .voice-mode-onboarding-voice-icon {
|
||||
width: var(--vscode-codiconFontSize-compact, 12px);
|
||||
width: var(--vscode-codiconFontSize-compact);
|
||||
margin-right: var(--vscode-spacing-size20);
|
||||
}
|
||||
|
||||
@@ -283,7 +190,7 @@
|
||||
}
|
||||
|
||||
.monaco-workbench .voice-mode-onboarding-voice-icon .codicon[class*='codicon-'] {
|
||||
font-size: var(--vscode-codiconFontSize-compact, 12px);
|
||||
font-size: var(--vscode-codiconFontSize-compact);
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
@@ -318,7 +225,7 @@
|
||||
|
||||
.voice-mode-onboarding-voice-bars {
|
||||
gap: 1px;
|
||||
height: var(--vscode-codiconFontSize-compact, 12px);
|
||||
height: var(--vscode-codiconFontSize-compact);
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-voice-bar {
|
||||
@@ -359,45 +266,3 @@
|
||||
.monaco-reduce-motion .voice-mode-onboarding-voice-icon > * {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* --- Close --- */
|
||||
|
||||
/*
|
||||
* Pinned to the corner and out of the content flow, so it never competes with
|
||||
* the voices for room and never moves as the card re-tiers. Always available:
|
||||
* a disabled dismiss would trap someone inside the card.
|
||||
*/
|
||||
.voice-mode-onboarding-close {
|
||||
position: absolute;
|
||||
top: var(--vscode-spacing-size80);
|
||||
right: var(--vscode-spacing-size80);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--vscode-spacing-size200);
|
||||
height: var(--vscode-spacing-size200);
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-close:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.voice-mode-onboarding-close:focus-visible {
|
||||
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/*
|
||||
* `.monaco-workbench .codicon` sets colour and size directly on the glyph, so
|
||||
* without out-ranking it this renders as a dark 16px icon rather than a compact
|
||||
* one inheriting the button's foreground.
|
||||
*/
|
||||
.monaco-workbench .voice-mode-onboarding-close .codicon[class*='codicon-'] {
|
||||
font-size: var(--vscode-codiconFontSize-compact, 12px);
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ import { IContextViewService } from '../../../../platform/contextview/browser/co
|
||||
import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';
|
||||
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
|
||||
import { CONFIGURE_VOICE_INSTRUCTIONS_ACTION_ID } from '../../chat/browser/actions/configureVoiceInstructionsAction.js';
|
||||
import { ChatInputOnboarding, ChatInputOnboardingCard, IChatInputOnboardingBanner, IChatInputOnboardingContext, IChatInputOnboardingHostOptions } from '../../chat/browser/widget/input/chatInputOnboarding.js';
|
||||
import { ChatInputOnboarding, IChatInputOnboardingBanner, IChatInputOnboardingContext, IChatInputOnboardingHostOptions } from '../../chat/browser/widget/input/chatInputOnboarding.js';
|
||||
import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../../chat/browser/widget/input/chatInputNoticeWidget.js';
|
||||
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
|
||||
import { defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js';
|
||||
import { asCssVariable, asCssVariableWithDefault, selectBackground, selectListBackground } from '../../../../platform/theme/common/colorRegistry.js';
|
||||
@@ -675,11 +676,8 @@ interface IVoiceElement {
|
||||
* afterwards. The leading icon carries that story: play before the click,
|
||||
* animating bars while it speaks, then a check once it is yours.
|
||||
*/
|
||||
export class VoiceModeOnboardingBanner extends Disposable implements IChatInputOnboardingBanner {
|
||||
export class VoiceModeOnboardingBanner extends ChatInputNoticeWidget implements IChatInputOnboardingBanner {
|
||||
|
||||
readonly domNode: HTMLElement;
|
||||
|
||||
private readonly card: ChatInputOnboardingCard;
|
||||
private readonly player: VoiceSamplePlayer;
|
||||
private animator: VoiceModeOnboardingAnimator | undefined;
|
||||
private readonly options: IVoiceModeOnboardingBannerOptions;
|
||||
@@ -711,12 +709,9 @@ export class VoiceModeOnboardingBanner extends Disposable implements IChatInputO
|
||||
@IStorageService private readonly storageService: IStorageService,
|
||||
@ITelemetryService private readonly telemetryService: ITelemetryService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.options = options;
|
||||
|
||||
this.card = this._register(new ChatInputOnboardingCard({
|
||||
super({
|
||||
container: options.container,
|
||||
variant: ChatInputNoticeVariant.Onboarding,
|
||||
className: 'voice-mode-onboarding-banner',
|
||||
ariaLabel: localize('voiceMode.onboarding.region', "Voice Mode introduction"),
|
||||
ariaDescription: localize('voiceMode.onboarding.regionDescription', "Choose how your agent speaks to you. Adjust settings anytime."),
|
||||
@@ -724,14 +719,15 @@ export class VoiceModeOnboardingBanner extends Disposable implements IChatInputO
|
||||
this.logAction('escape');
|
||||
this.options.onDismiss();
|
||||
},
|
||||
}));
|
||||
this.domNode = this.card.domNode;
|
||||
});
|
||||
|
||||
this.options = options;
|
||||
this.localizedVoice = localizedVoiceForLanguage(this.resolveSpokenLanguage());
|
||||
this.player = this._register(instantiationService.createInstance(VoiceSamplePlayer, this.domNode, options.audioFactory));
|
||||
this._register(this.player.onDidChangePlayingVoice(voiceId => this.updatePlaying(voiceId)));
|
||||
|
||||
const copy = dom.append(this.domNode, dom.$('.voice-mode-onboarding-copy'));
|
||||
const title = dom.append(copy, dom.$('.voice-mode-onboarding-title'));
|
||||
const title = dom.append(copy, dom.$('.chat-input-notice-title.voice-mode-onboarding-title'));
|
||||
title.textContent = localize('voiceMode.onboarding.title', "Welcome to Voice Mode");
|
||||
this.renderDescription(copy);
|
||||
|
||||
@@ -774,7 +770,7 @@ export class VoiceModeOnboardingBanner extends Disposable implements IChatInputO
|
||||
}
|
||||
|
||||
private renderMicrophonePicker(): void {
|
||||
this.microphonePickerContainer = dom.append(this.domNode, dom.$('.voice-mode-onboarding-microphone-picker'));
|
||||
this.microphonePickerContainer = dom.append(this.domNode, dom.$('.chat-input-notice-picker.voice-mode-onboarding-microphone-picker'));
|
||||
this.microphoneOptions = [{
|
||||
deviceId: '',
|
||||
label: localize('voiceMode.onboarding.systemDefault', "System default"),
|
||||
@@ -1018,7 +1014,7 @@ export class VoiceModeOnboardingBanner extends Disposable implements IChatInputO
|
||||
* concatenated onto the end.
|
||||
*/
|
||||
private renderDescription(container: HTMLElement): void {
|
||||
const description = dom.append(container, dom.$('.voice-mode-onboarding-description'));
|
||||
const description = dom.append(container, dom.$('.chat-input-notice-description.voice-mode-onboarding-description'));
|
||||
const text = localize({
|
||||
key: 'voiceMode.onboarding.description',
|
||||
comment: [
|
||||
@@ -1062,38 +1058,26 @@ export class VoiceModeOnboardingBanner extends Disposable implements IChatInputO
|
||||
* ever "I am done here" - and closing is what hands the session back.
|
||||
*/
|
||||
private renderClose(): void {
|
||||
this.card.addAction({
|
||||
this.addDismissAction({
|
||||
className: 'voice-mode-onboarding-close',
|
||||
ariaLabel: localize('voiceMode.onboarding.close', "Close the introduction"),
|
||||
icon: Codicon.closeCompact,
|
||||
onActivate: () => this.finish(),
|
||||
});
|
||||
}
|
||||
|
||||
announce(): void {
|
||||
this.card.announce();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the sample and the waveform while the card is put away for a
|
||||
* notification, so an invisible introduction is not still playing audio or
|
||||
* painting every frame.
|
||||
*/
|
||||
setVisible(visible: boolean): void {
|
||||
override setVisible(visible: boolean): void {
|
||||
super.setVisible(visible);
|
||||
this.animator?.setSuspended(!visible);
|
||||
if (!visible) {
|
||||
this.player.stop();
|
||||
}
|
||||
}
|
||||
|
||||
hasFocus(): boolean {
|
||||
return this.card.hasFocus();
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.card.focus();
|
||||
}
|
||||
|
||||
private selectVoice(voice: IVoiceModeVoice): void {
|
||||
if (this.player.playingVoice === voice.id) {
|
||||
this.player.stop();
|
||||
|
||||
@@ -317,14 +317,14 @@ suite('Voice Mode onboarding', () => {
|
||||
activeElement: document.activeElement,
|
||||
card,
|
||||
tabIndex: card?.tabIndex,
|
||||
closeIcon: host.container.querySelector('.voice-mode-onboarding-close .codicon')?.className,
|
||||
closeIcon: host.container.querySelector('.voice-mode-onboarding-close')?.className,
|
||||
listeningNotice: host.container.querySelector('.voice-mode-onboarding-listening-notice'),
|
||||
},
|
||||
{
|
||||
activeElement: document.body,
|
||||
card,
|
||||
tabIndex: 0,
|
||||
closeIcon: 'codicon codicon-close-compact',
|
||||
closeIcon: 'action-label codicon codicon-close-compact voice-mode-onboarding-close chat-input-notice-dismiss',
|
||||
listeningNotice: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,6 +165,85 @@ export class AgentHostAuthTokenCache {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a stable identity for an authentication challenge.
|
||||
*/
|
||||
function protectedResourceAuthenticationKey(resource: ProtectedResourceMetadata): string {
|
||||
return JSON.stringify([
|
||||
resource.resource,
|
||||
[...new Set(resource.scopes_supported ?? [])].sort(),
|
||||
resource.authorization_servers ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates recovery from authentication challenges for one agent-host connection.
|
||||
*/
|
||||
export class AgentHostAuthenticationRecovery {
|
||||
private readonly _resentTokens = new Map<string, string>();
|
||||
private readonly _pendingRecoveries = new Map<string, Promise<void>>();
|
||||
|
||||
recover(accessor: ServicesAccessor, resource: ProtectedResourceMetadata, options: IAgentHostAuthenticationOptions): Promise<void> {
|
||||
const key = protectedResourceAuthenticationKey(resource);
|
||||
const pendingRecovery = this._pendingRecoveries.get(key);
|
||||
if (pendingRecovery) {
|
||||
return pendingRecovery;
|
||||
}
|
||||
|
||||
const recovery = this._recover(accessor, key, resource, options)
|
||||
.finally(() => {
|
||||
if (this._pendingRecoveries.get(key) === recovery) {
|
||||
this._pendingRecoveries.delete(key);
|
||||
}
|
||||
});
|
||||
this._pendingRecoveries.set(key, recovery);
|
||||
return recovery;
|
||||
}
|
||||
|
||||
private async _recover(accessor: ServicesAccessor, key: string, resource: ProtectedResourceMetadata, options: IAgentHostAuthenticationOptions): Promise<void> {
|
||||
const authenticationService = accessor.get(IAuthenticationService);
|
||||
const commandService = accessor.get(ICommandService);
|
||||
const logService = accessor.get(ILogService);
|
||||
const scopes = resource.scopes_supported ?? [];
|
||||
const token = await resolveTokenForResource(
|
||||
URI.parse(resource.resource),
|
||||
resource.authorization_servers ?? [],
|
||||
scopes,
|
||||
authenticationService,
|
||||
logService,
|
||||
options.logPrefix,
|
||||
);
|
||||
if (!token) {
|
||||
logService.info(`${options.logPrefix} No token resolved for resource: ${resource.resource}`);
|
||||
options.authTokenCache?.clear(resource.resource, resource.scopes_supported);
|
||||
if (await forwardAuthenticationToken(options, resource.resource, scopes, '')) {
|
||||
this._resentTokens.delete(key);
|
||||
logService.info(`${options.logPrefix} Clearing authentication for resource: ${resource.resource}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const previousToken = this._resentTokens.get(key);
|
||||
if (previousToken !== undefined && previousToken === token) {
|
||||
options.authTokenCache?.clear(resource.resource, resource.scopes_supported);
|
||||
const interactiveToken = await forceAuthenticationInteractively(authenticationService, commandService, logService, resource, options);
|
||||
if (interactiveToken) {
|
||||
this._resentTokens.set(key, interactiveToken);
|
||||
if (interactiveToken === token) {
|
||||
logService.info(`${options.logPrefix} Interactive authentication completed without a new token for ${resource.resource}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
options.authTokenCache?.clear(resource.resource, resource.scopes_supported);
|
||||
if (await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported ?? [], token)) {
|
||||
this._resentTokens.set(key, token);
|
||||
logService.info(`${options.logPrefix} Authenticating for resource: ${resource.resource}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a bearer token for a protected resource by trying each
|
||||
* authorization server in order. First attempts an exact scope match,
|
||||
@@ -190,8 +269,9 @@ export async function resolveTokenForResource(
|
||||
|
||||
// Try exact scope match first
|
||||
const sessions = await authenticationService.getSessions(providerId, [...scopes], { authorizationServer: serverUri }, true);
|
||||
if (sessions.length > 0) {
|
||||
return sessions[0].accessToken;
|
||||
const exactSession = sessions[0];
|
||||
if (exactSession) {
|
||||
return exactSession.accessToken;
|
||||
}
|
||||
|
||||
// Fall back: get all sessions and find the narrowest superset of requested scopes
|
||||
@@ -284,28 +364,61 @@ export async function authenticateProtectedResources(
|
||||
const logService = accessor.get(ILogService);
|
||||
for (const agent of agents) {
|
||||
for (const resource of agent.protectedResources ?? []) {
|
||||
const resourceUri = URI.parse(resource.resource);
|
||||
const scopes = resource.scopes_supported ?? [];
|
||||
const token = await resolveTokenForResource(
|
||||
resourceUri,
|
||||
resource.authorization_servers ?? [],
|
||||
scopes,
|
||||
authenticationService,
|
||||
logService,
|
||||
options.logPrefix,
|
||||
);
|
||||
const authenticated = await forwardAuthenticationToken(options, resource.resource, scopes, token ?? '');
|
||||
if (!authenticated) {
|
||||
logService.trace(`${options.logPrefix} Authentication state for ${resource.resource} unchanged; skipping authenticate RPC`);
|
||||
continue;
|
||||
}
|
||||
logService.info(token
|
||||
? `${options.logPrefix} Authenticating for resource: ${resource.resource}`
|
||||
: `${options.logPrefix} Clearing authentication for resource: ${resource.resource}`);
|
||||
await authenticateProtectedResourceWithServices(authenticationService, logService, resource, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and forwards a bearer token for a single protected resource.
|
||||
*/
|
||||
export async function authenticateProtectedResource(
|
||||
accessor: ServicesAccessor,
|
||||
resource: ProtectedResourceMetadata,
|
||||
options: IAgentHostAuthenticationOptions,
|
||||
): Promise<boolean> {
|
||||
return authenticateProtectedResourceWithServices(accessor.get(IAuthenticationService), accessor.get(ILogService), resource, options);
|
||||
}
|
||||
|
||||
async function authenticateProtectedResourceWithServices(
|
||||
authenticationService: IAuthenticationService,
|
||||
logService: ILogService,
|
||||
resource: ProtectedResourceMetadata,
|
||||
options: IAgentHostAuthenticationOptions,
|
||||
): Promise<boolean> {
|
||||
const token = await resolveTokenForProtectedResource(authenticationService, logService, resource, options);
|
||||
|
||||
const authenticated = await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported ?? [], token ?? '');
|
||||
if (!authenticated) {
|
||||
logService.trace(`${options.logPrefix} Authentication state for ${resource.resource} unchanged; skipping authenticate RPC`);
|
||||
return false;
|
||||
}
|
||||
logService.info(token
|
||||
? `${options.logPrefix} Authenticating for resource: ${resource.resource}`
|
||||
: `${options.logPrefix} Clearing authentication for resource: ${resource.resource}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function resolveTokenForProtectedResource(
|
||||
authenticationService: IAuthenticationService,
|
||||
logService: ILogService,
|
||||
resource: ProtectedResourceMetadata,
|
||||
options: Pick<IAgentHostAuthenticationOptions, 'logPrefix'>,
|
||||
): Promise<string | undefined> {
|
||||
const token = await resolveTokenForResource(
|
||||
URI.parse(resource.resource),
|
||||
resource.authorization_servers ?? [],
|
||||
resource.scopes_supported ?? [],
|
||||
authenticationService,
|
||||
logService,
|
||||
options.logPrefix,
|
||||
);
|
||||
if (!token) {
|
||||
logService.info(`${options.logPrefix} No token resolved for resource: ${resource.resource}`);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user to authenticate one of the provided protected resources and
|
||||
* forwards the resulting token to the agent host connection.
|
||||
@@ -321,7 +434,7 @@ export async function resolveAuthenticationInteractively(
|
||||
for (const resource of protectedResources) {
|
||||
const resourceUri = URI.parse(resource.resource);
|
||||
const scopes = resource.scopes_supported ?? [];
|
||||
let token = await resolveTokenForResource(
|
||||
const existingToken = await resolveTokenForResource(
|
||||
resourceUri,
|
||||
resource.authorization_servers ?? [],
|
||||
scopes,
|
||||
@@ -329,44 +442,59 @@ export async function resolveAuthenticationInteractively(
|
||||
logService,
|
||||
options.logPrefix,
|
||||
);
|
||||
if (token) {
|
||||
await forwardAuthenticationToken(options, resource.resource, scopes, token);
|
||||
if (existingToken) {
|
||||
await forwardAuthenticationToken(options, resource.resource, scopes, existingToken);
|
||||
logService.info(`${options.logPrefix} Interactive authentication succeeded for ${resource.resource}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const setupResult = await commandService.executeCommand<IChatSetupResult>(CHAT_SETUP_ACTION_ID, undefined, {
|
||||
forceSignInDialog: true,
|
||||
additionalScopes: scopes,
|
||||
dialogTitle: localize('agentHost.signInDialogTitle', "Sign in to use GitHub Copilot"),
|
||||
disableChatViewReveal: true,
|
||||
returnResult: true,
|
||||
});
|
||||
if (setupResult?.success === undefined) {
|
||||
return false;
|
||||
}
|
||||
if (!setupResult.success) {
|
||||
throw setupResult.error ?? new Error(localize('agentHost.signInFailed', "Failed to sign in to use GitHub Copilot."));
|
||||
}
|
||||
token = await resolveTokenForResource(
|
||||
resourceUri,
|
||||
resource.authorization_servers ?? [],
|
||||
scopes,
|
||||
authenticationService,
|
||||
logService,
|
||||
options.logPrefix,
|
||||
);
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
await forwardAuthenticationToken(options, resource.resource, scopes, token);
|
||||
logService.info(`${options.logPrefix} Interactive authentication succeeded for ${resource.resource}`);
|
||||
return true;
|
||||
return (await forceAuthenticationInteractively(authenticationService, commandService, logService, resource, options)) !== undefined;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function forceAuthenticationInteractively(
|
||||
authenticationService: IAuthenticationService,
|
||||
commandService: ICommandService,
|
||||
logService: ILogService,
|
||||
resource: ProtectedResourceMetadata,
|
||||
options: IAgentHostAuthenticationOptions,
|
||||
): Promise<string | undefined> {
|
||||
const scopes = resource.scopes_supported ?? [];
|
||||
const setupResult = await commandService.executeCommand<IChatSetupResult>(CHAT_SETUP_ACTION_ID, undefined, {
|
||||
forceSignInDialog: true,
|
||||
additionalScopes: scopes,
|
||||
dialogTitle: localize('agentHost.signInDialogTitle', "Sign in to use GitHub Copilot"),
|
||||
disableChatViewReveal: true,
|
||||
returnResult: true,
|
||||
});
|
||||
if (setupResult?.success === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!setupResult.success) {
|
||||
throw setupResult.error ?? new Error(localize('agentHost.signInFailed', "Failed to sign in to use GitHub Copilot."));
|
||||
}
|
||||
const token = await resolveTokenForResource(
|
||||
URI.parse(resource.resource),
|
||||
resource.authorization_servers ?? [],
|
||||
scopes,
|
||||
authenticationService,
|
||||
logService,
|
||||
options.logPrefix,
|
||||
);
|
||||
if (!token) {
|
||||
logService.info(`${options.logPrefix} Interactive authentication did not provide a token for ${resource.resource}`);
|
||||
return undefined;
|
||||
}
|
||||
options.authTokenCache?.clear(resource.resource, scopes);
|
||||
if (!await forwardAuthenticationToken(options, resource.resource, scopes, token)) {
|
||||
return undefined;
|
||||
}
|
||||
logService.info(`${options.logPrefix} Interactive authentication completed for ${resource.resource}`);
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function resolveMcpServerAuthentication(
|
||||
accessor: ServicesAccessor,
|
||||
protectedResource: ProtectedResourceMetadata,
|
||||
|
||||
+18
-4
@@ -33,7 +33,7 @@ import { languageModelSourcePresentationRegistry } from '../../../common/languag
|
||||
import { Target } from '../../../common/promptSyntax/promptTypes.js';
|
||||
import { AgentCustomizationItemProvider } from './agentCustomizationItemProvider.js';
|
||||
import { AgentHostDownloadProgress } from './agentHostDownloadProgress.js';
|
||||
import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from './agentHostAuth.js';
|
||||
import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from './agentHostAuth.js';
|
||||
import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from './agentHostLanguageModelProvider.js';
|
||||
import { AgentHostSessionHandler } from './agentHostSessionHandler.js';
|
||||
import { AgentHostPromptCacheNotification } from './agentHostPromptCacheNotification.js';
|
||||
@@ -115,6 +115,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
|
||||
|
||||
/** Dedupes redundant `authenticate` RPCs when the resolved token hasn't changed. */
|
||||
private readonly _authTokenCache = new AgentHostAuthTokenCache();
|
||||
private readonly _authRecovery = new AgentHostAuthenticationRecovery();
|
||||
|
||||
private readonly _isSessionsWindow: boolean;
|
||||
private readonly _enableSmokeTestDriver: boolean;
|
||||
@@ -167,9 +168,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
|
||||
if (notification.type !== NotificationType.AuthRequired) {
|
||||
return;
|
||||
}
|
||||
this._authTokenCache.clear(notification.resource);
|
||||
this._authenticateWithServer(this._getRootAgents())
|
||||
.catch(() => { /* best-effort */ });
|
||||
this._authenticateNotificationResource(notification.resource);
|
||||
}));
|
||||
|
||||
// Surface the agent host's lazy, first-use SDK download as a progress
|
||||
@@ -383,6 +382,21 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr
|
||||
}
|
||||
}
|
||||
|
||||
private _authenticateNotificationResource(protectedResource: ProtectedResourceMetadata): void {
|
||||
this._agentHostService.setAuthenticationPending(true);
|
||||
this._instantiationService.invokeFunction(accessor => this._authRecovery.recover(accessor, protectedResource, {
|
||||
authTokenCache: this._authTokenCache,
|
||||
logPrefix: '[AgentHost]',
|
||||
authenticate: request => this._agentHostService.authenticate(request),
|
||||
}))
|
||||
.catch(err => {
|
||||
this._logService.error(`[AgentHost] Failed to authenticate notified resource ${protectedResource.resource}`, err);
|
||||
})
|
||||
.finally(() => {
|
||||
this._agentHostService.setAuthenticationPending(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactively prompt the user to authenticate when the server requires it.
|
||||
* Uses protectedResources from root state, resolves the auth provider,
|
||||
|
||||
@@ -24,7 +24,8 @@ import { ITelemetryService } from '../../../../../platform/telemetry/common/tele
|
||||
import { defaultSelectBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js';
|
||||
import { AgentsVoiceStorageKeys } from '../../../agentsVoice/common/agentsVoice.js';
|
||||
import { CONFIGURE_DICTATION_INSTRUCTIONS_ACTION_ID } from '../actions/configureVoiceInstructionsAction.js';
|
||||
import { ChatInputOnboarding, ChatInputOnboardingCard, IChatInputOnboardingBanner, IChatInputOnboardingHostOptions } from '../widget/input/chatInputOnboarding.js';
|
||||
import { ChatInputOnboarding, IChatInputOnboardingBanner, IChatInputOnboardingHostOptions } from '../widget/input/chatInputOnboarding.js';
|
||||
import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../widget/input/chatInputNoticeWidget.js';
|
||||
import './media/dictationOnboarding.css';
|
||||
|
||||
/**
|
||||
@@ -543,11 +544,8 @@ export interface IDictationOnboardingBannerOptions {
|
||||
* The card runs alongside the first dictation, so it explains the feature
|
||||
* without delaying the action the user invoked.
|
||||
*/
|
||||
export class DictationOnboardingBanner extends Disposable implements IChatInputOnboardingBanner {
|
||||
export class DictationOnboardingBanner extends ChatInputNoticeWidget implements IChatInputOnboardingBanner {
|
||||
|
||||
readonly domNode: HTMLElement;
|
||||
|
||||
private readonly card: ChatInputOnboardingCard;
|
||||
private readonly preview: MicrophonePreview | undefined;
|
||||
private readonly waveform: MicrophoneWaveform;
|
||||
private readonly hint: HTMLElement | undefined;
|
||||
@@ -569,28 +567,26 @@ export class DictationOnboardingBanner extends Disposable implements IChatInputO
|
||||
@IStorageService private readonly storageService: IStorageService,
|
||||
@ITelemetryService private readonly telemetryService: ITelemetryService,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.card = this._register(new ChatInputOnboardingCard({
|
||||
super({
|
||||
container: bannerOptions.container,
|
||||
variant: ChatInputNoticeVariant.Onboarding,
|
||||
className: 'dictation-onboarding-banner',
|
||||
ariaLabel: localize('dictation.onboarding.region', "Dictation introduction"),
|
||||
ariaDescription: bannerOptions.previewMicrophone
|
||||
? localize('dictation.onboarding.regionDescription.preview', "Say anything to check your microphone.")
|
||||
: localize('dictation.onboarding.regionDescription', "Speak and it becomes text."),
|
||||
onEscape: () => this.dismiss('escape'),
|
||||
}));
|
||||
this.domNode = this.card.domNode;
|
||||
});
|
||||
|
||||
const header = dom.append(this.domNode, dom.$('.dictation-onboarding-header'));
|
||||
const title = dom.append(header, dom.$('.dictation-onboarding-title'));
|
||||
const title = dom.append(header, dom.$('.chat-input-notice-title.dictation-onboarding-title'));
|
||||
title.textContent = localize('dictation.onboarding.title', "Dictation");
|
||||
this.renderDescription(header);
|
||||
|
||||
this.renderClose();
|
||||
|
||||
const device = dom.append(this.domNode, dom.$('.dictation-onboarding-device'));
|
||||
this.pickerContainer = dom.append(device, dom.$('.dictation-onboarding-picker'));
|
||||
this.pickerContainer = dom.append(device, dom.$('.chat-input-notice-picker.dictation-onboarding-picker'));
|
||||
this.options = [{
|
||||
deviceId: SYSTEM_DEFAULT_DEVICE_ID,
|
||||
label: localize('dictation.onboarding.systemDefault', "System default"),
|
||||
@@ -629,16 +625,13 @@ export class DictationOnboardingBanner extends Disposable implements IChatInputO
|
||||
this.logAction('shown');
|
||||
}
|
||||
|
||||
announce(): void {
|
||||
this.card.announce();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the waveform and releases the microphone while the card is put away
|
||||
* for a notification, so an invisible introduction never holds the microphone
|
||||
* open or keeps painting.
|
||||
*/
|
||||
setVisible(visible: boolean): void {
|
||||
override setVisible(visible: boolean): void {
|
||||
super.setVisible(visible);
|
||||
if (visible) {
|
||||
this.waveform.start();
|
||||
if (this.preview) {
|
||||
@@ -650,14 +643,6 @@ export class DictationOnboardingBanner extends Disposable implements IChatInputO
|
||||
}
|
||||
}
|
||||
|
||||
hasFocus(): boolean {
|
||||
return this.card.hasFocus();
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.card.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* What dictation is, and that none of it is fixed. The card is shown once, so
|
||||
* the two things a user might want to change afterwards - whether dictation
|
||||
@@ -668,7 +653,7 @@ export class DictationOnboardingBanner extends Disposable implements IChatInputO
|
||||
* sentence natural instead of having fixed phrases concatenated on.
|
||||
*/
|
||||
private renderDescription(container: HTMLElement): void {
|
||||
const description = dom.append(container, dom.$('.dictation-onboarding-description'));
|
||||
const description = dom.append(container, dom.$('.chat-input-notice-description.dictation-onboarding-description'));
|
||||
const text = localize({
|
||||
key: 'dictation.onboarding.description',
|
||||
comment: ['Preserve the double square brackets: they mark the text that becomes a link. Keep both links, in this order - the first opens settings, the second opens the customization file.'],
|
||||
@@ -841,10 +826,9 @@ export class DictationOnboardingBanner extends Disposable implements IChatInputO
|
||||
}
|
||||
|
||||
private renderClose(): void {
|
||||
this.card.addAction({
|
||||
this.addDismissAction({
|
||||
className: 'dictation-onboarding-close',
|
||||
ariaLabel: localize('dictation.onboarding.close', "Close the introduction"),
|
||||
icon: Codicon.close,
|
||||
onActivate: () => this.dismiss('close'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,30 +21,26 @@
|
||||
*/
|
||||
|
||||
/* The host is a peer directly above the chat input, and stays out of the layout
|
||||
* entirely until the card is attached. */
|
||||
* entirely until the card is attached. The frame comes from `.chat-input-notice`;
|
||||
* the overlap with the input below is per-surface and is set here. */
|
||||
.dictation-onboarding-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dictation-onboarding-container.has-dictation-onboarding {
|
||||
display: block;
|
||||
/* Tuck the squared-off bottom edge behind the rounded top of the chat input
|
||||
* so the two surfaces read as one stack with no visible seam. */
|
||||
margin-bottom: -10px;
|
||||
padding-bottom: 10px;
|
||||
/* Pull the input up over the card's squared bottom edge so the two read
|
||||
* as one stack with no visible seam. Kept next to the rule it overrides:
|
||||
* split across files these two tie on specificity. */
|
||||
margin-bottom: calc(-1 * var(--vscode-spacing-size100));
|
||||
padding-bottom: var(--vscode-spacing-size100);
|
||||
}
|
||||
|
||||
.dictation-onboarding-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* This card carries four tiers rather than the card default's two, so it takes
|
||||
* the next step up the spacing ramp for both its padding and its gaps. */
|
||||
.chat-input-notice.dictation-onboarding-banner {
|
||||
gap: var(--vscode-spacing-size120);
|
||||
box-sizing: border-box;
|
||||
padding: var(--vscode-spacing-size160);
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent));
|
||||
border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-xSmall) var(--vscode-cornerRadius-xSmall);
|
||||
background-color: var(--vscode-agentsChatInput-background, var(--vscode-input-background));
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
/* --- Copy --- */
|
||||
@@ -58,41 +54,6 @@
|
||||
padding-right: var(--vscode-spacing-size240);
|
||||
}
|
||||
|
||||
.dictation-onboarding-title {
|
||||
font-size: var(--vscode-fontSize-label1);
|
||||
font-weight: var(--vscode-fontWeight-semiBold);
|
||||
}
|
||||
|
||||
/* One sentence, demoted: it explains the feature once and then supports. */
|
||||
.dictation-onboarding-description {
|
||||
font-size: var(--vscode-fontSize-label2);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
/*
|
||||
* The two ways out of the card - what dictation does, and how it writes - are
|
||||
* the only actions in this tier, so they have to look like actions. Inherit the
|
||||
* quiet type role and lift only the colour, so the sentence still reads as a
|
||||
* sentence rather than as a row of buttons.
|
||||
*/
|
||||
.dictation-onboarding-description a {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dictation-onboarding-description a:hover,
|
||||
.dictation-onboarding-description a:active {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dictation-onboarding-description a:focus-visible {
|
||||
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--vscode-cornerRadius-xSmall);
|
||||
}
|
||||
|
||||
/* --- Device --- */
|
||||
|
||||
/*
|
||||
@@ -107,43 +68,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* --- Microphone picker --- */
|
||||
|
||||
/*
|
||||
* A real control, and it has to look like one: this is the only thing on the
|
||||
* card you can act on, and a borderless row of text gave no sign it could be
|
||||
* opened at all. It carries the standard dropdown surface so it reads the same
|
||||
* as every other select in the product.
|
||||
*/
|
||||
.dictation-onboarding-picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--vscode-spacing-size60);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
height: var(--vscode-spacing-size280);
|
||||
padding: 0 var(--vscode-spacing-size80);
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-dropdown-border, var(--vscode-input-border, transparent));
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
background-color: var(--vscode-dropdown-background, var(--vscode-input-background));
|
||||
transition: border-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.dictation-onboarding-picker[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dictation-onboarding-picker:hover {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.dictation-onboarding-picker:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.monaco-workbench .dictation-onboarding-picker-icon.codicon[class*='codicon-'] {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--vscode-codiconFontSize-compact);
|
||||
@@ -151,32 +75,6 @@
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/*
|
||||
* The select itself stays transparent so the housing above draws the surface -
|
||||
* one border, not two. It is still a real select: the dropdown, the keyboard
|
||||
* handling and the screen-reader semantics are untouched.
|
||||
*/
|
||||
.dictation-onboarding-picker .monaco-select-box {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
background-color: transparent;
|
||||
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
|
||||
font-family: inherit;
|
||||
font-size: var(--vscode-fontSize-label2);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The housing already shows focus; a second ring inside it is noise. */
|
||||
.dictation-onboarding-picker .monaco-select-box:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* --- Waveform --- */
|
||||
|
||||
/*
|
||||
@@ -244,45 +142,3 @@
|
||||
.dictation-onboarding-banner.has-error .dictation-onboarding-hint {
|
||||
color: var(--vscode-inputValidation-errorForeground, var(--vscode-foreground));
|
||||
}
|
||||
|
||||
/* --- Confirm --- */
|
||||
|
||||
/*
|
||||
* Pinned to the corner and out of the content flow, so it never competes with
|
||||
* the picker for room and never moves as the card re-flows. Always available: a
|
||||
* disabled dismiss would trap someone inside the card.
|
||||
*/
|
||||
.dictation-onboarding-close {
|
||||
position: absolute;
|
||||
top: var(--vscode-spacing-size80);
|
||||
right: var(--vscode-spacing-size80);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--vscode-spacing-size240);
|
||||
height: var(--vscode-spacing-size240);
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
color: var(--vscode-foreground);
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.dictation-onboarding-close:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.dictation-onboarding-close:focus-visible {
|
||||
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/*
|
||||
* `.monaco-workbench .codicon` sets colour and size directly on the glyph, so
|
||||
* without out-ranking it this renders as a dark 16px icon rather than a compact
|
||||
* one inheriting the button's foreground.
|
||||
*/
|
||||
.monaco-workbench .dictation-onboarding-close .codicon[class*='codicon-'] {
|
||||
font-size: var(--vscode-codiconFontSize);
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
+17
-8
@@ -25,11 +25,17 @@ import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js';
|
||||
import { CHAT_SETUP_ACTION_ID } from '../../actions/chatActions.js';
|
||||
import { IChatTip, IChatTipService } from '../../chatTipService.js';
|
||||
import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js';
|
||||
import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../input/chatInputNoticeWidget.js';
|
||||
|
||||
const $ = dom.$;
|
||||
|
||||
export class ChatTipContentPart extends Disposable {
|
||||
public readonly domNode: HTMLElement;
|
||||
|
||||
private readonly _notice: ChatInputNoticeWidget;
|
||||
|
||||
public get domNode(): HTMLElement {
|
||||
return this._notice.domNode;
|
||||
}
|
||||
|
||||
private readonly _onDidHide = this._register(new Emitter<void>());
|
||||
public readonly onDidHide = this._onDidHide.event;
|
||||
@@ -54,10 +60,13 @@ export class ChatTipContentPart extends Disposable {
|
||||
) {
|
||||
super();
|
||||
|
||||
this.domNode = $('.chat-tip-widget');
|
||||
this.domNode.tabIndex = 0;
|
||||
this.domNode.setAttribute('role', 'region');
|
||||
this.domNode.setAttribute('aria-roledescription', localize('chatTipRoleDescription', "tip"));
|
||||
// Built detached: the presenter commits this part before parenting it, so
|
||||
// a re-entrant render cannot leave a second tip in the container.
|
||||
this._notice = this._register(new ChatInputNoticeWidget({
|
||||
variant: ChatInputNoticeVariant.Tip,
|
||||
className: 'chat-tip-widget',
|
||||
ariaRoleDescription: localize('chatTipRoleDescription', "tip"),
|
||||
}));
|
||||
|
||||
this._inChatTipContextKey = ChatContextKeys.inChatTip.bindTo(this._contextKeyService);
|
||||
this._multipleChatTipsContextKey = ChatContextKeys.multipleChatTips.bindTo(this._contextKeyService);
|
||||
@@ -104,11 +113,11 @@ export class ChatTipContentPart extends Disposable {
|
||||
}
|
||||
|
||||
hasFocus(): boolean {
|
||||
return dom.isAncestorOfActiveElement(this.domNode);
|
||||
return this._notice.hasFocus();
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.domNode.focus();
|
||||
this._notice.focus();
|
||||
}
|
||||
|
||||
private _renderTip(tip: IChatTip): void {
|
||||
@@ -136,7 +145,7 @@ export class ChatTipContentPart extends Disposable {
|
||||
const ariaLabel = hasLink
|
||||
? localize('chatTipWithAction', "{0} Tab to reach the action.", textContent)
|
||||
: textContent;
|
||||
this.domNode.setAttribute('aria-label', ariaLabel);
|
||||
this._notice.setAriaLabel(ariaLabel);
|
||||
}
|
||||
|
||||
private async _handleTipAction(link: string, mdStr: IMarkdownString): Promise<void> {
|
||||
|
||||
+9
-24
@@ -42,7 +42,7 @@
|
||||
|
||||
.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
padding: 4px;
|
||||
padding: var(--vscode-spacing-size40);
|
||||
}
|
||||
|
||||
.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label:hover {
|
||||
@@ -55,30 +55,15 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-getting-started-tip-container .chat-tip-widget {
|
||||
display: flex;
|
||||
/*
|
||||
* The frame, row layout, type and link colours come from `.chat-input-notice`;
|
||||
* only what is particular to this tip is styled here. This one line is a single
|
||||
* row of text, so it centres rather than taking the variant's top alignment.
|
||||
* Prefixed with the base class deliberately: without it this ties the base rule
|
||||
* on specificity and which one wins would depend on stylesheet order.
|
||||
*/
|
||||
.chat-getting-started-tip-container .chat-input-notice.chat-tip-widget {
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
background-color: var(--vscode-editorWidget-background);
|
||||
border-radius: var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 0;
|
||||
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent));
|
||||
font-size: var(--vscode-chat-font-size-body-s);
|
||||
font-family: var(--vscode-chat-font-family, inherit);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-getting-started-tip-container .chat-tip-widget a {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
}
|
||||
|
||||
.chat-getting-started-tip-container .chat-tip-widget a:hover,
|
||||
.chat-getting-started-tip-container .chat-tip-widget a:active {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
}
|
||||
|
||||
.chat-getting-started-tip-container .chat-tip-widget .rendered-markdown p {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { addDisposableListener, EventType, isAncestorOfActiveElement, setVisibility } from '../../../../../../base/browser/dom.js';
|
||||
import { ActionBar } from '../../../../../../base/browser/ui/actionbar/actionbar.js';
|
||||
import { mainWindow } from '../../../../../../base/browser/window.js';
|
||||
import { alert, status } from '../../../../../../base/browser/ui/aria/aria.js';
|
||||
import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js';
|
||||
import { Action } from '../../../../../../base/common/actions.js';
|
||||
import { Codicon } from '../../../../../../base/common/codicons.js';
|
||||
import { KeyCode } from '../../../../../../base/common/keyCodes.js';
|
||||
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js';
|
||||
import { ThemeIcon } from '../../../../../../base/common/themables.js';
|
||||
import { localize } from '../../../../../../nls.js';
|
||||
import { IChatInputNoticeFocusTarget } from './chatInputNoticeHost.js';
|
||||
import './media/chatInputNotice.css';
|
||||
|
||||
/**
|
||||
* The visual roles a notice above a chat input can take. The border, radius,
|
||||
* background and action shape are one shared rule across all of them, so those
|
||||
* cannot drift apart again.
|
||||
*
|
||||
* What is deliberately not shared is the vertical offset between a notice and
|
||||
* the input below it. That is set by whichever surface the pair sits in, and it
|
||||
* is not one value: some surfaces pull the input up over the notice's bottom
|
||||
* edge so the two read as a single stack, and others leave a deliberate gap.
|
||||
*/
|
||||
export const enum ChatInputNoticeVariant {
|
||||
/** A first-run introduction. The tallest, most prominent notice. */
|
||||
Onboarding = 'onboarding',
|
||||
/** A one-line getting-started hint. Yields to everything else. */
|
||||
Tip = 'tip',
|
||||
/** Quota, promo, permission and extension-provided messages. */
|
||||
Notification = 'notification',
|
||||
}
|
||||
|
||||
export interface IChatInputNoticeWidgetOptions {
|
||||
/**
|
||||
* The element the notice is appended to. Omit for producers whose owner
|
||||
* parents the node itself once construction has committed - a chat content
|
||||
* part, whose presenter guards against a re-entrant render appending a second
|
||||
* one - and parent {@link ChatInputNoticeWidget.domNode} instead.
|
||||
*/
|
||||
readonly container?: HTMLElement;
|
||||
readonly variant: ChatInputNoticeVariant;
|
||||
/** Producer-specific class, for styling the content inside the frame. */
|
||||
readonly className?: string;
|
||||
/** Names the focusable region. Also what {@link ChatInputNoticeWidget.announce} speaks. */
|
||||
readonly ariaLabel?: string;
|
||||
readonly ariaDescription?: string;
|
||||
/** Spoken after the label, e.g. "notification". */
|
||||
readonly ariaRoleDescription?: string;
|
||||
/** Called on Escape. Omit to let Escape through to the input. */
|
||||
readonly onEscape?: () => void;
|
||||
}
|
||||
|
||||
export interface IChatInputNoticeActionOptions {
|
||||
readonly className?: string;
|
||||
readonly ariaLabel: string;
|
||||
readonly icon: ThemeIcon;
|
||||
readonly onActivate: () => void;
|
||||
/**
|
||||
* Where the action is placed. Defaults to the notice itself, which pins it to
|
||||
* the corner for an onboarding card and lays it out in the row for a tip.
|
||||
*/
|
||||
readonly parent?: HTMLElement;
|
||||
/**
|
||||
* Where the action's listeners are registered. Notices that rebuild their
|
||||
* content pass the store scoped to one render, so repeated renders do not
|
||||
* accumulate listeners for buttons that are already gone.
|
||||
*/
|
||||
readonly store?: DisposableStore;
|
||||
}
|
||||
|
||||
export type IChatInputNoticeDismissOptions = Omit<IChatInputNoticeActionOptions, 'ariaLabel' | 'icon'> & {
|
||||
/** Defaults to a generic "Dismiss". */
|
||||
readonly ariaLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The container every notice above a chat input is built in.
|
||||
*
|
||||
* Owns the things all five notices have to agree on - the frame, the focusable
|
||||
* ARIA region, the {@link IChatInputNoticeFocusTarget} contract the notice host
|
||||
* routes focus through, the dismiss affordance, and being put away for
|
||||
* higher-precedence content - so a producer only has to build its own content.
|
||||
*/
|
||||
export class ChatInputNoticeWidget extends Disposable implements IChatInputNoticeFocusTarget {
|
||||
|
||||
readonly domNode: HTMLElement;
|
||||
|
||||
private readonly _variant: ChatInputNoticeVariant;
|
||||
private readonly _ariaRoleDescription: string | undefined;
|
||||
private _ariaLabel: string | undefined;
|
||||
private _visible = true;
|
||||
|
||||
constructor(options: IChatInputNoticeWidgetOptions) {
|
||||
super();
|
||||
|
||||
this._variant = options.variant;
|
||||
this._ariaRoleDescription = options.ariaRoleDescription;
|
||||
|
||||
// Detached notices are created in the main window's document, the same as
|
||||
// `dom.$` does, and adopted when their owner parents them.
|
||||
this.domNode = (options.container?.ownerDocument ?? mainWindow.document).createElement('div');
|
||||
this.domNode.classList.add('chat-input-notice', `chat-input-notice-${options.variant}`);
|
||||
if (options.className) {
|
||||
this.domNode.classList.add(options.className);
|
||||
}
|
||||
if (options.ariaDescription) {
|
||||
this.domNode.setAttribute('aria-description', options.ariaDescription);
|
||||
}
|
||||
this.setAriaLabel(options.ariaLabel);
|
||||
|
||||
options.container?.appendChild(this.domNode);
|
||||
this._register(toDisposable(() => this.domNode.remove()));
|
||||
|
||||
const onEscape = options.onEscape;
|
||||
if (onEscape) {
|
||||
this._register(addDisposableListener(this.domNode, EventType.KEY_DOWN, event => {
|
||||
const keyboardEvent = new StandardKeyboardEvent(event);
|
||||
if (keyboardEvent.equals(KeyCode.Escape)) {
|
||||
keyboardEvent.preventDefault();
|
||||
keyboardEvent.stopPropagation();
|
||||
onEscape();
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the region. Notices whose message is only known per-render - a
|
||||
* notification, a tip being navigated - set this as they build their content.
|
||||
*/
|
||||
setAriaLabel(ariaLabel: string | undefined): void {
|
||||
this._ariaLabel = ariaLabel;
|
||||
this._applyRegionAttributes();
|
||||
}
|
||||
|
||||
/**
|
||||
* A notice is only a landmark and a tab stop while it is actually on screen.
|
||||
* Left in place while put away, it would be an unlabelled region the user can
|
||||
* still tab into and find nothing in.
|
||||
*/
|
||||
private _applyRegionAttributes(): void {
|
||||
if (this._visible) {
|
||||
this.domNode.setAttribute('role', 'region');
|
||||
// Reachable by the notice focus command, like every other notice above an input.
|
||||
this.domNode.tabIndex = 0;
|
||||
if (this._ariaRoleDescription) {
|
||||
this.domNode.setAttribute('aria-roledescription', this._ariaRoleDescription);
|
||||
}
|
||||
if (this._ariaLabel) {
|
||||
this.domNode.setAttribute('aria-label', this._ariaLabel);
|
||||
} else {
|
||||
this.domNode.removeAttribute('aria-label');
|
||||
}
|
||||
} else {
|
||||
this.domNode.removeAttribute('role');
|
||||
this.domNode.removeAttribute('tabindex');
|
||||
this.domNode.removeAttribute('aria-roledescription');
|
||||
this.domNode.removeAttribute('aria-label');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak the notice as it reaches the screen. A tip is advisory - it is in the
|
||||
* lane that yields to everything else - so it is announced politely and waits
|
||||
* its turn; an introduction or a notification is why the user's attention was
|
||||
* wanted in the first place, so it interrupts.
|
||||
*/
|
||||
announce(): void {
|
||||
if (!this._ariaLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = localize('chatInputNotice.focusHint', "{0}. Use Shift+Tab to reach the notice.", this._ariaLabel);
|
||||
if (this._variant === ChatInputNoticeVariant.Tip) {
|
||||
status(message);
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
hasFocus(): boolean {
|
||||
return isAncestorOfActiveElement(this.domNode);
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.domNode.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the notice is put away for higher-precedence content, and again
|
||||
* when it comes back. The notice is kept alive across this, so a producer with
|
||||
* live parts - microphone capture, audio, animation - calls this and stands
|
||||
* those down too rather than keep them going where the user cannot see them.
|
||||
*/
|
||||
setVisible(visible: boolean): void {
|
||||
if (this._visible === visible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._visible = visible;
|
||||
setVisibility(visible, this.domNode);
|
||||
this._applyRegionAttributes();
|
||||
}
|
||||
|
||||
/**
|
||||
* An icon button, in the shape every notice's actions share. Built on
|
||||
* `ActionBar` so keyboard handling, touch, focus and theming come from the
|
||||
* shared action infrastructure rather than being re-implemented per notice.
|
||||
*/
|
||||
addAction(options: IChatInputNoticeActionOptions): HTMLElement {
|
||||
const register = <T extends IDisposable>(disposable: T): T => options.store ? options.store.add(disposable) : this._register(disposable);
|
||||
|
||||
const container = this.domNode.ownerDocument.createElement('div');
|
||||
container.classList.add('chat-input-notice-action');
|
||||
(options.parent ?? this.domNode).appendChild(container);
|
||||
register(toDisposable(() => container.remove()));
|
||||
|
||||
// The producer's class goes on the action itself rather than the housing, so
|
||||
// it names the thing that is actually clicked, focused and styled.
|
||||
const cssClass = [ThemeIcon.asClassName(options.icon), options.className].filter(Boolean).join(' ');
|
||||
const actionBar = register(new ActionBar(container));
|
||||
actionBar.push(register(new Action('chatInputNotice.action', options.ariaLabel, cssClass, true, async () => options.onActivate())), { icon: true, label: false });
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard way out of a notice. Kept separate from {@link addAction} so
|
||||
* every notice's dismiss reads and behaves the same, wherever it appears.
|
||||
*/
|
||||
addDismissAction(options: IChatInputNoticeDismissOptions): HTMLElement {
|
||||
return this.addAction({
|
||||
...options,
|
||||
ariaLabel: options.ariaLabel ?? localize('chatInputNotice.dismiss', "Dismiss"),
|
||||
icon: Codicon.closeCompact,
|
||||
className: [options.className, 'chat-input-notice-dismiss'].filter(Boolean).join(' '),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { IMarkdownRendererService } from '../../../../../../platform/markdown/br
|
||||
import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js';
|
||||
import { defaultButtonStyles } from '../../../../../../platform/theme/browser/defaultStyles.js';
|
||||
import { IChatInputNoticeFocusTarget } from './chatInputNoticeHost.js';
|
||||
import { ChatInputNoticeVariant, ChatInputNoticeWidget } from './chatInputNoticeWidget.js';
|
||||
import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationAction, IChatInputNotificationCommandAction, IChatInputNotificationService, isChatInputNotificationApplicableToSession } from './chatInputNotificationService.js';
|
||||
import './media/chatInputNotificationWidget.css';
|
||||
|
||||
@@ -88,7 +89,11 @@ export interface IChatInputNotificationDelegate {
|
||||
*/
|
||||
export class ChatInputNotificationWidget extends Disposable implements IChatInputNoticeFocusTarget {
|
||||
|
||||
readonly domNode: HTMLElement;
|
||||
private readonly _notice: ChatInputNoticeWidget;
|
||||
|
||||
get domNode(): HTMLElement {
|
||||
return this._notice.domNode;
|
||||
}
|
||||
|
||||
private readonly _contentDisposables = this._register(new DisposableStore());
|
||||
private _lastShownTelemetryData: ChatInputNotificationTelemetryEvent | undefined;
|
||||
@@ -107,7 +112,14 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu
|
||||
) {
|
||||
super();
|
||||
|
||||
this.domNode = $('.chat-input-notification-widget');
|
||||
// Built detached: the input part parents this widget itself, into the lane
|
||||
// it lays out above the input.
|
||||
this._notice = this._register(new ChatInputNoticeWidget({
|
||||
variant: ChatInputNoticeVariant.Notification,
|
||||
className: 'chat-input-notification-widget',
|
||||
ariaRoleDescription: localize('chatInputNotificationRoleDescription', "notification"),
|
||||
}));
|
||||
this._notice.setVisible(false);
|
||||
|
||||
this._register(this._notificationService.onDidChange(() => this._render()));
|
||||
this._register(autorun(reader => {
|
||||
@@ -124,6 +136,7 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu
|
||||
const hadFocus = this.hasFocus();
|
||||
this._contentDisposables.clear();
|
||||
dom.clearNode(this.domNode);
|
||||
this.domNode.classList.remove(...Object.values(severityToClass));
|
||||
|
||||
const notification = this._notificationService.getActiveNotification(n => this._matchesSession(n));
|
||||
this._setVisible(!!notification);
|
||||
@@ -154,27 +167,16 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu
|
||||
}
|
||||
|
||||
this._visible = visible;
|
||||
// The widget element outlives any one notification, so it only carries the
|
||||
// region role and a tab stop while it actually renders something.
|
||||
if (visible) {
|
||||
this.domNode.tabIndex = 0;
|
||||
this.domNode.setAttribute('role', 'region');
|
||||
this.domNode.setAttribute('aria-roledescription', localize('chatInputNotificationRoleDescription', "notification"));
|
||||
} else {
|
||||
this.domNode.removeAttribute('tabindex');
|
||||
this.domNode.removeAttribute('role');
|
||||
this.domNode.removeAttribute('aria-roledescription');
|
||||
this.domNode.removeAttribute('aria-label');
|
||||
}
|
||||
this._notice.setVisible(visible);
|
||||
this._delegate?.onDidChangeVisibility?.(visible, this);
|
||||
}
|
||||
|
||||
hasFocus(): boolean {
|
||||
return dom.isAncestorOfActiveElement(this.domNode);
|
||||
return this._notice.hasFocus();
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.domNode.focus();
|
||||
this._notice.focus();
|
||||
}
|
||||
|
||||
private _matchesSession(notification: IChatInputNotification): boolean {
|
||||
@@ -182,9 +184,7 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu
|
||||
}
|
||||
|
||||
private _renderNotification(notification: IChatInputNotification): void {
|
||||
const container = dom.append(this.domNode, $('.chat-input-notification'));
|
||||
|
||||
// Apply severity class
|
||||
const container = this.domNode;
|
||||
container.classList.add(severityToClass[notification.severity]);
|
||||
|
||||
// Header row: icon + title + mute + dismiss
|
||||
@@ -206,60 +206,47 @@ export class ChatInputNotificationWidget extends Disposable implements IChatInpu
|
||||
const ariaTitle = isMarkdownString(notification.message) ? notification.message.value : notification.message;
|
||||
// Names the focusable region: `aria-roledescription` alone would have focus
|
||||
// land on something announced only as "notification".
|
||||
this.domNode.setAttribute('aria-label', ariaTitle);
|
||||
this._notice.setAriaLabel(ariaTitle);
|
||||
|
||||
if (notification.mute) {
|
||||
const mute = notification.mute;
|
||||
const muteButton = dom.append(headerRow, $('.chat-input-notification-mute'));
|
||||
muteButton.appendChild(dom.$(ThemeIcon.asCSSSelector(Codicon.bellSlash)));
|
||||
muteButton.tabIndex = 0;
|
||||
muteButton.role = 'button';
|
||||
muteButton.ariaLabel = mute.tooltip;
|
||||
this._contentDisposables.add(this._hoverService.setupManagedHover(getDefaultHoverDelegate('element'), muteButton, mute.tooltip));
|
||||
|
||||
// Defer to a microtask for the same reason as the dismiss button:
|
||||
// the command synchronously tears down the notification, and the
|
||||
// resulting re-render must happen after the click has propagated.
|
||||
const doMute = () => queueMicrotask(() => {
|
||||
this._telemetryService.publicLog2<WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification>('workbenchActionExecuted', {
|
||||
id: mute.commandId,
|
||||
from: 'chatInputNotification',
|
||||
});
|
||||
this._commandService.executeCommand(mute.commandId, ...(mute.commandArgs ?? []));
|
||||
const muteButton = this._notice.addAction({
|
||||
ariaLabel: mute.tooltip,
|
||||
icon: Codicon.bellSlash,
|
||||
parent: headerRow,
|
||||
store: this._contentDisposables,
|
||||
onActivate: () => queueMicrotask(() => {
|
||||
this._telemetryService.publicLog2<WorkbenchActionExecutedEvent, WorkbenchActionExecutedClassification>('workbenchActionExecuted', {
|
||||
id: mute.commandId,
|
||||
from: 'chatInputNotification',
|
||||
});
|
||||
this._commandService.executeCommand(mute.commandId, ...(mute.commandArgs ?? []));
|
||||
}),
|
||||
});
|
||||
this._contentDisposables.add(dom.addDisposableListener(muteButton, dom.EventType.CLICK, doMute));
|
||||
this._contentDisposables.add(dom.addDisposableListener(muteButton, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
doMute();
|
||||
}
|
||||
}));
|
||||
this._contentDisposables.add(this._hoverService.setupManagedHover(getDefaultHoverDelegate('element'), muteButton, mute.tooltip));
|
||||
}
|
||||
|
||||
// Dismiss button (in header row, pushed to the right)
|
||||
if (notification.dismissible) {
|
||||
const dismissButton = dom.append(headerRow, $('.chat-input-notification-dismiss'));
|
||||
dismissButton.appendChild(dom.$(ThemeIcon.asCSSSelector(Codicon.close)));
|
||||
dismissButton.tabIndex = 0;
|
||||
dismissButton.role = 'button';
|
||||
dismissButton.ariaLabel = localize('dismissNotification', "Dismiss notification");
|
||||
|
||||
// Defer the dismiss to a microtask so the synchronous re-render
|
||||
// (which clears all children of the widget) happens after the
|
||||
// browser has finished propagating the click event. Otherwise
|
||||
// blur handlers fired by removing the button from focus can
|
||||
// move/remove nodes that `clearNode` then trips over.
|
||||
const dismiss = () => queueMicrotask(() => {
|
||||
this._telemetryService.publicLog2<ChatInputNotificationTelemetryEvent, ChatInputNotificationTelemetryClassification>('chatInputNotificationDismissed', this._getTelemetryData(notification));
|
||||
this._notificationService.dismissNotification(notification.id);
|
||||
this._notice.addDismissAction({
|
||||
className: 'chat-input-notification-dismiss',
|
||||
ariaLabel: localize('dismissNotification', "Dismiss notification"),
|
||||
parent: headerRow,
|
||||
store: this._contentDisposables,
|
||||
onActivate: () => queueMicrotask(() => {
|
||||
this._telemetryService.publicLog2<ChatInputNotificationTelemetryEvent, ChatInputNotificationTelemetryClassification>('chatInputNotificationDismissed', this._getTelemetryData(notification));
|
||||
this._notificationService.dismissNotification(notification.id);
|
||||
}),
|
||||
});
|
||||
this._contentDisposables.add(dom.addDisposableListener(dismissButton, dom.EventType.CLICK, dismiss));
|
||||
this._contentDisposables.add(dom.addDisposableListener(dismissButton, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
dismiss();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// Body row: description + actions on the same line
|
||||
|
||||
@@ -3,14 +3,9 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { addDisposableListener, EventType, isAncestorOfActiveElement, setVisibility } from '../../../../../../base/browser/dom.js';
|
||||
import { alert } from '../../../../../../base/browser/ui/aria/aria.js';
|
||||
import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js';
|
||||
import { setVisibility } from '../../../../../../base/browser/dom.js';
|
||||
import { onUnexpectedError } from '../../../../../../base/common/errors.js';
|
||||
import { KeyCode } from '../../../../../../base/common/keyCodes.js';
|
||||
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js';
|
||||
import { ThemeIcon } from '../../../../../../base/common/themables.js';
|
||||
import { localize } from '../../../../../../nls.js';
|
||||
import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js';
|
||||
import { IChatInputNoticeClaimOptions, IChatInputNoticeFocusTarget, IChatInputSurface, pickActiveChatInput, trackChatInputRecency } from './chatInputNoticeHost.js';
|
||||
|
||||
@@ -63,21 +58,6 @@ export interface IChatInputOnboardingBanner extends IDisposable, IChatInputNotic
|
||||
setVisible?(visible: boolean): void;
|
||||
}
|
||||
|
||||
export interface IChatInputOnboardingCardOptions {
|
||||
readonly container: HTMLElement;
|
||||
readonly className: string;
|
||||
readonly ariaLabel: string;
|
||||
readonly ariaDescription?: string;
|
||||
readonly onEscape: () => void;
|
||||
}
|
||||
|
||||
export interface IChatInputOnboardingActionOptions {
|
||||
readonly className: string;
|
||||
readonly ariaLabel: string;
|
||||
readonly icon: ThemeIcon;
|
||||
readonly onActivate: () => void;
|
||||
}
|
||||
|
||||
export class ChatInputOnboarding extends Disposable {
|
||||
|
||||
private readonly hosts = new Set<IChatInputOnboardingHost>();
|
||||
@@ -315,76 +295,3 @@ export class ChatInputOnboarding extends Disposable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ChatInputOnboardingCard extends Disposable {
|
||||
|
||||
readonly domNode: HTMLElement;
|
||||
|
||||
private readonly ariaLabel: string;
|
||||
|
||||
constructor(options: IChatInputOnboardingCardOptions) {
|
||||
super();
|
||||
|
||||
this.ariaLabel = options.ariaLabel;
|
||||
|
||||
this.domNode = options.container.ownerDocument.createElement('div');
|
||||
this.domNode.classList.add(options.className);
|
||||
this.domNode.setAttribute('role', 'region');
|
||||
this.domNode.setAttribute('aria-label', options.ariaLabel);
|
||||
if (options.ariaDescription) {
|
||||
this.domNode.setAttribute('aria-description', options.ariaDescription);
|
||||
}
|
||||
|
||||
options.container.appendChild(this.domNode);
|
||||
this._register(toDisposable(() => this.domNode.remove()));
|
||||
|
||||
this.domNode.tabIndex = 0;
|
||||
|
||||
this._register(addDisposableListener(this.domNode, EventType.KEY_DOWN, event => {
|
||||
const keyboardEvent = new StandardKeyboardEvent(event);
|
||||
if (keyboardEvent.equals(KeyCode.Escape)) {
|
||||
keyboardEvent.preventDefault();
|
||||
keyboardEvent.stopPropagation();
|
||||
options.onEscape();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
announce(): void {
|
||||
alert(localize('chatInputOnboarding.focusHint', "{0}. Use Shift+Tab to reach the introduction.", this.ariaLabel));
|
||||
}
|
||||
|
||||
hasFocus(): boolean {
|
||||
return isAncestorOfActiveElement(this.domNode);
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this.domNode.focus();
|
||||
}
|
||||
|
||||
addAction(options: IChatInputOnboardingActionOptions): HTMLElement {
|
||||
const action = this.domNode.ownerDocument.createElement('div');
|
||||
action.classList.add(options.className);
|
||||
action.setAttribute('role', 'button');
|
||||
action.tabIndex = 0;
|
||||
action.setAttribute('aria-label', options.ariaLabel);
|
||||
const icon = this.domNode.ownerDocument.createElement('span');
|
||||
icon.classList.add(...ThemeIcon.asClassNameArray(options.icon));
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
action.appendChild(icon);
|
||||
this.domNode.appendChild(action);
|
||||
|
||||
const activate = () => options.onActivate();
|
||||
this._register(addDisposableListener(action, EventType.CLICK, activate));
|
||||
this._register(addDisposableListener(action, EventType.KEY_DOWN, event => {
|
||||
const keyboardEvent = new StandardKeyboardEvent(event);
|
||||
if (keyboardEvent.equals(KeyCode.Enter) || keyboardEvent.equals(KeyCode.Space)) {
|
||||
keyboardEvent.preventDefault();
|
||||
keyboardEvent.stopPropagation();
|
||||
activate();
|
||||
}
|
||||
}));
|
||||
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/*
|
||||
* The notice above a chat input. Five producers - notification, voice and
|
||||
* dictation introductions, getting-started tip, sub-session tip - share this
|
||||
* frame so the space reads as one surface no matter who is filling it.
|
||||
*
|
||||
* This is a baseline, not a contract: a producer can still re-specialise its
|
||||
* padding or density. What it should not have to restate is the border, the
|
||||
* radius, the background and the action shape - the things that were drifting.
|
||||
*/
|
||||
|
||||
/*
|
||||
* One thing is deliberately NOT shared: how far a notice sits from the input
|
||||
* below it. A notice has a square bottom edge and the input has rounded top
|
||||
* corners, so most places pull the input up over that edge - by 4 to 8px - to
|
||||
* hide the join. Inline chat wants the opposite and leaves a 16px gap.
|
||||
*
|
||||
* The right number depends on the surrounding layout, so each place sets its own
|
||||
* next to the rule it overrides. Sharing it from here has been tried twice and
|
||||
* broke both times: once because the shared value slipped past overrides that
|
||||
* only set `margin-bottom`, once because a hide/show pair split across two files
|
||||
* ended up equally specific, so whichever file loaded last won.
|
||||
*/
|
||||
|
||||
/* --- The frame --- */
|
||||
|
||||
.chat-input-notice {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--vscode-spacing-size80);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: var(--vscode-spacing-size120);
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent));
|
||||
/* Square at the bottom, and rounded at the top on the same ramp the input uses,
|
||||
* so the notice reads as the top of the input rather than a box resting on it.
|
||||
* The bottom edge is left open: the notice sits against the input, and drawing
|
||||
* a border there puts a line across what should read as one surface. */
|
||||
border-bottom: none;
|
||||
border-radius: var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large) 0 0;
|
||||
background-color: var(--vscode-agentsChatInput-background, var(--vscode-input-background));
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.chat-input-notice:focus-visible {
|
||||
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
|
||||
outline-offset: calc(-1 * var(--vscode-strokeThickness));
|
||||
}
|
||||
|
||||
/*
|
||||
* A tip and a notification are both prose about the conversation, so they take
|
||||
* the chat type ramp and the demoted foreground. An introduction is a card in
|
||||
* its own right and keeps the base foreground and the label ramp its own
|
||||
* content sets. Shared here so a fourth kind of notice does not restate it.
|
||||
*/
|
||||
.chat-input-notice.chat-input-notice-tip,
|
||||
.chat-input-notice.chat-input-notice-notification {
|
||||
font-size: var(--vscode-chat-font-size-body-s);
|
||||
font-family: var(--vscode-chat-font-family, inherit);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/*
|
||||
* Links, wherever they appear in a notice - prose in a tip, markdown in a
|
||||
* notification, the settings link in an introduction. Anchors are not covered by
|
||||
* the workbench's global focus rule, so without the `:focus-visible` outline
|
||||
* here a keyboard user tabbing to one of these gets no indication of where they
|
||||
* are - which a getting-started tip actively invites them to do.
|
||||
*/
|
||||
.chat-input-notice a,
|
||||
.chat-input-notice a:visited {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
cursor: pointer;
|
||||
border-radius: var(--vscode-cornerRadius-xSmall);
|
||||
}
|
||||
|
||||
.chat-input-notice a:hover,
|
||||
.chat-input-notice a:active {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chat-input-notice a:focus-visible {
|
||||
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* --- Variant: tip --- */
|
||||
|
||||
/* One line of supporting prose next to an icon: a row, and the densest of the three. */
|
||||
.chat-input-notice.chat-input-notice-tip {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: var(--vscode-spacing-size60);
|
||||
padding: var(--vscode-spacing-size60) var(--vscode-spacing-size80);
|
||||
}
|
||||
|
||||
/*
|
||||
* A card is the exception. In the panel layout it sits a few pixels clear of the
|
||||
* input rather than against it, so it needs its bottom edge to close the box.
|
||||
*/
|
||||
.chat-input-notice.chat-input-notice-onboarding {
|
||||
border-bottom: var(--vscode-strokeThickness) solid var(--vscode-input-border, var(--vscode-widget-border, transparent));
|
||||
}
|
||||
|
||||
/* --- Variant: notification --- */
|
||||
|
||||
/*
|
||||
* Severity tints the same frame rather than replacing it: the notice keeps its
|
||||
* shape and only its border and wash say how urgent it is. Each severity names
|
||||
* its colour once and the border, the wash and the icon all derive from it, so
|
||||
* a new severity is one declaration rather than three across two files.
|
||||
*/
|
||||
.chat-input-notice.chat-input-notice-notification {
|
||||
gap: var(--vscode-spacing-size20);
|
||||
border-color: var(--chat-input-notice-severity);
|
||||
background-color: color-mix(in srgb, var(--chat-input-notice-severity) 6%, var(--vscode-agentsChatInput-background, var(--vscode-input-background)));
|
||||
}
|
||||
|
||||
.chat-input-notice.chat-input-notice-notification.severity-info {
|
||||
--chat-input-notice-severity: var(--vscode-focusBorder);
|
||||
/* The quiet one: it washes and colours its icon, but keeps the plain input
|
||||
* border rather than tinting it. */
|
||||
border-color: var(--vscode-input-border, transparent);
|
||||
}
|
||||
|
||||
.chat-input-notice.chat-input-notice-notification.severity-warning {
|
||||
--chat-input-notice-severity: var(--vscode-editorWarning-foreground);
|
||||
}
|
||||
|
||||
.chat-input-notice.chat-input-notice-notification.severity-error {
|
||||
--chat-input-notice-severity: var(--vscode-editorError-foreground);
|
||||
}
|
||||
|
||||
/* --- Onboarding card: copy --- */
|
||||
|
||||
/*
|
||||
* A card names itself and then explains itself once. Both cards use the same two
|
||||
* type roles, so they are set here rather than restated per card.
|
||||
*/
|
||||
.chat-input-notice-title {
|
||||
font-size: var(--vscode-fontSize-label1);
|
||||
font-weight: var(--vscode-fontWeight-semiBold);
|
||||
}
|
||||
|
||||
/*
|
||||
* Wraps rather than truncating: this sentence carries the promise, the ask and
|
||||
* the escape hatch, so it survives at every width.
|
||||
*/
|
||||
.chat-input-notice-description {
|
||||
font-size: var(--vscode-fontSize-label2);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* --- Onboarding card: device picker --- */
|
||||
|
||||
/*
|
||||
* The housing for a device <select> inside a first-run card - the microphone in
|
||||
* the dictation card, the microphone in the voice card. The housing draws the
|
||||
* surface and the select inside it stays transparent, so there is one border
|
||||
* rather than two, while the select keeps its real dropdown, keyboard handling
|
||||
* and screen-reader semantics.
|
||||
*/
|
||||
.chat-input-notice-picker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--vscode-spacing-size60);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
height: var(--vscode-spacing-size280);
|
||||
padding: 0 var(--vscode-spacing-size80);
|
||||
border: var(--vscode-strokeThickness) solid var(--vscode-dropdown-border, var(--vscode-input-border, transparent));
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
background-color: var(--vscode-dropdown-background, var(--vscode-input-background));
|
||||
transition: border-color 100ms ease-out;
|
||||
}
|
||||
|
||||
.chat-input-notice-picker[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-input-notice-picker:hover {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.chat-input-notice-picker:focus-within {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.chat-input-notice-picker .monaco-select-box {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
background-color: transparent;
|
||||
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
|
||||
font-family: inherit;
|
||||
font-size: var(--vscode-fontSize-label2);
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-input-notice-picker .monaco-select-box:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* --- Actions --- */
|
||||
|
||||
/*
|
||||
* Actions are `ActionBar` items, so the hover, focus ring and keyboard handling
|
||||
* come from `.monaco-action-bar`. Only the compact size this surface wants is
|
||||
* set here.
|
||||
*/
|
||||
.chat-input-notice-action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input-notice-action .action-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--vscode-spacing-size200);
|
||||
height: var(--vscode-spacing-size200);
|
||||
padding: 0;
|
||||
border-radius: var(--vscode-cornerRadius-small);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-input-notice-action .action-label:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
/*
|
||||
* `.monaco-workbench .codicon` sets colour and size directly on the glyph, so
|
||||
* without out-ranking it this renders as a dark 16px icon rather than a compact
|
||||
* one inheriting the button's foreground.
|
||||
*/
|
||||
.monaco-workbench .chat-input-notice-action .codicon[class*='codicon-'] {
|
||||
font-size: var(--vscode-codiconFontSize-compact);
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/*
|
||||
* A card is a stack, so there is no row for its close to sit in: it is pinned to
|
||||
* the corner instead, and the copy underneath reserves the space. A tip and a
|
||||
* notification both have a row, so their actions stay in the flow.
|
||||
*/
|
||||
.chat-input-notice.chat-input-notice-onboarding > .chat-input-notice-action {
|
||||
position: absolute;
|
||||
top: var(--vscode-spacing-size80);
|
||||
right: var(--vscode-spacing-size80);
|
||||
}
|
||||
+46
-127
@@ -3,218 +3,137 @@
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/*
|
||||
* The frame and severity tints come from `.chat-input-notice`. The overlap with the
|
||||
* input below is NOT shared - it is per-surface - so it lives here.
|
||||
*/
|
||||
|
||||
/* Hide the container when no notification is active */
|
||||
.interactive-session .interactive-input-part > .chat-input-notification-container:not(.has-notification) {
|
||||
.chat-input-notification-container:not(.has-notification) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Counteract the `.interactive-input-part` `gap: 4px` so the notification attaches to the input. Add extra -6px to account for rounded corners. */
|
||||
.interactive-session .interactive-input-part > .chat-input-notification-container {
|
||||
margin-bottom: -10px;
|
||||
}
|
||||
|
||||
.interactive-session .interactive-input-part > .chat-input-notification-container .chat-input-notification {
|
||||
/* Padding needs to be -6px for top to account for -10px margin in parent container and 4px for half of chat border radius. */
|
||||
padding: 10px 16px 16px 16px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--vscode-input-border, transparent);
|
||||
border-bottom: none;
|
||||
border-top-left-radius: var(--vscode-cornerRadius-small, 4px);
|
||||
border-top-right-radius: var(--vscode-cornerRadius-small, 4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: var(--vscode-chat-font-size-body-s);
|
||||
font-family: var(--vscode-chat-font-family, inherit);
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/* Severity variants */
|
||||
.interactive-session .interactive-input-part > .chat-input-notification-container .chat-input-notification.severity-info {
|
||||
border-color: var(--vscode-input-border, transparent);
|
||||
background-color: color-mix(in srgb, var(--vscode-focusBorder) 6%, var(--vscode-editorWidget-background));
|
||||
margin-bottom: calc(-1 * var(--vscode-spacing-size100));
|
||||
}
|
||||
|
||||
/* Match the chat input's focus ring so the notification merges with it. */
|
||||
.interactive-session .interactive-input-part:has(.chat-input-container.focused) > .chat-input-notification-container .chat-input-notification.severity-info {
|
||||
.interactive-session .interactive-input-part:has(.chat-input-container.focused) > .chat-input-notification-container .chat-input-notification-widget.severity-info {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
/* Working+focused: the chat input dims its focus border; match it here. */
|
||||
.interactive-session .interactive-input-part:has(.chat-input-container.working.focused) > .chat-input-notification-container .chat-input-notification.severity-info {
|
||||
.interactive-session .interactive-input-part:has(.chat-input-container.working.focused) > .chat-input-notification-container .chat-input-notification-widget.severity-info {
|
||||
border-color: color-mix(in srgb, var(--vscode-focusBorder) 40%, transparent);
|
||||
}
|
||||
|
||||
.interactive-session .interactive-input-part > .chat-input-notification-container .chat-input-notification.severity-warning {
|
||||
border-color: var(--vscode-editorWarning-foreground);
|
||||
background-color: color-mix(in srgb, var(--vscode-editorWarning-foreground) 6%, var(--vscode-editorWidget-background));
|
||||
}
|
||||
|
||||
.interactive-session .interactive-input-part > .chat-input-notification-container .chat-input-notification.severity-error {
|
||||
border-color: var(--vscode-editorError-foreground);
|
||||
background-color: color-mix(in srgb, var(--vscode-editorError-foreground) 6%, var(--vscode-editorWidget-background));
|
||||
}
|
||||
|
||||
/* Header row: icon + title + dismiss */
|
||||
.chat-input-notification .chat-input-notification-header {
|
||||
.chat-input-notification-widget .chat-input-notification-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: var(--vscode-spacing-size60);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Severity icon */
|
||||
.chat-input-notification .chat-input-notification-icon {
|
||||
/* Severity icon. The colour comes from the severity the notice declares. */
|
||||
.chat-input-notification-widget .chat-input-notification-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--chat-input-notice-severity);
|
||||
}
|
||||
|
||||
.chat-input-notification.severity-info .chat-input-notification-icon {
|
||||
color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.chat-input-notification.severity-warning .chat-input-notification-icon {
|
||||
color: var(--vscode-editorWarning-foreground);
|
||||
}
|
||||
|
||||
.chat-input-notification.severity-error .chat-input-notification-icon {
|
||||
color: var(--vscode-editorError-foreground);
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.chat-input-notification .chat-input-notification-title {
|
||||
font-size: var(--vscode-agents-fontSize-label1);
|
||||
font-weight: var(--vscode-agents-fontWeight-semiBold);
|
||||
/*
|
||||
* Title and description share their type and wrapping; only weight, colour and
|
||||
* how they take space differ.
|
||||
*/
|
||||
.chat-input-notification-widget .chat-input-notification-title,
|
||||
.chat-input-notification-widget .chat-input-notification-description {
|
||||
font-size: var(--vscode-fontSize-label1);
|
||||
line-height: 18px;
|
||||
color: var(--vscode-foreground);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.chat-input-notification-widget .chat-input-notification-title {
|
||||
font-weight: var(--vscode-fontWeight-semiBold);
|
||||
color: var(--vscode-foreground);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Body row: description + actions inline */
|
||||
/* Markdown-rendered title/description: keep the text inline (no block <p> margins). */
|
||||
.chat-input-notification .chat-input-notification-title-markdown,
|
||||
.chat-input-notification .chat-input-notification-description-markdown {
|
||||
.chat-input-notification-widget .chat-input-notification-title-markdown,
|
||||
.chat-input-notification-widget .chat-input-notification-description-markdown {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.chat-input-notification .chat-input-notification-title-markdown > p,
|
||||
.chat-input-notification .chat-input-notification-description-markdown > p {
|
||||
.chat-input-notification-widget .chat-input-notification-title-markdown > p,
|
||||
.chat-input-notification-widget .chat-input-notification-description-markdown > p {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chat-input-notification .chat-input-notification-title-markdown a,
|
||||
.chat-input-notification .chat-input-notification-title-markdown a:visited,
|
||||
.chat-input-notification .chat-input-notification-description-markdown a,
|
||||
.chat-input-notification .chat-input-notification-description-markdown a:visited {
|
||||
color: var(--vscode-textLink-foreground);
|
||||
}
|
||||
|
||||
.chat-input-notification .chat-input-notification-title-markdown a:hover,
|
||||
.chat-input-notification .chat-input-notification-title-markdown a:active,
|
||||
.chat-input-notification .chat-input-notification-description-markdown a:hover,
|
||||
.chat-input-notification .chat-input-notification-description-markdown a:active {
|
||||
color: var(--vscode-textLink-activeForeground);
|
||||
}
|
||||
|
||||
.chat-input-notification .chat-input-notification-title-markdown code,
|
||||
.chat-input-notification .chat-input-notification-description-markdown code {
|
||||
.chat-input-notification-widget .chat-input-notification-title-markdown code,
|
||||
.chat-input-notification-widget .chat-input-notification-description-markdown code {
|
||||
font-family: var(--monaco-monospace-font);
|
||||
font-size: var(--vscode-agents-fontSize-body2);
|
||||
font-size: var(--vscode-fontSize-label2);
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--vscode-textCodeBlock-background);
|
||||
}
|
||||
|
||||
/* Body row: description + actions inline, wraps at small widths */
|
||||
.chat-input-notification .chat-input-notification-body {
|
||||
.chat-input-notification-widget .chat-input-notification-body {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
gap: var(--vscode-spacing-size80);
|
||||
min-width: 0;
|
||||
padding-left: 22px; /* align with title text after the severity icon */
|
||||
}
|
||||
|
||||
/* Description */
|
||||
.chat-input-notification .chat-input-notification-description {
|
||||
font-size: var(--vscode-agents-fontSize-label1);
|
||||
line-height: 18px;
|
||||
.chat-input-notification-widget .chat-input-notification-description {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Actions container */
|
||||
.chat-input-notification .chat-input-notification-actions {
|
||||
.chat-input-notification-widget .chat-input-notification-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: var(--vscode-spacing-size80);
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.chat-input-notification .chat-input-notification-action-button {
|
||||
font-size: var(--vscode-agents-fontSize-label2);
|
||||
padding: 4px 12px;
|
||||
.chat-input-notification-widget .chat-input-notification-action-button {
|
||||
font-size: var(--vscode-fontSize-label2);
|
||||
padding: var(--vscode-spacing-size40) var(--vscode-spacing-size120);
|
||||
min-width: unset;
|
||||
width: auto;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Transparent ghost style for secondary action buttons (e.g. "View Usage") */
|
||||
.chat-input-notification .chat-input-notification-action-button.secondary {
|
||||
.chat-input-notification-widget .chat-input-notification-action-button.secondary {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-foreground);
|
||||
padding: 4px 8px;
|
||||
padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.chat-input-notification .chat-input-notification-action-button.secondary:hover {
|
||||
.chat-input-notification-widget .chat-input-notification-action-button.secondary:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dismiss + mute icon buttons (header, right-aligned) */
|
||||
.chat-input-notification .chat-input-notification-dismiss,
|
||||
.chat-input-notification .chat-input-notification-mute {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--vscode-icon-foreground);
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input-notification .chat-input-notification-dismiss {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chat-input-notification .chat-input-notification-dismiss:hover,
|
||||
.chat-input-notification .chat-input-notification-mute:hover {
|
||||
background-color: var(--vscode-toolbar-hoverBackground);
|
||||
}
|
||||
|
||||
.chat-input-notification .chat-input-notification-dismiss:focus-visible,
|
||||
.chat-input-notification .chat-input-notification-mute:focus-visible {
|
||||
outline: 1px solid var(--vscode-focusBorder);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
/* Dismiss + mute icon buttons are styled by `.chat-input-notice-action`. */
|
||||
|
||||
/* Cascade styling — remove top border-radius from sibling containers when notification is visible */
|
||||
.interactive-session .interactive-input-part > .chat-input-notification-container.has-notification + .chat-todo-list-widget-container .chat-todo-list-widget {
|
||||
|
||||
+13
-3
@@ -15,9 +15,12 @@ import { Codicon } from '../../../../base/common/codicons.js';
|
||||
import { MarkdownString } from '../../../../base/common/htmlContent.js';
|
||||
import { localize } from '../../../../nls.js';
|
||||
import { IHoverService } from '../../../../platform/hover/browser/hover.js';
|
||||
import { IProductService } from '../../../../platform/product/common/productService.js';
|
||||
import { ITunnelHostService } from '../common/tunnelHost.js';
|
||||
import { RENAME_TUNNEL_ID, SHOW_TUNNEL_HOST_OUTPUT_ID } from './tunnelHostService.js';
|
||||
|
||||
const TUNNEL_ACCESS_DOCS_URL = 'https://aka.ms/vscode-agent-tunnel-access';
|
||||
|
||||
export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem {
|
||||
|
||||
private _iconElement: HTMLElement | undefined;
|
||||
@@ -29,6 +32,7 @@ export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem {
|
||||
action: IAction,
|
||||
@ITunnelHostService private readonly _tunnelHostService: ITunnelHostService,
|
||||
@IHoverService private readonly _hoverService: IHoverService,
|
||||
@IProductService private readonly _productService: IProductService,
|
||||
) {
|
||||
super(undefined, action);
|
||||
|
||||
@@ -118,10 +122,13 @@ export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem {
|
||||
lines.push(localize('tunnelHost.hover.enabled', "Remote session access is enabled"));
|
||||
}
|
||||
} else {
|
||||
lines.push(localize('tunnelHost.hover.idle', "Allow remote session access"));
|
||||
const agentsUrl = this._productService.webUrl ? `${this._productService.webUrl.replace(/\/$/, '')}/agents` : undefined;
|
||||
lines.push(agentsUrl
|
||||
? localize('tunnelHost.hover.idle', "Allow connections from other machines and {0}", `[${agentsUrl.replace(/https?:\/\//, '')}](${agentsUrl})`)
|
||||
: localize('tunnelHost.hover.idle.noWebUrl', "Allow connections from other machines"));
|
||||
}
|
||||
|
||||
lines.push(`[${localize('tunnelHost.hover.showOutput', "Show Output")}](command:${SHOW_TUNNEL_HOST_OUTPUT_ID}) | [${localize('tunnelHost.hover.renameTunnel', "Rename Tunnel")}](command:${RENAME_TUNNEL_ID})`);
|
||||
lines.push(`[${localize('tunnelHost.hover.showOutput', "Show Output")}](command:${SHOW_TUNNEL_HOST_OUTPUT_ID}) | [${localize('tunnelHost.hover.renameTunnel', "Rename Tunnel")}](command:${RENAME_TUNNEL_ID}) | [${localize('tunnelHost.hover.learnMore', "Learn More")}](${TUNNEL_ACCESS_DOCS_URL})`);
|
||||
|
||||
const md = new MarkdownString(lines.join('\n\n'), { isTrusted: { enabledCommands: [SHOW_TUNNEL_HOST_OUTPUT_ID, RENAME_TUNNEL_ID] } });
|
||||
return { markdown: md, markdownNotSupportedFallback: lines[0] };
|
||||
@@ -140,6 +147,9 @@ export class ToggleRemoteConnectionsActionViewItem extends BaseActionViewItem {
|
||||
}
|
||||
return localize('tunnelHost.hover.enabled', "Remote session access is enabled");
|
||||
}
|
||||
return localize('tunnelHost.hover.idle', "Allow remote session access");
|
||||
const agentsUrl = this._productService.webUrl ? `${this._productService.webUrl.replace(/\/$/, '')}/agents` : undefined;
|
||||
return agentsUrl
|
||||
? localize('tunnelHost.hover.idle.ariaLabel', "Allow connections from other machines and {0}", agentsUrl.replace(/https?:\/\//, ''))
|
||||
: localize('tunnelHost.hover.idle.ariaLabel.noWebUrl', "Allow connections from other machines");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,17 @@ import { IAuthenticationMcpUsageService } from '../../../../../services/authenti
|
||||
import { IAuthenticationService, type IAuthenticationProvider } from '../../../../../services/authentication/common/authentication.js';
|
||||
import { IDynamicAuthenticationProviderStorageService } from '../../../../../services/authentication/common/dynamicAuthenticationProviderStorage.js';
|
||||
import { CHAT_SETUP_ACTION_ID } from '../../../browser/actions/chatActions.js';
|
||||
import { authenticateProtectedResources, resolveAuthenticationInteractively, resolveTokenForResource, AgentHostAuthTokenCache, agentHostMcpServerId, resolveMcpServerAuthentication, modelRequiresAgentAuthentication, type IAgentHostAuthenticationOptions } from '../../../browser/agentSessions/agentHost/agentHostAuth.js';
|
||||
import { AgentHostAuthenticationRecovery, authenticateProtectedResources, resolveAuthenticationInteractively, resolveTokenForResource, AgentHostAuthTokenCache, agentHostMcpServerId, resolveMcpServerAuthentication, modelRequiresAgentAuthentication, type IAgentHostAuthenticationOptions } from '../../../browser/agentSessions/agentHost/agentHostAuth.js';
|
||||
import { createAgentModelByokMeta } from '../../../../../../platform/agentHost/common/agentModelByokMeta.js';
|
||||
|
||||
class TestCommandService extends mock<ICommandService>() {
|
||||
readonly calls: { commandId: string; args: unknown[] }[] = [];
|
||||
result: unknown = { success: true, dialogSkipped: false };
|
||||
onExecute: (() => void) | undefined;
|
||||
onExecute: (() => void | Promise<void>) | undefined;
|
||||
|
||||
override async executeCommand<R = unknown>(commandId: string, ...args: unknown[]): Promise<R | undefined> {
|
||||
this.calls.push({ commandId, args });
|
||||
this.onExecute?.();
|
||||
await this.onExecute?.();
|
||||
return this.result as R;
|
||||
}
|
||||
}
|
||||
@@ -370,6 +370,90 @@ suite('AgentHostAuthTokenCache', () => {
|
||||
});
|
||||
});
|
||||
|
||||
suite('AgentHostAuthenticationRecovery', () => {
|
||||
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
test('force-forwards the post-sign-in token when session-change handling repopulates the cache', async () => {
|
||||
const token = { value: 'tok-1' };
|
||||
const authService = createMockAuthService({
|
||||
getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'),
|
||||
getSessions: (_providerId, scopes) => Promise.resolve(scopes ? [{ scopes, accessToken: token.value }] : []),
|
||||
});
|
||||
const commandService = new TestCommandService();
|
||||
const instantiationService = createAuthInstantiationService(disposables, authService, commandService);
|
||||
const cache = new AgentHostAuthTokenCache();
|
||||
const recovery = new AgentHostAuthenticationRecovery();
|
||||
const resource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.example.com',
|
||||
authorization_servers: ['https://auth.example.com'],
|
||||
scopes_supported: ['read'],
|
||||
};
|
||||
const authenticateCalls: string[] = [];
|
||||
const options: IAgentHostAuthenticationOptions = {
|
||||
authTokenCache: cache,
|
||||
logPrefix: '[AgentHost]',
|
||||
authenticate: async request => { authenticateCalls.push(request.token); },
|
||||
};
|
||||
|
||||
await instantiationService.invokeFunction(accessor => recovery.recover(accessor, resource, options));
|
||||
commandService.onExecute = async () => {
|
||||
token.value = 'tok-2';
|
||||
await cache.authenticate(resource.resource, resource.scopes_supported, token.value, async () => {
|
||||
authenticateCalls.push(token.value);
|
||||
});
|
||||
};
|
||||
await instantiationService.invokeFunction(accessor => recovery.recover(accessor, resource, options));
|
||||
|
||||
assert.deepStrictEqual({
|
||||
commandCalls: commandService.calls.length,
|
||||
authenticateCalls,
|
||||
}, {
|
||||
commandCalls: 1,
|
||||
authenticateCalls: ['tok-1', 'tok-2', 'tok-2'],
|
||||
});
|
||||
|
||||
await instantiationService.invokeFunction(accessor => recovery.recover(accessor, resource, options));
|
||||
assert.strictEqual(commandService.calls.length, 2);
|
||||
});
|
||||
|
||||
test('forwards credential removal and resets escalation when the current token disappears', async () => {
|
||||
const token = { value: 'tok-1' as string | undefined };
|
||||
const authService = createMockAuthService({
|
||||
getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'),
|
||||
getSessions: (_providerId, scopes) => Promise.resolve(token.value && scopes ? [{ scopes, accessToken: token.value }] : []),
|
||||
});
|
||||
const commandService = new TestCommandService();
|
||||
const instantiationService = createAuthInstantiationService(disposables, authService, commandService);
|
||||
const recovery = new AgentHostAuthenticationRecovery();
|
||||
const resource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.example.com',
|
||||
authorization_servers: ['https://auth.example.com'],
|
||||
scopes_supported: ['read'],
|
||||
};
|
||||
const authenticateCalls: string[] = [];
|
||||
const options: IAgentHostAuthenticationOptions = {
|
||||
authTokenCache: new AgentHostAuthTokenCache(),
|
||||
logPrefix: '[AgentHost]',
|
||||
authenticate: async request => { authenticateCalls.push(request.token); },
|
||||
};
|
||||
|
||||
await instantiationService.invokeFunction(accessor => recovery.recover(accessor, resource, options));
|
||||
token.value = undefined;
|
||||
await instantiationService.invokeFunction(accessor => recovery.recover(accessor, resource, options));
|
||||
token.value = 'tok-1';
|
||||
await instantiationService.invokeFunction(accessor => recovery.recover(accessor, resource, options));
|
||||
|
||||
assert.deepStrictEqual({
|
||||
commandCalls: commandService.calls.length,
|
||||
authenticateCalls,
|
||||
}, {
|
||||
commandCalls: 0,
|
||||
authenticateCalls: ['tok-1', '', 'tok-1'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('resolveMcpServerAuthentication', () => {
|
||||
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
@@ -1124,7 +1208,7 @@ suite('resolveAuthenticationInteractively', () => {
|
||||
test('uses the product sign-in flow and forwards its token', async () => {
|
||||
let signedIn = false;
|
||||
const commandService = new TestCommandService();
|
||||
commandService.onExecute = () => signedIn = true;
|
||||
commandService.onExecute = () => { signedIn = true; };
|
||||
const authService = createMockAuthService({
|
||||
getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'),
|
||||
getSessions: () => Promise.resolve(signedIn ? [{ scopes: ['read'], accessToken: 'signed-in-token' }] : []),
|
||||
|
||||
+260
-13
@@ -29,7 +29,7 @@ import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey
|
||||
import { getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js';
|
||||
import { BrowserViewAttachmentDisplayKind, BrowserViewAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js';
|
||||
import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js';
|
||||
import { ActionType, isSessionAction, isChatAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction as AgentHostChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
|
||||
import { ActionType, AuthRequiredReason, isSessionAction, isChatAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction as AgentHostChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js';
|
||||
import { ProtocolError, type IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js';
|
||||
import { ChatInteractivity, ConfirmationOptionKind, CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type AgentCustomization, type ClientPluginCustomization, type ProtectedResourceMetadata, type SessionActiveClient, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js';
|
||||
import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ChatOriginKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallRiskAssessmentKind, ToolCallRiskAssessmentStatus, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, PendingMessageKind, withSessionMultiRootMetadata, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo, type MessageAttachment, type MessageChatAttachment } from '../../../../../../platform/agentHost/common/state/sessionState.js';
|
||||
@@ -62,6 +62,7 @@ import { IOutputService } from '../../../../../services/output/common/output.js'
|
||||
import { IWorkspaceContextService, WorkbenchState } from '../../../../../../platform/workspace/common/workspace.js';
|
||||
import { IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js';
|
||||
import { AgentHostContribution, AgentHostSessionHandler } from '../../../browser/agentSessions/agentHost/agentHostChatContribution.js';
|
||||
import { AgentHostAuthTokenCache } from '../../../browser/agentSessions/agentHost/agentHostAuth.js';
|
||||
import { AgentHostLanguageModelProvider } from '../../../browser/agentSessions/agentHost/agentHostLanguageModelProvider.js';
|
||||
import { AgentHostSessionListContribution } from '../../../browser/agentSessions/agentHost/agentHostSessionListContribution.js';
|
||||
import { AgentHostSessionListController } from '../../../browser/agentSessions/agentHost/agentHostSessionListController.js';
|
||||
@@ -11276,20 +11277,29 @@ suite('AgentHostChatContribution', () => {
|
||||
|
||||
suite('auth dedupe', () => {
|
||||
|
||||
const protectedResource = (): ProtectedResourceMetadata => ({
|
||||
resource: 'https://api.github.com',
|
||||
resource_name: 'GitHub',
|
||||
authorization_servers: ['https://github.com/login/oauth'],
|
||||
scopes_supported: ['read:user'],
|
||||
required: true,
|
||||
});
|
||||
|
||||
const protectedAgents = (): AgentInfo[] => [{
|
||||
provider: 'copilot',
|
||||
displayName: 'Agent Host - Copilot',
|
||||
description: 'test',
|
||||
models: [],
|
||||
protectedResources: [{
|
||||
resource: 'https://api.github.com',
|
||||
resource_name: 'GitHub',
|
||||
authorization_servers: ['https://github.com/login/oauth'],
|
||||
scopes_supported: ['read:user'],
|
||||
required: true,
|
||||
}],
|
||||
protectedResources: [protectedResource()],
|
||||
}];
|
||||
|
||||
const authRequiredNotification = (resource: ProtectedResourceMetadata, reason?: AuthRequiredReason): INotification => ({
|
||||
type: 'auth/required',
|
||||
channel: 'ahp-root://',
|
||||
resource,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
});
|
||||
|
||||
function tokenAuthService(tokenRef: { current: string }): Partial<IAuthenticationService> {
|
||||
// Always returns whatever token is in tokenRef.current. Returning a session
|
||||
// for the exact-scope `getSessions` call short-circuits the superset fallback.
|
||||
@@ -11352,11 +11362,7 @@ suite('AgentHostChatContribution', () => {
|
||||
|
||||
agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 });
|
||||
await timeout(0);
|
||||
agentHostService.fireNotification({
|
||||
type: 'auth/required',
|
||||
channel: 'ahp-root://',
|
||||
resource: 'https://api.github.com',
|
||||
});
|
||||
agentHostService.fireNotification(authRequiredNotification(protectedResource()));
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual(agentHostService.authenticateCalls, [
|
||||
@@ -11365,6 +11371,247 @@ suite('AgentHostChatContribution', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('authenticates an exact required resource that is not advertised by root agents', async () => {
|
||||
const tokenRef = { current: 'tok-1' };
|
||||
const { agentHostService } = createContribution(disposables, { authServiceOverride: tokenAuthService(tokenRef) });
|
||||
const sessionResource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.example.com/session',
|
||||
authorization_servers: ['https://auth.example.com'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
|
||||
agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 });
|
||||
await timeout(0);
|
||||
agentHostService.authenticateCalls.length = 0;
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource));
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual(agentHostService.authenticateCalls, [{
|
||||
resource: 'https://api.example.com/session',
|
||||
scopes: ['session:read'],
|
||||
token: 'tok-1',
|
||||
}]);
|
||||
});
|
||||
|
||||
test('resends the current token once for duplicate expired authentication notifications without prompting', async () => {
|
||||
const tokenRef = { current: 'tok-1' };
|
||||
const { instantiationService, agentHostService, commandService } = createTestServices(disposables, undefined, tokenAuthService(tokenRef));
|
||||
disposables.add(instantiationService.createInstance(AgentHostContribution));
|
||||
agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 });
|
||||
await timeout(0);
|
||||
agentHostService.authenticateCalls.length = 0;
|
||||
const sessionResource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.github.com/session',
|
||||
authorization_servers: ['https://github.com/login/oauth'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
commandCalls: commandService.calls.length,
|
||||
authenticateCalls: agentHostService.authenticateCalls,
|
||||
}, {
|
||||
commandCalls: 0,
|
||||
authenticateCalls: [{
|
||||
resource: 'https://api.github.com/session',
|
||||
scopes: ['session:read'],
|
||||
token: 'tok-1',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('prompts once and forwards after a second completed same-token challenge', async () => {
|
||||
const tokenRef = { current: 'tok-1' };
|
||||
const { instantiationService, agentHostService, commandService } = createTestServices(disposables, undefined, tokenAuthService(tokenRef));
|
||||
commandService.result = { success: true, dialogSkipped: false };
|
||||
disposables.add(instantiationService.createInstance(AgentHostContribution));
|
||||
const sessionResource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.github.com/session',
|
||||
authorization_servers: ['https://github.com/login/oauth'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
commandCalls: commandService.calls.length,
|
||||
authenticateCalls: agentHostService.authenticateCalls,
|
||||
}, {
|
||||
commandCalls: 1,
|
||||
authenticateCalls: [
|
||||
{ resource: 'https://api.github.com/session', scopes: ['session:read'], token: 'tok-1' },
|
||||
{ resource: 'https://api.github.com/session', scopes: ['session:read'], token: 'tok-1' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('forwards and records the post-sign-in token when authentication repopulates the cache during setup', async () => {
|
||||
const tokenRef = { current: 'tok-1' };
|
||||
const { instantiationService, agentHostService, commandService } = createTestServices(disposables, undefined, tokenAuthService(tokenRef));
|
||||
const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution));
|
||||
const sessionResource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.github.com/session',
|
||||
authorization_servers: ['https://github.com/login/oauth'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
let promptCount = 0;
|
||||
commandService.executeCommand = async <R>() => {
|
||||
promptCount++;
|
||||
tokenRef.current = 'tok-2';
|
||||
await (contribution as unknown as { readonly _authTokenCache: AgentHostAuthTokenCache })._authTokenCache.authenticate(
|
||||
sessionResource.resource,
|
||||
sessionResource.scopes_supported,
|
||||
tokenRef.current,
|
||||
() => agentHostService.authenticate({ resource: sessionResource.resource, scopes: sessionResource.scopes_supported, token: tokenRef.current }),
|
||||
);
|
||||
return { success: true, dialogSkipped: false } as R;
|
||||
};
|
||||
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
promptCount,
|
||||
authenticateCalls: agentHostService.authenticateCalls,
|
||||
}, {
|
||||
promptCount: 1,
|
||||
authenticateCalls: [
|
||||
{ resource: 'https://api.github.com/session', scopes: ['session:read'], token: 'tok-1' },
|
||||
{ resource: 'https://api.github.com/session', scopes: ['session:read'], token: 'tok-2' },
|
||||
{ resource: 'https://api.github.com/session', scopes: ['session:read'], token: 'tok-2' },
|
||||
],
|
||||
});
|
||||
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
assert.strictEqual(promptCount, 2);
|
||||
});
|
||||
|
||||
test('does not prompt after a silently rotated token on a second challenge', async () => {
|
||||
const tokenRef = { current: 'tok-1' };
|
||||
const { instantiationService, agentHostService, commandService } = createTestServices(disposables, undefined, tokenAuthService(tokenRef));
|
||||
disposables.add(instantiationService.createInstance(AgentHostContribution));
|
||||
const sessionResource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.github.com/session',
|
||||
authorization_servers: ['https://github.com/login/oauth'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
tokenRef.current = 'tok-2';
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
commandCalls: commandService.calls.length,
|
||||
authenticateCalls: agentHostService.authenticateCalls,
|
||||
}, {
|
||||
commandCalls: 0,
|
||||
authenticateCalls: [
|
||||
{ resource: 'https://api.github.com/session', scopes: ['session:read'], token: 'tok-1' },
|
||||
{ resource: 'https://api.github.com/session', scopes: ['session:read'], token: 'tok-2' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('does not claim or retry a canceled interactive authentication until a later challenge', async () => {
|
||||
const tokenRef = { current: 'tok-1' };
|
||||
const { instantiationService, agentHostService, commandService } = createTestServices(disposables, undefined, tokenAuthService(tokenRef));
|
||||
commandService.result = undefined;
|
||||
disposables.add(instantiationService.createInstance(AgentHostContribution));
|
||||
const sessionResource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.github.com/session',
|
||||
authorization_servers: ['https://github.com/login/oauth'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
commandCalls: commandService.calls.length,
|
||||
authenticateCalls: agentHostService.authenticateCalls,
|
||||
}, {
|
||||
commandCalls: 1,
|
||||
authenticateCalls: [{ resource: 'https://api.github.com/session', scopes: ['session:read'], token: 'tok-1' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('sends a silently rotated current token for an expired authentication notification', async () => {
|
||||
const tokenRef = { current: 'tok-1' };
|
||||
const { instantiationService, agentHostService, commandService } = createTestServices(disposables, undefined, tokenAuthService(tokenRef));
|
||||
disposables.add(instantiationService.createInstance(AgentHostContribution));
|
||||
agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 });
|
||||
await timeout(0);
|
||||
agentHostService.authenticateCalls.length = 0;
|
||||
tokenRef.current = 'tok-2';
|
||||
const sessionResource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.github.com/session',
|
||||
authorization_servers: ['https://github.com/login/oauth'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
|
||||
agentHostService.fireNotification(authRequiredNotification(sessionResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
commandCalls: commandService.calls.length,
|
||||
authenticateCalls: agentHostService.authenticateCalls,
|
||||
}, {
|
||||
commandCalls: 0,
|
||||
authenticateCalls: [{
|
||||
resource: 'https://api.github.com/session',
|
||||
scopes: ['session:read'],
|
||||
token: 'tok-2',
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
test('does not conflate authentication challenges with the same resource and distinct scopes', async () => {
|
||||
const tokenRef = { current: 'tok-1' };
|
||||
const { instantiationService, agentHostService, commandService } = createTestServices(disposables, undefined, tokenAuthService(tokenRef));
|
||||
disposables.add(instantiationService.createInstance(AgentHostContribution));
|
||||
agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 });
|
||||
await timeout(0);
|
||||
commandService.calls.length = 0;
|
||||
agentHostService.authenticateCalls.length = 0;
|
||||
|
||||
const readResource: ProtectedResourceMetadata = {
|
||||
resource: 'https://api.github.com/session',
|
||||
authorization_servers: ['https://github.com/login/oauth'],
|
||||
scopes_supported: ['session:read'],
|
||||
};
|
||||
const writeResource: ProtectedResourceMetadata = {
|
||||
...readResource,
|
||||
scopes_supported: ['session:write'],
|
||||
};
|
||||
agentHostService.fireNotification(authRequiredNotification(readResource, AuthRequiredReason.Expired));
|
||||
agentHostService.fireNotification(authRequiredNotification(writeResource, AuthRequiredReason.Expired));
|
||||
await timeout(0);
|
||||
|
||||
assert.deepStrictEqual({
|
||||
commandCalls: commandService.calls.length,
|
||||
authenticateCalls: agentHostService.authenticateCalls,
|
||||
}, {
|
||||
commandCalls: 0,
|
||||
authenticateCalls: [
|
||||
{ resource: 'https://api.github.com/session', scopes: ['session:read'], token: 'tok-1' },
|
||||
{ resource: 'https://api.github.com/session', scopes: ['session:write'], token: 'tok-1' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('forwards missing-token state once when no token is resolvable', async () => {
|
||||
const noTokenService: Partial<IAuthenticationService> = {
|
||||
onDidChangeSessions: Event.None,
|
||||
|
||||
@@ -5,14 +5,12 @@
|
||||
|
||||
import assert from 'assert';
|
||||
import * as dom from '../../../../../base/browser/dom.js';
|
||||
import { setARIAContainer } from '../../../../../base/browser/ui/aria/aria.js';
|
||||
import { Codicon } from '../../../../../base/common/codicons.js';
|
||||
import { errorHandler, setUnexpectedErrorHandler } from '../../../../../base/common/errors.js';
|
||||
import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
|
||||
import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';
|
||||
import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js';
|
||||
import { ChatInputNoticeClaim, ChatInputOnboarding, ChatInputOnboardingCard, IChatInputOnboardingContext } from '../../browser/widget/input/chatInputOnboarding.js';
|
||||
import { ChatInputNoticeClaim, ChatInputOnboarding, IChatInputOnboardingContext } from '../../browser/widget/input/chatInputOnboarding.js';
|
||||
import { ChatInputNoticeHost, ChatInputNoticeLane } from '../../browser/widget/input/chatInputNoticeHost.js';
|
||||
|
||||
suite('Chat input onboarding', () => {
|
||||
@@ -476,48 +474,4 @@ suite('Chat input onboarding', () => {
|
||||
{ shown: true, announceCalls: 1 });
|
||||
});
|
||||
|
||||
test('announces how to reach the card in the tab order', () => {
|
||||
const host = createHost(disposables);
|
||||
const ariaContainer = dom.append(host.root, dom.$('div'));
|
||||
setARIAContainer(ariaContainer);
|
||||
const card = disposables.add(new ChatInputOnboardingCard({
|
||||
container: host.container,
|
||||
className: 'chat-input-onboarding-card',
|
||||
ariaLabel: 'Test onboarding',
|
||||
ariaDescription: 'Test description.',
|
||||
onEscape: () => { },
|
||||
}));
|
||||
|
||||
card.announce();
|
||||
const announced = ariaContainer.textContent;
|
||||
|
||||
assert.deepStrictEqual(
|
||||
{ announced, tabIndex: card.domNode.tabIndex },
|
||||
{ announced: 'Test onboarding. Use Shift+Tab to reach the introduction.', tabIndex: 0 });
|
||||
});
|
||||
|
||||
test('handles unmodified keyboard dismissal and action activation', () => {
|
||||
const host = createHost(disposables);
|
||||
let dismissals = 0;
|
||||
let activations = 0;
|
||||
const card = disposables.add(new ChatInputOnboardingCard({
|
||||
container: host.container,
|
||||
className: 'chat-input-onboarding-card',
|
||||
ariaLabel: 'Test onboarding',
|
||||
onEscape: () => dismissals++,
|
||||
}));
|
||||
const action = card.addAction({
|
||||
className: 'chat-input-onboarding-action',
|
||||
ariaLabel: 'Continue',
|
||||
icon: Codicon.check,
|
||||
onActivate: () => activations++,
|
||||
});
|
||||
|
||||
action.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, ctrlKey: true, bubbles: true }));
|
||||
card.domNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, shiftKey: true, bubbles: true }));
|
||||
action.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true }));
|
||||
card.domNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }));
|
||||
|
||||
assert.deepStrictEqual({ dismissals, activations }, { dismissals: 1, activations: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,8 +163,10 @@ suite('ChatQuotaNotificationContribution integration', () => {
|
||||
return telemetryAppender.events.filter(e => e.eventName === 'chatInputNotificationShown' || e.eventName === 'chatInputNotificationDismissed');
|
||||
}
|
||||
|
||||
// The widget is the notification frame itself; its header row only exists
|
||||
// while something is actually rendered.
|
||||
function getRenderedText(widget: ChatInputNotificationWidget): string {
|
||||
return widget.domNode.querySelector<HTMLElement>('.chat-input-notification')?.textContent ?? '';
|
||||
return widget.domNode.querySelector('.chat-input-notification-header') ? widget.domNode.textContent ?? '' : '';
|
||||
}
|
||||
|
||||
function assertShownTelemetry(telemetryAppender: TestTelemetryAppender, telemetryId: string): void {
|
||||
@@ -183,7 +185,7 @@ suite('ChatQuotaNotificationContribution integration', () => {
|
||||
assert.deepStrictEqual(getNotificationTelemetryEvents(telemetryAppender), []);
|
||||
|
||||
const widget = store.add(instantiationService.createInstance(ChatInputNotificationWidget, undefined));
|
||||
assert.ok(widget.domNode.querySelector('.chat-input-notification'));
|
||||
assert.ok(widget.domNode.querySelector('.chat-input-notification-header'));
|
||||
|
||||
assertShownTelemetry(telemetryAppender, 'quotaExhausted');
|
||||
});
|
||||
@@ -335,7 +337,7 @@ suite('ChatQuotaNotificationContribution integration', () => {
|
||||
autoDismissOnMessage: true,
|
||||
});
|
||||
|
||||
assert.ok(widget.domNode.querySelector('.chat-input-notification'));
|
||||
assert.ok(widget.domNode.querySelector('.chat-input-notification-header'));
|
||||
assert.deepStrictEqual(getNotificationTelemetryEvents(telemetryAppender), [
|
||||
{
|
||||
eventName: 'chatInputNotificationShown',
|
||||
@@ -383,7 +385,7 @@ suite('ChatQuotaNotificationContribution integration', () => {
|
||||
actionButton.click();
|
||||
await timeout(0);
|
||||
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification'), null);
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification-header'), null);
|
||||
assert.deepStrictEqual(commandService.executedCommands, ['workbench.action.chat.manageAdditionalSpend']);
|
||||
assert.deepStrictEqual(telemetryAppender.events.filter(e => e.eventName === 'workbenchActionExecuted' || e.eventName === 'chatInputNotificationShown'), [
|
||||
{
|
||||
|
||||
@@ -113,7 +113,7 @@ suite('Dictation onboarding', () => {
|
||||
const shownFirstTime = service.showIfNeeded();
|
||||
const shown = host.container.classList.contains('has-dictation-onboarding');
|
||||
|
||||
const closeIcon = host.container.querySelector('.dictation-onboarding-close .codicon')?.className;
|
||||
const closeIcon = host.container.querySelector('.dictation-onboarding-close')?.className;
|
||||
const hasMicrophoneControls = host.container.querySelector('.dictation-onboarding-device') !== null;
|
||||
const hasWaveform = host.container.querySelector('.dictation-onboarding-waveform') !== null;
|
||||
host.container.querySelector<HTMLElement>('.dictation-onboarding-close')!.click();
|
||||
@@ -129,7 +129,7 @@ suite('Dictation onboarding', () => {
|
||||
telemetryEvents,
|
||||
},
|
||||
{
|
||||
shownFirstTime: true, shown: true, closeIcon: 'codicon codicon-close',
|
||||
shownFirstTime: true, shown: true, closeIcon: 'action-label codicon codicon-close-compact dictation-onboarding-close chat-input-notice-dismiss',
|
||||
hasMicrophoneControls: true,
|
||||
hasWaveform: true,
|
||||
visibleAfterClose: false,
|
||||
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 * as dom from '../../../../../../../base/browser/dom.js';
|
||||
import { setARIAContainer } from '../../../../../../../base/browser/ui/aria/aria.js';
|
||||
import { Codicon } from '../../../../../../../base/common/codicons.js';
|
||||
import { DisposableStore, toDisposable } from '../../../../../../../base/common/lifecycle.js';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
|
||||
import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../../../../browser/widget/input/chatInputNoticeWidget.js';
|
||||
|
||||
suite('ChatInputNoticeWidget', () => {
|
||||
|
||||
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
|
||||
|
||||
function createContainer(store: Pick<DisposableStore, 'add'>): HTMLElement {
|
||||
const root = dom.$('div');
|
||||
document.body.appendChild(root);
|
||||
store.add(toDisposable(() => root.remove()));
|
||||
return root;
|
||||
}
|
||||
|
||||
function createNotice(container?: HTMLElement): ChatInputNoticeWidget {
|
||||
return disposables.add(new ChatInputNoticeWidget({
|
||||
container,
|
||||
variant: ChatInputNoticeVariant.Onboarding,
|
||||
className: 'test-notice',
|
||||
ariaLabel: 'Test notice',
|
||||
ariaDescription: 'Test description.',
|
||||
}));
|
||||
}
|
||||
|
||||
test('builds one shared frame carrying the variant and the producer class', () => {
|
||||
const container = createContainer(disposables);
|
||||
const notice = createNotice(container);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
{
|
||||
classes: [...notice.domNode.classList],
|
||||
parented: notice.domNode.parentElement === container,
|
||||
role: notice.domNode.getAttribute('role'),
|
||||
label: notice.domNode.getAttribute('aria-label'),
|
||||
description: notice.domNode.getAttribute('aria-description'),
|
||||
tabIndex: notice.domNode.tabIndex,
|
||||
},
|
||||
{
|
||||
classes: ['chat-input-notice', 'chat-input-notice-onboarding', 'test-notice'],
|
||||
parented: true,
|
||||
role: 'region',
|
||||
label: 'Test notice',
|
||||
description: 'Test description.',
|
||||
tabIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('leaves the node unparented when no container is given', () => {
|
||||
const notice = createNotice();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
{ parented: !!notice.domNode.parentElement, connected: notice.domNode.isConnected },
|
||||
{ parented: false, connected: false });
|
||||
});
|
||||
|
||||
test('interrupts for an introduction, but waits its turn for a tip', () => {
|
||||
const container = createContainer(disposables);
|
||||
const ariaContainer = dom.append(container, dom.$('div'));
|
||||
setARIAContainer(ariaContainer);
|
||||
const spoken = (selector: string) => ariaContainer.querySelector(selector)?.textContent ?? '';
|
||||
|
||||
disposables.add(new ChatInputNoticeWidget({
|
||||
container,
|
||||
variant: ChatInputNoticeVariant.Onboarding,
|
||||
ariaLabel: 'An introduction',
|
||||
})).announce();
|
||||
disposables.add(new ChatInputNoticeWidget({
|
||||
container,
|
||||
variant: ChatInputNoticeVariant.Tip,
|
||||
ariaLabel: 'A tip',
|
||||
})).announce();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
{ assertive: spoken('.monaco-alert'), polite: spoken('.monaco-status') },
|
||||
{
|
||||
assertive: 'An introduction. Use Shift+Tab to reach the notice.',
|
||||
polite: 'A tip. Use Shift+Tab to reach the notice.',
|
||||
});
|
||||
});
|
||||
|
||||
test('dismisses on unmodified Escape only, and activates its actions', () => {
|
||||
const container = createContainer(disposables);
|
||||
let dismissals = 0;
|
||||
let activations = 0;
|
||||
const notice = disposables.add(new ChatInputNoticeWidget({
|
||||
container,
|
||||
variant: ChatInputNoticeVariant.Onboarding,
|
||||
ariaLabel: 'Test notice',
|
||||
onEscape: () => dismissals++,
|
||||
}));
|
||||
const action = notice.addAction({
|
||||
ariaLabel: 'Continue',
|
||||
icon: Codicon.check,
|
||||
onActivate: () => activations++,
|
||||
});
|
||||
|
||||
notice.domNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, shiftKey: true, bubbles: true }));
|
||||
notice.domNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }));
|
||||
action.querySelector<HTMLElement>('.action-label')!.click();
|
||||
|
||||
assert.deepStrictEqual({ dismissals, activations }, { dismissals: 1, activations: 1 });
|
||||
});
|
||||
|
||||
test('gives the dismiss action a standard shape, and honours the parent it is given', () => {
|
||||
const container = createContainer(disposables);
|
||||
const notice = createNotice(container);
|
||||
const header = dom.append(notice.domNode, dom.$('.header'));
|
||||
|
||||
const housing = notice.addDismissAction({ parent: header, onActivate: () => { } });
|
||||
const dismiss = housing.querySelector<HTMLElement>('.action-label')!;
|
||||
|
||||
assert.deepStrictEqual(
|
||||
{
|
||||
classes: [...dismiss.classList],
|
||||
role: dismiss.getAttribute('role'),
|
||||
label: dismiss.getAttribute('aria-label'),
|
||||
tabIndex: dismiss.tabIndex,
|
||||
inHeader: housing.parentElement === header,
|
||||
},
|
||||
{
|
||||
classes: ['action-label', 'codicon', 'codicon-close-compact', 'chat-input-notice-dismiss'],
|
||||
role: 'button',
|
||||
label: 'Dismiss',
|
||||
tabIndex: 0,
|
||||
inHeader: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('registers action listeners in the store it is given, so a rebuilt notice does not accumulate them', () => {
|
||||
const container = createContainer(disposables);
|
||||
const notice = createNotice(container);
|
||||
const renderStore = disposables.add(new DisposableStore());
|
||||
let activations = 0;
|
||||
|
||||
const action = notice.addAction({
|
||||
ariaLabel: 'Continue',
|
||||
icon: Codicon.check,
|
||||
store: renderStore,
|
||||
onActivate: () => activations++,
|
||||
});
|
||||
const button = () => action.querySelector<HTMLElement>('.action-label');
|
||||
button()?.click();
|
||||
renderStore.clear();
|
||||
button()?.click();
|
||||
|
||||
assert.strictEqual(activations, 1);
|
||||
});
|
||||
|
||||
test('stops being a landmark and a tab stop while put away, and comes back intact', () => {
|
||||
const container = createContainer(disposables);
|
||||
const notice = createNotice(container);
|
||||
|
||||
const read = () => ({
|
||||
role: notice.domNode.getAttribute('role'),
|
||||
label: notice.domNode.getAttribute('aria-label'),
|
||||
tabIndex: notice.domNode.getAttribute('tabindex'),
|
||||
hidden: notice.domNode.style.display === 'none',
|
||||
});
|
||||
|
||||
const shown = read();
|
||||
notice.setVisible(false);
|
||||
const away = read();
|
||||
notice.setVisible(true);
|
||||
const back = read();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
{ shown, away, back },
|
||||
{
|
||||
shown: { role: 'region', label: 'Test notice', tabIndex: '0', hidden: false },
|
||||
away: { role: null, label: null, tabIndex: null, hidden: true },
|
||||
back: { role: 'region', label: 'Test notice', tabIndex: '0', hidden: false },
|
||||
});
|
||||
});
|
||||
|
||||
test('renames the region for notices whose message is only known per render', () => {
|
||||
const container = createContainer(disposables);
|
||||
const notice = createNotice(container);
|
||||
|
||||
notice.setAriaLabel('Approaching your quota');
|
||||
const named = notice.domNode.getAttribute('aria-label');
|
||||
notice.setAriaLabel(undefined);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
{ named, cleared: notice.domNode.getAttribute('aria-label') },
|
||||
{ named: 'Approaching your quota', cleared: null });
|
||||
});
|
||||
|
||||
test('reports focus through the notice host contract', () => {
|
||||
const container = createContainer(disposables);
|
||||
const notice = createNotice(container);
|
||||
|
||||
const before = notice.hasFocus();
|
||||
notice.focus();
|
||||
|
||||
assert.deepStrictEqual({ before, after: notice.hasFocus() }, { before: false, after: true });
|
||||
});
|
||||
|
||||
test('takes itself out of the DOM when disposed', () => {
|
||||
const container = createContainer(disposables);
|
||||
const store = new DisposableStore();
|
||||
const notice = store.add(new ChatInputNoticeWidget({
|
||||
container,
|
||||
variant: ChatInputNoticeVariant.Tip,
|
||||
ariaLabel: 'Test tip',
|
||||
}));
|
||||
|
||||
const attached = notice.domNode.parentElement === container;
|
||||
store.dispose();
|
||||
|
||||
assert.deepStrictEqual({ attached, remaining: container.childElementCount }, { attached: true, remaining: 0 });
|
||||
});
|
||||
});
|
||||
+7
-7
@@ -100,13 +100,13 @@ suite('ChatInputNotificationWidget', () => {
|
||||
sessionTypes: [localChatSessionType],
|
||||
});
|
||||
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification')?.textContent, 'Local only');
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification-header')?.textContent, 'Local only');
|
||||
|
||||
currentSessionType.set(SessionType.AgentHostCopilot, undefined);
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification'), null);
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification-header'), null);
|
||||
|
||||
currentSessionType.set(localChatSessionType, undefined);
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification')?.textContent, 'Local only');
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification-header')?.textContent, 'Local only');
|
||||
});
|
||||
|
||||
test('reports visibility changes when a notification is shown and hidden', () => {
|
||||
@@ -165,9 +165,9 @@ suite('ChatInputNotificationWidget', () => {
|
||||
sessionResources: [firstSession],
|
||||
});
|
||||
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification')?.textContent, 'First session only');
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification-header')?.textContent, 'First session only');
|
||||
currentSessionResource.set(secondSession, undefined);
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification'), null);
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification-header'), null);
|
||||
});
|
||||
|
||||
test('renders markdown descriptions as rich content', () => {
|
||||
@@ -516,7 +516,7 @@ suite('ChatInputNotificationWidget', () => {
|
||||
sessionTypes: ['agent-host-copilotcli'],
|
||||
});
|
||||
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification')?.textContent, 'Agent Host promo');
|
||||
assert.strictEqual(widget.domNode.querySelector('.chat-input-notification-header')?.textContent, 'Agent Host promo');
|
||||
});
|
||||
|
||||
test('matches a notification scoped to both Copilot model targets', () => {
|
||||
@@ -531,7 +531,7 @@ suite('ChatInputNotificationWidget', () => {
|
||||
actions: [],
|
||||
sessionTypes: [SessionType.AgentHostCopilot, SessionType.CopilotCLI],
|
||||
});
|
||||
const text = () => widget.domNode.querySelector('.chat-input-notification')?.textContent;
|
||||
const text = () => widget.domNode.querySelector('.chat-input-notification-header')?.textContent;
|
||||
const agentHostText = text();
|
||||
currentSessionType.set(SessionType.CopilotCLI, undefined);
|
||||
const copilotCliText = text();
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as dom from '../../../../../base/browser/dom.js';
|
||||
import { Codicon } from '../../../../../base/common/codicons.js';
|
||||
import { ThemeIcon } from '../../../../../base/common/themables.js';
|
||||
import { ChatInputNoticeVariant, ChatInputNoticeWidget } from '../../../../contrib/chat/browser/widget/input/chatInputNoticeWidget.js';
|
||||
import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js';
|
||||
|
||||
/**
|
||||
* The three notice variants side by side. Five producers share one frame, and
|
||||
* the only way to see that they still agree - and that severity only tints what
|
||||
* it is meant to - is to put the variants next to each other at one width.
|
||||
*/
|
||||
function renderNotices(context: ComponentFixtureContext): void {
|
||||
const { container, disposableStore } = context;
|
||||
container.classList.add('monaco-workbench');
|
||||
container.style.width = '320px';
|
||||
container.style.padding = '24px';
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
container.style.gap = '16px';
|
||||
container.style.background = 'var(--vscode-editor-background)';
|
||||
|
||||
const addNotice = (variant: ChatInputNoticeVariant, className: string, severity?: string) => {
|
||||
// Each notice is followed by a stand-in for the chat input it sits against.
|
||||
// Notices leave their bottom edge open because the input covers it, so shown
|
||||
// on their own they would read as unfinished boxes.
|
||||
const stack = dom.append(container, dom.$('div'));
|
||||
const notice = disposableStore.add(new ChatInputNoticeWidget({
|
||||
container: stack,
|
||||
variant,
|
||||
className,
|
||||
ariaLabel: className,
|
||||
}));
|
||||
if (severity) {
|
||||
notice.domNode.classList.add(severity);
|
||||
}
|
||||
const input = dom.append(stack, dom.$('div'));
|
||||
input.textContent = 'Chat input';
|
||||
input.style.cssText = 'padding:10px;color:var(--vscode-descriptionForeground);'
|
||||
+ 'background:var(--vscode-agentsChatInput-background, var(--vscode-input-background));'
|
||||
+ 'border:var(--vscode-strokeThickness) solid var(--vscode-input-border);'
|
||||
+ 'border-radius:0 0 var(--vscode-cornerRadius-large) var(--vscode-cornerRadius-large);';
|
||||
return notice;
|
||||
};
|
||||
|
||||
// A tip: one row of prose, an icon, and a dismiss that sits in the flow.
|
||||
const tip = addNotice(ChatInputNoticeVariant.Tip, 'fixture-tip');
|
||||
dom.append(tip.domNode, dom.$(ThemeIcon.asCSSSelector(Codicon.lightbulb)));
|
||||
dom.append(tip.domNode, dom.$('span')).textContent = 'Start a parallel conversation to build on all the changes made in this session.';
|
||||
tip.addDismissAction({ onActivate: () => { } });
|
||||
|
||||
// A notification, at each severity: same frame, only the tint changes.
|
||||
for (const [severity, icon, message] of [
|
||||
['severity-info', Codicon.info, 'You are approaching your monthly limit.'],
|
||||
['severity-warning', Codicon.warning, 'This model is temporarily unavailable.'],
|
||||
['severity-error', Codicon.error, 'Sign in to keep using chat.'],
|
||||
] as const) {
|
||||
const notification = addNotice(ChatInputNoticeVariant.Notification, 'chat-input-notification-widget', severity);
|
||||
const header = dom.append(notification.domNode, dom.$('.chat-input-notification-header'));
|
||||
dom.append(dom.append(header, dom.$('.chat-input-notification-icon')), dom.$(ThemeIcon.asCSSSelector(icon)));
|
||||
dom.append(header, dom.$('.chat-input-notification-title')).textContent = message;
|
||||
notification.addDismissAction({ parent: header, onActivate: () => { } });
|
||||
}
|
||||
|
||||
// An onboarding card: a stack, with its close pinned to the corner.
|
||||
const card = addNotice(ChatInputNoticeVariant.Onboarding, 'fixture-card');
|
||||
const copy = dom.append(card.domNode, dom.$('div'));
|
||||
copy.style.paddingRight = 'var(--vscode-spacing-size240)';
|
||||
dom.append(copy, dom.$('div')).textContent = 'Welcome to Voice Mode';
|
||||
dom.append(copy, dom.$('div')).textContent = 'Choose how your agent speaks to you.';
|
||||
card.addDismissAction({ onActivate: () => { } });
|
||||
}
|
||||
|
||||
export default defineThemedFixtureGroup({ path: 'chat/input/' }, {
|
||||
'Chat input notices': defineComponentFixture({ render: renderNotices }),
|
||||
});
|
||||
Reference in New Issue
Block a user