Skip to content

Commit 29bb65b

Browse files
authored
utils: Changes to enable re running commands with copilot (#2136)
* add re run changes * add subscription to ActivityAttributes * changes * fix build * oops * revert * small changes * small changes * requested changes * bump version * make copilot sdk optional * remove * try removing skipLibCheck
1 parent 9047708 commit 29bb65b

11 files changed

Lines changed: 1719 additions & 904 deletions

File tree

utils/index.d.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import type { Environment } from '@azure/ms-rest-azure-env';
99
import type { AzExtResourceType, AzureResource, AzureSubscription, ResourceModelBase } from '@microsoft/vscode-azureresources-api';
1010
import type * as duration from 'dayjs/plugin/duration';
11+
import type * as vscode from 'vscode';
1112
import type * as vscodeTypes from 'vscode';
1213
import { AuthenticationSession, AuthenticationWwwAuthenticateRequest, CancellationToken, CancellationTokenSource, Command, Disposable, Event, ExtensionContext, FileChangeEvent, FileChangeType, FileStat, FileSystemProvider, FileType, InputBoxOptions, LanguageModelToolInvocationOptions, LanguageModelToolInvocationPrepareOptions, LanguageModelToolResult, LogLevel, LogOutputChannel, MarkdownString, MessageItem, MessageOptions, OpenDialogOptions, OutputChannel, PreparedToolInvocation, Progress, ProviderResult, QuickPickItem, TelemetryTrustedValue, TextDocumentShowOptions, ThemeIcon, TreeDataProvider, TreeItem, TreeItemCollapsibleState, TreeView, Uri, QuickPickOptions as VSCodeQuickPickOptions, WorkspaceFolder, WorkspaceFolderPickOptions } from 'vscode';
1314
import { TargetPopulation } from 'vscode-tas-client';
@@ -707,6 +708,14 @@ export declare function callWithTelemetryAndErrorHandlingSync<T>(callbackId: str
707708
*/
708709
export declare function callWithMaskHandling<T>(callback: () => Promise<T>, valueToMask: string): Promise<T>;
709710

711+
/**
712+
* A wrapper for the VS Code executeCommand command
713+
* Used to pass in additional context when executing a command
714+
* @param commandId Identifier of the command to execute
715+
* @param additionalContext Full context including addtional properties
716+
* @param args Parameters passed to the command function
717+
*/
718+
export declare function executeCommandWithAddedContext<T>(commandId: string, additionalContext: Partial<IActionContext>, ...args: unknown[]): Thenable<T>;
710719
/**
711720
* Add an extension-wide value to mask for all commands
712721
* This will apply to telemetry and "Report Issue", but _not_ VS Code UI (i.e. the error notification or output channel)
@@ -1413,6 +1422,10 @@ export interface ActivityAttributes {
14131422
* Any Azure resource envelope related to the command or activity being run
14141423
*/
14151424
azureResource?: unknown;
1425+
/**
1426+
* Optional Azure subscription to be added
1427+
*/
1428+
subscription?: AzureSubscription;
14161429

14171430
// For additional one-off properties that could be useful for Copilot
14181431
[key: string]: unknown;
@@ -2383,6 +2396,13 @@ export declare class QuickPickAzureResourceStep extends GenericQuickPickStep<Azu
23832396
export declare function runQuickPickWizard<TPick>(context: PickExperienceContext, wizardOptions?: IWizardOptions<AzureResourceQuickPickWizardContext>, startingNode?: unknown): Promise<TPick>;
23842397
//#endregion
23852398

2399+
/**
2400+
* Creates and runs a generic prompt step. Use runQuickPickWizard to run quick pick steps
2401+
* @param context The action context
2402+
* @param wizardOptions The options used to construct the wizard
2403+
*/
2404+
export declare function runGenericPromptStep(context: PickExperienceContext, wizardOptions: IWizardOptions<AzureResourceQuickPickWizardContext>): Promise<void>;
2405+
23862406
/**
23872407
* Registers a namespace for common random utility functions
23882408
*/
@@ -2909,6 +2929,22 @@ export declare function runWithInputs<T>(callbackId: string, inputs: (string | R
29092929
*/
29102930
export declare function testGlobalSetup(): UIExtensionVariables;
29112931

2932+
/**
2933+
* Disposes of copilot session created by `CopilotUserInput`
2934+
* Should be called after commands using `CopilotUserInput` to prevent any lingering copilot sessions
2935+
*/
2936+
export function disposeCopilotSession(): Promise<void>;
2937+
2938+
/**
2939+
* Checks if the context is using `CopilotUserInput`
2940+
*/
2941+
export function isCopilotUserInput(context: IActionContext): boolean;
2942+
2943+
/**
2944+
* When setting the ui to `CopilotUserInput`, call this function so that the context can be properly identified
2945+
* @param context The context to mark as using `CopilotUserInput`
2946+
*/
2947+
export function markAsCopilotUserInput(context: IActionContext, relevantContext?: string, getLoadingView?: () => vscode.WebviewPanel | undefined): void;
29122948
/**
29132949
* Checks if the Copilot CLI is installed, and if not, prompts the user to install it.
29142950
* If the user agrees to install it, this will attempt to install the Copilot CLI automatically.

utils/package-lock.json

Lines changed: 1493 additions & 834 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

utils/package.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@microsoft/vscode-azext-utils",
33
"author": "Microsoft Corporation",
4-
"version": "4.0.5",
4+
"version": "4.0.6",
55
"description": "Common UI tools for developing Azure extensions for VS Code",
66
"tags": [
77
"azure",
@@ -45,7 +45,13 @@
4545
"vscode-tas-client": "^0.1.84"
4646
},
4747
"peerDependencies": {
48-
"@azure/ms-rest-azure-env": "^2.0.0"
48+
"@azure/ms-rest-azure-env": "^2.0.0",
49+
"@github/copilot-sdk": "0.1.32"
50+
},
51+
"peerDependenciesMeta": {
52+
"@github/copilot-sdk": {
53+
"optional": true
54+
}
4955
},
5056
"devDependencies": {
5157
"@microsoft/vscode-azext-eng": "1.0.0-alpha.12",
@@ -54,6 +60,7 @@
5460
"@types/semver": "^7.7.1",
5561
"@types/vscode": "1.105.0",
5662
"@vscode/test-cli": "*",
57-
"@vscode/test-electron": "*"
63+
"@vscode/test-electron": "*",
64+
"@github/copilot-sdk": "*"
5865
}
5966
}

utils/src/copilot/copilot.ts

Lines changed: 86 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,35 @@
33
* Licensed under the MIT License. See License.md in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6-
import * as vscode from "vscode";
6+
import type { CopilotClient, CopilotSession } from "@github/copilot-sdk";
7+
import type * as vscode from "vscode";
78
import { InvalidCopilotResponseError } from "../errors";
89

9-
const languageModelPreference: { vendor: "copilot", family: string }[] = [
10-
// not yet seen/available
11-
// { vendor: "copilot", family: "gpt-4-turbo-preview" },
12-
// seen/available
13-
{ vendor: "copilot", family: "gpt-4o" },
14-
{ vendor: "copilot", family: "gpt-4-turbo" },
15-
{ vendor: "copilot", family: "gpt-4" },
16-
{ vendor: "copilot", family: "gpt-3.5-turbo" },
17-
];
18-
19-
async function selectMostPreferredLm(): Promise<vscode.LanguageModelChat | undefined> {
20-
const lms = await vscode.lm.selectChatModels({ vendor: "copilot" });
21-
const lmsInPreferredOrder = (lms || [])
22-
.filter((lm) => languageModelPreference.some((pref) => pref.family === lm.family && pref.vendor === lm.vendor))
23-
.sort((a, b) => languageModelPreference.findIndex((pref) => pref.family === a.family && pref.vendor === a.vendor) - languageModelPreference.findIndex((pref) => pref.family === b.family && pref.vendor === b.vendor));
24-
return lmsInPreferredOrder?.at(0);
10+
let client: CopilotClient | undefined;
11+
let session: CopilotSession | undefined;
12+
13+
async function loadCopilotSdk(): Promise<typeof import("@github/copilot-sdk")> {
14+
return await import("@github/copilot-sdk");
2515
}
2616

27-
export function createPrimaryPromptToGetSingleQuickPickInput(picks: string[], relevantContext?: string): string {
28-
return `The User is asking you to choose an item based on the following information:
29-
1. Choose from this list of picks ${picks.join(", ")}.
30-
2. You must choose one item from the list
31-
3. Use information from this context, if available, to make your decision: ${relevantContext ? relevantContext : "No context available"}
32-
4. If there is an option to skipForNow, you can choose that option.
33-
Respond with a JSON object of the pick you have chosen. Do not respond in a conversational tone, only JSON. `;
17+
function getCopilotCliPath(): string {
18+
return require.resolve(`@github/copilot-${process.platform}-${process.arch}`);
19+
}
20+
21+
export function createPrimaryPromptToGetSingleQuickPickInput(picks: string[], placeholder?: string): string {
22+
return `
23+
Task: choose one pick.
24+
25+
Input:
26+
picks: ${JSON.stringify(picks)}
27+
placeholder: ${placeholder ?? null}
28+
29+
Rules:
30+
- If the picks represent container image tags, always prefer the newest available image tag.
31+
32+
Output:
33+
Return ONLY the JSON object with fields "label" and "description".
34+
`;
3435
}
3536

3637
export function createPrimaryPromptToGetPickManyQuickPickInput(picks: string[], relevantContext?: string): string {
@@ -63,35 +64,68 @@ export function createPrimaryPromptForWorkspaceFolderPick(folders: readonly vsco
6364
Respond with the workspace folder you have chosen. Do not respond in a conversational tone. `;
6465
}
6566

66-
export async function doCopilotInteraction(primaryPrompt: string): Promise<string> {
67-
let messages: vscode.LanguageModelChatMessage[] = [];
68-
const lm: vscode.LanguageModelChat | undefined = await selectMostPreferredLm();
69-
70-
messages = [
71-
new vscode.LanguageModelChatMessage(vscode.LanguageModelChatMessageRole.User, primaryPrompt)
72-
];
73-
74-
if (lm !== undefined) {
75-
const chatRequestOptions = { justification: `Access to Copilot for the @azure agent.` };
76-
const request = await lm.sendRequest(messages, chatRequestOptions);
77-
const fragments: string[] = [];
78-
try {
79-
// Consume the stream and collect fragments
80-
for await (const fragment of request.text) {
81-
fragments.push(fragment);
82-
}
83-
84-
// Combine fragments into a single string
85-
const responseText = fragments.join("");
86-
const cleanedResponse = extractJsonString(responseText);
87-
return cleanedResponse;
88-
} catch {
89-
throw new InvalidCopilotResponseError();
90-
}
67+
export async function doGithubCopilotInteraction(primaryPrompt: string, relevantContext?: string): Promise<string> {
68+
const session = await getCopilotSession(relevantContext);
69+
const response = await session.sendAndWait({
70+
prompt: primaryPrompt,
71+
mode: "immediate"
72+
});
73+
74+
if (!response || !response.data) {
75+
throw new InvalidCopilotResponseError();
76+
}
77+
78+
return response?.data.content;
79+
}
80+
81+
export async function getCopilotSession(relevantContext?: string): Promise<CopilotSession> {
82+
if (session) {
83+
return session;
84+
}
85+
86+
const { CopilotClient } = await loadCopilotSdk();
87+
client = new CopilotClient({ cliPath: getCopilotCliPath() });
88+
session = await client.createSession({
89+
onPermissionRequest: () => ({ kind: "approved" })
90+
});
91+
const activityChildren = extractActivityChildren(relevantContext || '');
92+
const subscriptionId = relevantContext ? extractSubscriptionIdFromContext(relevantContext) : undefined;
93+
94+
await session.sendAndWait({
95+
mode: "immediate",
96+
prompt: `Picker. Choose one. Never explain your reasoning. Never use markdown. Output: {"label":"X","description":"Y"}
97+
98+
Rules (priority)
99+
1. Match activityChildren "Use X" → pick X
100+
2. Match subscriptionId → pick.data /subscriptions/{id}
101+
3. Else skipForNow
102+
103+
Context: ${JSON.stringify({ activityChildren, subscriptionId, relevantContext })}`
104+
});
105+
106+
return session;
107+
}
108+
109+
export async function disposeCopilotSession(): Promise<void> {
110+
if (client) {
111+
await client.stop();
112+
client = undefined;
113+
session = undefined;
91114
}
92-
return "";
93115
}
94116

95-
function extractJsonString(raw: string): string {
96-
return raw.replace(/```json\s*|```/g, '').trim();
117+
function extractSubscriptionIdFromContext(context: string): string | undefined {
118+
const regex = /https:\/\/management\.[^/]+\/subscriptions\/([0-9a-fA-F-]{36})/;
119+
const match = context.match(regex);
120+
return match ? match[1] : undefined;
121+
}
122+
123+
function extractActivityChildren(context: string): string | undefined {
124+
try {
125+
const activityLog = JSON.parse(context) as { children?: unknown };
126+
const children = activityLog.children;
127+
return JSON.stringify(children);
128+
} catch {
129+
return undefined;
130+
}
97131
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.md in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
import * as vscode from "vscode";
6+
import * as types from '../index';
7+
8+
export function executeCommandWithAddedContext<T = unknown>(commandId: string, additionalContext: Partial<types.IActionContext>, ...args: unknown[]): Thenable<T> {
9+
const metadata = { __injectedContext: additionalContext };
10+
return vscode.commands.executeCommand(commandId, ...args, metadata);
11+
}

utils/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export { createAzExtLogOutputChannel, createAzExtOutputChannel } from './AzExtOu
99
export * from './AzExtTreeFileSystem';
1010
export * from './callWithTelemetryAndErrorHandling';
1111
export { activityErrorContext, activityFailContext, activityFailIcon, activityInfoContext, activityInfoIcon, activityProgressContext, activityProgressIcon, activitySuccessContext, activitySuccessIcon } from './constants';
12+
export * from './copilot/copilot';
1213
export * from './copilot/installCopilotCli';
1314
export * from './createApiProvider';
1415
export { createExperimentationService } from './createExperimentationService';
@@ -18,6 +19,7 @@ export * from './dev/TestOutputChannel';
1819
export * from './dev/TestUserInput';
1920
export * from './DialogResponses';
2021
export * from './errors';
22+
export * from './executeCommandWithAddedContext';
2123
export * from './extensionUserAgent';
2224
export { registerUIExtensionVariables } from './extensionVariables';
2325
export { addExtensionValueToMask, callWithMaskHandling, maskUserInfo, maskValue } from './masking';
@@ -33,6 +35,7 @@ export * from './pickTreeItem/GenericQuickPickStep';
3335
export * from './pickTreeItem/quickPickAzureResource/QuickPickAzureResourceStep';
3436
export * from './pickTreeItem/quickPickAzureResource/QuickPickAzureSubscriptionStep';
3537
export * from './pickTreeItem/quickPickAzureResource/QuickPickGroupStep';
38+
export * from './pickTreeItem/runGenericPromptStep';
3639
export * from './pickTreeItem/runQuickPickWizard';
3740
export * from './registerCommand';
3841
export * from './registerCommandWithTreeNodeUnwrapping';
@@ -55,6 +58,7 @@ export * from './utils/activityUtils';
5558
export * from './utils/AzExtFsExtra';
5659
export * from './utils/AzureResourceIdTelemetry';
5760
export * from './utils/contextUtils';
61+
export * from './utils/copilotUtils';
5862
export * from './utils/credentialUtils';
5963
export * from './utils/dateTimeUtils';
6064
export * from './utils/findFreePort';
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.md in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as types from '../../index';
7+
import { AzureWizard } from '../wizard/AzureWizard';
8+
9+
export async function runGenericPromptStep(context: types.PickExperienceContext, wizardOptions?: types.IWizardOptions<types.AzureResourceQuickPickWizardContext>): Promise<void> {
10+
const wizard = new AzureWizard(context, {
11+
...wizardOptions,
12+
hideStepCount: wizardOptions?.hideStepCount ?? true,
13+
showLoadingPrompt: wizardOptions?.showLoadingPrompt ?? true
14+
});
15+
16+
await wizard.prompt();
17+
}

utils/src/registerCommand.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,20 @@ export function registerCommand(commandId: string, callback: (context: types.IAc
4242
return await callWithTelemetryAndErrorHandling(
4343
telemetryId || commandId,
4444
async (context: types.IActionContext) => {
45+
let injectedContext: Partial<types.IActionContext> | undefined;
4546
if (args.length > 0) {
47+
const metadata = args.find(
48+
(arg) => arg !== null && typeof arg === "object" && Object.prototype.hasOwnProperty.call(arg, "__injectedContext")
49+
);
50+
51+
if (metadata) {
52+
const candidate = (metadata as Record<string, unknown>).__injectedContext;
53+
if (candidate && typeof candidate === "object") {
54+
injectedContext = candidate as Partial<types.IActionContext>;
55+
}
56+
args.splice(args.indexOf(metadata), 1); // remove only the metadata
57+
}
58+
4659
try {
4760
await setTelemetryProperties(context, args);
4861
} catch (e: unknown) {
@@ -52,8 +65,8 @@ export function registerCommand(commandId: string, callback: (context: types.IAc
5265
context.telemetry.properties.telemetryError = error.message;
5366
}
5467
}
55-
56-
return callback(context, ...args);
68+
const finalContext = injectedContext ? { ...context, ...injectedContext } : context;
69+
return callback(finalContext, ...args);
5770
}
5871
);
5972
}));

0 commit comments

Comments
 (0)