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
1 change: 0 additions & 1 deletion build/azure-pipelines/copilot/setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ steps:
- script: npm ci --ignore-scripts --no-workspaces
workingDirectory: $(Build.SourcesDirectory)
displayName: Install vscode dependencies
condition: and(succeeded(), ne(variables.BUILD_CACHE_RESTORED, 'true'))

- script: npm ci
workingDirectory: $(Build.SourcesDirectory)/extensions/copilot
Expand Down
3 changes: 3 additions & 0 deletions build/azure-pipelines/product-copilot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ jobs:
value: true
- name: Codeql.SkipTaskAutoInjection
value: true
# Bundled into vscode product, so externalize shared deps from extensions/node_modules/.
- name: VSCODE_USE_SHARED_EXTENSION_DEPS
value: true
templateContext:
outputParentDirectory: $(Build.ArtifactStagingDirectory)
outputs:
Expand Down
57 changes: 57 additions & 0 deletions cli/src/tunnels/agent_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,31 @@ pub const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
/// CLI and may therefore advertise the management RPC method to clients.
pub const MANAGEMENT_SOCKET_ENV: &str = "VSCODE_AGENT_HOST_MANAGEMENT_SOCKET";

/// Environment variable holding a commit SHA used to override the agent
/// host version the *first* time it is resolved. When set, the agent host
/// is initially downloaded and started at this commit; subsequent upgrades
/// still resolve the real latest version. Intended for testing the upgrade
/// flow.
pub const INITIAL_AGENT_HOST_VERSION_ENV: &str = "VSCODE_CLI_INITIAL_AH_VERSION";

/// Reads {@link INITIAL_AGENT_HOST_VERSION_ENV}, returning the commit SHA
/// override if it is set to a non-empty value. The value is restricted to
/// hex digits so it can't smuggle path separators (`/`, `..`) or other
/// characters into the URL and filesystem paths derived from the commit.
fn initial_agent_host_version() -> Option<String> {
match std::env::var(INITIAL_AGENT_HOST_VERSION_ENV) {
Ok(v) => {
let v = v.trim();
if !v.is_empty() && v.chars().all(|c| c.is_ascii_hexdigit()) {
Some(v.to_string())
} else {
None
}
}
_ => None,
}
}

/// Delay between sending the upgrade response and actually killing the
/// running server. Lets the response hop back through the CLI proxy and
/// reach the requesting client before the transport drops out from under
Expand Down Expand Up @@ -363,6 +388,15 @@ impl AgentHostManager {
}
}

// On the very first resolution, an explicit initial version override
// (used to test the upgrade flow) must win over the generic cached
// fallback below so the requested commit is what we download and start.
if self.latest_release.lock().await.is_none() && initial_agent_host_version().is_some() {
let release = self.get_latest_release().await?;
let dir = self.ensure_downloaded(&release).await?;
return Ok((release, dir));
}

let quality = VSCODE_CLI_QUALITY
.ok_or(CodeError::UpdatesNotConfigured("no configured quality"))
.and_then(|q| {
Expand Down Expand Up @@ -440,6 +474,29 @@ impl AgentHostManager {
Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality"))
})?;

// The first time we resolve a version, honor an explicit commit
// override so the upgrade flow can be tested: the agent host is
// initially downloaded and started at this commit, and a subsequent
// upgrade (which calls this method again, with `latest` already set)
// still resolves the real latest version.
if latest.is_none() {
if let Some(commit) = initial_agent_host_version() {
let release = Release {
name: String::new(),
commit,
platform: self.platform,
target: TargetKind::Server,
quality,
};
info!(
self.log,
"Using initial agent host version override: {}", release.commit
);
*latest = Some((now, release.clone()));
return Ok(release);
}
}

let result = self
.update_service
.get_latest_commit(self.platform, TargetKind::Server, quality)
Expand Down
14 changes: 14 additions & 0 deletions extensions/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,17 @@ Make sure the browser build of the extension only uses browser-safe APIs. If an

- `src/extension.ts`: Desktop entrypoint.
- `src/extension.browser.ts`: Browser entrypoint. Make sure `esbuild.browser.mts` builds this and that `tsconfig.browser.json` targets it.

## Shared dependencies

A subset of runtime dependencies is shared across all built-in extensions and shipped once in the product under `extensions/node_modules/`, instead of being bundled into every extension that uses them. The single source of truth for which packages are shared and which version is shipped is the `dependencies` block in [extensions/package.json](./package.json).

When you change a dependency that is also listed in `extensions/package.json`:

- Bump the version range in `extensions/package.json` (and run `npm install` in `extensions/`).
- Update every consumer extension's `package.json` to **the same range string** as `extensions/package.json`.
- Run `npm install` in each affected extension to refresh its lockfile.

The `extensions/postinstall.mjs` script validates this at install time: if any consumer extension declares a range that doesn't match the shared range exactly, the install fails with a list of the offending entries. This catches the silent runtime failure that would otherwise happen (npm would install a private copy at the consumer's version, but esbuild marks the package as external and the runtime would resolve to the wrong major from the shared location).

The list of packages externalized by the shared esbuild config in [extensions/esbuild-extension-common.mts](./esbuild-extension-common.mts) is generated automatically from `extensions/package.json`, so you don't need to touch the esbuild config when adding or removing a shared package.
9 changes: 5 additions & 4 deletions extensions/configuration-editing/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion extensions/configuration-editing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
},
"dependencies": {
"@octokit/rest": "^21.1.1",
"jsonc-parser": "^3.2.0",
"jsonc-parser": "^3.3.1",
"tunnel": "^0.0.6"
},
"capabilities": {
Expand Down
18 changes: 18 additions & 0 deletions extensions/copilot/.esbuild.mts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ const isDev = process.argv.includes('--dev');
const generateSourceMaps = process.argv.includes('--sourcemaps');
const sourceMapOutDir = './dist-sourcemaps';

/** Externalize only when bundled with the vscode product (which ships `extensions/node_modules/`).
* Default unset = inlined, so the standalone marketplace .vsix stays self-contained. */
function sharedRuntimeExternal(packages: string[]): string[] {
return process.env['VSCODE_USE_SHARED_EXTENSION_DEPS'] === 'true' ? packages : [];
}

const baseBuildOptions = {
bundle: true,
logLevel: 'info',
Expand Down Expand Up @@ -42,6 +48,18 @@ const baseNodeBuildOptions = {
'sqlite3',
'node-pty', // Required by @github/copilot
'@github/copilot',
// Shared deps from `extensions/package.json` — externalized only in the
// in-tree vscode build (see `sharedRuntimeExternal`).
...sharedRuntimeExternal([
'@vscode/extension-telemetry',
'@microsoft/1ds-core-js',
'@microsoft/1ds-post-js',
'dompurify',
'jsonc-parser',
'markdown-it',
'minimatch',
'vscode-tas-client',
]),
...(isDev ? [] : ['dotenv', 'source-map-support'])
],
platform: 'node',
Expand Down
5 changes: 4 additions & 1 deletion extensions/copilot/.vscode-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ const config = {
color: true,
forbidOnly: !!process.env.CI,
timeout: 5000,
retries: isSanity ? 1 : 0
// Sanity tests hit the live model endpoint, so they can fail for
// transient upstream reasons (empty response, rate limit, etc.).
// Give each test up to three attempts before marking it as failed.
retries: isSanity ? 2 : 0
}
};

Expand Down
9 changes: 1 addition & 8 deletions extensions/copilot/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions extensions/copilot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6996,11 +6996,9 @@
"@vscode/prompt-tsx": "^0.4.0-alpha.8",
"@vscode/tree-sitter-wasm": "0.0.5-php.2",
"@vscode/webview-ui-toolkit": "^1.3.1",
"@xterm/headless": "^5.5.0",
"ajv": "^8.18.0",
"applicationinsights": "^2.9.7",
"best-effort-json-parser": "^1.2.1",
"diff": "^8.0.3",
"dompurify": "^3.4.1",
"express": "^5.2.1",
"ignore": "^7.0.5",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,28 @@ import { ContributedToolName } from '../../tools/common/toolNames';
import { IToolsService } from '../../tools/common/toolsService';
import { TestChatRequest } from '../node/testHelpers';

/**
* Render a short, log-friendly description of what came back from a chat
* request. Used in assertion messages so that flaky failures in CI carry
* enough context to tell an empty model response apart from a real
* regression (e.g. error details from upstream, or a stream that only
* produced non-markdown parts).
*/
function describeOutcome(stream: SpyChatResponseStream, result?: vscode.ChatResult): string {
const parts = stream.items.map(p => p.constructor.name);
let summary = `items=${stream.items.length} [${parts.join(', ')}] currentProgress=${JSON.stringify(stream.currentProgress)}`;
const responseId = result?.metadata?.responseId;
if (responseId) {
// The responseId is the most useful breadcrumb for correlating a
// failed sanity run with server-side logs.
summary += ` responseId=${responseId}`;
}
if (result?.errorDetails) {
summary += ` errorDetails=${JSON.stringify(result.errorDetails)}`;
}
return summary;
}

/**
* Running these locally? You may have to run `npm run setup` again
*/
Expand Down Expand Up @@ -77,16 +99,16 @@ suite('Copilot Chat Sanity Test', function () {
let stream = new SpyChatResponseStream();
let interactiveSession = instaService.createInstance(ChatParticipantRequestHandler, [], new TestChatRequest('Write me a for loop in javascript'), stream, fakeToken, { agentName: '', agentId: '', intentId: '' }, () => false, undefined);

await interactiveSession.getResult();
const result1 = await interactiveSession.getResult();

assert.ok(stream.currentProgress, 'Expected progress after first request');
assert.ok(stream.currentProgress, `Expected progress after first request. ${describeOutcome(stream, result1)}`);
const oldText = stream.currentProgress;

stream = new SpyChatResponseStream();
interactiveSession = instaService.createInstance(ChatParticipantRequestHandler, [], new TestChatRequest('Can you make it in typescript instead'), stream, fakeToken, { agentName: '', agentId: '', intentId: '' }, () => false, undefined);
const result2 = await interactiveSession.getResult();

assert.ok(stream.currentProgress, 'Expected progress after second request');
assert.ok(stream.currentProgress, `Expected progress after second request. ${describeOutcome(stream, result2)}`);
assert.notStrictEqual(stream.currentProgress, oldText, 'Expected different progress text after second request');

const conversation = conversationStore.getConversation(result2.metadata.responseId);
Expand Down Expand Up @@ -122,14 +144,10 @@ suite('Copilot Chat Sanity Test', function () {

const onWillInvokeTool = Event.toPromise(toolsService.onWillInvokeTool);
const getResultPromise = interactiveSession.getResult();
const dumpStream = () => {
const parts = stream.items.map(p => p.constructor.name);
return `items=${stream.items.length} [${parts.join(', ')}] currentProgress=${JSON.stringify(stream.currentProgress)}`;
};
try {
await Promise.race([
onWillInvokeTool,
timeout(20_000).then(() => Promise.reject(new Error('timed out waiting for tool call. ' + dumpStream())))
timeout(20_000).then(() => Promise.reject(new Error('timed out waiting for tool call. ' + describeOutcome(stream))))
]);
await getResultPromise;
return stream;
Expand All @@ -147,20 +165,21 @@ suite('Copilot Chat Sanity Test', function () {
? ` error=${settled.error.stack ?? settled.error.message}`
: ` error=${String(settled.error)}`
: '';
throw new Error(`${cause.message} | follow-up: kind=${settled.kind}${followUpError} ${dumpStream()}`, { cause });
const resolvedResult = settled.kind === 'resolved' ? settled.value : undefined;
throw new Error(`${cause.message} | follow-up: kind=${settled.kind}${followUpError} ${describeOutcome(stream, resolvedResult)}`, { cause });
}
};

const stream = await runAgentRequest();

assert.ok(stream.currentProgress, 'Expected output');
assert.ok(stream.currentProgress, `Expected output. ${describeOutcome(stream)}`);
const oldText = stream.currentProgress;

const stream2 = new SpyChatResponseStream();
const interactiveSession = instaService.createInstance(ChatParticipantRequestHandler, [], new TestChatRequest('And what is 1+1'), stream2, fakeToken, { agentName: '', agentId: '', intentId: Intent.Agent }, () => false, undefined);
const result2 = await interactiveSession.getResult();

assert.ok(stream2.currentProgress, 'Expected progress after second request');
assert.ok(stream2.currentProgress, `Expected progress after second request. ${describeOutcome(stream2, result2)}`);
assert.notStrictEqual(stream2.currentProgress, oldText, 'Expected different progress text after second request');

const conversation = conversationStore.getConversation(result2.metadata.responseId);
Expand All @@ -178,8 +197,8 @@ suite('Copilot Chat Sanity Test', function () {
const interactiveSession = instaService.createInstance(ChatParticipantRequestHandler, [], new TestChatRequest('What is a fibonacci sequence?'), progressReport, fakeToken, { agentName: '', agentId: '', intentId: 'explain' }, () => false, undefined);

// Ask a `/explain` question
await interactiveSession.getResult();
assert.ok(progressReport.currentProgress);
const result = await interactiveSession.getResult();
assert.ok(progressReport.currentProgress, `Expected progress from /explain. ${describeOutcome(progressReport, result)}`);
});
});

Expand Down
2 changes: 1 addition & 1 deletion extensions/emmet/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion extensions/emmet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@
"@emmetio/math-expression": "^1.0.5",
"@vscode/emmet-helper": "^2.8.8",
"image-size": "~1.0.0",
"vscode-languageserver-textdocument": "^1.0.1"
"vscode-languageserver-textdocument": "^1.0.11"
},
"capabilities": {
"virtualWorkspaces": true,
Expand Down
4 changes: 4 additions & 0 deletions extensions/esbuild-common.mts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export async function runBuild(
entryPoints: config.entryPoints,
outdir,
...(config.additionalOptions || {}),
external: [
...(baseOptions.external ?? []),
...(config.additionalOptions?.external ?? []),
],
};

const isWatch = args.indexOf('--watch') >= 0;
Expand Down
Loading
Loading