mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 08:45:50 +00:00
feat: 增强 ACP 桥接与权限处理
- 增强 ACP agent 测试覆盖 - 扩展 ACP bridge 测试用例 - 改进 ACP utils 权限管道 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown>) {
|
||||||
|
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(() => {})
|
const mockSetModel = mock(() => {})
|
||||||
|
|
||||||
|
// Fully synthetic — no real module to snapshot, so plain mock.module suffices.
|
||||||
mock.module('../../../QueryEngine.js', () => ({
|
mock.module('../../../QueryEngine.js', () => ({
|
||||||
QueryEngine: class MockQueryEngine {
|
QueryEngine: class MockQueryEngine {
|
||||||
submitMessage = mock(async function* () {})
|
submitMessage = mock(async function* () {})
|
||||||
@@ -14,26 +38,25 @@ mock.module('../../../QueryEngine.js', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module('../../../tools.js', () => ({
|
safeMockModule('../../../tools.ts', {
|
||||||
getTools: mock(() => []),
|
getTools: mock(() => []),
|
||||||
}))
|
})
|
||||||
|
|
||||||
mock.module('../../../Tool.js', () => ({
|
safeMockModule('../../../Tool.ts', {
|
||||||
getEmptyToolPermissionContext: mock(() => ({})),
|
|
||||||
toolMatchesName: mock(() => false),
|
toolMatchesName: mock(() => false),
|
||||||
findToolByName: mock(() => undefined),
|
findToolByName: mock(() => undefined),
|
||||||
filterToolProgressMessages: mock(() => []),
|
filterToolProgressMessages: mock(() => []),
|
||||||
buildTool: mock((def: any) => def),
|
buildTool: mock((def: any) => def),
|
||||||
}))
|
})
|
||||||
|
|
||||||
mock.module('src/utils/config.ts', () => ({
|
safeMockModule('../../../utils/config.ts', {
|
||||||
enableConfigs: mock(() => {}),
|
enableConfigs: mock(() => {}),
|
||||||
}))
|
})
|
||||||
|
|
||||||
mock.module('../../../bootstrap/state.js', () => ({
|
safeMockModule('../../../bootstrap/state.ts', {
|
||||||
setOriginalCwd: mock(() => {}),
|
setOriginalCwd: mock(() => {}),
|
||||||
addSlowOperation: mock(() => {}),
|
addSlowOperation: mock(() => {}),
|
||||||
}))
|
})
|
||||||
|
|
||||||
const mockGetDefaultAppState = mock(() => ({
|
const mockGetDefaultAppState = mock(() => ({
|
||||||
toolPermissionContext: {
|
toolPermissionContext: {
|
||||||
@@ -52,63 +75,66 @@ const mockGetDefaultAppState = mock(() => ({
|
|||||||
mainLoopModelForSession: null,
|
mainLoopModelForSession: null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module('../../../state/AppStateStore.js', () => ({
|
safeMockModule('../../../state/AppStateStore.ts', {
|
||||||
getDefaultAppState: mockGetDefaultAppState,
|
getDefaultAppState: mockGetDefaultAppState,
|
||||||
}))
|
})
|
||||||
|
|
||||||
mock.module('../../../utils/fileStateCache.js', () => ({
|
|
||||||
FileStateCache: class MockFileStateCache {
|
|
||||||
constructor() {}
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
// Single export, fully synthetic — no real module to snapshot.
|
||||||
mock.module('../permissions.js', () => ({
|
mock.module('../permissions.js', () => ({
|
||||||
createAcpCanUseTool: mock(() => mock(async () => ({ behavior: 'allow', updatedInput: {} }))),
|
createAcpCanUseTool: mock(() =>
|
||||||
|
mock(async () => ({ behavior: 'allow', updatedInput: {} })),
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock.module('../bridge.js', () => ({
|
safeMockModule('../utils.ts', {
|
||||||
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'),
|
resolvePermissionMode: mock(() => 'default'),
|
||||||
computeSessionFingerprint: mock(() => '{}'),
|
computeSessionFingerprint: mock(() => '{}'),
|
||||||
sanitizeTitle: mock((s: string) => s),
|
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 () => []),
|
listSessionsImpl: mock(async () => []),
|
||||||
}))
|
})
|
||||||
|
|
||||||
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
|
const mockGetMainLoopModel = mock(() => 'claude-sonnet-4-6')
|
||||||
|
|
||||||
mock.module('../../../utils/model/model.js', () => ({
|
safeMockModule('../../../utils/model/model.ts', {
|
||||||
getMainLoopModel: mockGetMainLoopModel,
|
getMainLoopModel: mockGetMainLoopModel,
|
||||||
}))
|
})
|
||||||
|
|
||||||
mock.module('../../../utils/model/modelOptions.ts', () => ({
|
safeMockModule('../../../utils/model/modelOptions.ts', {
|
||||||
getModelOptions: mock(() => []),
|
getModelOptions: mock(() => []),
|
||||||
}))
|
})
|
||||||
|
|
||||||
const mockApplySafeEnvVars = mock(() => {})
|
const mockApplySafeEnvVars = mock(() => {})
|
||||||
mock.module('../../../utils/managedEnv.js', () => ({
|
safeMockModule('../../../utils/managedEnv.ts', {
|
||||||
applySafeConfigEnvironmentVariables: mockApplySafeEnvVars,
|
applySafeConfigEnvironmentVariables: mockApplySafeEnvVars,
|
||||||
}))
|
})
|
||||||
|
|
||||||
const mockDeserializeMessages = mock((msgs: unknown[]) => msgs)
|
const mockDeserializeMessages = mock((msgs: unknown[]) => msgs)
|
||||||
|
safeMockModule('../../../utils/conversationRecovery.ts', {
|
||||||
|
deserializeMessages: mockDeserializeMessages,
|
||||||
|
})
|
||||||
|
|
||||||
const mockGetLastSessionLog = mock(async () => null)
|
const mockGetLastSessionLog = mock(async () => null)
|
||||||
const mockSessionIdExists = mock(() => false)
|
const mockSessionIdExists = mock(() => false)
|
||||||
|
safeMockModule('../../../utils/sessionStorage.ts', {
|
||||||
mock.module('../../../utils/conversationRecovery.js', () => ({
|
|
||||||
deserializeMessages: mockDeserializeMessages,
|
|
||||||
}))
|
|
||||||
|
|
||||||
mock.module('../../../utils/sessionStorage.js', () => ({
|
|
||||||
getLastSessionLog: mockGetLastSessionLog,
|
getLastSessionLog: mockGetLastSessionLog,
|
||||||
sessionIdExists: mockSessionIdExists,
|
sessionIdExists: mockSessionIdExists,
|
||||||
}))
|
})
|
||||||
|
|
||||||
const mockGetCommands = mock(async () => [
|
const mockGetCommands = mock(async () => [
|
||||||
{
|
{
|
||||||
@@ -135,9 +161,9 @@ const mockGetCommands = mock(async () => [
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
mock.module('../../../commands.js', () => ({
|
safeMockModule('../../../commands.ts', {
|
||||||
getCommands: mockGetCommands,
|
getCommands: mockGetCommands,
|
||||||
}))
|
})
|
||||||
|
|
||||||
// ── Import after mocks ────────────────────────────────────────────
|
// ── Import after mocks ────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -149,13 +175,18 @@ const { forwardSessionUpdates } = await import('../bridge.js')
|
|||||||
function makeConn() {
|
function makeConn() {
|
||||||
return {
|
return {
|
||||||
sessionUpdate: mock(async () => {}),
|
sessionUpdate: mock(async () => {}),
|
||||||
requestPermission: mock(async () => ({ outcome: { outcome: 'cancelled' } })),
|
requestPermission: mock(async () => ({
|
||||||
|
outcome: { outcome: 'cancelled' },
|
||||||
|
})),
|
||||||
} as any
|
} as any
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('AcpAgent', () => {
|
describe('AcpAgent', () => {
|
||||||
|
afterAll(() => {
|
||||||
|
for (const restore of _restores) restore()
|
||||||
|
})
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockSetModel.mockClear()
|
mockSetModel.mockClear()
|
||||||
mockGetMainLoopModel.mockClear()
|
mockGetMainLoopModel.mockClear()
|
||||||
@@ -175,7 +206,9 @@ describe('AcpAgent', () => {
|
|||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const res = await agent.initialize({} as any)
|
const res = await agent.initialize({} as any)
|
||||||
expect(res.agentCapabilities?.promptCapabilities?.image).toBe(true)
|
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 () => {
|
test('loadSession capability is true', async () => {
|
||||||
@@ -232,7 +265,6 @@ describe('AcpAgent', () => {
|
|||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
expect(mockGetMainLoopModel).toHaveBeenCalled()
|
expect(mockGetMainLoopModel).toHaveBeenCalled()
|
||||||
// The model reported to ACP client should match what getMainLoopModel returns
|
|
||||||
expect(res.models?.currentModelId).toBe('claude-sonnet-4-6')
|
expect(res.models?.currentModelId).toBe('claude-sonnet-4-6')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -243,7 +275,6 @@ describe('AcpAgent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('respects model alias resolution via getMainLoopModel', async () => {
|
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')
|
mockGetMainLoopModel.mockReturnValueOnce('glm-5.1')
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
@@ -253,9 +284,10 @@ describe('AcpAgent', () => {
|
|||||||
|
|
||||||
test('stores clientCapabilities from initialize', async () => {
|
test('stores clientCapabilities from initialize', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
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)
|
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
// Should not throw — clientCapabilities stored internally
|
|
||||||
expect(res.sessionId).toBeDefined()
|
expect(res.sessionId).toBeDefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -264,7 +296,7 @@ describe('AcpAgent', () => {
|
|||||||
test('throws when session not found', async () => {
|
test('throws when session not found', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
await expect(
|
await expect(
|
||||||
agent.prompt({ sessionId: 'nonexistent', prompt: [] } as any)
|
agent.prompt({ sessionId: 'nonexistent', prompt: [] } as any),
|
||||||
).rejects.toThrow('nonexistent')
|
).rejects.toThrow('nonexistent')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -288,7 +320,9 @@ describe('AcpAgent', () => {
|
|||||||
test('calls forwardSessionUpdates for valid prompt', async () => {
|
test('calls forwardSessionUpdates for valid prompt', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
|
{ stopReason: 'end_turn' },
|
||||||
|
)
|
||||||
const res = await agent.prompt({
|
const res = await agent.prompt({
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
prompt: [{ type: 'text', text: 'hello' }],
|
||||||
@@ -299,10 +333,10 @@ describe('AcpAgent', () => {
|
|||||||
test('cancel before prompt does not block next prompt', async () => {
|
test('cancel before prompt does not block next prompt', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
// Cancel when nothing is running is a no-op
|
|
||||||
await agent.cancel({ sessionId } as any)
|
await agent.cancel({ sessionId } as any)
|
||||||
// The next prompt should work normally
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
{ stopReason: 'end_turn' },
|
||||||
|
)
|
||||||
const res = await agent.prompt({
|
const res = await agent.prompt({
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
prompt: [{ type: 'text', text: 'hello' }],
|
||||||
@@ -313,10 +347,12 @@ describe('AcpAgent', () => {
|
|||||||
test('cancel during prompt returns cancelled', async () => {
|
test('cancel during prompt returns cancelled', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
// Start a prompt that hangs, then cancel it
|
|
||||||
let resolveStream!: () => void
|
let resolveStream!: () => void
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
;(
|
||||||
() => new Promise<{ stopReason: string }>((resolve) => {
|
forwardSessionUpdates as ReturnType<typeof mock>
|
||||||
|
).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<{ stopReason: string }>(resolve => {
|
||||||
resolveStream = () => resolve({ stopReason: 'cancelled' })
|
resolveStream = () => resolve({ stopReason: 'cancelled' })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -324,15 +360,14 @@ describe('AcpAgent', () => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
prompt: [{ type: 'text', text: 'hello' }],
|
||||||
} as any)
|
} as any)
|
||||||
// Cancel the running prompt
|
|
||||||
await agent.cancel({ sessionId } as any)
|
await agent.cancel({ sessionId } as any)
|
||||||
resolveStream()
|
resolveStream()
|
||||||
const res = await promptPromise
|
const res = await promptPromise
|
||||||
// After fix, forwardSessionUpdates mock controls the result
|
|
||||||
expect(res.stopReason).toBe('cancelled')
|
expect(res.stopReason).toBe('cancelled')
|
||||||
|
|
||||||
// Next prompt should work normally
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
{ stopReason: 'end_turn' },
|
||||||
|
)
|
||||||
const res2 = await agent.prompt({
|
const res2 = await agent.prompt({
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'world' }],
|
prompt: [{ type: 'text', text: 'world' }],
|
||||||
@@ -343,15 +378,12 @@ describe('AcpAgent', () => {
|
|||||||
test('returns end_turn on unexpected error', async () => {
|
test('returns end_turn on unexpected error', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(async () => {
|
;(
|
||||||
|
forwardSessionUpdates as ReturnType<typeof mock>
|
||||||
|
).mockImplementationOnce(async () => {
|
||||||
throw new Error('unexpected')
|
throw new Error('unexpected')
|
||||||
})
|
})
|
||||||
// Suppress console.error noise from catch block
|
const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const origError = console.error
|
|
||||||
console.error = (...args: unknown[]) => {
|
|
||||||
if (typeof args[0] === 'string' && args[0].includes('[ACP]')) return
|
|
||||||
origError.apply(console, args)
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const res = await agent.prompt({
|
const res = await agent.prompt({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -359,14 +391,15 @@ describe('AcpAgent', () => {
|
|||||||
} as any)
|
} as any)
|
||||||
expect(res.stopReason).toBe('end_turn')
|
expect(res.stopReason).toBe('end_turn')
|
||||||
} finally {
|
} finally {
|
||||||
console.error = origError
|
errorSpy.mockRestore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns usage from forwardSessionUpdates', async () => {
|
test('returns usage from forwardSessionUpdates', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
|
{
|
||||||
stopReason: 'end_turn',
|
stopReason: 'end_turn',
|
||||||
usage: {
|
usage: {
|
||||||
inputTokens: 100,
|
inputTokens: 100,
|
||||||
@@ -374,7 +407,8 @@ describe('AcpAgent', () => {
|
|||||||
cachedReadTokens: 10,
|
cachedReadTokens: 10,
|
||||||
cachedWriteTokens: 5,
|
cachedWriteTokens: 5,
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
)
|
||||||
const res = await agent.prompt({
|
const res = await agent.prompt({
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
prompt: [{ type: 'text', text: 'hello' }],
|
||||||
@@ -389,14 +423,18 @@ describe('AcpAgent', () => {
|
|||||||
describe('cancel', () => {
|
describe('cancel', () => {
|
||||||
test('does not throw for unknown session', async () => {
|
test('does not throw for unknown session', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
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', () => {
|
describe('closeSession', () => {
|
||||||
test('throws for unknown session', async () => {
|
test('throws for unknown session', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
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 () => {
|
test('removes session after close', async () => {
|
||||||
@@ -412,34 +450,37 @@ describe('AcpAgent', () => {
|
|||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
mockSetModel.mockClear()
|
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')
|
expect(mockSetModel).toHaveBeenCalledWith('glm-5.1')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('passes alias modelId to queryEngine as-is for later resolution', async () => {
|
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 agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
mockSetModel.mockClear()
|
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]')
|
expect(mockSetModel).toHaveBeenCalledWith('sonnet[1m]')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('entry.ts initialization contract', () => {
|
describe('entry.ts initialization contract', () => {
|
||||||
test('entry.ts imports applySafeConfigEnvironmentVariables from managedEnv', async () => {
|
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(
|
const entrySource = await Bun.file(
|
||||||
new URL('../entry.ts', import.meta.url),
|
new URL('../entry.ts', import.meta.url),
|
||||||
).text()
|
).text()
|
||||||
expect(entrySource).toContain('applySafeConfigEnvironmentVariables')
|
expect(entrySource).toContain('applySafeConfigEnvironmentVariables')
|
||||||
expect(entrySource).toContain('enableConfigs')
|
expect(entrySource).toContain('enableConfigs')
|
||||||
|
|
||||||
// Verify applySafe is called after enableConfigs in the source
|
|
||||||
const enableIdx = entrySource.indexOf('enableConfigs()')
|
const enableIdx = entrySource.indexOf('enableConfigs()')
|
||||||
const applyIdx = entrySource.indexOf('applySafeConfigEnvironmentVariables()')
|
const applyIdx = entrySource.indexOf(
|
||||||
|
'applySafeConfigEnvironmentVariables()',
|
||||||
|
)
|
||||||
expect(enableIdx).toBeGreaterThan(-1)
|
expect(enableIdx).toBeGreaterThan(-1)
|
||||||
expect(applyIdx).toBeGreaterThan(-1)
|
expect(applyIdx).toBeGreaterThan(-1)
|
||||||
expect(enableIdx).toBeLessThan(applyIdx)
|
expect(enableIdx).toBeLessThan(applyIdx)
|
||||||
@@ -450,7 +491,8 @@ describe('AcpAgent', () => {
|
|||||||
test('returns totalTokens as sum of all token types', async () => {
|
test('returns totalTokens as sum of all token types', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
|
{
|
||||||
stopReason: 'end_turn',
|
stopReason: 'end_turn',
|
||||||
usage: {
|
usage: {
|
||||||
inputTokens: 100,
|
inputTokens: 100,
|
||||||
@@ -458,7 +500,8 @@ describe('AcpAgent', () => {
|
|||||||
cachedReadTokens: 10,
|
cachedReadTokens: 10,
|
||||||
cachedWriteTokens: 5,
|
cachedWriteTokens: 5,
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
)
|
||||||
const res = await agent.prompt({
|
const res = await agent.prompt({
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
prompt: [{ type: 'text', text: 'hello' }],
|
||||||
@@ -470,9 +513,11 @@ describe('AcpAgent', () => {
|
|||||||
test('returns undefined usage when forwardSessionUpdates returns none', async () => {
|
test('returns undefined usage when forwardSessionUpdates returns none', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
|
{
|
||||||
stopReason: 'end_turn',
|
stopReason: 'end_turn',
|
||||||
})
|
},
|
||||||
|
)
|
||||||
const res = await agent.prompt({
|
const res = await agent.prompt({
|
||||||
sessionId,
|
sessionId,
|
||||||
prompt: [{ type: 'text', text: 'hello' }],
|
prompt: [{ type: 'text', text: 'hello' }],
|
||||||
@@ -485,8 +530,9 @@ describe('AcpAgent', () => {
|
|||||||
test('returns cancelled when session was cancelled during prompt', async () => {
|
test('returns cancelled when session was cancelled during prompt', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(async () => {
|
;(
|
||||||
// Simulate cancel happening during forward
|
forwardSessionUpdates as ReturnType<typeof mock>
|
||||||
|
).mockImplementationOnce(async () => {
|
||||||
const session = agent.sessions.get(sessionId)
|
const session = agent.sessions.get(sessionId)
|
||||||
if (session) session.cancelled = true
|
if (session) session.cancelled = true
|
||||||
return { stopReason: 'end_turn' }
|
return { stopReason: 'end_turn' }
|
||||||
@@ -501,7 +547,9 @@ describe('AcpAgent', () => {
|
|||||||
test('returns cancelled on cancel after error', async () => {
|
test('returns cancelled on cancel after error', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(async () => {
|
;(
|
||||||
|
forwardSessionUpdates as ReturnType<typeof mock>
|
||||||
|
).mockImplementationOnce(async () => {
|
||||||
const session = agent.sessions.get(sessionId)
|
const session = agent.sessions.get(sessionId)
|
||||||
if (session) session.cancelled = true
|
if (session) session.cancelled = true
|
||||||
throw new Error('unexpected')
|
throw new Error('unexpected')
|
||||||
@@ -523,9 +571,7 @@ describe('AcpAgent', () => {
|
|||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
} as any)
|
} as any)
|
||||||
// The session must be stored under the requested ID
|
|
||||||
expect(agent.sessions.has(requestedId)).toBe(true)
|
expect(agent.sessions.has(requestedId)).toBe(true)
|
||||||
// Response should have modes/models/configOptions
|
|
||||||
expect(res.modes).toBeDefined()
|
expect(res.modes).toBeDefined()
|
||||||
expect(res.models).toBeDefined()
|
expect(res.models).toBeDefined()
|
||||||
})
|
})
|
||||||
@@ -535,13 +581,11 @@ describe('AcpAgent', () => {
|
|||||||
const res1 = await agent.newSession({ cwd: '/tmp' } as any)
|
const res1 = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
const sid = res1.sessionId
|
const sid = res1.sessionId
|
||||||
const originalSession = agent.sessions.get(sid)
|
const originalSession = agent.sessions.get(sid)
|
||||||
// Resume with same params
|
|
||||||
const res2 = await agent.unstable_resumeSession({
|
const res2 = await agent.unstable_resumeSession({
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
} as any)
|
} as any)
|
||||||
// Same session object — not recreated
|
|
||||||
expect(agent.sessions.get(sid)).toBe(originalSession)
|
expect(agent.sessions.get(sid)).toBe(originalSession)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -553,7 +597,9 @@ describe('AcpAgent', () => {
|
|||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
} as any)
|
} as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
|
{ stopReason: 'end_turn' },
|
||||||
|
)
|
||||||
const res = await agent.prompt({
|
const res = await agent.prompt({
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
prompt: [{ type: 'text', text: 'hello after restore' }],
|
prompt: [{ type: 'text', text: 'hello after restore' }],
|
||||||
@@ -582,7 +628,9 @@ describe('AcpAgent', () => {
|
|||||||
cwd: '/tmp',
|
cwd: '/tmp',
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
} as any)
|
} as any)
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
|
{ stopReason: 'end_turn' },
|
||||||
|
)
|
||||||
const res = await agent.prompt({
|
const res = await agent.prompt({
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
prompt: [{ type: 'text', text: 'hello after load' }],
|
prompt: [{ type: 'text', text: 'hello after load' }],
|
||||||
@@ -639,10 +687,15 @@ describe('AcpAgent', () => {
|
|||||||
test('can switch to bypassPermissions mode', async () => {
|
test('can switch to bypassPermissions mode', async () => {
|
||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
await agent.setSessionMode({ sessionId, modeId: 'bypassPermissions' } as any)
|
await agent.setSessionMode({
|
||||||
|
sessionId,
|
||||||
|
modeId: 'bypassPermissions',
|
||||||
|
} as any)
|
||||||
const session = agent.sessions.get(sessionId)
|
const session = agent.sessions.get(sessionId)
|
||||||
expect(session?.modes.currentModeId).toBe('bypassPermissions')
|
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 agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
|
|
||||||
// First prompt hangs
|
|
||||||
let resolveFirst!: () => void
|
let resolveFirst!: () => void
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
;(
|
||||||
() => new Promise<{ stopReason: string }>((resolve) => {
|
forwardSessionUpdates as ReturnType<typeof mock>
|
||||||
|
).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<{ stopReason: string }>(resolve => {
|
||||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
// Second prompt resolves normally
|
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce(
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockResolvedValueOnce({ stopReason: 'end_turn' })
|
{ stopReason: 'end_turn' },
|
||||||
|
)
|
||||||
|
|
||||||
const p1 = agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'first' }] } as any)
|
const p1 = agent.prompt({
|
||||||
const p2 = agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'second' }] } as any)
|
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()
|
resolveFirst()
|
||||||
const [r1, r2] = await Promise.all([p1, p2])
|
const [r1, r2] = await Promise.all([p1, p2])
|
||||||
expect(r1.stopReason).toBe('end_turn')
|
expect(r1.stopReason).toBe('end_turn')
|
||||||
@@ -701,18 +762,25 @@ describe('AcpAgent', () => {
|
|||||||
const agent = new AcpAgent(makeConn())
|
const agent = new AcpAgent(makeConn())
|
||||||
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
const { sessionId } = await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
|
|
||||||
// First prompt hangs
|
|
||||||
let resolveFirst!: () => void
|
let resolveFirst!: () => void
|
||||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
;(
|
||||||
() => new Promise<{ stopReason: string }>((resolve) => {
|
forwardSessionUpdates as ReturnType<typeof mock>
|
||||||
|
).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<{ stopReason: string }>(resolve => {
|
||||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const p1 = agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'first' }] } as any)
|
const p1 = agent.prompt({
|
||||||
const p2 = agent.prompt({ sessionId, prompt: [{ type: 'text', text: 'second' }] } as any)
|
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)
|
await agent.cancel({ sessionId } as any)
|
||||||
resolveFirst()
|
resolveFirst()
|
||||||
const [r1, r2] = await Promise.all([p1, p2])
|
const [r1, r2] = await Promise.all([p1, p2])
|
||||||
@@ -727,7 +795,6 @@ describe('AcpAgent', () => {
|
|||||||
const agent = new AcpAgent(conn)
|
const agent = new AcpAgent(conn)
|
||||||
await agent.newSession({ cwd: '/tmp' } as any)
|
await agent.newSession({ cwd: '/tmp' } as any)
|
||||||
|
|
||||||
// Wait for setTimeout-based sendAvailableCommandsUpdate
|
|
||||||
await new Promise(r => setTimeout(r, 10))
|
await new Promise(r => setTimeout(r, 10))
|
||||||
|
|
||||||
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
||||||
@@ -738,11 +805,10 @@ describe('AcpAgent', () => {
|
|||||||
expect(cmdUpdate).toBeDefined()
|
expect(cmdUpdate).toBeDefined()
|
||||||
|
|
||||||
const cmds = (cmdUpdate as any[])[0].update.availableCommands
|
const cmds = (cmdUpdate as any[])[0].update.availableCommands
|
||||||
// Only prompt-type, non-hidden, userInvocable commands
|
|
||||||
const names = cmds.map((c: any) => c.name)
|
const names = cmds.map((c: any) => c.name)
|
||||||
expect(names).toContain('commit')
|
expect(names).toContain('commit')
|
||||||
expect(names).not.toContain('compact') // type: 'local'
|
expect(names).not.toContain('compact')
|
||||||
expect(names).not.toContain('hidden-skill') // isHidden: true, userInvocable: false
|
expect(names).not.toContain('hidden-skill')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('maps argumentHint to input.hint', async () => {
|
test('maps argumentHint to input.hint', async () => {
|
||||||
|
|||||||
@@ -11,15 +11,21 @@ import type { SDKMessage } from '../../../entrypoints/sdk/coreTypes.js'
|
|||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function makeConn(overrides: Partial<AgentSideConnection> = {}): AgentSideConnection {
|
function makeConn(
|
||||||
|
overrides: Partial<AgentSideConnection> = {},
|
||||||
|
): AgentSideConnection {
|
||||||
return {
|
return {
|
||||||
sessionUpdate: mock(async () => {}),
|
sessionUpdate: mock(async () => {}),
|
||||||
requestPermission: mock(async () => ({ outcome: { outcome: 'cancelled' } }) as any),
|
requestPermission: mock(
|
||||||
|
async () => ({ outcome: { outcome: 'cancelled' } }) as any,
|
||||||
|
),
|
||||||
...overrides,
|
...overrides,
|
||||||
} as unknown as AgentSideConnection
|
} as unknown as AgentSideConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
async function* makeStream(msgs: SDKMessage[]): AsyncGenerator<SDKMessage, void, unknown> {
|
async function* makeStream(
|
||||||
|
msgs: SDKMessage[],
|
||||||
|
): AsyncGenerator<SDKMessage, void, unknown> {
|
||||||
for (const m of msgs) yield m
|
for (const m of msgs) yield m
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,14 +55,22 @@ describe('toolInfoFromToolUse', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('unknown tool name → other', () => {
|
test('unknown tool name → other', () => {
|
||||||
expect(toolInfoFromToolUse({ name: 'SomeFancyTool', id: 'x', input: {} }).kind).toBe('other' as ToolKind)
|
expect(
|
||||||
expect(toolInfoFromToolUse({ name: '', id: 'x', input: {} }).kind).toBe('other' as ToolKind)
|
toolInfoFromToolUse({ name: 'SomeFancyTool', id: 'x', input: {} }).kind,
|
||||||
|
).toBe('other' as ToolKind)
|
||||||
|
expect(toolInfoFromToolUse({ name: '', id: 'x', input: {} }).kind).toBe(
|
||||||
|
'other' as ToolKind,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Bash ──────────────────────────────────────────────────────
|
// ── Bash ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('Bash with command → title shows command', () => {
|
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.title).toBe('ls -la')
|
||||||
expect(info.content).toEqual([
|
expect(info.content).toEqual([
|
||||||
{ type: 'content', content: { type: 'text', text: 'List files' } },
|
{ type: 'content', content: { type: 'text', text: 'List files' } },
|
||||||
@@ -73,20 +87,32 @@ describe('toolInfoFromToolUse', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('Bash without description → empty content', () => {
|
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([])
|
expect(info.content).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Glob ──────────────────────────────────────────────────────
|
// ── Glob ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('Glob with pattern → title shows Find', () => {
|
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.title).toBe('Find `*/**.ts`')
|
||||||
expect(info.locations).toEqual([])
|
expect(info.locations).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Glob with path → locations include path', () => {
|
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.title).toBe('Find `/src` `*.ts`')
|
||||||
expect(info.locations).toEqual([{ path: '/src' }])
|
expect(info.locations).toEqual([{ path: '/src' }])
|
||||||
})
|
})
|
||||||
@@ -162,7 +188,10 @@ describe('toolInfoFromToolUse', () => {
|
|||||||
const info = toolInfoFromToolUse({
|
const info = toolInfoFromToolUse({
|
||||||
name: 'Write',
|
name: 'Write',
|
||||||
id: 'x',
|
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.kind).toBe('edit')
|
||||||
expect(info.title).toBe('Write /Users/test/project/example.txt')
|
expect(info.title).toBe('Write /Users/test/project/example.txt')
|
||||||
@@ -174,7 +203,9 @@ describe('toolInfoFromToolUse', () => {
|
|||||||
newText: 'Hello, World!\nThis is test content.',
|
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 ──────────────────────────────────────────────────────
|
// ── Edit ──────────────────────────────────────────────────────
|
||||||
@@ -183,7 +214,11 @@ describe('toolInfoFromToolUse', () => {
|
|||||||
const info = toolInfoFromToolUse({
|
const info = toolInfoFromToolUse({
|
||||||
name: 'Edit',
|
name: 'Edit',
|
||||||
id: 'x',
|
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.kind).toBe('edit')
|
||||||
expect(info.title).toBe('Edit /Users/test/project/test.txt')
|
expect(info.title).toBe('Edit /Users/test/project/test.txt')
|
||||||
@@ -206,34 +241,56 @@ describe('toolInfoFromToolUse', () => {
|
|||||||
// ── Read ──────────────────────────────────────────────────────
|
// ── Read ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
test('Read with file_path → locations include path and line 1', () => {
|
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 }])
|
expect(info.locations).toEqual([{ path: '/src/foo.ts', line: 1 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Read with limit', () => {
|
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)')
|
expect(info.title).toContain('(1 - 100)')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Read with offset and limit', () => {
|
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.title).toContain('(50 - 149)')
|
||||||
expect(info.locations).toEqual([{ path: '/large.txt', line: 50 }])
|
expect(info.locations).toEqual([{ path: '/large.txt', line: 50 }])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Read with only offset', () => {
|
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)')
|
expect(info.title).toContain('(from line 200)')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('Read with cwd → relative path in title, absolute in locations', () => {
|
test('Read with cwd → relative path in title, absolute in locations', () => {
|
||||||
const info = toolInfoFromToolUse(
|
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,
|
false,
|
||||||
'/Users/test/project',
|
'/Users/test/project',
|
||||||
)
|
)
|
||||||
expect(info.title).toBe('Read src/main.ts')
|
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 ─────────────────────────────────────────────────
|
// ── WebSearch ─────────────────────────────────────────────────
|
||||||
@@ -242,7 +299,11 @@ describe('toolInfoFromToolUse', () => {
|
|||||||
const info = toolInfoFromToolUse({
|
const info = toolInfoFromToolUse({
|
||||||
name: 'WebSearch',
|
name: 'WebSearch',
|
||||||
id: 'x',
|
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('allowed: a.com')
|
||||||
expect(info.title).toContain('blocked: b.com')
|
expect(info.title).toContain('blocked: b.com')
|
||||||
@@ -280,7 +341,11 @@ describe('toolInfoFromToolUse', () => {
|
|||||||
describe('toolUpdateFromToolResult', () => {
|
describe('toolUpdateFromToolResult', () => {
|
||||||
test('returns empty for Edit success', () => {
|
test('returns empty for Edit success', () => {
|
||||||
const result = toolUpdateFromToolResult(
|
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' },
|
{ name: 'Edit', id: 't1' },
|
||||||
)
|
)
|
||||||
expect(result).toEqual({})
|
expect(result).toEqual({})
|
||||||
@@ -288,11 +353,21 @@ describe('toolUpdateFromToolResult', () => {
|
|||||||
|
|
||||||
test('returns error content for Edit failure', () => {
|
test('returns error content for Edit failure', () => {
|
||||||
const result = toolUpdateFromToolResult(
|
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' },
|
{ name: 'Edit', id: 't1' },
|
||||||
)
|
)
|
||||||
expect(result.content).toEqual([
|
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).toBeDefined()
|
||||||
expect(result.content![0].type).toBe('content')
|
expect(result.content![0].type).toBe('content')
|
||||||
// Should be wrapped in markdown code fence
|
// 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('```')
|
||||||
expect(text).toContain('let x = 1')
|
expect(text).toContain('let x = 1')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('returns console block for Bash output', () => {
|
test('returns console block for Bash output', () => {
|
||||||
const result = toolUpdateFromToolResult(
|
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' },
|
{ name: 'Bash', id: 't1' },
|
||||||
)
|
)
|
||||||
expect(result.content).toEqual([
|
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', () => {
|
test('returns terminal metadata for Bash with terminalOutput', () => {
|
||||||
const result = toolUpdateFromToolResult(
|
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' },
|
{ name: 'Bash', id: 't1' },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
expect(result.content).toEqual([{ type: 'terminal', terminalId: 't1' }])
|
expect(result.content).toEqual([{ type: 'terminal', terminalId: 't1' }])
|
||||||
expect(result._meta).toBeDefined()
|
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_info).toEqual({
|
||||||
expect((result._meta as Record<string, unknown>).terminal_output).toEqual({ terminal_id: 't1', data: 'output' })
|
terminal_id: 't1',
|
||||||
expect((result._meta as Record<string, unknown>).terminal_exit).toEqual({ terminal_id: 't1', exit_code: 0, signal: null })
|
})
|
||||||
|
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', () => {
|
test('handles bash_code_execution_result format', () => {
|
||||||
const result = toolUpdateFromToolResult(
|
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' },
|
{ name: 'Bash', id: 't1' },
|
||||||
true,
|
true,
|
||||||
)
|
)
|
||||||
@@ -353,7 +462,11 @@ describe('toolUpdateFromToolResult', () => {
|
|||||||
|
|
||||||
test('transforms tool_reference content', () => {
|
test('transforms tool_reference content', () => {
|
||||||
const result = toolUpdateFromToolResult(
|
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' },
|
{ name: 'ToolSearch', id: 't1' },
|
||||||
)
|
)
|
||||||
expect(result.content).toEqual([
|
expect(result.content).toEqual([
|
||||||
@@ -363,21 +476,43 @@ describe('toolUpdateFromToolResult', () => {
|
|||||||
|
|
||||||
test('transforms web_search_result content', () => {
|
test('transforms web_search_result content', () => {
|
||||||
const result = toolUpdateFromToolResult(
|
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' },
|
{ name: 'WebSearch', id: 't1' },
|
||||||
)
|
)
|
||||||
expect(result.content).toEqual([
|
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', () => {
|
test('transforms code_execution_result content', () => {
|
||||||
const result = toolUpdateFromToolResult(
|
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' },
|
{ name: 'CodeExecution', id: 't1' },
|
||||||
)
|
)
|
||||||
expect(result.content).toEqual([
|
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,
|
oldLines: 3,
|
||||||
newStart: 1,
|
newStart: 1,
|
||||||
newLines: 3,
|
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({
|
const result = toolUpdateFromEditToolResponse({
|
||||||
filePath: '/Users/test/project/file.ts',
|
filePath: '/Users/test/project/file.ts',
|
||||||
structuredPatch: [
|
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)
|
expect(result.content).toHaveLength(2)
|
||||||
@@ -451,7 +603,13 @@ describe('toolUpdateFromEditToolResponse', () => {
|
|||||||
const result = toolUpdateFromEditToolResponse({
|
const result = toolUpdateFromEditToolResponse({
|
||||||
filePath: '/Users/test/project/file.ts',
|
filePath: '/Users/test/project/file.ts',
|
||||||
structuredPatch: [
|
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([
|
expect(result.content).toEqual([
|
||||||
@@ -466,7 +624,10 @@ describe('toolUpdateFromEditToolResponse', () => {
|
|||||||
|
|
||||||
test('returns empty for empty structuredPatch array', () => {
|
test('returns empty for empty structuredPatch array', () => {
|
||||||
expect(
|
expect(
|
||||||
toolUpdateFromEditToolResponse({ filePath: '/foo.ts', structuredPatch: [] }),
|
toolUpdateFromEditToolResponse({
|
||||||
|
filePath: '/foo.ts',
|
||||||
|
structuredPatch: [],
|
||||||
|
}),
|
||||||
).toEqual({})
|
).toEqual({})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -480,7 +641,9 @@ describe('markdownEscape', () => {
|
|||||||
|
|
||||||
test('extends fence for text containing backtick fences', () => {
|
test('extends fence for text containing backtick fences', () => {
|
||||||
const text = 'for example:\n```markdown\nHello *world*!\n```\n'
|
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', () => {
|
describe('toDisplayPath', () => {
|
||||||
test('relativizes paths inside cwd', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
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', () => {
|
describe('forwardSessionUpdates', () => {
|
||||||
test('returns end_turn when stream is empty', async () => {
|
test('returns end_turn when stream is empty', async () => {
|
||||||
const conn = makeConn()
|
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')
|
expect(result.stopReason).toBe('end_turn')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -517,23 +694,47 @@ describe('forwardSessionUpdates', () => {
|
|||||||
const ac = new AbortController()
|
const ac = new AbortController()
|
||||||
ac.abort()
|
ac.abort()
|
||||||
const conn = makeConn()
|
const conn = makeConn()
|
||||||
const result = await forwardSessionUpdates('s1', makeStream([
|
const result = await forwardSessionUpdates(
|
||||||
{ type: 'assistant', message: { content: [{ type: 'text', text: 'hi' }] } } as unknown as SDKMessage,
|
's1',
|
||||||
]), conn, ac.signal, {})
|
makeStream([
|
||||||
|
{
|
||||||
|
type: 'assistant',
|
||||||
|
message: { content: [{ type: 'text', text: 'hi' }] },
|
||||||
|
} as unknown as SDKMessage,
|
||||||
|
]),
|
||||||
|
conn,
|
||||||
|
ac.signal,
|
||||||
|
{},
|
||||||
|
)
|
||||||
expect(result.stopReason).toBe('cancelled')
|
expect(result.stopReason).toBe('cancelled')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('forwards assistant text message as agent_message_chunk', async () => {
|
test('forwards assistant text message as agent_message_chunk', async () => {
|
||||||
const conn = makeConn()
|
const conn = makeConn()
|
||||||
const msgs: SDKMessage[] = [
|
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<typeof mock>).mock.calls
|
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls
|
||||||
expect(calls.length).toBeGreaterThanOrEqual(1)
|
expect(calls.length).toBeGreaterThanOrEqual(1)
|
||||||
expect(calls[0][0]).toMatchObject({
|
expect(calls[0][0]).toMatchObject({
|
||||||
sessionId: 's1',
|
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')
|
expect(result.stopReason).toBe('end_turn')
|
||||||
})
|
})
|
||||||
@@ -541,11 +742,25 @@ describe('forwardSessionUpdates', () => {
|
|||||||
test('forwards thinking block as agent_thought_chunk', async () => {
|
test('forwards thinking block as agent_thought_chunk', async () => {
|
||||||
const conn = makeConn()
|
const conn = makeConn()
|
||||||
const msgs: SDKMessage[] = [
|
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<typeof mock>).mock.calls
|
const calls = (conn.sessionUpdate as ReturnType<typeof mock>).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 () => {
|
test('forwards tool_use block as tool_call', async () => {
|
||||||
@@ -554,18 +769,27 @@ describe('forwardSessionUpdates', () => {
|
|||||||
{
|
{
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
message: {
|
message: {
|
||||||
content: [{
|
content: [
|
||||||
|
{
|
||||||
type: 'tool_use',
|
type: 'tool_use',
|
||||||
id: 'tu_1',
|
id: 'tu_1',
|
||||||
name: 'Bash',
|
name: 'Bash',
|
||||||
input: { command: 'ls' },
|
input: { command: 'ls' },
|
||||||
}],
|
},
|
||||||
|
],
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
},
|
},
|
||||||
} as unknown as SDKMessage,
|
} as unknown as SDKMessage,
|
||||||
]
|
]
|
||||||
await forwardSessionUpdates('s1', makeStream(msgs), conn, new AbortController().signal, {})
|
await forwardSessionUpdates(
|
||||||
const update = (conn.sessionUpdate as ReturnType<typeof mock>).mock.calls[0][0].update as Record<string, unknown>
|
'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.sessionUpdate).toBe('tool_call')
|
||||||
expect(update.toolCallId).toBe('tu_1')
|
expect(update.toolCallId).toBe('tu_1')
|
||||||
expect(update.kind).toBe('execute' as ToolKind)
|
expect(update.kind).toBe('execute' as ToolKind)
|
||||||
@@ -580,11 +804,22 @@ describe('forwardSessionUpdates', () => {
|
|||||||
subtype: 'success',
|
subtype: 'success',
|
||||||
is_error: false,
|
is_error: false,
|
||||||
result: '',
|
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,
|
total_cost_usd: 0.01,
|
||||||
} as unknown as SDKMessage,
|
} 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.stopReason).toBe('end_turn')
|
||||||
expect(result.usage).toBeDefined()
|
expect(result.usage).toBeDefined()
|
||||||
expect(result.usage!.inputTokens).toBe(100)
|
expect(result.usage!.inputTokens).toBe(100)
|
||||||
@@ -600,7 +835,12 @@ describe('forwardSessionUpdates', () => {
|
|||||||
content: [{ type: 'text', text: 'hi' }],
|
content: [{ type: 'text', text: 'hi' }],
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
model: 'claude-opus-4-20250514',
|
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,
|
parent_tool_use_id: null,
|
||||||
} as unknown as SDKMessage,
|
} as unknown as SDKMessage,
|
||||||
@@ -609,17 +849,40 @@ describe('forwardSessionUpdates', () => {
|
|||||||
subtype: 'success',
|
subtype: 'success',
|
||||||
is_error: false,
|
is_error: false,
|
||||||
result: '',
|
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: {
|
modelUsage: {
|
||||||
'claude-opus-4-20250514': { contextWindow: 1000000 },
|
'claude-opus-4-20250514': { contextWindow: 1000000 },
|
||||||
},
|
},
|
||||||
} as unknown as SDKMessage,
|
} 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<typeof mock>).mock.calls
|
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')
|
const usageUpdate = calls.find(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
((c[0] as Record<string, Record<string, unknown>>).update ?? {})[
|
||||||
|
'sessionUpdate'
|
||||||
|
] === 'usage_update',
|
||||||
|
)
|
||||||
expect(usageUpdate).toBeDefined()
|
expect(usageUpdate).toBeDefined()
|
||||||
expect(((usageUpdate![0] as Record<string, unknown>).update as Record<string, unknown>).size).toBe(1000000)
|
expect(
|
||||||
|
(
|
||||||
|
(usageUpdate![0] as Record<string, unknown>).update as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
).size,
|
||||||
|
).toBe(1000000)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('sends usage_update with prefix-matched modelUsage', async () => {
|
test('sends usage_update with prefix-matched modelUsage', async () => {
|
||||||
@@ -631,7 +894,12 @@ describe('forwardSessionUpdates', () => {
|
|||||||
content: [{ type: 'text', text: 'hi' }],
|
content: [{ type: 'text', text: 'hi' }],
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
model: 'claude-opus-4-6-20250514',
|
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,
|
parent_tool_use_id: null,
|
||||||
} as unknown as SDKMessage,
|
} as unknown as SDKMessage,
|
||||||
@@ -640,17 +908,40 @@ describe('forwardSessionUpdates', () => {
|
|||||||
subtype: 'success',
|
subtype: 'success',
|
||||||
is_error: false,
|
is_error: false,
|
||||||
result: '',
|
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: {
|
modelUsage: {
|
||||||
'claude-opus-4-6': { contextWindow: 2000000 },
|
'claude-opus-4-6': { contextWindow: 2000000 },
|
||||||
},
|
},
|
||||||
} as unknown as SDKMessage,
|
} 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<typeof mock>).mock.calls
|
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')
|
const usageUpdate = calls.find(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
((c[0] as Record<string, Record<string, unknown>>).update ?? {})[
|
||||||
|
'sessionUpdate'
|
||||||
|
] === 'usage_update',
|
||||||
|
)
|
||||||
expect(usageUpdate).toBeDefined()
|
expect(usageUpdate).toBeDefined()
|
||||||
expect(((usageUpdate![0] as Record<string, unknown>).update as Record<string, unknown>).size).toBe(2000000)
|
expect(
|
||||||
|
(
|
||||||
|
(usageUpdate![0] as Record<string, unknown>).update as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
).size,
|
||||||
|
).toBe(2000000)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('resets usage on compact_boundary', async () => {
|
test('resets usage on compact_boundary', async () => {
|
||||||
@@ -658,20 +949,49 @@ describe('forwardSessionUpdates', () => {
|
|||||||
const msgs: SDKMessage[] = [
|
const msgs: SDKMessage[] = [
|
||||||
{ type: 'system', subtype: 'compact_boundary' } as unknown as 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<typeof mock>).mock.calls
|
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')
|
const usageCall = calls.find(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
((c[0] as Record<string, Record<string, unknown>>).update ?? {})[
|
||||||
|
'sessionUpdate'
|
||||||
|
] === 'usage_update',
|
||||||
|
)
|
||||||
expect(usageCall).toBeDefined()
|
expect(usageCall).toBeDefined()
|
||||||
expect(((usageCall![0] as Record<string, unknown>).update as Record<string, unknown>).used).toBe(0)
|
expect(
|
||||||
|
(
|
||||||
|
(usageCall![0] as Record<string, unknown>).update as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
).used,
|
||||||
|
).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('re-throws unexpected errors from stream', async () => {
|
test('re-throws unexpected errors from stream', async () => {
|
||||||
const conn = makeConn()
|
const conn = makeConn()
|
||||||
async function* errorStream(): AsyncGenerator<SDKMessage, void, unknown> {
|
async function* errorStream(): AsyncGenerator<
|
||||||
|
SDKMessage,
|
||||||
|
undefined,
|
||||||
|
unknown
|
||||||
|
> {
|
||||||
|
yield undefined as unknown as SDKMessage
|
||||||
throw new Error('stream exploded')
|
throw new Error('stream exploded')
|
||||||
}
|
}
|
||||||
await expect(
|
await expect(
|
||||||
forwardSessionUpdates('s1', errorStream(), conn, new AbortController().signal, {}),
|
forwardSessionUpdates(
|
||||||
|
's1',
|
||||||
|
errorStream(),
|
||||||
|
conn,
|
||||||
|
new AbortController().signal,
|
||||||
|
{},
|
||||||
|
),
|
||||||
).rejects.toThrow('stream exploded')
|
).rejects.toThrow('stream exploded')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -41,9 +41,12 @@ export class Pushable<T> implements AsyncIterable<T> {
|
|||||||
return Promise.resolve({ value, done: false })
|
return Promise.resolve({ value, done: false })
|
||||||
}
|
}
|
||||||
if (this.done) {
|
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<IteratorResult<T>>((resolve) => {
|
return new Promise<IteratorResult<T>>(resolve => {
|
||||||
this.resolvers.push(resolve)
|
this.resolvers.push(resolve)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -53,11 +56,13 @@ export class Pushable<T> implements AsyncIterable<T> {
|
|||||||
|
|
||||||
// ── Stream helpers ────────────────────────────────────────────────
|
// ── Stream helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Array> {
|
export function nodeToWebWritable(
|
||||||
|
nodeStream: Writable,
|
||||||
|
): WritableStream<Uint8Array> {
|
||||||
return new WritableStream<Uint8Array>({
|
return new WritableStream<Uint8Array>({
|
||||||
write(chunk) {
|
write(chunk) {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
nodeStream.write(Buffer.from(chunk), (err) => {
|
nodeStream.write(Buffer.from(chunk), err => {
|
||||||
if (err) reject(err)
|
if (err) reject(err)
|
||||||
else resolve()
|
else resolve()
|
||||||
})
|
})
|
||||||
@@ -66,14 +71,16 @@ export function nodeToWebWritable(nodeStream: Writable): WritableStream<Uint8Arr
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nodeToWebReadable(nodeStream: Readable): ReadableStream<Uint8Array> {
|
export function nodeToWebReadable(
|
||||||
|
nodeStream: Readable,
|
||||||
|
): ReadableStream<Uint8Array> {
|
||||||
return new ReadableStream<Uint8Array>({
|
return new ReadableStream<Uint8Array>({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
nodeStream.on('data', (chunk: Buffer) => {
|
nodeStream.on('data', (chunk: Buffer) => {
|
||||||
controller.enqueue(new Uint8Array(chunk))
|
controller.enqueue(new Uint8Array(chunk))
|
||||||
})
|
})
|
||||||
nodeStream.on('end', () => controller.close())
|
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()
|
const normalized = defaultMode.trim().toLowerCase()
|
||||||
if (normalized === '') {
|
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]
|
const mapped = PERMISSION_MODE_ALIASES[normalized]
|
||||||
@@ -190,7 +199,7 @@ export function toDisplayPath(filePath: string, cwd?: string): string {
|
|||||||
resolvedFile.startsWith(resolvedCwd + path.sep) ||
|
resolvedFile.startsWith(resolvedCwd + path.sep) ||
|
||||||
resolvedFile === resolvedCwd
|
resolvedFile === resolvedCwd
|
||||||
) {
|
) {
|
||||||
return path.relative(resolvedCwd, resolvedFile)
|
return path.relative(resolvedCwd, resolvedFile).replaceAll('\\', '/')
|
||||||
}
|
}
|
||||||
return filePath
|
return filePath
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user