Skip to content

Commit e2796aa

Browse files
Fix Copilot CLI mission control remote flows (#312240)
* Fix Copilot CLI mission control remote flows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refine mission control remote events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 59be36b commit e2796aa

7 files changed

Lines changed: 713 additions & 216 deletions

File tree

extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts

Lines changed: 179 additions & 202 deletions
Large diffs are not rendered by default.

extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts

Lines changed: 210 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import type { internal, LocalSession, LocalSessionMetadata, Session, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
6+
import type { AutoModeSessionManager as SDKAutoModeSessionManager, AutoModeSessionResult, internal, LocalSession, LocalSessionMetadata, Session, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
77
import * as l10n from '@vscode/l10n';
88
import { createReadStream } from 'node:fs';
99
import { devNull } from 'node:os';
@@ -53,6 +53,195 @@ import { INTEGRATION_ID } from '../../../../platform/endpoint/common/licenseAgre
5353

5454

5555
const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile';
56+
const AUTO_MODE_REFRESH_LEAD_TIME_MS = 300 * 1000;
57+
58+
type SDKPackage = Awaited<ReturnType<ICopilotCLISDK['getPackage']>>;
59+
type AutoModeResolveArgs = Parameters<SDKAutoModeSessionManager['resolve']>[0];
60+
type AutoModeResolveResult = Awaited<ReturnType<SDKAutoModeSessionManager['resolve']>>;
61+
type AutoModeListener = Parameters<SDKAutoModeSessionManager['subscribe']>[0];
62+
63+
class AutoModeSessionManagerCompat {
64+
65+
private current: AutoModeSessionResult | undefined;
66+
private previousConcreteModel: string | undefined;
67+
private inflight: Promise<AutoModeResolveResult> | undefined;
68+
private readonly listeners = new Set<AutoModeListener>();
69+
70+
constructor(private readonly sdkPackage: Pick<SDKPackage, 'AutoModeUnavailableError' | 'AutoModeUnsupportedError' | 'acquireAutoModeSession' | 'isAutoModel' | 'refreshAutoModeSession'>) { }
71+
72+
recordPreviousConcreteModel(modelId: string | undefined): void {
73+
if (modelId && !this.sdkPackage.isAutoModel(modelId)) {
74+
this.previousConcreteModel = modelId;
75+
}
76+
}
77+
78+
getLastResolved(): string | undefined {
79+
return this.current?.selectedModel;
80+
}
81+
82+
getDiscountPercent(): number | undefined {
83+
const discountedCosts = this.current?.discountedCosts;
84+
if (!discountedCosts) {
85+
return undefined;
86+
}
87+
88+
const selectedModelDiscount = this.current?.selectedModel ? discountedCosts[this.current.selectedModel] : undefined;
89+
if (selectedModelDiscount !== undefined) {
90+
return Math.round(selectedModelDiscount * 100);
91+
}
92+
93+
const allDiscounts = Object.values(discountedCosts);
94+
if (allDiscounts.length === 0) {
95+
return undefined;
96+
}
97+
98+
return Math.round((allDiscounts.reduce((sum, discount) => sum + discount, 0) / allDiscounts.length) * 100);
99+
}
100+
101+
getPreviousConcreteModel(): string | undefined {
102+
return this.previousConcreteModel;
103+
}
104+
105+
subscribe(listener: AutoModeListener): () => void {
106+
this.listeners.add(listener);
107+
return () => {
108+
this.listeners.delete(listener);
109+
};
110+
}
111+
112+
async resolve(args: AutoModeResolveArgs): Promise<AutoModeResolveResult> {
113+
if (this.isFresh() && this.current) {
114+
const current = this.current;
115+
this.applySessionToken(args.settings, current.sessionToken);
116+
return { modelId: current.selectedModel, sessionToken: current.sessionToken };
117+
}
118+
119+
if (this.inflight) {
120+
const resolved = await this.inflight;
121+
if (resolved) {
122+
this.applySessionToken(args.settings, resolved.sessionToken);
123+
}
124+
125+
return resolved;
126+
}
127+
128+
this.inflight = this.doResolve(args).finally(() => {
129+
this.inflight = undefined;
130+
});
131+
132+
return this.inflight;
133+
}
134+
135+
clear(settings?: AutoModeResolveArgs['settings']): void {
136+
this.current = undefined;
137+
if (settings) {
138+
this.clearSessionToken(settings);
139+
}
140+
this.notify();
141+
}
142+
143+
handleModelChange(prevModel: string | undefined, nextModel: string, settings?: AutoModeResolveArgs['settings']): void {
144+
if (this.sdkPackage.isAutoModel(nextModel) && !this.sdkPackage.isAutoModel(prevModel)) {
145+
this.recordPreviousConcreteModel(prevModel);
146+
} else if (!this.sdkPackage.isAutoModel(nextModel) && this.sdkPackage.isAutoModel(prevModel)) {
147+
this.clear(settings);
148+
}
149+
}
150+
151+
private notify(): void {
152+
const resolvedModel = this.current?.selectedModel;
153+
const discountPercent = this.getDiscountPercent();
154+
for (const listener of this.listeners) {
155+
try {
156+
listener(resolvedModel, discountPercent);
157+
} catch {
158+
// Ignore listener failures to mirror the SDK manager behavior.
159+
}
160+
}
161+
}
162+
163+
private async doResolve(args: AutoModeResolveArgs): Promise<AutoModeResolveResult> {
164+
const { logger, settings } = args;
165+
166+
if (this.current) {
167+
try {
168+
const refreshed = await this.sdkPackage.refreshAutoModeSession({ ...args, existingToken: this.current.sessionToken });
169+
this.current = refreshed;
170+
this.applySessionToken(settings, refreshed.sessionToken);
171+
this.notify();
172+
return { modelId: refreshed.selectedModel, sessionToken: refreshed.sessionToken };
173+
} catch (error) {
174+
if (this.isUnauthorizedError(error)) {
175+
logger.debug('Auto-mode refresh unauthorized; acquiring a new session');
176+
} else if (error instanceof this.sdkPackage.AutoModeUnsupportedError) {
177+
logger.debug(`Auto-mode refresh unsupported: ${error.message}`);
178+
this.current = undefined;
179+
this.notify();
180+
return undefined;
181+
} else if (error instanceof this.sdkPackage.AutoModeUnavailableError) {
182+
logger.debug(`Auto-mode unavailable during refresh: ${error.message}`);
183+
this.current = undefined;
184+
this.notify();
185+
return undefined;
186+
} else {
187+
logger.debug(`Auto-mode refresh failed; reusing last token until expiry: ${this.formatError(error)}`);
188+
this.applySessionToken(settings, this.current.sessionToken);
189+
return { modelId: this.current.selectedModel, sessionToken: this.current.sessionToken };
190+
}
191+
}
192+
}
193+
194+
try {
195+
const acquired = await this.sdkPackage.acquireAutoModeSession(args);
196+
this.current = acquired;
197+
this.applySessionToken(settings, acquired.sessionToken);
198+
this.notify();
199+
logger.debug(`Auto-mode session acquired: selected_model=${acquired.selectedModel}${acquired.expiresAt ? ` expires_at=${acquired.expiresAt}` : ''}`);
200+
return { modelId: acquired.selectedModel, sessionToken: acquired.sessionToken };
201+
} catch (error) {
202+
if (error instanceof this.sdkPackage.AutoModeUnsupportedError) {
203+
logger.debug(`Auto-mode unsupported: ${error.message}`);
204+
return undefined;
205+
}
206+
207+
if (error instanceof this.sdkPackage.AutoModeUnavailableError) {
208+
logger.debug(`Auto-mode unavailable: ${error.message}`);
209+
return undefined;
210+
}
211+
212+
logger.debug(`Auto-mode acquire failed: ${this.formatError(error)}`);
213+
return undefined;
214+
}
215+
}
216+
217+
private isFresh(): boolean {
218+
return this.current ? (this.current.expiresAt ? this.current.expiresAt * 1000 - Date.now() > AUTO_MODE_REFRESH_LEAD_TIME_MS : true) : false;
219+
}
220+
221+
private isUnauthorizedError(error: unknown): error is { kind: 'unauthorized' } {
222+
return typeof error === 'object' && error !== null && 'kind' in error && error.kind === 'unauthorized';
223+
}
224+
225+
private applySessionToken(settings: AutoModeResolveArgs['settings'], sessionToken: string): void {
226+
if (!settings) {
227+
return;
228+
}
229+
230+
settings.api ??= {};
231+
settings.api.copilot ??= {};
232+
settings.api.copilot.capiSessionToken = sessionToken;
233+
}
234+
235+
private clearSessionToken(settings: AutoModeResolveArgs['settings']): void {
236+
if (settings?.api?.copilot) {
237+
delete settings.api.copilot.capiSessionToken;
238+
}
239+
}
240+
241+
private formatError(error: unknown): string {
242+
return error instanceof Error ? error.message : String(error);
243+
}
244+
}
56245

57246
export interface ICopilotCLISessionItem {
58247
readonly id: string;
@@ -179,7 +368,8 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
179368
this.monitorSessionFiles();
180369
this._sessionManager = new Lazy<Promise<internal.LocalSessionManager>>(async () => {
181370
try {
182-
const { internal, createLocalFeatureFlagService, AutoModeSessionManager } = await this.getSDKPackage();
371+
const sdkPackage = await this.getSDKPackage();
372+
const { internal, createLocalFeatureFlagService } = sdkPackage;
183373
// Always enable SDK OTel so the debug panel receives native spans via the bridge.
184374
// When user OTel is disabled, we force file exporter to /dev/null so the SDK
185375
// creates OtelSessionTracker (for debug panel) but doesn't export to any collector.
@@ -211,7 +401,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
211401
return new internal.LocalSessionManager({
212402
featureFlagService: createLocalFeatureFlagService(),
213403
telemetryService: new internal.NoopTelemetryService(),
214-
autoModeManager: new AutoModeSessionManager(),
404+
autoModeManager: this.createAutoModeManager(sdkPackage),
215405
}, { flushDebounceMs: undefined, settings: undefined, version: undefined });
216406
}
217407
catch (error) {
@@ -222,9 +412,23 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
222412
this._sessionTracker = this.instantiationService.createInstance(CopilotCLISessionWorkspaceTracker);
223413
}
224414

225-
private async getSDKPackage() {
226-
const { internal, LocalSession, createLocalFeatureFlagService, AutoModeSessionManager } = await this.copilotCLISDK.getPackage();
227-
return { internal, LocalSession, createLocalFeatureFlagService, AutoModeSessionManager };
415+
private async getSDKPackage(): Promise<SDKPackage> {
416+
return this.copilotCLISDK.getPackage();
417+
}
418+
419+
private createAutoModeManager(sdkPackage: SDKPackage): SDKAutoModeSessionManager {
420+
if (typeof sdkPackage.AutoModeSessionManager === 'function') {
421+
try {
422+
return new sdkPackage.AutoModeSessionManager();
423+
} catch (error) {
424+
if (!(error instanceof TypeError)) {
425+
throw error;
426+
}
427+
}
428+
}
429+
430+
this.logService.warn('Failed to construct SDK AutoModeSessionManager, using compatibility fallback.');
431+
return new AutoModeSessionManagerCompat(sdkPackage) as unknown as SDKAutoModeSessionManager;
228432
}
229433

230434
getSessionWorkingDirectory(sessionId: string): Uri | undefined {

extensions/copilot/src/extension/chatSessions/copilotcli/node/missionControlApiClient.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,11 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
7+
import { INTEGRATION_ID } from '../../../../platform/endpoint/common/licenseAgreement';
78
import { PermissiveAuthRequiredError } from '../../../../platform/github/common/githubService';
89
import { ILogService } from '../../../../platform/log/common/logService';
910
import { IFetcherService } from '../../../../platform/networking/common/fetcherService';
1011

11-
/** Integration id for requests originating from the Copilot CLI /remote feature. */
12-
const INTEGRATION_ID = 'copilot-developer-cli';
13-
1412
/** Base path for Mission Control (agent session) endpoints. */
1513
const SESSIONS_PATH = '/agents/sessions';
1614

@@ -72,9 +70,9 @@ export interface McAuthOptions {
7270
export class MissionControlApiClient {
7371

7472
constructor(
75-
private readonly _authService: IAuthenticationService,
76-
private readonly _fetcherService: IFetcherService,
77-
private readonly _logService: ILogService,
73+
@IAuthenticationService private readonly _authService: IAuthenticationService,
74+
@IFetcherService private readonly _fetcherService: IFetcherService,
75+
@ILogService private readonly _logService: ILogService,
7876
) { }
7977

8078
/**

extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { ICopilotCLISDK } from '../copilotCli';
4141
import { CopilotCLISession, ICopilotCLISession } from '../copilotcliSession';
4242
import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCLISessionItem } from '../copilotcliSessionService';
4343
import { CopilotCLIMCPHandler } from '../mcpHandler';
44+
import { MissionControlApiClient } from '../missionControlApiClient';
4445
import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHelpers';
4546
import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullCopilotCLIModels, NullICopilotCLIImageSupport } from './testHelpers';
4647

@@ -151,6 +152,14 @@ describe('CopilotCLISessionService', () => {
151152
}
152153
}();
153154
}
155+
if (ctor === MissionControlApiClient) {
156+
return {
157+
createSession: vi.fn(),
158+
submitEvents: vi.fn(),
159+
getPendingCommands: vi.fn(async () => []),
160+
deleteSession: vi.fn(async () => { }),
161+
};
162+
}
154163
return disposables.add(new CopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new MockGitService(), { _serviceBrand: undefined } as any));
155164
}
156165
} as unknown as IInstantiationService;
@@ -174,6 +183,44 @@ describe('CopilotCLISessionService', () => {
174183

175184
// --- Tests ----------------------------------------------------------------------------------
176185

186+
it('falls back to a compatibility auto-mode manager when the SDK export is not constructable', async () => {
187+
const sdk = {
188+
getPackage: vi.fn(async () => ({
189+
internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } },
190+
LocalSession: MockLocalSession,
191+
createLocalFeatureFlagService: () => ({}),
192+
AutoModeSessionManager: {} as never,
193+
acquireAutoModeSession: vi.fn(async () => { throw new Error('unexpected auto-mode acquire'); }),
194+
refreshAutoModeSession: vi.fn(async () => { throw new Error('unexpected auto-mode refresh'); }),
195+
AutoModeUnavailableError: class extends Error { },
196+
AutoModeUnsupportedError: class extends Error { },
197+
isAutoModel: (model: string | undefined) => model === 'auto',
198+
noopTelemetryBinder: {},
199+
})),
200+
getRequestId: vi.fn(() => undefined),
201+
} as unknown as ICopilotCLISDK;
202+
203+
const services = disposables.add(createExtensionUnitTestingServices());
204+
const accessor = services.createTestingAccessor();
205+
const configurationService = accessor.get(IConfigurationService);
206+
const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;
207+
const nullMcpServer = disposables.add(new NullMcpService());
208+
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
209+
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
210+
override async summarize(): Promise<string | undefined> { return undefined; }
211+
}();
212+
const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));
213+
214+
const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager & { opts: { autoModeManager: Record<string, unknown> } };
215+
216+
expect(localManager.opts.autoModeManager).toEqual(expect.objectContaining({
217+
resolve: expect.any(Function),
218+
clear: expect.any(Function),
219+
handleModelChange: expect.any(Function),
220+
subscribe: expect.any(Function),
221+
}));
222+
});
223+
177224
describe('CopilotCLISessionService.createSession', () => {
178225
it('get session will return the same session created using createSession', async () => {
179226
const session = await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);

0 commit comments

Comments
 (0)