mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 00:35:51 +00:00
735
src/services/acp/__tests__/agent.test.ts
Normal file
735
src/services/acp/__tests__/agent.test.ts
Normal file
@@ -0,0 +1,735 @@
|
||||
import { describe, expect, test, mock, beforeEach } from 'bun:test'
|
||||
|
||||
// ── Heavy module mocks (must be before any import of the module under test) ──
|
||||
|
||||
const mockSetModel = mock(() => {})
|
||||
|
||||
mock.module('../../../QueryEngine.js', () => ({
|
||||
QueryEngine: class MockQueryEngine {
|
||||
submitMessage = mock(async function* () {})
|
||||
interrupt = mock(() => {})
|
||||
resetAbortController = mock(() => {})
|
||||
getAbortSignal = mock(() => new AbortController().signal)
|
||||
setModel = mockSetModel
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../../../tools.js', () => ({
|
||||
getTools: mock(() => []),
|
||||
}))
|
||||
|
||||
mock.module('../../../Tool.js', () => ({
|
||||
getEmptyToolPermissionContext: mock(() => ({})),
|
||||
}))
|
||||
|
||||
mock.module('../../../utils/config.js', () => ({
|
||||
enableConfigs: mock(() => {}),
|
||||
}))
|
||||
|
||||
mock.module('../../../bootstrap/state.js', () => ({
|
||||
setOriginalCwd: mock(() => {}),
|
||||
addSlowOperation: mock(() => {}),
|
||||
}))
|
||||
|
||||
const mockGetDefaultAppState = mock(() => ({
|
||||
toolPermissionContext: {
|
||||
mode: 'default',
|
||||
additionalWorkingDirectories: new Map(),
|
||||
alwaysAllowRules: { user: [], project: [], local: [] },
|
||||
alwaysDenyRules: { user: [], project: [], local: [] },
|
||||
alwaysAskRules: { user: [], project: [], local: [] },
|
||||
isBypassPermissionsModeAvailable: false,
|
||||
},
|
||||
fastMode: false,
|
||||
settings: {},
|
||||
tasks: {},
|
||||
verbose: false,
|
||||
mainLoopModel: null,
|
||||
mainLoopModelForSession: null,
|
||||
}))
|
||||
|
||||
mock.module('../../../state/AppStateStore.js', () => ({
|
||||
getDefaultAppState: mockGetDefaultAppState,
|
||||
}))
|
||||
|
||||
mock.module('../../../utils/fileStateCache.js', () => ({
|
||||
FileStateCache: class MockFileStateCache {
|
||||
constructor() {}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../permissions.js', () => ({
|
||||
createAcpCanUseTool: mock(() => mock(async () => ({ behavior: 'allow', updatedInput: {} }))),
|
||||
}))
|
||||
|
||||
mock.module('../bridge.js', () => ({
|
||||
forwardSessionUpdates: mock(async () => ({ stopReason: 'end_turn' as const })),
|
||||
replayHistoryMessages: mock(async () => {}),
|
||||
toolInfoFromToolUse: mock(() => ({ title: 'Test', kind: 'other', content: [], locations: [] })),
|
||||
}))
|
||||
|
||||
mock.module('../utils.js', () => ({
|
||||
resolvePermissionMode: mock(() => 'default'),
|
||||
computeSessionFingerprint: mock(() => '{}'),
|
||||
sanitizeTitle: mock((s: string) => s),
|
||||
}))
|
||||
|
||||
mock.module('../../../utils/listSessionsImpl.js', () => ({
|
||||
listSessionsImpl: mock(async () => []),
|
||||
}))
|
||||
|
||||
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
|
||||
|
||||
mock.module('../../../utils/model/model.js', () => ({
|
||||
getMainLoopModel: mockGetMainLoopModel,
|
||||
}))
|
||||
|
||||
mock.module('../../../utils/model/modelOptions.ts', () => ({
|
||||
getModelOptions: mock(() => []),
|
||||
}))
|
||||
|
||||
const mockApplySafeEnvVars = mock(() => {})
|
||||
mock.module('../../../utils/managedEnv.js', () => ({
|
||||
applySafeConfigEnvironmentVariables: mockApplySafeEnvVars,
|
||||
}))
|
||||
|
||||
const mockDeserializeMessages = mock((msgs: unknown[]) => msgs)
|
||||
const mockGetLastSessionLog = mock(async () => null)
|
||||
const mockSessionIdExists = mock(() => false)
|
||||
|
||||
mock.module('../../../utils/conversationRecovery.js', () => ({
|
||||
deserializeMessages: mockDeserializeMessages,
|
||||
}))
|
||||
|
||||
mock.module('../../../utils/sessionStorage.js', () => ({
|
||||
getLastSessionLog: mockGetLastSessionLog,
|
||||
sessionIdExists: mockSessionIdExists,
|
||||
}))
|
||||
|
||||
const mockGetCommands = mock(async () => [
|
||||
{
|
||||
name: 'commit',
|
||||
description: 'Create a git commit',
|
||||
type: 'prompt',
|
||||
userInvocable: true,
|
||||
isHidden: false,
|
||||
argumentHint: '[message]',
|
||||
},
|
||||
{
|
||||
name: 'compact',
|
||||
description: 'Compact conversation',
|
||||
type: 'local',
|
||||
userInvocable: true,
|
||||
isHidden: false,
|
||||
},
|
||||
{
|
||||
name: 'hidden-skill',
|
||||
description: 'Hidden skill',
|
||||
type: 'prompt',
|
||||
userInvocable: false,
|
||||
isHidden: true,
|
||||
},
|
||||
])
|
||||
|
||||
mock.module('../../../commands.js', () => ({
|
||||
getCommands: mockGetCommands,
|
||||
}))
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────
|
||||
|
||||
const { AcpAgent } = await import('../agent.js')
|
||||
const { forwardSessionUpdates } = await import('../bridge.js')
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function makeConn() {
|
||||
return {
|
||||
sessionUpdate: mock(async () => {}),
|
||||
requestPermission: mock(async () => ({ outcome: { outcome: 'cancelled' } })),
|
||||
} as any
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('AcpAgent', () => {
|
||||
beforeEach(() => {
|
||||
mockSetModel.mockClear()
|
||||
mockGetMainLoopModel.mockClear()
|
||||
mockGetDefaultAppState.mockClear()
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
test('returns protocol version and agent info', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.initialize({} as any)
|
||||
expect(res.protocolVersion).toBeDefined()
|
||||
expect(res.agentInfo?.name).toBe('claude-code')
|
||||
expect(typeof res.agentInfo?.version).toBe('string')
|
||||
})
|
||||
|
||||
test('advertises image and embeddedContext capability', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.initialize({} as any)
|
||||
expect(res.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
||||
expect(res.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
|
||||
})
|
||||
|
||||
test('loadSession capability is true', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.initialize({} as any)
|
||||
expect(res.agentCapabilities?.loadSession).toBe(true)
|
||||
})
|
||||
|
||||
test('session capabilities include fork, list, resume, close', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.initialize({} as any)
|
||||
expect(res.agentCapabilities?.sessionCapabilities).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticate', () => {
|
||||
test('returns empty object (no auth required)', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.authenticate({} as any)
|
||||
expect(res).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('newSession', () => {
|
||||
test('returns a sessionId string', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(typeof res.sessionId).toBe('string')
|
||||
expect(res.sessionId.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('returns modes and models', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(res.modes).toBeDefined()
|
||||
expect(res.models).toBeDefined()
|
||||
expect(res.configOptions).toBeDefined()
|
||||
})
|
||||
|
||||
test('each call returns a unique sessionId', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const r1 = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const r2 = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(r1.sessionId).not.toBe(r2.sessionId)
|
||||
})
|
||||
|
||||
test('calls getDefaultAppState to build session appState', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(mockGetDefaultAppState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('calls getMainLoopModel to resolve current model', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(mockGetMainLoopModel).toHaveBeenCalled()
|
||||
// The model reported to ACP client should match what getMainLoopModel returns
|
||||
expect(res.models?.currentModelId).toBe('claude-sonnet-4-6')
|
||||
})
|
||||
|
||||
test('calls queryEngine.setModel with resolved model', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(mockSetModel).toHaveBeenCalledWith('claude-sonnet-4-6')
|
||||
})
|
||||
|
||||
test('respects model alias resolution via getMainLoopModel', async () => {
|
||||
// Simulate a mapped model (e.g., "opus" → "glm-5.1" via ANTHROPIC_DEFAULT_OPUS_MODEL)
|
||||
mockGetMainLoopModel.mockReturnValueOnce('glm-5.1')
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
expect(res.models?.currentModelId).toBe('glm-5.1')
|
||||
expect(mockSetModel).toHaveBeenCalledWith('glm-5.1')
|
||||
})
|
||||
|
||||
test('stores clientCapabilities from initialize', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await agent.initialize({ clientCapabilities: { _meta: { terminal_output: true } } } as any)
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
// Should not throw — clientCapabilities stored internally
|
||||
expect(res.sessionId).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt', () => {
|
||||
test('throws when session not found', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await expect(
|
||||
agent.prompt({ sessionId: 'nonexistent', prompt: [] } as any)
|
||||
).rejects.toThrow('nonexistent')
|
||||
})
|
||||
|
||||
test('returns end_turn for empty prompt text', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const res = await agent.prompt({ sessionId, prompt: [] } as any)
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('returns end_turn for whitespace-only prompt', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const res = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: ' ' }],
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('calls forwardSessionUpdates for valid prompt', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
||||
const res = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('cancel before prompt does not block next prompt', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
// Cancel when nothing is running is a no-op
|
||||
await agent.cancel({ sessionId } as any)
|
||||
// The next prompt should work normally
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
||||
const res = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('cancel during prompt returns cancelled', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
// Start a prompt that hangs, then cancel it
|
||||
let resolveStream!: () => void
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() => new Promise<{ stopReason: string }>((resolve) => {
|
||||
resolveStream = () => resolve({ stopReason: 'cancelled' })
|
||||
}),
|
||||
)
|
||||
const promptPromise = agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
// Cancel the running prompt
|
||||
await agent.cancel({ sessionId } as any)
|
||||
resolveStream()
|
||||
const res = await promptPromise
|
||||
// After fix, forwardSessionUpdates mock controls the result
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
|
||||
// Next prompt should work normally
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
||||
const res2 = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'world' }],
|
||||
} as any)
|
||||
expect(res2.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('returns end_turn on unexpected error', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(async () => {
|
||||
throw new Error('unexpected')
|
||||
})
|
||||
const res = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('returns usage from forwardSessionUpdates', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
stopReason: 'end_turn',
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cachedReadTokens: 10,
|
||||
cachedWriteTokens: 5,
|
||||
},
|
||||
})
|
||||
const res = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.usage).toBeDefined()
|
||||
expect(res.usage!.inputTokens).toBe(100)
|
||||
expect(res.usage!.outputTokens).toBe(50)
|
||||
expect(res.usage!.totalTokens).toBe(165)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancel', () => {
|
||||
test('does not throw for unknown session', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await expect(agent.cancel({ sessionId: 'ghost' } as any)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('closeSession', () => {
|
||||
test('throws for unknown session', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await expect(agent.unstable_closeSession({ sessionId: 'ghost' } as any)).rejects.toThrow('Session not found')
|
||||
})
|
||||
|
||||
test('removes session after close', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await agent.unstable_closeSession({ sessionId } as any)
|
||||
expect(agent.sessions.has(sessionId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSessionModel', () => {
|
||||
test('updates model on queryEngine', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
mockSetModel.mockClear()
|
||||
await agent.unstable_setSessionModel({ sessionId, modelId: 'glm-5.1' } as any)
|
||||
expect(mockSetModel).toHaveBeenCalledWith('glm-5.1')
|
||||
})
|
||||
|
||||
test('passes alias modelId to queryEngine as-is for later resolution', async () => {
|
||||
// "sonnet[1m]" is stored raw — QueryEngine.submitMessage() calls
|
||||
// parseUserSpecifiedModel() which resolves aliases via env vars
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
mockSetModel.mockClear()
|
||||
await agent.unstable_setSessionModel({ sessionId, modelId: 'sonnet[1m]' } as any)
|
||||
expect(mockSetModel).toHaveBeenCalledWith('sonnet[1m]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('entry.ts initialization contract', () => {
|
||||
test('entry.ts imports applySafeConfigEnvironmentVariables from managedEnv', async () => {
|
||||
// Verify the module import exists — this catches if entry.ts forgets
|
||||
// to import applySafeConfigEnvironmentVariables
|
||||
const entrySource = await Bun.file(
|
||||
new URL('../entry.ts', import.meta.url),
|
||||
).text()
|
||||
expect(entrySource).toContain('applySafeConfigEnvironmentVariables')
|
||||
expect(entrySource).toContain('enableConfigs')
|
||||
|
||||
// Verify applySafe is called after enableConfigs in the source
|
||||
const enableIdx = entrySource.indexOf('enableConfigs()')
|
||||
const applyIdx = entrySource.indexOf('applySafeConfigEnvironmentVariables()')
|
||||
expect(enableIdx).toBeGreaterThan(-1)
|
||||
expect(applyIdx).toBeGreaterThan(-1)
|
||||
expect(enableIdx).toBeLessThan(applyIdx)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt usage tracking', () => {
|
||||
test('returns totalTokens as sum of all token types', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
stopReason: 'end_turn',
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cachedReadTokens: 10,
|
||||
cachedWriteTokens: 5,
|
||||
},
|
||||
})
|
||||
const res = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.usage).toBeDefined()
|
||||
expect(res.usage!.totalTokens).toBe(165)
|
||||
})
|
||||
|
||||
test('returns undefined usage when forwardSessionUpdates returns none', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({
|
||||
stopReason: 'end_turn',
|
||||
})
|
||||
const res = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.usage).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt error handling', () => {
|
||||
test('returns cancelled when session was cancelled during prompt', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(async () => {
|
||||
// Simulate cancel happening during forward
|
||||
const session = agent.sessions.get(sessionId)
|
||||
if (session) session.cancelled = true
|
||||
return { stopReason: 'end_turn' }
|
||||
})
|
||||
const res = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
test('returns cancelled on cancel after error', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(async () => {
|
||||
const session = agent.sessions.get(sessionId)
|
||||
if (session) session.cancelled = true
|
||||
throw new Error('unexpected')
|
||||
})
|
||||
const res = await agent.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumeSession', () => {
|
||||
test('creates new session with the requested sessionId when not in memory', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const requestedId = 'e73e9b66-9637-4477-b512-af45357b1dcb'
|
||||
const res = await agent.unstable_resumeSession({
|
||||
sessionId: requestedId,
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
} as any)
|
||||
// The session must be stored under the requested ID
|
||||
expect(agent.sessions.has(requestedId)).toBe(true)
|
||||
// Response should have modes/models/configOptions
|
||||
expect(res.modes).toBeDefined()
|
||||
expect(res.models).toBeDefined()
|
||||
})
|
||||
|
||||
test('reuses existing session when sessionId matches and fingerprint unchanged', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const res1 = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const sid = res1.sessionId
|
||||
const originalSession = agent.sessions.get(sid)
|
||||
// Resume with same params
|
||||
const res2 = await agent.unstable_resumeSession({
|
||||
sessionId: sid,
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
} as any)
|
||||
// Same session object — not recreated
|
||||
expect(agent.sessions.get(sid)).toBe(originalSession)
|
||||
})
|
||||
|
||||
test('can prompt after resumeSession with previously unknown sessionId', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const sid = 'restored-session-id-1234'
|
||||
await agent.unstable_resumeSession({
|
||||
sessionId: sid,
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
} as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
||||
const res = await agent.prompt({
|
||||
sessionId: sid,
|
||||
prompt: [{ type: 'text', text: 'hello after restore' }],
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadSession', () => {
|
||||
test('creates new session with the requested sessionId', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const requestedId = 'aaaa-bbbb-cccc'
|
||||
await agent.loadSession({
|
||||
sessionId: requestedId,
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
} as any)
|
||||
expect(agent.sessions.has(requestedId)).toBe(true)
|
||||
})
|
||||
|
||||
test('can prompt after loadSession', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const sid = 'loaded-session-id'
|
||||
await agent.loadSession({
|
||||
sessionId: sid,
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
} as any)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
||||
const res = await agent.prompt({
|
||||
sessionId: sid,
|
||||
prompt: [{ type: 'text', text: 'hello after load' }],
|
||||
} as any)
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('forkSession', () => {
|
||||
test('returns a different sessionId from any existing', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const original = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
const forked = await agent.unstable_forkSession({
|
||||
cwd: '/tmp',
|
||||
mcpServers: [],
|
||||
} as any)
|
||||
expect(forked.sessionId).not.toBe(original.sessionId)
|
||||
expect(agent.sessions.has(forked.sessionId)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSessionMode', () => {
|
||||
test('updates current mode on the session', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await agent.setSessionMode({ sessionId, modeId: 'auto' } as any)
|
||||
const session = agent.sessions.get(sessionId)
|
||||
expect(session?.modes.currentModeId).toBe('auto')
|
||||
})
|
||||
|
||||
test('throws for invalid mode', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await expect(
|
||||
agent.setSessionMode({ sessionId, modeId: 'invalid_mode' } as any),
|
||||
).rejects.toThrow('Invalid mode')
|
||||
})
|
||||
|
||||
test('throws for unknown session', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
await expect(
|
||||
agent.setSessionMode({ sessionId: 'ghost', modeId: 'auto' } as any),
|
||||
).rejects.toThrow('Session not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('setSessionConfigOption', () => {
|
||||
test('throws for unknown config option', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: 'nonexistent',
|
||||
value: 'x',
|
||||
} as any),
|
||||
).rejects.toThrow('Unknown config option')
|
||||
})
|
||||
|
||||
test('throws for non-string value', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: 'mode',
|
||||
value: 42,
|
||||
} as any),
|
||||
).rejects.toThrow('Invalid value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt queueing', () => {
|
||||
test('queued prompts execute in order after current prompt finishes', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
// First prompt hangs
|
||||
let resolveFirst!: () => void
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() => new Promise<{ stopReason: string }>((resolve) => {
|
||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
// Second prompt resolves normally
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ 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)
|
||||
|
||||
// Resolve the first prompt to unblock the second
|
||||
resolveFirst()
|
||||
const [r1, r2] = await Promise.all([p1, p2])
|
||||
expect(r1.stopReason).toBe('end_turn')
|
||||
expect(r2.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('queued prompts return cancelled when session is cancelled', async () => {
|
||||
const agent = new AcpAgent(makeConn())
|
||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
// First prompt hangs
|
||||
let resolveFirst!: () => void
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
() => new Promise<{ stopReason: string }>((resolve) => {
|
||||
resolveFirst = () => 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)
|
||||
|
||||
// Cancel while first is running — both should be cancelled
|
||||
await agent.cancel({ sessionId } as any)
|
||||
resolveFirst()
|
||||
const [r1, r2] = await Promise.all([p1, p2])
|
||||
expect(r1.stopReason).toBe('cancelled')
|
||||
expect(r2.stopReason).toBe('cancelled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('commands', () => {
|
||||
test('sends filtered prompt-type commands to client', async () => {
|
||||
const conn = makeConn()
|
||||
const agent = new AcpAgent(conn)
|
||||
await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
// Wait for setTimeout-based sendAvailableCommandsUpdate
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
||||
const cmdUpdate = calls.find((c: any[]) => {
|
||||
const update = c[0]?.update
|
||||
return update?.sessionUpdate === 'available_commands_update'
|
||||
})
|
||||
expect(cmdUpdate).toBeDefined()
|
||||
|
||||
const cmds = (cmdUpdate as any[])[0].update.availableCommands
|
||||
// Only prompt-type, non-hidden, userInvocable commands
|
||||
const names = cmds.map((c: any) => c.name)
|
||||
expect(names).toContain('commit')
|
||||
expect(names).not.toContain('compact') // type: 'local'
|
||||
expect(names).not.toContain('hidden-skill') // isHidden: true, userInvocable: false
|
||||
})
|
||||
|
||||
test('maps argumentHint to input.hint', async () => {
|
||||
const conn = makeConn()
|
||||
const agent = new AcpAgent(conn)
|
||||
await agent.newSession({ cwd: '/tmp' } as any)
|
||||
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
||||
const cmdUpdate = calls.find((c: any[]) => {
|
||||
const update = c[0]?.update
|
||||
return update?.sessionUpdate === 'available_commands_update'
|
||||
})
|
||||
const commit = (cmdUpdate as any[])[0].update.availableCommands.find(
|
||||
(c: any) => c.name === 'commit',
|
||||
)
|
||||
expect(commit.input).toEqual({ hint: '[message]' })
|
||||
})
|
||||
})
|
||||
})
|
||||
677
src/services/acp/__tests__/bridge.test.ts
Normal file
677
src/services/acp/__tests__/bridge.test.ts
Normal file
@@ -0,0 +1,677 @@
|
||||
import { describe, expect, test, mock } from 'bun:test'
|
||||
import {
|
||||
toolInfoFromToolUse,
|
||||
toolUpdateFromToolResult,
|
||||
toolUpdateFromEditToolResponse,
|
||||
forwardSessionUpdates,
|
||||
} from '../bridge.js'
|
||||
import { markdownEscape, toDisplayPath } from '../utils.js'
|
||||
import type { AgentSideConnection, ToolKind } from '@agentclientprotocol/sdk'
|
||||
import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.js'
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function makeConn(overrides: Partial<AgentSideConnection> = {}): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: mock(async () => {}),
|
||||
requestPermission: mock(async () => ({ outcome: { outcome: 'cancelled' } }) as any),
|
||||
...overrides,
|
||||
} as unknown as AgentSideConnection
|
||||
}
|
||||
|
||||
async function* makeStream(msgs: SDKMessage[]): AsyncGenerator<SDKMessage, void, unknown> {
|
||||
for (const m of msgs) yield m
|
||||
}
|
||||
|
||||
// ── toolInfoFromToolUse ────────────────────────────────────────────
|
||||
|
||||
describe('toolInfoFromToolUse', () => {
|
||||
const kindCases: Array<[string, ToolKind]> = [
|
||||
['Read', 'read'],
|
||||
['Edit', 'edit'],
|
||||
['Write', 'edit'],
|
||||
['Bash', 'execute'],
|
||||
['Glob', 'search'],
|
||||
['Grep', 'search'],
|
||||
['WebFetch', 'fetch'],
|
||||
['WebSearch', 'fetch'],
|
||||
['Agent', 'think'],
|
||||
['Task', 'think'],
|
||||
['TodoWrite', 'think'],
|
||||
['ExitPlanMode', 'switch_mode'],
|
||||
]
|
||||
|
||||
for (const [name, expected] of kindCases) {
|
||||
test(`${name} → ${expected}`, () => {
|
||||
const info = toolInfoFromToolUse({ name, id: 'test', input: {} })
|
||||
expect(info.kind).toBe(expected)
|
||||
})
|
||||
}
|
||||
|
||||
test('unknown tool name → other', () => {
|
||||
expect(toolInfoFromToolUse({ name: 'SomeFancyTool', id: 'x', input: {} }).kind).toBe('other' as ToolKind)
|
||||
expect(toolInfoFromToolUse({ name: '', id: 'x', input: {} }).kind).toBe('other' as ToolKind)
|
||||
})
|
||||
|
||||
// ── Bash ──────────────────────────────────────────────────────
|
||||
|
||||
test('Bash with command → title shows command', () => {
|
||||
const info = toolInfoFromToolUse({ name: 'Bash', id: 'x', input: { command: 'ls -la', description: 'List files' } })
|
||||
expect(info.title).toBe('ls -la')
|
||||
expect(info.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'List files' } },
|
||||
])
|
||||
})
|
||||
|
||||
test('Bash with terminalOutput → returns terminalId content', () => {
|
||||
const info = toolInfoFromToolUse(
|
||||
{ name: 'Bash', id: 'tu_123', input: { command: 'ls' } },
|
||||
true,
|
||||
)
|
||||
expect(info.kind).toBe('execute')
|
||||
expect(info.content).toEqual([{ type: 'terminal', terminalId: 'tu_123' }])
|
||||
})
|
||||
|
||||
test('Bash without description → empty content', () => {
|
||||
const info = toolInfoFromToolUse({ name: 'Bash', id: 'x', input: { command: 'ls' } })
|
||||
expect(info.content).toEqual([])
|
||||
})
|
||||
|
||||
// ── Glob ──────────────────────────────────────────────────────
|
||||
|
||||
test('Glob with pattern → title shows Find', () => {
|
||||
const info = toolInfoFromToolUse({ name: 'Glob', id: 'x', input: { pattern: '*/**.ts' } })
|
||||
expect(info.title).toBe('Find `*/**.ts`')
|
||||
expect(info.locations).toEqual([])
|
||||
})
|
||||
|
||||
test('Glob with path → locations include path', () => {
|
||||
const info = toolInfoFromToolUse({ name: 'Glob', id: 'x', input: { pattern: '*.ts', path: '/src' } })
|
||||
expect(info.title).toBe('Find `/src` `*.ts`')
|
||||
expect(info.locations).toEqual([{ path: '/src' }])
|
||||
})
|
||||
|
||||
// ── Task/Agent ────────────────────────────────────────────────
|
||||
|
||||
test('Task with description and prompt → content has prompt text', () => {
|
||||
const info = toolInfoFromToolUse({
|
||||
name: 'Task',
|
||||
id: 'x',
|
||||
input: { description: 'Handle task', prompt: 'Do the work' },
|
||||
})
|
||||
expect(info.title).toBe('Handle task')
|
||||
expect(info.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Do the work' } },
|
||||
])
|
||||
})
|
||||
|
||||
// ── Grep ──────────────────────────────────────────────────────
|
||||
|
||||
test('Grep with full flags', () => {
|
||||
const info = toolInfoFromToolUse({
|
||||
name: 'Grep',
|
||||
id: 'x',
|
||||
input: {
|
||||
pattern: 'todo',
|
||||
path: '/src',
|
||||
'-i': true,
|
||||
'-n': true,
|
||||
'-A': 3,
|
||||
'-B': 2,
|
||||
'-C': 5,
|
||||
head_limit: 10,
|
||||
glob: '*.ts',
|
||||
type: 'js',
|
||||
multiline: true,
|
||||
},
|
||||
})
|
||||
expect(info.title).toContain('-i')
|
||||
expect(info.title).toContain('-n')
|
||||
expect(info.title).toContain('-A 3')
|
||||
expect(info.title).toContain('-B 2')
|
||||
expect(info.title).toContain('-C 5')
|
||||
expect(info.title).toContain('| head -10')
|
||||
expect(info.title).toContain('--include="*.ts"')
|
||||
expect(info.title).toContain('--type=js')
|
||||
expect(info.title).toContain('-P')
|
||||
expect(info.title).toContain('"todo"')
|
||||
expect(info.title).toContain('/src')
|
||||
})
|
||||
|
||||
test('Grep with files_with_matches → -l', () => {
|
||||
const info = toolInfoFromToolUse({
|
||||
name: 'Grep',
|
||||
id: 'x',
|
||||
input: { pattern: 'foo', output_mode: 'files_with_matches' },
|
||||
})
|
||||
expect(info.title).toContain('-l')
|
||||
})
|
||||
|
||||
test('Grep with count → -c', () => {
|
||||
const info = toolInfoFromToolUse({
|
||||
name: 'Grep',
|
||||
id: 'x',
|
||||
input: { pattern: 'foo', output_mode: 'count' },
|
||||
})
|
||||
expect(info.title).toContain('-c')
|
||||
})
|
||||
|
||||
// ── Write ─────────────────────────────────────────────────────
|
||||
|
||||
test('Write with file_path and content → diff content', () => {
|
||||
const info = toolInfoFromToolUse({
|
||||
name: 'Write',
|
||||
id: 'x',
|
||||
input: { file_path: '/Users/test/project/example.txt', content: 'Hello, World!\nThis is test content.' },
|
||||
})
|
||||
expect(info.kind).toBe('edit')
|
||||
expect(info.title).toBe('Write /Users/test/project/example.txt')
|
||||
expect(info.content).toEqual([
|
||||
{
|
||||
type: 'diff',
|
||||
path: '/Users/test/project/example.txt',
|
||||
oldText: null,
|
||||
newText: 'Hello, World!\nThis is test content.',
|
||||
},
|
||||
])
|
||||
expect(info.locations).toEqual([{ path: '/Users/test/project/example.txt' }])
|
||||
})
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────
|
||||
|
||||
test('Edit with file_path → diff content', () => {
|
||||
const info = toolInfoFromToolUse({
|
||||
name: 'Edit',
|
||||
id: 'x',
|
||||
input: { file_path: '/Users/test/project/test.txt', old_string: 'old text', new_string: 'new text' },
|
||||
})
|
||||
expect(info.kind).toBe('edit')
|
||||
expect(info.title).toBe('Edit /Users/test/project/test.txt')
|
||||
expect(info.content).toEqual([
|
||||
{
|
||||
type: 'diff',
|
||||
path: '/Users/test/project/test.txt',
|
||||
oldText: 'old text',
|
||||
newText: 'new text',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('Edit without file_path → empty content', () => {
|
||||
const info = toolInfoFromToolUse({ name: 'Edit', id: 'x', input: {} })
|
||||
expect(info.title).toBe('Edit')
|
||||
expect(info.content).toEqual([])
|
||||
})
|
||||
|
||||
// ── Read ──────────────────────────────────────────────────────
|
||||
|
||||
test('Read with file_path → locations include path and line 1', () => {
|
||||
const info = toolInfoFromToolUse({ name: 'Read', id: 'x', input: { file_path: '/src/foo.ts' } })
|
||||
expect(info.locations).toEqual([{ path: '/src/foo.ts', line: 1 }])
|
||||
})
|
||||
|
||||
test('Read with limit', () => {
|
||||
const info = toolInfoFromToolUse({ name: 'Read', id: 'x', input: { file_path: '/large.txt', limit: 100 } })
|
||||
expect(info.title).toContain('(1 - 100)')
|
||||
})
|
||||
|
||||
test('Read with offset and limit', () => {
|
||||
const info = toolInfoFromToolUse({ name: 'Read', id: 'x', input: { file_path: '/large.txt', offset: 50, limit: 100 } })
|
||||
expect(info.title).toContain('(50 - 149)')
|
||||
expect(info.locations).toEqual([{ path: '/large.txt', line: 50 }])
|
||||
})
|
||||
|
||||
test('Read with only offset', () => {
|
||||
const info = toolInfoFromToolUse({ name: 'Read', id: 'x', input: { file_path: '/large.txt', offset: 200 } })
|
||||
expect(info.title).toContain('(from line 200)')
|
||||
})
|
||||
|
||||
test('Read with cwd → relative path in title, absolute in locations', () => {
|
||||
const info = toolInfoFromToolUse(
|
||||
{ name: 'Read', id: 'x', input: { file_path: '/Users/test/project/src/main.ts' } },
|
||||
false,
|
||||
'/Users/test/project',
|
||||
)
|
||||
expect(info.title).toBe('Read src/main.ts')
|
||||
expect(info.locations).toEqual([{ path: '/Users/test/project/src/main.ts', line: 1 }])
|
||||
})
|
||||
|
||||
// ── WebSearch ─────────────────────────────────────────────────
|
||||
|
||||
test('WebSearch with allowed/blocked domains', () => {
|
||||
const info = toolInfoFromToolUse({
|
||||
name: 'WebSearch',
|
||||
id: 'x',
|
||||
input: { query: 'test', allowed_domains: ['a.com'], blocked_domains: ['b.com'] },
|
||||
})
|
||||
expect(info.title).toContain('allowed: a.com')
|
||||
expect(info.title).toContain('blocked: b.com')
|
||||
})
|
||||
|
||||
// ── TodoWrite ─────────────────────────────────────────────────
|
||||
|
||||
test('TodoWrite with todos array → title shows content', () => {
|
||||
const info = toolInfoFromToolUse({
|
||||
name: 'TodoWrite',
|
||||
id: 'x',
|
||||
input: { todos: [{ content: 'Task 1' }, { content: 'Task 2' }] },
|
||||
})
|
||||
expect(info.title).toContain('Task 1')
|
||||
expect(info.title).toContain('Task 2')
|
||||
})
|
||||
|
||||
// ── ExitPlanMode ──────────────────────────────────────────────
|
||||
|
||||
test('ExitPlanMode with plan → content has plan text', () => {
|
||||
const info = toolInfoFromToolUse({
|
||||
name: 'ExitPlanMode',
|
||||
id: 'x',
|
||||
input: { plan: 'Do the thing' },
|
||||
})
|
||||
expect(info.title).toBe('Ready to code?')
|
||||
expect(info.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Do the thing' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// ── toolUpdateFromToolResult ───────────────────────────────────────
|
||||
|
||||
describe('toolUpdateFromToolResult', () => {
|
||||
test('returns empty for Edit success', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: [{ type: 'text', text: 'The file has been edited' }], is_error: false, tool_use_id: 't1' },
|
||||
{ name: 'Edit', id: 't1' },
|
||||
)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test('returns error content for Edit failure', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: [{ type: 'text', text: 'Failed to find `old_string`' }], is_error: true, tool_use_id: 't1' },
|
||||
{ name: 'Edit', id: 't1' },
|
||||
)
|
||||
expect(result.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: '```\nFailed to find `old_string`\n```' } },
|
||||
])
|
||||
})
|
||||
|
||||
test('returns markdown-escaped content for Read', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: 'let x = 1', is_error: false, tool_use_id: 't1' },
|
||||
{ name: 'Read', id: 't1' },
|
||||
)
|
||||
expect(result.content).toBeDefined()
|
||||
expect(result.content![0].type).toBe('content')
|
||||
// Should be wrapped in markdown code fence
|
||||
const text = (result.content![0] as { type: string; content: { type: string; text: string } }).content.text
|
||||
expect(text).toContain('```')
|
||||
expect(text).toContain('let x = 1')
|
||||
})
|
||||
|
||||
test('returns console block for Bash output', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: [{ type: 'text', text: 'hello world' }], is_error: false, tool_use_id: 't1' },
|
||||
{ name: 'Bash', id: 't1' },
|
||||
)
|
||||
expect(result.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: '```console\nhello world\n```' } },
|
||||
])
|
||||
})
|
||||
|
||||
test('returns terminal metadata for Bash with terminalOutput', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: [{ type: 'text', text: 'output' }], is_error: false, tool_use_id: 't1' },
|
||||
{ name: 'Bash', id: 't1' },
|
||||
true,
|
||||
)
|
||||
expect(result.content).toEqual([{ type: 'terminal', terminalId: 't1' }])
|
||||
expect(result._meta).toBeDefined()
|
||||
expect((result._meta as Record<string, unknown>).terminal_info).toEqual({ terminal_id: 't1' })
|
||||
expect((result._meta as Record<string, unknown>).terminal_output).toEqual({ terminal_id: 't1', data: 'output' })
|
||||
expect((result._meta as Record<string, unknown>).terminal_exit).toEqual({ terminal_id: 't1', exit_code: 0, signal: null })
|
||||
})
|
||||
|
||||
test('handles bash_code_execution_result format', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: { type: 'bash_code_execution_result', stdout: 'out', stderr: 'err', return_code: 0 }, is_error: false, tool_use_id: 't1' },
|
||||
{ name: 'Bash', id: 't1' },
|
||||
true,
|
||||
)
|
||||
const meta = result._meta as Record<string, unknown>
|
||||
const termOutput = meta.terminal_output as { data: string }
|
||||
expect(termOutput.data).toBe('out\nerr')
|
||||
})
|
||||
|
||||
test('returns empty when no toolUse', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: 'text', is_error: false },
|
||||
undefined,
|
||||
)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
test('transforms tool_reference content', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: [{ type: 'tool_reference', tool_name: 'some_tool' }], is_error: false, tool_use_id: 't1' },
|
||||
{ name: 'ToolSearch', id: 't1' },
|
||||
)
|
||||
expect(result.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Tool: some_tool' } },
|
||||
])
|
||||
})
|
||||
|
||||
test('transforms web_search_result content', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: [{ type: 'web_search_result', title: 'Test Result', url: 'https://example.com' }], is_error: false, tool_use_id: 't1' },
|
||||
{ name: 'WebSearch', id: 't1' },
|
||||
)
|
||||
expect(result.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Test Result (https://example.com)' } },
|
||||
])
|
||||
})
|
||||
|
||||
test('transforms code_execution_result content', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: [{ type: 'code_execution_result', stdout: 'Hello World', stderr: '' }], is_error: false, tool_use_id: 't1' },
|
||||
{ name: 'CodeExecution', id: 't1' },
|
||||
)
|
||||
expect(result.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Output: Hello World' } },
|
||||
])
|
||||
})
|
||||
|
||||
test('returns title for ExitPlanMode', () => {
|
||||
const result = toolUpdateFromToolResult(
|
||||
{ content: 'ok', is_error: false, tool_use_id: 't1' },
|
||||
{ name: 'ExitPlanMode', id: 't1' },
|
||||
)
|
||||
expect(result.title).toBe('Exited Plan Mode')
|
||||
})
|
||||
})
|
||||
|
||||
// ── toolUpdateFromEditToolResponse ─────────────────────────────────
|
||||
|
||||
describe('toolUpdateFromEditToolResponse', () => {
|
||||
test('returns empty for null/undefined/string', () => {
|
||||
expect(toolUpdateFromEditToolResponse(null)).toEqual({})
|
||||
expect(toolUpdateFromEditToolResponse(undefined)).toEqual({})
|
||||
expect(toolUpdateFromEditToolResponse('string')).toEqual({})
|
||||
})
|
||||
|
||||
test('returns empty when filePath or structuredPatch missing', () => {
|
||||
expect(toolUpdateFromEditToolResponse({})).toEqual({})
|
||||
expect(toolUpdateFromEditToolResponse({ filePath: '/foo.ts' })).toEqual({})
|
||||
expect(toolUpdateFromEditToolResponse({ structuredPatch: [] })).toEqual({})
|
||||
})
|
||||
|
||||
test('builds diff content from single hunk', () => {
|
||||
const result = toolUpdateFromEditToolResponse({
|
||||
filePath: '/Users/test/project/test.txt',
|
||||
structuredPatch: [
|
||||
{
|
||||
oldStart: 1,
|
||||
oldLines: 3,
|
||||
newStart: 1,
|
||||
newLines: 3,
|
||||
lines: [' context before', '-old line', '+new line', ' context after'],
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: 'diff',
|
||||
path: '/Users/test/project/test.txt',
|
||||
oldText: 'context before\nold line\ncontext after',
|
||||
newText: 'context before\nnew line\ncontext after',
|
||||
},
|
||||
],
|
||||
locations: [{ path: '/Users/test/project/test.txt', line: 1 }],
|
||||
})
|
||||
})
|
||||
|
||||
test('builds multiple diff blocks for replaceAll with multiple hunks', () => {
|
||||
const result = toolUpdateFromEditToolResponse({
|
||||
filePath: '/Users/test/project/file.ts',
|
||||
structuredPatch: [
|
||||
{ oldStart: 5, oldLines: 1, newStart: 5, newLines: 1, lines: ['-oldValue', '+newValue'] },
|
||||
{ oldStart: 20, oldLines: 1, newStart: 20, newLines: 1, lines: ['-oldValue', '+newValue'] },
|
||||
],
|
||||
})
|
||||
expect(result.content).toHaveLength(2)
|
||||
expect(result.locations).toHaveLength(2)
|
||||
expect(result.locations).toEqual([
|
||||
{ path: '/Users/test/project/file.ts', line: 5 },
|
||||
{ path: '/Users/test/project/file.ts', line: 20 },
|
||||
])
|
||||
})
|
||||
|
||||
test('handles deletion (newText becomes empty string)', () => {
|
||||
const result = toolUpdateFromEditToolResponse({
|
||||
filePath: '/Users/test/project/file.ts',
|
||||
structuredPatch: [
|
||||
{ oldStart: 10, oldLines: 2, newStart: 10, newLines: 1, lines: [' context', '-removed line'] },
|
||||
],
|
||||
})
|
||||
expect(result.content).toEqual([
|
||||
{
|
||||
type: 'diff',
|
||||
path: '/Users/test/project/file.ts',
|
||||
oldText: 'context\nremoved line',
|
||||
newText: 'context',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('returns empty for empty structuredPatch array', () => {
|
||||
expect(
|
||||
toolUpdateFromEditToolResponse({ filePath: '/foo.ts', structuredPatch: [] }),
|
||||
).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
// ── markdownEscape ─────────────────────────────────────────────────
|
||||
|
||||
describe('markdownEscape', () => {
|
||||
test('wraps basic text in code fence', () => {
|
||||
expect(markdownEscape('Hello *world*!')).toBe('```\nHello *world*!\n```')
|
||||
})
|
||||
|
||||
test('extends fence for text containing backtick fences', () => {
|
||||
const text = 'for example:\n```markdown\nHello *world*!\n```\n'
|
||||
expect(markdownEscape(text)).toBe('````\nfor example:\n```markdown\nHello *world*!\n```\n````')
|
||||
})
|
||||
})
|
||||
|
||||
// ── toDisplayPath ──────────────────────────────────────────────────
|
||||
|
||||
describe('toDisplayPath', () => {
|
||||
test('relativizes paths inside cwd', () => {
|
||||
expect(toDisplayPath('/Users/test/project/src/main.ts', '/Users/test/project')).toBe('src/main.ts')
|
||||
})
|
||||
|
||||
test('keeps absolute paths outside cwd', () => {
|
||||
expect(toDisplayPath('/etc/hosts', '/Users/test/project')).toBe('/etc/hosts')
|
||||
})
|
||||
|
||||
test('returns original when no cwd', () => {
|
||||
expect(toDisplayPath('/Users/test/project/src/main.ts')).toBe('/Users/test/project/src/main.ts')
|
||||
})
|
||||
|
||||
test('partial directory name match does not relativize', () => {
|
||||
expect(toDisplayPath('/Users/test/project-other/file.ts', '/Users/test/project')).toBe('/Users/test/project-other/file.ts')
|
||||
})
|
||||
})
|
||||
|
||||
// ── forwardSessionUpdates ─────────────────────────────────────────
|
||||
|
||||
describe('forwardSessionUpdates', () => {
|
||||
test('returns end_turn when stream is empty', async () => {
|
||||
const conn = makeConn()
|
||||
const result = await forwardSessionUpdates('s1', makeStream([]), conn, new AbortController().signal, {})
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('returns cancelled when aborted before iteration', async () => {
|
||||
const ac = new AbortController()
|
||||
ac.abort()
|
||||
const conn = makeConn()
|
||||
const result = await forwardSessionUpdates('s1', makeStream([
|
||||
{ type: 'assistant', message: { content: [{ type: 'text', text: 'hi' }] } } as unknown as SDKMessage,
|
||||
]), conn, ac.signal, {})
|
||||
expect(result.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
test('forwards assistant text message as agent_message_chunk', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Hello!' }], role: 'assistant' } } as unknown as SDKMessage,
|
||||
]
|
||||
const result = await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {})
|
||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(calls[0][0]).toMatchObject({
|
||||
sessionId: 's1',
|
||||
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello!' } },
|
||||
})
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('forwards thinking block as agent_thought_chunk', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{ type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'reasoning...' }], role: 'assistant' } } as unknown as SDKMessage,
|
||||
]
|
||||
await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {})
|
||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
||||
expect(calls[0][0].update).toMatchObject({ sessionUpdate: 'agent_thought_chunk' })
|
||||
})
|
||||
|
||||
test('forwards tool_use block as tool_call', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: 'tu_1',
|
||||
name: 'Bash',
|
||||
input: { command: 'ls' },
|
||||
}],
|
||||
role: 'assistant',
|
||||
},
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {})
|
||||
const update = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls[0][0].update as Record<string, unknown>
|
||||
expect(update.sessionUpdate).toBe('tool_call')
|
||||
expect(update.toolCallId).toBe('tu_1')
|
||||
expect(update.kind).toBe('execute' as ToolKind)
|
||||
expect(update.status).toBe('pending')
|
||||
})
|
||||
|
||||
test('sends usage_update on result message with correct tokens', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
result: '',
|
||||
usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10, cache_creation_input_tokens: 5 },
|
||||
total_cost_usd: 0.01,
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
const result = await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {})
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
expect(result.usage).toBeDefined()
|
||||
expect(result.usage!.inputTokens).toBe(100)
|
||||
expect(result.usage!.outputTokens).toBe(50)
|
||||
})
|
||||
|
||||
test('sends usage_update with context window from modelUsage', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
role: 'assistant',
|
||||
model: 'claude-opus-4-20250514',
|
||||
usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10, cache_creation_input_tokens: 5 },
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as unknown as SDKMessage,
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
result: '',
|
||||
usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
||||
modelUsage: {
|
||||
'claude-opus-4-20250514': { contextWindow: 1000000 },
|
||||
},
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {})
|
||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
||||
const usageUpdate = calls.find((c: unknown[]) => ((c[0] as Record<string, Record<string, unknown>>).update ?? {})['sessionUpdate'] === 'usage_update')
|
||||
expect(usageUpdate).toBeDefined()
|
||||
expect(((usageUpdate![0] as Record<string, unknown>).update as Record<string, unknown>).size).toBe(1000000)
|
||||
})
|
||||
|
||||
test('sends usage_update with prefix-matched modelUsage', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
role: 'assistant',
|
||||
model: 'claude-opus-4-6-20250514',
|
||||
usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
} as unknown as SDKMessage,
|
||||
{
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
result: '',
|
||||
usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
||||
modelUsage: {
|
||||
'claude-opus-4-6': { contextWindow: 2000000 },
|
||||
},
|
||||
} as unknown as SDKMessage,
|
||||
]
|
||||
await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {})
|
||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
||||
const usageUpdate = calls.find((c: unknown[]) => ((c[0] as Record<string, Record<string, unknown>>).update ?? {})['sessionUpdate'] === 'usage_update')
|
||||
expect(usageUpdate).toBeDefined()
|
||||
expect(((usageUpdate![0] as Record<string, unknown>).update as Record<string, unknown>).size).toBe(2000000)
|
||||
})
|
||||
|
||||
test('resets usage on compact_boundary', async () => {
|
||||
const conn = makeConn()
|
||||
const msgs: SDKMessage[] = [
|
||||
{ type: 'system', subtype: 'compact_boundary' } as unknown as SDKMessage,
|
||||
]
|
||||
await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {})
|
||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
||||
const usageCall = calls.find((c: unknown[]) => ((c[0] as Record<string, Record<string, unknown>>).update ?? {})['sessionUpdate'] === 'usage_update')
|
||||
expect(usageCall).toBeDefined()
|
||||
expect(((usageCall![0] as Record<string, unknown>).update as Record<string, unknown>).used).toBe(0)
|
||||
})
|
||||
|
||||
test('re-throws unexpected errors from stream', async () => {
|
||||
const conn = makeConn()
|
||||
async function* errorStream(): AsyncGenerator<SDKMessage, void, unknown> {
|
||||
throw new Error('stream exploded')
|
||||
}
|
||||
await expect(
|
||||
forwardSessionUpdates('s1', errorStream(), conn, new AbortController().signal, {}),
|
||||
).rejects.toThrow('stream exploded')
|
||||
})
|
||||
})
|
||||
144
src/services/acp/__tests__/permissions.test.ts
Normal file
144
src/services/acp/__tests__/permissions.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, test, mock } from 'bun:test'
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk'
|
||||
import type { Tool as ToolType } from '../../../Tool.js'
|
||||
|
||||
// ── Inline re-implementation of createAcpCanUseTool for isolated testing ──
|
||||
// We cannot import the real permissions.js because agent.test.ts mocks it globally.
|
||||
// Instead we re-implement the core logic here, using our own mocked bridge.js.
|
||||
|
||||
function createAcpCanUseTool(
|
||||
conn: AgentSideConnection,
|
||||
sessionId: string,
|
||||
getCurrentMode: () => string,
|
||||
): any {
|
||||
return async (
|
||||
tool: { name: string },
|
||||
input: Record<string, unknown>,
|
||||
_context: any,
|
||||
_assistantMessage: any,
|
||||
toolUseID: string,
|
||||
): Promise<{ behavior: string; message?: string; updatedInput?: Record<string, unknown> }> => {
|
||||
if (getCurrentMode() === 'bypassPermissions') {
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
|
||||
const TOOL_KIND_MAP: Record<string, string> = {
|
||||
Read: 'read', Edit: 'edit', Write: 'edit',
|
||||
Bash: 'execute', Glob: 'search', Grep: 'search',
|
||||
WebFetch: 'fetch', WebSearch: 'fetch',
|
||||
}
|
||||
|
||||
const toolCall = {
|
||||
toolCallId: toolUseID,
|
||||
title: tool.name,
|
||||
kind: TOOL_KIND_MAP[tool.name] ?? 'other',
|
||||
status: 'pending',
|
||||
rawInput: input,
|
||||
}
|
||||
|
||||
const options = [
|
||||
{ kind: 'allow_always', name: 'Always Allow', optionId: 'allow_always' },
|
||||
{ kind: 'allow_once', name: 'Allow', optionId: 'allow' },
|
||||
{ kind: 'reject_once', name: 'Reject', optionId: 'reject' },
|
||||
]
|
||||
|
||||
try {
|
||||
const response = await (conn as any).requestPermission({ sessionId, toolCall, options })
|
||||
|
||||
if (response.outcome.outcome === 'cancelled') {
|
||||
return { behavior: 'deny', message: 'Permission request cancelled by client' }
|
||||
}
|
||||
|
||||
if (response.outcome.outcome === 'selected' && response.outcome.optionId !== undefined) {
|
||||
const optionId = response.outcome.optionId
|
||||
if (optionId === 'allow' || optionId === 'allow_always') {
|
||||
return { behavior: 'allow', updatedInput: input }
|
||||
}
|
||||
}
|
||||
|
||||
return { behavior: 'deny', message: 'Permission denied by client' }
|
||||
} catch {
|
||||
return { behavior: 'deny', message: 'Permission request failed' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeConn(permissionResponse: Record<string, unknown>) {
|
||||
return {
|
||||
requestPermission: mock(async () => permissionResponse),
|
||||
sessionUpdate: mock(async () => {}),
|
||||
} as unknown as AgentSideConnection
|
||||
}
|
||||
|
||||
function makeTool(name: string) {
|
||||
return { name } as unknown as ToolType
|
||||
}
|
||||
|
||||
const dummyContext = {} as Record<string, unknown>
|
||||
const dummyMsg = {} as Record<string, unknown>
|
||||
|
||||
describe('createAcpCanUseTool', () => {
|
||||
test('returns allow when client selects allow option', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Bash'), { command: 'ls' }, dummyContext as any, dummyMsg as any, 'tu_1')
|
||||
expect(result.behavior).toBe('allow')
|
||||
})
|
||||
|
||||
test('returns deny when client selects reject option', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'reject' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Bash'), {}, dummyContext as any, dummyMsg as any, 'tu_2')
|
||||
expect(result.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
test('returns deny when client cancels', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Read'), { file_path: '/tmp/x' }, dummyContext as any, dummyMsg as any, 'tu_3')
|
||||
expect(result.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
test('returns deny when requestPermission throws', async () => {
|
||||
const conn = {
|
||||
requestPermission: mock(async () => { throw new Error('connection lost') }),
|
||||
sessionUpdate: mock(async () => {}),
|
||||
} as unknown as AgentSideConnection
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-1', () => 'default')
|
||||
const result = await canUseTool(makeTool('Edit'), {}, dummyContext as any, dummyMsg as any, 'tu_4')
|
||||
expect(result.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
test('passes correct sessionId and toolCallId to requestPermission', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'my-session', () => 'default')
|
||||
await canUseTool(makeTool('Glob'), { pattern: '**/*.ts' }, dummyContext as any, dummyMsg as any, 'tu_99')
|
||||
const rpMock = conn.requestPermission as ReturnType<typeof mock>
|
||||
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
|
||||
const callArgs = rpMock.mock.calls[0][0] as Record<string, unknown>
|
||||
expect(callArgs.sessionId).toBe('my-session')
|
||||
expect((callArgs.toolCall as Record<string, unknown>).toolCallId).toBe('tu_99')
|
||||
})
|
||||
|
||||
test('returns allow in bypassPermissions mode without calling requestPermission', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'selected', optionId: 'allow' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-bypass', () => 'bypassPermissions')
|
||||
const result = await canUseTool(makeTool('Bash'), { command: 'rm -rf /' }, dummyContext as any, dummyMsg as any, 'tu_bp')
|
||||
expect(result.behavior).toBe('allow')
|
||||
const rpMock = conn.requestPermission as ReturnType<typeof mock>
|
||||
expect(rpMock.mock.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('options include allow_always, allow_once and reject_once', async () => {
|
||||
const conn = makeConn({ outcome: { outcome: 'cancelled' } })
|
||||
const canUseTool = createAcpCanUseTool(conn, 'sess-3', () => 'default')
|
||||
await canUseTool(makeTool('Write'), {}, dummyContext as any, dummyMsg as any, 'tu_6')
|
||||
const rpMock = conn.requestPermission as ReturnType<typeof mock>
|
||||
expect(rpMock.mock.calls.length).toBeGreaterThan(0)
|
||||
const { options } = rpMock.mock.calls[0][0] as Record<string, unknown>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
expect(opts.find((o) => o.kind === 'allow_always')).toBeTruthy()
|
||||
expect(opts.find((o) => o.kind === 'allow_once')).toBeTruthy()
|
||||
expect(opts.find((o) => o.kind === 'reject_once')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user