* 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
185 lines
5.2 KiB
TypeScript
185 lines
5.2 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|