Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class AuthUpgradeAsk extends Disposable {
}

private async waitForChatEnabled() {
if (!this._authenticationService.anyGitHubSession) {
if (!this._authenticationService.hasCopilotTokenSource) {
// BYOK / air-gapped: do not wait for a Copilot token that may never arrive.
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels {
}

private _fetchAndCacheModels(): void {
if (!this._authenticationService.anyGitHubSession) {
this.logService.info('[CopilotCLIModels] Skipping model fetch since there is no GitHub session');
if (!this._authenticationService.hasCopilotTokenSource) {
this.logService.info('[CopilotCLIModels] Skipping model fetch since there is no Copilot token source');
return;
}
const availableModels = this._availableModels = this._getAvailableModels();
Expand Down Expand Up @@ -137,7 +137,7 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels {
}

public async getModels(): Promise<CopilotCLIModelInfo[]> {
if (!this._authenticationService.anyGitHubSession) {
if (!this._authenticationService.hasCopilotTokenSource) {
return [];
}

Expand Down Expand Up @@ -178,7 +178,7 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels {
onDidChangeLanguageModelChatInformation: this._onDidChange.event,
provideLanguageModelChatInformation: async (_options, _token) => {
const autoModelEnabled = this.configurationService.getConfig(ConfigKey.Advanced.CLIAutoModelEnabled);
if (!this._authenticationService.anyGitHubSession || !this._resolvedModelInfos) {
if (!this._authenticationService.hasCopilotTokenSource || !this._resolvedModelInfos) {
return autoModelEnabled ? [buildAutoModel()] : [];
}
return this._resolvedModelInfos;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ class MockAuthenticationService {
return this._anyGitHubSession;
}

get hasCopilotTokenSource(): boolean {
return !!this._anyGitHubSession;
}

setSession(session: AuthenticationSession | undefined): void {
this._anyGitHubSession = session;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,8 +558,8 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib
}

private async _getToken(): Promise<CopilotToken | undefined> {
if (!this._authenticationService.anyGitHubSession) {
this._logService.warn('[LanguageModelAccess] LanguageModel/Embeddings are not available without auth session');
if (!this._authenticationService.hasCopilotTokenSource) {
this._logService.warn('[LanguageModelAccess] LanguageModel/Embeddings are not available without a Copilot token source');
return undefined;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { groupBy } from '../../../../util/vs/base/common/collections';
import { StopWatch } from '../../../../util/vs/base/common/stopwatch';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { LanguageModelToolExtensionSource, LanguageModelToolMCPSource } from '../../../../vscodeTypes';
import { ToolName } from '../toolNames';
import { BuiltInToolGroupHandler } from './builtInToolGroupHandler';
import { EMBEDDING_TYPE_FOR_TOOL_GROUPING } from './preComputedToolEmbeddingsCache';
import { IToolEmbeddingsComputer } from './toolEmbeddingsComputer';
Expand Down Expand Up @@ -58,9 +59,14 @@ export class VirtualToolGrouper implements IToolCategorization {
async addGroups(query: string, root: VirtualTool, tools: LanguageModelToolInformation[], token: CancellationToken): Promise<void> {
// If there's no need to group tools, just add them all directly;

// Don't group built-in tools when tool search is active, otherwise non-deferred tools can be hidden
// behind virtual groups that tool search never surfaces. Checked against the full `tools` list since
// `tool_search` has an extension source and is bucketed out of `builtinTools` below.
const toolSearchActive = tools.some(t => t.name === ToolName.ToolSearch);

// if there are more than START_BUILTIN_GROUPING_AFTER_TOOL_COUNT tools, we should group built-in tools
// otherwise, follow the existing logic of grouping all tools together
const shouldGroup = this.shouldTriggerBuiltInGrouping(tools);
const shouldGroup = !toolSearchActive && this.shouldTriggerBuiltInGrouping(tools);

if (!shouldGroup && tools.length < Constant.START_GROUPING_AFTER_TOOL_COUNT) {
root.contents = tools;
Expand Down Expand Up @@ -96,7 +102,7 @@ export class VirtualToolGrouper implements IToolCategorization {
const groupedResults: (VirtualTool | LanguageModelToolInformation)[] = [];

// Handle built-in tools - apply grouping logic if needed
const shouldGroupBuiltin = this.shouldTriggerBuiltInGrouping(builtinTools);
const shouldGroupBuiltin = !toolSearchActive && this.shouldTriggerBuiltInGrouping(builtinTools);
if (shouldGroupBuiltin) {
const builtinGroups = this.builtInToolGroupHandler.createBuiltInToolGroups(builtinTools);
groupedResults.push(...builtinGroups);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ class TestAuthService extends Disposable implements IAuthenticationService {
readonly isMinimalMode = true;
readonly anyGitHubSession = undefined;
readonly permissiveGitHubSession = undefined;
readonly hasCopilotTokenSource = true;
readonly copilotToken = createTestCopilotToken();
speculativeDecodingEndpointToken: string | undefined;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ export interface IAuthenticationService {
*/
readonly anyGitHubSession: AuthenticationSession | undefined;

/**
* Whether the authentication service has a source from which a Copilot token can potentially be obtained
* (e.g. a cached GitHub session, a static token provider, or a proxy/HMAC pathway). This is used as a fast,
* synchronous gate before calling {@link getCopilotToken} in air-gapped/BYOK scenarios.
*
* Unlike {@link anyGitHubSession}, this does not assume GitHub OAuth is the only token pathway, so it stays
* truthy for proxy/HMAC and test-harness implementations where {@link getCopilotToken} succeeds without a
* cached GitHub session.
*/
readonly hasCopilotTokenSource: boolean;

/**
* Checks if there is currently a permissive session available in the cache. Does not make any network requests and does not
* call out to the underlying authentication provider.
Expand Down Expand Up @@ -211,6 +222,14 @@ export abstract class BaseAuthenticationService extends Disposable implements IA

//#endregion

//#region Copilot Token Source

get hasCopilotTokenSource(): boolean {
return !!this._anyGitHubSession;
}

//#endregion

//#region Permissive GitHub Token

protected _permissiveGitHubSession: AuthenticationSession | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export class StaticGitHubAuthenticationService extends BaseAuthenticationService
} : undefined;
}

override get hasCopilotTokenSource(): boolean {
// Static auth always represents a non-OAuth token pathway (proxy/HMAC, eval harness, ...),
// so a Copilot token is obtainable even when no GitHub session is cached.
return true;
}

override async getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions & { createIfNone: StrictAuthenticationPresentationOptions }): Promise<AuthenticationSession>;
override async getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions & { forceNewSession: StrictAuthenticationPresentationOptions }): Promise<AuthenticationSession>;
override async getGitHubSession(kind: 'permissive' | 'any', options: AuthenticationGetSessionOptions): Promise<AuthenticationSession | undefined>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@ suite('AuthenticationService', function () {
expect(authenticationService.copilotToken?.token).toBe(testToken);
});

test('hasCopilotTokenSource is true for static auth even without a GitHub session', () => {
// Static auth represents non-OAuth Copilot token pathways (proxy/HMAC, eval harness, ...),
// so it must report a token source regardless of whether anyGitHubSession is populated.
const accessor = disposables.add(createPlatformServices().createTestingAccessor());
const staticWithoutSession = disposables.add(new StaticGitHubAuthenticationService(
undefined,
accessor.get(ILogService),
accessor.get(ICopilotTokenStore),
copilotTokenManager,
accessor.get(IConfigurationService),
));
expect(staticWithoutSession.anyGitHubSession).toBeUndefined();
expect(staticWithoutSession.hasCopilotTokenSource).toBe(true);
});

test('Emits onDidAuthenticationChange when a Copilot Token change is notified', async () => {
const promise = Event.toPromise(authenticationService.onDidAuthenticationChange);
const newToken = 'tid=new';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer {
});
try {
return await logExecTime(this._logService, 'RemoteEmbeddingsComputer::computeEmbeddings', async () => {
// The remote embeddings endpoint requires GitHub authentication.
if (!this._authService.anyGitHubSession) {
// The remote embeddings endpoint requires a Copilot token.
if (!this._authService.hasCopilotTokenSource) {
return { type: embeddingType, values: [] };
}

Expand All @@ -76,9 +76,12 @@ export class RemoteEmbeddingsComputer implements IEmbeddingsComputer {
return embeddings ?? { type: embeddingType, values: [] };
}

// The Dotcom embeddings path requires a GitHub access token. Token pathways that mint a
// Copilot token without a cached GitHub session (proxy/HMAC, eval harness) cannot reach this
// endpoint, so fall back to returning empty embeddings instead of throwing.
const token = (await this._authService.getGitHubSession('any', { silent: true }))?.accessToken;
if (!token) {
throw new Error('No authentication token available');
return { type: embeddingType, values: [] };
}

const embeddingsOut: Embedding[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export class MockAuthenticationService implements IAuthenticationService {
readonly onDidAdoAuthenticationChange: Event<void> = Event.None;
readonly anyGitHubSession: AuthenticationSession | undefined = undefined;
readonly permissiveGitHubSession: AuthenticationSession | undefined = undefined;
readonly hasCopilotTokenSource: boolean = false;

copilotToken: Omit<CopilotToken, 'token'> | undefined = undefined;
speculativeDecodingEndpointToken: string | undefined = undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ export function getInProgressSessionDescription(chatModel: IChatModel): string |
description = part.message;
} else if (part.kind === 'toolInvocation') {
const toolInvocation = part as IChatToolInvocation;
// Skip hidden tool invocations — they are not shown in the chat
// view, so they shouldn't surface a description in the sidebar
// either (e.g. a no-op `manage_todo_list` write).
if (IChatToolInvocation.isEffectivelyHidden(toolInvocation)) {
continue;
}
const state = toolInvocation.state.get();
description = toolInvocation.generatedTitle || toolInvocation.pastTenseMessage || toolInvocation.invocationMessage;
if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) {
Expand All @@ -52,6 +58,9 @@ export function getInProgressSessionDescription(chatModel: IChatModel): string |
description = titleMessage ?? localize('chat.sessions.description.waitingForConfirmation', "Waiting for confirmation: {0}", descriptionValue);
}
} else if (part.kind === 'toolInvocationSerialized') {
if (IChatToolInvocation.isEffectivelyHidden(part)) {
continue;
}
description = part.invocationMessage;
} else if (part.kind === 'progressMessage') {
description = part.content;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,8 +173,10 @@ export class ManageTodoListTool extends Disposable implements IToolImpl {
}

private generatePastTenseMessage(currentTodos: IChatTodo[], newTodos: IManageTodoListToolInputParams['todoList']): string {
// If no current todos, this is creating new ones
if (currentTodos.length === 0) {
// If no current todos and we're adding new ones, this is creating new ones.
// When both lists are empty (a no-op write), fall through to the default
// "Updated todo list" message rather than showing "Created 0 todos".
if (currentTodos.length === 0 && newTodos.length > 0) {
return newTodos.length === 1
? localize('todo.created.single', "Created 1 todo")
: localize('todo.created.multiple', "Created {0} todos", newTodos.length);
Expand Down
Loading