mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-08 01:43:07 +00:00
* 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>
138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
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();
|
|
});
|
|
});
|