mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-13 20:09:55 +00:00
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:
co-authored by
Cursor
HAPI
parent
b67f4e56e5
commit
f10fbc7496
@@ -6,6 +6,7 @@ import { join } from 'node:path'
|
||||
const ioMock = vi.hoisted(() => vi.fn())
|
||||
const listOpencodeModelsForCwdMock = vi.hoisted(() => vi.fn())
|
||||
const listGrokModelsForCwdMock = vi.hoisted(() => vi.fn())
|
||||
const listCopilotModelsForCwdMock = vi.hoisted(() => vi.fn())
|
||||
const inspectCursorChatStoreMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('socket.io-client', () => ({
|
||||
@@ -24,6 +25,10 @@ vi.mock('../modules/common/grokModels', () => ({
|
||||
listGrokModelsForCwd: listGrokModelsForCwdMock
|
||||
}))
|
||||
|
||||
vi.mock('../modules/common/copilotModels', () => ({
|
||||
listCopilotModelsForCwd: listCopilotModelsForCwdMock
|
||||
}))
|
||||
|
||||
vi.mock('@/cursor/cursorChatStoreStatus', () => ({
|
||||
inspectCursorChatStore: inspectCursorChatStoreMock
|
||||
}))
|
||||
@@ -79,6 +84,15 @@ async function callListGrokModels(client: ApiMachineClient, machineId: string, c
|
||||
return JSON.parse(raw) as unknown
|
||||
}
|
||||
|
||||
async function callListCopilotModels(client: ApiMachineClient, machineId: string, cwd: string): Promise<unknown> {
|
||||
const manager = (client as unknown as { rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise<string> } }).rpcHandlerManager
|
||||
const raw = await manager.handleRequest({
|
||||
method: `${machineId}:listCopilotModelsForCwd`,
|
||||
params: JSON.stringify({ cwd })
|
||||
})
|
||||
return JSON.parse(raw) as unknown
|
||||
}
|
||||
|
||||
async function callCursorChatStoreStatus(
|
||||
client: ApiMachineClient,
|
||||
machineId: string,
|
||||
@@ -277,6 +291,58 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiMachineClient listCopilotModelsForCwd handler', () => {
|
||||
let workspaceRoot: string
|
||||
|
||||
beforeEach(() => {
|
||||
ioMock.mockReset()
|
||||
listCopilotModelsForCwdMock.mockReset()
|
||||
workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-copilot-machine-ws-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workspaceRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects cwd outside workspace roots before running the Copilot model probe', async () => {
|
||||
const machine = makeMachine('copilot-machine-1')
|
||||
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
|
||||
const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-copilot-outside-'))
|
||||
|
||||
try {
|
||||
expect(await callListCopilotModels(client, machine.id, outsideCwd)).toEqual({
|
||||
success: false,
|
||||
error: 'Path is outside workspace roots'
|
||||
})
|
||||
expect(listCopilotModelsForCwdMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
rmSync(outsideCwd, { recursive: true, force: true })
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards a resolved workspace cwd to the Copilot model probe', async () => {
|
||||
const machine = makeMachine('copilot-machine-2')
|
||||
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
|
||||
listCopilotModelsForCwdMock.mockResolvedValueOnce({
|
||||
success: true,
|
||||
availableModels: [{ modelId: 'gpt-5.6' }],
|
||||
currentModelId: 'gpt-5.6'
|
||||
})
|
||||
|
||||
try {
|
||||
expect(await callListCopilotModels(client, machine.id, workspaceRoot)).toEqual({
|
||||
success: true,
|
||||
availableModels: [{ modelId: 'gpt-5.6' }],
|
||||
currentModelId: 'gpt-5.6'
|
||||
})
|
||||
expect(listCopilotModelsForCwdMock).toHaveBeenCalledWith(realpathSync.native(workspaceRoot))
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiMachineClient listGrokModelsForCwd handler', () => {
|
||||
let workspaceRoot: string
|
||||
|
||||
|
||||
@@ -35,6 +35,11 @@ import {
|
||||
type ListGrokModelsForCwdRequest,
|
||||
type ListGrokModelsForCwdResponse
|
||||
} from '../modules/common/grokModels'
|
||||
import {
|
||||
listCopilotModelsForCwd,
|
||||
type ListCopilotModelsForCwdRequest,
|
||||
type ListCopilotModelsForCwdResponse
|
||||
} from '../modules/common/copilotModels'
|
||||
import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes'
|
||||
import { applyVersionedAck } from './versionedUpdate'
|
||||
import { archiveLocalCodexSession, listLocalCodexSessionSummaries, listLocalCodexSessionsWithMessagesByIds } from '../modules/common/codexSessions'
|
||||
@@ -270,6 +275,21 @@ export class ApiMachineClient {
|
||||
}
|
||||
)
|
||||
|
||||
this.rpcHandlerManager.registerHandler<ListCopilotModelsForCwdRequest, ListCopilotModelsForCwdResponse>(
|
||||
RPC_METHODS.ListCopilotModelsForCwd,
|
||||
async (params) => {
|
||||
const rawCwd = typeof params?.cwd === 'string' ? params.cwd.trim() : ''
|
||||
if (!rawCwd) return { success: false, error: 'cwd is required' }
|
||||
|
||||
const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd)
|
||||
if (!this.isWithinWorkspaceRoots(resolvedCwd)) {
|
||||
return { success: false, error: 'Path is outside workspace roots' }
|
||||
}
|
||||
|
||||
return await listCopilotModelsForCwd(resolvedCwd)
|
||||
}
|
||||
)
|
||||
|
||||
this.rpcHandlerManager.registerHandler<unknown, ListCodexSessionsRpcResponse>(
|
||||
RPC_METHODS.ListCodexSessions,
|
||||
async (params) => {
|
||||
@@ -360,7 +380,7 @@ export class ApiMachineClient {
|
||||
|
||||
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
|
||||
this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => {
|
||||
const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, collaborationMode, token, sessionType, worktreeName, forkSession } = params || {}
|
||||
const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, collaborationMode, copilotAgentMode, token, sessionType, worktreeName, forkSession } = params || {}
|
||||
|
||||
if (!directory) {
|
||||
throw new Error('Directory is required')
|
||||
@@ -386,6 +406,7 @@ export class ApiMachineClient {
|
||||
permissionMode,
|
||||
serviceTier,
|
||||
collaborationMode,
|
||||
copilotAgentMode,
|
||||
token,
|
||||
sessionType,
|
||||
worktreeName,
|
||||
|
||||
@@ -859,6 +859,7 @@ export class ApiSessionClient extends EventEmitter {
|
||||
effort?: string | null
|
||||
serviceTier?: string | null
|
||||
collaborationMode?: SessionCollaborationMode
|
||||
copilotAgentMode?: import('@hapi/protocol').CopilotAgentMode
|
||||
}
|
||||
): void {
|
||||
if (this.state !== 'active') {
|
||||
|
||||
Reference in New Issue
Block a user