Skip to content

Commit c73e6bc

Browse files
roblourensCopilot
andcommitted
agentHost: add integration test for CopilotAgent working directory regression
Copilot SDK path to verify that workingDirectory is correctly preserved when the active client changes between createSession and sendMessage. This reproduces the regression from #309672 where setting client tools causes the session to be recreated via _resumeSession, losing the original working directory. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 28b5573 commit c73e6bc

2 files changed

Lines changed: 217 additions & 0 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import assert from 'assert';
7+
import { execFileSync } from 'child_process';
8+
import * as fs from 'fs';
9+
import * as os from 'os';
10+
import { join } from '../../../../../base/common/path.js';
11+
import { generateUuid } from '../../../../../base/common/uuid.js';
12+
import { URI } from '../../../../../base/common/uri.js';
13+
import { AgentSession } from '../../../common/agentService.js';
14+
import type { INotificationBroadcastParams } from '../../../common/state/sessionProtocol.js';
15+
import type { ISessionAddedNotification } from '../../../common/state/sessionActions.js';
16+
import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js';
17+
import {
18+
dispatchTurnStarted,
19+
isActionNotification,
20+
type IServerHandle,
21+
TestProtocolClient,
22+
} from './testHelpers.js';
23+
import { startCopilotServer } from './copilotAgentCwdHelpers.js';
24+
25+
/**
26+
* Integration test that exercises the real CopilotAgent → Copilot SDK path.
27+
*
28+
* Verifies that the `workingDirectory` passed to `createSession` is correctly
29+
* forwarded to the SDK and appears in the `session.start` event in
30+
* `events.jsonl`.
31+
*
32+
* Requirements:
33+
* - `gh auth` logged in (provides the GitHub token)
34+
* - `node_modules` installed
35+
*
36+
* Run: scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/protocol/copilotAgentCwd.integrationTest.ts
37+
*/
38+
suite('CopilotAgent — Working Directory (real SDK)', function () {
39+
40+
let server: IServerHandle;
41+
let client: TestProtocolClient;
42+
let tmpDir: string;
43+
let ghToken: string;
44+
45+
suiteSetup(async function () {
46+
this.timeout(30_000);
47+
48+
// Obtain a GitHub token. Prefer GITHUB_OAUTH_TOKEN (used by CI and
49+
// the copilot extension's simulation tests), then fall back to the
50+
// local `gh` CLI.
51+
ghToken = process.env['GITHUB_OAUTH_TOKEN'] ?? '';
52+
if (!ghToken) {
53+
try {
54+
ghToken = execFileSync('gh', ['auth', 'token'], { encoding: 'utf-8' }).trim();
55+
} catch {
56+
// Neither env var nor gh CLI available — skip the suite.
57+
return this.skip();
58+
}
59+
}
60+
61+
// Start the agent host server with CopilotAgent (not --quiet, not --enable-mock-agent)
62+
server = await startCopilotServer();
63+
});
64+
65+
suiteTeardown(function () {
66+
server?.process.kill();
67+
});
68+
69+
setup(async function () {
70+
this.timeout(15_000);
71+
72+
tmpDir = fs.mkdtempSync(join(os.tmpdir(), 'copilot-cwd-test-'));
73+
client = new TestProtocolClient(server.port);
74+
await client.connect();
75+
76+
// Handshake
77+
await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-copilot-cwd' });
78+
79+
// Authenticate with the real GitHub token so CopilotAgent can talk to the API
80+
await client.call('authenticate', {
81+
resource: 'https://api.github.com',
82+
token: ghToken,
83+
});
84+
});
85+
86+
teardown(function () {
87+
client?.close();
88+
try {
89+
fs.rmSync(tmpDir, { recursive: true, force: true });
90+
} catch { /* best effort */ }
91+
});
92+
93+
test('session.start event in events.jsonl reports the correct cwd', async function () {
94+
this.timeout(120_000);
95+
96+
// 1. Create a session with a specific working directory
97+
const sessionId = generateUuid();
98+
const requestedSessionUri = AgentSession.uri('copilot', sessionId).toString();
99+
await client.call('createSession', {
100+
session: requestedSessionUri,
101+
provider: 'copilot',
102+
workingDirectory: URI.file(tmpDir).toString(),
103+
}, 60_000);
104+
105+
// Wait for the sessionAdded notification to learn the real session URI
106+
const addedNotif = await client.waitForNotification(
107+
n => n.method === 'notification' &&
108+
(n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded',
109+
15_000,
110+
);
111+
const sessionUri = ((addedNotif.params as INotificationBroadcastParams).notification as ISessionAddedNotification).summary.resource;
112+
113+
// 2. Set client tools (mimics what VS Code does before the first message).
114+
// This populates the ActiveClient for the session, so the sendMessage
115+
// path sees an outdated snapshot and recreates the SDK session.
116+
await client.call('subscribe', { resource: sessionUri }, 5_000);
117+
client.clearReceived();
118+
119+
client.notify('dispatchAction', {
120+
clientSeq: 1,
121+
action: {
122+
type: 'session/activeClientChanged',
123+
session: sessionUri,
124+
activeClient: {
125+
clientId: 'test-copilot-cwd',
126+
tools: [{
127+
name: 'dummy_tool',
128+
description: 'A no-op tool for testing',
129+
}],
130+
},
131+
},
132+
});
133+
134+
// Give the server a moment to process the activeClientChanged action
135+
await new Promise(resolve => setTimeout(resolve, 500));
136+
137+
// 3. Send a trivial message by dispatching a turnStarted action.
138+
// Because the active client was set after createSession, the
139+
// sendMessage path detects an outdated snapshot, disposes the
140+
// existing SDK session, and recreates it via _resumeSession.
141+
dispatchTurnStarted(client, sessionUri, 'turn-cwd-1', 'Respond with exactly: HELLO', 2);
142+
143+
// Wait for the turn to complete (the real LLM processes the request)
144+
await client.waitForNotification(
145+
n => isActionNotification(n, 'session/turnComplete'),
146+
90_000,
147+
);
148+
149+
// 4. Read the SDK's events.jsonl and verify the cwd in the session.start event
150+
const eventsPath = join(os.homedir(), '.copilot', 'session-state', sessionId, 'events.jsonl');
151+
assert.ok(fs.existsSync(eventsPath), `events.jsonl should exist at: ${eventsPath}`);
152+
153+
const lines = fs.readFileSync(eventsPath, 'utf-8').trim().split('\n');
154+
const sessionStartLine = lines.find(line => line.includes('"session.start"'));
155+
assert.ok(sessionStartLine, 'events.jsonl should contain a session.start event');
156+
157+
const sessionStartEvent = JSON.parse(sessionStartLine);
158+
assert.strictEqual(
159+
sessionStartEvent.data?.context?.cwd,
160+
tmpDir,
161+
`session.start cwd should match the requested workingDirectory`,
162+
);
163+
});
164+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { fork } from 'child_process';
7+
import { fileURLToPath } from 'url';
8+
import type { IServerHandle } from './testHelpers.js';
9+
10+
/**
11+
* Starts the agent host server with the real CopilotAgent registered.
12+
*
13+
* Unlike {@link startServer} in testHelpers.ts, this does NOT pass
14+
* `--enable-mock-agent` or `--quiet`, so the server boots the real
15+
* CopilotAgent backed by the Copilot SDK.
16+
*/
17+
export function startCopilotServer(): Promise<IServerHandle> {
18+
return new Promise((resolve, reject) => {
19+
const serverPath = fileURLToPath(new URL('../../../node/agentHostServerMain.js', import.meta.url));
20+
const args = ['--port', '0', '--without-connection-token'];
21+
const child = fork(serverPath, args, {
22+
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
23+
});
24+
25+
const timer = setTimeout(() => {
26+
child.kill();
27+
reject(new Error('Copilot server startup timed out'));
28+
}, 30_000);
29+
30+
child.stdout!.on('data', (data: Buffer) => {
31+
const text = data.toString();
32+
const match = text.match(/READY:(\d+)/);
33+
if (match) {
34+
clearTimeout(timer);
35+
resolve({ process: child, port: parseInt(match[1], 10) });
36+
}
37+
});
38+
39+
child.stderr!.on('data', () => {
40+
// Swallowed — the test runner fails if stderr leaks through.
41+
});
42+
43+
child.on('error', err => {
44+
clearTimeout(timer);
45+
reject(err);
46+
});
47+
48+
child.on('exit', code => {
49+
clearTimeout(timer);
50+
reject(new Error(`Copilot server exited prematurely with code ${code}`));
51+
});
52+
});
53+
}

0 commit comments

Comments
 (0)