Skip to content
Merged
76 changes: 68 additions & 8 deletions build/lib/test/agentHostDependencies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ const agentHostEntryPoints = [
'src/vs/platform/agentHost/node/agentHostMain.ts',
'src/vs/platform/agentHost/node/agentHostServerMain.ts',
];
const excludedLiteralDynamicImports = new Set([
// Test-only provider loaded by the standalone server's --enable-mock-agent option.
literalDynamicImportKey(
path.join(repositoryRoot, 'src/vs/platform/agentHost/node/agentHostServerMain.ts'),
'../test/node/mockAgent.js'
),
// Built products use the downloaded SDK path; the bare package import is a dev fallback.
literalDynamicImportKey(
path.join(repositoryRoot, 'src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts'),
'@anthropic-ai/claude-agent-sdk'
),
]);

suite('Agent Host dependencies', () => {
test('runtime packages are included in the remote server', () => {
Expand All @@ -36,6 +48,40 @@ suite('Agent Host dependencies', () => {

assert.deepStrictEqual(missingDependencies, []);
});

test('collects literal dynamic imports with narrow exclusions', () => {
const regularSource = ts.createSourceFile(
path.join(repositoryRoot, 'src/regular.ts'),
`import('node-pty'); import(\`ws\`); import('node-addon-api', { with: { type: 'json' } }); import('../test/node/mockAgent.js'); import('@anthropic-ai/claude-agent-sdk'); import(variable);`,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);
const mockAgentServerSource = ts.createSourceFile(
path.join(repositoryRoot, 'src/vs/platform/agentHost/node/agentHostServerMain.ts'),
`import('../test/node/mockAgent.js'); import('node-pty');`,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);
const claudeSdkServiceSource = ts.createSourceFile(
path.join(repositoryRoot, 'src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts'),
`import('@anthropic-ai/claude-agent-sdk'); import('node-pty');`,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS
);

assert.deepStrictEqual({
regular: getRuntimeModuleSpecifiers(regularSource),
mockAgentServer: getRuntimeModuleSpecifiers(mockAgentServerSource),
claudeSdkService: getRuntimeModuleSpecifiers(claudeSdkServiceSource),
}, {
regular: ['node-pty', 'ws', 'node-addon-api', '../test/node/mockAgent.js', '@anthropic-ai/claude-agent-sdk'],
mockAgentServer: ['node-pty'],
claudeSdkService: ['node-pty'],
});
});
});

function collectRuntimePackageImports(entryPoints: readonly string[]): Map<string, Set<string>> {
Expand Down Expand Up @@ -89,14 +135,24 @@ function getRuntimeModuleSpecifiers(sourceFile: ts.SourceFile): string[] {
}

const visit = (node: ts.Node): void => {
if (
ts.isCallExpression(node)
&& ts.isIdentifier(node.expression)
&& (node.expression.text === 'require' || node.expression.text === 'nativeRequire')
&& node.arguments.length === 1
&& ts.isStringLiteral(node.arguments[0])
) {
moduleSpecifiers.push(node.arguments[0].text);
if (ts.isCallExpression(node)) {
if (
node.expression.kind === ts.SyntaxKind.ImportKeyword
&& node.arguments.length > 0
&& ts.isStringLiteralLike(node.arguments[0])
) {
const moduleSpecifier = node.arguments[0].text;
if (!excludedLiteralDynamicImports.has(literalDynamicImportKey(sourceFile.fileName, moduleSpecifier))) {
moduleSpecifiers.push(moduleSpecifier);
}
} else if (
ts.isIdentifier(node.expression)
&& (node.expression.text === 'require' || node.expression.text === 'nativeRequire')
&& node.arguments.length === 1
&& ts.isStringLiteralLike(node.arguments[0])
) {
moduleSpecifiers.push(node.arguments[0].text);
}
}
ts.forEachChild(node, visit);
};
Expand Down Expand Up @@ -134,3 +190,7 @@ function getPackageName(moduleSpecifier: string): string {
const segments = moduleSpecifier.split('/');
return moduleSpecifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0];
}

function literalDynamicImportKey(importer: string, moduleSpecifier: string): string {
return `${path.relative(repositoryRoot, importer)}\0${moduleSpecifier}`;
}
71 changes: 58 additions & 13 deletions src/vs/base/node/zip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { createWriteStream, WriteStream, promises } from 'fs';
import type { FileHandle } from 'fs/promises';
import { Readable } from 'stream';
import { createCancelablePromise, Sequencer } from '../common/async.js';
import { CancellationToken } from '../common/cancellation.js';
Expand Down Expand Up @@ -190,29 +191,73 @@ export interface IFile {
path: string;
contents?: Buffer | string;
localPath?: string;
/**
* When set (and `contents` is not provided), stream at most this many bytes
* from the start of {@link localPath} instead of adding the whole file. The
* prefix is clamped to the file's current size so a rotated or truncated file
* still produces a valid entry.
*/
localPathSize?: number;
}

export async function zip(zipPath: string, files: IFile[]): Promise<string> {
const { ZipFile } = await import('yazl');

return new Promise<string>((c, e) => {
const zip = new ZipFile();
files.forEach(f => {
if (f.contents) {
zip.addBuffer(typeof f.contents === 'string' ? Buffer.from(f.contents, 'utf8') : f.contents, f.path);
} else if (f.localPath) {
zip.addFile(f.localPath, f.path);
}
});
zip.end();

const zipStream = createWriteStream(zipPath);
zip.outputStream.pipe(zipStream);
const zip = new ZipFile();
const zipStream = createWriteStream(zipPath);

// Attach error/finish handling before adding entries so a read stream that
// errors while a later entry is still awaiting I/O has a listener.
const result = new Promise<string>((c, e) => {
zip.outputStream.once('error', e);
zipStream.once('error', e);
zipStream.once('finish', () => c(zipPath));
});
zip.outputStream.pipe(zipStream);

for (const f of files) {
if (f.contents !== undefined) {
zip.addBuffer(typeof f.contents === 'string' ? Buffer.from(f.contents, 'utf8') : f.contents, f.path);
} else if (f.localPath) {
if (f.localPathSize === undefined) {
zip.addFile(f.localPath, f.path);
} else {
// yazl aborts the archive unless the streamed byte count matches the
// declared size. Derive both from a single handle so the counts match
// even if the path is rotated or truncated concurrently; skip a
// vanished file rather than failing the whole archive.
let handle: FileHandle;
try {
handle = await promises.open(f.localPath, 'r');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
continue;
}
throw error;
}
let streamOwnsHandle = false;
try {
const size = Math.min(f.localPathSize, (await handle.stat()).size);
if (size === 0) {
zip.addBuffer(Buffer.alloc(0), f.path);
} else {
const readStream = handle.createReadStream({ start: 0, end: size - 1 });
readStream.once('error', error => zip.outputStream.emit('error', error));
zip.addReadStream(readStream, f.path, { size });
streamOwnsHandle = true;
}
} finally {
// The read stream closes the handle when it finishes.
if (!streamOwnsHandle) {
await handle.close();
}
}
}
}
}
zip.end();

return result;
}

export function extract(zipPath: string, targetPath: string, options: IExtractOptions = {}, token: CancellationToken): Promise<void> {
Expand Down
60 changes: 59 additions & 1 deletion src/vs/base/test/node/zip/zip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { createCancelablePromise } from '../../../common/async.js';
import { FileAccess } from '../../../common/network.js';
import * as path from '../../../common/path.js';
import { Promises } from '../../../node/pfs.js';
import { extract } from '../../../node/zip.js';
import { buffer, extract, zip } from '../../../node/zip.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../common/utils.js';
import { getRandomTestPath } from '../testUtils.js';

Expand All @@ -31,4 +31,62 @@ suite('Zip', () => {

await Promises.rm(testDir);
});

test('zip should stream a fixed prefix of a local file', async () => {
const testDir = getRandomTestPath(tmpdir(), 'vsctests', 'zip');
const sourcePath = path.join(testDir, 'source.txt');
const zipPath = path.join(testDir, 'logs.zip');
await fs.promises.mkdir(testDir, { recursive: true });
await fs.promises.writeFile(sourcePath, 'snapshot-appended');
await zip(zipPath, [{ path: 'source.txt', localPath: sourcePath, localPathSize: 8 }]);

assert.strictEqual((await buffer(zipPath, 'source.txt')).toString(), 'snapshot');

await Promises.rm(testDir);
});

test('zip should clamp a prefix larger than the current file size', async () => {
const testDir = getRandomTestPath(tmpdir(), 'vsctests', 'zip');
const sourcePath = path.join(testDir, 'source.txt');
const zipPath = path.join(testDir, 'logs.zip');
await fs.promises.mkdir(testDir, { recursive: true });
await fs.promises.writeFile(sourcePath, 'shrunk');
// Request more bytes than the file holds: the entry contains the whole file.
await zip(zipPath, [{ path: 'source.txt', localPath: sourcePath, localPathSize: 1024 }]);

assert.strictEqual((await buffer(zipPath, 'source.txt')).toString(), 'shrunk');

await Promises.rm(testDir);
});

test('zip should write an empty entry for a zero-length prefix', async () => {
const testDir = getRandomTestPath(tmpdir(), 'vsctests', 'zip');
const sourcePath = path.join(testDir, 'source.txt');
const zipPath = path.join(testDir, 'logs.zip');
await fs.promises.mkdir(testDir, { recursive: true });
await fs.promises.writeFile(sourcePath, 'ignored');
await zip(zipPath, [{ path: 'source.txt', localPath: sourcePath, localPathSize: 0 }]);

assert.strictEqual((await buffer(zipPath, 'source.txt')).toString(), '');

await Promises.rm(testDir);
});

test('zip should skip a vanished streamed source without failing the archive', async () => {
const testDir = getRandomTestPath(tmpdir(), 'vsctests', 'zip');
const presentPath = path.join(testDir, 'present.txt');
const missingPath = path.join(testDir, 'missing.txt');
const zipPath = path.join(testDir, 'logs.zip');
await fs.promises.mkdir(testDir, { recursive: true });
await fs.promises.writeFile(presentPath, 'present-contents');
await zip(zipPath, [
{ path: 'missing.txt', localPath: missingPath, localPathSize: 8 },
{ path: 'present.txt', localPath: presentPath, localPathSize: 7 },
]);

assert.strictEqual((await buffer(zipPath, 'present.txt')).toString(), 'present');
await assert.rejects(buffer(zipPath, 'missing.txt'));

await Promises.rm(testDir);
});
});
7 changes: 6 additions & 1 deletion src/vs/platform/agentHost/common/agentHostGitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,15 @@ export interface IPullOptions {

export const IAgentHostGitService = createDecorator<IAgentHostGitService>('agentHostGitService');

export interface IDefaultBranch {
readonly name: string;
readonly startPoint: string;
}

export interface IAgentHostGitService {
readonly _serviceBrand: undefined;
getCurrentBranch(workingDirectory: URI): Promise<string | undefined>;
getDefaultBranch(workingDirectory: URI): Promise<string | undefined>;
getDefaultBranch(workingDirectory: URI): Promise<IDefaultBranch | undefined>;
getBranches(workingDirectory: URI, options?: { readonly query?: string; readonly limit?: number }): Promise<string[]>;
getRepositoryRoot(workingDirectory: URI): Promise<URI | undefined>;
getWorktreeRoots(workingDirectory: URI): Promise<URI[]>;
Expand Down
10 changes: 5 additions & 5 deletions src/vs/platform/agentHost/node/agentHostGitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { IFileService } from '../../files/common/files.js';
import { ILogService } from '../../log/common/log.js';
import { FileEditKind, type ISessionFileDiff, type ISessionGitState } from '../common/state/sessionState.js';
import { buildGitBlobUri } from './gitDiffContent.js';
import { EMPTY_TREE_OBJECT, getBranchCompletions, IAgentHostGitService, IComputeSessionFileDiffsOptions, IPullOptions, IPushOptions } from '../common/agentHostGitService.js';
import { EMPTY_TREE_OBJECT, getBranchCompletions, IAgentHostGitService, IComputeSessionFileDiffsOptions, IDefaultBranch, IPullOptions, IPushOptions } from '../common/agentHostGitService.js';
import { LRUCache } from '../../../base/common/map.js';
import { Limiter, SequencerByKey } from '../../../base/common/async.js';

Expand All @@ -41,12 +41,12 @@ export class AgentHostGitService implements IAgentHostGitService {
|| undefined;
}

async getDefaultBranch(workingDirectory: URI): Promise<string | undefined> {
async getDefaultBranch(workingDirectory: URI): Promise<IDefaultBranch | undefined> {
// Try to read the default branch from the remote HEAD reference
const remoteRef = (await this._runGit(workingDirectory, ['symbolic-ref', 'refs/remotes/origin/HEAD']))?.trim();
if (remoteRef) {
if (!remoteRef.startsWith('refs/remotes/origin/')) {
return remoteRef;
return { name: remoteRef, startPoint: remoteRef };
}

const branch = remoteRef.substring('refs/remotes/origin/'.length);
Expand All @@ -59,10 +59,10 @@ export class AgentHostGitService implements IAgentHostGitService {
// (e.g. fresh clone with no remote-tracking refs yet).
const hasRemoteRef = (await this._runGit(workingDirectory, ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`])) !== undefined;
if (hasRemoteRef) {
return `origin/${branch}`;
return { name: branch, startPoint: `origin/${branch}` };
}
const hasLocalBranch = (await this._runGit(workingDirectory, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== undefined;
return hasLocalBranch ? branch : undefined;
return hasLocalBranch ? { name: branch, startPoint: branch } : undefined;
}
return undefined;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,11 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine current branch for ${workingDirectory}`);
}

const baseBranchName = gitState?.baseBranchName ?? await this._gitService.getDefaultBranch(workingDirectory);
const baseBranchName = gitState?.baseBranchName ?? (await this._gitService.getDefaultBranch(workingDirectory))?.name;
if (!baseBranchName) {
throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Could not determine base branch for ${workingDirectory}`);
}
// `getDefaultBranch` may return `origin/<branch>` — `pulls` API wants the bare name.
const base = baseBranchName.startsWith('origin/') ? baseBranchName.substring('origin/'.length) : baseBranchName;
const base = baseBranchName;

const repoResource = this._gitHubEndpointService.getRepoResource();
const authToken = this._agentService.getAuthToken({
Expand Down
14 changes: 7 additions & 7 deletions src/vs/platform/agentHost/node/protocolServerHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -875,9 +875,8 @@ export class ProtocolServerHandler extends Disposable {
* any armed timers, so this is a no-op when the client is active. The delay
* is the remaining grace measured from when the client disconnected — so a
* client that disconnected a while before the call was issued gets the
* residual window rather than a fresh one, and a stamp from a long-dead
* client fails promptly. A never-connected client has its grace clock pinned
* to the first arm, so re-arms triggered by later orphaned tool calls in the
* residual window rather than a fresh one, and a stamp from a long-disconnected
* client fails promptly. Re-arms triggered by later orphaned tool calls in the
* same session shrink the remaining window instead of resetting it.
*/
private _startClientToolCallDisconnectTimeout(clientId: string, session: string, chatChannel: string): void {
Expand All @@ -895,20 +894,21 @@ export class ProtocolServerHandler extends Disposable {
}

/**
* Scan a session for pending client tool calls whose owning client is not
* currently connected, and arm the disconnect timeout for each such owner.
* Scan a chat for pending client tool calls owned by a disconnected client
* of this protocol server, and arm the disconnect timeout for each owner.
* Called when a `ChatToolCallStart` / `ChatToolCallReady` envelope is
* observed — covering calls issued for an already-gone client, which the
* live disconnect path never sees. Ownerless client tool calls (no client
* connected at stamp time) are failed immediately by the provider, so they
* never reach a pending state here.
* never reach a pending state here. Unknown client ids are ignored because
* they may belong to another transport such as local IPC.
*/
private _checkOrphanedClientToolCalls(session: string, chatChannel: string): void {
const state = this._stateManager.getSessionState(chatChannel);
const orphanOwners = new Set<string>();
for (const { clientId } of this._pendingClientToolCalls(state)) {
const ownerRecord = this._clients.get(clientId);
if (ownerRecord?.state !== 'active') {
if (ownerRecord?.state === 'grace') {
orphanOwners.add(clientId);
}
}
Expand Down
Loading
Loading