From 6950401c06a555482079d7ed0fc6ea3c9e71f682 Mon Sep 17 00:00:00 2001 From: "xuzhongpeng.xzp" <1452754335@qq.com> Date: Tue, 12 May 2026 19:03:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(acp):=20=E5=AF=B9=E9=BD=90=20ACP=20session?= =?UTF-8?q?=20ID=20=E4=B8=8E=E5=85=A8=E5=B1=80=E4=BC=9A=E8=AF=9D=E7=8A=B6?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 newSession/resumeSession/loadSession 中调用 switchSession, 确保 transcript 持久化、analytics 与 cost tracking 使用 ACP session ID, 而非内部默认 session ID。 - newSession 生成 sessionId 后立即对齐全局状态 - resumeSession 命中 fingerprint 缓存路径也对齐 - loadSession 在 sessionIdExists() 检查前对齐(lookup 依赖 getSessionId) - 补充 5 个测试覆盖上述路径,以及 prompt 不触发额外 switchSession --- src/services/acp/__tests__/agent.test.ts | 484 +++++++++- src/services/acp/agent.ts | 1055 +++++++++++++++++++++- 2 files changed, 1469 insertions(+), 70 deletions(-) diff --git a/src/services/acp/__tests__/agent.test.ts b/src/services/acp/__tests__/agent.test.ts index 194977036..2c6417f00 100644 --- a/src/services/acp/__tests__/agent.test.ts +++ b/src/services/acp/__tests__/agent.test.ts @@ -4,57 +4,76 @@ import { test, mock, beforeEach, + afterEach, afterAll, spyOn, } from 'bun:test' // ── Mock infrastructure ────────────────────────────────────────── // bun:test mock.module is process-global: it leaks to sibling test files -// in the same worker. safeMockModule snapshots real exports before mocking +// in the same worker. Preserve real exports before partial module mocking // so afterAll can restore them, preventing cross-file pollution. const _restores: (() => void)[] = [] +const originalCwd = process.cwd() +const originalAcpPermissionMode = process.env.ACP_PERMISSION_MODE +const originalAcpAllowBypass = + process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS -function safeMockModule(tsPath: string, overrides: Record) { +function mockModulePreservingExports( + tsPath: string, + overrides: Record, +) { const jsPath = tsPath.replace(/\.ts$/, '.js') - const real = require(tsPath) - const snapshot = { ...real } + const snapshot = { ...(require(tsPath) as Record) } mock.module(jsPath, () => ({ ...snapshot, ...overrides })) _restores.push(() => mock.module(jsPath, () => snapshot)) } +afterAll(() => { + for (let i = _restores.length - 1; i >= 0; i--) { + _restores[i]() + } + _restores.length = 0 + restoreEnv('ACP_PERMISSION_MODE', originalAcpPermissionMode) + restoreEnv('CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS', originalAcpAllowBypass) +}) + // ── Module mocks (must precede any import of the module under test) ── const mockSetModel = mock(() => {}) +const mockSubmitMessage = mock(async function* (_input: string) {}) -// Fully synthetic — no real module to snapshot, so plain mock.module suffices. -mock.module('../../../QueryEngine.js', () => ({ +mockModulePreservingExports('../../../QueryEngine.ts', { QueryEngine: class MockQueryEngine { - submitMessage = mock(async function* () {}) + submitMessage = mockSubmitMessage interrupt = mock(() => {}) resetAbortController = mock(() => {}) getAbortSignal = mock(() => new AbortController().signal) setModel = mockSetModel }, -})) +}) -safeMockModule('../../../tools.ts', { +mockModulePreservingExports('../../../tools.ts', { getTools: mock(() => []), }) -safeMockModule('../../../Tool.ts', { +mockModulePreservingExports('../../../Tool.ts', { toolMatchesName: mock(() => false), findToolByName: mock(() => undefined), filterToolProgressMessages: mock(() => []), buildTool: mock((def: any) => def), }) -safeMockModule('../../../utils/config.ts', { +mockModulePreservingExports('../../../utils/config.ts', { enableConfigs: mock(() => {}), }) -safeMockModule('../../../bootstrap/state.ts', { +const mockSwitchSession = mock(() => {}) + +mockModulePreservingExports('../../../bootstrap/state.ts', { setOriginalCwd: mock(() => {}), + switchSession: mockSwitchSession, addSlowOperation: mock(() => {}), }) @@ -75,24 +94,16 @@ const mockGetDefaultAppState = mock(() => ({ mainLoopModelForSession: null, })) -safeMockModule('../../../state/AppStateStore.ts', { +mockModulePreservingExports('../../../state/AppStateStore.ts', { getDefaultAppState: mockGetDefaultAppState, }) -// Single export, fully synthetic — no real module to snapshot. -mock.module('../permissions.js', () => ({ - createAcpCanUseTool: mock(() => - mock(async () => ({ behavior: 'allow', updatedInput: {} })), - ), -})) - -safeMockModule('../utils.ts', { - resolvePermissionMode: mock(() => 'default'), +mockModulePreservingExports('../utils.ts', { computeSessionFingerprint: mock(() => '{}'), sanitizeTitle: mock((s: string) => s), }) -safeMockModule('../bridge.ts', { +mockModulePreservingExports('../bridge.ts', { forwardSessionUpdates: mock(async () => ({ stopReason: 'end_turn' as const, })), @@ -105,33 +116,38 @@ safeMockModule('../bridge.ts', { })), }) -safeMockModule('../../../utils/listSessionsImpl.ts', { +mockModulePreservingExports('../../../utils/listSessionsImpl.ts', { listSessionsImpl: mock(async () => []), }) const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6') -safeMockModule('../../../utils/model/model.ts', { +mockModulePreservingExports('../../../utils/model/model.ts', { getMainLoopModel: mockGetMainLoopModel, }) -safeMockModule('../../../utils/model/modelOptions.ts', { +mockModulePreservingExports('../../../utils/model/modelOptions.ts', { getModelOptions: mock(() => []), }) const mockApplySafeEnvVars = mock(() => {}) -safeMockModule('../../../utils/managedEnv.ts', { +mockModulePreservingExports('../../../utils/managedEnv.ts', { applySafeConfigEnvironmentVariables: mockApplySafeEnvVars, }) +const mockGetSettings = mock(() => ({})) +mockModulePreservingExports('../../../utils/settings/settings.ts', { + getSettings_DEPRECATED: mockGetSettings, +}) + const mockDeserializeMessages = mock((msgs: unknown[]) => msgs) -safeMockModule('../../../utils/conversationRecovery.ts', { +mockModulePreservingExports('../../../utils/conversationRecovery.ts', { deserializeMessages: mockDeserializeMessages, }) const mockGetLastSessionLog = mock(async () => null) const mockSessionIdExists = mock(() => false) -safeMockModule('../../../utils/sessionStorage.ts', { +mockModulePreservingExports('../../../utils/sessionStorage.ts', { getLastSessionLog: mockGetLastSessionLog, sessionIdExists: mockSessionIdExists, }) @@ -161,7 +177,7 @@ const mockGetCommands = mock(async () => [ }, ]) -safeMockModule('../../../commands.ts', { +mockModulePreservingExports('../../../commands.ts', { getCommands: mockGetCommands, }) @@ -181,16 +197,49 @@ function makeConn() { } as any } +function removeBypassMode(session: any) { + session.modes = { + ...session.modes, + availableModes: session.modes.availableModes.filter( + (mode: any) => mode.id !== 'bypassPermissions', + ), + } + session.appState.toolPermissionContext = { + ...session.appState.toolPermissionContext, + isBypassPermissionsModeAvailable: false, + } +} + +function restoreEnv(name: string, value: string | undefined) { + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } +} + // ── Tests ───────────────────────────────────────────────────────── describe('AcpAgent', () => { - afterAll(() => { - for (const restore of _restores) restore() - }) beforeEach(() => { + delete process.env.ACP_PERMISSION_MODE + delete process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS mockSetModel.mockClear() + mockSwitchSession.mockClear() + mockSubmitMessage.mockReset() + mockSubmitMessage.mockImplementation(async function* (_input: string) {}) mockGetMainLoopModel.mockClear() mockGetDefaultAppState.mockClear() + mockGetSettings.mockReset() + mockGetSettings.mockImplementation(() => ({})) + ;(forwardSessionUpdates as ReturnType).mockReset() + ;(forwardSessionUpdates as ReturnType).mockImplementation( + async () => ({ stopReason: 'end_turn' as const }), + ) + }) + + afterEach(() => { + process.chdir(originalCwd) }) describe('initialize', () => { @@ -255,6 +304,13 @@ describe('AcpAgent', () => { expect(r1.sessionId).not.toBe(r2.sessionId) }) + test('does not leave process cwd changed after session creation', async () => { + const cwdBeforeSession = process.cwd() + const agent = new AcpAgent(makeConn()) + await agent.newSession({ cwd: '/tmp' } as any) + expect(process.cwd()).toBe(cwdBeforeSession) + }) + test('calls getDefaultAppState to build session appState', async () => { const agent = new AcpAgent(makeConn()) await agent.newSession({ cwd: '/tmp' } as any) @@ -290,6 +346,105 @@ describe('AcpAgent', () => { const res = await agent.newSession({ cwd: '/tmp' } as any) expect(res.sessionId).toBeDefined() }) + + test('uses settings permissions.defaultMode when _meta does not provide a mode', async () => { + mockGetSettings.mockImplementationOnce(() => ({ + permissions: { defaultMode: 'acceptEdits' }, + })) + const agent = new AcpAgent(makeConn()) + const res = await agent.newSession({ cwd: '/tmp' } as any) + + expect(res.modes?.currentModeId).toBe('acceptEdits') + }) + + test('uses _meta.permissionMode before settings permissions.defaultMode', async () => { + mockGetSettings.mockImplementationOnce(() => ({ + permissions: { defaultMode: 'acceptEdits' }, + })) + const agent = new AcpAgent(makeConn()) + const res = await agent.newSession({ + cwd: '/tmp', + _meta: { permissionMode: 'plan' }, + } as any) + + expect(res.modes?.currentModeId).toBe('plan') + }) + + test('rejects _meta.permissionMode bypass without a local ACP bypass gate', async () => { + mockGetSettings.mockImplementationOnce(() => ({ + permissions: { defaultMode: 'acceptEdits' }, + })) + const consoleErrorSpy = spyOn(console, 'error').mockImplementation( + () => {}, + ) + const agent = new AcpAgent(makeConn()) + try { + await expect( + agent.newSession({ + cwd: '/tmp', + _meta: { permissionMode: 'bypassPermissions' }, + } as any), + ).rejects.toThrow('Mode not available: bypassPermissions') + + expect(consoleErrorSpy).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } + }) + + test('honors _meta.permissionMode bypass with a local ACP bypass gate', async () => { + process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1' + const agent = new AcpAgent(makeConn()) + const res = await agent.newSession({ + cwd: '/tmp', + _meta: { permissionMode: 'bypassPermissions' }, + } as any) + + expect(res.modes?.currentModeId).toBe('bypassPermissions') + expect(res.modes?.availableModes.map((mode: any) => mode.id)).toContain( + 'bypassPermissions', + ) + }) + + test('falls back to default when settings permissions.defaultMode is invalid', async () => { + mockGetSettings.mockImplementationOnce(() => ({ + permissions: { defaultMode: 'invalid-mode' }, + })) + const consoleErrorSpy = spyOn(console, 'error').mockImplementation( + () => {}, + ) + const agent = new AcpAgent(makeConn()) + try { + const res = await agent.newSession({ cwd: '/tmp' } as any) + + expect(res.modes?.currentModeId).toBe('default') + expect(consoleErrorSpy).toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } + }) + + test('rejects invalid _meta.permissionMode without falling back to settings', async () => { + mockGetSettings.mockImplementationOnce(() => ({ + permissions: { defaultMode: 'acceptEdits' }, + })) + const consoleErrorSpy = spyOn(console, 'error').mockImplementation( + () => {}, + ) + const agent = new AcpAgent(makeConn()) + try { + await expect( + agent.newSession({ + cwd: '/tmp', + _meta: { permissionMode: 'invalid-mode' }, + } as any), + ).rejects.toThrow('Invalid _meta.permissionMode: invalid-mode') + + expect(consoleErrorSpy).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } + }) }) describe('prompt', () => { @@ -375,7 +530,7 @@ describe('AcpAgent', () => { expect(res2.stopReason).toBe('end_turn') }) - test('returns end_turn on unexpected error', async () => { + test('propagates unexpected prompt errors', async () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) ;( @@ -383,16 +538,13 @@ describe('AcpAgent', () => { ).mockImplementationOnce(async () => { throw new Error('unexpected') }) - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}) - try { - const res = await agent.prompt({ + + await expect( + agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }], - } as any) - expect(res.stopReason).toBe('end_turn') - } finally { - errorSpy.mockRestore() - } + } as any), + ).rejects.toThrow('unexpected') }) test('returns usage from forwardSessionUpdates', async () => { @@ -676,15 +828,28 @@ describe('AcpAgent', () => { ).rejects.toThrow('Session not found') }) - test('availableModes includes bypassPermissions when not root', async () => { + test('availableModes excludes bypassPermissions without a local ACP bypass gate', async () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) const session = agent.sessions.get(sessionId) const modeIds = session?.modes.availableModes.map((m: any) => m.id) - expect(modeIds).toContain('bypassPermissions') + expect(modeIds).not.toContain('bypassPermissions') }) - test('can switch to bypassPermissions mode', async () => { + test('rejects bypassPermissions without a local ACP bypass gate', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + await expect( + agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any), + ).rejects.toThrow('Mode not available') + + const session = agent.sessions.get(sessionId) + expect(session?.modes.currentModeId).toBe('default') + expect(session?.appState.toolPermissionContext.mode).toBe('default') + }) + + test('can switch to bypassPermissions mode with a local ACP bypass gate', async () => { + process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1' const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) await agent.setSessionMode({ @@ -697,6 +862,21 @@ describe('AcpAgent', () => { 'bypassPermissions', ) }) + + test('rejects bypassPermissions when the session does not expose it', async () => { + process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS = '1' + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + const session = agent.sessions.get(sessionId) + removeBypassMode(session) + + await expect( + agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any), + ).rejects.toThrow('Mode not available') + + expect(session?.modes.currentModeId).toBe('default') + expect(session?.appState.toolPermissionContext.mode).toBe('default') + }) }) describe('setSessionConfigOption', () => { @@ -723,6 +903,24 @@ describe('AcpAgent', () => { } as any), ).rejects.toThrow('Invalid value') }) + + test('rejects unavailable mode config values', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + const session = agent.sessions.get(sessionId) + removeBypassMode(session) + + await expect( + agent.setSessionConfigOption({ + sessionId, + configId: 'mode', + value: 'bypassPermissions', + } as any), + ).rejects.toThrow('Mode not available') + + expect(session?.modes.currentModeId).toBe('default') + expect(session?.appState.toolPermissionContext.mode).toBe('default') + }) }) describe('prompt queueing', () => { @@ -758,6 +956,100 @@ describe('AcpAgent', () => { expect(r2.stopReason).toBe('end_turn') }) + test('drains 1000 queued prompts in FIFO order without sorting the pending map', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + + let resolveFirst!: () => void + ;( + forwardSessionUpdates as ReturnType + ).mockImplementationOnce( + () => + new Promise<{ stopReason: string }>(resolve => { + resolveFirst = () => resolve({ stopReason: 'end_turn' }) + }), + ) + + const first = agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'first' }], + } as any) + const queued = Array.from({ length: 1000 }, (_, index) => + agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: `queued-${index}` }], + } as any), + ) + + resolveFirst() + const results = await Promise.all([first, ...queued]) + + expect(results.every(result => result.stopReason === 'end_turn')).toBe( + true, + ) + expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([ + 'first', + ...Array.from({ length: 1000 }, (_, index) => `queued-${index}`), + ]) + }) + + test('keeps promptRunning true while handing off to the next queued prompt', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + + let resolveFirst!: () => void + let resolveSecond!: () => void + ;( + forwardSessionUpdates as ReturnType + ).mockImplementationOnce( + () => + new Promise<{ stopReason: string }>(resolve => { + resolveFirst = () => resolve({ stopReason: 'end_turn' }) + }), + ) + ;( + forwardSessionUpdates as ReturnType + ).mockImplementationOnce( + () => + new Promise<{ stopReason: string }>(resolve => { + resolveSecond = () => resolve({ stopReason: 'end_turn' }) + }), + ) + + const p1 = agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'first' }], + } as any) + const p2 = agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'second' }], + } as any) + + const p3 = p1.then(() => + agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'third' }], + } as any), + ) + + resolveFirst() + await p1 + const session = agent.sessions.get(sessionId) + expect(session?.promptRunning).toBe(true) + expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([ + 'first', + 'second', + ]) + + resolveSecond() + await Promise.all([p2, p3]) + expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([ + 'first', + 'second', + 'third', + ]) + }) + test('queued prompts return cancelled when session is cancelled', async () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) @@ -787,6 +1079,46 @@ describe('AcpAgent', () => { expect(r1.stopReason).toBe('cancelled') expect(r2.stopReason).toBe('cancelled') }) + + test('queued prompt does not clear active prompt cancellation', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + + let resolveFirst!: () => void + ;( + forwardSessionUpdates as ReturnType + ).mockImplementationOnce( + () => + new Promise<{ stopReason: string }>(resolve => { + resolveFirst = () => resolve({ stopReason: 'end_turn' }) + }), + ) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( + { stopReason: 'end_turn' }, + ) + + const p1 = agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'first' }], + } as any) + + await agent.cancel({ sessionId } as any) + + const p2 = agent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'second' }], + } as any) + + resolveFirst() + + const [r1, r2] = await Promise.all([p1, p2]) + expect(r1.stopReason).toBe('cancelled') + expect(r2.stopReason).toBe('end_turn') + expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([ + 'first', + 'second', + ]) + }) }) describe('commands', () => { @@ -829,4 +1161,66 @@ describe('AcpAgent', () => { expect(commit.input).toEqual({ hint: '[message]' }) }) }) + + describe('sessionId alignment with global state', () => { + test('newSession calls switchSession with the generated sessionId', async () => { + const agent = new AcpAgent(makeConn()) + const res = await agent.newSession({ cwd: '/tmp' } as any) + expect(mockSwitchSession).toHaveBeenCalledWith(res.sessionId) + }) + + test('resumeSession calls switchSession with the requested sessionId', async () => { + const agent = new AcpAgent(makeConn()) + const requestedId = 'resume-test-session-id' + await agent.unstable_resumeSession({ + sessionId: requestedId, + cwd: '/tmp', + mcpServers: [], + } as any) + + expect(mockSwitchSession).toHaveBeenCalledWith(requestedId) + }) + + test('loadSession calls switchSession with the requested sessionId', async () => { + const agent = new AcpAgent(makeConn()) + const requestedId = 'load-test-session-id' + await agent.loadSession({ + sessionId: requestedId, + cwd: '/tmp', + mcpServers: [], + } as any) + + expect(mockSwitchSession).toHaveBeenCalledWith(requestedId) + }) + + test('resumeSession with existing session still calls switchSession', async () => { + const agent = new AcpAgent(makeConn()) + const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) + mockSwitchSession.mockClear() + + // Resume the same session — should still align global state + await agent.unstable_resumeSession({ + sessionId, + cwd: '/tmp', + mcpServers: [], + } as any) + + expect(mockSwitchSession).toHaveBeenCalledWith(sessionId) + }) + + test('prompt does not trigger additional switchSession for multi-session', async () => { + const agent = new AcpAgent(makeConn()) + await agent.newSession({ cwd: '/tmp' } as any) + await agent.newSession({ cwd: '/tmp' } as any) + mockSwitchSession.mockClear() + + // Prompts should not call switchSession — alignment happens at session creation + const s1 = agent.sessions.keys().next().value + await agent.prompt({ + sessionId: s1, + prompt: [{ type: 'text', text: 'hello' }], + } as any) + expect(mockSwitchSession).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/services/acp/agent.ts b/src/services/acp/agent.ts index 128328d82..4c747a6ac 100644 --- a/src/services/acp/agent.ts +++ b/src/services/acp/agent.ts @@ -1,42 +1,1047 @@ -// CC_Pure: ACP agent stub. ACP (Agent Communication Protocol) is not -// enabled in this build; this file exists only to satisfy the typechecker -// for modules that import './agent.js'. - +/** + * ACP Agent implementation — bridges ACP protocol methods to Claude Code's + * internal QueryEngine / query() pipeline. + * + * Architecture: Uses internal QueryEngine (not @anthropic-ai/claude-agent-sdk) + * to directly run queries, with a bridge layer converting SDKMessage → ACP SessionUpdate. + */ import type { + Agent, AgentSideConnection, + InitializeRequest, + InitializeResponse, + AuthenticateRequest, + AuthenticateResponse, + NewSessionRequest, + NewSessionResponse, + PromptRequest, + PromptResponse, + CancelNotification, + LoadSessionRequest, + LoadSessionResponse, + ListSessionsRequest, + ListSessionsResponse, + ResumeSessionRequest, + ResumeSessionResponse, + ForkSessionRequest, + ForkSessionResponse, + CloseSessionRequest, + CloseSessionResponse, + SetSessionModeRequest, + SetSessionModeResponse, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + ClientCapabilities, + SessionModeState, + SessionModelState, + SessionConfigOption, } from '@agentclientprotocol/sdk' -import type * as schema from '@agentclientprotocol/sdk' +import { randomUUID, type UUID } from 'node:crypto' +import type { Message } from '../../types/message.js' +import { deserializeMessages } from '../../utils/conversationRecovery.js' +import { + getLastSessionLog, + sessionIdExists, +} from '../../utils/sessionStorage.js' +import { QueryEngine } from '../../QueryEngine.js' +import type { QueryEngineConfig } from '../../QueryEngine.js' +import type { Tools } from '../../Tool.js' +import { getTools } from '../../tools.js' +import { getEmptyToolPermissionContext } from '../../Tool.js' +import type { PermissionMode } from '../../types/permissions.js' +import type { Command } from '../../types/command.js' +import { getCommands } from '../../commands.js' +import { setOriginalCwd, switchSession } from '../../bootstrap/state.js' +import type { SessionId } from '../../types/ids.js' +import { enableConfigs } from '../../utils/config.js' +import { FileStateCache } from '../../utils/fileStateCache.js' +import { getDefaultAppState } from '../../state/AppStateStore.js' +import type { AppState } from '../../state/AppStateStore.js' +import { createAcpCanUseTool } from './permissions.js' +import { + forwardSessionUpdates, + replayHistoryMessages, + type ToolUseCache, +} from './bridge.js' +import { + resolvePermissionMode, + computeSessionFingerprint, + sanitizeTitle, +} from './utils.js' +import { promptToQueryInput } from './promptConversion.js' +import { listSessionsImpl } from '../../utils/listSessionsImpl.js' +import { getMainLoopModel } from '../../utils/model/model.js' +import { getModelOptions } from '../../utils/model/modelOptions.js' +import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' -const NOT_AVAILABLE = 'ACP agent not available in CC_Pure' +// ── Session state ───────────────────────────────────────────────── -export class AcpAgent { - readonly sessions = new Map() +type AcpSession = { + queryEngine: QueryEngine + cancelled: boolean + cancelGeneration: number + cwd: string + sessionFingerprint: string + modes: SessionModeState + models: SessionModelState + configOptions: SessionConfigOption[] + promptRunning: boolean + pendingMessages: Map + pendingQueue: string[] + pendingQueueHead: number + toolUseCache: ToolUseCache + clientCapabilities?: ClientCapabilities + appState: AppState + commands: Command[] +} - constructor(_connection: AgentSideConnection) {} +type PendingPrompt = { + resolve: (cancelled: boolean) => void +} - async initialize(_params: schema.InitializeRequest): Promise { - throw new Error(NOT_AVAILABLE) +// ── Agent class ─────────────────────────────────────────────────── + +export class AcpAgent implements Agent { + private conn: AgentSideConnection + sessions = new Map() + private clientCapabilities?: ClientCapabilities + + constructor(conn: AgentSideConnection) { + this.conn = conn } - async newSession(_params: schema.NewSessionRequest): Promise { - throw new Error(NOT_AVAILABLE) + + // ── initialize ──────────────────────────────────────────────── + + async initialize(params: InitializeRequest): Promise { + this.clientCapabilities = params.clientCapabilities + + return { + protocolVersion: 1, + agentInfo: { + name: 'claude-code', + title: 'Claude Code', + version: + typeof (globalThis as unknown as Record).MACRO === + 'object' && + (globalThis as unknown as Record>) + .MACRO !== null + ? String( + ( + ( + globalThis as unknown as Record< + string, + Record + > + ).MACRO as Record + ).VERSION ?? '0.0.0', + ) + : '0.0.0', + }, + agentCapabilities: { + _meta: { + claudeCode: { + promptQueueing: true, + }, + }, + promptCapabilities: { + image: true, + embeddedContext: true, + }, + mcpCapabilities: { + http: true, + sse: true, + }, + loadSession: true, + sessionCapabilities: { + fork: {}, + list: {}, + resume: {}, + close: {}, + }, + }, + } } - async authenticate(_params: schema.AuthenticateRequest): Promise { - throw new Error(NOT_AVAILABLE) + + // ── authenticate ────────────────────────────────────────────── + + async authenticate( + _params: AuthenticateRequest, + ): Promise { + // No authentication required — this is a self-hosted/custom deployment + return {} } - async prompt(_params: schema.PromptRequest): Promise { - throw new Error(NOT_AVAILABLE) + + // ── newSession ──────────────────────────────────────────────── + + async newSession(params: NewSessionRequest): Promise { + const result = await this.createSession(params) + this.scheduleAvailableCommandsUpdate(result.sessionId) + return result } - async cancel(_params: schema.CancelNotification): Promise { - throw new Error(NOT_AVAILABLE) + + // ── resumeSession ────────────────────────────────────────────── + + async unstable_resumeSession( + params: ResumeSessionRequest, + ): Promise { + const result = await this.getOrCreateSession(params) + this.scheduleAvailableCommandsUpdate(result.sessionId) + return result } - async loadSession(_params: schema.LoadSessionRequest): Promise { - throw new Error(NOT_AVAILABLE) + + // ── loadSession ──────────────────────────────────────────────── + + async loadSession(params: LoadSessionRequest): Promise { + const result = await this.getOrCreateSession(params) + this.scheduleAvailableCommandsUpdate(result.sessionId) + return result } - async unstable_closeSession(_params: schema.CloseSessionRequest): Promise { - throw new Error(NOT_AVAILABLE) + + // ── listSessions ─────────────────────────────────────────────── + + async listSessions( + params: ListSessionsRequest, + ): Promise { + const candidates = await listSessionsImpl({ + dir: params.cwd ?? undefined, + limit: 100, + }) + + const sessions = [] + for (const candidate of candidates) { + if (!candidate.cwd) continue + sessions.push({ + sessionId: candidate.sessionId, + cwd: candidate.cwd, + title: sanitizeTitle(candidate.summary ?? ''), + updatedAt: new Date(candidate.lastModified).toISOString(), + }) + } + + return { sessions } + } + + // ── forkSession ──────────────────────────────────────────────── + + async unstable_forkSession( + params: ForkSessionRequest, + ): Promise { + const response = await this.createSession({ + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + _meta: params._meta, + }) + this.scheduleAvailableCommandsUpdate(response.sessionId) + return response + } + + // ── closeSession ─────────────────────────────────────────────── + + async unstable_closeSession( + params: CloseSessionRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + await this.teardownSession(params.sessionId) + return {} + } + + // ── prompt ──────────────────────────────────────────────────── + + async prompt(params: PromptRequest): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error(`Session ${params.sessionId} not found`) + } + + // Extract text/image content from the prompt + const promptInput = promptToQueryInput(params.prompt) + + if (!promptInput.trim()) { + return { stopReason: 'end_turn' } + } + + const promptCancelGeneration = session.cancelGeneration + + // Handle prompt queuing — if a prompt is already running, queue this one + if (session.promptRunning) { + const promptUuid = randomUUID() + const cancelled = await new Promise(resolve => { + session.pendingQueue.push(promptUuid) + session.pendingMessages.set(promptUuid, { resolve }) + }) + if (cancelled) { + return { stopReason: 'cancelled' } + } + } + + if (session.cancelGeneration !== promptCancelGeneration) { + return { stopReason: 'cancelled' } + } + + // Reset cancellation only when this prompt is about to run. Queued prompts + // must not clear the cancellation state for the active prompt. + session.cancelled = false + session.promptRunning = true + + try { + // Reset the query engine's abort controller for a fresh query. + // After a previous interrupt(), the internal controller is stuck in + // aborted state — without this, submitMessage() fails immediately. + session.queryEngine.resetAbortController() + + const sdkMessages = session.queryEngine.submitMessage(promptInput) + + const { stopReason, usage } = await forwardSessionUpdates( + params.sessionId, + sdkMessages, + this.conn, + session.queryEngine.getAbortSignal(), + session.toolUseCache, + this.clientCapabilities, + session.cwd, + () => session.cancelled, + ) + + // If the session was cancelled during processing, return cancelled + if (session.cancelled) { + return { stopReason: 'cancelled' } + } + + return { + stopReason, + usage: usage + ? { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedReadTokens: usage.cachedReadTokens, + cachedWriteTokens: usage.cachedWriteTokens, + totalTokens: + usage.inputTokens + + usage.outputTokens + + usage.cachedReadTokens + + usage.cachedWriteTokens, + } + : undefined, + } + } catch (err: unknown) { + if (session.cancelled) { + return { stopReason: 'cancelled' } + } + + // Check for process death errors + if ( + err instanceof Error && + (err.message.includes('terminated') || + err.message.includes('process exited')) + ) { + this.teardownSession(params.sessionId) + throw new Error( + 'The Claude Agent process exited unexpectedly. Please start a new session.', + ) + } + + throw err + } finally { + // Resolve next pending prompt if any + const nextPrompt = popNextPendingPrompt(session) + if (nextPrompt) { + session.promptRunning = true + nextPrompt.resolve(false) + } else { + session.promptRunning = false + } + } + } + + // ── cancel ──────────────────────────────────────────────────── + + async cancel(params: CancelNotification): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) return + + // Set cancelled flag — checked by prompt() loop to break out + session.cancelled = true + session.cancelGeneration += 1 + + // Cancel any queued prompts + for (const [, pending] of session.pendingMessages) { + pending.resolve(true) + } + session.pendingMessages.clear() + session.pendingQueue = [] + session.pendingQueueHead = 0 + + // Interrupt the query engine to abort the current API call + session.queryEngine.interrupt() + } + + // ── setSessionMode ────────────────────────────────────────────── + + async setSessionMode( + params: SetSessionModeRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + + this.applySessionMode(params.sessionId, params.modeId) + await this.updateConfigOption(params.sessionId, 'mode', params.modeId) + return {} + } + + // ── setSessionModel ───────────────────────────────────────────── + + async unstable_setSessionModel( + params: SetSessionModelRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + // Store the raw value — QueryEngine.submitMessage() calls + // parseUserSpecifiedModel() to resolve aliases (e.g. "sonnet" → "glm-5.1-turbo") + session.queryEngine.setModel(params.modelId) + await this.updateConfigOption(params.sessionId, 'model', params.modelId) + return {} + } + + // ── setSessionConfigOption ────────────────────────────────────── + + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const session = this.sessions.get(params.sessionId) + if (!session) { + throw new Error('Session not found') + } + if (typeof params.value !== 'string') { + throw new Error( + `Invalid value for config option ${params.configId}: ${String(params.value)}`, + ) + } + + const option = session.configOptions.find(o => o.id === params.configId) + if (!option) { + throw new Error(`Unknown config option: ${params.configId}`) + } + + const value = params.value + + if (params.configId === 'mode') { + this.applySessionMode(params.sessionId, value) + await this.conn.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: 'current_mode_update', + currentModeId: value, + }, + }) + } else if (params.configId === 'model') { + session.queryEngine.setModel(value) + } + + this.syncSessionConfigState(session, params.configId, value) + + session.configOptions = session.configOptions.map(o => + o.id === params.configId && typeof o.currentValue === 'string' + ? { ...o, currentValue: value } + : o, + ) + + return { configOptions: session.configOptions } + } + + // ── Private helpers ───────────────────────────────────────────── + + private async createSession( + params: NewSessionRequest, + opts: { + forceNewId?: boolean + sessionId?: string + initialMessages?: Message[] + } = {}, + ): Promise { + enableConfigs() + + const sessionId = opts.sessionId ?? randomUUID() + const cwd = params.cwd + + // Align the global session state so that transcript persistence, + // analytics, and cost tracking use the ACP session ID. + switchSession(sessionId as SessionId) + + // Set CWD for the session + setOriginalCwd(cwd) + const previousProcessCwd = process.cwd() + let processCwdChanged = false + try { + process.chdir(cwd) + processCwdChanged = true + } catch { + // CWD may not exist yet; best-effort + } + + try { + // Build tools with a permissive permission context. + const permissionContext = getEmptyToolPermissionContext() + const tools: Tools = getTools(permissionContext) + + // Parse permission mode from _meta (passed by RCS/acp-link) or settings. + const meta = params._meta as Record | null | undefined + const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode') + const metaPermissionMode = hasMetaPermissionMode + ? meta?.permissionMode + : undefined + const settingsPermissionMode = this.getSetting( + 'permissions.defaultMode', + ) + const permissionMode = resolveSessionPermissionMode( + metaPermissionMode, + hasMetaPermissionMode, + settingsPermissionMode, + ) + + // Create the permission bridge canUseTool function + const canUseTool = createAcpCanUseTool( + this.conn, + sessionId, + () => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default', + this.clientCapabilities, + cwd, + (modeId: string) => { + this.applySessionMode(sessionId, modeId) + }, + () => + this.sessions.get(sessionId)?.appState.toolPermissionContext + .isBypassPermissionsModeAvailable ?? false, + ) + + // Parse MCP servers from ACP params + // MCP server config is handled separately in the tools system + + // ACP clients can expose bypass only when both the process and local config allow it. + const isBypassAvailable = isAcpBypassPermissionModeAvailable( + settingsPermissionMode, + ) + + // Create a mutable AppState for the session + const appState: AppState = { + ...getDefaultAppState(), + toolPermissionContext: { + ...permissionContext, + mode: permissionMode as PermissionMode, + isBypassPermissionsModeAvailable: isBypassAvailable, + }, + } + + // Load commands for slash command and skill support + const commands = await getCommands(cwd) + + // Build QueryEngine config + const engineConfig: QueryEngineConfig = { + cwd, + tools, + commands, + mcpClients: [], + agents: [], + canUseTool, + getAppState: () => appState, + setAppState: (updater: (prev: AppState) => AppState) => { + const updated = updater(appState) + Object.assign(appState, updated) + }, + readFileCache: new FileStateCache(500, 50 * 1024 * 1024), + includePartialMessages: true, + replayUserMessages: true, + initialMessages: opts.initialMessages, + } + + const queryEngine = new QueryEngine(engineConfig) + + // Build modes — bypassPermissions is opt-in for ACP clients. + const availableModes = [ + { + id: 'default', + name: 'Default', + description: 'Standard behavior, prompts for dangerous operations', + }, + { + id: 'acceptEdits', + name: 'Accept Edits', + description: 'Auto-accept file edit operations', + }, + { + id: 'plan', + name: 'Plan Mode', + description: 'Planning mode, no actual tool execution', + }, + { + id: 'auto', + name: 'Auto', + description: + 'Use a model classifier to approve/deny permission prompts.', + }, + ...(isBypassAvailable + ? [ + { + id: 'bypassPermissions' as const, + name: 'Bypass Permissions', + description: 'Skip all permission checks', + }, + ] + : []), + { + id: 'dontAsk', + name: "Don't Ask", + description: "Don't prompt for permissions, deny if not pre-approved", + }, + ] + + const modes: SessionModeState = { + currentModeId: permissionMode, + availableModes, + } + + // Build models + const modelOptions = getModelOptions() + const currentModel = getMainLoopModel() + const models: SessionModelState = { + availableModels: modelOptions.map(m => ({ + modelId: String(m.value ?? ''), + name: m.label ?? String(m.value ?? ''), + description: m.description ?? undefined, + })), + currentModelId: currentModel, + } + + // Set the model on the engine + queryEngine.setModel(currentModel) + + // Build config options + const configOptions = buildConfigOptions(modes, models) + + const session: AcpSession = { + queryEngine, + cancelled: false, + cancelGeneration: 0, + cwd, + modes, + models, + configOptions, + promptRunning: false, + pendingMessages: new Map(), + pendingQueue: [], + pendingQueueHead: 0, + toolUseCache: {}, + clientCapabilities: this.clientCapabilities, + appState, + commands, + sessionFingerprint: computeSessionFingerprint({ + cwd, + mcpServers: params.mcpServers as + | Array<{ name: string; [key: string]: unknown }> + | undefined, + }), + } + + this.sessions.set(sessionId, session) + + return { + sessionId, + models, + modes, + configOptions, + } + } finally { + if (processCwdChanged) { + process.chdir(previousProcessCwd) + } + } + } + + private async getOrCreateSession(params: { + sessionId: string + cwd: string + mcpServers?: NewSessionRequest['mcpServers'] + _meta?: NewSessionRequest['_meta'] + }): Promise { + const existingSession = this.sessions.get(params.sessionId) + if (existingSession) { + const fingerprint = computeSessionFingerprint({ + cwd: params.cwd, + mcpServers: params.mcpServers as + | Array<{ name: string; [key: string]: unknown }> + | undefined, + }) + if (fingerprint === existingSession.sessionFingerprint) { + // Align global state so subsequent operations use the correct session + switchSession(params.sessionId as SessionId) + return { + sessionId: params.sessionId, + modes: existingSession.modes, + models: existingSession.models, + configOptions: existingSession.configOptions, + } + } + + // Session-defining params changed — tear down and recreate + await this.teardownSession(params.sessionId) + } + + // Align global state BEFORE sessionIdExists() check — the lookup uses + // getSessionId() internally when resolving project-scoped paths. + switchSession(params.sessionId as SessionId) + + // Set CWD early so session file lookup can find the right project directory + setOriginalCwd(params.cwd) + + // Try to load session history for resume/load + let initialMessages: Message[] | undefined + if (sessionIdExists(params.sessionId)) { + try { + const log = await getLastSessionLog(params.sessionId as UUID) + if (log && log.messages.length > 0) { + initialMessages = deserializeMessages(log.messages) + } + } catch (err) { + console.error('[ACP] Failed to load session history:', err) + } + } + + const response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + _meta: params._meta, + }, + { sessionId: params.sessionId, initialMessages }, + ) + + // Replay history to client if loaded + if (initialMessages && initialMessages.length > 0) { + const session = this.sessions.get(params.sessionId) + if (session) { + await replayHistoryMessages( + params.sessionId, + initialMessages as unknown as Array>, + this.conn, + session.toolUseCache, + this.clientCapabilities, + session.cwd, + ) + } + } + + return { + sessionId: response.sessionId, + modes: response.modes, + models: response.models, + configOptions: response.configOptions, + } + } + + private async teardownSession(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + + await this.cancel({ sessionId }) + this.sessions.delete(sessionId) + } + + private applySessionMode(sessionId: string, modeId: string): void { + if (!isPermissionMode(modeId)) { + throw new Error(`Invalid mode: ${modeId}`) + } + const session = this.sessions.get(sessionId) + if (session) { + if ( + modeId === 'bypassPermissions' && + !session.appState.toolPermissionContext.isBypassPermissionsModeAvailable + ) { + throw new Error(`Mode not available: ${modeId}`) + } + const isAvailable = session.modes.availableModes.some( + mode => mode.id === modeId, + ) + if (!isAvailable) { + throw new Error(`Mode not available: ${modeId}`) + } + + session.modes = { ...session.modes, currentModeId: modeId } + // Sync mode to appState so the permission pipeline sees the correct mode + session.appState.toolPermissionContext = { + ...session.appState.toolPermissionContext, + mode: modeId as PermissionMode, + } + } + } + + private async updateConfigOption( + sessionId: string, + configId: string, + value: string, + ): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + + this.syncSessionConfigState(session, configId, value) + + session.configOptions = session.configOptions.map(o => + o.id === configId && typeof o.currentValue === 'string' + ? { ...o, currentValue: value } + : o, + ) + + await this.conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'config_option_update', + configOptions: session.configOptions, + }, + }) + } + + private syncSessionConfigState( + session: AcpSession, + configId: string, + value: string, + ): void { + if (configId === 'mode') { + session.modes = { ...session.modes, currentModeId: value } + } else if (configId === 'model') { + session.models = { ...session.models, currentModelId: value } + } + } + + private async sendAvailableCommandsUpdate(sessionId: string): Promise { + const session = this.sessions.get(sessionId) + if (!session) return + + const availableCommands = session.commands + .filter( + cmd => + cmd.type === 'prompt' && !cmd.isHidden && cmd.userInvocable !== false, + ) + .map(cmd => ({ + name: cmd.name, + description: cmd.description, + input: cmd.argumentHint ? { hint: cmd.argumentHint } : undefined, + })) + + await this.conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands, + }, + }) + } + + private scheduleAvailableCommandsUpdate(sessionId: string): void { + setTimeout(() => { + void this.sendAvailableCommandsUpdate(sessionId).catch(err => { + console.error('[ACP] Failed to send available commands update:', err) + }) + }, 0) + } + + /** Read a setting from Claude config (simplified — no file watching) */ + private getSetting(key: string): T | undefined { + const settings = getSettings_DEPRECATED() as Record + const value = key.split('.').reduce((current, segment) => { + if (!current || typeof current !== 'object') return undefined + return (current as Record)[segment] + }, settings) + return value as T | undefined } } -export async function runAcpAgent(): Promise { - throw new Error(NOT_AVAILABLE) +// ── Helpers ──────────────────────────────────────────────────────── + +const permissionModeIds: readonly PermissionMode[] = [ + 'auto', + 'default', + 'acceptEdits', + 'bypassPermissions', + 'dontAsk', + 'plan', +] + +function isPermissionMode(modeId: string): modeId is PermissionMode { + return (permissionModeIds as readonly string[]).includes(modeId) +} + +function resolveSessionPermissionMode( + metaMode: unknown, + hasMetaMode: boolean, + settingsMode: unknown, +): PermissionMode { + if (hasMetaMode) { + const metaResolved = resolveRequiredPermissionMode( + metaMode, + '_meta.permissionMode', + ) + if ( + metaResolved === 'bypassPermissions' && + !isAcpBypassPermissionModeAvailable(settingsMode) + ) { + throw new Error( + 'Mode not available: bypassPermissions requires a local ACP bypass opt-in.', + ) + } + + return metaResolved + } + + const settingsResolved = resolveConfiguredPermissionMode(settingsMode) + return settingsResolved ?? 'default' +} + +function resolveRequiredPermissionMode( + mode: unknown, + source: string, +): PermissionMode { + if (mode === undefined || mode === null) { + throw new Error(`Invalid ${source}: expected a string.`) + } + + return resolvePermissionMode(mode, source) as PermissionMode +} + +function resolveConfiguredPermissionMode( + mode: unknown, +): PermissionMode | undefined { + if (mode === undefined || mode === null) return undefined + + try { + return resolvePermissionMode( + mode, + 'permissions.defaultMode', + ) as PermissionMode + } catch (err: unknown) { + const reason = err instanceof Error ? err.message : String(err) + console.error( + '[ACP] Invalid permissions.defaultMode, using default:', + reason, + ) + return undefined + } +} + +function hasOwnField( + value: Record | null | undefined, + key: string, +): boolean { + return !!value && Object.hasOwn(value, key) +} + +function isAcpBypassPermissionModeAvailable(settingsMode?: unknown): boolean { + return ( + isProcessBypassPermissionModeAvailable() && + (isAcpBypassLocallyEnabled() || + isSettingsBypassPermissionMode(settingsMode)) + ) +} + +function isProcessBypassPermissionModeAvailable(): boolean { + if (process.env.IS_SANDBOX) return true + if (typeof process.geteuid === 'function') return process.geteuid() !== 0 + if (typeof process.getuid === 'function') return process.getuid() !== 0 + return true +} + +function isAcpBypassLocallyEnabled(): boolean { + return ( + process.env.ACP_PERMISSION_MODE === 'bypassPermissions' || + isTruthyEnv(process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS) + ) +} + +function isSettingsBypassPermissionMode(settingsMode: unknown): boolean { + try { + return resolvePermissionMode(settingsMode) === 'bypassPermissions' + } catch { + return false + } +} + +function isTruthyEnv(value: string | undefined): boolean { + return value === '1' || value?.toLowerCase() === 'true' +} + +function popNextPendingPrompt(session: AcpSession): PendingPrompt | undefined { + while (session.pendingQueueHead < session.pendingQueue.length) { + const nextId = session.pendingQueue[session.pendingQueueHead++] + if (!nextId) continue + const next = session.pendingMessages.get(nextId) + if (!next) continue + session.pendingMessages.delete(nextId) + compactPendingQueue(session) + return next + } + + compactPendingQueue(session) + return undefined +} + +function compactPendingQueue(session: AcpSession): void { + if (session.pendingQueueHead === 0) return + + if (session.pendingQueueHead >= session.pendingQueue.length) { + session.pendingQueue = [] + session.pendingQueueHead = 0 + return + } + + if ( + session.pendingQueueHead > 1024 && + session.pendingQueueHead * 2 > session.pendingQueue.length + ) { + session.pendingQueue = session.pendingQueue.slice(session.pendingQueueHead) + session.pendingQueueHead = 0 + } +} + +function buildConfigOptions( + modes: SessionModeState, + models: SessionModelState, +): SessionConfigOption[] { + return [ + { + id: 'mode', + name: 'Mode', + description: 'Session permission mode', + category: 'mode', + type: 'select' as const, + currentValue: modes.currentModeId, + options: modes.availableModes.map( + (m: SessionModeState['availableModes'][number]) => ({ + value: m.id, + name: m.name, + description: m.description, + }), + ), + }, + { + id: 'model', + name: 'Model', + description: 'AI model to use', + category: 'model', + type: 'select' as const, + currentValue: models.currentModelId, + options: models.availableModels.map( + (m: SessionModelState['availableModels'][number]) => ({ + value: m.modelId, + name: m.name, + description: m.description ?? undefined, + }), + ), + }, + ] as SessionConfigOption[] }