Skip to content
Open
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
251 changes: 246 additions & 5 deletions packages/plus/agents/src/__tests__/claudeCodeProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,24 @@ import type { AgentProviderCallbacks, IpcRegistrar } from '../types.js';

interface MockCallbacks {
callbacks: AgentProviderCallbacks;
handlers: Map<string, IpcHandler>;
handlers: Map<string, IpcHandler<unknown, unknown>>;
publishedPaths: string[][];
}

function createMockCallbacks(options?: { resolveGitInfo?: AgentProviderCallbacks['resolveGitInfo'] }): MockCallbacks {
const handlers = new Map<string, IpcHandler>();
function createMockCallbacks(options?: {
resolveGitInfo?: AgentProviderCallbacks['resolveGitInfo'];
openSessionInClaudeExtension?: AgentProviderCallbacks['openSessionInClaudeExtension'];
port?: number;
agentDiscoveryDir?: string;
}): MockCallbacks {
const handlers = new Map<string, IpcHandler<unknown, unknown>>();
const publishedPaths: string[][] = [];

const ipc: IpcRegistrar = {
port: 1234,
port: options?.port ?? 1234,
agentDiscoveryDir: options?.agentDiscoveryDir,
registerHandler: <Request, Response>(name: string, handler: IpcHandler<Request, Response>) => {
handlers.set(name, handler as unknown as IpcHandler);
handlers.set(name, handler as unknown as IpcHandler<unknown, unknown>);
return createDisposable(() => {
handlers.delete(name);
});
Expand All @@ -35,6 +41,7 @@ function createMockCallbacks(options?: { resolveGitInfo?: AgentProviderCallbacks
ipc: ipc,
runCLICommand: () => Promise.resolve('[]'),
resolveGitInfo: options?.resolveGitInfo,
openSessionInClaudeExtension: options?.openSessionInClaudeExtension,
};

return { callbacks: callbacks, handlers: handlers, publishedPaths: publishedPaths };
Expand Down Expand Up @@ -503,6 +510,240 @@ suite('ClaudeCodeProvider', () => {
}
});
});

suite('agents/sessions/open IPC handler', () => {
test('invokes the host callback with the requested sessionId', async () => {
const calls: string[] = [];
const { callbacks, handlers } = createMockCallbacks({
openSessionInClaudeExtension: sessionId => {
calls.push(sessionId);
return Promise.resolve();
},
});
const provider = new ClaudeCodeProvider(callbacks);
try {
provider.start(['/repo']);
await flushMicrotasks();

const handler = handlers.get('agents/sessions/open');
assert.ok(handler != null, 'agents/sessions/open handler should be registered');

const response = await handler({ sessionId: 'sess-1' }, new URLSearchParams());
assert.deepStrictEqual(calls, ['sess-1']);
assert.deepStrictEqual(response, {});
} finally {
provider.dispose();
}
});

test('returns {} without invoking the callback when sessionId is missing', async () => {
let called = false;
const { callbacks, handlers } = createMockCallbacks({
openSessionInClaudeExtension: () => {
called = true;
return Promise.resolve();
},
});
const provider = new ClaudeCodeProvider(callbacks);
try {
provider.start(['/repo']);
await flushMicrotasks();

const handler = handlers.get('agents/sessions/open')!;
const response = await handler({}, new URLSearchParams());
assert.strictEqual(called, false, 'callback must not run when sessionId is absent');
assert.deepStrictEqual(response, {});
} finally {
provider.dispose();
}
});

test('returns {} when the host did not wire openSessionInClaudeExtension', async () => {
const { callbacks, handlers } = createMockCallbacks();
const provider = new ClaudeCodeProvider(callbacks);
try {
provider.start(['/repo']);
await flushMicrotasks();

const handler = handlers.get('agents/sessions/open')!;
const response = await handler({ sessionId: 'sess-1' }, new URLSearchParams());
assert.deepStrictEqual(response, {});
} finally {
provider.dispose();
}
});

test('swallows callback errors so the peer never sees a 500', async () => {
const { callbacks, handlers } = createMockCallbacks({
openSessionInClaudeExtension: () => Promise.reject(new Error('extension not installed')),
});
const provider = new ClaudeCodeProvider(callbacks);
try {
provider.start(['/repo']);
await flushMicrotasks();

const handler = handlers.get('agents/sessions/open')!;
const response = await handler({ sessionId: 'sess-1' }, new URLSearchParams());
assert.deepStrictEqual(response, {});
} finally {
provider.dispose();
}
});
});

suite('notifyPeerOpenSession', () => {
test("skips the discovery file matching this provider's own port", async () => {
const { default: http } = await import('node:http');
const { mkdtemp, rm, writeFile } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');

const dir = await mkdtemp(join(tmpdir(), 'gitlens-discovery-self-'));
const hits: string[] = [];
const server = http.createServer((req, res) => {
hits.push(req.url ?? '');
res.writeHead(200);
res.end('{}');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as { port: number }).port;
try {
await writeFile(
join(dir, 'gitlens-ipc-server-self.json'),
JSON.stringify({
token: 't',
address: `http://127.0.0.1:${port}`,
port: port,
workspacePaths: ['/repo'],
}),
);

const { callbacks } = createMockCallbacks({ port: port, agentDiscoveryDir: dir });
const provider = new ClaudeCodeProvider(callbacks);
try {
provider.start(['/repo']);
await flushMicrotasks();
hits.length = 0; // ignore any pre-existing list-route hits (there should be none)
await provider.notifyPeerOpenSession('/repo', 'sess-1');
assert.deepStrictEqual(
hits.filter(u => u === '/agents/sessions/open'),
[],
'own-port discovery file must be skipped',
);
} finally {
provider.dispose();
}
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(dir, { recursive: true, force: true });
}
});

test('skips peers whose workspacePaths do not include the target', async () => {
const { default: http } = await import('node:http');
const { mkdtemp, rm, writeFile } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');

const dir = await mkdtemp(join(tmpdir(), 'gitlens-discovery-mismatch-'));
const hits: string[] = [];
const server = http.createServer((req, res) => {
hits.push(req.url ?? '');
res.writeHead(200);
res.end('{}');
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const peerPort = (server.address() as { port: number }).port;
try {
await writeFile(
join(dir, 'gitlens-ipc-server-other.json'),
JSON.stringify({
token: 't',
address: `http://127.0.0.1:${peerPort}`,
port: peerPort,
workspacePaths: ['/other/workspace'],
}),
);

const { callbacks } = createMockCallbacks({ port: peerPort + 1, agentDiscoveryDir: dir });
const provider = new ClaudeCodeProvider(callbacks);
try {
provider.start(['/repo']);
await flushMicrotasks();
hits.length = 0; // ignore `/agents/sessions/list` from querySiblingWindowSessions
await provider.notifyPeerOpenSession('/repo', 'sess-1');
assert.deepStrictEqual(
hits.filter(u => u === '/agents/sessions/open'),
[],
'mismatched-workspace peer must not be POSTed',
);
} finally {
provider.dispose();
}
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(dir, { recursive: true, force: true });
}
});

test('POSTs the sessionId to peers whose workspacePaths include the (normalized) target', async () => {
const { default: http } = await import('node:http');
const { mkdtemp, rm, writeFile } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');

const dir = await mkdtemp(join(tmpdir(), 'gitlens-discovery-match-'));
const requests: { url: string; auth: string | undefined; body: string }[] = [];
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', c => chunks.push(c as Buffer));
req.on('end', () => {
requests.push({
url: req.url ?? '',
auth: req.headers['authorization'],
body: Buffer.concat(chunks).toString('utf8'),
});
res.writeHead(200);
res.end('{}');
});
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const peerPort = (server.address() as { port: number }).port;
try {
await writeFile(
join(dir, 'gitlens-ipc-server-peer.json'),
JSON.stringify({
token: 'peer-token',
address: `http://127.0.0.1:${peerPort}`,
port: peerPort,
// Mixed-separator path on purpose — `notifyPeerOpenSession` normalizes both sides.
workspacePaths: ['d:\\PROJ\\GKGL\\vscode-gitlens'],
}),
);

const { callbacks } = createMockCallbacks({ port: peerPort + 1, agentDiscoveryDir: dir });
const provider = new ClaudeCodeProvider(callbacks);
try {
provider.start(['/somewhere/else']);
await flushMicrotasks();
// Ignore the unrelated `/agents/sessions/list` POST that `querySiblingWindowSessions`
// fires on start — we only care about what `notifyPeerOpenSession` does.
requests.length = 0;
await provider.notifyPeerOpenSession('d:/PROJ/GKGL/vscode-gitlens', 'sess-42');

const openRequests = requests.filter(r => r.url === '/agents/sessions/open');
assert.strictEqual(openRequests.length, 1, 'matching peer should receive exactly one open POST');
assert.strictEqual(openRequests[0].auth, 'Bearer peer-token');
assert.deepStrictEqual(JSON.parse(openRequests[0].body), { sessionId: 'sess-42' });
} finally {
provider.dispose();
}
} finally {
await new Promise<void>(resolve => server.close(() => resolve()));
await rm(dir, { recursive: true, force: true });
}
});
});
});

/** Drop-in transcript reader for provider tests — records calls and returns canned titles. */
Expand Down
63 changes: 63 additions & 0 deletions packages/plus/agents/src/providers/claudeCodeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,20 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
})),
),
),
this.callbacks.ipc.registerHandler('agents/sessions/open', async request => {
const sessionId = (request as { sessionId?: string } | undefined)?.sessionId;
if (!sessionId || this.callbacks.openSessionInClaudeExtension == null) return {};

try {
await this.callbacks.openSessionInClaudeExtension(sessionId);
} catch (ex) {
Logger.warn(
`ClaudeCodeProvider.agents/sessions/open: ${ex instanceof Error ? ex.message : String(ex)}`,
);
}

return {};
}),
];

try {
Expand Down Expand Up @@ -1513,4 +1527,53 @@ export class ClaudeCodeProvider implements AgentSessionProvider {
return undefined;
}
}

/** Find the peer GitLens window whose discovery file claims `workspacePath`, and POST to its
* `/agents/sessions/open` route to ask its Claude Code extension to open the session. The
* caller is responsible for the follow-up `vscode.openFolder` that focuses the peer window —
* this method just makes sure the session is ready in the peer's editor before the focus
* switch lands. Best-effort: swallows scan, network, and JSON-parse errors. */
async notifyPeerOpenSession(workspacePath: string, sessionId: string): Promise<void> {
const discoveryDir = this.callbacks.ipc.agentDiscoveryDir;
if (discoveryDir == null) return;

const target = normalizePath(workspacePath);
const ownPort = this.callbacks.ipc.port;

let files: string[];
try {
files = await readdir(discoveryDir);
} catch {
return;
}

await Promise.all(
files
.filter(f => f.startsWith('gitlens-ipc-server-') && f.endsWith('.json'))
.map(async f => {
let discovery: DiscoveryFile;
try {
discovery = JSON.parse(await readFile(join(discoveryDir, f), 'utf8')) as DiscoveryFile;
} catch {
return;
}
if (ownPort != null && discovery.port === ownPort) return;
if (!discovery.workspacePaths?.some(p => normalizePath(p) === target)) return;

try {
await fetch(`${discovery.address}/agents/sessions/open`, {
method: 'POST',
headers: {
Authorization: `Bearer ${discovery.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ sessionId: sessionId }),
signal: AbortSignal.timeout(2000),
});
} catch {
// Best-effort — vscode.openFolder still focuses the peer window.
}
}),
);
}
}
13 changes: 13 additions & 0 deletions packages/plus/agents/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ export interface AgentSessionProvider extends UnifiedDisposable {
decision: PermissionDecision,
updatedPermissions?: PermissionSuggestion[],
): boolean;

/** Asks the peer GitLens window that has `workspacePath` open to open the given session in its
* Claude Code extension (via the `agents/sessions/open` IPC route). Best-effort: resolves
* silently if no peer claims the workspace or the POST fails. Callers should follow up with
* `vscode.openFolder` so VS Code focuses that peer window. */
notifyPeerOpenSession?(workspacePath: string, sessionId: string): Promise<void>;
}

/**
Expand Down Expand Up @@ -243,4 +249,11 @@ export interface AgentProviderCallbacks {
}
| undefined
>;

/**
* Open a Claude Code session in the Claude Code VS Code extension. Invoked by the IPC handler
* when a peer GitLens window asks this window to open a session on its behalf — the host wires
* this to `claude-vscode.editor.open`. Throws if the extension isn't installed/active.
*/
openSessionInClaudeExtension?(sessionId: string): Promise<void>;
}
Loading
Loading