Skip to content
19 changes: 19 additions & 0 deletions utils/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,14 @@ export declare function callWithTelemetryAndErrorHandlingSync<T>(callbackId: str
*/
export declare function callWithMaskHandling<T>(callback: () => Promise<T>, valueToMask: string): Promise<T>;

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

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

/**
* Creates and runs a generic prompt step. Use runQuickPickWizard to run quick pick steps
* @param context The action context
* @param wizardOptions The options used to construct the wizard
*/
export declare function runGenericPromptStep(context: PickExperienceContext, wizardOptions: IWizardOptions<AzureResourceQuickPickWizardContext>): Promise<void>;

/**
* Registers a namespace for common random utility functions
*/
Expand Down
33 changes: 27 additions & 6 deletions utils/src/copilot/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ import * as vscode from "vscode";
import { InvalidCopilotResponseError } from "../errors";

const languageModelPreference: { vendor: "copilot", family: string }[] = [
// not yet seen/available
// { vendor: "copilot", family: "gpt-4-turbo-preview" },
// seen/available
{ vendor: "copilot", family: "gpt-5-mini" },
{ vendor: "copilot", family: "claude-sonnet-4" },
{ vendor: "copilot", family: "claude-sonnet-4.5" },
{ vendor: "copilot", family: "gpt-4o" },
{ vendor: "copilot", family: "gpt-4-turbo" },
{ vendor: "copilot", family: "gpt-4" },
Expand All @@ -23,12 +23,21 @@ async function selectMostPreferredLm(): Promise<vscode.LanguageModelChat | undef
return lmsInPreferredOrder?.at(0);
}

export function createPrimaryPromptToGetSingleQuickPickInput(picks: string[], relevantContext?: string): string {
export function createPrimaryPromptToGetSingleQuickPickInput(picks: string[], placeholder?: string, relevantContext?: string): string {
const subscriptionId = relevantContext ? extractSubscriptionIdFromContext(relevantContext) : undefined;
const activityChildren = extractActivityChildren(relevantContext || '');
return `The User is asking you to choose an item based on the following information:
1. Choose from this list of picks ${picks.join(", ")}.
2. You must choose one item from the list
3. Use information from this context, if available, to make your decision: ${relevantContext ? relevantContext : "No context available"}
4. If there is an option to skipForNow, you can choose that option.
3. The placeholder contains the question being asked use this to help guide your input: ${placeholder ? placeholder : "No placeholder available"}
4. Use information from this context, if available, to make your decision: ${relevantContext ? relevantContext : "No context available"}
5. If there are activity children you must use them to them to inform your decision: ${activityChildren ? activityChildren : "No activity children available"}
Here are some examples on how to use activity children to inform your decision:
- If the placeholder is 'Select a container app' and there is an activity child 'Use container app: myContainerApp', you should choose the pick with the label 'myContainerApp' from the picks.
- If the placeholder is 'Select a container apps environment' and there is an activity child 'Use managed environment: myEnv', you should choose the pick with the label'myEnv' from the picks.
6. If there is an option to skipForNow, you can choose that option.
7. If the placeholder is 'Select subscription' use the subscription id to help guide your input. Use the subscription id: ${subscriptionId}
and match to the data portion of the picks which will have the id in the form of accounts/{accountId}/tenants/{tenantId}/subscriptions/{subscriptionID}. Match the {subscriptionId} portions to inform your decision.
Respond with a JSON object of the pick you have chosen. Do not respond in a conversational tone, only JSON. `;
}

Expand Down Expand Up @@ -94,3 +103,15 @@ export async function doCopilotInteraction(primaryPrompt: string): Promise<strin
function extractJsonString(raw: string): string {
return raw.replace(/```json\s*|```/g, '').trim();
}

function extractSubscriptionIdFromContext(context: string): string | undefined {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is going to be too strict for any other cloud (government, China, etc.)

function extractSubscriptionIdFromContext(context: string): string | undefined {
    const regex = /https:\/\/management\.[^/]+\/subscriptions\/([0-9a-fA-F-]{36})/;
    const match = context.match(regex);
    return match ? match[1] : undefined;
}

It's a little looser matching, but I don't think we need to be too strict since we kind of know what we're parsing.

const regex = /https:\/\/management\.azure\.com\/subscriptions\/([0-9a-fA-F-]{36})/;
const match = context.match(regex);
return match ? match[1] : undefined;
}

function extractActivityChildren(context: string): string {
const activityLog = JSON.parse(context) as { children?: unknown };

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should probably try/catch this just in case the shape is corrupted.

const children = activityLog.children;
return JSON.stringify(children);
}
11 changes: 11 additions & 0 deletions utils/src/executeCommandWithAddedContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.md in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from "vscode";
import * as types from '../index';

export function executeCommandWithAddedContext<T = unknown>(commandId: string, additionalContext: Partial<types.IActionContext>, ...args: unknown[]): Thenable<T> {
const metadata = { __injectedContext: additionalContext };

Check warning on line 9 in utils/src/executeCommandWithAddedContext.ts

View workflow job for this annotation

GitHub Actions / Build (utils) / Build

Object Literal Property name `__injectedContext` must match one of the following formats: camelCase, PascalCase
return vscode.commands.executeCommand(commandId, ...args, metadata);
}
Comment on lines +8 to +11

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no automated coverage for the new executeCommandWithAddedContext + registerCommand injection path. Given this changes how command args/context are interpreted, add unit tests to ensure: metadata is stripped from args, context is merged as intended, and normal command calls (including object args) aren’t mis-identified as metadata.

Copilot uses AI. Check for mistakes.
2 changes: 2 additions & 0 deletions utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export * from './dev/TestOutputChannel';
export * from './dev/TestUserInput';
export * from './DialogResponses';
export * from './errors';
export * from './executeCommandWithAddedContext';
export * from './extensionUserAgent';
export { registerUIExtensionVariables } from './extensionVariables';
export { addExtensionValueToMask, callWithMaskHandling, maskUserInfo, maskValue } from './masking';
Expand All @@ -31,6 +32,7 @@ export * from './pickTreeItem/GenericQuickPickStep';
export * from './pickTreeItem/quickPickAzureResource/QuickPickAzureResourceStep';
export * from './pickTreeItem/quickPickAzureResource/QuickPickAzureSubscriptionStep';
export * from './pickTreeItem/quickPickAzureResource/QuickPickGroupStep';
export * from './pickTreeItem/runGenericPromptStep';
export * from './pickTreeItem/runQuickPickWizard';
export * from './registerCommand';
export * from './registerCommandWithTreeNodeUnwrapping';
Expand Down
17 changes: 17 additions & 0 deletions utils/src/pickTreeItem/runGenericPromptStep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.md in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as types from '../../index';
import { AzureWizard } from '../wizard/AzureWizard';

export async function runGenericPromptStep(context: types.PickExperienceContext, wizardOptions?: types.IWizardOptions<types.AzureResourceQuickPickWizardContext>): Promise<void> {
const wizard = new AzureWizard(context, {
hideStepCount: true,
showLoadingPrompt: wizardOptions?.showLoadingPrompt ?? true,
...wizardOptions

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...wizardOptions is spread after showLoadingPrompt: wizardOptions?.showLoadingPrompt ?? true, so if wizardOptions.showLoadingPrompt is undefined it will overwrite the default back to undefined. Spread wizardOptions first and then apply defaults/overrides so defaults aren’t accidentally lost (same applies to hideStepCount).

Suggested change
hideStepCount: true,
showLoadingPrompt: wizardOptions?.showLoadingPrompt ?? true,
...wizardOptions
...wizardOptions,
hideStepCount: wizardOptions?.hideStepCount ?? true,
showLoadingPrompt: wizardOptions?.showLoadingPrompt ?? true

Copilot uses AI. Check for mistakes.
});

await wizard.prompt();
}
15 changes: 13 additions & 2 deletions utils/src/registerCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,18 @@ export function registerCommand(commandId: string, callback: (context: types.IAc
return await callWithTelemetryAndErrorHandling(
telemetryId || commandId,
async (context: types.IActionContext) => {
let injectedContext: Partial<types.IActionContext> | undefined;
if (args.length > 0) {
const metadata = args[args.length - 1];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just do a .find on the metadata instead of assuming that it'll always be at the end if the arg list? I think that'll make this command a little more resilient.


// Look for our metadata object at the end
if (metadata && typeof metadata === "object" && "__injectedContext" in metadata) {
injectedContext = metadata.__injectedContext as types.IActionContext;

// remove only the metadata
args.pop();
}

try {
await setTelemetryProperties(context, args);
} catch (e: unknown) {
Expand All @@ -52,8 +63,8 @@ export function registerCommand(commandId: string, callback: (context: types.IAc
context.telemetry.properties.telemetryError = error.message;
}
}

return callback(context, ...args);
const finalContext = injectedContext ? { ...context, ...injectedContext } : context;
return callback(finalContext, ...args);
}
);
}));
Expand Down
21 changes: 17 additions & 4 deletions utils/src/userInput/CopilotUserInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,18 @@ export class CopilotUserInput implements types.IAzureUserInput {
public async showQuickPick<T extends types.IAzureQuickPickItem<unknown>>(items: T[] | Thenable<T[]>, options: vscodeTypes.QuickPickOptions): Promise<T | T[]> {
let primaryPrompt: string;
const resolvedItems: T[] = await Promise.resolve(items);
const jsonItems: string[] = resolvedItems.map(item => JSON.stringify(item));

// Clean up items to only include label and description
const cleanedItems = this.cleanQuickPickItems(resolvedItems);
const jsonItems = cleanedItems.map(item => JSON.stringify(item));
try {
if (options.canPickMany) {
primaryPrompt = createPrimaryPromptToGetPickManyQuickPickInput(jsonItems, this._relevantContext);
const response = await doCopilotInteraction(primaryPrompt);
const jsonResponse: T[] = JSON.parse(response) as T[];
const picks = resolvedItems.filter(item => {
return jsonResponse.some(resp => JSON.stringify(resp) === JSON.stringify(item));
return jsonResponse.some(resp => JSON.stringify(resp.label) === JSON.stringify(item.label) &&
JSON.stringify(resp.description || '') === JSON.stringify(item.description || ''));
});
Comment on lines 104 to 107

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matching picks by JSON.stringify(label) and JSON.stringify(description) is unnecessary (these are strings) and adds overhead. More importantly, label+description may not uniquely identify an item; if an id is available (e.g., IAzureQuickPickItem.id), include it in the model output and use it for matching to avoid selecting the wrong item when there are duplicates.

Copilot uses AI. Check for mistakes.

if (!picks || picks.length === 0) {
Expand All @@ -109,11 +113,12 @@ export class CopilotUserInput implements types.IAzureUserInput {

return picks;
} else {
primaryPrompt = createPrimaryPromptToGetSingleQuickPickInput(jsonItems, this._relevantContext);
primaryPrompt = createPrimaryPromptToGetSingleQuickPickInput(jsonItems, options.placeHolder, this._relevantContext);
const response = await doCopilotInteraction(primaryPrompt);
const jsonResponse: T = JSON.parse(response) as T;
const pick = resolvedItems.find(item => {
return JSON.stringify(item) === JSON.stringify(jsonResponse);
return JSON.stringify(item.label) === JSON.stringify(jsonResponse.label) &&
JSON.stringify(item.description || '') === JSON.stringify(jsonResponse.description || '');
});

if (!pick) {
Expand All @@ -128,4 +133,12 @@ export class CopilotUserInput implements types.IAzureUserInput {
throw new InvalidCopilotResponseError();
}
}

private cleanQuickPickItems<T extends types.IAzureQuickPickItem<unknown>>(items: T[]): { label: string, description: string, id: string | undefined }[] {
return items.map(item => ({
label: item.label,
description: item.description || '',
id: (item.data && typeof item.data === 'object' && 'id' in item.data) ? String(item.data.id) : undefined
}));
}
}
Loading