feat(cli): add GitHub Copilot CLI agent support via ACP (#1245)

* feat(cli): add GitHub Copilot CLI agent support via ACP

Wrap `copilot --acp --stdio` for remote sessions and spawn the native TUI locally, with full hub/web integration for spawn, resume, and permissions.

Fixes tiann/hapi#362

Co-Authored-By: HAPI <noreply@hapi.run>
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(copilot): agent modes, models, slash/file UX, local session sync

Add Interactive/Plan/Autopilot (fleet is slash-only), subscription-aware
model discovery, web StatusBar/permission UX, @ file mentions, and fix
local Safe Yolo plus session-id locator for handoff/resume.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: re-trigger Codex PR review after auth outage

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: retry Codex PR review

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(copilot): preserve agent mode on resume and apply via ACP set_mode

Resume was dropping copilotAgentMode so Plan/Autopilot reset to interactive.
Also switch local/remote mode application to --mode / session set_mode instead of slash prompts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(copilot): wake remote loop when agent mode changes

Empty isolated queue tick lets setMode apply without inventing a user prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(copilot): confirm mode changes before persisting

Await Copilot mode changes and expose discovered models so session state reflects backend acceptance.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(copilot): guard mode discovery and slash updates

Keep model probes within runner roots and preserve active sessions when mode switching is unavailable or rejected.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(copilot): preserve resume and auto semantics

Deduplicate Copilot resume rows, apply Auto explicitly, and fail closed on denied permissions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(copilot): close permission and model discovery gaps

Keep write-capable commands pending in read-only mode, extend model probe RPCs, and preserve explicit model validation before session creation.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(copilot): persist runtime model and agent mode

Fallback to ACP model options when direct model switching is unavailable and retain Copilot agent mode across hub restarts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(copilot): normalize composer auto selection

Use the null session sentinel for Copilot Auto so the composer selects and resets default models consistently.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(copilot): reject local permission mode changes

* style(copilot): remove trailing blank line

* fix(copilot): secure local config handoffs

* fix(copilot): reject local agent mode slashes

* fix(copilot): reject mode changes during turns

* fix(copilot): consume rejected slash updates

* fix(copilot): preserve thinking across slash handling

* fix(copilot): stabilize async config changes

* fix(copilot): roll back rejected startup model

* fix(copilot): preserve cancellation and file mentions

* fix(copilot): hide local permission controls

* fix(deps): support clean workspace installs

* test(copilot): account for spawn mode argument

* fix(copilot): attribute usage to active model

---------

Co-authored-by: HAPI <noreply@hapi.run>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SSU-WEI HUANG
2026-08-04 08:19:12 +08:00
committed by GitHub
co-authored by Cursor HAPI
parent b67f4e56e5
commit f10fbc7496
98 changed files with 4255 additions and 56 deletions
+10
View File
@@ -0,0 +1,10 @@
export type CopilotModelSource = 'explicit' | 'default';
export function resolveCopilotRuntimeConfig(opts: {
model?: string;
} = {}): { model: string | undefined; modelSource: CopilotModelSource } {
if (opts.model) {
return { model: opts.model, modelSource: 'explicit' };
}
return { model: undefined, modelSource: 'default' };
}
@@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest';
import { buildCopilotAcpArgs, createCopilotBackend } from './copilotBackend';
describe('buildCopilotAcpArgs', () => {
test('defaults to acp stdio without mode', () => {
expect(buildCopilotAcpArgs()).toEqual(['--acp', '--stdio']);
expect(buildCopilotAcpArgs({ agentMode: 'interactive' })).toEqual(['--acp', '--stdio']);
});
test('passes --mode for plan and autopilot', () => {
expect(buildCopilotAcpArgs({ agentMode: 'plan' })).toEqual(['--acp', '--stdio', '--mode', 'plan']);
expect(buildCopilotAcpArgs({ agentMode: 'autopilot' })).toEqual([
'--acp',
'--stdio',
'--mode',
'autopilot'
]);
});
});
describe('createCopilotBackend', () => {
test('creates an ACP backend for copilot', () => {
const backend = createCopilotBackend({ agentMode: 'plan' });
expect(backend).toBeDefined();
});
});
+29
View File
@@ -0,0 +1,29 @@
import { AcpSdkBackend } from '@/agent/backends/acp';
import type { CopilotAgentMode } from '@hapi/protocol';
function filterEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
/** ACP process args. Non-interactive modes use `--mode` so Plan/Autopilot apply at spawn. */
export function buildCopilotAcpArgs(opts?: { agentMode?: CopilotAgentMode }): string[] {
const args = ['--acp', '--stdio'];
if (opts?.agentMode && opts.agentMode !== 'interactive') {
args.push('--mode', opts.agentMode);
}
return args;
}
export function createCopilotBackend(opts?: { agentMode?: CopilotAgentMode }): AcpSdkBackend {
return new AcpSdkBackend({
command: process.env.COPILOT_CLI_PATH ?? 'copilot',
args: buildCopilotAcpArgs(opts),
env: filterEnv(process.env)
});
}
@@ -0,0 +1,137 @@
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mapCopilotLocalApprovalMode } from '../copilotLocalLauncher';
import {
createCopilotSessionLocator,
parseCopilotWorkspaceYaml
} from './copilotSessionLocator';
describe('mapCopilotLocalApprovalMode', () => {
it('only escalates full yolo to --allow-all', () => {
expect(mapCopilotLocalApprovalMode('yolo')).toEqual({ yolo: true });
expect(mapCopilotLocalApprovalMode('safe-yolo')).toEqual({ yolo: false });
expect(mapCopilotLocalApprovalMode('default')).toEqual({ yolo: false });
expect(mapCopilotLocalApprovalMode('read-only')).toEqual({ yolo: false });
expect(mapCopilotLocalApprovalMode(undefined)).toEqual({ yolo: false });
});
});
describe('parseCopilotWorkspaceYaml', () => {
it('reads id and cwd fields', () => {
expect(parseCopilotWorkspaceYaml([
'id: abc-123',
'cwd: /home/ubuntu/hapi',
'branch: main'
].join('\n'))).toEqual({
id: 'abc-123',
cwd: '/home/ubuntu/hapi'
});
});
it('unquotes special characters in cwd values', () => {
expect(parseCopilotWorkspaceYaml([
'id: abc-123',
'cwd: "/home/ubuntu/project #1"'
].join('\n'))).toEqual({
id: 'abc-123',
cwd: '/home/ubuntu/project #1'
});
});
});
describe('createCopilotSessionLocator', () => {
const locators: Array<{ cleanup: () => Promise<void> }> = [];
afterEach(async () => {
while (locators.length > 0) {
await locators.pop()?.cleanup();
}
});
it('locates a fresh session for the working directory', async () => {
const root = await mkdtemp(join(tmpdir(), 'copilot-locator-'));
const sessionId = 'fresh-session-1';
const sessionDir = join(root, sessionId);
const located = vi.fn();
const locator = createCopilotSessionLocator({
cwd: '/work/project',
startupTimestampMs: Date.now() - 1000,
sessionStateRoot: root,
intervalMs: 50,
onLocated: located
});
locators.push(locator);
await locator.ready;
await mkdir(sessionDir, { recursive: true });
await writeFile(join(sessionDir, 'workspace.yaml'), [
`id: ${sessionId}`,
'cwd: /work/project',
'client_name: github/cli'
].join('\n'));
await vi.waitFor(() => {
expect(located).toHaveBeenCalledWith({
sessionId,
sessionDir
});
}, { timeout: 2000 });
});
it('locates a session with a quoted cwd containing special characters', async () => {
const root = await mkdtemp(join(tmpdir(), 'copilot-locator-'));
const sessionId = 'quoted-cwd';
const sessionDir = join(root, sessionId);
const cwd = '/work/project #1';
const located = vi.fn();
const locator = createCopilotSessionLocator({
cwd,
startupTimestampMs: Date.now() - 1000,
sessionStateRoot: root,
intervalMs: 50,
onLocated: located
});
locators.push(locator);
await locator.ready;
await mkdir(sessionDir, { recursive: true });
await writeFile(join(sessionDir, 'workspace.yaml'), [
`id: ${sessionId}`,
`cwd: "${cwd}"`
].join('\n'));
await vi.waitFor(() => {
expect(located).toHaveBeenCalledWith({
sessionId,
sessionDir
});
}, { timeout: 2000 });
});
it('ignores sessions for other working directories', async () => {
const root = await mkdtemp(join(tmpdir(), 'copilot-locator-'));
const sessionDir = join(root, 'other-cwd');
await mkdir(sessionDir, { recursive: true });
await writeFile(join(sessionDir, 'workspace.yaml'), [
'id: other-cwd',
'cwd: /work/other',
'client_name: github/cli'
].join('\n'));
const located = vi.fn();
const locator = createCopilotSessionLocator({
cwd: '/work/project',
startupTimestampMs: Date.now() - 1000,
sessionStateRoot: root,
intervalMs: 50,
onLocated: located
});
locators.push(locator);
await locator.ready;
await new Promise((resolve) => setTimeout(resolve, 200));
expect(located).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,220 @@
import { readdir, readFile, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import { parse as parseYaml } from 'yaml';
import { logger } from '@/ui/logger';
export type LocatedCopilotSession = {
sessionId: string;
sessionDir: string;
};
export type CopilotSessionLocator = {
ready: Promise<void>;
cleanup: () => Promise<void>;
};
type CopilotSessionLocatorOptions = {
cwd: string;
startupTimestampMs: number;
resumeSessionId?: string | null;
intervalMs?: number;
sessionStateRoot?: string;
onLocated: (located: LocatedCopilotSession) => void;
onAmbiguous?: (sessionIds: string[]) => void;
};
const DEFAULT_LOCATOR_INTERVAL_MS = 500;
const STARTUP_GRACE_MS = 2000;
export function getCopilotSessionStateRoot(): string {
return join(process.env.HOME ?? process.env.USERPROFILE ?? homedir(), '.copilot', 'session-state');
}
function normalizePath(path: string): string {
return resolve(path).replace(/\\/g, '/').replace(/\/+$/, '');
}
/** Minimal workspace.yaml field extraction (id + cwd). */
export function parseCopilotWorkspaceYaml(content: string): { id?: string; cwd?: string } {
let parsed: unknown;
try {
parsed = parseYaml(content);
} catch {
return {};
}
if (!parsed || typeof parsed !== 'object') {
return {};
}
const { id, cwd } = parsed as { id?: unknown; cwd?: unknown };
return {
id: typeof id === 'string' ? id : undefined,
cwd: typeof cwd === 'string' ? cwd : undefined
};
}
/**
* Polls ~/.copilot/session-state for the session the locally spawned Copilot
* TUI just created in this working directory, then persists its id via onLocated.
*/
export function createCopilotSessionLocator(options: CopilotSessionLocatorOptions): CopilotSessionLocator {
const locator = new CopilotSessionLocatorImpl(options);
const ready = locator.start().catch((error) => {
logger.debug('[copilot-session-locator] Failed to initialize', error);
});
return {
ready,
cleanup: async () => {
await locator.cleanup();
await ready;
}
};
}
class CopilotSessionLocatorImpl {
private readonly sessionStateRoot: string;
private readonly targetCwd: string;
private readonly startupTimestampMs: number;
private readonly resumeSessionId: string | null;
private readonly intervalMs: number;
private readonly onLocated: CopilotSessionLocatorOptions['onLocated'];
private readonly onAmbiguous?: CopilotSessionLocatorOptions['onAmbiguous'];
private readonly initialSessionIds = new Set<string>();
private interval: ReturnType<typeof setInterval> | null = null;
private scanPromise: Promise<void> | null = null;
private stopped = false;
constructor(options: CopilotSessionLocatorOptions) {
this.sessionStateRoot = options.sessionStateRoot ?? getCopilotSessionStateRoot();
this.targetCwd = normalizePath(options.cwd);
this.startupTimestampMs = options.startupTimestampMs;
this.resumeSessionId = options.resumeSessionId ?? null;
this.intervalMs = options.intervalMs ?? DEFAULT_LOCATOR_INTERVAL_MS;
this.onLocated = options.onLocated;
this.onAmbiguous = options.onAmbiguous;
}
async start(): Promise<void> {
if (!this.resumeSessionId) {
try {
const entries = await readdir(this.sessionStateRoot, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
this.initialSessionIds.add(entry.name);
}
}
} catch {
// Session-state root may not exist yet.
}
}
if (this.stopped) return;
void this.scan();
this.interval = setInterval(() => void this.scan(), this.intervalMs);
this.interval.unref?.();
}
async cleanup(): Promise<void> {
this.stopped = true;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
await this.scanPromise?.catch(() => {});
}
private stopPolling(): void {
this.stopped = true;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
private async scan(): Promise<void> {
if (this.stopped || this.scanPromise) {
return this.scanPromise ?? Promise.resolve();
}
this.scanPromise = this.runScan();
try {
await this.scanPromise;
} finally {
this.scanPromise = null;
}
}
private async runScan(): Promise<void> {
const candidates = await this.listCandidates();
if (this.stopped || candidates.length === 0) {
return;
}
if (candidates.length > 1) {
logger.warn(
`[copilot-session-locator] Ambiguous Copilot sessions (${candidates.length} fresh candidates); refusing attachment`,
candidates.map((candidate) => candidate.sessionId)
);
this.stopPolling();
this.onAmbiguous?.(candidates.map((candidate) => candidate.sessionId));
return;
}
const [located] = candidates;
logger.debug(`[copilot-session-locator] Located ${located.sessionId}`);
this.stopPolling();
this.onLocated(located);
}
private async listCandidates(): Promise<LocatedCopilotSession[]> {
let entries;
try {
entries = await readdir(this.sessionStateRoot, { withFileTypes: true });
} catch {
return [];
}
const candidates: LocatedCopilotSession[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
if (this.resumeSessionId) {
if (entry.name !== this.resumeSessionId) {
continue;
}
} else if (this.initialSessionIds.has(entry.name)) {
continue;
}
const sessionDir = join(this.sessionStateRoot, entry.name);
const workspacePath = join(sessionDir, 'workspace.yaml');
const workspaceStats = await stat(workspacePath).catch(() => null);
if (!workspaceStats || !workspaceStats.isFile()) {
continue;
}
if (!this.resumeSessionId) {
const birthMs = workspaceStats.birthtimeMs || workspaceStats.ctimeMs;
if (birthMs + STARTUP_GRACE_MS < this.startupTimestampMs) {
continue;
}
}
const content = await readFile(workspacePath, 'utf8').catch(() => null);
if (!content) {
continue;
}
const parsed = parseCopilotWorkspaceYaml(content);
const sessionId = parsed.id ?? entry.name;
if (this.resumeSessionId && sessionId !== this.resumeSessionId) {
continue;
}
if (!parsed.cwd || normalizePath(parsed.cwd) !== this.targetCwd) {
continue;
}
candidates.push({ sessionId, sessionDir });
}
return candidates;
}
}
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest';
import type { AgentBackend, PermissionRequest } from '@/agent/types';
import type { ApiSessionClient } from '@/api/apiSession';
import { CopilotPermissionHandler, mapCopilotPermissionDecision } from './permissionHandler';
describe('mapCopilotPermissionDecision', () => {
it('cancels an explicit denial when only an allow option is available', () => {
const request: PermissionRequest = {
id: 'permission-1',
sessionId: 'session-1',
toolCallId: 'tool-1',
options: [{
optionId: 'allow-once',
name: 'Allow once',
kind: 'allow_once'
}]
};
expect(mapCopilotPermissionDecision(request, 'denied')).toEqual({
outcome: 'cancelled'
});
});
it('cancels an explicit approval when only a reject option is available', () => {
const request: PermissionRequest = {
id: 'permission-1',
sessionId: 'session-1',
toolCallId: 'tool-1',
options: [{
optionId: 'reject-once',
name: 'Reject once',
kind: 'reject_once'
}]
};
expect(mapCopilotPermissionDecision(request, 'approved')).toEqual({
outcome: 'cancelled'
});
});
it('keeps Bash pending in read-only mode', () => {
let onPermissionRequest: ((request: PermissionRequest) => void) | undefined;
const updateAgentState = vi.fn();
const backend = {
onPermissionRequest: (handler: (request: PermissionRequest) => void) => {
onPermissionRequest = handler;
},
respondToPermission: vi.fn()
} as unknown as AgentBackend;
const session = {
rpcHandlerManager: { registerHandler: vi.fn() },
updateAgentState
} as unknown as ApiSessionClient;
new CopilotPermissionHandler(session, backend, () => 'read-only');
onPermissionRequest?.({
id: 'permission-1',
sessionId: 'session-1',
toolCallId: 'tool-1',
title: 'Bash',
options: [{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }]
});
expect(backend.respondToPermission).not.toHaveBeenCalled();
expect(updateAgentState).toHaveBeenCalledOnce();
});
});
+190
View File
@@ -0,0 +1,190 @@
import type { ApiSessionClient } from '@/api/apiSession';
import type { AgentBackend, PermissionRequest, PermissionResponse } from '@/agent/types';
import type { CopilotPermissionMode } from '@hapi/protocol/types';
import { deriveToolName } from '@/agent/utils';
import { logger } from '@/ui/logger';
import {
BasePermissionHandler,
type AutoApprovalDecision,
type PendingPermissionRequest,
type PermissionCompletion
} from '@/modules/common/permission/BasePermissionHandler';
interface PermissionResponseMessage {
id: string;
approved: boolean;
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort';
reason?: string;
}
const READ_ONLY_TOOL_NAMES = new Set([
'read',
'read file',
'grep',
'glob',
'search',
'list files'
]);
function deriveToolInput(request: PermissionRequest): unknown {
if (request.rawInput !== undefined) {
return request.rawInput;
}
return request.rawOutput;
}
function pickOptionId(
request: PermissionRequest,
preferredKinds: string[],
fallbackToFirstOption = true
): string | null {
for (const kind of preferredKinds) {
const match = request.options.find((option) => option.kind === kind);
if (match) {
return match.optionId;
}
}
return fallbackToFirstOption && request.options.length > 0
? request.options[0].optionId
: null;
}
export function mapCopilotPermissionDecision(
request: PermissionRequest,
decision: PermissionResponseMessage['decision']
): PermissionResponse {
if (decision === 'abort') {
return { outcome: 'cancelled' };
}
if (decision === 'approved_for_session') {
const optionId = pickOptionId(request, ['allow_always', 'allow_once'], false);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
if (decision === 'approved') {
const optionId = pickOptionId(request, ['allow_once', 'allow_always'], false);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
const optionId = pickOptionId(request, ['reject_once', 'reject_always'], false);
return optionId ? { outcome: 'selected', optionId } : { outcome: 'cancelled' };
}
export class CopilotPermissionHandler extends BasePermissionHandler<PermissionResponseMessage, void> {
private readonly pendingBackendRequests = new Map<string, PermissionRequest>();
constructor(
session: ApiSessionClient,
private readonly backend: AgentBackend,
private readonly getPermissionMode: () => CopilotPermissionMode | undefined
) {
super(session);
this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request));
}
private handlePermissionRequest(request: PermissionRequest): void {
const toolName = deriveToolName({
title: request.title,
kind: request.kind,
rawInput: request.rawInput
});
const toolInput = deriveToolInput(request);
const mode = this.getPermissionMode() ?? 'default';
const autoDecision = mode === 'read-only'
? (READ_ONLY_TOOL_NAMES.has(toolName.trim().toLowerCase()) ? 'approved' : null)
: this.resolveAutoApprovalDecision(mode, toolName, request.toolCallId);
if (autoDecision) {
void this.autoApprove(request, toolName, toolInput, autoDecision);
return;
}
this.pendingBackendRequests.set(request.id, request);
this.addPendingRequest(request.id, toolName, toolInput, {
resolve: () => {},
reject: () => {}
});
logger.debug(`[Copilot] Permission request queued for ${toolName} (${request.id})`);
}
private async autoApprove(
request: PermissionRequest,
toolName: string,
toolInput: unknown,
decision: AutoApprovalDecision
): Promise<void> {
const outcome = mapCopilotPermissionDecision(request, decision);
await this.backend.respondToPermission(request.sessionId, request, outcome);
this.client.updateAgentState((currentState) => ({
...currentState,
completedRequests: {
...currentState.completedRequests,
[request.id]: {
tool: toolName,
arguments: toolInput,
createdAt: Date.now(),
completedAt: Date.now(),
status: 'approved',
decision
}
}
}));
logger.debug(`[Copilot] Auto-approved ${toolName} (${request.id}) mode=${decision}`);
}
protected async handlePermissionResponse(
response: PermissionResponseMessage,
pending: PendingPermissionRequest<void>
): Promise<PermissionCompletion> {
const pendingRequest = this.pendingBackendRequests.get(response.id);
if (pendingRequest) {
this.pendingBackendRequests.delete(response.id);
} else {
logger.debug('[Copilot] Permission response missing backend request', response.id);
}
const decision = response.decision ?? (response.approved ? 'approved' : 'denied');
if (decision === 'abort' && pendingRequest) {
await this.backend.cancelPrompt(pendingRequest.sessionId);
}
if (pendingRequest) {
const outcome = mapCopilotPermissionDecision(pendingRequest, decision);
await this.backend.respondToPermission(pendingRequest.sessionId, pendingRequest, outcome);
}
pending.resolve();
logger.debug(`[Copilot] Permission ${response.approved ? 'approved' : 'denied'} for ${pending.toolName}`);
return {
status: response.approved ? 'approved' : 'denied',
decision,
reason: response.reason
};
}
protected handleMissingPendingResponse(response: PermissionResponseMessage): void {
logger.debug('[Copilot] Permission response received for unknown request', response.id);
}
async cancelAll(reason: string): Promise<void> {
const pending = Array.from(this.pendingBackendRequests.values());
this.pendingBackendRequests.clear();
for (const request of pending) {
await this.backend.respondToPermission(request.sessionId, request, { outcome: 'cancelled' });
}
this.cancelPendingRequests({
completedReason: reason,
rejectMessage: reason,
decision: 'abort'
});
}
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { resolveCopilotSlashCommand } from './slashCommands';
const state = {
permissionMode: 'default' as const,
model: 'gpt-5-mini',
agentMode: 'interactive' as const
};
describe('resolveCopilotSlashCommand', () => {
it('handles /status with agent mode', () => {
const result = resolveCopilotSlashCommand('/status', {
...state,
agentMode: 'autopilot'
});
expect(result).toMatchObject({ kind: 'handled' });
if (result.kind === 'handled') {
expect(result.message).toContain('autopilot');
expect(result.message).toContain('gpt-5-mini');
}
});
it('passes /fleet through without changing agent mode', () => {
expect(resolveCopilotSlashCommand('/fleet', state)).toEqual({ kind: 'passthrough' });
expect(resolveCopilotSlashCommand('/fleet check parser and tests', state)).toEqual({
kind: 'passthrough'
});
});
it('rejects /mode fleet as an agent mode', () => {
const result = resolveCopilotSlashCommand('/mode fleet', state);
expect(result).toMatchObject({ kind: 'handled' });
if (result.kind === 'handled') {
expect(result.message).toContain('/fleet');
expect(result.updates).toBeUndefined();
}
});
it('preserves a task supplied with /autopilot', () => {
expect(resolveCopilotSlashCommand('/autopilot implement the fix', state)).toEqual({
kind: 'replace',
text: 'implement the fix',
message: 'Copilot autopilot mode enabled',
updates: { agentMode: 'autopilot' }
});
});
it('sets model from /model', () => {
expect(resolveCopilotSlashCommand('/model gpt-5.4', state)).toEqual({
kind: 'handled',
message: 'Copilot model set to gpt-5.4',
updates: { model: 'gpt-5.4' }
});
});
it('passes agent slash commands through to Copilot CLI', () => {
for (const command of [
'/rubber-duck review this plan',
'/security-review',
'/research auth flow',
'/review',
'/skills list',
]) {
expect(resolveCopilotSlashCommand(command, state)).toEqual({ kind: 'passthrough' });
}
});
});
+222
View File
@@ -0,0 +1,222 @@
import { COPILOT_PERMISSION_MODES } from '@hapi/protocol/modes';
import type { CopilotAgentMode } from '@hapi/protocol';
import type { CopilotPermissionMode } from '@hapi/protocol/types';
import type { SlashCommand } from '@/modules/common/slashCommands';
export type CopilotSlashResolution =
| { kind: 'passthrough' }
| {
kind: 'handled';
message: string;
updates?: {
permissionMode?: CopilotPermissionMode;
model?: string | null;
agentMode?: CopilotAgentMode;
};
}
| {
kind: 'replace';
text: string;
message?: string;
updates?: {
permissionMode?: CopilotPermissionMode;
model?: string | null;
agentMode?: CopilotAgentMode;
};
};
function resolveCopilotPermissionMode(rest: string): CopilotPermissionMode | null {
const normalized = rest.trim().toLowerCase();
if (!normalized) return null;
if (normalized === 'default' || normalized === 'off') return 'default';
if ((COPILOT_PERMISSION_MODES as readonly string[]).includes(normalized)) {
return normalized as CopilotPermissionMode;
}
return null;
}
function resolveCopilotAgentMode(rest: string): CopilotAgentMode | null {
const normalized = rest.trim().toLowerCase();
if (!normalized || normalized === 'default' || normalized === 'off' || normalized === 'interactive') {
return 'interactive';
}
if (normalized === 'plan') return 'plan';
if (normalized === 'autopilot') return 'autopilot';
return null;
}
export function resolveCopilotSlashCommand(
text: string,
state: {
commands?: readonly SlashCommand[];
permissionMode: CopilotPermissionMode;
model?: string | null;
agentMode: CopilotAgentMode;
}
): CopilotSlashResolution {
const match = /^\s*\/([a-z0-9:_-]+)(?:\s+([\s\S]*))?$/i.exec(text);
if (!match) return { kind: 'passthrough' };
const command = match[1]?.toLowerCase();
const rest = match[2]?.trim() ?? '';
if (!command) return { kind: 'passthrough' };
const custom = state.commands?.find((candidate) =>
candidate.source !== 'builtin' && candidate.name.toLowerCase() === command
);
if (custom?.content) {
return {
kind: 'replace',
text: rest ? `${custom.content}\n\nUser arguments: ${rest}` : custom.content,
message: `Expanded /${custom.name}`
};
}
if (command === 'help') {
const lines = (state.commands ?? [])
.filter((entry) => entry.source === 'builtin')
.map((entry) => `- \`/${entry.name}\` — ${entry.description}`);
return {
kind: 'handled',
message: [
'**Supported Copilot slash commands**',
'',
...lines,
'',
'`/fleet` is orthogonal to agent mode (Interactive / Plan / Autopilot) and is passed through to Copilot CLI.'
].join('\n')
};
}
if (command === 'status') {
return {
kind: 'handled',
message: [
'**Copilot status**',
'',
`- agent mode: \`${state.agentMode}\``,
`- permission: \`${state.permissionMode}\``,
`- model: \`${state.model ?? 'auto'}\``
].join('\n')
};
}
if (command === 'model') {
if (!rest) {
return { kind: 'handled', message: `Copilot model: ${state.model ?? 'auto'}` };
}
const model = rest === 'auto' || rest === 'default' ? null : rest;
return {
kind: 'handled',
message: `Copilot model set to ${model ?? 'auto'}`,
updates: { model }
};
}
if (command === 'permissions' || command === 'permission') {
if (!rest) {
return { kind: 'handled', message: `Copilot permission mode: ${state.permissionMode}` };
}
const permissionMode = resolveCopilotPermissionMode(rest);
if (!permissionMode) {
return {
kind: 'handled',
message: `Unknown permission mode \`${rest}\`. Use one of: ${COPILOT_PERMISSION_MODES.join(', ')}`
};
}
return {
kind: 'handled',
message: `Copilot permission mode set to ${permissionMode}`,
updates: { permissionMode }
};
}
if (command === 'plan') {
const lowerRest = rest.toLowerCase();
if (lowerRest === 'off' || lowerRest === 'default' || lowerRest === 'exit' || lowerRest === 'disable') {
return {
kind: 'handled',
message: 'Copilot plan mode disabled',
updates: { agentMode: 'interactive' }
};
}
if (rest) {
return {
kind: 'replace',
text: rest,
message: 'Copilot plan mode enabled',
updates: { agentMode: 'plan' }
};
}
return {
kind: 'handled',
message: 'Copilot plan mode enabled',
updates: { agentMode: 'plan' }
};
}
if (command === 'autopilot') {
const lowerRest = rest.toLowerCase();
if (lowerRest === 'off' || lowerRest === 'default' || lowerRest === 'exit' || lowerRest === 'disable') {
return {
kind: 'handled',
message: 'Copilot autopilot mode disabled',
updates: { agentMode: 'interactive' }
};
}
if (rest) {
return {
kind: 'replace',
text: rest,
message: 'Copilot autopilot mode enabled',
updates: { agentMode: 'autopilot' }
};
}
return {
kind: 'handled',
message: 'Copilot autopilot mode enabled',
updates: { agentMode: 'autopilot' }
};
}
// /fleet is not an agent mode — pass through so Copilot CLI can orchestrate
// parallel subagents alongside Interactive / Plan / Autopilot.
if (command === 'fleet') {
return { kind: 'passthrough' };
}
if (command === 'interactive' || command === 'default') {
return {
kind: 'handled',
message: 'Copilot interactive mode enabled',
updates: { agentMode: 'interactive' }
};
}
if (command === 'mode') {
if (rest.trim().toLowerCase() === 'fleet') {
return {
kind: 'handled',
message: 'Fleet is not an agent mode. Use `/fleet <task>` (works with Interactive, Plan, or Autopilot).'
};
}
const agentMode = resolveCopilotAgentMode(rest);
if (!agentMode) {
return {
kind: 'handled',
message: 'Unknown agent mode. Use interactive, plan, or autopilot. For parallel subagents use `/fleet <task>`.'
};
}
return {
kind: 'handled',
message: `Copilot agent mode set to ${agentMode}`,
updates: { agentMode }
};
}
if (command === 'context' || command === 'usage' || command === 'tasks' || command === 'subagents' || command === 'agents' || command === 'delegate' || command === 'agent' || command === 'rubber-duck' || command === 'security-review' || command === 'research' || command === 'review' || command === 'skills') {
return { kind: 'passthrough' };
}
return { kind: 'passthrough' };
}