Path: blob/main/src/vs/platform/agentHost/test/node/protocol/sessionDiffsRealSdk.integrationTest.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45/**6* Real-SDK integration test for the git-driven session diff path.7*8* Disabled by default. Run with:9*10* AGENT_HOST_REAL_SDK=1 ./scripts/test-integration.sh \11* --run src/vs/platform/agentHost/test/node/protocol/sessionDiffsRealSdk.integrationTest.ts12*13* Authentication: token from `gh auth token` (or `GITHUB_TOKEN`).14*15* SAFETY: Working directory is always a freshly-`git init`-ed temp folder16* scoped to a single test, removed in teardown.17*/1819import assert from 'assert';20import * as cp from 'child_process';21import { execSync } from 'child_process';22import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'fs';23import { tmpdir } from 'os';24import { join } from '../../../../../base/common/path.js';25import { URI } from '../../../../../base/common/uri.js';26import { SubscribeResult } from '../../../common/state/protocol/commands.js';27import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js';28import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js';29import type { SessionState } from '../../../common/state/sessionState.js';30import type { SessionAddedNotification, SessionDiffsChangedAction, SessionToolCallReadyAction } from '../../../common/state/sessionActions.js';31import {32getActionEnvelope,33IServerHandle,34isActionNotification,35startRealServer,36TestProtocolClient,37} from './testHelpers.js';3839const REAL_SDK_ENABLED = process.env['AGENT_HOST_REAL_SDK'] === '1';4041const hasGit = (() => {42try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; }43})();4445function resolveGitHubToken(): string {46const envToken = process.env['GITHUB_TOKEN'];47if (envToken) {48return envToken;49}50return execSync('gh auth token', { encoding: 'utf-8' }).trim();51}5253(REAL_SDK_ENABLED && hasGit ? suite : suite.skip)('Protocol WebSocket — Real Copilot SDK git-driven diffs', function () {5455let server: IServerHandle;56let client: TestProtocolClient;57const createdSessions: string[] = [];58const tempDirs: string[] = [];5960suiteSetup(async function () {61this.timeout(60_000);62server = await startRealServer();63});6465suiteTeardown(function () {66server?.process.kill();67});6869setup(async function () {70this.timeout(30_000);71client = new TestProtocolClient(server.port);72await client.connect();73});7475teardown(async function () {76for (const session of createdSessions) {77try { await client.call('disposeSession', { session }, 5000); } catch { /* best-effort */ }78}79createdSessions.length = 0;80client.close();81for (const dir of tempDirs) {82rmSync(dir, { recursive: true, force: true });83}84tempDirs.length = 0;85});8687test('terminal-driven file edit shows up in summary.diffs (no ToolResultFileEditContent emitted)', async function () {88this.timeout(180_000);8990// Initialize a tmp git repo as the working directory.91const tempDir = mkdtempSync(`${tmpdir()}/ahp-real-diff-`);92tempDirs.push(tempDir);93const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' };94const runGit = (...args: string[]) => execSync(`git ${args.join(' ')}`, { cwd: tempDir, env, stdio: 'pipe' });95runGit('init', '-q', '-b', 'main');96writeFileSync(join(tempDir, 'seed.txt'), 'seed\n');97runGit('add', '.');98runGit('commit', '-q', '-m', 'init');99100const workingDirUri = URI.file(tempDir).toString();101102await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'real-sdk-git-diffs' }, 30_000);103await client.call('authenticate', { resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000);104105const sessionUri = URI.from({ scheme: 'copilotcli', path: `/real-diff-${Date.now()}` }).toString();106await client.call('createSession', { session: sessionUri, provider: 'copilotcli', workingDirectory: workingDirUri }, 30_000);107108const addedNotif = await client.waitForNotification(n =>109n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded',11015_000,111);112const realSessionUri = ((addedNotif.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource;113createdSessions.push(realSessionUri);114115await client.call<SubscribeResult>('subscribe', { resource: realSessionUri });116client.clearReceived();117118// Approve any tool call the agent issues. Restricted to `bash`-style119// shell tools so the model can't trick the test into running arbitrary120// other tools.121let approvalSeq = 1;122const approve = (action: SessionToolCallReadyAction & { session: string; turnId: string }) => {123client.notify('dispatchAction', {124clientSeq: ++approvalSeq,125action: {126type: 'session/toolCallConfirmed',127session: action.session,128turnId: action.turnId,129toolCallId: action.toolCallId,130approved: true,131},132});133};134const seenSeqs = new Set<number>();135let approverActive = true;136const approverLoop = (async () => {137while (approverActive) {138try {139const ready = await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady') && !seenSeqs.has(getActionEnvelope(n).serverSeq), 2_000);140const env = getActionEnvelope(ready);141seenSeqs.add(env.serverSeq);142approve(env.action as SessionToolCallReadyAction & { session: string; turnId: string });143} catch { /* timeout — keep polling */ }144}145})();146147// Ask the agent to use bash to write a specific file. The exact filename148// is fixed so we can assert on it. The model is instructed to use bash149// (not a write_file tool) so the edit isn't reported via the SDK's150// file-edit content events — the diff has to come from git.151const targetFile = join(tempDir, 'from-bash.txt');152// Quote/escape targetFile for the shell so paths containing spaces or153// shell metacharacters don't break the test.154const shellQuotedTargetFile = `'${targetFile.replace(/'/g, `'\\''`)}'`;155const prompt = `Use the bash shell tool to run exactly: echo hello > ${shellQuotedTargetFile}\nDo not use any file-write tool. Use only bash.`;156client.notify('dispatchAction', {157clientSeq: 1,158action: { type: 'session/turnStarted', session: realSessionUri, turnId: 'turn-diff', userMessage: { text: prompt } },159});160161await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'), 150_000);162approverActive = false;163await approverLoop;164165// Sanity: file was actually written by the agent.166const files = readdirSync(tempDir);167assert.ok(files.includes('from-bash.txt'), `agent did not write the requested file. dir contents: ${files.join(', ')}`);168169// The diff broadcast may have already arrived during the turn — accept170// any matching one received during the run, or look at the final state.171const targetUri = URI.file(targetFile).toString();172const diffNotifs = client.receivedNotifications(n => isActionNotification(n, 'session/diffsChanged'));173const sawInLive = diffNotifs.some(n => {174const a = getActionEnvelope(n).action as SessionDiffsChangedAction;175return a.diffs.some(d => d.after?.uri === targetUri || d.before?.uri === targetUri);176});177178if (!sawInLive) {179// Fall back to the final snapshot.180const result = await client.call<SubscribeResult>('subscribe', { resource: realSessionUri });181const state = result.snapshot.state as SessionState;182const diffs = state.summary.diffs ?? [];183const matching = diffs.find(d => d.after?.uri === targetUri || d.before?.uri === targetUri);184assert.ok(matching, `expected git-driven diff for ${targetUri}; live notifications=${diffNotifs.length}; snapshot diffs=${JSON.stringify(diffs.map(d => d.after?.uri ?? d.before?.uri))}`);185}186});187});188189190