feat: add GitHub Copilot CLI as a new provider (#425)
* feat: add GitHub Copilot CLI as a new provider Add support for GitHub Copilot CLI (@github/copilot) as a takt provider, enabling the 'copilot' command to be used for AI-driven task execution. New files: - src/infra/copilot/client.ts: CLI client with streaming, session ID extraction via --share, and permission mode mapping - src/infra/copilot/types.ts: CopilotCallOptions type definitions - src/infra/copilot/index.ts: barrel exports - src/infra/providers/copilot.ts: CopilotProvider implementing Provider - src/__tests__/copilot-client.test.ts: 20 unit tests for client - src/__tests__/copilot-provider.test.ts: 8 unit tests for provider Key features: - Spawns 'copilot -p' in non-interactive mode with --silent --no-color - Permission modes: full (--yolo), edit (--allow-all-tools --no-ask-user), readonly (no permission flags) - Session ID extraction from --share transcript files - Real-time stdout streaming via onStream callbacks - Configurable via COPILOT_CLI_PATH and COPILOT_GITHUB_TOKEN env vars * fix: remove unused COPILOT_DEFAULT_MAX_AUTOPILOT_CONTINUES constant * fix: address review feedback for copilot provider - Remove excess maxAutopilotContinues property from test (#1 High) - Extract cleanupTmpDir() helper to eliminate DRY violation (#2 Medium) - Deduplicate chunk string conversion in stdout handler (#3 Medium) - Remove 5 what/how comments that restate code (#4 Medium) - Log readFile failure instead of silently swallowing (#5 Medium) - Add credential scrubbing (ghp_/ghs_/gho_/github_pat_) for stderr (#6 Medium) - Add buffer overflow tests for stdout and stderr (#7 Medium) - Add pre-aborted AbortSignal test (#8 Low) - Add mkdtemp failure fallback test (#9 Low) - Add rm cleanup verification to fallback test (#10 Low) - Log mkdtemp failure with debug level (#11 Persist) - Add createLogger('copilot-client') for structured logging
This commit is contained in:
parent
45663342c6
commit
17232f9940
482
src/__tests__/copilot-client.test.ts
Normal file
482
src/__tests__/copilot-client.test.ts
Normal file
@ -0,0 +1,482 @@
|
|||||||
|
/**
|
||||||
|
* Tests for GitHub Copilot CLI client
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const { mockSpawn, mockMkdtemp, mockReadFile, mockRm } = vi.hoisted(() => ({
|
||||||
|
mockSpawn: vi.fn(),
|
||||||
|
mockMkdtemp: vi.fn(),
|
||||||
|
mockReadFile: vi.fn(),
|
||||||
|
mockRm: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
spawn: mockSpawn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:fs/promises', () => ({
|
||||||
|
mkdtemp: mockMkdtemp,
|
||||||
|
readFile: mockReadFile,
|
||||||
|
rm: mockRm,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { callCopilot, extractSessionIdFromShareFile } from '../infra/copilot/client.js';
|
||||||
|
|
||||||
|
type SpawnScenario = {
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
code?: number | null;
|
||||||
|
signal?: NodeJS.Signals | null;
|
||||||
|
error?: Partial<NodeJS.ErrnoException> & { message: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockChildProcess = EventEmitter & {
|
||||||
|
stdout: EventEmitter;
|
||||||
|
stderr: EventEmitter;
|
||||||
|
kill: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createMockChildProcess(): MockChildProcess {
|
||||||
|
const child = new EventEmitter() as MockChildProcess;
|
||||||
|
child.stdout = new EventEmitter();
|
||||||
|
child.stderr = new EventEmitter();
|
||||||
|
child.kill = vi.fn(() => true);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockSpawnWithScenario(scenario: SpawnScenario): void {
|
||||||
|
mockSpawn.mockImplementation((_cmd: string, _args: string[], _options: object) => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (scenario.stdout) {
|
||||||
|
child.stdout.emit('data', Buffer.from(scenario.stdout, 'utf-8'));
|
||||||
|
}
|
||||||
|
if (scenario.stderr) {
|
||||||
|
child.stderr.emit('data', Buffer.from(scenario.stderr, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenario.error) {
|
||||||
|
const error = Object.assign(new Error(scenario.error.message), scenario.error);
|
||||||
|
child.emit('error', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
child.emit('close', scenario.code ?? 0, scenario.signal ?? null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('callCopilot', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
delete process.env.COPILOT_GITHUB_TOKEN;
|
||||||
|
// Default: mkdtemp creates a temp dir, readFile returns a session transcript, rm succeeds
|
||||||
|
mockMkdtemp.mockResolvedValue('/tmp/takt-copilot-XXXXXX');
|
||||||
|
mockReadFile.mockResolvedValue(
|
||||||
|
'# 🤖 Copilot CLI Session\n\n> **Session ID:** `aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee`\n',
|
||||||
|
);
|
||||||
|
mockRm.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should invoke copilot with required args including --silent, --no-color', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'Implementation complete. All tests pass.',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement feature', {
|
||||||
|
cwd: '/repo',
|
||||||
|
model: 'claude-sonnet-4.6',
|
||||||
|
sessionId: 'sess-prev',
|
||||||
|
permissionMode: 'full',
|
||||||
|
copilotGithubToken: 'gh-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('done');
|
||||||
|
expect(result.content).toBe('Implementation complete. All tests pass.');
|
||||||
|
// Session ID extracted from --share file
|
||||||
|
expect(result.sessionId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
|
||||||
|
|
||||||
|
expect(mockSpawn).toHaveBeenCalledTimes(1);
|
||||||
|
const [command, args, options] = mockSpawn.mock.calls[0] as [string, string[], { env?: NodeJS.ProcessEnv; stdio?: unknown }];
|
||||||
|
|
||||||
|
expect(command).toBe('copilot');
|
||||||
|
// --yolo is used for full permission; --share is included for session extraction
|
||||||
|
expect(args).toContain('-p');
|
||||||
|
expect(args).toContain('--silent');
|
||||||
|
expect(args).toContain('--no-color');
|
||||||
|
expect(args).toContain('--no-auto-update');
|
||||||
|
expect(args).toContain('--model');
|
||||||
|
expect(args).toContain('--resume');
|
||||||
|
expect(args).toContain('--yolo');
|
||||||
|
expect(args).toContain('--share');
|
||||||
|
expect(options.env?.COPILOT_GITHUB_TOKEN).toBe('gh-token');
|
||||||
|
expect(options.stdio).toEqual(['ignore', 'pipe', 'pipe']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use --allow-all-tools --no-ask-user for edit permission mode (no --autopilot)', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'done',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await callCopilot('coder', 'implement feature', {
|
||||||
|
cwd: '/repo',
|
||||||
|
permissionMode: 'edit',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, args] = mockSpawn.mock.calls[0] as [string, string[]];
|
||||||
|
expect(args).toContain('--allow-all-tools');
|
||||||
|
expect(args).toContain('--no-ask-user');
|
||||||
|
expect(args).not.toContain('--yolo');
|
||||||
|
expect(args).not.toContain('--autopilot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add permission flags for readonly mode', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'done',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await callCopilot('coder', 'implement feature', {
|
||||||
|
cwd: '/repo',
|
||||||
|
permissionMode: 'readonly',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, args] = mockSpawn.mock.calls[0] as [string, string[]];
|
||||||
|
expect(args).not.toContain('--yolo');
|
||||||
|
expect(args).not.toContain('--allow-all-tools');
|
||||||
|
expect(args).not.toContain('--no-ask-user');
|
||||||
|
expect(args).not.toContain('--autopilot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not inject COPILOT_GITHUB_TOKEN when copilotGithubToken is undefined', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'done',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement feature', {
|
||||||
|
cwd: '/repo',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('done');
|
||||||
|
|
||||||
|
const [, , options] = mockSpawn.mock.calls[0] as [string, string[], { env?: NodeJS.ProcessEnv }];
|
||||||
|
expect(options.env).toBe(process.env);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom CLI path when copilotCliPath is specified', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'done',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await callCopilot('coder', 'implement', {
|
||||||
|
cwd: '/repo',
|
||||||
|
copilotCliPath: '/custom/bin/copilot',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [command] = mockSpawn.mock.calls[0] as [string];
|
||||||
|
expect(command).toBe('/custom/bin/copilot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include --autopilot or --max-autopilot-continues flags', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'done',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await callCopilot('coder', 'implement', {
|
||||||
|
cwd: '/repo',
|
||||||
|
permissionMode: 'readonly',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, args] = mockSpawn.mock.calls[0] as [string, string[]];
|
||||||
|
expect(args).not.toContain('--max-autopilot-continues');
|
||||||
|
expect(args).not.toContain('--autopilot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prepend system prompt to user prompt', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'reviewed',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await callCopilot('reviewer', 'review this code', {
|
||||||
|
cwd: '/repo',
|
||||||
|
systemPrompt: 'You are a strict reviewer.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [, args] = mockSpawn.mock.calls[0] as [string, string[]];
|
||||||
|
// -p is at index 0, prompt is at index 1
|
||||||
|
expect(args[1]).toBe('You are a strict reviewer.\n\nreview this code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return structured error when copilot binary is not found', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
error: { code: 'ENOENT', message: 'spawn copilot ENOENT' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement feature', { cwd: '/repo' });
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.content).toContain('copilot binary not found');
|
||||||
|
expect(result.content).toContain('npm install -g @github/copilot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should classify authentication errors', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
code: 1,
|
||||||
|
stderr: 'Authentication required. Not logged in.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement feature', { cwd: '/repo' });
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.content).toContain('Copilot authentication failed');
|
||||||
|
expect(result.content).toContain('TAKT_COPILOT_GITHUB_TOKEN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should classify non-zero exits with detail', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
code: 2,
|
||||||
|
stderr: 'unexpected failure',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement feature', { cwd: '/repo' });
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.content).toContain('code 2');
|
||||||
|
expect(result.content).toContain('unexpected failure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when stdout is empty', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: '',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement feature', { cwd: '/repo' });
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.content).toContain('copilot returned empty output');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return plain text content (no JSON parsing needed)', async () => {
|
||||||
|
const output = 'Here is the implementation:\n\n```typescript\nconsole.log("hello");\n```';
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: output,
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement feature', { cwd: '/repo' });
|
||||||
|
|
||||||
|
expect(result.status).toBe('done');
|
||||||
|
expect(result.content).toBe(output);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onStream callback with text and result events on success', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'stream content',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onStream = vi.fn();
|
||||||
|
await callCopilot('coder', 'implement', {
|
||||||
|
cwd: '/repo',
|
||||||
|
onStream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onStream).toHaveBeenCalledTimes(2);
|
||||||
|
expect(onStream).toHaveBeenNthCalledWith(1, {
|
||||||
|
type: 'text',
|
||||||
|
data: { text: 'stream content' },
|
||||||
|
});
|
||||||
|
expect(onStream).toHaveBeenNthCalledWith(2, {
|
||||||
|
type: 'result',
|
||||||
|
data: expect.objectContaining({
|
||||||
|
result: 'stream content',
|
||||||
|
success: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onStream callback with error result on failure', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
error: { code: 'ENOENT', message: 'spawn copilot ENOENT' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const onStream = vi.fn();
|
||||||
|
await callCopilot('coder', 'implement', {
|
||||||
|
cwd: '/repo',
|
||||||
|
onStream,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onStream).toHaveBeenCalledWith({
|
||||||
|
type: 'result',
|
||||||
|
data: expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
error: expect.stringContaining('copilot binary not found'),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle abort signal', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
mockSpawn.mockImplementation(() => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
// Simulate abort
|
||||||
|
controller.abort();
|
||||||
|
child.emit('close', null, 'SIGTERM');
|
||||||
|
});
|
||||||
|
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement', {
|
||||||
|
cwd: '/repo',
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.content).toContain('Copilot execution aborted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall back to options.sessionId when share file extraction fails', async () => {
|
||||||
|
mockReadFile.mockRejectedValue(new Error('ENOENT'));
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'done',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement', {
|
||||||
|
cwd: '/repo',
|
||||||
|
sessionId: 'fallback-session-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('done');
|
||||||
|
expect(result.sessionId).toBe('fallback-session-id');
|
||||||
|
expect(mockRm).toHaveBeenCalledWith('/tmp/takt-copilot-XXXXXX', { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract session ID from --share file on success', async () => {
|
||||||
|
mockReadFile.mockResolvedValue(
|
||||||
|
'# Session\n\n> **Session ID:** `12345678-abcd-1234-ef01-123456789012`\n',
|
||||||
|
);
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'hello',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement', {
|
||||||
|
cwd: '/repo',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('done');
|
||||||
|
expect(result.sessionId).toBe('12345678-abcd-1234-ef01-123456789012');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when stdout buffer overflows', async () => {
|
||||||
|
mockSpawn.mockImplementation(() => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
child.stdout.emit('data', Buffer.alloc(10 * 1024 * 1024 + 1));
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement', { cwd: '/repo' });
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.content).toContain('Copilot CLI output exceeded buffer limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when stderr buffer overflows', async () => {
|
||||||
|
mockSpawn.mockImplementation(() => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
child.stderr.emit('data', Buffer.alloc(10 * 1024 * 1024 + 1));
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement', { cwd: '/repo' });
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.content).toContain('Copilot CLI output exceeded buffer limit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error when abort signal is already aborted before call', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
mockSpawn.mockImplementation(() => {
|
||||||
|
const child = createMockChildProcess();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
child.emit('close', null, 'SIGTERM');
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement', {
|
||||||
|
cwd: '/repo',
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.content).toContain('Copilot execution aborted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should proceed without session extraction when mkdtemp fails', async () => {
|
||||||
|
mockMkdtemp.mockRejectedValue(new Error('ENOSPC'));
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
stdout: 'done',
|
||||||
|
code: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement', {
|
||||||
|
cwd: '/repo',
|
||||||
|
sessionId: 'existing-session-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe('done');
|
||||||
|
expect(result.sessionId).toBe('existing-session-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redact credentials from error stderr', async () => {
|
||||||
|
mockSpawnWithScenario({
|
||||||
|
code: 2,
|
||||||
|
stderr: 'config error: secret ghp_abcdefghijklmnopqrstuvwxyz1234567890 is wrong',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await callCopilot('coder', 'implement', { cwd: '/repo' });
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.content).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz1234567890');
|
||||||
|
expect(result.content).toContain('[REDACTED]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractSessionIdFromShareFile', () => {
|
||||||
|
it('should extract UUID from standard share file format', () => {
|
||||||
|
const content = '# 🤖 Copilot CLI Session\n\n> **Session ID:** `107256ee-226c-4677-bf55-7b6b158ddadf`\n';
|
||||||
|
expect(extractSessionIdFromShareFile(content)).toBe('107256ee-226c-4677-bf55-7b6b158ddadf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for content without session ID', () => {
|
||||||
|
expect(extractSessionIdFromShareFile('no session here')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for empty content', () => {
|
||||||
|
expect(extractSessionIdFromShareFile('')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
184
src/__tests__/copilot-provider.test.ts
Normal file
184
src/__tests__/copilot-provider.test.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Tests for Copilot provider implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockCallCopilot,
|
||||||
|
mockCallCopilotCustom,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockCallCopilot: vi.fn(),
|
||||||
|
mockCallCopilotCustom: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockResolveCopilotGithubToken,
|
||||||
|
mockResolveCopilotCliPath,
|
||||||
|
mockLoadProjectConfig,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockResolveCopilotGithubToken: vi.fn(() => undefined),
|
||||||
|
mockResolveCopilotCliPath: vi.fn(() => undefined),
|
||||||
|
mockLoadProjectConfig: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../infra/copilot/index.js', () => ({
|
||||||
|
callCopilot: mockCallCopilot,
|
||||||
|
callCopilotCustom: mockCallCopilotCustom,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../infra/config/index.js', () => ({
|
||||||
|
resolveCopilotGithubToken: mockResolveCopilotGithubToken,
|
||||||
|
resolveCopilotCliPath: mockResolveCopilotCliPath,
|
||||||
|
loadProjectConfig: mockLoadProjectConfig,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CopilotProvider } from '../infra/providers/copilot.js';
|
||||||
|
import { ProviderRegistry } from '../infra/providers/index.js';
|
||||||
|
|
||||||
|
function doneResponse(persona: string) {
|
||||||
|
return {
|
||||||
|
persona,
|
||||||
|
status: 'done' as const,
|
||||||
|
content: 'ok',
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CopilotProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockResolveCopilotGithubToken.mockReturnValue(undefined);
|
||||||
|
mockResolveCopilotCliPath.mockReturnValue(undefined);
|
||||||
|
mockLoadProjectConfig.mockReturnValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when claudeAgent is specified', () => {
|
||||||
|
const provider = new CopilotProvider();
|
||||||
|
|
||||||
|
expect(() => provider.setup({
|
||||||
|
name: 'test',
|
||||||
|
claudeAgent: 'some-agent',
|
||||||
|
})).toThrow('Claude Code agent calls are not supported by the Copilot provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when claudeSkill is specified', () => {
|
||||||
|
const provider = new CopilotProvider();
|
||||||
|
|
||||||
|
expect(() => provider.setup({
|
||||||
|
name: 'test',
|
||||||
|
claudeSkill: 'some-skill',
|
||||||
|
})).toThrow('Claude Code skill calls are not supported by the Copilot provider');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass model/session/permission and resolved token to callCopilot', async () => {
|
||||||
|
mockResolveCopilotGithubToken.mockReturnValue('resolved-token');
|
||||||
|
mockCallCopilot.mockResolvedValue(doneResponse('coder'));
|
||||||
|
|
||||||
|
const provider = new CopilotProvider();
|
||||||
|
const agent = provider.setup({ name: 'coder' });
|
||||||
|
|
||||||
|
await agent.call('implement', {
|
||||||
|
cwd: '/tmp/work',
|
||||||
|
model: 'claude-sonnet-4.6',
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
permissionMode: 'full',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCallCopilot).toHaveBeenCalledWith(
|
||||||
|
'coder',
|
||||||
|
'implement',
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: '/tmp/work',
|
||||||
|
model: 'claude-sonnet-4.6',
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
permissionMode: 'full',
|
||||||
|
copilotGithubToken: 'resolved-token',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer explicit copilotGithubToken over resolver', async () => {
|
||||||
|
mockResolveCopilotGithubToken.mockReturnValue('resolved-token');
|
||||||
|
mockCallCopilot.mockResolvedValue(doneResponse('coder'));
|
||||||
|
|
||||||
|
const provider = new CopilotProvider();
|
||||||
|
const agent = provider.setup({ name: 'coder' });
|
||||||
|
|
||||||
|
await agent.call('implement', {
|
||||||
|
cwd: '/tmp/work',
|
||||||
|
copilotGithubToken: 'explicit-token',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCallCopilot).toHaveBeenCalledWith(
|
||||||
|
'coder',
|
||||||
|
'implement',
|
||||||
|
expect.objectContaining({
|
||||||
|
copilotGithubToken: 'explicit-token',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delegate to callCopilotCustom when systemPrompt is specified', async () => {
|
||||||
|
mockCallCopilotCustom.mockResolvedValue(doneResponse('reviewer'));
|
||||||
|
|
||||||
|
const provider = new CopilotProvider();
|
||||||
|
const agent = provider.setup({
|
||||||
|
name: 'reviewer',
|
||||||
|
systemPrompt: 'You are a strict reviewer.',
|
||||||
|
});
|
||||||
|
|
||||||
|
await agent.call('review this', {
|
||||||
|
cwd: '/tmp/work',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCallCopilotCustom).toHaveBeenCalledWith(
|
||||||
|
'reviewer',
|
||||||
|
'review this',
|
||||||
|
'You are a strict reviewer.',
|
||||||
|
expect.objectContaining({ cwd: '/tmp/work' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass resolved copilotCliPath to callCopilot', async () => {
|
||||||
|
mockResolveCopilotCliPath.mockReturnValue('/custom/bin/copilot');
|
||||||
|
mockCallCopilot.mockResolvedValue(doneResponse('coder'));
|
||||||
|
|
||||||
|
const provider = new CopilotProvider();
|
||||||
|
const agent = provider.setup({ name: 'coder' });
|
||||||
|
|
||||||
|
await agent.call('implement', { cwd: '/tmp/work' });
|
||||||
|
|
||||||
|
expect(mockCallCopilot).toHaveBeenCalledWith(
|
||||||
|
'coder',
|
||||||
|
'implement',
|
||||||
|
expect.objectContaining({
|
||||||
|
copilotCliPath: '/custom/bin/copilot',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass undefined copilotCliPath when resolver returns undefined', async () => {
|
||||||
|
mockResolveCopilotCliPath.mockReturnValue(undefined);
|
||||||
|
mockCallCopilot.mockResolvedValue(doneResponse('coder'));
|
||||||
|
|
||||||
|
const provider = new CopilotProvider();
|
||||||
|
const agent = provider.setup({ name: 'coder' });
|
||||||
|
|
||||||
|
await agent.call('implement', { cwd: '/tmp/work' });
|
||||||
|
|
||||||
|
const opts = mockCallCopilot.mock.calls[0]?.[2];
|
||||||
|
expect(opts.copilotCliPath).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ProviderRegistry with Copilot', () => {
|
||||||
|
it('should return Copilot provider from registry', () => {
|
||||||
|
ProviderRegistry.resetInstance();
|
||||||
|
const registry = ProviderRegistry.getInstance();
|
||||||
|
const provider = registry.get('copilot');
|
||||||
|
|
||||||
|
expect(provider).toBeDefined();
|
||||||
|
expect(provider).toBeInstanceOf(CopilotProvider);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -13,9 +13,9 @@ export interface RunAgentOptions {
|
|||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
stepModel?: string;
|
stepModel?: string;
|
||||||
stepProvider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
stepProvider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
personaPath?: string;
|
personaPath?: string;
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
mcpServers?: Record<string, McpServerConfig>;
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
|
|||||||
@ -47,7 +47,7 @@ program
|
|||||||
.option('--auto-pr', 'Create PR after successful execution')
|
.option('--auto-pr', 'Create PR after successful execution')
|
||||||
.option('--draft', 'Create PR as draft (requires --auto-pr or auto_pr config)')
|
.option('--draft', 'Create PR as draft (requires --auto-pr or auto_pr config)')
|
||||||
.option('--repo <owner/repo>', 'Repository (defaults to current)')
|
.option('--repo <owner/repo>', 'Repository (defaults to current)')
|
||||||
.option('--provider <name>', 'Override agent provider (claude|codex|opencode|cursor|mock)')
|
.option('--provider <name>', 'Override agent provider (claude|codex|opencode|cursor|copilot|mock)')
|
||||||
.option('--model <name>', 'Override agent model')
|
.option('--model <name>', 'Override agent model')
|
||||||
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
|
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
|
||||||
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')
|
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.
|
|||||||
import type { ProviderPermissionProfiles } from './provider-profiles.js';
|
import type { ProviderPermissionProfiles } from './provider-profiles.js';
|
||||||
|
|
||||||
export interface PersonaProviderEntry {
|
export interface PersonaProviderEntry {
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,7 +70,7 @@ export interface NotificationSoundEventsConfig {
|
|||||||
export interface PersistedGlobalConfig {
|
export interface PersistedGlobalConfig {
|
||||||
language: Language;
|
language: Language;
|
||||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
observability?: ObservabilityConfig;
|
observability?: ObservabilityConfig;
|
||||||
analytics?: AnalyticsConfig;
|
analytics?: AnalyticsConfig;
|
||||||
@ -94,6 +94,10 @@ export interface PersistedGlobalConfig {
|
|||||||
claudeCliPath?: string;
|
claudeCliPath?: string;
|
||||||
/** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */
|
/** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */
|
||||||
cursorCliPath?: string;
|
cursorCliPath?: string;
|
||||||
|
/** External Copilot CLI path (overridden by TAKT_COPILOT_CLI_PATH env var) */
|
||||||
|
copilotCliPath?: string;
|
||||||
|
/** Copilot GitHub token (overridden by TAKT_COPILOT_GITHUB_TOKEN env var) */
|
||||||
|
copilotGithubToken?: string;
|
||||||
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
||||||
opencodeApiKey?: string;
|
opencodeApiKey?: string;
|
||||||
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
|
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
|
||||||
@ -139,7 +143,7 @@ export interface PersistedGlobalConfig {
|
|||||||
/** Project-level configuration */
|
/** Project-level configuration */
|
||||||
export interface ProjectConfig {
|
export interface ProjectConfig {
|
||||||
piece?: string;
|
piece?: string;
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
model?: string;
|
model?: string;
|
||||||
providerOptions?: MovementProviderOptions;
|
providerOptions?: MovementProviderOptions;
|
||||||
/** Provider-specific permission profiles */
|
/** Provider-specific permission profiles */
|
||||||
|
|||||||
@ -135,7 +135,7 @@ export interface PieceMovement {
|
|||||||
/** Resolved absolute path to persona prompt file (set by loader) */
|
/** Resolved absolute path to persona prompt file (set by loader) */
|
||||||
personaPath?: string;
|
personaPath?: string;
|
||||||
/** Provider override for this movement */
|
/** Provider override for this movement */
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
/** Model override for this movement */
|
/** Model override for this movement */
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Required minimum permission mode for tool execution in this movement */
|
/** Required minimum permission mode for tool execution in this movement */
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
import type { PermissionMode } from './status.js';
|
import type { PermissionMode } from './status.js';
|
||||||
|
|
||||||
/** Supported providers for profile-based permission resolution. */
|
/** Supported providers for profile-based permission resolution. */
|
||||||
export type ProviderProfileName = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
export type ProviderProfileName = 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
|
|
||||||
/** Permission profile for a single provider. */
|
/** Permission profile for a single provider. */
|
||||||
export interface ProviderPermissionProfile {
|
export interface ProviderPermissionProfile {
|
||||||
|
|||||||
@ -79,7 +79,7 @@ export const MovementProviderOptionsSchema = z.object({
|
|||||||
}).optional();
|
}).optional();
|
||||||
|
|
||||||
/** Provider key schema for profile maps */
|
/** Provider key schema for profile maps */
|
||||||
export const ProviderProfileNameSchema = z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']);
|
export const ProviderProfileNameSchema = z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']);
|
||||||
|
|
||||||
/** Provider permission profile schema */
|
/** Provider permission profile schema */
|
||||||
export const ProviderPermissionProfileSchema = z.object({
|
export const ProviderPermissionProfileSchema = z.object({
|
||||||
@ -247,7 +247,7 @@ export const ParallelSubMovementRawSchema = z.object({
|
|||||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
allowed_tools: z.array(z.string()).optional(),
|
allowed_tools: z.array(z.string()).optional(),
|
||||||
mcp_servers: McpServersSchema,
|
mcp_servers: McpServersSchema,
|
||||||
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
/** Removed legacy field (no backward compatibility) */
|
/** Removed legacy field (no backward compatibility) */
|
||||||
permission_mode: z.never().optional(),
|
permission_mode: z.never().optional(),
|
||||||
@ -280,7 +280,7 @@ export const PieceMovementRawSchema = z.object({
|
|||||||
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
|
||||||
allowed_tools: z.array(z.string()).optional(),
|
allowed_tools: z.array(z.string()).optional(),
|
||||||
mcp_servers: McpServersSchema,
|
mcp_servers: McpServersSchema,
|
||||||
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
/** Removed legacy field (no backward compatibility) */
|
/** Removed legacy field (no backward compatibility) */
|
||||||
permission_mode: z.never().optional(),
|
permission_mode: z.never().optional(),
|
||||||
@ -369,7 +369,7 @@ export const PieceConfigRawSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const PersonaProviderEntrySchema = z.object({
|
export const PersonaProviderEntrySchema = z.object({
|
||||||
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -425,7 +425,7 @@ export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfi
|
|||||||
export const GlobalConfigSchema = z.object({
|
export const GlobalConfigSchema = z.object({
|
||||||
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
|
||||||
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
|
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
|
||||||
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional().default('claude'),
|
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']).optional().default('claude'),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
observability: ObservabilityConfigSchema.optional(),
|
observability: ObservabilityConfigSchema.optional(),
|
||||||
analytics: AnalyticsConfigSchema.optional(),
|
analytics: AnalyticsConfigSchema.optional(),
|
||||||
@ -449,6 +449,10 @@ export const GlobalConfigSchema = z.object({
|
|||||||
claude_cli_path: z.string().optional(),
|
claude_cli_path: z.string().optional(),
|
||||||
/** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */
|
/** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */
|
||||||
cursor_cli_path: z.string().optional(),
|
cursor_cli_path: z.string().optional(),
|
||||||
|
/** External Copilot CLI path (overridden by TAKT_COPILOT_CLI_PATH env var) */
|
||||||
|
copilot_cli_path: z.string().optional(),
|
||||||
|
/** Copilot GitHub token (overridden by TAKT_COPILOT_GITHUB_TOKEN env var) */
|
||||||
|
copilot_github_token: z.string().optional(),
|
||||||
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
|
||||||
opencode_api_key: z.string().optional(),
|
opencode_api_key: z.string().optional(),
|
||||||
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
|
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
|
||||||
@ -463,7 +467,7 @@ export const GlobalConfigSchema = z.object({
|
|||||||
piece_categories_file: z.string().optional(),
|
piece_categories_file: z.string().optional(),
|
||||||
/** Per-persona provider and model overrides. */
|
/** Per-persona provider and model overrides. */
|
||||||
persona_providers: z.record(z.string(), z.union([
|
persona_providers: z.record(z.string(), z.union([
|
||||||
z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']),
|
z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']),
|
||||||
PersonaProviderEntrySchema,
|
PersonaProviderEntrySchema,
|
||||||
])).optional(),
|
])).optional(),
|
||||||
/** Global provider-specific options (lowest priority) */
|
/** Global provider-specific options (lowest priority) */
|
||||||
@ -503,7 +507,7 @@ export const GlobalConfigSchema = z.object({
|
|||||||
/** Project config schema */
|
/** Project config schema */
|
||||||
export const ProjectConfigSchema = z.object({
|
export const ProjectConfigSchema = z.object({
|
||||||
piece: z.string().optional(),
|
piece: z.string().optional(),
|
||||||
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'mock']).optional(),
|
provider: z.enum(['claude', 'codex', 'opencode', 'cursor', 'copilot', 'mock']).optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
provider_options: MovementProviderOptionsSchema,
|
provider_options: MovementProviderOptionsSchema,
|
||||||
provider_profiles: ProviderPermissionProfilesSchema,
|
provider_profiles: ProviderPermissionProfilesSchema,
|
||||||
@ -528,4 +532,6 @@ export const ProjectConfigSchema = z.object({
|
|||||||
codex_cli_path: z.string().optional(),
|
codex_cli_path: z.string().optional(),
|
||||||
/** cursor-agent CLI path override (project-level) */
|
/** cursor-agent CLI path override (project-level) */
|
||||||
cursor_cli_path: z.string().optional(),
|
cursor_cli_path: z.string().optional(),
|
||||||
|
/** Copilot CLI path override (project-level) */
|
||||||
|
copilot_cli_path: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export interface DecomposeTaskOptions {
|
|||||||
personaPath?: string;
|
personaPath?: string;
|
||||||
language?: Language;
|
language?: Language;
|
||||||
model?: string;
|
model?: string;
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MorePartsResponse {
|
export interface MorePartsResponse {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export const DEFAULT_PROVIDER_PERMISSION_PROFILES: ProviderPermissionProfiles =
|
|||||||
codex: { defaultPermissionMode: 'edit' },
|
codex: { defaultPermissionMode: 'edit' },
|
||||||
opencode: { defaultPermissionMode: 'edit' },
|
opencode: { defaultPermissionMode: 'edit' },
|
||||||
cursor: { defaultPermissionMode: 'edit' },
|
cursor: { defaultPermissionMode: 'edit' },
|
||||||
|
copilot: { defaultPermissionMode: 'edit' },
|
||||||
mock: { defaultPermissionMode: 'edit' },
|
mock: { defaultPermissionMode: 'edit' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import type { PersonaProviderEntry } from '../models/persisted-global-config.js'
|
|||||||
import type { ProviderPermissionProfiles } from '../models/provider-profiles.js';
|
import type { ProviderPermissionProfiles } from '../models/provider-profiles.js';
|
||||||
import type { MovementProviderOptions } from '../models/piece-types.js';
|
import type { MovementProviderOptions } from '../models/piece-types.js';
|
||||||
|
|
||||||
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
export type ProviderOptionsSource = 'env' | 'project' | 'global' | 'default';
|
export type ProviderOptionsSource = 'env' | 'project' | 'global' | 'default';
|
||||||
|
|
||||||
export interface StreamInitEventData {
|
export interface StreamInitEventData {
|
||||||
|
|||||||
3
src/infra/config/env/config-env-overrides.ts
vendored
3
src/infra/config/env/config-env-overrides.ts
vendored
@ -96,6 +96,8 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
|
|||||||
{ path: 'codex_cli_path', type: 'string' },
|
{ path: 'codex_cli_path', type: 'string' },
|
||||||
{ path: 'claude_cli_path', type: 'string' },
|
{ path: 'claude_cli_path', type: 'string' },
|
||||||
{ path: 'cursor_cli_path', type: 'string' },
|
{ path: 'cursor_cli_path', type: 'string' },
|
||||||
|
{ path: 'copilot_cli_path', type: 'string' },
|
||||||
|
{ path: 'copilot_github_token', type: 'string' },
|
||||||
{ path: 'opencode_api_key', type: 'string' },
|
{ path: 'opencode_api_key', type: 'string' },
|
||||||
{ path: 'cursor_api_key', type: 'string' },
|
{ path: 'cursor_api_key', type: 'string' },
|
||||||
{ path: 'pipeline', type: 'json' },
|
{ path: 'pipeline', type: 'json' },
|
||||||
@ -150,6 +152,7 @@ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
|
|||||||
{ path: 'claude_cli_path', type: 'string' },
|
{ path: 'claude_cli_path', type: 'string' },
|
||||||
{ path: 'codex_cli_path', type: 'string' },
|
{ path: 'codex_cli_path', type: 'string' },
|
||||||
{ path: 'cursor_cli_path', type: 'string' },
|
{ path: 'cursor_cli_path', type: 'string' },
|
||||||
|
{ path: 'copilot_cli_path', type: 'string' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void {
|
export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void {
|
||||||
|
|||||||
@ -423,7 +423,7 @@ export function setLanguage(language: Language): void {
|
|||||||
saveGlobalConfig(config);
|
saveGlobalConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor'): void {
|
export function setProvider(provider: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot'): void {
|
||||||
const config = loadGlobalConfig();
|
const config = loadGlobalConfig();
|
||||||
config.provider = provider;
|
config.provider = provider;
|
||||||
saveGlobalConfig(config);
|
saveGlobalConfig(config);
|
||||||
@ -570,3 +570,45 @@ export function resolveCursorApiKey(): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Copilot CLI path override.
|
||||||
|
* Priority: TAKT_COPILOT_CLI_PATH env var > project config > global config > undefined (default 'copilot')
|
||||||
|
*/
|
||||||
|
export function resolveCopilotCliPath(projectConfig?: { copilotCliPath?: string }): string | undefined {
|
||||||
|
const envPath = process.env[envVarNameFromPath('copilot_cli_path')];
|
||||||
|
if (envPath !== undefined) {
|
||||||
|
return validateCliPath(envPath, 'TAKT_COPILOT_CLI_PATH');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projectConfig?.copilotCliPath !== undefined) {
|
||||||
|
return validateCliPath(projectConfig.copilotCliPath, 'copilot_cli_path (project)');
|
||||||
|
}
|
||||||
|
|
||||||
|
let config: PersistedGlobalConfig;
|
||||||
|
try {
|
||||||
|
config = loadGlobalConfig();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (config.copilotCliPath === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return validateCliPath(config.copilotCliPath, 'copilot_cli_path');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Copilot GitHub token.
|
||||||
|
* Priority: TAKT_COPILOT_GITHUB_TOKEN env var > config.yaml > undefined
|
||||||
|
*/
|
||||||
|
export function resolveCopilotGithubToken(): string | undefined {
|
||||||
|
const envKey = process.env[envVarNameFromPath('copilot_github_token')];
|
||||||
|
if (envKey) return envKey;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
return config.copilotGithubToken;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -17,6 +17,8 @@ export {
|
|||||||
resolveCodexCliPath,
|
resolveCodexCliPath,
|
||||||
resolveClaudeCliPath,
|
resolveClaudeCliPath,
|
||||||
resolveCursorCliPath,
|
resolveCursorCliPath,
|
||||||
|
resolveCopilotCliPath,
|
||||||
|
resolveCopilotGithubToken,
|
||||||
resolveOpencodeApiKey,
|
resolveOpencodeApiKey,
|
||||||
resolveCursorApiKey,
|
resolveCursorApiKey,
|
||||||
validateCliPath,
|
validateCliPath,
|
||||||
|
|||||||
@ -56,12 +56,13 @@ export async function promptLanguageSelection(): Promise<Language> {
|
|||||||
* Prompt user to select provider for resources.
|
* Prompt user to select provider for resources.
|
||||||
* Exits process if cancelled (initial setup is required).
|
* Exits process if cancelled (initial setup is required).
|
||||||
*/
|
*/
|
||||||
export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode' | 'cursor'> {
|
export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot'> {
|
||||||
const options: { label: string; value: 'claude' | 'codex' | 'opencode' | 'cursor' }[] = [
|
const options: { label: string; value: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' }[] = [
|
||||||
{ label: 'Claude Code', value: 'claude' },
|
{ label: 'Claude Code', value: 'claude' },
|
||||||
{ label: 'Codex', value: 'codex' },
|
{ label: 'Codex', value: 'codex' },
|
||||||
{ label: 'OpenCode', value: 'opencode' },
|
{ label: 'OpenCode', value: 'opencode' },
|
||||||
{ label: 'Cursor Agent', value: 'cursor' },
|
{ label: 'Cursor Agent', value: 'cursor' },
|
||||||
|
{ label: 'GitHub Copilot', value: 'copilot' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = await selectOptionWithDefault(
|
const result = await selectOptionWithDefault(
|
||||||
|
|||||||
@ -160,6 +160,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|||||||
claude_cli_path,
|
claude_cli_path,
|
||||||
codex_cli_path,
|
codex_cli_path,
|
||||||
cursor_cli_path,
|
cursor_cli_path,
|
||||||
|
copilot_cli_path,
|
||||||
...rest
|
...rest
|
||||||
} = parsedConfig;
|
} = parsedConfig;
|
||||||
|
|
||||||
@ -190,6 +191,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|||||||
claudeCliPath: claude_cli_path as string | undefined,
|
claudeCliPath: claude_cli_path as string | undefined,
|
||||||
codexCliPath: codex_cli_path as string | undefined,
|
codexCliPath: codex_cli_path as string | undefined,
|
||||||
cursorCliPath: cursor_cli_path as string | undefined,
|
cursorCliPath: cursor_cli_path as string | undefined,
|
||||||
|
copilotCliPath: copilot_cli_path as string | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export interface ProjectLocalConfig {
|
|||||||
/** Current piece name */
|
/** Current piece name */
|
||||||
piece?: string;
|
piece?: string;
|
||||||
/** Provider selection for agent runtime */
|
/** Provider selection for agent runtime */
|
||||||
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
/** Model selection for agent runtime */
|
/** Model selection for agent runtime */
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Auto-create PR after worktree execution */
|
/** Auto-create PR after worktree execution */
|
||||||
@ -40,6 +40,8 @@ export interface ProjectLocalConfig {
|
|||||||
codexCliPath?: string;
|
codexCliPath?: string;
|
||||||
/** cursor-agent CLI path override (project-level) */
|
/** cursor-agent CLI path override (project-level) */
|
||||||
cursorCliPath?: string;
|
cursorCliPath?: string;
|
||||||
|
/** Copilot CLI path override (project-level) */
|
||||||
|
copilotCliPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persona session data for persistence */
|
/** Persona session data for persistence */
|
||||||
|
|||||||
503
src/infra/copilot/client.ts
Normal file
503
src/infra/copilot/client.ts
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
/**
|
||||||
|
* GitHub Copilot CLI integration for agent interactions
|
||||||
|
*
|
||||||
|
* Wraps the `copilot` CLI (@github/copilot) as a child process,
|
||||||
|
* following the same pattern as the Cursor provider.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { AgentResponse } from '../../core/models/index.js';
|
||||||
|
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
||||||
|
import type { CopilotCallOptions } from './types.js';
|
||||||
|
|
||||||
|
const log = createLogger('copilot-client');
|
||||||
|
|
||||||
|
export type { CopilotCallOptions } from './types.js';
|
||||||
|
|
||||||
|
const COPILOT_COMMAND = 'copilot';
|
||||||
|
const COPILOT_ABORTED_MESSAGE = 'Copilot execution aborted';
|
||||||
|
const COPILOT_MAX_BUFFER_BYTES = 10 * 1024 * 1024;
|
||||||
|
const COPILOT_FORCE_KILL_DELAY_MS_DEFAULT = 1_000;
|
||||||
|
const COPILOT_ERROR_DETAIL_MAX_LENGTH = 400;
|
||||||
|
|
||||||
|
function resolveForceKillDelayMs(): number {
|
||||||
|
const raw = process.env.TAKT_COPILOT_FORCE_KILL_DELAY_MS;
|
||||||
|
if (!raw) {
|
||||||
|
return COPILOT_FORCE_KILL_DELAY_MS_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return COPILOT_FORCE_KILL_DELAY_MS_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CopilotExecResult = {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CopilotExecError = Error & {
|
||||||
|
code?: string | number;
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
signal?: NodeJS.Signals | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildPrompt(prompt: string, systemPrompt?: string): string {
|
||||||
|
if (!systemPrompt) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
return `${systemPrompt}\n\n${prompt}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArgs(prompt: string, options: CopilotCallOptions & { shareFilePath?: string }): string[] {
|
||||||
|
const args = [
|
||||||
|
'-p',
|
||||||
|
buildPrompt(prompt, options.systemPrompt),
|
||||||
|
'--silent',
|
||||||
|
'--no-color',
|
||||||
|
'--no-auto-update',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (options.model) {
|
||||||
|
args.push('--model', options.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.sessionId) {
|
||||||
|
args.push('--resume', options.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission mode mapping
|
||||||
|
// Note: -p mode is already non-interactive. --autopilot and
|
||||||
|
// --max-autopilot-continues are not used because they conflict with
|
||||||
|
// permission flags in Copilot CLI v0.0.418+ and -p already implies
|
||||||
|
// single-prompt execution.
|
||||||
|
if (options.permissionMode === 'full') {
|
||||||
|
args.push('--yolo');
|
||||||
|
} else if (options.permissionMode === 'edit') {
|
||||||
|
args.push('--allow-all-tools', '--no-ask-user');
|
||||||
|
}
|
||||||
|
// 'readonly' / undefined: no permission flags (copilot runs without tool access)
|
||||||
|
|
||||||
|
// --share exports session transcript to a markdown file, which we parse
|
||||||
|
// to extract the session ID for later resumption.
|
||||||
|
if (options.shareFilePath) {
|
||||||
|
args.push('--share', options.shareFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEnv(copilotGithubToken?: string): NodeJS.ProcessEnv {
|
||||||
|
if (!copilotGithubToken) {
|
||||||
|
return process.env;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...process.env,
|
||||||
|
COPILOT_GITHUB_TOKEN: copilotGithubToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createExecError(
|
||||||
|
message: string,
|
||||||
|
params: {
|
||||||
|
code?: string | number;
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
signal?: NodeJS.Signals | null;
|
||||||
|
name?: string;
|
||||||
|
} = {},
|
||||||
|
): CopilotExecError {
|
||||||
|
const error = new Error(message) as CopilotExecError;
|
||||||
|
if (params.name) {
|
||||||
|
error.name = params.name;
|
||||||
|
}
|
||||||
|
error.code = params.code;
|
||||||
|
error.stdout = params.stdout;
|
||||||
|
error.stderr = params.stderr;
|
||||||
|
error.signal = params.signal;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function execCopilot(args: string[], options: CopilotCallOptions): Promise<CopilotExecResult> {
|
||||||
|
return new Promise<CopilotExecResult>((resolve, reject) => {
|
||||||
|
const child = spawn(options.copilotCliPath ?? COPILOT_COMMAND, args, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
env: buildEnv(options.copilotGithubToken),
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
let stdoutBytes = 0;
|
||||||
|
let stderrBytes = 0;
|
||||||
|
let settled = false;
|
||||||
|
let abortTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const abortHandler = (): void => {
|
||||||
|
if (settled) return;
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
const forceKillDelayMs = resolveForceKillDelayMs();
|
||||||
|
abortTimer = setTimeout(() => {
|
||||||
|
if (!settled) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, forceKillDelayMs);
|
||||||
|
abortTimer.unref?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
if (abortTimer !== undefined) {
|
||||||
|
clearTimeout(abortTimer);
|
||||||
|
}
|
||||||
|
if (options.abortSignal) {
|
||||||
|
options.abortSignal.removeEventListener('abort', abortHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveOnce = (result: CopilotExecResult): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectOnce = (error: CopilotExecError): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendChunk = (target: 'stdout' | 'stderr', chunk: Buffer | string): void => {
|
||||||
|
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
|
||||||
|
const byteLength = Buffer.byteLength(text);
|
||||||
|
|
||||||
|
if (target === 'stdout') {
|
||||||
|
stdoutBytes += byteLength;
|
||||||
|
if (stdoutBytes > COPILOT_MAX_BUFFER_BYTES) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
rejectOnce(createExecError('copilot stdout exceeded buffer limit', {
|
||||||
|
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stdout += text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrBytes += byteLength;
|
||||||
|
if (stderrBytes > COPILOT_MAX_BUFFER_BYTES) {
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
rejectOnce(createExecError('copilot stderr exceeded buffer limit', {
|
||||||
|
code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER',
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stderr += text;
|
||||||
|
};
|
||||||
|
|
||||||
|
child.stdout?.on('data', (chunk: Buffer | string) => {
|
||||||
|
appendChunk('stdout', chunk);
|
||||||
|
if (options.onStream) {
|
||||||
|
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
|
||||||
|
if (text) {
|
||||||
|
options.onStream({ type: 'text', data: { text } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr?.on('data', (chunk: Buffer | string) => appendChunk('stderr', chunk));
|
||||||
|
|
||||||
|
child.on('error', (error: NodeJS.ErrnoException) => {
|
||||||
|
rejectOnce(createExecError(error.message, {
|
||||||
|
code: error.code,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
|
||||||
|
if (settled) return;
|
||||||
|
|
||||||
|
if (options.abortSignal?.aborted) {
|
||||||
|
rejectOnce(createExecError(COPILOT_ABORTED_MESSAGE, {
|
||||||
|
name: 'AbortError',
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
signal,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 0) {
|
||||||
|
resolveOnce({ stdout, stderr });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectOnce(createExecError(
|
||||||
|
signal
|
||||||
|
? `copilot terminated by signal ${signal}`
|
||||||
|
: `copilot exited with code ${code ?? 'unknown'}`,
|
||||||
|
{
|
||||||
|
code: code ?? undefined,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.abortSignal) {
|
||||||
|
if (options.abortSignal.aborted) {
|
||||||
|
abortHandler();
|
||||||
|
} else {
|
||||||
|
options.abortSignal.addEventListener('abort', abortHandler, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const CREDENTIAL_PATTERNS = [
|
||||||
|
/ghp_[A-Za-z0-9_]{36,}/g,
|
||||||
|
/ghs_[A-Za-z0-9_]{36,}/g,
|
||||||
|
/gho_[A-Za-z0-9_]{36,}/g,
|
||||||
|
/github_pat_[A-Za-z0-9_]{82,}/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
function redactCredentials(text: string): string {
|
||||||
|
let result = text;
|
||||||
|
for (const pattern of CREDENTIAL_PATTERNS) {
|
||||||
|
result = result.replace(pattern, '[REDACTED]');
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimDetail(value: string | undefined, fallback = ''): string {
|
||||||
|
const normalized = (value ?? '').trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const redacted = redactCredentials(normalized);
|
||||||
|
return redacted.length > COPILOT_ERROR_DETAIL_MAX_LENGTH
|
||||||
|
? `${redacted.slice(0, COPILOT_ERROR_DETAIL_MAX_LENGTH)}...`
|
||||||
|
: redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAuthenticationError(error: CopilotExecError): boolean {
|
||||||
|
const message = [
|
||||||
|
trimDetail(error.message),
|
||||||
|
trimDetail(error.stderr),
|
||||||
|
trimDetail(error.stdout),
|
||||||
|
].join('\n').toLowerCase();
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
'authentication',
|
||||||
|
'unauthorized',
|
||||||
|
'forbidden',
|
||||||
|
'not logged in',
|
||||||
|
'login required',
|
||||||
|
'token',
|
||||||
|
'copilot_github_token',
|
||||||
|
'gh_token',
|
||||||
|
'github_token',
|
||||||
|
];
|
||||||
|
return patterns.some((pattern) => message.includes(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyExecutionError(error: CopilotExecError, options: CopilotCallOptions): string {
|
||||||
|
if (options.abortSignal?.aborted || error.name === 'AbortError') {
|
||||||
|
return COPILOT_ABORTED_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
|
||||||
|
return 'Copilot CLI output exceeded buffer limit';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return 'copilot binary not found. Install GitHub Copilot CLI (`npm install -g @github/copilot`) and ensure `copilot` is in PATH.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticationError(error)) {
|
||||||
|
return 'Copilot authentication failed. Run `copilot auth login` or set TAKT_COPILOT_GITHUB_TOKEN / COPILOT_GITHUB_TOKEN / GH_TOKEN.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error.code === 'number') {
|
||||||
|
const detail = trimDetail(error.stderr, trimDetail(error.stdout, getErrorMessage(error)));
|
||||||
|
return `Copilot CLI exited with code ${error.code}: ${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getErrorMessage(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract session ID from the --share markdown file content.
|
||||||
|
*
|
||||||
|
* The file format includes a line like:
|
||||||
|
* > **Session ID:** `107256ee-226c-4677-bf55-7b6b158ddadf`
|
||||||
|
*/
|
||||||
|
const SESSION_ID_PATTERN = /\*\*Session ID:\*\*\s*`([0-9a-f-]{36})`/i;
|
||||||
|
|
||||||
|
export function extractSessionIdFromShareFile(content: string): string | undefined {
|
||||||
|
const match = content.match(SESSION_ID_PATTERN);
|
||||||
|
return match?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and extract session ID from a --share transcript file, then clean up.
|
||||||
|
*/
|
||||||
|
function cleanupTmpDir(dir?: string): void {
|
||||||
|
if (dir) {
|
||||||
|
rm(dir, { recursive: true, force: true }).catch((err) => {
|
||||||
|
log.debug('Failed to clean up tmp dir', { dir, err });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractAndCleanupSessionId(shareFilePath: string, shareTmpDir: string): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(shareFilePath, 'utf-8');
|
||||||
|
return extractSessionIdFromShareFile(content);
|
||||||
|
} catch (err) {
|
||||||
|
log.debug('readFile share transcript failed', { shareFilePath, err });
|
||||||
|
return undefined;
|
||||||
|
} finally {
|
||||||
|
cleanupTmpDir(shareTmpDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Copilot CLI output.
|
||||||
|
*
|
||||||
|
* Since Copilot CLI does not support JSON output mode,
|
||||||
|
* we use --silent --no-color and treat stdout as plain text content.
|
||||||
|
*/
|
||||||
|
function parseCopilotOutput(stdout: string): { content: string } | { error: string } {
|
||||||
|
const trimmed = stdout.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { error: 'copilot returned empty output' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { content: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for GitHub Copilot CLI interactions.
|
||||||
|
*/
|
||||||
|
export class CopilotClient {
|
||||||
|
async call(agentType: string, prompt: string, options: CopilotCallOptions): Promise<AgentResponse> {
|
||||||
|
// Create a temp directory for --share session transcript
|
||||||
|
let shareTmpDir: string | undefined;
|
||||||
|
let shareFilePath: string | undefined;
|
||||||
|
try {
|
||||||
|
shareTmpDir = await mkdtemp(join(tmpdir(), 'takt-copilot-'));
|
||||||
|
shareFilePath = join(shareTmpDir, 'session.md');
|
||||||
|
} catch (err) {
|
||||||
|
log.debug('mkdtemp failed, skipping session extraction', { err });
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = buildArgs(prompt, { ...options, shareFilePath });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stdout } = await execCopilot(args, options);
|
||||||
|
const parsed = parseCopilotOutput(stdout);
|
||||||
|
if ('error' in parsed) {
|
||||||
|
cleanupTmpDir(shareTmpDir);
|
||||||
|
return {
|
||||||
|
persona: agentType,
|
||||||
|
status: 'error',
|
||||||
|
content: parsed.error,
|
||||||
|
timestamp: new Date(),
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedSessionId = (shareFilePath && shareTmpDir)
|
||||||
|
? await extractAndCleanupSessionId(shareFilePath, shareTmpDir)
|
||||||
|
: undefined;
|
||||||
|
const sessionId = extractedSessionId ?? options.sessionId;
|
||||||
|
|
||||||
|
if (options.onStream) {
|
||||||
|
options.onStream({
|
||||||
|
type: 'result',
|
||||||
|
data: {
|
||||||
|
result: parsed.content,
|
||||||
|
success: true,
|
||||||
|
sessionId: sessionId ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
persona: agentType,
|
||||||
|
status: 'done',
|
||||||
|
content: parsed.content,
|
||||||
|
timestamp: new Date(),
|
||||||
|
sessionId,
|
||||||
|
};
|
||||||
|
} catch (rawError) {
|
||||||
|
cleanupTmpDir(shareTmpDir);
|
||||||
|
const error = rawError as CopilotExecError;
|
||||||
|
const message = classifyExecutionError(error, options);
|
||||||
|
if (options.onStream) {
|
||||||
|
options.onStream({
|
||||||
|
type: 'result',
|
||||||
|
data: {
|
||||||
|
result: '',
|
||||||
|
success: false,
|
||||||
|
error: message,
|
||||||
|
sessionId: options.sessionId ?? '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
persona: agentType,
|
||||||
|
status: 'error',
|
||||||
|
content: message,
|
||||||
|
timestamp: new Date(),
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async callCustom(
|
||||||
|
agentName: string,
|
||||||
|
prompt: string,
|
||||||
|
systemPrompt: string,
|
||||||
|
options: CopilotCallOptions,
|
||||||
|
): Promise<AgentResponse> {
|
||||||
|
return this.call(agentName, prompt, {
|
||||||
|
...options,
|
||||||
|
systemPrompt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultClient = new CopilotClient();
|
||||||
|
|
||||||
|
export async function callCopilot(
|
||||||
|
agentType: string,
|
||||||
|
prompt: string,
|
||||||
|
options: CopilotCallOptions,
|
||||||
|
): Promise<AgentResponse> {
|
||||||
|
return defaultClient.call(agentType, prompt, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function callCopilotCustom(
|
||||||
|
agentName: string,
|
||||||
|
prompt: string,
|
||||||
|
systemPrompt: string,
|
||||||
|
options: CopilotCallOptions,
|
||||||
|
): Promise<AgentResponse> {
|
||||||
|
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
|
||||||
|
}
|
||||||
6
src/infra/copilot/index.ts
Normal file
6
src/infra/copilot/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Copilot integration exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { CopilotClient, callCopilot, callCopilotCustom } from './client.js';
|
||||||
|
export type { CopilotCallOptions } from './types.js';
|
||||||
21
src/infra/copilot/types.ts
Normal file
21
src/infra/copilot/types.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Type definitions for GitHub Copilot CLI integration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { StreamCallback } from '../claude/index.js';
|
||||||
|
import type { PermissionMode } from '../../core/models/index.js';
|
||||||
|
|
||||||
|
/** Options for calling GitHub Copilot CLI */
|
||||||
|
export interface CopilotCallOptions {
|
||||||
|
cwd: string;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
sessionId?: string;
|
||||||
|
model?: string;
|
||||||
|
systemPrompt?: string;
|
||||||
|
permissionMode?: PermissionMode;
|
||||||
|
onStream?: StreamCallback;
|
||||||
|
/** GitHub token for Copilot authentication */
|
||||||
|
copilotGithubToken?: string;
|
||||||
|
/** Custom path to copilot executable */
|
||||||
|
copilotCliPath?: string;
|
||||||
|
}
|
||||||
62
src/infra/providers/copilot.ts
Normal file
62
src/infra/providers/copilot.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Copilot provider implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { callCopilot, callCopilotCustom, type CopilotCallOptions } from '../copilot/index.js';
|
||||||
|
import { resolveCopilotGithubToken, resolveCopilotCliPath, loadProjectConfig } from '../config/index.js';
|
||||||
|
import { createLogger } from '../../shared/utils/index.js';
|
||||||
|
import type { AgentResponse } from '../../core/models/index.js';
|
||||||
|
import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
|
||||||
|
|
||||||
|
const log = createLogger('copilot-provider');
|
||||||
|
|
||||||
|
function toCopilotOptions(options: ProviderCallOptions): CopilotCallOptions {
|
||||||
|
if (options.allowedTools && options.allowedTools.length > 0) {
|
||||||
|
log.info('Copilot provider does not support allowedTools; ignoring');
|
||||||
|
}
|
||||||
|
if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
|
||||||
|
log.info('Copilot provider does not support mcpServers in non-interactive mode; ignoring');
|
||||||
|
}
|
||||||
|
if (options.outputSchema) {
|
||||||
|
log.info('Copilot provider does not support outputSchema; ignoring');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectConfig = loadProjectConfig(options.cwd);
|
||||||
|
return {
|
||||||
|
cwd: options.cwd,
|
||||||
|
abortSignal: options.abortSignal,
|
||||||
|
sessionId: options.sessionId,
|
||||||
|
model: options.model,
|
||||||
|
permissionMode: options.permissionMode,
|
||||||
|
onStream: options.onStream,
|
||||||
|
copilotGithubToken: options.copilotGithubToken ?? resolveCopilotGithubToken(),
|
||||||
|
copilotCliPath: resolveCopilotCliPath(projectConfig),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copilot provider — delegates to GitHub Copilot CLI */
|
||||||
|
export class CopilotProvider implements Provider {
|
||||||
|
setup(config: AgentSetup): ProviderAgent {
|
||||||
|
if (config.claudeAgent) {
|
||||||
|
throw new Error('Claude Code agent calls are not supported by the Copilot provider');
|
||||||
|
}
|
||||||
|
if (config.claudeSkill) {
|
||||||
|
throw new Error('Claude Code skill calls are not supported by the Copilot provider');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, systemPrompt } = config;
|
||||||
|
if (systemPrompt) {
|
||||||
|
return {
|
||||||
|
call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
|
||||||
|
return callCopilotCustom(name, prompt, systemPrompt, toCopilotOptions(options));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
|
||||||
|
return callCopilot(name, prompt, toCopilotOptions(options));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import { ClaudeProvider } from './claude.js';
|
|||||||
import { CodexProvider } from './codex.js';
|
import { CodexProvider } from './codex.js';
|
||||||
import { OpenCodeProvider } from './opencode.js';
|
import { OpenCodeProvider } from './opencode.js';
|
||||||
import { CursorProvider } from './cursor.js';
|
import { CursorProvider } from './cursor.js';
|
||||||
|
import { CopilotProvider } from './copilot.js';
|
||||||
import { MockProvider } from './mock.js';
|
import { MockProvider } from './mock.js';
|
||||||
import type { Provider, ProviderType } from './types.js';
|
import type { Provider, ProviderType } from './types.js';
|
||||||
|
|
||||||
@ -28,6 +29,7 @@ export class ProviderRegistry {
|
|||||||
codex: new CodexProvider(),
|
codex: new CodexProvider(),
|
||||||
opencode: new OpenCodeProvider(),
|
opencode: new OpenCodeProvider(),
|
||||||
cursor: new CursorProvider(),
|
cursor: new CursorProvider(),
|
||||||
|
copilot: new CopilotProvider(),
|
||||||
mock: new MockProvider(),
|
mock: new MockProvider(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ export interface ProviderCallOptions {
|
|||||||
openaiApiKey?: string;
|
openaiApiKey?: string;
|
||||||
opencodeApiKey?: string;
|
opencodeApiKey?: string;
|
||||||
cursorApiKey?: string;
|
cursorApiKey?: string;
|
||||||
|
copilotGithubToken?: string;
|
||||||
outputSchema?: Record<string, unknown>;
|
outputSchema?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,4 +51,4 @@ export interface Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Provider type */
|
/** Provider type */
|
||||||
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'mock';
|
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user