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,
|
||||
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<string, unknown>) {
|
||||
function mockModulePreservingExports(
|
||||
tsPath: string,
|
||||
overrides: Record<string, unknown>,
|
||||
) {
|
||||
const jsPath = tsPath.replace(/\.ts$/, '.js')
|
||||
const real = require(tsPath)
|
||||
const snapshot = { ...real }
|
||||
const snapshot = { ...(require(tsPath) as Record<string, unknown>) }
|
||||
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<typeof mock>).mockReset()
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).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<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 () => {
|
||||
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<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', () => {
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user