Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions src/vs/platform/agentHost/common/agentSdkSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ const AGENT_SDK_SETUP_STATUS_KEY_PREFIX = 'vscode.agentSdkSetup.status.';

export const AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY = 'vscode.agentSdkSetup.downloadRequest';

/**
* Ask an agent to look again at a setup the user completed outside the app
* (`claude login`, an exported key) — the only completion signal there is.
*/
export const AGENT_SDK_SETUP_RELOAD_REQUEST_KEY = 'vscode.agentSdkSetup.reloadRequest';

export function agentSdkSetupStatusKey(agent: string): string {
return `${AGENT_SDK_SETUP_STATUS_KEY_PREFIX}${agent}`;
}
Expand Down
53 changes: 38 additions & 15 deletions src/vs/platform/agentHost/node/agentSdkSetupChannel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import { Disposable } from '../../../base/common/lifecycle.js';
import { ILogService } from '../../log/common/log.js';
import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AgentSdkDownloadStatus, IAgentSdkSetupInfo, agentSdkSetupStatusKey, isAgentSdkSetupRequestFor } from '../common/agentSdkSetup.js';
import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY, AgentSdkDownloadStatus, IAgentSdkSetupInfo, agentSdkSetupStatusKey, isAgentSdkSetupRequestFor } from '../common/agentSdkSetup.js';
import { IAgentConfigurationService } from './agentConfigurationService.js';
import { IAgentSdkDownloader, IAgentSdkPackage } from './agentSdkDownloader.js';

Expand Down Expand Up @@ -33,14 +33,14 @@ export interface IAgentSdkSetupChannelAgent {

/**
* One agent's side of the SDK setup channel: publishes whether its SDK is on
* disk, and performs the download the workbench asks for. Every agent needs the
* same nonce handling, latching and publish ordering, so only the calls in
* {@link IAgentSdkSetupChannelAgent} differ.
* disk, performs the download the workbench asks for, and looks again when it
* asks for that. Every agent needs the same nonce handling, latching and publish
* ordering, so only the calls in {@link IAgentSdkSetupChannelAgent} differ.
*/
export class AgentSdkSetupChannel extends Disposable {

/** Consumed request nonce, so a root-config change we caused isn't re-handled. */
private _lastRequest: string | undefined;
/** Consumed request nonce per request key, so a root-config change we caused isn't re-handled. */
private readonly _lastRequests = new Map<string, string>();

/**
* Latched while the *explicit* download runs. {@link IAgentSdkSetupChannelAgent.isSdkLocal}
Expand Down Expand Up @@ -80,13 +80,27 @@ export class AgentSdkSetupChannel extends Disposable {
}

private _handleRequest(): void {
const request = this._configurationService.getRootConfigValues?.()[AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY];
if (!isAgentSdkSetupRequestFor(request, this._agent.id) || request.request === this._lastRequest) {
return;
const values = this._configurationService.getRootConfigValues?.() ?? {};
if (this._takeRequest(values, AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY)) {
void this._download();
}
if (this._takeRequest(values, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY)) {
this._logService.info(`[AgentSdkSetup] ${this._agent.id}: reloading the agent's configuration at the user's request`);
// Nothing to publish: the SDK is already on disk either way, and what the
// banner reads is the catalog the re-look republishes.
void this._lookAgain();
}
}

/** Claim one request addressed to this agent, clearing the key so a repeat press still lands. */
private _takeRequest(values: Readonly<Record<string, unknown>>, key: string): boolean {
const request = values[key];
if (!isAgentSdkSetupRequestFor(request, this._agent.id) || request.request === this._lastRequests.get(key)) {
return false;
}
this._lastRequest = request.request;
this._configurationService.updateRootConfig({ [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: undefined });
void this._download();
this._lastRequests.set(key, request.request);
this._configurationService.updateRootConfig({ [key]: undefined });
return true;
}

/**
Expand All @@ -110,12 +124,21 @@ export class AgentSdkSetupChannel extends Disposable {
this._downloadInFlight = false;
progressInterest.dispose();
}
await this._lookAgain();
}

/**
* Re-read the world: the tail of a download, and the whole of a reload. Both
* gestures change exactly what these two calls see — one puts the SDK on disk,
* the other follows a `claude login` the app could not observe.
*/
private async _lookAgain(): Promise<void> {
// Chat discovery deferred itself while there was no SDK to read the catalog
// from; this is the one moment that can change.
this._agent.restartChatDiscovery();
// Second, not first: the refresh is what asks the fresh SDK about the account,
// so announcing `ready` ahead of it would show "no account found" to a user
// who has one for as long as enumeration takes.
// Second, not first: the refresh is what asks the SDK about the account, so
// announcing `ready` ahead of it would show "no account found" to a user who
// has one for as long as enumeration takes.
await this._agent.refreshModels();
}
}
53 changes: 52 additions & 1 deletion src/vs/platform/agentHost/test/node/claudeAgent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ import { createClaudeInternalMcpServerCustomization } from '../../node/claude/cu
import { ClaudeSessionMetadataStore } from '../../node/claude/claudeSessionMetadataStore.js';
import { ClaudeSessionConfigKey } from '../../common/claudeSessionConfigKeys.js';
import { ClaudeAgentSdkService, IClaudeAgentSdkService, IClaudeSdkBindings } from '../../node/claude/claudeAgentSdkService.js';
import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../common/agentSdkSetup.js';
import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../common/agentSdkSetup.js';
import { IAgentSdkDownloader } from '../../node/agentSdkDownloader.js';
import { RecordingAgentSdkDownloader } from './testAgentSdkDownloader.js';
import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js';
Expand Down Expand Up @@ -6073,6 +6073,11 @@ suite('ClaudeAgent — agent SDK setup channel', () => {
ctx.configService.updateRootConfig({ [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent, request } });
}

/** Addresses a reload request the same way, as the banner's link does. */
function dispatchReload(ctx: ITestContext, agent = 'claude', request = 'req-1'): void {
ctx.configService.updateRootConfig({ [AGENT_SDK_SETUP_RELOAD_REQUEST_KEY]: { agent, request } });
}

/** Waits for the ctor's queued publish (and any refresh it chains) to settle. */
async function settle(): Promise<void> {
for (let i = 0; i < 20; i++) {
Expand Down Expand Up @@ -6264,6 +6269,52 @@ suite('ClaudeAgent — agent SDK setup channel', () => {
migratable: [],
});
});

test('a reload re-asks the SDK for the account the user set up elsewhere, fetching nothing', async () => {
// Setup happens outside the app, so nothing fires when it finishes — a fresh
// `accountInfo()` is the only way to see it, and the SDK is already on disk.
const ctx = createTestContext(disposables);
await settle();
const before = ctx.sdk.accountInfoCallCount;
ctx.sdk.accountInfoResult = NATIVE_ACCOUNT;
ctx.sdk.supportedModelsResult = [
{ value: 'claude-sonnet-4-5-20250929', displayName: 'Claude Sonnet 4.5', description: '', supportedEffortLevels: ['high'] },
];

dispatchReload(ctx);
await settle();

assert.deepStrictEqual({
asked: ctx.sdk.accountInfoCallCount > before,
fetches: ctx.sdk.ensureAvailableCalls,
models: ctx.agent.models.get().map(model => model.name),
// Consumed like the download key, so pressing the link twice is two reloads.
key: ctx.configService.getRootConfigValues()[AGENT_SDK_SETUP_RELOAD_REQUEST_KEY],
}, {
asked: true,
fetches: 0,
models: ['Claude Sonnet 4.5'],
key: undefined,
});
});

test('a reload addressed to another agent is ignored', async () => {
const ctx = createTestContext(disposables);
await settle();
const before = ctx.sdk.accountInfoCallCount;

dispatchReload(ctx, 'codex');
await settle();

assert.deepStrictEqual({
asked: ctx.sdk.accountInfoCallCount > before,
// Left in place for the agent it names, rather than consumed by this one.
key: ctx.configService.getRootConfigValues()[AGENT_SDK_SETUP_RELOAD_REQUEST_KEY],
}, {
asked: false,
key: { agent: 'codex', request: 'req-1' },
});
});
});

suite('ClaudeAgent — per-session provider', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { IAgentHostSessionTitleSignal } from '../../../node/agentHostSessionTitl
import { IAgentSdkDownloader } from '../../../node/agentSdkDownloader.js';
import { RecordingAgentSdkDownloader } from '../testAgentSdkDownloader.js';
import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../../common/agentHostCheckpointService.js';
import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../../common/agentSdkSetup.js';
import { AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY, AGENT_SDK_SETUP_RELOAD_REQUEST_KEY, readAgentSdkSetupInfos } from '../../../common/agentSdkSetup.js';
import { CodexAgent, toCodexModelSelectionId } from '../../../node/codex/codexAgent.js';
import { ICodexProxyService } from '../../../node/codex/codexProxyService.js';
import { ICopilotApiService } from '../../../node/shared/copilotApiService.js';
Expand Down Expand Up @@ -681,6 +681,11 @@ suite('CodexAgent — agent SDK setup channel', () => {
ctx.configurationService.updateRootConfig({ [AGENT_SDK_SETUP_DOWNLOAD_REQUEST_KEY]: { agent, request } });
}

/** Addresses a reload request the same way, as the banner's link does. */
function dispatchReload(ctx: ITestAgentContext, agent = 'codex', request = 'req-1'): void {
ctx.configurationService.updateRootConfig({ [AGENT_SDK_SETUP_RELOAD_REQUEST_KEY]: { agent, request } });
}

/** Waits for the ctor's queued publish (and any refresh it chains) to settle. */
async function settle(): Promise<void> {
for (let i = 0; i < 20; i++) {
Expand Down Expand Up @@ -840,4 +845,22 @@ suite('CodexAgent — agent SDK setup channel', () => {
held: 0,
});
});

test('a reload is claimed here too, since the request handling is the shared channel and not per-agent code', async () => {
const ctx = createAgentContext(disposables, async () => []);
ctx.agent['_ensureConnection'] = async () => { throw new Error('offline'); };
await settle();

dispatchReload(ctx);
await settle();

assert.deepStrictEqual({
key: ctx.configurationService.getRootConfigValues()[AGENT_SDK_SETUP_RELOAD_REQUEST_KEY],
// Reload only re-reads what is already there; nothing is ever fetched.
interests: ctx.sdkDownloader.progressInterests,
}, {
key: undefined,
interests: [],
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js';
import { Event } from '../../../../../../base/common/event.js';
import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js';
import { localize } from '../../../../../../nls.js';
import { AgentHostAllowSignedOutWhenUsableSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js';
import { LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js';
Expand Down Expand Up @@ -84,25 +85,45 @@ export function getAgentSdkSetupStateToReport(previous: AgentSdkSetupState | und

// #region Banner

/**
* A `command:` href carrying the agent id, so a link in the copy takes the same
* route a button would — funnel step and URL validation included.
*/
function setupCommandLink(commandId: string, agent: string): string {
return `command:${commandId}?${encodeURIComponent(JSON.stringify(agent))}`;
}
Comment thread
TylerLeonhardt marked this conversation as resolved.
Outdated

/** Trusted for the commands its links address, and nothing else. */
function setupMarkdown(value: string): MarkdownString {
return new MarkdownString(value, { isTrusted: { enabledCommands: [AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, AGENT_SDK_SETUP_RELOAD_COMMAND_ID] } });
}

/**
* The "no account" second line: one whole sentence per combination of routes,
* never assembled from localized fragments, because clause order is not stable
* across languages. The GitHub clause is unconditional — every agent behind this
* banner reaches models through our Copilot proxy once signed in, which is
* workbench knowledge rather than something an agent could declare.
*
* Reload leads every variant: setup finished in a terminal has no completion
* signal, so the user who has already done it should not read the routes at all.
* Docs are a link inside the sentence that already explains them rather than a
* third button competing with the two routes that actually sign you in.
*/
function noAccountDescription(setup: IAgentSdkSetupInfo, displayName: string): string {
function noAccountDescription(setup: IAgentSdkSetupInfo, displayName: string): IMarkdownString {
const provider = setup.signInProviderName;
if (provider && setup.setupDocsUrl) {
return localize('agentHost.sdkSetup.noAccountDescription.all', "Sign in to GitHub to use GitHub Copilot models, sign in to {0} to use your {0} subscription, or read the instructions for other ways to set up {1}.", provider, displayName);
const reload = setupCommandLink(AGENT_SDK_SETUP_RELOAD_COMMAND_ID, setup.agent);
const docs = setup.setupDocsUrl ? setupCommandLink(AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, setup.agent) : undefined;
Comment thread
TylerLeonhardt marked this conversation as resolved.
Outdated
if (provider && docs) {
return setupMarkdown(localize('agentHost.sdkSetup.noAccountDescription.all', "If you already set up {0} elsewhere, [reload {0} configuration]({1}). Sign in to GitHub to use GitHub Copilot models, sign in to {2} to use your {2} subscription, or [learn more]({3}) for other ways to set up {0}.", displayName, reload, provider, docs));
}
if (provider) {
return localize('agentHost.sdkSetup.noAccountDescription.signIn', "Sign in to GitHub to use GitHub Copilot models, or sign in to {0} to use your {0} subscription.", provider);
return setupMarkdown(localize('agentHost.sdkSetup.noAccountDescription.signIn', "If you already set up {0} elsewhere, [reload {0} configuration]({1}). Sign in to GitHub to use GitHub Copilot models, or sign in to {2} to use your {2} subscription.", displayName, reload, provider));
}
if (setup.setupDocsUrl) {
return localize('agentHost.sdkSetup.noAccountDescription.docs', "Sign in to GitHub to use GitHub Copilot models, or read the instructions for other ways to set up {0}.", displayName);
if (docs) {
return setupMarkdown(localize('agentHost.sdkSetup.noAccountDescription.docs', "If you already set up {0} elsewhere, [reload {0} configuration]({1}). Sign in to GitHub to use GitHub Copilot models, or [learn more]({2}) for other ways to set up {0}.", displayName, reload, docs));
}
return localize('agentHost.sdkSetup.noAccountDescription', "Sign in to GitHub to use GitHub Copilot models.");
return setupMarkdown(localize('agentHost.sdkSetup.noAccountDescription', "If you already set up {0} elsewhere, [reload {0} configuration]({1}). Sign in to GitHub to use GitHub Copilot models.", displayName, reload));
}

/**
Expand Down Expand Up @@ -197,9 +218,6 @@ export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displ
};
}
const actions: IChatInputNotificationAction[] = [];
if (setup.setupDocsUrl) {
actions.push(action(localize('agentHost.sdkSetup.docsAction', "Setup Instructions"), AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID));
}
if (setup.signInProviderName) {
actions.push(action(localize('agentHost.sdkSetup.signInAction', "Sign in to {0}", setup.signInProviderName), AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID));
}
Expand All @@ -220,6 +238,7 @@ export function createAgentSdkSetupNotification(setup: IAgentSdkSetupInfo, displ

export const AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID = 'workbench.action.chat.agentHost.downloadAgentSdk';
export const AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID = 'workbench.action.chat.agentHost.openAgentSetupDocs';
export const AGENT_SDK_SETUP_RELOAD_COMMAND_ID = 'workbench.action.chat.agentHost.reloadAgentConfiguration';
export const AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID = 'workbench.action.chat.agentHost.signInToGitHubForAgent';
export const AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID = 'workbench.action.chat.agentHost.signInToAgent';

Expand All @@ -239,6 +258,7 @@ function registerAgentSdkSetupCommand(id: string, run: (setupService: IAgentSdkS

registerAgentSdkSetupCommand(AGENT_SDK_SETUP_DOWNLOAD_COMMAND_ID, (setupService, agent) => setupService.requestDownload(agent));
registerAgentSdkSetupCommand(AGENT_SDK_SETUP_OPEN_DOCS_COMMAND_ID, (setupService, agent) => setupService.openSetupDocs(agent));
registerAgentSdkSetupCommand(AGENT_SDK_SETUP_RELOAD_COMMAND_ID, (setupService, agent) => setupService.requestReload(agent));
registerAgentSdkSetupCommand(AGENT_SDK_SETUP_GITHUB_SIGN_IN_COMMAND_ID, (setupService, agent) => setupService.signInToGitHub(agent));
registerAgentSdkSetupCommand(AGENT_SDK_SETUP_SIGN_IN_COMMAND_ID, (setupService, agent) => setupService.signIn(agent));

Expand Down
Loading
Loading