fix(acp): 对齐 ACP session ID 与全局会话状态
在 newSession/resumeSession/loadSession 中调用 switchSession, 确保 transcript 持久化、analytics 与 cost tracking 使用 ACP session ID, 而非内部默认 session ID。 - newSession 生成 sessionId 后立即对齐全局状态 - resumeSession 命中 fingerprint 缓存路径也对齐 - loadSession 在 sessionIdExists() 检查前对齐(lookup 依赖 getSessionId) - 补充 5 个测试覆盖上述路径,以及 prompt 不触发额外 switchSession
This commit is contained in:
parent
188bcea5ba
commit
6950401c06
|
|
@ -4,57 +4,76 @@ import {
|
||||||
test,
|
test,
|
||||||
mock,
|
mock,
|
||||||
beforeEach,
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
afterAll,
|
afterAll,
|
||||||
spyOn,
|
spyOn,
|
||||||
} from 'bun:test'
|
} from 'bun:test'
|
||||||
|
|
||||||
// ── Mock infrastructure ──────────────────────────────────────────
|
// ── Mock infrastructure ──────────────────────────────────────────
|
||||||
// bun:test mock.module is process-global: it leaks to sibling test files
|
// 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.
|
// so afterAll can restore them, preventing cross-file pollution.
|
||||||
|
|
||||||
const _restores: (() => void)[] = []
|
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<string, unknown>) {
|
function mockModulePreservingExports(
|
||||||
|
tsPath: string,
|
||||||
|
overrides: Record<string, unknown>,
|
||||||
|
) {
|
||||||
const jsPath = tsPath.replace(/\.ts$/, '.js')
|
const jsPath = tsPath.replace(/\.ts$/, '.js')
|
||||||
const real = require(tsPath)
|
const snapshot = { ...(require(tsPath) as Record<string, unknown>) }
|
||||||
const snapshot = { ...real }
|
|
||||||
mock.module(jsPath, () => ({ ...snapshot, ...overrides }))
|
mock.module(jsPath, () => ({ ...snapshot, ...overrides }))
|
||||||
_restores.push(() => mock.module(jsPath, () => snapshot))
|
_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) ──
|
// ── Module mocks (must precede any import of the module under test) ──
|
||||||
|
|
||||||
const mockSetModel = mock(() => {})
|
const mockSetModel = mock(() => {})
|
||||||
|
const mockSubmitMessage = mock(async function* (_input: string) {})
|
||||||
|
|
||||||
// Fully synthetic — no real module to snapshot, so plain mock.module suffices.
|
mockModulePreservingExports('../../../QueryEngine.ts', {
|
||||||
mock.module('../../../QueryEngine.js', () => ({
|
|
||||||
QueryEngine: class MockQueryEngine {
|
QueryEngine: class MockQueryEngine {
|
||||||
submitMessage = mock(async function* () {})
|
submitMessage = mockSubmitMessage
|
||||||
interrupt = mock(() => {})
|
interrupt = mock(() => {})
|
||||||
resetAbortController = mock(() => {})
|
resetAbortController = mock(() => {})
|
||||||
getAbortSignal = mock(() => new AbortController().signal)
|
getAbortSignal = mock(() => new AbortController().signal)
|
||||||
setModel = mockSetModel
|
setModel = mockSetModel
|
||||||
},
|
},
|
||||||
}))
|
})
|
||||||
|
|
||||||
safeMockModule('../../../tools.ts', {
|
mockModulePreservingExports('../../../tools.ts', {
|
||||||
getTools: mock(() => []),
|
getTools: mock(() => []),
|
||||||
})
|
})
|
||||||
|
|
||||||
safeMockModule('../../../Tool.ts', {
|
mockModulePreservingExports('../../../Tool.ts', {
|
||||||
toolMatchesName: mock(() => false),
|
toolMatchesName: mock(() => false),
|
||||||
findToolByName: mock(() => undefined),
|
findToolByName: mock(() => undefined),
|
||||||
filterToolProgressMessages: mock(() => []),
|
filterToolProgressMessages: mock(() => []),
|
||||||
buildTool: mock((def: any) => def),
|
buildTool: mock((def: any) => def),
|
||||||
})
|
})
|
||||||
|
|
||||||
safeMockModule('../../../utils/config.ts', {
|
mockModulePreservingExports('../../../utils/config.ts', {
|
||||||
enableConfigs: mock(() => {}),
|
enableConfigs: mock(() => {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
safeMockModule('../../../bootstrap/state.ts', {
|
const mockSwitchSession = mock(() => {})
|
||||||
|
|
||||||
|
mockModulePreservingExports('../../../bootstrap/state.ts', {
|
||||||
setOriginalCwd: mock(() => {}),
|
setOriginalCwd: mock(() => {}),
|
||||||
|
switchSession: mockSwitchSession,
|
||||||
addSlowOperation: mock(() => {}),
|
addSlowOperation: mock(() => {}),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -75,24 +94,16 @@ const mockGetDefaultAppState = mock(() => ({
|
||||||
mainLoopModelForSession: null,
|
mainLoopModelForSession: null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
safeMockModule('../../../state/AppStateStore.ts', {
|
mockModulePreservingExports('../../../state/AppStateStore.ts', {
|
||||||
getDefaultAppState: mockGetDefaultAppState,
|
getDefaultAppState: mockGetDefaultAppState,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Single export, fully synthetic — no real module to snapshot.
|
mockModulePreservingExports('../utils.ts', {
|
||||||
mock.module('../permissions.js', () => ({
|
|
||||||
createAcpCanUseTool: mock(() =>
|
|
||||||
mock(async () => ({ behavior: 'allow', updatedInput: {} })),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
safeMockModule('../utils.ts', {
|
|
||||||
resolvePermissionMode: mock(() => 'default'),
|
|
||||||
computeSessionFingerprint: mock(() => '{}'),
|
computeSessionFingerprint: mock(() => '{}'),
|
||||||
sanitizeTitle: mock((s: string) => s),
|
sanitizeTitle: mock((s: string) => s),
|
||||||
})
|
})
|
||||||
|
|
||||||
safeMockModule('../bridge.ts', {
|
mockModulePreservingExports('../bridge.ts', {
|
||||||
forwardSessionUpdates: mock(async () => ({
|
forwardSessionUpdates: mock(async () => ({
|
||||||
stopReason: 'end_turn' as const,
|
stopReason: 'end_turn' as const,
|
||||||
})),
|
})),
|
||||||
|
|
@ -105,33 +116,38 @@ safeMockModule('../bridge.ts', {
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
safeMockModule('../../../utils/listSessionsImpl.ts', {
|
mockModulePreservingExports('../../../utils/listSessionsImpl.ts', {
|
||||||
listSessionsImpl: mock(async () => []),
|
listSessionsImpl: mock(async () => []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
|
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
|
||||||
|
|
||||||
safeMockModule('../../../utils/model/model.ts', {
|
mockModulePreservingExports('../../../utils/model/model.ts', {
|
||||||
getMainLoopModel: mockGetMainLoopModel,
|
getMainLoopModel: mockGetMainLoopModel,
|
||||||
})
|
})
|
||||||
|
|
||||||
safeMockModule('../../../utils/model/modelOptions.ts', {
|
mockModulePreservingExports('../../../utils/model/modelOptions.ts', {
|
||||||
getModelOptions: mock(() => []),
|
getModelOptions: mock(() => []),
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockApplySafeEnvVars = mock(() => {})
|
const mockApplySafeEnvVars = mock(() => {})
|
||||||
safeMockModule('../../../utils/managedEnv.ts', {
|
mockModulePreservingExports('../../../utils/managedEnv.ts', {
|
||||||
applySafeConfigEnvironmentVariables: mockApplySafeEnvVars,
|
applySafeConfigEnvironmentVariables: mockApplySafeEnvVars,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const mockGetSettings = mock(() => ({}))
|
||||||
|
mockModulePreservingExports('../../../utils/settings/settings.ts', {
|
||||||
|
getSettings_DEPRECATED: mockGetSettings,
|
||||||
|
})
|
||||||
|
|
||||||
const mockDeserializeMessages = mock((msgs: unknown[]) => msgs)
|
const mockDeserializeMessages = mock((msgs: unknown[]) => msgs)
|
||||||
safeMockModule('../../../utils/conversationRecovery.ts', {
|
mockModulePreservingExports('../../../utils/conversationRecovery.ts', {
|
||||||
deserializeMessages: mockDeserializeMessages,
|
deserializeMessages: mockDeserializeMessages,
|
||||||
})
|
})
|
||||||
|
|
||||||
const mockGetLastSessionLog = mock(async () => null)
|
const mockGetLastSessionLog = mock(async () => null)
|
||||||
const mockSessionIdExists = mock(() => false)
|
const mockSessionIdExists = mock(() => false)
|
||||||
safeMockModule('../../../utils/sessionStorage.ts', {
|
mockModulePreservingExports('../../../utils/sessionStorage.ts', {
|
||||||
getLastSessionLog: mockGetLastSessionLog,
|
getLastSessionLog: mockGetLastSessionLog,
|
||||||
sessionIdExists: mockSessionIdExists,
|
sessionIdExists: mockSessionIdExists,
|
||||||
})
|
})
|
||||||
|
|
@ -161,7 +177,7 @@ const mockGetCommands = mock(async () => [
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
safeMockModule('../../../commands.ts', {
|
mockModulePreservingExports('../../../commands.ts', {
|
||||||
getCommands: mockGetCommands,
|
getCommands: mockGetCommands,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -181,16 +197,49 @@ function makeConn() {
|
||||||
} as any
|
} 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 ─────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('AcpAgent', () => {
|
describe('AcpAgent', () => {
|
||||||
afterAll(() => {
|
|
||||||
for (const restore of _restores) restore()
|
|
||||||
})
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
delete process.env.ACP_PERMISSION_MODE
|
||||||
|
delete process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
|
||||||
mockSetModel.mockClear()
|
mockSetModel.mockClear()
|
||||||
|
mockSwitchSession.mockClear()
|
||||||
|
mockSubmitMessage.mockReset()
|
||||||
|
mockSubmitMessage.mockImplementation(async function* (_input: string) {})
|
||||||
mockGetMainLoopModel.mockClear()
|
mockGetMainLoopModel.mockClear()
|
||||||
mockGetDefaultAppState.mockClear()
|
mockGetDefaultAppState.mockClear()
|
||||||
|
mockGetSettings.mockReset()
|
||||||
|
mockGetSettings.mockImplementation(() => ({}))
|
||||||
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockReset()
|
||||||
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementation(
|
||||||
|
async () => ({ stopReason: 'end_turn' as const }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(originalCwd)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('initialize', () => {
|
describe('initialize', () => {
|
||||||
|
|
@ -255,6 +304,13 @@ describe('AcpAgent', () => {
|
||||||
expect(r1.sessionId).not.toBe(r2.sessionId)
|
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 () => {
|
test('calls getDefaultAppState to build session appState', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
await agent.newSession({ cwd: '/tmp' } as any)
|
await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
|
|
@ -290,6 +346,105 @@ describe('AcpAgent', () => {
|
||||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
expect(res.sessionId).toBeDefined()
|
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', () => {
|
describe('prompt', () => {
|
||||||
|
|
@ -375,7 +530,7 @@ describe('AcpAgent', () => {
|
||||||
expect(res2.stopReason).toBe('end_turn')
|
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 agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(
|
;(
|
||||||
|
|
@ -383,16 +538,13 @@ describe('AcpAgent', () => {
|
||||||
).mockImplementationOnce(async () => {
|
).mockImplementationOnce(async () => {
|
||||||
throw new Error('unexpected')
|
throw new Error('unexpected')
|
||||||
})
|
})
|
||||||
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
|
||||||
try {
|
await expect(
|
||||||
const res = await agent.prompt({
|
agent.prompt({
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
prompt: [{ type: 'text', text: 'hello' }],
|
||||||
} as any)
|
} as any),
|
||||||
expect(res.stopReason).toBe('end_turn')
|
).rejects.toThrow('unexpected')
|
||||||
} finally {
|
|
||||||
errorSpy.mockRestore()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns usage from forwardSessionUpdates', async () => {
|
test('returns usage from forwardSessionUpdates', async () => {
|
||||||
|
|
@ -676,15 +828,28 @@ describe('AcpAgent', () => {
|
||||||
).rejects.toThrow('Session not found')
|
).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 agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
const session = agent.sessions.get(sessionId)
|
const session = agent.sessions.get(sessionId)
|
||||||
const modeIds = session?.modes.availableModes.map((m: any) => m.id)
|
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 agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
await agent.setSessionMode({
|
await agent.setSessionMode({
|
||||||
|
|
@ -697,6 +862,21 @@ describe('AcpAgent', () => {
|
||||||
'bypassPermissions',
|
'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', () => {
|
describe('setSessionConfigOption', () => {
|
||||||
|
|
@ -723,6 +903,24 @@ describe('AcpAgent', () => {
|
||||||
} as any),
|
} as any),
|
||||||
).rejects.toThrow('Invalid value')
|
).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', () => {
|
describe('prompt queueing', () => {
|
||||||
|
|
@ -758,6 +956,100 @@ describe('AcpAgent', () => {
|
||||||
expect(r2.stopReason).toBe('end_turn')
|
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<typeof mock>
|
||||||
|
).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<typeof mock>
|
||||||
|
).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<{ stopReason: string }>(resolve => {
|
||||||
|
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
;(
|
||||||
|
forwardSessionUpdates as ReturnType<typeof mock>
|
||||||
|
).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 () => {
|
test('queued prompts return cancelled when session is cancelled', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
|
|
@ -787,6 +1079,46 @@ describe('AcpAgent', () => {
|
||||||
expect(r1.stopReason).toBe('cancelled')
|
expect(r1.stopReason).toBe('cancelled')
|
||||||
expect(r2.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<typeof mock>
|
||||||
|
).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<{ stopReason: string }>(resolve => {
|
||||||
|
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
;(forwardSessionUpdates as ReturnType<typeof mock>).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', () => {
|
describe('commands', () => {
|
||||||
|
|
@ -829,4 +1161,66 @@ describe('AcpAgent', () => {
|
||||||
expect(commit.input).toEqual({ hint: '[message]' })
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user