From 7881cc617ccdd34950f5cd506d0ad0ff2a12f1af Mon Sep 17 00:00:00 2001 From: unraid Date: Wed, 22 Apr 2026 22:38:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20ACP=20=E6=A1=A5?= =?UTF-8?q?=E6=8E=A5=E4=B8=8E=E6=9D=83=E9=99=90=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增强 ACP agent 测试覆盖 - 扩展 ACP bridge 测试用例 - 改进 ACP utils 权限管道 Co-Authored-By: Claude Opus 4.6 --- src/services/acp/__tests__/agent.test.ts | 336 +++++++++------ src/services/acp/__tests__/bridge.test.ts | 480 ++++++++++++++++++---- src/services/acp/utils.ts | 25 +- 3 files changed, 618 insertions(+), 223 deletions(-) diff --git a/src/services/acp/__tests__/agent.test.ts b/src/services/acp/__tests__/agent.test.ts index 78baf8199..194977036 100644 --- a/src/services/acp/__tests__/agent.test.ts +++ b/src/services/acp/__tests__/agent.test.ts @@ -1,9 +1,33 @@ -import { describe, expect, test, mock, beforeEach } from 'bun:test' +import { + describe, + expect, + test, + mock, + beforeEach, + afterAll, + spyOn, +} from 'bun:test' -// ── Heavy module mocks (must be before any import of the module under 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 +// so afterAll can restore them, preventing cross-file pollution. + +const _restores: (() => void)[] = [] + +function safeMockModule(tsPath: string, overrides: Record) { + const jsPath = tsPath.replace(/\.ts$/, '.js') + const real = require(tsPath) + const snapshot = { ...real } + mock.module(jsPath, () => ({ ...snapshot, ...overrides })) + _restores.push(() => mock.module(jsPath, () => snapshot)) +} + +// ── Module mocks (must precede any import of the module under test) ── const mockSetModel = mock(() => {}) +// Fully synthetic — no real module to snapshot, so plain mock.module suffices. mock.module('../../../QueryEngine.js', () => ({ QueryEngine: class MockQueryEngine { submitMessage = mock(async function* () {}) @@ -14,26 +38,25 @@ mock.module('../../../QueryEngine.js', () => ({ }, })) -mock.module('../../../tools.js', () => ({ +safeMockModule('../../../tools.ts', { getTools: mock(() => []), -})) +}) -mock.module('../../../Tool.js', () => ({ - getEmptyToolPermissionContext: mock(() => ({})), +safeMockModule('../../../Tool.ts', { toolMatchesName: mock(() => false), findToolByName: mock(() => undefined), filterToolProgressMessages: mock(() => []), buildTool: mock((def: any) => def), -})) +}) -mock.module('src/utils/config.ts', () => ({ +safeMockModule('../../../utils/config.ts', { enableConfigs: mock(() => {}), -})) +}) -mock.module('../../../bootstrap/state.js', () => ({ +safeMockModule('../../../bootstrap/state.ts', { setOriginalCwd: mock(() => {}), addSlowOperation: mock(() => {}), -})) +}) const mockGetDefaultAppState = mock(() => ({ toolPermissionContext: { @@ -52,63 +75,66 @@ const mockGetDefaultAppState = mock(() => ({ mainLoopModelForSession: null, })) -mock.module('../../../state/AppStateStore.js', () => ({ +safeMockModule('../../../state/AppStateStore.ts', { getDefaultAppState: mockGetDefaultAppState, -})) - -mock.module('../../../utils/fileStateCache.js', () => ({ - FileStateCache: class MockFileStateCache { - constructor() {} - }, -})) +}) +// Single export, fully synthetic — no real module to snapshot. mock.module('../permissions.js', () => ({ - createAcpCanUseTool: mock(() => mock(async () => ({ behavior: 'allow', updatedInput: {} }))), + 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', () => ({ +safeMockModule('../utils.ts', { resolvePermissionMode: mock(() => 'default'), computeSessionFingerprint: mock(() => '{}'), sanitizeTitle: mock((s: string) => s), -})) +}) -mock.module('../../../utils/listSessionsImpl.js', () => ({ +safeMockModule('../bridge.ts', { + forwardSessionUpdates: mock(async () => ({ + stopReason: 'end_turn' as const, + })), + replayHistoryMessages: mock(async () => {}), + toolInfoFromToolUse: mock(() => ({ + title: 'Test', + kind: 'other', + content: [], + locations: [], + })), +}) + +safeMockModule('../../../utils/listSessionsImpl.ts', { listSessionsImpl: mock(async () => []), -})) +}) const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6') -mock.module('../../../utils/model/model.js', () => ({ +safeMockModule('../../../utils/model/model.ts', { getMainLoopModel: mockGetMainLoopModel, -})) +}) -mock.module('../../../utils/model/modelOptions.ts', () => ({ +safeMockModule('../../../utils/model/modelOptions.ts', { getModelOptions: mock(() => []), -})) +}) const mockApplySafeEnvVars = mock(() => {}) -mock.module('../../../utils/managedEnv.js', () => ({ +safeMockModule('../../../utils/managedEnv.ts', { applySafeConfigEnvironmentVariables: mockApplySafeEnvVars, -})) +}) const mockDeserializeMessages = mock((msgs: unknown[]) => msgs) +safeMockModule('../../../utils/conversationRecovery.ts', { + deserializeMessages: mockDeserializeMessages, +}) + const mockGetLastSessionLog = mock(async () => null) const mockSessionIdExists = mock(() => false) - -mock.module('../../../utils/conversationRecovery.js', () => ({ - deserializeMessages: mockDeserializeMessages, -})) - -mock.module('../../../utils/sessionStorage.js', () => ({ +safeMockModule('../../../utils/sessionStorage.ts', { getLastSessionLog: mockGetLastSessionLog, sessionIdExists: mockSessionIdExists, -})) +}) const mockGetCommands = mock(async () => [ { @@ -135,9 +161,9 @@ const mockGetCommands = mock(async () => [ }, ]) -mock.module('../../../commands.js', () => ({ +safeMockModule('../../../commands.ts', { getCommands: mockGetCommands, -})) +}) // ── Import after mocks ──────────────────────────────────────────── @@ -149,13 +175,18 @@ const { forwardSessionUpdates } = await import('../bridge.js') function makeConn() { return { sessionUpdate: mock(async () => {}), - requestPermission: mock(async () => ({ outcome: { outcome: 'cancelled' } })), + requestPermission: mock(async () => ({ + outcome: { outcome: 'cancelled' }, + })), } as any } // ── Tests ───────────────────────────────────────────────────────── describe('AcpAgent', () => { + afterAll(() => { + for (const restore of _restores) restore() + }) beforeEach(() => { mockSetModel.mockClear() mockGetMainLoopModel.mockClear() @@ -175,7 +206,9 @@ describe('AcpAgent', () => { 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) + expect(res.agentCapabilities?.promptCapabilities?.embeddedContext).toBe( + true, + ) }) test('loadSession capability is true', async () => { @@ -232,7 +265,6 @@ describe('AcpAgent', () => { 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') }) @@ -243,7 +275,6 @@ describe('AcpAgent', () => { }) 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) @@ -253,9 +284,10 @@ describe('AcpAgent', () => { test('stores clientCapabilities from initialize', async () => { const agent = new AcpAgent(makeConn()) - await agent.initialize({ clientCapabilities: { _meta: { terminal_output: true } } } as any) + 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() }) }) @@ -264,7 +296,7 @@ describe('AcpAgent', () => { test('throws when session not found', async () => { const agent = new AcpAgent(makeConn()) await expect( - agent.prompt({ sessionId: 'nonexistent', prompt: [] } as any) + agent.prompt({ sessionId: 'nonexistent', prompt: [] } as any), ).rejects.toThrow('nonexistent') }) @@ -288,7 +320,9 @@ describe('AcpAgent', () => { test('calls forwardSessionUpdates for valid prompt', async () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) - ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ stopReason: 'end_turn' }) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( + { stopReason: 'end_turn' }, + ) const res = await agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }], @@ -299,10 +333,10 @@ describe('AcpAgent', () => { 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).mockResolvedValueOnce({ stopReason: 'end_turn' }) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( + { stopReason: 'end_turn' }, + ) const res = await agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }], @@ -313,26 +347,27 @@ describe('AcpAgent', () => { 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).mockImplementationOnce( - () => new Promise<{ stopReason: string }>((resolve) => { - resolveStream = () => resolve({ stopReason: 'cancelled' }) - }), + ;( + forwardSessionUpdates as ReturnType + ).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).mockResolvedValueOnce({ stopReason: 'end_turn' }) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( + { stopReason: 'end_turn' }, + ) const res2 = await agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'world' }], @@ -343,15 +378,12 @@ describe('AcpAgent', () => { 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).mockImplementationOnce(async () => { + ;( + forwardSessionUpdates as ReturnType + ).mockImplementationOnce(async () => { throw new Error('unexpected') }) - // Suppress console.error noise from catch block - const origError = console.error - console.error = (...args: unknown[]) => { - if (typeof args[0] === 'string' && args[0].includes('[ACP]')) return - origError.apply(console, args) - } + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}) try { const res = await agent.prompt({ sessionId, @@ -359,22 +391,24 @@ describe('AcpAgent', () => { } as any) expect(res.stopReason).toBe('end_turn') } finally { - console.error = origError + errorSpy.mockRestore() } }) test('returns usage from forwardSessionUpdates', async () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) - ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ - stopReason: 'end_turn', - usage: { - inputTokens: 100, - outputTokens: 50, - cachedReadTokens: 10, - cachedWriteTokens: 5, + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( + { + stopReason: 'end_turn', + usage: { + inputTokens: 100, + outputTokens: 50, + cachedReadTokens: 10, + cachedWriteTokens: 5, + }, }, - }) + ) const res = await agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }], @@ -389,14 +423,18 @@ describe('AcpAgent', () => { 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() + 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') + await expect( + agent.unstable_closeSession({ sessionId: 'ghost' } as any), + ).rejects.toThrow('Session not found') }) test('removes session after close', async () => { @@ -412,34 +450,37 @@ describe('AcpAgent', () => { 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) + 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) + 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()') + const applyIdx = entrySource.indexOf( + 'applySafeConfigEnvironmentVariables()', + ) expect(enableIdx).toBeGreaterThan(-1) expect(applyIdx).toBeGreaterThan(-1) expect(enableIdx).toBeLessThan(applyIdx) @@ -450,15 +491,17 @@ describe('AcpAgent', () => { 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).mockResolvedValueOnce({ - stopReason: 'end_turn', - usage: { - inputTokens: 100, - outputTokens: 50, - cachedReadTokens: 10, - cachedWriteTokens: 5, + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( + { + stopReason: 'end_turn', + usage: { + inputTokens: 100, + outputTokens: 50, + cachedReadTokens: 10, + cachedWriteTokens: 5, + }, }, - }) + ) const res = await agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }], @@ -470,9 +513,11 @@ describe('AcpAgent', () => { 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).mockResolvedValueOnce({ - stopReason: 'end_turn', - }) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( + { + stopReason: 'end_turn', + }, + ) const res = await agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }], @@ -485,8 +530,9 @@ describe('AcpAgent', () => { 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).mockImplementationOnce(async () => { - // Simulate cancel happening during forward + ;( + forwardSessionUpdates as ReturnType + ).mockImplementationOnce(async () => { const session = agent.sessions.get(sessionId) if (session) session.cancelled = true return { stopReason: 'end_turn' } @@ -501,7 +547,9 @@ describe('AcpAgent', () => { 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).mockImplementationOnce(async () => { + ;( + forwardSessionUpdates as ReturnType + ).mockImplementationOnce(async () => { const session = agent.sessions.get(sessionId) if (session) session.cancelled = true throw new Error('unexpected') @@ -523,9 +571,7 @@ describe('AcpAgent', () => { 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() }) @@ -535,13 +581,11 @@ describe('AcpAgent', () => { 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) }) @@ -553,7 +597,9 @@ describe('AcpAgent', () => { cwd: '/tmp', mcpServers: [], } as any) - ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ stopReason: 'end_turn' }) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( + { stopReason: 'end_turn' }, + ) const res = await agent.prompt({ sessionId: sid, prompt: [{ type: 'text', text: 'hello after restore' }], @@ -582,7 +628,9 @@ describe('AcpAgent', () => { cwd: '/tmp', mcpServers: [], } as any) - ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce({ stopReason: 'end_turn' }) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( + { stopReason: 'end_turn' }, + ) const res = await agent.prompt({ sessionId: sid, prompt: [{ type: 'text', text: 'hello after load' }], @@ -639,10 +687,15 @@ describe('AcpAgent', () => { test('can switch to bypassPermissions mode', async () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) - await agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any) + await agent.setSessionMode({ + sessionId, + modeId: 'bypassPermissions', + } as any) const session = agent.sessions.get(sessionId) expect(session?.modes.currentModeId).toBe('bypassPermissions') - expect(session?.appState.toolPermissionContext.mode).toBe('bypassPermissions') + expect(session?.appState.toolPermissionContext.mode).toBe( + 'bypassPermissions', + ) }) }) @@ -677,20 +730,28 @@ describe('AcpAgent', () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) - // First prompt hangs let resolveFirst!: () => void - ;(forwardSessionUpdates as ReturnType).mockImplementationOnce( - () => new Promise<{ stopReason: string }>((resolve) => { - resolveFirst = () => resolve({ stopReason: 'end_turn' }) - }), + ;( + forwardSessionUpdates as ReturnType + ).mockImplementationOnce( + () => + new Promise<{ stopReason: string }>(resolve => { + resolveFirst = () => resolve({ stopReason: 'end_turn' }) + }), + ) + ;(forwardSessionUpdates as ReturnType).mockResolvedValueOnce( + { stopReason: 'end_turn' }, ) - // Second prompt resolves normally - ;(forwardSessionUpdates as ReturnType).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) + 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') @@ -701,18 +762,25 @@ describe('AcpAgent', () => { const agent = new AcpAgent(makeConn()) const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any) - // First prompt hangs let resolveFirst!: () => void - ;(forwardSessionUpdates as ReturnType).mockImplementationOnce( - () => new Promise<{ stopReason: string }>((resolve) => { - resolveFirst = () => resolve({ stopReason: 'end_turn' }) - }), + ;( + forwardSessionUpdates as ReturnType + ).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) + 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]) @@ -727,7 +795,6 @@ describe('AcpAgent', () => { 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).mock.calls @@ -738,11 +805,10 @@ describe('AcpAgent', () => { 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 + expect(names).not.toContain('compact') + expect(names).not.toContain('hidden-skill') }) test('maps argumentHint to input.hint', async () => { diff --git a/src/services/acp/__tests__/bridge.test.ts b/src/services/acp/__tests__/bridge.test.ts index 5e885d95d..c8d3d319b 100644 --- a/src/services/acp/__tests__/bridge.test.ts +++ b/src/services/acp/__tests__/bridge.test.ts @@ -11,15 +11,21 @@ import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.js' // ── Helpers ──────────────────────────────────────────────────────── -function makeConn(overrides: Partial = {}): AgentSideConnection { +function makeConn( + overrides: Partial = {}, +): AgentSideConnection { return { sessionUpdate: mock(async () => {}), - requestPermission: mock(async () => ({ outcome: { outcome: 'cancelled' } }) as any), + requestPermission: mock( + async () => ({ outcome: { outcome: 'cancelled' } }) as any, + ), ...overrides, } as unknown as AgentSideConnection } -async function* makeStream(msgs: SDKMessage[]): AsyncGenerator { +async function* makeStream( + msgs: SDKMessage[], +): AsyncGenerator { for (const m of msgs) yield m } @@ -49,14 +55,22 @@ describe('toolInfoFromToolUse', () => { } 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) + 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' } }) + 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' } }, @@ -73,20 +87,32 @@ describe('toolInfoFromToolUse', () => { }) test('Bash without description → empty content', () => { - const info = toolInfoFromToolUse({ name: 'Bash', id: 'x', input: { command: 'ls' } }) + 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' } }) + 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' } }) + 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' }]) }) @@ -162,7 +188,10 @@ describe('toolInfoFromToolUse', () => { const info = toolInfoFromToolUse({ name: 'Write', id: 'x', - input: { file_path: '/Users/test/project/example.txt', content: 'Hello, World!\nThis is test content.' }, + 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') @@ -174,7 +203,9 @@ describe('toolInfoFromToolUse', () => { newText: 'Hello, World!\nThis is test content.', }, ]) - expect(info.locations).toEqual([{ path: '/Users/test/project/example.txt' }]) + expect(info.locations).toEqual([ + { path: '/Users/test/project/example.txt' }, + ]) }) // ── Edit ────────────────────────────────────────────────────── @@ -183,7 +214,11 @@ describe('toolInfoFromToolUse', () => { const info = toolInfoFromToolUse({ name: 'Edit', id: 'x', - input: { file_path: '/Users/test/project/test.txt', old_string: 'old text', new_string: 'new text' }, + 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') @@ -206,34 +241,56 @@ describe('toolInfoFromToolUse', () => { // ── 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' } }) + 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 } }) + 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 } }) + 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 } }) + 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' } }, + { + 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 }]) + expect(info.locations).toEqual([ + { path: '/Users/test/project/src/main.ts', line: 1 }, + ]) }) // ── WebSearch ───────────────────────────────────────────────── @@ -242,7 +299,11 @@ describe('toolInfoFromToolUse', () => { const info = toolInfoFromToolUse({ name: 'WebSearch', id: 'x', - input: { query: 'test', allowed_domains: ['a.com'], blocked_domains: ['b.com'] }, + 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') @@ -280,7 +341,11 @@ describe('toolInfoFromToolUse', () => { 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' }, + { + content: [{ type: 'text', text: 'The file has been edited' }], + is_error: false, + tool_use_id: 't1', + }, { name: 'Edit', id: 't1' }, ) expect(result).toEqual({}) @@ -288,11 +353,21 @@ describe('toolUpdateFromToolResult', () => { 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' }, + { + 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```' } }, + { + type: 'content', + content: { + type: 'text', + text: '```\nFailed to find `old_string`\n```', + }, + }, ]) }) @@ -304,37 +379,71 @@ describe('toolUpdateFromToolResult', () => { 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 + 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' }, + { + 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```' } }, + { + 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' }, + { + 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).terminal_info).toEqual({ terminal_id: 't1' }) - expect((result._meta as Record).terminal_output).toEqual({ terminal_id: 't1', data: 'output' }) - expect((result._meta as Record).terminal_exit).toEqual({ terminal_id: 't1', exit_code: 0, signal: null }) + expect((result._meta as Record).terminal_info).toEqual({ + terminal_id: 't1', + }) + expect((result._meta as Record).terminal_output).toEqual({ + terminal_id: 't1', + data: 'output', + }) + expect((result._meta as Record).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' }, + { + 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, ) @@ -353,7 +462,11 @@ describe('toolUpdateFromToolResult', () => { test('transforms tool_reference content', () => { const result = toolUpdateFromToolResult( - { content: [{ type: 'tool_reference', tool_name: 'some_tool' }], is_error: false, tool_use_id: 't1' }, + { + content: [{ type: 'tool_reference', tool_name: 'some_tool' }], + is_error: false, + tool_use_id: 't1', + }, { name: 'ToolSearch', id: 't1' }, ) expect(result.content).toEqual([ @@ -363,21 +476,43 @@ describe('toolUpdateFromToolResult', () => { 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' }, + { + 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)' } }, + { + 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' }, + { + 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' } }, + { + type: 'content', + content: { type: 'text', text: 'Output: Hello World' }, + }, ]) }) @@ -414,7 +549,12 @@ describe('toolUpdateFromEditToolResponse', () => { oldLines: 3, newStart: 1, newLines: 3, - lines: [' context before', '-old line', '+new line', ' context after'], + lines: [ + ' context before', + '-old line', + '+new line', + ' context after', + ], }, ], }) @@ -435,8 +575,20 @@ describe('toolUpdateFromEditToolResponse', () => { 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'] }, + { + 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) @@ -451,7 +603,13 @@ describe('toolUpdateFromEditToolResponse', () => { const result = toolUpdateFromEditToolResponse({ filePath: '/Users/test/project/file.ts', structuredPatch: [ - { oldStart: 10, oldLines: 2, newStart: 10, newLines: 1, lines: [' context', '-removed line'] }, + { + oldStart: 10, + oldLines: 2, + newStart: 10, + newLines: 1, + lines: [' context', '-removed line'], + }, ], }) expect(result.content).toEqual([ @@ -466,7 +624,10 @@ describe('toolUpdateFromEditToolResponse', () => { test('returns empty for empty structuredPatch array', () => { expect( - toolUpdateFromEditToolResponse({ filePath: '/foo.ts', structuredPatch: [] }), + toolUpdateFromEditToolResponse({ + filePath: '/foo.ts', + structuredPatch: [], + }), ).toEqual({}) }) }) @@ -480,7 +641,9 @@ describe('markdownEscape', () => { 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````') + expect(markdownEscape(text)).toBe( + '````\nfor example:\n```markdown\nHello *world*!\n```\n````', + ) }) }) @@ -488,19 +651,27 @@ describe('markdownEscape', () => { describe('toDisplayPath', () => { test('relativizes paths inside cwd', () => { - expect(toDisplayPath('/Users/test/project/src/main.ts', '/Users/test/project')).toBe('src/main.ts') + 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') + 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') + 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') + expect( + toDisplayPath('/Users/test/project-other/file.ts', '/Users/test/project'), + ).toBe('/Users/test/project-other/file.ts') }) }) @@ -509,7 +680,13 @@ describe('toDisplayPath', () => { describe('forwardSessionUpdates', () => { test('returns end_turn when stream is empty', async () => { const conn = makeConn() - const result = await forwardSessionUpdates('s1', makeStream([]), conn, new AbortController().signal, {}) + const result = await forwardSessionUpdates( + 's1', + makeStream([]), + conn, + new AbortController().signal, + {}, + ) expect(result.stopReason).toBe('end_turn') }) @@ -517,23 +694,47 @@ describe('forwardSessionUpdates', () => { 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, {}) + 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, + { + 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 result = await forwardSessionUpdates( + 's1', + makeStream(msgs), + conn, + new AbortController().signal, + {}, + ) const calls = (conn.sessionUpdate as ReturnType).mock.calls expect(calls.length).toBeGreaterThanOrEqual(1) expect(calls[0][0]).toMatchObject({ sessionId: 's1', - update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Hello!' } }, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Hello!' }, + }, }) expect(result.stopReason).toBe('end_turn') }) @@ -541,11 +742,25 @@ describe('forwardSessionUpdates', () => { 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, + { + type: 'assistant', + message: { + content: [{ type: 'thinking', thinking: 'reasoning...' }], + role: 'assistant', + }, + } as unknown as SDKMessage, ] - await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {}) + await forwardSessionUpdates( + 's1', + makeStream(msgs), + conn, + new AbortController().signal, + {}, + ) const calls = (conn.sessionUpdate as ReturnType).mock.calls - expect(calls[0][0].update).toMatchObject({ sessionUpdate: 'agent_thought_chunk' }) + expect(calls[0][0].update).toMatchObject({ + sessionUpdate: 'agent_thought_chunk', + }) }) test('forwards tool_use block as tool_call', async () => { @@ -554,18 +769,27 @@ describe('forwardSessionUpdates', () => { { type: 'assistant', message: { - content: [{ - type: 'tool_use', - id: 'tu_1', - name: 'Bash', - input: { command: 'ls' }, - }], + 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).mock.calls[0][0].update as Record + await forwardSessionUpdates( + 's1', + makeStream(msgs), + conn, + new AbortController().signal, + {}, + ) + const update = (conn.sessionUpdate as ReturnType).mock + .calls[0][0].update as Record expect(update.sessionUpdate).toBe('tool_call') expect(update.toolCallId).toBe('tu_1') expect(update.kind).toBe('execute' as ToolKind) @@ -580,11 +804,22 @@ describe('forwardSessionUpdates', () => { subtype: 'success', is_error: false, result: '', - usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10, cache_creation_input_tokens: 5 }, + 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, {}) + 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) @@ -600,7 +835,12 @@ describe('forwardSessionUpdates', () => { 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 }, + 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, @@ -609,17 +849,40 @@ describe('forwardSessionUpdates', () => { subtype: 'success', is_error: false, result: '', - usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + 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, {}) + await forwardSessionUpdates( + 's1', + makeStream(msgs), + conn, + new AbortController().signal, + {}, + ) const calls = (conn.sessionUpdate as ReturnType).mock.calls - const usageUpdate = calls.find((c: unknown[]) => ((c[0] as Record>).update ?? {})['sessionUpdate'] === 'usage_update') + const usageUpdate = calls.find( + (c: unknown[]) => + ((c[0] as Record>).update ?? {})[ + 'sessionUpdate' + ] === 'usage_update', + ) expect(usageUpdate).toBeDefined() - expect(((usageUpdate![0] as Record).update as Record).size).toBe(1000000) + expect( + ( + (usageUpdate![0] as Record).update as Record< + string, + unknown + > + ).size, + ).toBe(1000000) }) test('sends usage_update with prefix-matched modelUsage', async () => { @@ -631,7 +894,12 @@ describe('forwardSessionUpdates', () => { 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 }, + 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, @@ -640,17 +908,40 @@ describe('forwardSessionUpdates', () => { subtype: 'success', is_error: false, result: '', - usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + 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, {}) + await forwardSessionUpdates( + 's1', + makeStream(msgs), + conn, + new AbortController().signal, + {}, + ) const calls = (conn.sessionUpdate as ReturnType).mock.calls - const usageUpdate = calls.find((c: unknown[]) => ((c[0] as Record>).update ?? {})['sessionUpdate'] === 'usage_update') + const usageUpdate = calls.find( + (c: unknown[]) => + ((c[0] as Record>).update ?? {})[ + 'sessionUpdate' + ] === 'usage_update', + ) expect(usageUpdate).toBeDefined() - expect(((usageUpdate![0] as Record).update as Record).size).toBe(2000000) + expect( + ( + (usageUpdate![0] as Record).update as Record< + string, + unknown + > + ).size, + ).toBe(2000000) }) test('resets usage on compact_boundary', async () => { @@ -658,20 +949,49 @@ describe('forwardSessionUpdates', () => { const msgs: SDKMessage[] = [ { type: 'system', subtype: 'compact_boundary' } as unknown as SDKMessage, ] - await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {}) + await forwardSessionUpdates( + 's1', + makeStream(msgs), + conn, + new AbortController().signal, + {}, + ) const calls = (conn.sessionUpdate as ReturnType).mock.calls - const usageCall = calls.find((c: unknown[]) => ((c[0] as Record>).update ?? {})['sessionUpdate'] === 'usage_update') + const usageCall = calls.find( + (c: unknown[]) => + ((c[0] as Record>).update ?? {})[ + 'sessionUpdate' + ] === 'usage_update', + ) expect(usageCall).toBeDefined() - expect(((usageCall![0] as Record).update as Record).used).toBe(0) + expect( + ( + (usageCall![0] as Record).update as Record< + string, + unknown + > + ).used, + ).toBe(0) }) test('re-throws unexpected errors from stream', async () => { const conn = makeConn() - async function* errorStream(): AsyncGenerator { + async function* errorStream(): AsyncGenerator< + SDKMessage, + undefined, + unknown + > { + yield undefined as unknown as SDKMessage throw new Error('stream exploded') } await expect( - forwardSessionUpdates('s1', errorStream(), conn, new AbortController().signal, {}), + forwardSessionUpdates( + 's1', + errorStream(), + conn, + new AbortController().signal, + {}, + ), ).rejects.toThrow('stream exploded') }) }) diff --git a/src/services/acp/utils.ts b/src/services/acp/utils.ts index c7bbb1e24..f2eab98d4 100644 --- a/src/services/acp/utils.ts +++ b/src/services/acp/utils.ts @@ -41,9 +41,12 @@ export class Pushable implements AsyncIterable { return Promise.resolve({ value, done: false }) } if (this.done) { - return Promise.resolve({ value: undefined as unknown as T, done: true }) + return Promise.resolve({ + value: undefined as unknown as T, + done: true, + }) } - return new Promise>((resolve) => { + return new Promise>(resolve => { this.resolvers.push(resolve) }) }, @@ -53,11 +56,13 @@ export class Pushable implements AsyncIterable { // ── Stream helpers ──────────────────────────────────────────────── -export function nodeToWebWritable(nodeStream: Writable): WritableStream { +export function nodeToWebWritable( + nodeStream: Writable, +): WritableStream { return new WritableStream({ write(chunk) { return new Promise((resolve, reject) => { - nodeStream.write(Buffer.from(chunk), (err) => { + nodeStream.write(Buffer.from(chunk), err => { if (err) reject(err) else resolve() }) @@ -66,14 +71,16 @@ export function nodeToWebWritable(nodeStream: Writable): WritableStream { +export function nodeToWebReadable( + nodeStream: Readable, +): ReadableStream { return new ReadableStream({ start(controller) { nodeStream.on('data', (chunk: Buffer) => { controller.enqueue(new Uint8Array(chunk)) }) nodeStream.on('end', () => controller.close()) - nodeStream.on('error', (err) => controller.error(err)) + nodeStream.on('error', err => controller.error(err)) }, }) } @@ -125,7 +132,9 @@ export function resolvePermissionMode(defaultMode?: unknown): PermissionMode { const normalized = defaultMode.trim().toLowerCase() if (normalized === '') { - throw new Error('Invalid permissions.defaultMode: expected a non-empty string.') + throw new Error( + 'Invalid permissions.defaultMode: expected a non-empty string.', + ) } const mapped = PERMISSION_MODE_ALIASES[normalized] @@ -190,7 +199,7 @@ export function toDisplayPath(filePath: string, cwd?: string): string { resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd ) { - return path.relative(resolvedCwd, resolvedFile) + return path.relative(resolvedCwd, resolvedFile).replaceAll('\\', '/') } return filePath }