mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -192,7 +192,9 @@ describe('startAgentSummarization', () => {
|
||||
|
||||
expect(forkCalls).toEqual([])
|
||||
expect(updateCalls).toEqual([])
|
||||
expectDebugLogContaining('[AgentSummary] Skipping summary — poor mode active')
|
||||
expectDebugLogContaining(
|
||||
'[AgentSummary] Skipping summary — poor mode active',
|
||||
)
|
||||
expect(scheduledCount).toBe(initialScheduledCount + 1)
|
||||
expect(lastTimerHandle).not.toBe(initialTimerHandle)
|
||||
})
|
||||
|
||||
@@ -142,9 +142,9 @@ describe('getSummaryContextFingerprint', () => {
|
||||
})
|
||||
|
||||
test('treats unsupported top-level primitives as zero-size estimates', () => {
|
||||
expect(
|
||||
estimateMessageChars((() => undefined) as unknown as Message),
|
||||
).toBe(0)
|
||||
expect(estimateMessageChars((() => undefined) as unknown as Message)).toBe(
|
||||
0,
|
||||
)
|
||||
expect(estimateMessageChars(1n as unknown as Message)).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -59,11 +59,9 @@ export function startAgentSummarization(
|
||||
const clearTimeoutImpl = dependencies.clearTimeout ?? clearTimeout
|
||||
const getAgentTranscriptImpl =
|
||||
dependencies.getAgentTranscript ?? getAgentTranscript
|
||||
const isPoorModeActiveImpl =
|
||||
dependencies.isPoorModeActive ?? isPoorModeActive
|
||||
const isPoorModeActiveImpl = dependencies.isPoorModeActive ?? isPoorModeActive
|
||||
const logErrorImpl = dependencies.logError ?? logError
|
||||
const logForDebuggingImpl =
|
||||
dependencies.logForDebugging ?? logForDebugging
|
||||
const logForDebuggingImpl = dependencies.logForDebugging ?? logForDebugging
|
||||
const runForkedAgentImpl = dependencies.runForkedAgent ?? runForkedAgent
|
||||
const setTimeoutImpl = dependencies.setTimeout ?? setTimeout
|
||||
const updateAgentSummaryImpl =
|
||||
|
||||
@@ -86,9 +86,7 @@ function substituteVariables(
|
||||
// (replacer fn treats $ literally), and (2) double-substitution when user
|
||||
// content happens to contain {{varName}} matching a later variable.
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
|
||||
Object.hasOwn(variables, key)
|
||||
? variables[key]!
|
||||
: match,
|
||||
Object.hasOwn(variables, key) ? variables[key]! : match,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -249,7 +249,9 @@ export function getParentCacheSuppressReason(
|
||||
// The fork re-processes the parent's output (never cached) plus its own prompt.
|
||||
const outputTokens = usage!.output_tokens ?? 0
|
||||
|
||||
return (inputTokens as number) + (cacheWriteTokens as number) + (outputTokens as number) >
|
||||
return (inputTokens as number) +
|
||||
(cacheWriteTokens as number) +
|
||||
(outputTokens as number) >
|
||||
MAX_PARENT_UNCACHED_TOKENS
|
||||
? 'cache_cold'
|
||||
: null
|
||||
@@ -339,7 +341,9 @@ export async function generateSuggestion(
|
||||
|
||||
for (const msg of result.messages) {
|
||||
if (msg.type !== 'assistant') continue
|
||||
const contentArr = Array.isArray(msg.message!.content) ? msg.message!.content as Array<{ type: string; text?: string }> : []
|
||||
const contentArr = Array.isArray(msg.message!.content)
|
||||
? (msg.message!.content as Array<{ type: string; text?: string }>)
|
||||
: []
|
||||
const textBlock = contentArr.find(b => b.type === 'text')
|
||||
if (textBlock?.type === 'text' && typeof textBlock.text === 'string') {
|
||||
const suggestion = textBlock.text.trim()
|
||||
@@ -349,7 +353,7 @@ export async function generateSuggestion(
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestion: null as (string | null), generationRequestId }
|
||||
return { suggestion: null as string | null, generationRequestId }
|
||||
}
|
||||
|
||||
export function shouldFilterSuggestion(
|
||||
|
||||
@@ -197,7 +197,9 @@ function getBoundaryDetail(
|
||||
function isUserMessageWithArrayContent(
|
||||
m: Message,
|
||||
): m is Message & { message: { content: unknown[] } } {
|
||||
return m.type === 'user' && 'message' in m && Array.isArray(m.message?.content)
|
||||
return (
|
||||
m.type === 'user' && 'message' in m && Array.isArray(m.message?.content)
|
||||
)
|
||||
}
|
||||
|
||||
export function prepareMessagesForInjection(messages: Message[]): Message[] {
|
||||
@@ -254,7 +256,8 @@ export function prepareMessagesForInjection(messages: Message[]): Message[] {
|
||||
|
||||
return messages
|
||||
.map(msg => {
|
||||
if (!('message' in msg) || !Array.isArray(msg.message?.content)) return msg
|
||||
if (!('message' in msg) || !Array.isArray(msg.message?.content))
|
||||
return msg
|
||||
const content = msg.message!.content.filter(keep)
|
||||
if (content.length === msg.message!.content.length) return msg
|
||||
if (content.length === 0) return null
|
||||
|
||||
@@ -206,9 +206,7 @@ function substituteVariables(
|
||||
// (replacer fn treats $ literally), and (2) double-substitution when user
|
||||
// content happens to contain {{varName}} matching a later variable.
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
|
||||
Object.hasOwn(variables, key)
|
||||
? variables[key]!
|
||||
: match,
|
||||
Object.hasOwn(variables, key) ? variables[key]! : match,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
const _restores: (() => void)[] = []
|
||||
const originalCwd = process.cwd()
|
||||
const originalAcpPermissionMode = process.env.ACP_PERMISSION_MODE
|
||||
const originalAcpAllowBypass = process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
|
||||
const originalAcpAllowBypass =
|
||||
process.env.CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS
|
||||
|
||||
function mockModulePreservingExports(
|
||||
tsPath: string,
|
||||
@@ -35,10 +36,7 @@ afterAll(() => {
|
||||
}
|
||||
_restores.length = 0
|
||||
restoreEnv('ACP_PERMISSION_MODE', originalAcpPermissionMode)
|
||||
restoreEnv(
|
||||
'CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS',
|
||||
originalAcpAllowBypass,
|
||||
)
|
||||
restoreEnv('CLAUDE_CODE_ACP_ALLOW_BYPASS_PERMISSIONS', originalAcpAllowBypass)
|
||||
})
|
||||
|
||||
// ── Module mocks (must precede any import of the module under test) ──
|
||||
@@ -372,7 +370,9 @@ describe('AcpAgent', () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
}))
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
const agent = new AcpAgent(makeConn())
|
||||
try {
|
||||
await expect(
|
||||
@@ -406,7 +406,9 @@ describe('AcpAgent', () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'invalid-mode' },
|
||||
}))
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
const agent = new AcpAgent(makeConn())
|
||||
try {
|
||||
const res = await agent.newSession({ cwd: '/tmp' } as any)
|
||||
@@ -422,7 +424,9 @@ describe('AcpAgent', () => {
|
||||
mockGetSettings.mockImplementationOnce(() => ({
|
||||
permissions: { defaultMode: 'acceptEdits' },
|
||||
}))
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {})
|
||||
const consoleErrorSpy = spyOn(console, 'error').mockImplementation(
|
||||
() => {},
|
||||
)
|
||||
const agent = new AcpAgent(makeConn())
|
||||
try {
|
||||
await expect(
|
||||
@@ -976,7 +980,9 @@ describe('AcpAgent', () => {
|
||||
resolveFirst()
|
||||
const results = await Promise.all([first, ...queued])
|
||||
|
||||
expect(results.every(result => result.stopReason === 'end_turn')).toBe(true)
|
||||
expect(results.every(result => result.stopReason === 'end_turn')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(mockSubmitMessage.mock.calls.map(call => call[0])).toEqual([
|
||||
'first',
|
||||
...Array.from({ length: 1000 }, (_, index) => `queued-${index}`),
|
||||
@@ -989,13 +995,17 @@ describe('AcpAgent', () => {
|
||||
|
||||
let resolveFirst!: () => void
|
||||
let resolveSecond!: () => void
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
;(
|
||||
forwardSessionUpdates as ReturnType<typeof mock>
|
||||
).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopReason: string }>(resolve => {
|
||||
resolveFirst = () => resolve({ stopReason: 'end_turn' })
|
||||
}),
|
||||
)
|
||||
;(forwardSessionUpdates as ReturnType<typeof mock>).mockImplementationOnce(
|
||||
;(
|
||||
forwardSessionUpdates as ReturnType<typeof mock>
|
||||
).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<{ stopReason: string }>(resolve => {
|
||||
resolveSecond = () => resolve({ stopReason: 'end_turn' })
|
||||
|
||||
@@ -748,10 +748,14 @@ describe('forwardSessionUpdates', () => {
|
||||
ac.signal.addEventListener = addEventListener
|
||||
ac.signal.removeEventListener = removeEventListener
|
||||
|
||||
const msgs = Array.from({ length: 10_000 }, () => ({
|
||||
type: 'system',
|
||||
subtype: 'api_retry',
|
||||
}) as unknown as SDKMessage)
|
||||
const msgs = Array.from(
|
||||
{ length: 10_000 },
|
||||
() =>
|
||||
({
|
||||
type: 'system',
|
||||
subtype: 'api_retry',
|
||||
}) as unknown as SDKMessage,
|
||||
)
|
||||
|
||||
const result = await forwardSessionUpdates(
|
||||
's1',
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { afterAll, beforeEach, describe, expect, mock, spyOn, test } from 'bun:test'
|
||||
import {
|
||||
afterAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
spyOn,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import type { AgentSideConnection } from '@agentclientprotocol/sdk'
|
||||
import type { Tool as ToolType, ToolUseContext } from '../../../Tool.js'
|
||||
import type { AssistantMessage } from '../../../types/message.js'
|
||||
@@ -29,7 +37,10 @@ const bridgeModuleSnapshot = {
|
||||
|
||||
afterAll(() => {
|
||||
mock.module('../bridge.js', () => bridgeModuleSnapshot)
|
||||
mock.module('../../../utils/permissions/permissions.js', () => permissionsModuleSnapshot)
|
||||
mock.module(
|
||||
'../../../utils/permissions/permissions.js',
|
||||
() => permissionsModuleSnapshot,
|
||||
)
|
||||
})
|
||||
|
||||
mock.module('../../../utils/permissions/permissions.js', () => ({
|
||||
@@ -248,12 +259,20 @@ describe('createAcpCanUseTool', () => {
|
||||
() => false,
|
||||
)
|
||||
|
||||
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_9')
|
||||
await canUseTool(
|
||||
makeTool('ExitPlanMode'),
|
||||
{},
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_9',
|
||||
)
|
||||
|
||||
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(false)
|
||||
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('ExitPlanMode includes bypass option when the session exposes it', async () => {
|
||||
@@ -268,12 +287,20 @@ describe('createAcpCanUseTool', () => {
|
||||
() => true,
|
||||
)
|
||||
|
||||
await canUseTool(makeTool('ExitPlanMode'), {}, dummyContext, dummyMsg, 'tu_10')
|
||||
await canUseTool(
|
||||
makeTool('ExitPlanMode'),
|
||||
{},
|
||||
dummyContext,
|
||||
dummyMsg,
|
||||
'tu_10',
|
||||
)
|
||||
|
||||
const { options } = (conn.requestPermission as ReturnType<typeof mock>).mock
|
||||
.calls[0][0] as Record<string, unknown>
|
||||
const opts = options as Array<Record<string, unknown>>
|
||||
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(true)
|
||||
expect(opts.some(option => option.optionId === 'bypassPermissions')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('ExitPlanMode rejects a bypass selection that was not offered', async () => {
|
||||
@@ -301,6 +328,8 @@ describe('createAcpCanUseTool', () => {
|
||||
|
||||
expect(result.behavior).toBe('deny')
|
||||
expect(onModeChange).not.toHaveBeenCalled()
|
||||
expect((conn.sessionUpdate as ReturnType<typeof mock>).mock.calls).toHaveLength(0)
|
||||
expect(
|
||||
(conn.sessionUpdate as ReturnType<typeof mock>).mock.calls,
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,10 @@ import type {
|
||||
import { randomUUID, type UUID } from 'node:crypto'
|
||||
import type { Message } from '../../types/message.js'
|
||||
import { deserializeMessages } from '../../utils/conversationRecovery.js'
|
||||
import { getLastSessionLog, sessionIdExists } from '../../utils/sessionStorage.js'
|
||||
import {
|
||||
getLastSessionLog,
|
||||
sessionIdExists,
|
||||
} from '../../utils/sessionStorage.js'
|
||||
import { QueryEngine } from '../../QueryEngine.js'
|
||||
import type { QueryEngineConfig } from '../../QueryEngine.js'
|
||||
import type { Tools } from '../../Tool.js'
|
||||
@@ -56,16 +59,18 @@ import { FileStateCache } from '../../utils/fileStateCache.js'
|
||||
import { getDefaultAppState } from '../../state/AppStateStore.js'
|
||||
import type { AppState } from '../../state/AppStateStore.js'
|
||||
import { createAcpCanUseTool } from './permissions.js'
|
||||
import { forwardSessionUpdates, replayHistoryMessages, type ToolUseCache } from './bridge.js'
|
||||
import {
|
||||
forwardSessionUpdates,
|
||||
replayHistoryMessages,
|
||||
type ToolUseCache,
|
||||
} from './bridge.js'
|
||||
import {
|
||||
resolvePermissionMode,
|
||||
computeSessionFingerprint,
|
||||
sanitizeTitle,
|
||||
} from './utils.js'
|
||||
import { promptToQueryInput } from './promptConversion.js'
|
||||
import {
|
||||
listSessionsImpl,
|
||||
} from '../../utils/listSessionsImpl.js'
|
||||
import { listSessionsImpl } from '../../utils/listSessionsImpl.js'
|
||||
import { getMainLoopModel } from '../../utils/model/model.js'
|
||||
import { getModelOptions } from '../../utils/model/modelOptions.js'
|
||||
import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'
|
||||
@@ -123,8 +128,12 @@ export class AcpAgent implements Agent {
|
||||
.MACRO !== null
|
||||
? String(
|
||||
(
|
||||
(globalThis as unknown as Record<string, Record<string, unknown>>)
|
||||
.MACRO as Record<string, unknown>
|
||||
(
|
||||
globalThis as unknown as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>
|
||||
).MACRO as Record<string, unknown>
|
||||
).VERSION ?? '0.0.0',
|
||||
)
|
||||
: '0.0.0',
|
||||
@@ -156,7 +165,9 @@ export class AcpAgent implements Agent {
|
||||
|
||||
// ── authenticate ──────────────────────────────────────────────
|
||||
|
||||
async authenticate(_params: AuthenticateRequest): Promise<AuthenticateResponse> {
|
||||
async authenticate(
|
||||
_params: AuthenticateRequest,
|
||||
): Promise<AuthenticateResponse> {
|
||||
// No authentication required — this is a self-hosted/custom deployment
|
||||
return {}
|
||||
}
|
||||
@@ -189,7 +200,9 @@ export class AcpAgent implements Agent {
|
||||
|
||||
// ── listSessions ───────────────────────────────────────────────
|
||||
|
||||
async listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
|
||||
async listSessions(
|
||||
params: ListSessionsRequest,
|
||||
): Promise<ListSessionsResponse> {
|
||||
const candidates = await listSessionsImpl({
|
||||
dir: params.cwd ?? undefined,
|
||||
limit: 100,
|
||||
@@ -214,13 +227,11 @@ export class AcpAgent implements Agent {
|
||||
async unstable_forkSession(
|
||||
params: ForkSessionRequest,
|
||||
): Promise<ForkSessionResponse> {
|
||||
const response = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
},
|
||||
)
|
||||
const response = await this.createSession({
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
})
|
||||
this.scheduleAvailableCommandsUpdate(response.sessionId)
|
||||
return response
|
||||
}
|
||||
@@ -258,7 +269,7 @@ export class AcpAgent implements Agent {
|
||||
// Handle prompt queuing — if a prompt is already running, queue this one
|
||||
if (session.promptRunning) {
|
||||
const promptUuid = randomUUID()
|
||||
const cancelled = await new Promise<boolean>((resolve) => {
|
||||
const cancelled = await new Promise<boolean>(resolve => {
|
||||
session.pendingQueue.push(promptUuid)
|
||||
session.pendingMessages.set(promptUuid, { resolve })
|
||||
})
|
||||
@@ -414,7 +425,7 @@ export class AcpAgent implements Agent {
|
||||
)
|
||||
}
|
||||
|
||||
const option = session.configOptions.find((o) => o.id === params.configId)
|
||||
const option = session.configOptions.find(o => o.id === params.configId)
|
||||
if (!option) {
|
||||
throw new Error(`Unknown config option: ${params.configId}`)
|
||||
}
|
||||
@@ -436,7 +447,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
this.syncSessionConfigState(session, params.configId, value)
|
||||
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
session.configOptions = session.configOptions.map(o =>
|
||||
o.id === params.configId && typeof o.currentValue === 'string'
|
||||
? { ...o, currentValue: value }
|
||||
: o,
|
||||
@@ -449,7 +460,11 @@ export class AcpAgent implements Agent {
|
||||
|
||||
private async createSession(
|
||||
params: NewSessionRequest,
|
||||
opts: { forceNewId?: boolean; sessionId?: string; initialMessages?: Message[] } = {},
|
||||
opts: {
|
||||
forceNewId?: boolean
|
||||
sessionId?: string
|
||||
initialMessages?: Message[]
|
||||
} = {},
|
||||
): Promise<NewSessionResponse> {
|
||||
enableConfigs()
|
||||
|
||||
@@ -468,140 +483,176 @@ export class AcpAgent implements Agent {
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tools with a permissive permission context.
|
||||
const permissionContext = getEmptyToolPermissionContext()
|
||||
const tools: Tools = getTools(permissionContext)
|
||||
// Build tools with a permissive permission context.
|
||||
const permissionContext = getEmptyToolPermissionContext()
|
||||
const tools: Tools = getTools(permissionContext)
|
||||
|
||||
// Parse permission mode from _meta (passed by RCS/acp-link) or settings.
|
||||
const meta = params._meta as Record<string, unknown> | null | undefined
|
||||
const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode')
|
||||
const metaPermissionMode = hasMetaPermissionMode
|
||||
? meta?.permissionMode
|
||||
: undefined
|
||||
const settingsPermissionMode = this.getSetting<string>('permissions.defaultMode')
|
||||
const permissionMode = resolveSessionPermissionMode(
|
||||
metaPermissionMode,
|
||||
hasMetaPermissionMode,
|
||||
settingsPermissionMode,
|
||||
)
|
||||
// Parse permission mode from _meta (passed by RCS/acp-link) or settings.
|
||||
const meta = params._meta as Record<string, unknown> | null | undefined
|
||||
const hasMetaPermissionMode = hasOwnField(meta, 'permissionMode')
|
||||
const metaPermissionMode = hasMetaPermissionMode
|
||||
? meta?.permissionMode
|
||||
: undefined
|
||||
const settingsPermissionMode = this.getSetting<string>(
|
||||
'permissions.defaultMode',
|
||||
)
|
||||
const permissionMode = resolveSessionPermissionMode(
|
||||
metaPermissionMode,
|
||||
hasMetaPermissionMode,
|
||||
settingsPermissionMode,
|
||||
)
|
||||
|
||||
// Create the permission bridge canUseTool function
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
this.conn,
|
||||
sessionId,
|
||||
() => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default',
|
||||
this.clientCapabilities,
|
||||
cwd,
|
||||
(modeId: string) => { this.applySessionMode(sessionId, modeId) },
|
||||
() => this.sessions.get(sessionId)?.appState
|
||||
.toolPermissionContext.isBypassPermissionsModeAvailable ?? false,
|
||||
)
|
||||
|
||||
// Parse MCP servers from ACP params
|
||||
// MCP server config is handled separately in the tools system
|
||||
|
||||
// ACP clients can expose bypass only when both the process and local config allow it.
|
||||
const isBypassAvailable = isAcpBypassPermissionModeAvailable(settingsPermissionMode)
|
||||
|
||||
// Create a mutable AppState for the session
|
||||
const appState: AppState = {
|
||||
...getDefaultAppState(),
|
||||
toolPermissionContext: {
|
||||
...permissionContext,
|
||||
mode: permissionMode as PermissionMode,
|
||||
isBypassPermissionsModeAvailable: isBypassAvailable,
|
||||
},
|
||||
}
|
||||
|
||||
// Load commands for slash command and skill support
|
||||
const commands = await getCommands(cwd)
|
||||
|
||||
// Build QueryEngine config
|
||||
const engineConfig: QueryEngineConfig = {
|
||||
cwd,
|
||||
tools,
|
||||
commands,
|
||||
mcpClients: [],
|
||||
agents: [],
|
||||
canUseTool,
|
||||
getAppState: () => appState,
|
||||
setAppState: (updater: (prev: AppState) => AppState) => {
|
||||
const updated = updater(appState)
|
||||
Object.assign(appState, updated)
|
||||
},
|
||||
readFileCache: new FileStateCache(500, 50 * 1024 * 1024),
|
||||
includePartialMessages: true,
|
||||
replayUserMessages: true,
|
||||
initialMessages: opts.initialMessages,
|
||||
}
|
||||
|
||||
const queryEngine = new QueryEngine(engineConfig)
|
||||
|
||||
// Build modes — bypassPermissions is opt-in for ACP clients.
|
||||
const availableModes = [
|
||||
{ id: 'default', name: 'Default', description: 'Standard behavior, prompts for dangerous operations' },
|
||||
{ id: 'acceptEdits', name: 'Accept Edits', description: 'Auto-accept file edit operations' },
|
||||
{ id: 'plan', name: 'Plan Mode', description: 'Planning mode, no actual tool execution' },
|
||||
{ id: 'auto', name: 'Auto', description: 'Use a model classifier to approve/deny permission prompts.' },
|
||||
...(isBypassAvailable
|
||||
? [{ id: 'bypassPermissions' as const, name: 'Bypass Permissions', description: 'Skip all permission checks' }]
|
||||
: []),
|
||||
{ id: 'dontAsk', name: "Don't Ask", description: "Don't prompt for permissions, deny if not pre-approved" },
|
||||
]
|
||||
|
||||
const modes: SessionModeState = {
|
||||
currentModeId: permissionMode,
|
||||
availableModes,
|
||||
}
|
||||
|
||||
// Build models
|
||||
const modelOptions = getModelOptions()
|
||||
const currentModel = getMainLoopModel()
|
||||
const models: SessionModelState = {
|
||||
availableModels: modelOptions.map((m) => ({
|
||||
modelId: String(m.value ?? ''),
|
||||
name: m.label ?? String(m.value ?? ''),
|
||||
description: m.description ?? undefined,
|
||||
})),
|
||||
currentModelId: currentModel,
|
||||
}
|
||||
|
||||
// Set the model on the engine
|
||||
queryEngine.setModel(currentModel)
|
||||
|
||||
// Build config options
|
||||
const configOptions = buildConfigOptions(modes, models)
|
||||
|
||||
const session: AcpSession = {
|
||||
queryEngine,
|
||||
cancelled: false,
|
||||
cancelGeneration: 0,
|
||||
cwd,
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
pendingQueue: [],
|
||||
pendingQueueHead: 0,
|
||||
toolUseCache: {},
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
appState,
|
||||
commands,
|
||||
sessionFingerprint: computeSessionFingerprint({
|
||||
// Create the permission bridge canUseTool function
|
||||
const canUseTool = createAcpCanUseTool(
|
||||
this.conn,
|
||||
sessionId,
|
||||
() => this.sessions.get(sessionId)?.modes.currentModeId ?? 'default',
|
||||
this.clientCapabilities,
|
||||
cwd,
|
||||
mcpServers: params.mcpServers as Array<{ name: string; [key: string]: unknown }> | undefined,
|
||||
}),
|
||||
}
|
||||
(modeId: string) => {
|
||||
this.applySessionMode(sessionId, modeId)
|
||||
},
|
||||
() =>
|
||||
this.sessions.get(sessionId)?.appState.toolPermissionContext
|
||||
.isBypassPermissionsModeAvailable ?? false,
|
||||
)
|
||||
|
||||
this.sessions.set(sessionId, session)
|
||||
// Parse MCP servers from ACP params
|
||||
// MCP server config is handled separately in the tools system
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models,
|
||||
modes,
|
||||
configOptions,
|
||||
}
|
||||
// ACP clients can expose bypass only when both the process and local config allow it.
|
||||
const isBypassAvailable = isAcpBypassPermissionModeAvailable(
|
||||
settingsPermissionMode,
|
||||
)
|
||||
|
||||
// Create a mutable AppState for the session
|
||||
const appState: AppState = {
|
||||
...getDefaultAppState(),
|
||||
toolPermissionContext: {
|
||||
...permissionContext,
|
||||
mode: permissionMode as PermissionMode,
|
||||
isBypassPermissionsModeAvailable: isBypassAvailable,
|
||||
},
|
||||
}
|
||||
|
||||
// Load commands for slash command and skill support
|
||||
const commands = await getCommands(cwd)
|
||||
|
||||
// Build QueryEngine config
|
||||
const engineConfig: QueryEngineConfig = {
|
||||
cwd,
|
||||
tools,
|
||||
commands,
|
||||
mcpClients: [],
|
||||
agents: [],
|
||||
canUseTool,
|
||||
getAppState: () => appState,
|
||||
setAppState: (updater: (prev: AppState) => AppState) => {
|
||||
const updated = updater(appState)
|
||||
Object.assign(appState, updated)
|
||||
},
|
||||
readFileCache: new FileStateCache(500, 50 * 1024 * 1024),
|
||||
includePartialMessages: true,
|
||||
replayUserMessages: true,
|
||||
initialMessages: opts.initialMessages,
|
||||
}
|
||||
|
||||
const queryEngine = new QueryEngine(engineConfig)
|
||||
|
||||
// Build modes — bypassPermissions is opt-in for ACP clients.
|
||||
const availableModes = [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
description: 'Standard behavior, prompts for dangerous operations',
|
||||
},
|
||||
{
|
||||
id: 'acceptEdits',
|
||||
name: 'Accept Edits',
|
||||
description: 'Auto-accept file edit operations',
|
||||
},
|
||||
{
|
||||
id: 'plan',
|
||||
name: 'Plan Mode',
|
||||
description: 'Planning mode, no actual tool execution',
|
||||
},
|
||||
{
|
||||
id: 'auto',
|
||||
name: 'Auto',
|
||||
description:
|
||||
'Use a model classifier to approve/deny permission prompts.',
|
||||
},
|
||||
...(isBypassAvailable
|
||||
? [
|
||||
{
|
||||
id: 'bypassPermissions' as const,
|
||||
name: 'Bypass Permissions',
|
||||
description: 'Skip all permission checks',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'dontAsk',
|
||||
name: "Don't Ask",
|
||||
description: "Don't prompt for permissions, deny if not pre-approved",
|
||||
},
|
||||
]
|
||||
|
||||
const modes: SessionModeState = {
|
||||
currentModeId: permissionMode,
|
||||
availableModes,
|
||||
}
|
||||
|
||||
// Build models
|
||||
const modelOptions = getModelOptions()
|
||||
const currentModel = getMainLoopModel()
|
||||
const models: SessionModelState = {
|
||||
availableModels: modelOptions.map(m => ({
|
||||
modelId: String(m.value ?? ''),
|
||||
name: m.label ?? String(m.value ?? ''),
|
||||
description: m.description ?? undefined,
|
||||
})),
|
||||
currentModelId: currentModel,
|
||||
}
|
||||
|
||||
// Set the model on the engine
|
||||
queryEngine.setModel(currentModel)
|
||||
|
||||
// Build config options
|
||||
const configOptions = buildConfigOptions(modes, models)
|
||||
|
||||
const session: AcpSession = {
|
||||
queryEngine,
|
||||
cancelled: false,
|
||||
cancelGeneration: 0,
|
||||
cwd,
|
||||
modes,
|
||||
models,
|
||||
configOptions,
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
pendingQueue: [],
|
||||
pendingQueueHead: 0,
|
||||
toolUseCache: {},
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
appState,
|
||||
commands,
|
||||
sessionFingerprint: computeSessionFingerprint({
|
||||
cwd,
|
||||
mcpServers: params.mcpServers as
|
||||
| Array<{ name: string; [key: string]: unknown }>
|
||||
| undefined,
|
||||
}),
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session)
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
models,
|
||||
modes,
|
||||
configOptions,
|
||||
}
|
||||
} finally {
|
||||
if (processCwdChanged) {
|
||||
process.chdir(previousProcessCwd)
|
||||
@@ -619,8 +670,9 @@ export class AcpAgent implements Agent {
|
||||
if (existingSession) {
|
||||
const fingerprint = computeSessionFingerprint({
|
||||
cwd: params.cwd,
|
||||
mcpServers:
|
||||
params.mcpServers as Array<{ name: string; [key: string]: unknown }> | undefined,
|
||||
mcpServers: params.mcpServers as
|
||||
| Array<{ name: string; [key: string]: unknown }>
|
||||
| undefined,
|
||||
})
|
||||
if (fingerprint === existingSession.sessionFingerprint) {
|
||||
return {
|
||||
@@ -703,7 +755,9 @@ export class AcpAgent implements Agent {
|
||||
) {
|
||||
throw new Error(`Mode not available: ${modeId}`)
|
||||
}
|
||||
const isAvailable = session.modes.availableModes.some(mode => mode.id === modeId)
|
||||
const isAvailable = session.modes.availableModes.some(
|
||||
mode => mode.id === modeId,
|
||||
)
|
||||
if (!isAvailable) {
|
||||
throw new Error(`Mode not available: ${modeId}`)
|
||||
}
|
||||
@@ -727,7 +781,7 @@ export class AcpAgent implements Agent {
|
||||
|
||||
this.syncSessionConfigState(session, configId, value)
|
||||
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
session.configOptions = session.configOptions.map(o =>
|
||||
o.id === configId && typeof o.currentValue === 'string'
|
||||
? { ...o, currentValue: value }
|
||||
: o,
|
||||
@@ -761,9 +815,7 @@ export class AcpAgent implements Agent {
|
||||
const availableCommands = session.commands
|
||||
.filter(
|
||||
cmd =>
|
||||
cmd.type === 'prompt' &&
|
||||
!cmd.isHidden &&
|
||||
cmd.userInvocable !== false,
|
||||
cmd.type === 'prompt' && !cmd.isHidden && cmd.userInvocable !== false,
|
||||
)
|
||||
.map(cmd => ({
|
||||
name: cmd.name,
|
||||
@@ -851,14 +903,22 @@ function resolveRequiredPermissionMode(
|
||||
return resolvePermissionMode(mode, source) as PermissionMode
|
||||
}
|
||||
|
||||
function resolveConfiguredPermissionMode(mode: unknown): PermissionMode | undefined {
|
||||
function resolveConfiguredPermissionMode(
|
||||
mode: unknown,
|
||||
): PermissionMode | undefined {
|
||||
if (mode === undefined || mode === null) return undefined
|
||||
|
||||
try {
|
||||
return resolvePermissionMode(mode, 'permissions.defaultMode') as PermissionMode
|
||||
return resolvePermissionMode(
|
||||
mode,
|
||||
'permissions.defaultMode',
|
||||
) as PermissionMode
|
||||
} catch (err: unknown) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
console.error('[ACP] Invalid permissions.defaultMode, using default:', reason)
|
||||
console.error(
|
||||
'[ACP] Invalid permissions.defaultMode, using default:',
|
||||
reason,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -873,7 +933,8 @@ function hasOwnField(
|
||||
function isAcpBypassPermissionModeAvailable(settingsMode?: unknown): boolean {
|
||||
return (
|
||||
isProcessBypassPermissionModeAvailable() &&
|
||||
(isAcpBypassLocallyEnabled() || isSettingsBypassPermissionMode(settingsMode))
|
||||
(isAcpBypassLocallyEnabled() ||
|
||||
isSettingsBypassPermissionMode(settingsMode))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -948,11 +1009,13 @@ function buildConfigOptions(
|
||||
category: 'mode',
|
||||
type: 'select' as const,
|
||||
currentValue: modes.currentModeId,
|
||||
options: modes.availableModes.map((m: SessionModeState['availableModes'][number]) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
options: modes.availableModes.map(
|
||||
(m: SessionModeState['availableModes'][number]) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'model',
|
||||
@@ -961,11 +1024,13 @@ function buildConfigOptions(
|
||||
category: 'model',
|
||||
type: 'select' as const,
|
||||
currentValue: models.currentModelId,
|
||||
options: models.availableModels.map((m: SessionModelState['availableModels'][number]) => ({
|
||||
value: m.modelId,
|
||||
name: m.name,
|
||||
description: m.description ?? undefined,
|
||||
})),
|
||||
options: models.availableModels.map(
|
||||
(m: SessionModelState['availableModels'][number]) => ({
|
||||
value: m.modelId,
|
||||
name: m.name,
|
||||
description: m.description ?? undefined,
|
||||
}),
|
||||
),
|
||||
},
|
||||
] as SessionConfigOption[]
|
||||
}
|
||||
|
||||
@@ -72,7 +72,12 @@ export function toolInfoFromToolUse(
|
||||
title: description,
|
||||
kind: 'think',
|
||||
content: prompt
|
||||
? [{ type: 'content' as const, content: { type: 'text' as const, text: prompt } }]
|
||||
? [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: prompt },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
@@ -86,7 +91,12 @@ export function toolInfoFromToolUse(
|
||||
content: _supportsTerminalOutput
|
||||
? [{ type: 'terminal' as const, terminalId: toolUse.id }]
|
||||
: description
|
||||
? [{ type: 'content' as const, content: { type: 'text' as const, text: description } }]
|
||||
? [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: description },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
@@ -118,8 +128,20 @@ export function toolInfoFromToolUse(
|
||||
title: displayPath ? `Write ${displayPath}` : 'Write',
|
||||
kind: 'edit',
|
||||
content: filePath
|
||||
? [{ type: 'diff' as const, path: filePath, oldText: null, newText: content }]
|
||||
: [{ type: 'content' as const, content: { type: 'text' as const, text: content } }],
|
||||
? [
|
||||
{
|
||||
type: 'diff' as const,
|
||||
path: filePath,
|
||||
oldText: null,
|
||||
newText: content,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: content },
|
||||
},
|
||||
],
|
||||
locations: filePath ? [{ path: filePath }] : [],
|
||||
}
|
||||
}
|
||||
@@ -133,7 +155,14 @@ export function toolInfoFromToolUse(
|
||||
title: displayPath ? `Edit ${displayPath}` : 'Edit',
|
||||
kind: 'edit',
|
||||
content: filePath
|
||||
? [{ type: 'diff' as const, path: filePath, oldText: oldString || null, newText: newString }]
|
||||
? [
|
||||
{
|
||||
type: 'diff' as const,
|
||||
path: filePath,
|
||||
oldText: oldString || null,
|
||||
newText: newString,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
locations: filePath ? [{ path: filePath }] : [],
|
||||
}
|
||||
@@ -164,7 +193,8 @@ export function toolInfoFromToolUse(
|
||||
if (input?.['-C'] !== undefined) label += ` -C ${input['-C'] as number}`
|
||||
if (input?.output_mode === 'files_with_matches') label += ' -l'
|
||||
else if (input?.output_mode === 'count') label += ' -c'
|
||||
if (input?.head_limit !== undefined) label += ` | head -${input.head_limit as number}`
|
||||
if (input?.head_limit !== undefined)
|
||||
label += ` | head -${input.head_limit as number}`
|
||||
if (input?.glob) label += ` --include="${input.glob as string}"`
|
||||
if (input?.type) label += ` --type=${input.type as string}`
|
||||
if (input?.multiline) label += ' -P'
|
||||
@@ -184,7 +214,12 @@ export function toolInfoFromToolUse(
|
||||
title: url ? `Fetch ${url}` : 'Fetch',
|
||||
kind: 'fetch',
|
||||
content: fetchPrompt
|
||||
? [{ type: 'content' as const, content: { type: 'text' as const, text: fetchPrompt } }]
|
||||
? [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: fetchPrompt },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
@@ -194,8 +229,10 @@ export function toolInfoFromToolUse(
|
||||
let label = `"${query}"`
|
||||
const allowed = input?.allowed_domains as string[] | undefined
|
||||
const blocked = input?.blocked_domains as string[] | undefined
|
||||
if (allowed && allowed.length > 0) label += ` (allowed: ${allowed.join(', ')})`
|
||||
if (blocked && blocked.length > 0) label += ` (blocked: ${blocked.join(', ')})`
|
||||
if (allowed && allowed.length > 0)
|
||||
label += ` (allowed: ${allowed.join(', ')})`
|
||||
if (blocked && blocked.length > 0)
|
||||
label += ` (blocked: ${blocked.join(', ')})`
|
||||
return {
|
||||
title: label,
|
||||
kind: 'fetch',
|
||||
@@ -207,7 +244,7 @@ export function toolInfoFromToolUse(
|
||||
const todos = input?.todos as Array<{ content: string }> | undefined
|
||||
return {
|
||||
title: Array.isArray(todos)
|
||||
? `Update TODOs: ${todos.map((t) => t.content).join(', ')}`
|
||||
? `Update TODOs: ${todos.map(t => t.content).join(', ')}`
|
||||
: 'Update TODOs',
|
||||
kind: 'think',
|
||||
content: [],
|
||||
@@ -215,12 +252,19 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case 'ExitPlanMode': {
|
||||
const plan = (input as Record<string, unknown>)?.plan as string | undefined
|
||||
const plan = (input as Record<string, unknown>)?.plan as
|
||||
| string
|
||||
| undefined
|
||||
return {
|
||||
title: 'Ready to code?',
|
||||
kind: 'switch_mode',
|
||||
content: plan
|
||||
? [{ type: 'content' as const, content: { type: 'text' as const, text: plan } }]
|
||||
? [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: plan },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
@@ -240,7 +284,11 @@ export function toolUpdateFromToolResult(
|
||||
toolResult: Record<string, unknown>,
|
||||
toolUse: { name: string; id: string } | undefined,
|
||||
_supportsTerminalOutput: boolean = false,
|
||||
): { content?: ToolCallContent[]; title?: string; _meta?: Record<string, unknown> } {
|
||||
): {
|
||||
content?: ToolCallContent[]
|
||||
title?: string
|
||||
_meta?: Record<string, unknown>
|
||||
} {
|
||||
if (!toolUse) return {}
|
||||
|
||||
const isError = toolResult.is_error === true
|
||||
@@ -261,7 +309,10 @@ export function toolUpdateFromToolResult(
|
||||
content: [
|
||||
{
|
||||
type: 'content' as const,
|
||||
content: { type: 'text' as const, text: markdownEscape(resultContent) },
|
||||
content: {
|
||||
type: 'text' as const,
|
||||
text: markdownEscape(resultContent),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -272,7 +323,10 @@ export function toolUpdateFromToolResult(
|
||||
type: 'content' as const,
|
||||
content:
|
||||
c.type === 'text'
|
||||
? { type: 'text' as const, text: markdownEscape(c.text as string) }
|
||||
? {
|
||||
type: 'text' as const,
|
||||
text: markdownEscape(c.text as string),
|
||||
}
|
||||
: toAcpContentBlock(c, false),
|
||||
})),
|
||||
}
|
||||
@@ -290,10 +344,13 @@ export function toolUpdateFromToolResult(
|
||||
resultContent &&
|
||||
typeof resultContent === 'object' &&
|
||||
!Array.isArray(resultContent) &&
|
||||
(resultContent as Record<string, unknown>).type === 'bash_code_execution_result'
|
||||
(resultContent as Record<string, unknown>).type ===
|
||||
'bash_code_execution_result'
|
||||
) {
|
||||
const bashResult = resultContent as Record<string, unknown>
|
||||
output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join('\n')
|
||||
output = [bashResult.stdout, bashResult.stderr]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
exitCode = (bashResult.return_code as number) ?? (isError ? 1 : 0)
|
||||
} else if (typeof resultContent === 'string') {
|
||||
output = resultContent
|
||||
@@ -311,7 +368,11 @@ export function toolUpdateFromToolResult(
|
||||
_meta: {
|
||||
terminal_info: { terminal_id: terminalId },
|
||||
terminal_output: { terminal_id: terminalId, data: output },
|
||||
terminal_exit: { terminal_id: terminalId, exit_code: exitCode, signal: null },
|
||||
terminal_exit: {
|
||||
terminal_id: terminalId,
|
||||
exit_code: exitCode,
|
||||
signal: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -342,10 +403,7 @@ export function toolUpdateFromToolResult(
|
||||
}
|
||||
|
||||
default: {
|
||||
return toAcpContentUpdate(
|
||||
resultContent ?? '',
|
||||
isError,
|
||||
)
|
||||
return toAcpContentUpdate(resultContent ?? '', isError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -411,8 +469,12 @@ function toAcpContentBlock(
|
||||
case 'tool_reference':
|
||||
return wrapText(`Tool: ${content.tool_name as string}`)
|
||||
case 'tool_search_tool_search_result': {
|
||||
const refs = content.tool_references as Array<{ tool_name: string }> | undefined
|
||||
return wrapText(`Tools found: ${refs?.map((r) => r.tool_name).join(', ') || 'none'}`)
|
||||
const refs = content.tool_references as
|
||||
| Array<{ tool_name: string }>
|
||||
| undefined
|
||||
return wrapText(
|
||||
`Tools found: ${refs?.map(r => r.tool_name).join(', ') || 'none'}`,
|
||||
)
|
||||
}
|
||||
case 'tool_search_tool_result_error':
|
||||
return wrapText(
|
||||
@@ -428,7 +490,9 @@ function toAcpContentBlock(
|
||||
return wrapText(`Error: ${content.error_code as string}`)
|
||||
case 'code_execution_result':
|
||||
case 'bash_code_execution_result':
|
||||
return wrapText(`Output: ${(content.stdout as string) || (content.stderr as string) || ''}`)
|
||||
return wrapText(
|
||||
`Output: ${(content.stdout as string) || (content.stderr as string) || ''}`,
|
||||
)
|
||||
case 'code_execution_tool_result_error':
|
||||
case 'bash_code_execution_tool_result_error':
|
||||
return wrapText(`Error: ${content.error_code as string}`)
|
||||
@@ -508,7 +572,10 @@ export function toolUpdateFromEditToolResponse(toolResponse: unknown): {
|
||||
}
|
||||
}
|
||||
|
||||
const result: { content?: ToolCallContent[]; locations?: ToolCallLocation[] } = {}
|
||||
const result: {
|
||||
content?: ToolCallContent[]
|
||||
locations?: ToolCallLocation[]
|
||||
} = {}
|
||||
if (content.length > 0) result.content = content
|
||||
if (locations.length > 0) result.locations = locations
|
||||
return result
|
||||
@@ -523,10 +590,12 @@ function nextSdkMessageOrAbort(
|
||||
}
|
||||
|
||||
let abortHandler: (() => void) | undefined
|
||||
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>((resolve) => {
|
||||
abortHandler = () => resolve({ done: true, value: undefined })
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true })
|
||||
})
|
||||
const abortPromise = new Promise<IteratorResult<SDKMessage, void>>(
|
||||
resolve => {
|
||||
abortHandler = () => resolve({ done: true, value: undefined })
|
||||
abortSignal.addEventListener('abort', abortHandler, { once: true })
|
||||
},
|
||||
)
|
||||
|
||||
return Promise.race([sdkMessages.next(), abortPromise]).finally(() => {
|
||||
if (abortHandler) {
|
||||
@@ -622,7 +691,8 @@ export async function forwardSessionUpdates(
|
||||
accumulatedUsage.inputTokens += usage.input_tokens
|
||||
accumulatedUsage.outputTokens += usage.output_tokens
|
||||
accumulatedUsage.cachedReadTokens += usage.cache_read_input_tokens
|
||||
accumulatedUsage.cachedWriteTokens += usage.cache_creation_input_tokens
|
||||
accumulatedUsage.cachedWriteTokens +=
|
||||
usage.cache_creation_input_tokens
|
||||
}
|
||||
|
||||
// Resolve context window size from modelUsage via prefix matching
|
||||
@@ -638,12 +708,12 @@ export async function forwardSessionUpdates(
|
||||
|
||||
// Send usage_update — use lastAssistantTotalUsage if available
|
||||
// (more accurate than accumulatedUsage which may include background tasks)
|
||||
const usedTokens = lastAssistantTotalUsage ?? (
|
||||
const usedTokens =
|
||||
lastAssistantTotalUsage ??
|
||||
accumulatedUsage.inputTokens +
|
||||
accumulatedUsage.outputTokens +
|
||||
accumulatedUsage.cachedReadTokens +
|
||||
accumulatedUsage.cachedWriteTokens
|
||||
)
|
||||
accumulatedUsage.outputTokens +
|
||||
accumulatedUsage.cachedReadTokens +
|
||||
accumulatedUsage.cachedWriteTokens
|
||||
|
||||
const totalCostUsd = msg.total_cost_usd as number | undefined
|
||||
await conn.sessionUpdate({
|
||||
@@ -652,9 +722,10 @@ export async function forwardSessionUpdates(
|
||||
sessionUpdate: 'usage_update',
|
||||
used: usedTokens,
|
||||
size: lastContextWindowSize,
|
||||
cost: totalCostUsd != null
|
||||
? { amount: totalCostUsd, currency: 'USD' }
|
||||
: undefined,
|
||||
cost:
|
||||
totalCostUsd != null
|
||||
? { amount: totalCostUsd, currency: 'USD' }
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -724,8 +795,13 @@ export async function forwardSessionUpdates(
|
||||
case 'assistant': {
|
||||
// Track last assistant total usage for context window computation
|
||||
// (only for top-level messages, not subagents)
|
||||
const assistantMsg = msg.message as Record<string, unknown> | undefined
|
||||
const parentToolUseId = msg.parent_tool_use_id as string | null | undefined
|
||||
const assistantMsg = msg.message as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const parentToolUseId = msg.parent_tool_use_id as
|
||||
| string
|
||||
| null
|
||||
| undefined
|
||||
if (assistantMsg?.usage && parentToolUseId === null) {
|
||||
const msgUsage = assistantMsg.usage as Record<string, unknown>
|
||||
lastAssistantTotalUsage =
|
||||
@@ -773,7 +849,10 @@ export async function forwardSessionUpdates(
|
||||
|
||||
// Handle agent/skill subagent progress
|
||||
const progressType = progressData.type as string | undefined
|
||||
if (progressType === 'agent_progress' || progressType === 'skill_progress') {
|
||||
if (
|
||||
progressType === 'agent_progress' ||
|
||||
progressType === 'skill_progress'
|
||||
) {
|
||||
const progressMessage = progressData.message as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
@@ -887,7 +966,15 @@ function assistantMessageToAcpNotifications(
|
||||
]
|
||||
}
|
||||
|
||||
return toAcpNotifications(content, 'assistant', sessionId, toolUseCache, conn, undefined, options)
|
||||
return toAcpNotifications(
|
||||
content,
|
||||
'assistant',
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
conn,
|
||||
undefined,
|
||||
options,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stream event conversion ───────────────────────────────────────
|
||||
@@ -907,7 +994,9 @@ function streamEventToAcpNotifications(
|
||||
|
||||
switch (event.type as string) {
|
||||
case 'content_block_start': {
|
||||
const contentBlock = event.content_block as Record<string, unknown> | undefined
|
||||
const contentBlock = event.content_block as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (!contentBlock) return []
|
||||
return toAcpNotifications(
|
||||
[contentBlock],
|
||||
@@ -1001,7 +1090,9 @@ function toAcpNotifications(
|
||||
if (source?.type === 'base64') {
|
||||
update = {
|
||||
sessionUpdate:
|
||||
role === 'assistant' ? 'agent_message_chunk' : 'user_message_chunk',
|
||||
role === 'assistant'
|
||||
? 'agent_message_chunk'
|
||||
: 'user_message_chunk',
|
||||
content: {
|
||||
type: 'image',
|
||||
data: source.data as string,
|
||||
@@ -1034,7 +1125,7 @@ function toAcpNotifications(
|
||||
| Array<{ content: string; status: string }>
|
||||
| undefined
|
||||
if (Array.isArray(todos)) {
|
||||
const entries: PlanEntry[] = todos.map((todo) => ({
|
||||
const entries: PlanEntry[] = todos.map(todo => ({
|
||||
content: todo.content,
|
||||
status: normalizePlanStatus(todo.status),
|
||||
priority: 'medium',
|
||||
@@ -1086,8 +1177,7 @@ function toAcpNotifications(
|
||||
|
||||
case 'tool_result':
|
||||
case 'mcp_tool_result': {
|
||||
const toolUseId =
|
||||
(chunk.tool_use_id as string | undefined) ?? ''
|
||||
const toolUseId = (chunk.tool_use_id as string | undefined) ?? ''
|
||||
const toolUse = toolUseCache[toolUseId]
|
||||
if (!toolUse) break
|
||||
|
||||
@@ -1105,7 +1195,9 @@ function toAcpNotifications(
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: 'tool_call_update',
|
||||
status:
|
||||
(chunk.is_error as boolean | undefined) === true ? 'failed' : 'completed',
|
||||
(chunk.is_error as boolean | undefined) === true
|
||||
? 'failed'
|
||||
: 'completed',
|
||||
rawOutput: chunk.content,
|
||||
...toolUpdate,
|
||||
}
|
||||
@@ -1178,7 +1270,8 @@ export async function replayHistoryMessages(
|
||||
const content = messageData?.content
|
||||
if (!content) continue
|
||||
|
||||
const role: 'assistant' | 'user' = type === 'assistant' ? 'assistant' : 'user'
|
||||
const role: 'assistant' | 'user' =
|
||||
type === 'assistant' ? 'assistant' : 'user'
|
||||
|
||||
if (typeof content === 'string') {
|
||||
if (!content.trim()) continue
|
||||
@@ -1234,5 +1327,5 @@ function getMatchingModelUsage(
|
||||
}
|
||||
}
|
||||
|
||||
return bestKey ? modelUsage[bestKey] ?? null : null
|
||||
return bestKey ? (modelUsage[bestKey] ?? null) : null
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
AgentSideConnection,
|
||||
ndJsonStream,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import { AgentSideConnection, ndJsonStream } from '@agentclientprotocol/sdk'
|
||||
import type { Stream } from '@agentclientprotocol/sdk'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { AcpAgent } from './agent.js'
|
||||
@@ -39,7 +36,7 @@ export async function runAcpAgent(): Promise<void> {
|
||||
const stream = createAcpStream(process.stdin, process.stdout)
|
||||
|
||||
let agent!: AcpAgent
|
||||
const connection = new AgentSideConnection((conn) => {
|
||||
const connection = new AgentSideConnection(conn => {
|
||||
agent = new AcpAgent(conn)
|
||||
return agent
|
||||
}, stream)
|
||||
|
||||
@@ -44,14 +44,25 @@ export function createAcpCanUseTool(
|
||||
context: ToolUseContext,
|
||||
assistantMessage: AssistantMessage,
|
||||
toolUseID: string,
|
||||
forceDecision?: PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision,
|
||||
): Promise<PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision> => {
|
||||
forceDecision?:
|
||||
| PermissionAllowDecision
|
||||
| PermissionAskDecision
|
||||
| PermissionDenyDecision,
|
||||
): Promise<
|
||||
PermissionAllowDecision | PermissionAskDecision | PermissionDenyDecision
|
||||
> => {
|
||||
const supportsTerminalOutput = checkTerminalOutput(clientCapabilities)
|
||||
|
||||
// ── ExitPlanMode special handling ────────────────────────────
|
||||
if (tool.name === 'ExitPlanMode') {
|
||||
return handleExitPlanMode(
|
||||
conn, sessionId, toolUseID, input, supportsTerminalOutput, cwd, onModeChange,
|
||||
conn,
|
||||
sessionId,
|
||||
toolUseID,
|
||||
input,
|
||||
supportsTerminalOutput,
|
||||
cwd,
|
||||
onModeChange,
|
||||
isBypassModeAvailable,
|
||||
)
|
||||
}
|
||||
@@ -66,7 +77,11 @@ export function createAcpCanUseTool(
|
||||
// bypassPermissions mode, dontAsk mode, acceptEdits mode, auto mode classifier
|
||||
try {
|
||||
const pipelineResult = await hasPermissionsToUseTool(
|
||||
tool, input, context, assistantMessage, toolUseID,
|
||||
tool,
|
||||
input,
|
||||
context,
|
||||
assistantMessage,
|
||||
toolUseID,
|
||||
)
|
||||
|
||||
// If the pipeline resolved to allow or deny, return that
|
||||
@@ -168,9 +183,21 @@ async function handleExitPlanMode(
|
||||
isBypassModeAvailable?: () => boolean,
|
||||
): Promise<PermissionAllowDecision | PermissionDenyDecision> {
|
||||
const options: Array<PermissionOption> = [
|
||||
{ kind: 'allow_always', name: 'Yes, and use "auto" mode', optionId: 'auto' },
|
||||
{ kind: 'allow_always', name: 'Yes, and auto-accept edits', optionId: 'acceptEdits' },
|
||||
{ kind: 'allow_once', name: 'Yes, and manually approve edits', optionId: 'default' },
|
||||
{
|
||||
kind: 'allow_always',
|
||||
name: 'Yes, and use "auto" mode',
|
||||
optionId: 'auto',
|
||||
},
|
||||
{
|
||||
kind: 'allow_always',
|
||||
name: 'Yes, and auto-accept edits',
|
||||
optionId: 'acceptEdits',
|
||||
},
|
||||
{
|
||||
kind: 'allow_once',
|
||||
name: 'Yes, and manually approve edits',
|
||||
optionId: 'default',
|
||||
},
|
||||
{ kind: 'reject_once', name: 'No, keep planning', optionId: 'plan' },
|
||||
]
|
||||
if (isBypassModeAvailable?.() === true) {
|
||||
@@ -215,15 +242,15 @@ async function handleExitPlanMode(
|
||||
response.outcome.optionId !== undefined
|
||||
) {
|
||||
const selectedOption = response.outcome.optionId
|
||||
const isOfferedOption = options.some(option => option.optionId === selectedOption)
|
||||
const isOfferedOption = options.some(
|
||||
option => option.optionId === selectedOption,
|
||||
)
|
||||
if (
|
||||
isOfferedOption &&
|
||||
(
|
||||
selectedOption === 'default' ||
|
||||
(selectedOption === 'default' ||
|
||||
selectedOption === 'acceptEdits' ||
|
||||
selectedOption === 'auto' ||
|
||||
selectedOption === 'bypassPermissions'
|
||||
)
|
||||
selectedOption === 'bypassPermissions')
|
||||
) {
|
||||
// Sync mode to session state and appState
|
||||
onModeChange?.(selectedOption)
|
||||
|
||||
@@ -16,10 +16,8 @@ import { getEventMetadata } from './metadata.js'
|
||||
* DATADOG_LOGS_ENDPOINT=https://http-intake.logs.datadoghq.com/api/v2/logs
|
||||
* DATADOG_API_KEY=<your-key>
|
||||
*/
|
||||
const DATADOG_LOGS_ENDPOINT =
|
||||
process.env.DATADOG_LOGS_ENDPOINT ?? ''
|
||||
const DATADOG_CLIENT_TOKEN =
|
||||
process.env.DATADOG_API_KEY ?? ''
|
||||
const DATADOG_LOGS_ENDPOINT = process.env.DATADOG_LOGS_ENDPOINT ?? ''
|
||||
const DATADOG_CLIENT_TOKEN = process.env.DATADOG_API_KEY ?? ''
|
||||
const DEFAULT_FLUSH_INTERVAL_MS = 15000
|
||||
const MAX_BATCH_SIZE = 100
|
||||
const NETWORK_TIMEOUT_MS = 5000
|
||||
|
||||
@@ -673,7 +673,9 @@ export class FirstPartyEventLoggingExporter implements LogRecordExporter {
|
||||
(attributes.event_name as string) || (log.body as string) || 'unknown'
|
||||
|
||||
// Extract metadata objects directly (no JSON parsing needed)
|
||||
const coreMetadata = attributes.core_metadata as unknown as EventMetadata | undefined
|
||||
const coreMetadata = attributes.core_metadata as unknown as
|
||||
| EventMetadata
|
||||
| undefined
|
||||
const userMetadata = attributes.user_metadata as CoreUserData
|
||||
const eventMetadata = (attributes.event_metadata || {}) as Record<
|
||||
string,
|
||||
|
||||
@@ -742,7 +742,6 @@ export async function getEventMetadata(
|
||||
return metadata
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Core event metadata for 1P event logging (snake_case format).
|
||||
*/
|
||||
|
||||
@@ -230,7 +230,11 @@ import { getInitializationStatus } from '../lsp/manager.js'
|
||||
import { isToolFromMcpServer } from '../mcp/utils.js'
|
||||
import { recordLLMObservation } from '../langfuse/index.js'
|
||||
import type { LangfuseSpan } from '../langfuse/index.js'
|
||||
import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../langfuse/convert.js'
|
||||
import {
|
||||
convertMessagesToLangfuse,
|
||||
convertOutputToLangfuse,
|
||||
convertToolsToLangfuse,
|
||||
} from '../langfuse/convert.js'
|
||||
import { withStreamingVCR, withVCR } from '../vcr.js'
|
||||
import { CLIENT_REQUEST_ID_HEADER, getAnthropicClient } from './client.js'
|
||||
import {
|
||||
@@ -442,7 +446,7 @@ function configureEffortParams(
|
||||
betas.push(EFFORT_BETA_HEADER)
|
||||
} else if (typeof effortValue === 'string') {
|
||||
// Send string effort level as is
|
||||
outputConfig.effort = effortValue as "high" | "medium" | "low" | "max"
|
||||
outputConfig.effort = effortValue as 'high' | 'medium' | 'low' | 'max'
|
||||
betas.push(EFFORT_BETA_HEADER)
|
||||
} else if (process.env.USER_TYPE === 'ant') {
|
||||
// Numeric effort override - ant-only (uses anthropic_internal)
|
||||
@@ -615,7 +619,8 @@ export function userMessageToMessageParam(
|
||||
role: 'user',
|
||||
content: (Array.isArray(message.message!.content)
|
||||
? [...message.message!.content]
|
||||
: message.message!.content) as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlockParam[],
|
||||
: message.message!
|
||||
.content) as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlockParam[],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,7 +671,9 @@ export function assistantMessageToMessageParam(
|
||||
content:
|
||||
typeof message.message!.content === 'string'
|
||||
? message.message!.content
|
||||
: message.message!.content!.map(stripGeminiProviderMetadata) as BetaContentBlockParam[],
|
||||
: (message.message!.content!.map(
|
||||
stripGeminiProviderMetadata,
|
||||
) as BetaContentBlockParam[]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,10 +688,8 @@ function stripGeminiProviderMetadata<T extends BetaContentBlockParam | string>(
|
||||
}
|
||||
|
||||
const obj = contentBlock as unknown as Record<string, unknown>
|
||||
const {
|
||||
_geminiThoughtSignature: _unusedGeminiThoughtSignature,
|
||||
...rest
|
||||
} = obj
|
||||
const { _geminiThoughtSignature: _unusedGeminiThoughtSignature, ...rest } =
|
||||
obj
|
||||
return rest as unknown as T
|
||||
}
|
||||
|
||||
@@ -1343,7 +1348,13 @@ async function* queryModel(
|
||||
// OpenAI emulates Anthropic's dynamic tool loading client-side. It needs
|
||||
// the full tool pool so ToolSearchTool can search deferred MCP tools that
|
||||
// were intentionally filtered out of the initial API tool list above.
|
||||
yield* queryModelOpenAI(messagesForAPI, systemPrompt, tools, signal, options)
|
||||
yield* queryModelOpenAI(
|
||||
messagesForAPI,
|
||||
systemPrompt,
|
||||
tools,
|
||||
signal,
|
||||
options,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1362,7 +1373,13 @@ async function* queryModel(
|
||||
|
||||
if (getAPIProvider() === 'grok') {
|
||||
const { queryModelGrok } = await import('./grok/index.js')
|
||||
yield* queryModelGrok(messagesForAPI, systemPrompt, filteredTools, signal, options)
|
||||
yield* queryModelGrok(
|
||||
messagesForAPI,
|
||||
systemPrompt,
|
||||
filteredTools,
|
||||
signal,
|
||||
options,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2127,7 +2144,8 @@ async function* queryModel(
|
||||
})
|
||||
throw new Error('Content block is not a connector_text block')
|
||||
}
|
||||
;(contentBlock as { connector_text: string }).connector_text += delta.connector_text
|
||||
;(contentBlock as { connector_text: string }).connector_text +=
|
||||
delta.connector_text
|
||||
} else {
|
||||
switch (delta.type) {
|
||||
case 'citations_delta':
|
||||
@@ -2206,7 +2224,8 @@ async function* queryModel(
|
||||
})
|
||||
throw new Error('Content block is not a thinking block')
|
||||
}
|
||||
;(contentBlock as { thinking: string }).thinking += delta.thinking
|
||||
;(contentBlock as { thinking: string }).thinking +=
|
||||
delta.thinking
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -2298,7 +2317,10 @@ async function* queryModel(
|
||||
}
|
||||
|
||||
// Update cost
|
||||
const costUSDForPart = calculateUSDCost(resolvedModel, usage as unknown as BetaUsage)
|
||||
const costUSDForPart = calculateUSDCost(
|
||||
resolvedModel,
|
||||
usage as unknown as BetaUsage,
|
||||
)
|
||||
costUSD += addToTotalSessionCost(
|
||||
costUSDForPart,
|
||||
usage as unknown as BetaUsage,
|
||||
@@ -2887,10 +2909,14 @@ async function* queryModel(
|
||||
// message_delta handler before any yield. Fallback pushes to newMessages
|
||||
// then yields, so tracking must be here to survive .return() at the yield.
|
||||
if (fallbackMessage) {
|
||||
const fallbackUsage = fallbackMessage.message.usage as BetaMessageDeltaUsage
|
||||
const fallbackUsage = fallbackMessage.message
|
||||
.usage as BetaMessageDeltaUsage
|
||||
usage = updateUsage(EMPTY_USAGE, fallbackUsage)
|
||||
stopReason = fallbackMessage.message.stop_reason as BetaStopReason
|
||||
const fallbackCost = calculateUSDCost(resolvedModel, fallbackUsage as unknown as BetaUsage)
|
||||
const fallbackCost = calculateUSDCost(
|
||||
resolvedModel,
|
||||
fallbackUsage as unknown as BetaUsage,
|
||||
)
|
||||
costUSD += addToTotalSessionCost(
|
||||
fallbackCost,
|
||||
fallbackUsage as unknown as BetaUsage,
|
||||
@@ -2946,7 +2972,9 @@ async function* queryModel(
|
||||
void options.getToolPermissionContext().then(permissionContext => {
|
||||
logAPISuccessAndDuration({
|
||||
model:
|
||||
(newMessages[0]?.message.model as string | undefined) ?? partialMessage?.model ?? options.model,
|
||||
(newMessages[0]?.message.model as string | undefined) ??
|
||||
partialMessage?.model ??
|
||||
options.model,
|
||||
preNormalizedModel: options.model,
|
||||
usage,
|
||||
start,
|
||||
|
||||
@@ -19,9 +19,20 @@ import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
||||
import type { ThinkingConfig } from '../../../utils/thinking.js'
|
||||
import type { Options } from '../claude.js'
|
||||
import { recordLLMObservation } from '../../../services/langfuse/tracing.js'
|
||||
import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../../../services/langfuse/convert.js'
|
||||
import {
|
||||
convertMessagesToLangfuse,
|
||||
convertOutputToLangfuse,
|
||||
convertToolsToLangfuse,
|
||||
} from '../../../services/langfuse/convert.js'
|
||||
import { streamGeminiGenerateContent } from './client.js'
|
||||
import { anthropicMessagesToGemini, resolveGeminiModel, adaptGeminiStreamToAnthropic, anthropicToolsToGemini, anthropicToolChoiceToGemini, GEMINI_THOUGHT_SIGNATURE_FIELD } from '@ant/model-provider'
|
||||
import {
|
||||
anthropicMessagesToGemini,
|
||||
resolveGeminiModel,
|
||||
adaptGeminiStreamToAnthropic,
|
||||
anthropicToolsToGemini,
|
||||
anthropicToolChoiceToGemini,
|
||||
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||
} from '@ant/model-provider'
|
||||
|
||||
export async function* queryModelGemini(
|
||||
messages: Message[],
|
||||
@@ -209,7 +220,9 @@ export async function* queryModelGemini(
|
||||
yield createAssistantAPIErrorMessage({
|
||||
content: `API Error: ${errorMessage}`,
|
||||
apiError: 'api_error',
|
||||
error: (error instanceof Error ? error : new Error(String(error))) as unknown as SDKAssistantMessageError,
|
||||
error: (error instanceof Error
|
||||
? error
|
||||
: new Error(String(error))) as unknown as SDKAssistantMessageError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test, beforeEach, afterEach, mock } from 'bun:test'
|
||||
|
||||
// Defensive: agent.test.ts can corrupt Bun's src/* path alias at runtime.
|
||||
mock.module('src/utils/proxy.js', () => ({
|
||||
getProxyFetchOptions: () => ({} as any),
|
||||
getProxyFetchOptions: () => ({}) as any,
|
||||
}))
|
||||
|
||||
import { getGrokClient, clearGrokClientCache } from '../client.js'
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
||||
import type { Message, StreamEvent, SystemAPIErrorMessage, AssistantMessage } from '../../../types/message.js'
|
||||
import type {
|
||||
Message,
|
||||
StreamEvent,
|
||||
SystemAPIErrorMessage,
|
||||
AssistantMessage,
|
||||
} from '../../../types/message.js'
|
||||
import type { Tools } from '../../../Tool.js'
|
||||
import type {
|
||||
ChatCompletionChunk,
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
} from 'openai/resources/chat/completions/completions.mjs'
|
||||
import { getGrokClient } from './client.js'
|
||||
import { anthropicMessagesToOpenAI, anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI, adaptOpenAIStreamToAnthropic, resolveGrokModel } from '@ant/model-provider'
|
||||
import {
|
||||
anthropicMessagesToOpenAI,
|
||||
anthropicToolsToOpenAI,
|
||||
anthropicToolChoiceToOpenAI,
|
||||
adaptOpenAIStreamToAnthropic,
|
||||
resolveGrokModel,
|
||||
} from '@ant/model-provider'
|
||||
import { normalizeMessagesForAPI } from '../../../utils/messages.js'
|
||||
import type { SDKAssistantMessageError } from '../../../entrypoints/agentSdkTypes.js'
|
||||
import { toolToAPISchema } from '../../../utils/api.js'
|
||||
@@ -15,7 +26,11 @@ import { logForDebugging } from '../../../utils/debug.js'
|
||||
import { addToTotalSessionCost } from '../../../cost-tracker.js'
|
||||
import { calculateUSDCost } from '../../../utils/modelCost.js'
|
||||
import { recordLLMObservation } from '../../../services/langfuse/tracing.js'
|
||||
import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../../../services/langfuse/convert.js'
|
||||
import {
|
||||
convertMessagesToLangfuse,
|
||||
convertOutputToLangfuse,
|
||||
convertToolsToLangfuse,
|
||||
} from '../../../services/langfuse/convert.js'
|
||||
import type { Options } from '../claude.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import {
|
||||
@@ -56,11 +71,16 @@ export async function* queryModelGrok(
|
||||
const standardTools = toolSchemas.filter(
|
||||
(t): t is BetaToolUnion & { type: string } => {
|
||||
const anyT = t as unknown as Record<string, unknown>
|
||||
return anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124'
|
||||
return (
|
||||
anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124'
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt)
|
||||
const openaiMessages = anthropicMessagesToOpenAI(
|
||||
messagesForAPI,
|
||||
systemPrompt,
|
||||
)
|
||||
const openaiTools = anthropicToolsToOpenAI(standardTools)
|
||||
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
|
||||
|
||||
@@ -70,7 +90,9 @@ export async function* queryModelGrok(
|
||||
source: options.querySource,
|
||||
})
|
||||
|
||||
logForDebugging(`[Grok] Calling model=${grokModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`)
|
||||
logForDebugging(
|
||||
`[Grok] Calling model=${grokModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`,
|
||||
)
|
||||
|
||||
const stream = await client.chat.completions.create(
|
||||
{
|
||||
@@ -91,7 +113,10 @@ export async function* queryModelGrok(
|
||||
},
|
||||
)
|
||||
|
||||
const adaptedStream = adaptOpenAIStreamToAnthropic(stream as AsyncIterable<ChatCompletionChunk>, grokModel)
|
||||
const adaptedStream = adaptOpenAIStreamToAnthropic(
|
||||
stream as AsyncIterable<ChatCompletionChunk>,
|
||||
grokModel,
|
||||
)
|
||||
|
||||
const contentBlocks: Record<number, any> = {}
|
||||
const collectedMessages: AssistantMessage[] = []
|
||||
@@ -111,7 +136,7 @@ export async function* queryModelGrok(
|
||||
partialMessage = (event as any).message
|
||||
ttftMs = Date.now() - start
|
||||
if ((event as any).message?.usage) {
|
||||
usage = { ...usage, ...((event as any).message.usage) }
|
||||
usage = { ...usage, ...(event as any).message.usage }
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -175,7 +200,10 @@ export async function* queryModelGrok(
|
||||
break
|
||||
}
|
||||
|
||||
if (event.type === 'message_stop' && usage.input_tokens + usage.output_tokens > 0) {
|
||||
if (
|
||||
event.type === 'message_stop' &&
|
||||
usage.input_tokens + usage.output_tokens > 0
|
||||
) {
|
||||
const costUSD = calculateUSDCost(grokModel, usage as any)
|
||||
addToTotalSessionCost(costUSD, usage as any, options.model)
|
||||
}
|
||||
@@ -210,7 +238,9 @@ export async function* queryModelGrok(
|
||||
yield createAssistantAPIErrorMessage({
|
||||
content: `API Error: ${errorMessage}`,
|
||||
apiError: 'api_error',
|
||||
error: (error instanceof Error ? error : new Error(String(error))) as unknown as SDKAssistantMessageError,
|
||||
error: (error instanceof Error
|
||||
? error
|
||||
: new Error(String(error))) as unknown as SDKAssistantMessageError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +195,8 @@ export function logAPIQuery({
|
||||
previousRequestId?: string | null
|
||||
}): void {
|
||||
const thinkingType = thinkingConfig?.type ?? 'disabled'
|
||||
const thinkingBudgetTokens = thinkingConfig?.type === 'enabled' ? thinkingConfig.budgetTokens : undefined
|
||||
const thinkingBudgetTokens =
|
||||
thinkingConfig?.type === 'enabled' ? thinkingConfig.budgetTokens : undefined
|
||||
logEvent('tengu_api_query', {
|
||||
model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
messagesLength,
|
||||
@@ -662,7 +663,9 @@ export function logAPISuccessAndDuration({
|
||||
let connectorCount = 0
|
||||
|
||||
for (const msg of newMessages) {
|
||||
const contentArr = Array.isArray(msg.message.content) ? msg.message.content : []
|
||||
const contentArr = Array.isArray(msg.message.content)
|
||||
? msg.message.content
|
||||
: []
|
||||
for (const block of contentArr) {
|
||||
if (typeof block === 'string') continue
|
||||
if (block.type === 'text') {
|
||||
@@ -670,14 +673,19 @@ export function logAPISuccessAndDuration({
|
||||
} else if (feature('CONNECTOR_TEXT') && isConnectorTextBlock(block)) {
|
||||
connectorCount++
|
||||
} else if (block.type === 'thinking') {
|
||||
thinkingLen += (block as { type: 'thinking'; thinking: string }).thinking.length
|
||||
thinkingLen += (block as { type: 'thinking'; thinking: string })
|
||||
.thinking.length
|
||||
} else if (
|
||||
block.type === 'tool_use' ||
|
||||
block.type === 'server_tool_use' ||
|
||||
(block.type as string) === 'mcp_tool_use'
|
||||
) {
|
||||
const inputLen = jsonStringify((block as { input: unknown }).input).length
|
||||
const sanitizedName = sanitizeToolNameForAnalytics((block as { name: string }).name)
|
||||
const inputLen = jsonStringify(
|
||||
(block as { input: unknown }).input,
|
||||
).length
|
||||
const sanitizedName = sanitizeToolNameForAnalytics(
|
||||
(block as { name: string }).name,
|
||||
)
|
||||
toolLengths[sanitizedName] =
|
||||
(toolLengths[sanitizedName] ?? 0) + inputLen
|
||||
hasToolUse = true
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { isOpenAIThinkingEnabled, buildOpenAIRequestBody } from '../requestBody.js'
|
||||
import {
|
||||
isOpenAIThinkingEnabled,
|
||||
buildOpenAIRequestBody,
|
||||
} from '../requestBody.js'
|
||||
|
||||
describe('isOpenAIThinkingEnabled', () => {
|
||||
const originalEnv = {
|
||||
@@ -81,7 +84,9 @@ describe('isOpenAIThinkingEnabled', () => {
|
||||
})
|
||||
|
||||
test('returns true when model name is namespaced for deepseek-reasoner', () => {
|
||||
expect(isOpenAIThinkingEnabled('TokenService/deepseek-reasoner')).toBe(true)
|
||||
expect(isOpenAIThinkingEnabled('TokenService/deepseek-reasoner')).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('returns true when model name is "deepseek-v3.2"', () => {
|
||||
@@ -185,14 +190,20 @@ describe('buildOpenAIRequestBody — thinking params', () => {
|
||||
})
|
||||
|
||||
test('does NOT include thinking params when disabled', () => {
|
||||
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false })
|
||||
const body = buildOpenAIRequestBody({
|
||||
...baseParams,
|
||||
enableThinking: false,
|
||||
})
|
||||
expect(body.thinking).toBeUndefined()
|
||||
expect(body.enable_thinking).toBeUndefined()
|
||||
expect(body.chat_template_kwargs).toBeUndefined()
|
||||
})
|
||||
|
||||
test('always includes stream and stream_options', () => {
|
||||
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false })
|
||||
const body = buildOpenAIRequestBody({
|
||||
...baseParams,
|
||||
enableThinking: false,
|
||||
})
|
||||
expect(body.stream).toBe(true)
|
||||
expect(body.stream_options).toEqual({ include_usage: true })
|
||||
})
|
||||
@@ -216,7 +227,10 @@ describe('buildOpenAIRequestBody — thinking params', () => {
|
||||
})
|
||||
|
||||
test('excludes temperature when thinking is off and no override', () => {
|
||||
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false })
|
||||
const body = buildOpenAIRequestBody({
|
||||
...baseParams,
|
||||
enableThinking: false,
|
||||
})
|
||||
expect(body.temperature).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -232,8 +246,11 @@ describe('buildOpenAIRequestBody — thinking params', () => {
|
||||
})
|
||||
|
||||
test('excludes tools when empty', () => {
|
||||
const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false })
|
||||
const body = buildOpenAIRequestBody({
|
||||
...baseParams,
|
||||
enableThinking: false,
|
||||
})
|
||||
expect(body.tools).toBeUndefined()
|
||||
expect(body.tool_choice).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,8 +56,12 @@ export function getOpenAIClient(options?: {
|
||||
maxRetries: options?.maxRetries ?? 0,
|
||||
timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10),
|
||||
dangerouslyAllowBrowser: true,
|
||||
...(process.env.OPENAI_ORG_ID && { organization: process.env.OPENAI_ORG_ID }),
|
||||
...(process.env.OPENAI_PROJECT_ID && { project: process.env.OPENAI_PROJECT_ID }),
|
||||
...(process.env.OPENAI_ORG_ID && {
|
||||
organization: process.env.OPENAI_ORG_ID,
|
||||
}),
|
||||
...(process.env.OPENAI_PROJECT_ID && {
|
||||
project: process.env.OPENAI_PROJECT_ID,
|
||||
}),
|
||||
fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }),
|
||||
fetch: wrappedFetch,
|
||||
})
|
||||
|
||||
@@ -10,11 +10,15 @@ import type {
|
||||
import type { AgentId } from '../../../types/ids.js'
|
||||
import type { Tools } from '../../../Tool.js'
|
||||
import type { Stream } from 'openai/streaming.mjs'
|
||||
import type {
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
} from 'openai/resources/chat/completions/completions.mjs'
|
||||
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions/completions.mjs'
|
||||
import { getOpenAIClient } from './client.js'
|
||||
import { anthropicMessagesToOpenAI, resolveOpenAIModel, adaptOpenAIStreamToAnthropic, anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '@ant/model-provider'
|
||||
import {
|
||||
anthropicMessagesToOpenAI,
|
||||
resolveOpenAIModel,
|
||||
adaptOpenAIStreamToAnthropic,
|
||||
anthropicToolsToOpenAI,
|
||||
anthropicToolChoiceToOpenAI,
|
||||
} from '@ant/model-provider'
|
||||
import { normalizeMessagesForAPI } from '../../../utils/messages.js'
|
||||
import { toolToAPISchema } from '../../../utils/api.js'
|
||||
import {
|
||||
@@ -24,10 +28,22 @@ import {
|
||||
import { logForDebugging } from '../../../utils/debug.js'
|
||||
import { addToTotalSessionCost } from '../../../cost-tracker.js'
|
||||
import { calculateUSDCost } from '../../../utils/modelCost.js'
|
||||
import { isOpenAIThinkingEnabled, resolveOpenAIMaxTokens, buildOpenAIRequestBody } from './requestBody.js'
|
||||
import {
|
||||
isOpenAIThinkingEnabled,
|
||||
resolveOpenAIMaxTokens,
|
||||
buildOpenAIRequestBody,
|
||||
} from './requestBody.js'
|
||||
import { recordLLMObservation } from '../../../services/langfuse/tracing.js'
|
||||
import { convertMessagesToLangfuse, convertOutputToLangfuse, convertToolsToLangfuse } from '../../../services/langfuse/convert.js'
|
||||
export { isOpenAIThinkingEnabled, resolveOpenAIMaxTokens, buildOpenAIRequestBody }
|
||||
import {
|
||||
convertMessagesToLangfuse,
|
||||
convertOutputToLangfuse,
|
||||
convertToolsToLangfuse,
|
||||
} from '../../../services/langfuse/convert.js'
|
||||
export {
|
||||
isOpenAIThinkingEnabled,
|
||||
resolveOpenAIMaxTokens,
|
||||
buildOpenAIRequestBody,
|
||||
}
|
||||
import { getModelMaxOutputTokens } from '../../../utils/context.js'
|
||||
import type { Options } from '../claude.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
@@ -81,7 +97,9 @@ function prependDeferredToolListIfNeeded(
|
||||
]
|
||||
}
|
||||
|
||||
function isOpenAIConvertibleMessage(msg: Message): msg is AssistantMessage | UserMessage {
|
||||
function isOpenAIConvertibleMessage(
|
||||
msg: Message,
|
||||
): msg is AssistantMessage | UserMessage {
|
||||
return msg.type === 'assistant' || msg.type === 'user'
|
||||
}
|
||||
|
||||
@@ -95,11 +113,24 @@ function assembleFinalAssistantOutputs(params: {
|
||||
contentBlocks: Record<number, any>
|
||||
tools: Tools
|
||||
agentId: string | undefined
|
||||
usage: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; cache_read_input_tokens: number }
|
||||
usage: {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_read_input_tokens: number
|
||||
}
|
||||
stopReason: string | null
|
||||
maxTokens: number
|
||||
}): (AssistantMessage | SystemAPIErrorMessage)[] {
|
||||
const { partialMessage, contentBlocks, tools, agentId, usage, stopReason, maxTokens } = params
|
||||
const {
|
||||
partialMessage,
|
||||
contentBlocks,
|
||||
tools,
|
||||
agentId,
|
||||
usage,
|
||||
stopReason,
|
||||
maxTokens,
|
||||
} = params
|
||||
const outputs: (AssistantMessage | SystemAPIErrorMessage)[] = []
|
||||
|
||||
const allBlocks = Object.keys(contentBlocks)
|
||||
@@ -111,7 +142,11 @@ function assembleFinalAssistantOutputs(params: {
|
||||
outputs.push({
|
||||
message: {
|
||||
...partialMessage,
|
||||
content: normalizeContentFromAPI(allBlocks, tools, agentId as AgentId | undefined),
|
||||
content: normalizeContentFromAPI(
|
||||
allBlocks,
|
||||
tools,
|
||||
agentId as AgentId | undefined,
|
||||
),
|
||||
usage,
|
||||
stop_reason: stopReason,
|
||||
stop_sequence: null,
|
||||
@@ -124,12 +159,15 @@ function assembleFinalAssistantOutputs(params: {
|
||||
}
|
||||
|
||||
if (stopReason === 'max_tokens') {
|
||||
outputs.push(createAssistantAPIErrorMessage({
|
||||
content: `Output truncated: response exceeded the ${maxTokens} token limit. ` +
|
||||
`Set OPENAI_MAX_TOKENS or CLAUDE_CODE_MAX_OUTPUT_TOKENS to override.`,
|
||||
apiError: 'max_output_tokens',
|
||||
error: 'max_output_tokens',
|
||||
}))
|
||||
outputs.push(
|
||||
createAssistantAPIErrorMessage({
|
||||
content:
|
||||
`Output truncated: response exceeded the ${maxTokens} token limit. ` +
|
||||
`Set OPENAI_MAX_TOKENS or CLAUDE_CODE_MAX_OUTPUT_TOKENS to override.`,
|
||||
apiError: 'max_output_tokens',
|
||||
error: 'max_output_tokens',
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return outputs
|
||||
@@ -217,7 +255,9 @@ export async function* queryModelOpenAI(
|
||||
|
||||
// 8. Convert messages and tools to OpenAI format
|
||||
const enableThinking = isOpenAIThinkingEnabled(openaiModel)
|
||||
const openAIConvertibleMessages = messagesForAPI.filter(isOpenAIConvertibleMessage)
|
||||
const openAIConvertibleMessages = messagesForAPI.filter(
|
||||
isOpenAIConvertibleMessage,
|
||||
)
|
||||
const messagesWithDeferredToolList = prependDeferredToolListIfNeeded(
|
||||
openAIConvertibleMessages,
|
||||
tools,
|
||||
@@ -264,7 +304,10 @@ export async function* queryModelOpenAI(
|
||||
// 3. CLAUDE_CODE_MAX_OUTPUT_TOKENS env var (generic override)
|
||||
// 4. upperLimit default (64000)
|
||||
const { upperLimit } = getModelMaxOutputTokens(openaiModel)
|
||||
const maxTokens = resolveOpenAIMaxTokens(upperLimit, options.maxOutputTokensOverride)
|
||||
const maxTokens = resolveOpenAIMaxTokens(
|
||||
upperLimit,
|
||||
options.maxOutputTokensOverride,
|
||||
)
|
||||
|
||||
// 11. Get client
|
||||
const client = getOpenAIClient({
|
||||
@@ -287,10 +330,7 @@ export async function* queryModelOpenAI(
|
||||
maxTokens,
|
||||
temperatureOverride: options.temperatureOverride,
|
||||
})
|
||||
const stream = await client.chat.completions.create(
|
||||
requestBody,
|
||||
{ signal },
|
||||
)
|
||||
const stream = await client.chat.completions.create(requestBody, { signal })
|
||||
|
||||
// 12. Convert OpenAI stream to Anthropic events, then process into
|
||||
// AssistantMessage + StreamEvent (matching the Anthropic path behavior)
|
||||
@@ -373,8 +413,13 @@ export async function* queryModelOpenAI(
|
||||
// here and injected so tokenCountWithEstimation() can read it.
|
||||
if (partialMessage) {
|
||||
for (const output of assembleFinalAssistantOutputs({
|
||||
partialMessage, contentBlocks, tools, agentId: options.agentId,
|
||||
usage, stopReason, maxTokens,
|
||||
partialMessage,
|
||||
contentBlocks,
|
||||
tools,
|
||||
agentId: options.agentId,
|
||||
usage,
|
||||
stopReason,
|
||||
maxTokens,
|
||||
})) {
|
||||
if (output.type === 'assistant') {
|
||||
collectedMessages.push(output)
|
||||
@@ -424,8 +469,13 @@ export async function* queryModelOpenAI(
|
||||
// Safety: if stream ended without message_stop, assemble and yield whatever we have
|
||||
if (partialMessage) {
|
||||
for (const output of assembleFinalAssistantOutputs({
|
||||
partialMessage, contentBlocks, tools, agentId: options.agentId,
|
||||
usage, stopReason, maxTokens,
|
||||
partialMessage,
|
||||
contentBlocks,
|
||||
tools,
|
||||
agentId: options.agentId,
|
||||
usage,
|
||||
stopReason,
|
||||
maxTokens,
|
||||
})) {
|
||||
yield output
|
||||
}
|
||||
@@ -436,7 +486,9 @@ export async function* queryModelOpenAI(
|
||||
yield createAssistantAPIErrorMessage({
|
||||
content: `API Error: ${errorMessage}`,
|
||||
apiError: 'api_error',
|
||||
error: (error instanceof Error ? error : new Error(String(error))) as unknown as SDKAssistantMessageError,
|
||||
error: (error instanceof Error
|
||||
? error
|
||||
: new Error(String(error))) as unknown as SDKAssistantMessageError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
* thinking mode. Extracted from index.ts so tests can import them without
|
||||
* triggering heavy module side-effects (OpenAI client, stream adapter, etc.).
|
||||
*/
|
||||
import type {
|
||||
ChatCompletionCreateParamsStreaming,
|
||||
} from 'openai/resources/chat/completions/completions.mjs'
|
||||
import type { ChatCompletionCreateParamsStreaming } from 'openai/resources/chat/completions/completions.mjs'
|
||||
import { isEnvTruthy, isEnvDefinedFalsy } from '../../../utils/envUtils.js'
|
||||
|
||||
/**
|
||||
@@ -44,10 +42,16 @@ export function resolveOpenAIMaxTokens(
|
||||
upperLimit: number,
|
||||
maxOutputTokensOverride?: number,
|
||||
): number {
|
||||
return maxOutputTokensOverride
|
||||
?? (process.env.OPENAI_MAX_TOKENS ? parseInt(process.env.OPENAI_MAX_TOKENS, 10) || undefined : undefined)
|
||||
?? (process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS ? parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10) || undefined : undefined)
|
||||
?? upperLimit
|
||||
return (
|
||||
maxOutputTokensOverride ??
|
||||
(process.env.OPENAI_MAX_TOKENS
|
||||
? parseInt(process.env.OPENAI_MAX_TOKENS, 10) || undefined
|
||||
: undefined) ??
|
||||
(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||
? parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 10) || undefined
|
||||
: undefined) ??
|
||||
upperLimit
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +78,15 @@ export function buildOpenAIRequestBody(params: {
|
||||
enable_thinking?: boolean
|
||||
chat_template_kwargs?: { thinking: boolean }
|
||||
} {
|
||||
const { model, messages, tools, toolChoice, enableThinking, maxTokens, temperatureOverride } = params
|
||||
const {
|
||||
model,
|
||||
messages,
|
||||
tools,
|
||||
toolChoice,
|
||||
enableThinking,
|
||||
maxTokens,
|
||||
temperatureOverride,
|
||||
} = params
|
||||
return {
|
||||
model,
|
||||
messages,
|
||||
@@ -96,8 +108,9 @@ export function buildOpenAIRequestBody(params: {
|
||||
}),
|
||||
// Only send temperature when thinking mode is off (DeepSeek ignores it anyway,
|
||||
// but other providers may respect it)
|
||||
...(!enableThinking && temperatureOverride !== undefined && {
|
||||
temperature: temperatureOverride,
|
||||
}),
|
||||
...(!enableThinking &&
|
||||
temperatureOverride !== undefined && {
|
||||
temperature: temperatureOverride,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,8 @@ export async function checkResponseForCacheBreak(
|
||||
// assistant message timestamp in the messages array (before the current response)
|
||||
const lastAssistantMessage = messages.findLast(m => m.type === 'assistant')
|
||||
const timeSinceLastAssistantMsg = lastAssistantMessage
|
||||
? Date.now() - new Date(lastAssistantMessage.timestamp as string | number).getTime()
|
||||
? Date.now() -
|
||||
new Date(lastAssistantMessage.timestamp as string | number).getTime()
|
||||
: null
|
||||
|
||||
// Skip the first call — no previous value to compare against
|
||||
|
||||
@@ -539,10 +539,7 @@ export function getRetryDelay(
|
||||
}
|
||||
}
|
||||
|
||||
const baseDelay = Math.min(
|
||||
BASE_DELAY_MS * 2 ** (attempt - 1),
|
||||
maxDelayMs,
|
||||
)
|
||||
const baseDelay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), maxDelayMs)
|
||||
const jitter = Math.random() * 0.25 * baseDelay
|
||||
return baseDelay + jitter
|
||||
}
|
||||
|
||||
@@ -1,121 +1,115 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { groupMessagesByApiRound } from "../grouping";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { groupMessagesByApiRound } from '../grouping'
|
||||
|
||||
function makeMsg(type: "user" | "assistant" | "system", id: string): any {
|
||||
function makeMsg(type: 'user' | 'assistant' | 'system', id: string): any {
|
||||
return {
|
||||
type,
|
||||
message: { id, content: `${type}-${id}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("groupMessagesByApiRound", () => {
|
||||
describe('groupMessagesByApiRound', () => {
|
||||
// Boundary fires when: assistant msg with NEW id AND current group has items
|
||||
test("splits before first assistant if user messages precede it", () => {
|
||||
const messages = [makeMsg("user", "u1"), makeMsg("assistant", "a1")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
test('splits before first assistant if user messages precede it', () => {
|
||||
const messages = [makeMsg('user', 'u1'), makeMsg('assistant', 'a1')]
|
||||
const groups = groupMessagesByApiRound(messages)
|
||||
// user msgs form group 1, assistant starts group 2
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]).toHaveLength(1);
|
||||
expect(groups[1]).toHaveLength(1);
|
||||
});
|
||||
expect(groups).toHaveLength(2)
|
||||
expect(groups[0]).toHaveLength(1)
|
||||
expect(groups[1]).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("single assistant message forms one group", () => {
|
||||
const messages = [makeMsg("assistant", "a1")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
});
|
||||
test('single assistant message forms one group', () => {
|
||||
const messages = [makeMsg('assistant', 'a1')]
|
||||
const groups = groupMessagesByApiRound(messages)
|
||||
expect(groups).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("splits at new assistant message ID", () => {
|
||||
test('splits at new assistant message ID', () => {
|
||||
const messages = [
|
||||
makeMsg("user", "u1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a2"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(3);
|
||||
});
|
||||
makeMsg('user', 'u1'),
|
||||
makeMsg('assistant', 'a1'),
|
||||
makeMsg('assistant', 'a2'),
|
||||
]
|
||||
const groups = groupMessagesByApiRound(messages)
|
||||
expect(groups).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("keeps same-ID assistant messages in same group (streaming chunks)", () => {
|
||||
test('keeps same-ID assistant messages in same group (streaming chunks)', () => {
|
||||
const messages = [
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toHaveLength(3);
|
||||
});
|
||||
makeMsg('assistant', 'a1'),
|
||||
makeMsg('assistant', 'a1'),
|
||||
makeMsg('assistant', 'a1'),
|
||||
]
|
||||
const groups = groupMessagesByApiRound(messages)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(groupMessagesByApiRound([])).toEqual([]);
|
||||
});
|
||||
test('returns empty array for empty input', () => {
|
||||
expect(groupMessagesByApiRound([])).toEqual([])
|
||||
})
|
||||
|
||||
test("handles all user messages (no assistant)", () => {
|
||||
const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
});
|
||||
test('handles all user messages (no assistant)', () => {
|
||||
const messages = [makeMsg('user', 'u1'), makeMsg('user', 'u2')]
|
||||
const groups = groupMessagesByApiRound(messages)
|
||||
expect(groups).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("three API rounds produce correct groups", () => {
|
||||
test('three API rounds produce correct groups', () => {
|
||||
const messages = [
|
||||
makeMsg("user", "u1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("user", "u2"),
|
||||
makeMsg("assistant", "a2"),
|
||||
makeMsg("user", "u3"),
|
||||
makeMsg("assistant", "a3"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
makeMsg('user', 'u1'),
|
||||
makeMsg('assistant', 'a1'),
|
||||
makeMsg('user', 'u2'),
|
||||
makeMsg('assistant', 'a2'),
|
||||
makeMsg('user', 'u3'),
|
||||
makeMsg('assistant', 'a3'),
|
||||
]
|
||||
const groups = groupMessagesByApiRound(messages)
|
||||
// [u1], [a1, u2], [a2, u3], [a3] = 4 groups
|
||||
expect(groups).toHaveLength(4);
|
||||
});
|
||||
expect(groups).toHaveLength(4)
|
||||
})
|
||||
|
||||
test("consecutive user messages stay in same group", () => {
|
||||
const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")];
|
||||
expect(groupMessagesByApiRound(messages)).toHaveLength(1);
|
||||
});
|
||||
test('consecutive user messages stay in same group', () => {
|
||||
const messages = [makeMsg('user', 'u1'), makeMsg('user', 'u2')]
|
||||
expect(groupMessagesByApiRound(messages)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("does not produce empty groups", () => {
|
||||
const messages = [
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("assistant", "a2"),
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
test('does not produce empty groups', () => {
|
||||
const messages = [makeMsg('assistant', 'a1'), makeMsg('assistant', 'a2')]
|
||||
const groups = groupMessagesByApiRound(messages)
|
||||
for (const group of groups) {
|
||||
expect(group.length).toBeGreaterThan(0);
|
||||
expect(group.length).toBeGreaterThan(0)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("handles single message", () => {
|
||||
expect(groupMessagesByApiRound([makeMsg("user", "u1")])).toHaveLength(1);
|
||||
});
|
||||
test('handles single message', () => {
|
||||
expect(groupMessagesByApiRound([makeMsg('user', 'u1')])).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("preserves message order within groups", () => {
|
||||
const messages = [makeMsg("assistant", "a1"), makeMsg("user", "u2")];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups[0]![0]!.message!.id).toBe("a1");
|
||||
expect(groups[0]![1]!.message!.id).toBe("u2");
|
||||
});
|
||||
test('preserves message order within groups', () => {
|
||||
const messages = [makeMsg('assistant', 'a1'), makeMsg('user', 'u2')]
|
||||
const groups = groupMessagesByApiRound(messages)
|
||||
expect(groups[0]![0]!.message!.id).toBe('a1')
|
||||
expect(groups[0]![1]!.message!.id).toBe('u2')
|
||||
})
|
||||
|
||||
test("handles system messages", () => {
|
||||
const messages = [
|
||||
makeMsg("system", "s1"),
|
||||
makeMsg("assistant", "a1"),
|
||||
];
|
||||
test('handles system messages', () => {
|
||||
const messages = [makeMsg('system', 's1'), makeMsg('assistant', 'a1')]
|
||||
// system msg is non-assistant, goes to current. Then assistant a1 is new ID
|
||||
// and current has items, so split.
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
const groups = groupMessagesByApiRound(messages)
|
||||
expect(groups).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("tool_result after assistant stays in same round", () => {
|
||||
test('tool_result after assistant stays in same round', () => {
|
||||
const messages = [
|
||||
makeMsg("assistant", "a1"),
|
||||
makeMsg("user", "tool_result_1"),
|
||||
makeMsg("assistant", "a1"), // same ID = no new boundary
|
||||
];
|
||||
const groups = groupMessagesByApiRound(messages);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
makeMsg('assistant', 'a1'),
|
||||
makeMsg('user', 'tool_result_1'),
|
||||
makeMsg('assistant', 'a1'), // same ID = no new boundary
|
||||
]
|
||||
const groups = groupMessagesByApiRound(messages)
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,77 +1,80 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { mock, describe, expect, test } from 'bun:test'
|
||||
|
||||
mock.module("bun:bundle", () => ({ feature: () => false }));
|
||||
mock.module('bun:bundle', () => ({ feature: () => false }))
|
||||
|
||||
const { formatCompactSummary } = await import("../prompt");
|
||||
const { formatCompactSummary } = await import('../prompt')
|
||||
|
||||
describe("formatCompactSummary", () => {
|
||||
test("strips <analysis>...</analysis> block", () => {
|
||||
const input = "<analysis>my thought process</analysis>\n<summary>the summary</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("<analysis>");
|
||||
expect(result).not.toContain("my thought process");
|
||||
});
|
||||
describe('formatCompactSummary', () => {
|
||||
test('strips <analysis>...</analysis> block', () => {
|
||||
const input =
|
||||
'<analysis>my thought process</analysis>\n<summary>the summary</summary>'
|
||||
const result = formatCompactSummary(input)
|
||||
expect(result).not.toContain('<analysis>')
|
||||
expect(result).not.toContain('my thought process')
|
||||
})
|
||||
|
||||
test("replaces <summary>...</summary> with 'Summary:\\n' prefix", () => {
|
||||
const input = "<summary>key points here</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toContain("Summary:");
|
||||
expect(result).toContain("key points here");
|
||||
expect(result).not.toContain("<summary>");
|
||||
});
|
||||
const input = '<summary>key points here</summary>'
|
||||
const result = formatCompactSummary(input)
|
||||
expect(result).toContain('Summary:')
|
||||
expect(result).toContain('key points here')
|
||||
expect(result).not.toContain('<summary>')
|
||||
})
|
||||
|
||||
test("handles analysis + summary together", () => {
|
||||
const input = "<analysis>thinking</analysis><summary>result</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("thinking");
|
||||
expect(result).toContain("result");
|
||||
});
|
||||
test('handles analysis + summary together', () => {
|
||||
const input = '<analysis>thinking</analysis><summary>result</summary>'
|
||||
const result = formatCompactSummary(input)
|
||||
expect(result).not.toContain('thinking')
|
||||
expect(result).toContain('result')
|
||||
})
|
||||
|
||||
test("handles summary without analysis", () => {
|
||||
const input = "<summary>just the summary</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toContain("just the summary");
|
||||
});
|
||||
test('handles summary without analysis', () => {
|
||||
const input = '<summary>just the summary</summary>'
|
||||
const result = formatCompactSummary(input)
|
||||
expect(result).toContain('just the summary')
|
||||
})
|
||||
|
||||
test("handles analysis without summary", () => {
|
||||
const input = "<analysis>just analysis</analysis>and some text";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("just analysis");
|
||||
expect(result).toContain("and some text");
|
||||
});
|
||||
test('handles analysis without summary', () => {
|
||||
const input = '<analysis>just analysis</analysis>and some text'
|
||||
const result = formatCompactSummary(input)
|
||||
expect(result).not.toContain('just analysis')
|
||||
expect(result).toContain('and some text')
|
||||
})
|
||||
|
||||
test("collapses multiple newlines to double", () => {
|
||||
const input = "hello\n\n\n\nworld";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toMatch(/\n{3,}/);
|
||||
});
|
||||
test('collapses multiple newlines to double', () => {
|
||||
const input = 'hello\n\n\n\nworld'
|
||||
const result = formatCompactSummary(input)
|
||||
expect(result).not.toMatch(/\n{3,}/)
|
||||
})
|
||||
|
||||
test("trims leading/trailing whitespace", () => {
|
||||
const input = " \n hello \n ";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
test('trims leading/trailing whitespace', () => {
|
||||
const input = ' \n hello \n '
|
||||
const result = formatCompactSummary(input)
|
||||
expect(result).toBe('hello')
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(formatCompactSummary("")).toBe("");
|
||||
});
|
||||
test('handles empty string', () => {
|
||||
expect(formatCompactSummary('')).toBe('')
|
||||
})
|
||||
|
||||
test("handles plain text without tags", () => {
|
||||
const input = "just plain text";
|
||||
expect(formatCompactSummary(input)).toBe("just plain text");
|
||||
});
|
||||
test('handles plain text without tags', () => {
|
||||
const input = 'just plain text'
|
||||
expect(formatCompactSummary(input)).toBe('just plain text')
|
||||
})
|
||||
|
||||
test("handles multiline analysis content", () => {
|
||||
const input = "<analysis>\nline1\nline2\nline3\n</analysis><summary>ok</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).not.toContain("line1");
|
||||
expect(result).toContain("ok");
|
||||
});
|
||||
test('handles multiline analysis content', () => {
|
||||
const input =
|
||||
'<analysis>\nline1\nline2\nline3\n</analysis><summary>ok</summary>'
|
||||
const result = formatCompactSummary(input)
|
||||
expect(result).not.toContain('line1')
|
||||
expect(result).toContain('ok')
|
||||
})
|
||||
|
||||
test("preserves content between analysis and summary", () => {
|
||||
const input = "<analysis>thoughts</analysis>middle text<summary>final</summary>";
|
||||
const result = formatCompactSummary(input);
|
||||
expect(result).toContain("middle text");
|
||||
expect(result).toContain("final");
|
||||
});
|
||||
});
|
||||
test('preserves content between analysis and summary', () => {
|
||||
const input =
|
||||
'<analysis>thoughts</analysis>middle text<summary>final</summary>'
|
||||
const result = formatCompactSummary(input)
|
||||
expect(result).toContain('middle text')
|
||||
expect(result).toContain('final')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -38,10 +38,7 @@ function makeSystemMessage(
|
||||
return msg
|
||||
}
|
||||
|
||||
function makeSnipBoundary(
|
||||
uuid: string,
|
||||
removedUuids: string[],
|
||||
): Message {
|
||||
function makeSnipBoundary(uuid: string, removedUuids: string[]): Message {
|
||||
return makeSystemMessage(uuid, 'snip_boundary', {
|
||||
snipMetadata: { removedUuids },
|
||||
content: '[snip] Conversation history before this point has been snipped.',
|
||||
@@ -126,7 +123,7 @@ describe('snipCompactIfNeeded', () => {
|
||||
|
||||
expect(result.executed).toBe(true)
|
||||
expect(result.messages).toHaveLength(2)
|
||||
expect(result.messages.map((m) => m.uuid) as string[]).toEqual(['c', 'bnd'])
|
||||
expect(result.messages.map(m => m.uuid) as string[]).toEqual(['c', 'bnd'])
|
||||
expect(result.tokensFreed).toBeGreaterThan(0)
|
||||
expect(result.boundaryMessage).toBe(boundary)
|
||||
})
|
||||
@@ -154,7 +151,7 @@ describe('snipCompactIfNeeded', () => {
|
||||
|
||||
expect(result.executed).toBe(true)
|
||||
expect(result.messages).toHaveLength(2)
|
||||
expect(result.messages.map((m) => m.uuid) as string[]).toEqual(['bnd', 'c'])
|
||||
expect(result.messages.map(m => m.uuid) as string[]).toEqual(['bnd', 'c'])
|
||||
})
|
||||
|
||||
test('handles empty removedUuids array', () => {
|
||||
@@ -183,7 +180,12 @@ describe('snipCompactIfNeeded', () => {
|
||||
expect(result.executed).toBe(true)
|
||||
expect(result.boundaryMessage!.uuid as string).toBe('bnd2')
|
||||
// 'b' removed by boundary2, 'a' not in boundary2's removedUuids
|
||||
expect(result.messages.map((m) => m.uuid) as string[]).toEqual(['a', 'bnd1', 'bnd2', 'c'])
|
||||
expect(result.messages.map(m => m.uuid) as string[]).toEqual([
|
||||
'a',
|
||||
'bnd1',
|
||||
'bnd2',
|
||||
'c',
|
||||
])
|
||||
})
|
||||
|
||||
test('respects force option (no functional difference — both execute)', () => {
|
||||
|
||||
@@ -32,10 +32,7 @@ function makeSystemMessage(
|
||||
return msg
|
||||
}
|
||||
|
||||
function makeSnipBoundary(
|
||||
uuid: string,
|
||||
removedUuids: string[],
|
||||
): Message {
|
||||
function makeSnipBoundary(uuid: string, removedUuids: string[]): Message {
|
||||
return makeSystemMessage(uuid, 'snip_boundary', {
|
||||
snipMetadata: { removedUuids },
|
||||
content: '[snip]',
|
||||
@@ -87,7 +84,7 @@ describe('projectSnippedView', () => {
|
||||
const boundary = makeSnipBoundary('bnd', ['a', 'c'])
|
||||
|
||||
const result = projectSnippedView([a, b, c, boundary])
|
||||
expect(result.map((m) => m.uuid) as string[]).toEqual(['b', 'bnd'])
|
||||
expect(result.map(m => m.uuid) as string[]).toEqual(['b', 'bnd'])
|
||||
})
|
||||
|
||||
test('preserves boundary messages themselves', () => {
|
||||
@@ -108,7 +105,12 @@ describe('projectSnippedView', () => {
|
||||
const boundary2 = makeSnipBoundary('bnd2', ['c'])
|
||||
|
||||
const result = projectSnippedView([a, boundary1, b, c, boundary2, d])
|
||||
expect(result.map((m) => m.uuid) as string[]).toEqual(['bnd1', 'b', 'bnd2', 'd'])
|
||||
expect(result.map(m => m.uuid) as string[]).toEqual([
|
||||
'bnd1',
|
||||
'b',
|
||||
'bnd2',
|
||||
'd',
|
||||
])
|
||||
})
|
||||
|
||||
test('returns all messages when boundary has empty removedUuids', () => {
|
||||
@@ -116,7 +118,7 @@ describe('projectSnippedView', () => {
|
||||
const boundary = makeSnipBoundary('bnd', [])
|
||||
|
||||
const result = projectSnippedView([a, boundary])
|
||||
expect(result.map((m) => m.uuid) as string[]).toEqual(['a', 'bnd'])
|
||||
expect(result.map(m => m.uuid) as string[]).toEqual(['a', 'bnd'])
|
||||
})
|
||||
|
||||
test('handles empty message array', () => {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const getCachedMCConfig: () => { enabled?: boolean; systemPromptSuggestSummaries?: boolean; supportedModels?: string[]; [key: string]: unknown } = () => ({});
|
||||
export {}
|
||||
export const getCachedMCConfig: () => {
|
||||
enabled?: boolean
|
||||
systemPromptSuggestSummaries?: boolean
|
||||
supportedModels?: string[]
|
||||
[key: string]: unknown
|
||||
} = () => ({})
|
||||
|
||||
@@ -267,7 +267,9 @@ export function truncateHeadForPTLRetry(
|
||||
let acc = 0
|
||||
dropCount = 0
|
||||
for (const g of groups) {
|
||||
acc += roughTokenCountEstimationForMessages(g as Parameters<typeof roughTokenCountEstimationForMessages>[0])
|
||||
acc += roughTokenCountEstimationForMessages(
|
||||
g as Parameters<typeof roughTokenCountEstimationForMessages>[0],
|
||||
)
|
||||
dropCount++
|
||||
if (acc >= tokenGap) break
|
||||
}
|
||||
@@ -762,7 +764,7 @@ export async function compactConversation(
|
||||
context.setStreamMode?.('requesting')
|
||||
context.setResponseLength?.(() => 0)
|
||||
context.onCompactProgress?.({ type: 'compact_end' })
|
||||
context.setSDKStatus?.("" as SDKStatus)
|
||||
context.setSDKStatus?.('' as SDKStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1105,7 +1107,7 @@ export async function partialCompactConversation(
|
||||
context.setStreamMode?.('requesting')
|
||||
context.setResponseLength?.(() => 0)
|
||||
context.onCompactProgress?.({ type: 'compact_end' })
|
||||
context.setSDKStatus?.("" as SDKStatus)
|
||||
context.setSDKStatus?.('' as SDKStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1333,8 +1335,18 @@ async function streamCompactSummary({
|
||||
let next = await streamIter.next()
|
||||
|
||||
while (!next.done) {
|
||||
const event = next.value as StreamEvent | AssistantMessage | SystemAPIErrorMessage
|
||||
const streamEvent = event as { type: string; event: { type: string; content_block: { type: string }; delta: { type: string; text: string } } }
|
||||
const event = next.value as
|
||||
| StreamEvent
|
||||
| AssistantMessage
|
||||
| SystemAPIErrorMessage
|
||||
const streamEvent = event as {
|
||||
type: string
|
||||
event: {
|
||||
type: string
|
||||
content_block: { type: string }
|
||||
delta: { type: string; text: string }
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!hasStartedStreaming &&
|
||||
|
||||
@@ -436,7 +436,9 @@ export function evaluateTimeBasedTrigger(
|
||||
return null
|
||||
}
|
||||
const gapMinutes =
|
||||
(Date.now() - new Date(lastAssistant.timestamp as string | number).getTime()) / 60_000
|
||||
(Date.now() -
|
||||
new Date(lastAssistant.timestamp as string | number).getTime()) /
|
||||
60_000
|
||||
if (!Number.isFinite(gapMinutes) || gapMinutes < config.gapThresholdMinutes) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export {}
|
||||
|
||||
import type { Message } from 'src/types/message';
|
||||
import type { CompactionResult } from './compact.js';
|
||||
import type { Message } from 'src/types/message'
|
||||
import type { CompactionResult } from './compact.js'
|
||||
|
||||
export const isReactiveOnlyMode: () => boolean = () => false;
|
||||
export const isReactiveOnlyMode: () => boolean = () => false
|
||||
export const reactiveCompactOnPromptTooLong: (
|
||||
messages: Message[],
|
||||
cacheSafeParams: Record<string, unknown>,
|
||||
options: { customInstructions?: string; trigger?: string },
|
||||
) => Promise<{ ok: boolean; reason?: string; result?: CompactionResult }> = async () => ({ ok: false });
|
||||
export const isReactiveCompactEnabled: () => boolean = () => false;
|
||||
export const isWithheldPromptTooLong: (message: Message) => boolean = () => false;
|
||||
export const isWithheldMediaSizeError: (message: Message) => boolean = () => false;
|
||||
) => Promise<{ ok: boolean; reason?: string; result?: CompactionResult }> =
|
||||
async () => ({ ok: false })
|
||||
export const isReactiveCompactEnabled: () => boolean = () => false
|
||||
export const isWithheldPromptTooLong: (message: Message) => boolean = () =>
|
||||
false
|
||||
export const isWithheldMediaSizeError: (message: Message) => boolean = () =>
|
||||
false
|
||||
export const tryReactiveCompact: (params: {
|
||||
hasAttempted: boolean;
|
||||
querySource: string;
|
||||
aborted: boolean;
|
||||
messages: Message[];
|
||||
cacheSafeParams: Record<string, unknown>;
|
||||
}) => Promise<CompactionResult | null> = async () => null;
|
||||
hasAttempted: boolean
|
||||
querySource: string
|
||||
aborted: boolean
|
||||
messages: Message[]
|
||||
cacheSafeParams: Record<string, unknown>
|
||||
}) => Promise<CompactionResult | null> = async () => null
|
||||
|
||||
@@ -135,7 +135,9 @@ async function initSessionMemoryCompactConfig(): Promise<void> {
|
||||
export function hasTextBlocks(message: Message): boolean {
|
||||
if (message.type === 'assistant') {
|
||||
const content = message.message!.content
|
||||
return Array.isArray(content) && content.some(block => block.type === 'text')
|
||||
return (
|
||||
Array.isArray(content) && content.some(block => block.type === 'text')
|
||||
)
|
||||
}
|
||||
if (message.type === 'user') {
|
||||
const content = message.message!.content
|
||||
|
||||
@@ -56,5 +56,5 @@ export function projectSnippedView(messages: Message[]): Message[] {
|
||||
return messages
|
||||
}
|
||||
|
||||
return messages.filter((msg) => !removedSet.has(msg.uuid))
|
||||
return messages.filter(msg => !removedSet.has(msg.uuid))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
import type { Message } from 'src/types/message.js';
|
||||
export const projectView: (messages: Message[]) => Message[] = (messages) => messages;
|
||||
export {}
|
||||
import type { Message } from 'src/types/message.js'
|
||||
export const projectView: (messages: Message[]) => Message[] = messages =>
|
||||
messages
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const restoreFromEntries: (...args: unknown[]) => void = () => {};
|
||||
export {}
|
||||
export const restoreFromEntries: (...args: unknown[]) => void = () => {}
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
|
||||
import { homedir } from 'node:os'
|
||||
import type { ASRResponse } from 'doubaoime-asr'
|
||||
import type { FinalizeSource, VoiceStreamCallbacks, VoiceStreamConnection } from './voiceStreamSTT.js'
|
||||
import type {
|
||||
FinalizeSource,
|
||||
VoiceStreamCallbacks,
|
||||
VoiceStreamConnection,
|
||||
} from './voiceStreamSTT.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
|
||||
@@ -66,7 +70,7 @@ class AudioChunkQueue {
|
||||
if (this.done) {
|
||||
return { value: undefined, done: true }
|
||||
}
|
||||
return new Promise<IteratorResult<Uint8Array>>((resolve) => {
|
||||
return new Promise<IteratorResult<Uint8Array>>(resolve => {
|
||||
this.waiting = resolve
|
||||
})
|
||||
},
|
||||
@@ -108,7 +112,10 @@ export async function connectDoubaoStream(
|
||||
doubaoAsr = await import('doubaoime-asr')
|
||||
} catch {
|
||||
logError(new Error('[doubao-asr] Failed to import doubaoime-asr package'))
|
||||
callbacks.onError('doubaoime-asr package is not installed. Install it with: bun add doubaoime-asr', { fatal: true })
|
||||
callbacks.onError(
|
||||
'doubaoime-asr package is not installed. Install it with: bun add doubaoime-asr',
|
||||
{ fatal: true },
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -119,12 +126,19 @@ export async function connectDoubaoStream(
|
||||
|
||||
// Resolve handle for finalize() promise — wrapped in an object to avoid
|
||||
// TypeScript closure-scope type narrowing issues (TS2349 "not callable").
|
||||
const finalizeHandle: { resolve: ((source: FinalizeSource) => void) | null } = { resolve: null }
|
||||
const finalizeHandle: { resolve: ((source: FinalizeSource) => void) | null } =
|
||||
{ resolve: null }
|
||||
|
||||
const connection: VoiceStreamConnection = {
|
||||
send(audioChunk: Buffer): void {
|
||||
if (finalized) return
|
||||
queue.push(new Uint8Array(audioChunk.buffer, audioChunk.byteOffset, audioChunk.byteLength))
|
||||
queue.push(
|
||||
new Uint8Array(
|
||||
audioChunk.buffer,
|
||||
audioChunk.byteOffset,
|
||||
audioChunk.byteLength,
|
||||
),
|
||||
)
|
||||
},
|
||||
finalize(): Promise<FinalizeSource> {
|
||||
if (finalized) return Promise.resolve<FinalizeSource>('ws_already_closed')
|
||||
@@ -151,14 +165,22 @@ export async function connectDoubaoStream(
|
||||
}
|
||||
|
||||
// Start the ASR session in the background
|
||||
const config = new ASRConfig({ credentialPath: `${homedir()}/.claude/tts/doubao/credentials.json` })
|
||||
const config = new ASRConfig({
|
||||
credentialPath: `${homedir()}/.claude/tts/doubao/credentials.json`,
|
||||
})
|
||||
|
||||
// Ensure credentials are initialized (may auto-generate)
|
||||
try {
|
||||
await config.ensureCredentials()
|
||||
} catch (err) {
|
||||
logError(new Error(`[doubao-asr] Credential initialization failed: ${String(err)}`))
|
||||
callbacks.onError(`Doubao ASR 凭证初始化失败: ${String(err)}`, { fatal: true })
|
||||
logError(
|
||||
new Error(
|
||||
`[doubao-asr] Credential initialization failed: ${String(err)}`,
|
||||
),
|
||||
)
|
||||
callbacks.onError(`Doubao ASR 凭证初始化失败: ${String(err)}`, {
|
||||
fatal: true,
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -173,10 +195,16 @@ export async function connectDoubaoStream(
|
||||
void (async () => {
|
||||
try {
|
||||
const audioSource: AsyncIterable<Uint8Array> = queue
|
||||
const gen: AsyncGenerator<ASRResponse> = transcribeRealtime(audioSource, { config })
|
||||
const gen: AsyncGenerator<ASRResponse> = transcribeRealtime(audioSource, {
|
||||
config,
|
||||
})
|
||||
|
||||
for await (const resp of gen) {
|
||||
if (finalized && resp.type !== ResponseType.FINAL_RESULT && resp.type !== ResponseType.SESSION_FINISHED) {
|
||||
if (
|
||||
finalized &&
|
||||
resp.type !== ResponseType.FINAL_RESULT &&
|
||||
resp.type !== ResponseType.SESSION_FINISHED
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -272,9 +272,7 @@ function extractWrittenPaths(agentMessages: Message[]): string[] {
|
||||
// Initialization & Closure-scoped State
|
||||
// ============================================================================
|
||||
|
||||
type AppendSystemMessageFn = (
|
||||
msg: SystemMessage,
|
||||
) => void
|
||||
type AppendSystemMessageFn = (msg: SystemMessage) => void
|
||||
|
||||
/** The active extractor function, set by initExtractMemories(). */
|
||||
let extractor:
|
||||
|
||||
@@ -39,7 +39,11 @@ const mockLangfuseOtelSpanAttributes: Record<string, string> = {
|
||||
OBSERVATION_USAGE_DETAILS: 'observation.usageDetails',
|
||||
}
|
||||
|
||||
const mockSpanContext = { traceId: 'test-trace-id', spanId: 'test-span-id', traceFlags: 1 }
|
||||
const mockSpanContext = {
|
||||
traceId: 'test-trace-id',
|
||||
spanId: 'test-span-id',
|
||||
traceFlags: 1,
|
||||
}
|
||||
const mockSetAttribute = mock(() => {})
|
||||
|
||||
// Child observation mock (returned by rootSpan.startObservation for tools)
|
||||
@@ -105,13 +109,18 @@ describe('Langfuse integration', () => {
|
||||
test('replaces home dir in file_path', async () => {
|
||||
const { sanitizeToolInput } = await import('../sanitize.js')
|
||||
const home = process.env.HOME ?? '/Users/testuser'
|
||||
const result = sanitizeToolInput('FileReadTool', { file_path: `${home}/project/file.ts` }) as Record<string, string>
|
||||
const result = sanitizeToolInput('FileReadTool', {
|
||||
file_path: `${home}/project/file.ts`,
|
||||
}) as Record<string, string>
|
||||
expect(result.file_path).toBe('~/project/file.ts')
|
||||
})
|
||||
|
||||
test('redacts sensitive keys', async () => {
|
||||
const { sanitizeToolInput } = await import('../sanitize.js')
|
||||
const result = sanitizeToolInput('MCPTool', { api_key: 'secret123', token: 'abc' }) as Record<string, string>
|
||||
const result = sanitizeToolInput('MCPTool', {
|
||||
api_key: 'secret123',
|
||||
token: 'abc',
|
||||
}) as Record<string, string>
|
||||
expect(result.api_key).toBe('[REDACTED]')
|
||||
expect(result.token).toBe('[REDACTED]')
|
||||
})
|
||||
@@ -172,7 +181,9 @@ describe('Langfuse integration', () => {
|
||||
|
||||
test('recursively sanitizes nested objects', async () => {
|
||||
const { sanitizeGlobal } = await import('../sanitize.js')
|
||||
const result = sanitizeGlobal({ nested: { api_key: 'secret', name: 'test' } }) as Record<string, Record<string, string>>
|
||||
const result = sanitizeGlobal({
|
||||
nested: { api_key: 'secret', name: 'test' },
|
||||
}) as Record<string, Record<string, string>>
|
||||
expect(result.nested.api_key).toBe('[REDACTED]')
|
||||
expect(result.nested.name).toBe('test')
|
||||
})
|
||||
@@ -313,7 +324,10 @@ describe('Langfuse integration', () => {
|
||||
// client.js singleton: once processor is set, initLangfuse returns true immediately
|
||||
// We verify this by checking that calling it multiple times doesn't throw
|
||||
const { initLangfuse } = await import('../client.js')
|
||||
expect(() => { initLangfuse(); initLangfuse() }).not.toThrow()
|
||||
expect(() => {
|
||||
initLangfuse()
|
||||
initLangfuse()
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -330,7 +344,11 @@ describe('Langfuse integration', () => {
|
||||
describe('createTrace', () => {
|
||||
test('returns null when langfuse not enabled', async () => {
|
||||
const { createTrace } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
expect(span).toBeNull()
|
||||
})
|
||||
|
||||
@@ -338,26 +356,50 @@ describe('Langfuse integration', () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty', input: [] })
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
input: [],
|
||||
})
|
||||
expect(span).not.toBeNull()
|
||||
expect(mockStartObservation).toHaveBeenCalledWith('agent-run', expect.objectContaining({
|
||||
metadata: expect.objectContaining({ provider: 'firstParty', model: 'claude-3' }),
|
||||
}), { asType: 'agent' })
|
||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
||||
'agent-run',
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
provider: 'firstParty',
|
||||
model: 'claude-3',
|
||||
}),
|
||||
}),
|
||||
{ asType: 'agent' },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordLLMObservation', () => {
|
||||
test('no-ops when rootSpan is null', async () => {
|
||||
const { recordLLMObservation } = await import('../tracing.js')
|
||||
recordLLMObservation(null, { model: 'm', provider: 'firstParty', input: [], output: [], usage: { input_tokens: 10, output_tokens: 5 } })
|
||||
recordLLMObservation(null, {
|
||||
model: 'm',
|
||||
provider: 'firstParty',
|
||||
input: [],
|
||||
output: [],
|
||||
usage: { input_tokens: 10, output_tokens: 5 },
|
||||
})
|
||||
expect(mockStartObservation).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
test('records generation child observation via global startObservation', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, recordLLMObservation } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const { createTrace, recordLLMObservation } = await import(
|
||||
'../tracing.js'
|
||||
)
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
mockStartObservation.mockClear()
|
||||
recordLLMObservation(span, {
|
||||
model: 'claude-3',
|
||||
@@ -367,23 +409,35 @@ describe('Langfuse integration', () => {
|
||||
usage: { input_tokens: 10, output_tokens: 5 },
|
||||
})
|
||||
// Should call the global startObservation with asType: 'generation' and parentSpanContext
|
||||
expect(mockStartObservation).toHaveBeenCalledWith('ChatAnthropic', expect.objectContaining({
|
||||
model: 'claude-3',
|
||||
}), expect.objectContaining({
|
||||
asType: 'generation',
|
||||
parentSpanContext: mockSpanContext,
|
||||
}))
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
usageDetails: { input: 10, output: 5 },
|
||||
}))
|
||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
||||
'ChatAnthropic',
|
||||
expect.objectContaining({
|
||||
model: 'claude-3',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
asType: 'generation',
|
||||
parentSpanContext: mockSpanContext,
|
||||
}),
|
||||
)
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
usageDetails: { input: 10, output: 5 },
|
||||
}),
|
||||
)
|
||||
expect(mockRootEnd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('includes cache tokens in usageDetails when provided', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, recordLLMObservation } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const { createTrace, recordLLMObservation } = await import(
|
||||
'../tracing.js'
|
||||
)
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
mockStartObservation.mockClear()
|
||||
mockRootUpdate.mockClear()
|
||||
recordLLMObservation(span, {
|
||||
@@ -391,23 +445,36 @@ describe('Langfuse integration', () => {
|
||||
provider: 'firstParty',
|
||||
input: [],
|
||||
output: [],
|
||||
usage: { input_tokens: 10000, output_tokens: 50, cache_creation_input_tokens: 2000, cache_read_input_tokens: 7000 },
|
||||
})
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
usageDetails: {
|
||||
input: 19000, // 10000 + 2000 + 7000
|
||||
output: 50,
|
||||
cache_read: 7000,
|
||||
cache_creation: 2000,
|
||||
usage: {
|
||||
input_tokens: 10000,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 2000,
|
||||
cache_read_input_tokens: 7000,
|
||||
},
|
||||
}))
|
||||
})
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
usageDetails: {
|
||||
input: 19000, // 10000 + 2000 + 7000
|
||||
output: 50,
|
||||
cache_read: 7000,
|
||||
cache_creation: 2000,
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('omits cache fields when not provided', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, recordLLMObservation } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const { createTrace, recordLLMObservation } = await import(
|
||||
'../tracing.js'
|
||||
)
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
mockRootUpdate.mockClear()
|
||||
recordLLMObservation(span, {
|
||||
model: 'claude-3',
|
||||
@@ -416,24 +483,37 @@ describe('Langfuse integration', () => {
|
||||
output: [],
|
||||
usage: { input_tokens: 100, output_tokens: 20 },
|
||||
})
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
usageDetails: { input: 100, output: 20 },
|
||||
}))
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
usageDetails: { input: 100, output: 20 },
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordToolObservation', () => {
|
||||
test('no-ops when rootSpan is null', async () => {
|
||||
const { recordToolObservation } = await import('../tracing.js')
|
||||
recordToolObservation(null, { toolName: 'BashTool', toolUseId: 'id1', input: {}, output: 'out' })
|
||||
recordToolObservation(null, {
|
||||
toolName: 'BashTool',
|
||||
toolUseId: 'id1',
|
||||
input: {},
|
||||
output: 'out',
|
||||
})
|
||||
// startObservation should not be called beyond the initial trace creation (none here)
|
||||
})
|
||||
|
||||
test('records tool child observation via global startObservation', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, recordToolObservation } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const { createTrace, recordToolObservation } = await import(
|
||||
'../tracing.js'
|
||||
)
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
mockStartObservation.mockClear()
|
||||
mockRootUpdate.mockClear()
|
||||
mockRootEnd.mockClear()
|
||||
@@ -444,12 +524,16 @@ describe('Langfuse integration', () => {
|
||||
output: 'file.ts',
|
||||
})
|
||||
// Should call the global startObservation with asType: 'tool' and parentSpanContext
|
||||
expect(mockStartObservation).toHaveBeenCalledWith('BashTool', expect.objectContaining({
|
||||
input: expect.any(Object),
|
||||
}), expect.objectContaining({
|
||||
asType: 'tool',
|
||||
parentSpanContext: mockSpanContext,
|
||||
}))
|
||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
||||
'BashTool',
|
||||
expect.objectContaining({
|
||||
input: expect.any(Object),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
asType: 'tool',
|
||||
parentSpanContext: mockSpanContext,
|
||||
}),
|
||||
)
|
||||
expect(mockRootUpdate).toHaveBeenCalled()
|
||||
expect(mockRootEnd).toHaveBeenCalled()
|
||||
})
|
||||
@@ -457,8 +541,14 @@ describe('Langfuse integration', () => {
|
||||
test('passes startTime to global startObservation', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, recordToolObservation } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const { createTrace, recordToolObservation } = await import(
|
||||
'../tracing.js'
|
||||
)
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
mockStartObservation.mockClear()
|
||||
const startTime = new Date('2026-01-01T00:00:00Z')
|
||||
recordToolObservation(span, {
|
||||
@@ -468,17 +558,27 @@ describe('Langfuse integration', () => {
|
||||
output: 'out',
|
||||
startTime,
|
||||
})
|
||||
expect(mockStartObservation).toHaveBeenCalledWith('BashTool', expect.any(Object), expect.objectContaining({
|
||||
startTime,
|
||||
parentSpanContext: mockSpanContext,
|
||||
}))
|
||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
||||
'BashTool',
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
startTime,
|
||||
parentSpanContext: mockSpanContext,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('sanitizes FileReadTool output', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, recordToolObservation } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const { createTrace, recordToolObservation } = await import(
|
||||
'../tracing.js'
|
||||
)
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
mockRootUpdate.mockClear()
|
||||
recordToolObservation(span, {
|
||||
toolName: 'FileReadTool',
|
||||
@@ -486,16 +586,24 @@ describe('Langfuse integration', () => {
|
||||
input: { file_path: '/tmp/file.ts' },
|
||||
output: 'file content here',
|
||||
})
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
output: '[file content redacted, 17 chars]',
|
||||
}))
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
output: '[file content redacted, 17 chars]',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('sets ERROR level for error observations', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, recordToolObservation } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const { createTrace, recordToolObservation } = await import(
|
||||
'../tracing.js'
|
||||
)
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
mockRootUpdate.mockClear()
|
||||
recordToolObservation(span, {
|
||||
toolName: 'BashTool',
|
||||
@@ -504,7 +612,9 @@ describe('Langfuse integration', () => {
|
||||
output: 'error occurred',
|
||||
isError: true,
|
||||
})
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith(expect.objectContaining({ level: 'ERROR' }))
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: 'ERROR' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -519,7 +629,11 @@ describe('Langfuse integration', () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, endTrace } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
endTrace(span)
|
||||
expect(mockRootEnd).toHaveBeenCalled()
|
||||
})
|
||||
@@ -528,7 +642,11 @@ describe('Langfuse integration', () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, endTrace } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
endTrace(span, 'final output')
|
||||
expect(mockRootUpdate).toHaveBeenCalledWith({ output: 'final output' })
|
||||
expect(mockRootEnd).toHaveBeenCalled()
|
||||
@@ -561,14 +679,18 @@ describe('Langfuse integration', () => {
|
||||
input: [{ role: 'user', content: 'search for X' }],
|
||||
})
|
||||
expect(span).not.toBeNull()
|
||||
expect(mockStartObservation).toHaveBeenCalledWith('agent:Explore', expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
agentType: 'Explore',
|
||||
agentId: 'agent-1',
|
||||
provider: 'firstParty',
|
||||
model: 'claude-3',
|
||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
||||
'agent:Explore',
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
agentType: 'Explore',
|
||||
agentId: 'agent-1',
|
||||
provider: 'firstParty',
|
||||
model: 'claude-3',
|
||||
}),
|
||||
}),
|
||||
}), { asType: 'agent' })
|
||||
{ asType: 'agent' },
|
||||
)
|
||||
// Verify session.id attribute is set
|
||||
expect(mockSetAttribute).toHaveBeenCalledWith('session.id', 's1')
|
||||
})
|
||||
@@ -576,7 +698,9 @@ describe('Langfuse integration', () => {
|
||||
test('returns null on SDK error', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
|
||||
mockStartObservation.mockImplementationOnce(() => {
|
||||
throw new Error('SDK error')
|
||||
})
|
||||
const { createSubagentTrace } = await import('../tracing.js')
|
||||
const span = createSubagentTrace({
|
||||
sessionId: 's1',
|
||||
@@ -601,12 +725,16 @@ describe('Langfuse integration', () => {
|
||||
querySource: 'user',
|
||||
})
|
||||
expect(span).not.toBeNull()
|
||||
expect(mockStartObservation).toHaveBeenCalledWith('agent-run:user', expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
agentType: 'main',
|
||||
querySource: 'user',
|
||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
||||
'agent-run:user',
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
agentType: 'main',
|
||||
querySource: 'user',
|
||||
}),
|
||||
}),
|
||||
}), { asType: 'agent' })
|
||||
{ asType: 'agent' },
|
||||
)
|
||||
})
|
||||
|
||||
test('omits querySource when not provided', async () => {
|
||||
@@ -614,7 +742,11 @@ describe('Langfuse integration', () => {
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
mockStartObservation.mockClear()
|
||||
const { createTrace } = await import('../tracing.js')
|
||||
createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
const calls = mockStartObservation.mock.calls as unknown[][]
|
||||
const secondArg = calls[0]?.[1] as Record<string, unknown> | undefined
|
||||
const metadata = (secondArg?.metadata ?? {}) as Record<string, unknown>
|
||||
@@ -635,7 +767,10 @@ describe('Langfuse integration', () => {
|
||||
username: 'user@example.com',
|
||||
})
|
||||
expect(span).not.toBeNull()
|
||||
expect(mockSetAttribute).toHaveBeenCalledWith('user.id', 'user@example.com')
|
||||
expect(mockSetAttribute).toHaveBeenCalledWith(
|
||||
'user.id',
|
||||
'user@example.com',
|
||||
)
|
||||
})
|
||||
|
||||
test('falls back to LANGFUSE_USER_ID env when username not provided', async () => {
|
||||
@@ -650,7 +785,10 @@ describe('Langfuse integration', () => {
|
||||
provider: 'firstParty',
|
||||
})
|
||||
expect(span).not.toBeNull()
|
||||
expect(mockSetAttribute).toHaveBeenCalledWith('user.id', 'env-user@test.com')
|
||||
expect(mockSetAttribute).toHaveBeenCalledWith(
|
||||
'user.id',
|
||||
'env-user@test.com',
|
||||
)
|
||||
delete process.env.LANGFUSE_USER_ID
|
||||
})
|
||||
|
||||
@@ -660,7 +798,11 @@ describe('Langfuse integration', () => {
|
||||
delete process.env.LANGFUSE_USER_ID
|
||||
mockSetAttribute.mockClear()
|
||||
const { createTrace } = await import('../tracing.js')
|
||||
createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
// Falls back to getCoreUserData().deviceId (mocked as 'test-device-id')
|
||||
expect(mockSetAttribute).toHaveBeenCalledWith('user.id', 'test-device-id')
|
||||
})
|
||||
@@ -714,7 +856,10 @@ describe('Langfuse integration', () => {
|
||||
|
||||
// Both should have set session.id attribute
|
||||
const sessionAttributeCalls = mockSetAttribute.mock.calls.filter(
|
||||
(call: unknown[]) => Array.isArray(call) && call[0] === 'session.id' && call[1] === 'shared-session',
|
||||
(call: unknown[]) =>
|
||||
Array.isArray(call) &&
|
||||
call[0] === 'session.id' &&
|
||||
call[1] === 'shared-session',
|
||||
)
|
||||
expect(sessionAttributeCalls.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
@@ -739,8 +884,8 @@ describe('Langfuse integration', () => {
|
||||
expect(subTrace).not.toBeNull()
|
||||
|
||||
// Simulate query.ts logic: if langfuseTrace already set, don't create new one
|
||||
const ownsTrace = false // Would be: !params.toolUseContext.langfuseTrace
|
||||
const langfuseTrace = subTrace // Would be: params.toolUseContext.langfuseTrace ?? createTrace(...)
|
||||
const ownsTrace = false // Would be: !params.toolUseContext.langfuseTrace
|
||||
const langfuseTrace = subTrace // Would be: params.toolUseContext.langfuseTrace ?? createTrace(...)
|
||||
|
||||
expect(ownsTrace).toBe(false)
|
||||
expect(langfuseTrace).toBe(subTrace)
|
||||
@@ -761,7 +906,9 @@ describe('Langfuse integration', () => {
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
|
||||
const result = convertToolsToLangfuse(tools) as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toEqual({
|
||||
type: 'function',
|
||||
@@ -780,22 +927,44 @@ describe('Langfuse integration', () => {
|
||||
test('converts multiple tools', async () => {
|
||||
const { convertToolsToLangfuse } = await import('../convert.js')
|
||||
const tools = [
|
||||
{ name: 'ReadTool', description: 'Read a file', input_schema: { type: 'object' } },
|
||||
{ name: 'WriteTool', description: 'Write a file', input_schema: { type: 'object' } },
|
||||
{
|
||||
name: 'ReadTool',
|
||||
description: 'Read a file',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
{
|
||||
name: 'WriteTool',
|
||||
description: 'Write a file',
|
||||
input_schema: { type: 'object' },
|
||||
},
|
||||
]
|
||||
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
|
||||
const result = convertToolsToLangfuse(tools) as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
expect(result).toHaveLength(2)
|
||||
expect((result[0]!.function as Record<string, unknown>).name).toBe('ReadTool')
|
||||
expect((result[1]!.function as Record<string, unknown>).name).toBe('WriteTool')
|
||||
expect((result[0]!.function as Record<string, unknown>).name).toBe(
|
||||
'ReadTool',
|
||||
)
|
||||
expect((result[1]!.function as Record<string, unknown>).name).toBe(
|
||||
'WriteTool',
|
||||
)
|
||||
})
|
||||
|
||||
test('falls back to parameters when input_schema is missing', async () => {
|
||||
const { convertToolsToLangfuse } = await import('../convert.js')
|
||||
const tools = [
|
||||
{ name: 'Tool1', description: 'desc', parameters: { type: 'object', properties: { a: { type: 'string' } } } },
|
||||
{
|
||||
name: 'Tool1',
|
||||
description: 'desc',
|
||||
parameters: { type: 'object', properties: { a: { type: 'string' } } },
|
||||
},
|
||||
]
|
||||
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
|
||||
expect((result[0]!.function as Record<string, unknown>).parameters).toEqual({
|
||||
const result = convertToolsToLangfuse(tools) as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
expect(
|
||||
(result[0]!.function as Record<string, unknown>).parameters,
|
||||
).toEqual({
|
||||
type: 'object',
|
||||
properties: { a: { type: 'string' } },
|
||||
})
|
||||
@@ -804,8 +973,12 @@ describe('Langfuse integration', () => {
|
||||
test('uses empty object when neither input_schema nor parameters exist', async () => {
|
||||
const { convertToolsToLangfuse } = await import('../convert.js')
|
||||
const tools = [{ name: 'Tool1', description: 'desc' }]
|
||||
const result = convertToolsToLangfuse(tools) as Array<Record<string, unknown>>
|
||||
expect((result[0]!.function as Record<string, unknown>).parameters).toEqual({})
|
||||
const result = convertToolsToLangfuse(tools) as Array<
|
||||
Record<string, unknown>
|
||||
>
|
||||
expect(
|
||||
(result[0]!.function as Record<string, unknown>).parameters,
|
||||
).toEqual({})
|
||||
})
|
||||
|
||||
test('returns empty array for empty input', async () => {
|
||||
@@ -818,11 +991,22 @@ describe('Langfuse integration', () => {
|
||||
test('wraps input into { messages, tools } when tools provided', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, recordLLMObservation } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const { createTrace, recordLLMObservation } = await import(
|
||||
'../tracing.js'
|
||||
)
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
mockStartObservation.mockClear()
|
||||
const messages = [{ role: 'user', content: 'hello' }]
|
||||
const tools = [{ type: 'function', function: { name: 'Bash', description: 'Run', parameters: {} } }]
|
||||
const tools = [
|
||||
{
|
||||
type: 'function',
|
||||
function: { name: 'Bash', description: 'Run', parameters: {} },
|
||||
},
|
||||
]
|
||||
recordLLMObservation(span, {
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
@@ -831,18 +1015,28 @@ describe('Langfuse integration', () => {
|
||||
usage: { input_tokens: 10, output_tokens: 5 },
|
||||
tools,
|
||||
})
|
||||
expect(mockStartObservation).toHaveBeenCalledWith('ChatAnthropic', expect.objectContaining({
|
||||
input: { messages, tools },
|
||||
}), expect.objectContaining({
|
||||
asType: 'generation',
|
||||
}))
|
||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
||||
'ChatAnthropic',
|
||||
expect.objectContaining({
|
||||
input: { messages, tools },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
asType: 'generation',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps input as-is when tools not provided', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
const { createTrace, recordLLMObservation } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const { createTrace, recordLLMObservation } = await import(
|
||||
'../tracing.js'
|
||||
)
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
mockStartObservation.mockClear()
|
||||
const messages = [{ role: 'user', content: 'hello' }]
|
||||
recordLLMObservation(span, {
|
||||
@@ -852,9 +1046,13 @@ describe('Langfuse integration', () => {
|
||||
output: [],
|
||||
usage: { input_tokens: 10, output_tokens: 5 },
|
||||
})
|
||||
expect(mockStartObservation).toHaveBeenCalledWith('ChatAnthropic', expect.objectContaining({
|
||||
input: messages,
|
||||
}), expect.any(Object))
|
||||
expect(mockStartObservation).toHaveBeenCalledWith(
|
||||
'ChatAnthropic',
|
||||
expect.objectContaining({
|
||||
input: messages,
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -862,27 +1060,45 @@ describe('Langfuse integration', () => {
|
||||
test('createTrace returns null on SDK error', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
|
||||
mockStartObservation.mockImplementationOnce(() => {
|
||||
throw new Error('SDK error')
|
||||
})
|
||||
const { createTrace } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
})
|
||||
expect(span).toBeNull()
|
||||
})
|
||||
|
||||
test('recordLLMObservation silently fails on SDK error', async () => {
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk-test'
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk-test'
|
||||
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
|
||||
const { createTrace, recordLLMObservation } = await import('../tracing.js')
|
||||
const span = createTrace({ sessionId: 's1', model: 'claude-3', provider: 'firstParty' })
|
||||
// The second call to startObservation (for the generation) will throw
|
||||
mockStartObservation.mockImplementationOnce(() => { throw new Error('SDK error') })
|
||||
expect(() => recordLLMObservation(span, {
|
||||
model: 'm',
|
||||
mockStartObservation.mockImplementationOnce(() => {
|
||||
throw new Error('SDK error')
|
||||
})
|
||||
const { createTrace, recordLLMObservation } = await import(
|
||||
'../tracing.js'
|
||||
)
|
||||
const span = createTrace({
|
||||
sessionId: 's1',
|
||||
model: 'claude-3',
|
||||
provider: 'firstParty',
|
||||
input: [],
|
||||
output: [],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
})).not.toThrow()
|
||||
})
|
||||
// The second call to startObservation (for the generation) will throw
|
||||
mockStartObservation.mockImplementationOnce(() => {
|
||||
throw new Error('SDK error')
|
||||
})
|
||||
expect(() =>
|
||||
recordLLMObservation(span, {
|
||||
model: 'm',
|
||||
provider: 'firstParty',
|
||||
input: [],
|
||||
output: [],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,7 +37,11 @@ export function initLangfuse(): boolean {
|
||||
mask: maskFn,
|
||||
environment: process.env.LANGFUSE_TRACING_ENVIRONMENT ?? 'development',
|
||||
release: MACRO.VERSION,
|
||||
exportMode: (process.env.LANGFUSE_EXPORT_MODE as 'batched' | 'immediate' | undefined) ?? 'batched',
|
||||
exportMode:
|
||||
(process.env.LANGFUSE_EXPORT_MODE as
|
||||
| 'batched'
|
||||
| 'immediate'
|
||||
| undefined) ?? 'batched',
|
||||
timeout: parseInt(process.env.LANGFUSE_TIMEOUT ?? '5', 10),
|
||||
})
|
||||
|
||||
|
||||
@@ -74,7 +74,8 @@ function mergeToolCalls(
|
||||
): LangfuseToolCall[] {
|
||||
const merged = new Map<string, LangfuseToolCall>()
|
||||
for (const toolCall of groups.flat()) {
|
||||
const key = toolCall.id || `${toolCall.function.name}:${toolCall.function.arguments}`
|
||||
const key =
|
||||
toolCall.id || `${toolCall.function.name}:${toolCall.function.arguments}`
|
||||
if (!merged.has(key)) merged.set(key, toolCall)
|
||||
}
|
||||
return [...merged.values()]
|
||||
@@ -87,40 +88,65 @@ type LangfuseInputMessage =
|
||||
| ChatCompletionMessageParam
|
||||
|
||||
/** Normalize a content block into a LangfuseContentPart (non-tool_use, non-tool_result) */
|
||||
function toContentPart(block: Record<string, unknown>): LangfuseContentPart | null {
|
||||
function toContentPart(
|
||||
block: Record<string, unknown>,
|
||||
): LangfuseContentPart | null {
|
||||
const type = block.type as string | undefined
|
||||
if (type === 'text') {
|
||||
return { type: 'text', text: String(block.text ?? '') }
|
||||
}
|
||||
if (type === 'thinking' || type === 'redacted_thinking') {
|
||||
return { type: 'thinking', thinking: String(block.thinking ?? '[redacted]') }
|
||||
return {
|
||||
type: 'thinking',
|
||||
thinking: String(block.thinking ?? '[redacted]'),
|
||||
}
|
||||
}
|
||||
if (type === 'image') {
|
||||
return { type: 'text', text: '[image]' }
|
||||
}
|
||||
if (type === 'document') {
|
||||
const name = (block.source as Record<string, unknown> | undefined)?.filename
|
||||
?? (block.title as string | undefined)
|
||||
?? 'document'
|
||||
const name =
|
||||
(block.source as Record<string, unknown> | undefined)?.filename ??
|
||||
(block.title as string | undefined) ??
|
||||
'document'
|
||||
return { type: 'text', text: `[document: ${name}]` }
|
||||
}
|
||||
if (type === 'server_tool_use' || type === 'web_search_tool_result' || type === 'tool_search_tool_result') {
|
||||
return { type, id: String(block.id ?? ''), name: String(block.name ?? type) }
|
||||
if (
|
||||
type === 'server_tool_use' ||
|
||||
type === 'web_search_tool_result' ||
|
||||
type === 'tool_search_tool_result'
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
id: String(block.id ?? ''),
|
||||
name: String(block.name ?? type),
|
||||
}
|
||||
}
|
||||
// unknown block: keep type + scalar fields only
|
||||
const safe: Record<string, unknown> = { type: type ?? 'unknown' }
|
||||
for (const [k, v] of Object.entries(block)) {
|
||||
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') safe[k] = v
|
||||
if (
|
||||
typeof v === 'string' ||
|
||||
typeof v === 'number' ||
|
||||
typeof v === 'boolean'
|
||||
)
|
||||
safe[k] = v
|
||||
}
|
||||
return safe as LangfuseContentPart
|
||||
}
|
||||
|
||||
/** Extract tool_use blocks from content into OpenAI-style tool_calls */
|
||||
function extractToolCalls(content: unknown[]): { tool_calls: LangfuseToolCall[]; rest: unknown[] } {
|
||||
function extractToolCalls(content: unknown[]): {
|
||||
tool_calls: LangfuseToolCall[]
|
||||
rest: unknown[]
|
||||
} {
|
||||
const toolCalls: LangfuseToolCall[] = []
|
||||
const rest: unknown[] = []
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object') { rest.push(block); continue }
|
||||
if (!block || typeof block !== 'object') {
|
||||
rest.push(block)
|
||||
continue
|
||||
}
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'tool_use') {
|
||||
toolCalls.push({
|
||||
@@ -128,7 +154,10 @@ function extractToolCalls(content: unknown[]): { tool_calls: LangfuseToolCall[];
|
||||
type: 'function',
|
||||
function: {
|
||||
name: String(b.name ?? ''),
|
||||
arguments: typeof b.input === 'string' ? b.input : JSON.stringify(b.input ?? {}),
|
||||
arguments:
|
||||
typeof b.input === 'string'
|
||||
? b.input
|
||||
: JSON.stringify(b.input ?? {}),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
@@ -139,11 +168,17 @@ function extractToolCalls(content: unknown[]): { tool_calls: LangfuseToolCall[];
|
||||
}
|
||||
|
||||
/** Extract tool_result blocks into separate { role: 'tool' } messages */
|
||||
function extractToolResults(content: unknown[]): { toolMessages: LangfuseChatMessage[]; rest: unknown[] } {
|
||||
function extractToolResults(content: unknown[]): {
|
||||
toolMessages: LangfuseChatMessage[]
|
||||
rest: unknown[]
|
||||
} {
|
||||
const toolMessages: LangfuseChatMessage[] = []
|
||||
const rest: unknown[] = []
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object') { rest.push(block); continue }
|
||||
if (!block || typeof block !== 'object') {
|
||||
rest.push(block)
|
||||
continue
|
||||
}
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type === 'tool_result') {
|
||||
const resultContent = Array.isArray(b.content)
|
||||
@@ -169,7 +204,9 @@ function extractToolResults(content: unknown[]): { toolMessages: LangfuseChatMes
|
||||
}
|
||||
|
||||
/** Collapse content parts: join all-text arrays into a single string */
|
||||
function collapseContent(parts: LangfuseContentPart[]): string | LangfuseContentPart[] {
|
||||
function collapseContent(
|
||||
parts: LangfuseContentPart[],
|
||||
): string | LangfuseContentPart[] {
|
||||
if (parts.length === 0) return ''
|
||||
if (parts.every(p => p.type === 'text')) {
|
||||
return parts.map(p => (p as { type: 'text'; text: string }).text).join('\n')
|
||||
@@ -177,7 +214,9 @@ function collapseContent(parts: LangfuseContentPart[]): string | LangfuseContent
|
||||
return parts
|
||||
}
|
||||
|
||||
function toRoleFromWrappedMessage(msg: Record<string, unknown>): 'user' | 'assistant' | 'system' {
|
||||
function toRoleFromWrappedMessage(
|
||||
msg: Record<string, unknown>,
|
||||
): 'user' | 'assistant' | 'system' {
|
||||
if (msg.type === 'assistant') return 'assistant'
|
||||
if (msg.type === 'system') return 'system'
|
||||
return 'user'
|
||||
@@ -199,8 +238,11 @@ export function convertMessagesToLangfuse(
|
||||
const wrappedMessage = msg.message
|
||||
const isWrappedMessage = isRecord(wrappedMessage)
|
||||
const inner = isWrappedMessage ? wrappedMessage : msg
|
||||
const role =
|
||||
isLangfuseRole(inner.role) ? inner.role : isWrappedMessage ? toRoleFromWrappedMessage(msg) : 'user'
|
||||
const role = isLangfuseRole(inner.role)
|
||||
? inner.role
|
||||
: isWrappedMessage
|
||||
? toRoleFromWrappedMessage(msg)
|
||||
: 'user'
|
||||
const rawContent = inner.content
|
||||
if (typeof rawContent === 'string' || !Array.isArray(rawContent)) {
|
||||
const toolCalls = getToolCalls(inner.tool_calls)
|
||||
@@ -224,7 +266,10 @@ export function convertMessagesToLangfuse(
|
||||
getContentToolCalls(rest),
|
||||
)
|
||||
const parts = rest
|
||||
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
|
||||
.filter(
|
||||
(b): b is Record<string, unknown> =>
|
||||
b != null && typeof b === 'object',
|
||||
)
|
||||
.map(b => toContentPart(b))
|
||||
.filter((p): p is LangfuseContentPart => p !== null)
|
||||
result.push({
|
||||
@@ -236,7 +281,10 @@ export function convertMessagesToLangfuse(
|
||||
// User messages: extract tool_result → separate tool messages
|
||||
const { toolMessages, rest } = extractToolResults(rawContent)
|
||||
const parts = rest
|
||||
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
|
||||
.filter(
|
||||
(b): b is Record<string, unknown> =>
|
||||
b != null && typeof b === 'object',
|
||||
)
|
||||
.map(b => toContentPart(b))
|
||||
.filter((p): p is LangfuseContentPart => p !== null)
|
||||
if (parts.length > 0 || toolMessages.length === 0) {
|
||||
@@ -287,7 +335,9 @@ export function convertOutputToLangfuse(
|
||||
}
|
||||
const { tool_calls, rest } = extractToolCalls(rawContent)
|
||||
const parts = rest
|
||||
.filter((b): b is Record<string, unknown> => b != null && typeof b === 'object')
|
||||
.filter(
|
||||
(b): b is Record<string, unknown> => b != null && typeof b === 'object',
|
||||
)
|
||||
.map(b => toContentPart(b))
|
||||
.filter((p): p is LangfuseContentPart => p !== null)
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
export { initLangfuse, shutdownLangfuse, isLangfuseEnabled, getLangfuseProcessor } from './client.js'
|
||||
export { createTrace, createSubagentTrace, createChildSpan, recordLLMObservation, recordToolObservation, endTrace, createToolBatchSpan, endToolBatchSpan } from './tracing.js'
|
||||
export {
|
||||
initLangfuse,
|
||||
shutdownLangfuse,
|
||||
isLangfuseEnabled,
|
||||
getLangfuseProcessor,
|
||||
} from './client.js'
|
||||
export {
|
||||
createTrace,
|
||||
createSubagentTrace,
|
||||
createChildSpan,
|
||||
recordLLMObservation,
|
||||
recordToolObservation,
|
||||
endTrace,
|
||||
createToolBatchSpan,
|
||||
endToolBatchSpan,
|
||||
} from './tracing.js'
|
||||
export type { LangfuseSpan } from './tracing.js'
|
||||
export { sanitizeToolInput, sanitizeToolOutput, sanitizeGlobal } from './sanitize.js'
|
||||
export {
|
||||
sanitizeToolInput,
|
||||
sanitizeToolOutput,
|
||||
sanitizeGlobal,
|
||||
} from './sanitize.js'
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { homedir } from 'os'
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 500
|
||||
const REDACTED_FILE_TOOLS = new Set(['FileReadTool', 'FileWriteTool', 'FileEditTool'])
|
||||
const REDACTED_FILE_TOOLS = new Set([
|
||||
'FileReadTool',
|
||||
'FileWriteTool',
|
||||
'FileEditTool',
|
||||
])
|
||||
const REDACTED_SHELL_TOOLS = new Set(['BashTool', 'PowerShellTool'])
|
||||
const SENSITIVE_OUTPUT_TOOLS = new Set(['ConfigTool', 'MCPTool'])
|
||||
|
||||
@@ -27,7 +31,8 @@ function homePathPatterns(): string[] {
|
||||
|
||||
const HOME_DIR_PATTERN = new RegExp(`(?:${homePathPatterns().join('|')})`, 'g')
|
||||
|
||||
const SENSITIVE_KEY_PATTERN = /(?:api_?key|token|secret|password|credential|auth_header)/i
|
||||
const SENSITIVE_KEY_PATTERN =
|
||||
/(?:api_?key|token|secret|password|credential|auth_header)/i
|
||||
|
||||
export function sanitizeGlobal(data: unknown): unknown {
|
||||
if (typeof data === 'string') {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { startObservation, LangfuseOtelSpanAttributes } from '@langfuse/tracing'
|
||||
import type { LangfuseSpan, LangfuseGeneration, LangfuseAgent } from '@langfuse/tracing'
|
||||
import type {
|
||||
LangfuseSpan,
|
||||
LangfuseGeneration,
|
||||
LangfuseAgent,
|
||||
} from '@langfuse/tracing'
|
||||
import { isLangfuseEnabled } from './client.js'
|
||||
import { sanitizeToolInput, sanitizeToolOutput } from './sanitize.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
@@ -12,7 +16,12 @@ type RootTrace = LangfuseAgent & { _sessionId?: string; _userId?: string }
|
||||
|
||||
/** Resolve the user ID for Langfuse traces: explicit param > env var > email > deviceId */
|
||||
function resolveLangfuseUserId(username?: string): string | undefined {
|
||||
return username ?? process.env.LANGFUSE_USER_ID ?? getCoreUserData().email ?? getCoreUserData().deviceId
|
||||
return (
|
||||
username ??
|
||||
process.env.LANGFUSE_USER_ID ??
|
||||
getCoreUserData().email ??
|
||||
getCoreUserData().deviceId
|
||||
)
|
||||
}
|
||||
|
||||
export function createTrace(params: {
|
||||
@@ -26,21 +35,33 @@ export function createTrace(params: {
|
||||
}): LangfuseSpan | null {
|
||||
if (!isLangfuseEnabled()) return null
|
||||
try {
|
||||
const traceName = params.name ?? (params.querySource ? `agent-run:${params.querySource}` : 'agent-run')
|
||||
const rootSpan = startObservation(traceName, {
|
||||
input: params.input,
|
||||
metadata: {
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
agentType: 'main',
|
||||
...(params.querySource && { querySource: params.querySource }),
|
||||
const traceName =
|
||||
params.name ??
|
||||
(params.querySource ? `agent-run:${params.querySource}` : 'agent-run')
|
||||
const rootSpan = startObservation(
|
||||
traceName,
|
||||
{
|
||||
input: params.input,
|
||||
metadata: {
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
agentType: 'main',
|
||||
...(params.querySource && { querySource: params.querySource }),
|
||||
},
|
||||
},
|
||||
}, { asType: 'agent' }) as RootTrace
|
||||
rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, params.sessionId)
|
||||
{ asType: 'agent' },
|
||||
) as RootTrace
|
||||
rootSpan.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_SESSION_ID,
|
||||
params.sessionId,
|
||||
)
|
||||
rootSpan._sessionId = params.sessionId
|
||||
const userId = resolveLangfuseUserId(params.username)
|
||||
if (userId) {
|
||||
rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
|
||||
rootSpan.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_USER_ID,
|
||||
userId,
|
||||
)
|
||||
rootSpan._userId = userId
|
||||
}
|
||||
logForDebugging(`[langfuse] Trace created: ${rootSpan.id}`)
|
||||
@@ -92,7 +113,8 @@ export function recordLLMObservation(
|
||||
): void {
|
||||
if (!rootSpan || !isLangfuseEnabled()) return
|
||||
try {
|
||||
const genName = PROVIDER_GENERATION_NAMES[params.provider] ?? `Chat${params.provider}`
|
||||
const genName =
|
||||
PROVIDER_GENERATION_NAMES[params.provider] ?? `Chat${params.provider}`
|
||||
|
||||
// Use the global startObservation directly instead of rootSpan.startObservation().
|
||||
// The instance method only forwards asType to the global function and drops startTime,
|
||||
@@ -109,7 +131,9 @@ export function recordLLMObservation(
|
||||
model: params.model,
|
||||
...(params.thinking && { thinking: params.thinking }),
|
||||
},
|
||||
...(params.completionStartTime && { completionStartTime: params.completionStartTime }),
|
||||
...(params.completionStartTime && {
|
||||
completionStartTime: params.completionStartTime,
|
||||
}),
|
||||
},
|
||||
{
|
||||
asType: 'generation',
|
||||
@@ -121,11 +145,17 @@ export function recordLLMObservation(
|
||||
// Propagate session ID and user ID to generation span so Langfuse links it correctly
|
||||
const sessionId = (rootSpan as unknown as RootTrace)._sessionId
|
||||
if (sessionId) {
|
||||
gen.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId)
|
||||
gen.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_SESSION_ID,
|
||||
sessionId,
|
||||
)
|
||||
}
|
||||
const userId = (rootSpan as unknown as RootTrace)._userId
|
||||
if (userId) {
|
||||
gen.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
|
||||
gen.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_USER_ID,
|
||||
userId,
|
||||
)
|
||||
}
|
||||
|
||||
// Anthropic splits input into uncached + cache_read + cache_creation.
|
||||
@@ -145,7 +175,9 @@ export function recordLLMObservation(
|
||||
gen.end(params.endTime)
|
||||
logForDebugging(`[langfuse] LLM observation recorded: ${gen.id}`)
|
||||
} catch (e) {
|
||||
logForDebugging(`[langfuse] recordLLMObservation failed: ${e}`, { level: 'error' })
|
||||
logForDebugging(`[langfuse] recordLLMObservation failed: ${e}`, {
|
||||
level: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,11 +218,17 @@ export function recordToolObservation(
|
||||
// Propagate session ID and user ID to tool span so Langfuse links it correctly
|
||||
const sessionId = (rootSpan as unknown as RootTrace)._sessionId
|
||||
if (sessionId) {
|
||||
toolObs.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId)
|
||||
toolObs.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_SESSION_ID,
|
||||
sessionId,
|
||||
)
|
||||
}
|
||||
const userId = (rootSpan as unknown as RootTrace)._userId
|
||||
if (userId) {
|
||||
toolObs.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
|
||||
toolObs.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_USER_ID,
|
||||
userId,
|
||||
)
|
||||
}
|
||||
|
||||
toolObs.update({
|
||||
@@ -199,9 +237,13 @@ export function recordToolObservation(
|
||||
})
|
||||
|
||||
toolObs.end()
|
||||
logForDebugging(`[langfuse] Tool observation recorded: ${params.toolName} (${toolObs.id})`)
|
||||
logForDebugging(
|
||||
`[langfuse] Tool observation recorded: ${params.toolName} (${toolObs.id})`,
|
||||
)
|
||||
} catch (e) {
|
||||
logForDebugging(`[langfuse] recordToolObservation failed: ${e}`, { level: 'error' })
|
||||
logForDebugging(`[langfuse] recordToolObservation failed: ${e}`, {
|
||||
level: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,17 +275,27 @@ export function createToolBatchSpan(
|
||||
|
||||
const sessionId = (rootSpan as unknown as RootTrace)._sessionId
|
||||
if (sessionId) {
|
||||
batchSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId)
|
||||
batchSpan.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_SESSION_ID,
|
||||
sessionId,
|
||||
)
|
||||
}
|
||||
const userId = (rootSpan as unknown as RootTrace)._userId
|
||||
if (userId) {
|
||||
batchSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
|
||||
batchSpan.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_USER_ID,
|
||||
userId,
|
||||
)
|
||||
}
|
||||
|
||||
logForDebugging(`[langfuse] Tool batch span created: ${batchSpan.id} (tools=${params.toolNames.join(',')})`)
|
||||
logForDebugging(
|
||||
`[langfuse] Tool batch span created: ${batchSpan.id} (tools=${params.toolNames.join(',')})`,
|
||||
)
|
||||
return batchSpan
|
||||
} catch (e) {
|
||||
logForDebugging(`[langfuse] createToolBatchSpan failed: ${e}`, { level: 'error' })
|
||||
logForDebugging(`[langfuse] createToolBatchSpan failed: ${e}`, {
|
||||
level: 'error',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -254,7 +306,9 @@ export function endToolBatchSpan(batchSpan: LangfuseSpan | null): void {
|
||||
batchSpan.end()
|
||||
logForDebugging(`[langfuse] Tool batch span ended: ${batchSpan.id}`)
|
||||
} catch (e) {
|
||||
logForDebugging(`[langfuse] endToolBatchSpan failed: ${e}`, { level: 'error' })
|
||||
logForDebugging(`[langfuse] endToolBatchSpan failed: ${e}`, {
|
||||
level: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,26 +323,40 @@ export function createSubagentTrace(params: {
|
||||
}): LangfuseSpan | null {
|
||||
if (!isLangfuseEnabled()) return null
|
||||
try {
|
||||
const rootSpan = startObservation(`agent:${params.agentType}`, {
|
||||
input: params.input,
|
||||
metadata: {
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
agentType: params.agentType,
|
||||
agentId: params.agentId,
|
||||
const rootSpan = startObservation(
|
||||
`agent:${params.agentType}`,
|
||||
{
|
||||
input: params.input,
|
||||
metadata: {
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
agentType: params.agentType,
|
||||
agentId: params.agentId,
|
||||
},
|
||||
},
|
||||
}, { asType: 'agent' }) as RootTrace
|
||||
rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, params.sessionId)
|
||||
{ asType: 'agent' },
|
||||
) as RootTrace
|
||||
rootSpan.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_SESSION_ID,
|
||||
params.sessionId,
|
||||
)
|
||||
rootSpan._sessionId = params.sessionId
|
||||
const userId = resolveLangfuseUserId(params.username)
|
||||
if (userId) {
|
||||
rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
|
||||
rootSpan.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_USER_ID,
|
||||
userId,
|
||||
)
|
||||
rootSpan._userId = userId
|
||||
}
|
||||
logForDebugging(`[langfuse] Sub-agent trace created: ${rootSpan.id} (type=${params.agentType})`)
|
||||
logForDebugging(
|
||||
`[langfuse] Sub-agent trace created: ${rootSpan.id} (type=${params.agentType})`,
|
||||
)
|
||||
return rootSpan as unknown as LangfuseSpan
|
||||
} catch (e) {
|
||||
logForDebugging(`[langfuse] createSubagentTrace failed: ${e}`, { level: 'error' })
|
||||
logForDebugging(`[langfuse] createSubagentTrace failed: ${e}`, {
|
||||
level: 'error',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -331,18 +399,28 @@ export function createChildSpan(
|
||||
const parent = parentSpan as unknown as RootTrace
|
||||
const sessionId = parent._sessionId ?? params.sessionId
|
||||
if (sessionId) {
|
||||
span.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId)
|
||||
span.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_SESSION_ID,
|
||||
sessionId,
|
||||
)
|
||||
;(span as unknown as RootTrace)._sessionId = sessionId
|
||||
}
|
||||
const userId = parent._userId ?? resolveLangfuseUserId(params.username)
|
||||
if (userId) {
|
||||
span.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId)
|
||||
span.otelSpan.setAttribute(
|
||||
LangfuseOtelSpanAttributes.TRACE_USER_ID,
|
||||
userId,
|
||||
)
|
||||
;(span as unknown as RootTrace)._userId = userId
|
||||
}
|
||||
logForDebugging(`[langfuse] Child span created: ${span.id} (parent=${parentSpan.id})`)
|
||||
logForDebugging(
|
||||
`[langfuse] Child span created: ${span.id} (parent=${parentSpan.id})`,
|
||||
)
|
||||
return span
|
||||
} catch (e) {
|
||||
logForDebugging(`[langfuse] createChildSpan failed: ${e}`, { level: 'error' })
|
||||
logForDebugging(`[langfuse] createChildSpan failed: ${e}`, {
|
||||
level: 'error',
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -360,7 +438,9 @@ export function endTrace(
|
||||
else if (status === 'error') updatePayload.level = 'ERROR'
|
||||
if (Object.keys(updatePayload).length > 0) rootSpan.update(updatePayload)
|
||||
rootSpan.end()
|
||||
logForDebugging(`[langfuse] Trace ended: ${rootSpan.id}${status ? ` (${status})` : ''}`)
|
||||
logForDebugging(
|
||||
`[langfuse] Trace ended: ${rootSpan.id}${status ? ` (${status})` : ''}`,
|
||||
)
|
||||
} catch (e) {
|
||||
logForDebugging(`[langfuse] endTrace failed: ${e}`, { level: 'error' })
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ describe('LSPServerManager closeAllFiles', () => {
|
||||
// didClose should have been sent for both files
|
||||
expect(sendNotificationMock).toHaveBeenCalledTimes(2)
|
||||
const calls = sendNotificationMock.mock.calls.map((c: any[]) => c)
|
||||
const uris = calls.map((c) => (c[1] as any)?.textDocument?.uri as string)
|
||||
const uris = calls.map(c => (c[1] as any)?.textDocument?.uri as string)
|
||||
expect(uris).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('a.ts'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type LspServerConfig = any;
|
||||
export type ScopedLspServerConfig = any;
|
||||
export type LspServerState = any;
|
||||
export type LspServerConfig = any
|
||||
export type ScopedLspServerConfig = any
|
||||
export type LspServerState = any
|
||||
|
||||
@@ -1,54 +1,41 @@
|
||||
import React, {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import type { Command } from '../../commands.js'
|
||||
import type { Tool } from '../../Tool.js'
|
||||
import type {
|
||||
MCPServerConnection,
|
||||
ScopedMcpServerConfig,
|
||||
ServerResource,
|
||||
} from './types.js'
|
||||
import { useManageMCPConnections } from './useManageMCPConnections.js'
|
||||
import React, { createContext, type ReactNode, useContext, useMemo } from 'react';
|
||||
import type { Command } from '../../commands.js';
|
||||
import type { Tool } from '../../Tool.js';
|
||||
import type { MCPServerConnection, ScopedMcpServerConfig, ServerResource } from './types.js';
|
||||
import { useManageMCPConnections } from './useManageMCPConnections.js';
|
||||
|
||||
interface MCPConnectionContextValue {
|
||||
reconnectMcpServer: (serverName: string) => Promise<{
|
||||
client: MCPServerConnection
|
||||
tools: Tool[]
|
||||
commands: Command[]
|
||||
resources?: ServerResource[]
|
||||
}>
|
||||
toggleMcpServer: (serverName: string) => Promise<void>
|
||||
client: MCPServerConnection;
|
||||
tools: Tool[];
|
||||
commands: Command[];
|
||||
resources?: ServerResource[];
|
||||
}>;
|
||||
toggleMcpServer: (serverName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const MCPConnectionContext = createContext<MCPConnectionContextValue | null>(
|
||||
null,
|
||||
)
|
||||
const MCPConnectionContext = createContext<MCPConnectionContextValue | null>(null);
|
||||
|
||||
export function useMcpReconnect() {
|
||||
const context = useContext(MCPConnectionContext)
|
||||
const context = useContext(MCPConnectionContext);
|
||||
if (!context) {
|
||||
throw new Error('useMcpReconnect must be used within MCPConnectionManager')
|
||||
throw new Error('useMcpReconnect must be used within MCPConnectionManager');
|
||||
}
|
||||
return context.reconnectMcpServer
|
||||
return context.reconnectMcpServer;
|
||||
}
|
||||
|
||||
export function useMcpToggleEnabled() {
|
||||
const context = useContext(MCPConnectionContext)
|
||||
const context = useContext(MCPConnectionContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
'useMcpToggleEnabled must be used within MCPConnectionManager',
|
||||
)
|
||||
throw new Error('useMcpToggleEnabled must be used within MCPConnectionManager');
|
||||
}
|
||||
return context.toggleMcpServer
|
||||
return context.toggleMcpServer;
|
||||
}
|
||||
|
||||
interface MCPConnectionManagerProps {
|
||||
children: ReactNode
|
||||
dynamicMcpConfig: Record<string, ScopedMcpServerConfig> | undefined
|
||||
isStrictMcpConfig: boolean
|
||||
children: ReactNode;
|
||||
dynamicMcpConfig: Record<string, ScopedMcpServerConfig> | undefined;
|
||||
isStrictMcpConfig: boolean;
|
||||
}
|
||||
|
||||
// TODO (ollie): We may be able to get rid of this context by putting these function on app state
|
||||
@@ -57,18 +44,8 @@ export function MCPConnectionManager({
|
||||
dynamicMcpConfig,
|
||||
isStrictMcpConfig,
|
||||
}: MCPConnectionManagerProps): React.ReactNode {
|
||||
const { reconnectMcpServer, toggleMcpServer } = useManageMCPConnections(
|
||||
dynamicMcpConfig,
|
||||
isStrictMcpConfig,
|
||||
)
|
||||
const value = useMemo(
|
||||
() => ({ reconnectMcpServer, toggleMcpServer }),
|
||||
[reconnectMcpServer, toggleMcpServer],
|
||||
)
|
||||
const { reconnectMcpServer, toggleMcpServer } = useManageMCPConnections(dynamicMcpConfig, isStrictMcpConfig);
|
||||
const value = useMemo(() => ({ reconnectMcpServer, toggleMcpServer }), [reconnectMcpServer, toggleMcpServer]);
|
||||
|
||||
return (
|
||||
<MCPConnectionContext.Provider value={value}>
|
||||
{children}
|
||||
</MCPConnectionContext.Provider>
|
||||
)
|
||||
return <MCPConnectionContext.Provider value={value}>{children}</MCPConnectionContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
// findChannelEntry extracted from ../channelNotification.ts (line 161)
|
||||
// Copied to avoid heavy import chain
|
||||
|
||||
type ChannelEntry = {
|
||||
kind: "server" | "plugin"
|
||||
kind: 'server' | 'plugin'
|
||||
name: string
|
||||
}
|
||||
|
||||
@@ -12,58 +12,58 @@ function findChannelEntry(
|
||||
serverName: string,
|
||||
channels: readonly ChannelEntry[],
|
||||
): ChannelEntry | undefined {
|
||||
const parts = serverName.split(":")
|
||||
const parts = serverName.split(':')
|
||||
return channels.find(c =>
|
||||
c.kind === "server"
|
||||
c.kind === 'server'
|
||||
? serverName === c.name
|
||||
: parts[0] === "plugin" && parts[1] === c.name,
|
||||
: parts[0] === 'plugin' && parts[1] === c.name,
|
||||
)
|
||||
}
|
||||
|
||||
describe("findChannelEntry", () => {
|
||||
test("finds server entry by exact name match", () => {
|
||||
const channels = [{ kind: "server" as const, name: "my-server" }]
|
||||
expect(findChannelEntry("my-server", channels)).toBeDefined()
|
||||
expect(findChannelEntry("my-server", channels)!.name).toBe("my-server")
|
||||
describe('findChannelEntry', () => {
|
||||
test('finds server entry by exact name match', () => {
|
||||
const channels = [{ kind: 'server' as const, name: 'my-server' }]
|
||||
expect(findChannelEntry('my-server', channels)).toBeDefined()
|
||||
expect(findChannelEntry('my-server', channels)!.name).toBe('my-server')
|
||||
})
|
||||
|
||||
test("finds plugin entry by matching second segment", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
|
||||
test('finds plugin entry by matching second segment', () => {
|
||||
const channels = [{ kind: 'plugin' as const, name: 'slack' }]
|
||||
expect(findChannelEntry('plugin:slack:tg', channels)).toBeDefined()
|
||||
})
|
||||
|
||||
test("returns undefined for no match", () => {
|
||||
const channels = [{ kind: "server" as const, name: "other" }]
|
||||
expect(findChannelEntry("my-server", channels)).toBeUndefined()
|
||||
test('returns undefined for no match', () => {
|
||||
const channels = [{ kind: 'server' as const, name: 'other' }]
|
||||
expect(findChannelEntry('my-server', channels)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("handles empty channels array", () => {
|
||||
expect(findChannelEntry("my-server", [])).toBeUndefined()
|
||||
test('handles empty channels array', () => {
|
||||
expect(findChannelEntry('my-server', [])).toBeUndefined()
|
||||
})
|
||||
|
||||
test("handles server name without colon", () => {
|
||||
const channels = [{ kind: "server" as const, name: "simple" }]
|
||||
expect(findChannelEntry("simple", channels)).toBeDefined()
|
||||
test('handles server name without colon', () => {
|
||||
const channels = [{ kind: 'server' as const, name: 'simple' }]
|
||||
expect(findChannelEntry('simple', channels)).toBeDefined()
|
||||
})
|
||||
|
||||
test("handles 'plugin:name' format correctly", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined()
|
||||
expect(findChannelEntry("plugin:discord:tg", channels)).toBeUndefined()
|
||||
const channels = [{ kind: 'plugin' as const, name: 'slack' }]
|
||||
expect(findChannelEntry('plugin:slack:tg', channels)).toBeDefined()
|
||||
expect(findChannelEntry('plugin:discord:tg', channels)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("prefers exact match (server kind) over partial match", () => {
|
||||
test('prefers exact match (server kind) over partial match', () => {
|
||||
const channels = [
|
||||
{ kind: "server" as const, name: "plugin:slack" },
|
||||
{ kind: "plugin" as const, name: "slack" },
|
||||
{ kind: 'server' as const, name: 'plugin:slack' },
|
||||
{ kind: 'plugin' as const, name: 'slack' },
|
||||
]
|
||||
const result = findChannelEntry("plugin:slack", channels)
|
||||
const result = findChannelEntry('plugin:slack', channels)
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.kind).toBe("server")
|
||||
expect(result!.kind).toBe('server')
|
||||
})
|
||||
|
||||
test("plugin kind does not match bare name", () => {
|
||||
const channels = [{ kind: "plugin" as const, name: "slack" }]
|
||||
expect(findChannelEntry("slack", channels)).toBeUndefined()
|
||||
test('plugin kind does not match bare name', () => {
|
||||
const channels = [{ kind: 'plugin' as const, name: 'slack' }]
|
||||
expect(findChannelEntry('slack', channels)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { mock, describe, expect, test } from 'bun:test'
|
||||
|
||||
mock.module("src/services/analytics/growthbook.js", () => ({
|
||||
mock.module('src/services/analytics/growthbook.js', () => ({
|
||||
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
|
||||
}));
|
||||
}))
|
||||
|
||||
const {
|
||||
filterPermissionRelayClients,
|
||||
@@ -10,185 +10,187 @@ const {
|
||||
truncateForPreview,
|
||||
PERMISSION_REPLY_RE,
|
||||
createChannelPermissionCallbacks,
|
||||
} = await import("../channelPermissions");
|
||||
} = await import('../channelPermissions')
|
||||
|
||||
describe("shortRequestId", () => {
|
||||
test("returns 5-char string from tool use ID", () => {
|
||||
const result = shortRequestId("toolu_abc123");
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
describe('shortRequestId', () => {
|
||||
test('returns 5-char string from tool use ID', () => {
|
||||
const result = shortRequestId('toolu_abc123')
|
||||
expect(result).toHaveLength(5)
|
||||
})
|
||||
|
||||
test("is deterministic (same input = same output)", () => {
|
||||
const a = shortRequestId("toolu_abc123");
|
||||
const b = shortRequestId("toolu_abc123");
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
test('is deterministic (same input = same output)', () => {
|
||||
const a = shortRequestId('toolu_abc123')
|
||||
const b = shortRequestId('toolu_abc123')
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
test("different inputs produce different outputs", () => {
|
||||
const a = shortRequestId("toolu_aaa");
|
||||
const b = shortRequestId("toolu_bbb");
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
test('different inputs produce different outputs', () => {
|
||||
const a = shortRequestId('toolu_aaa')
|
||||
const b = shortRequestId('toolu_bbb')
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
|
||||
test("result contains only valid letters (no 'l')", () => {
|
||||
const validChars = new Set("abcdefghijkmnopqrstuvwxyz");
|
||||
const validChars = new Set('abcdefghijkmnopqrstuvwxyz')
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const result = shortRequestId(`toolu_${i}`);
|
||||
const result = shortRequestId(`toolu_${i}`)
|
||||
for (const ch of result) {
|
||||
expect(validChars.has(ch)).toBe(true);
|
||||
expect(validChars.has(ch)).toBe(true)
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("handles empty string", () => {
|
||||
const result = shortRequestId("");
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
test('handles empty string', () => {
|
||||
const result = shortRequestId('')
|
||||
expect(result).toHaveLength(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("truncateForPreview", () => {
|
||||
test("returns JSON string for object input", () => {
|
||||
const result = truncateForPreview({ key: "value" });
|
||||
expect(result).toBe('{"key":"value"}');
|
||||
});
|
||||
describe('truncateForPreview', () => {
|
||||
test('returns JSON string for object input', () => {
|
||||
const result = truncateForPreview({ key: 'value' })
|
||||
expect(result).toBe('{"key":"value"}')
|
||||
})
|
||||
|
||||
test("truncates to <=200 chars with ellipsis when input is long", () => {
|
||||
const longObj = { data: "x".repeat(300) };
|
||||
const result = truncateForPreview(longObj);
|
||||
expect(result.length).toBeLessThanOrEqual(203); // 200 + '…'
|
||||
expect(result.endsWith("…")).toBe(true);
|
||||
});
|
||||
test('truncates to <=200 chars with ellipsis when input is long', () => {
|
||||
const longObj = { data: 'x'.repeat(300) }
|
||||
const result = truncateForPreview(longObj)
|
||||
expect(result.length).toBeLessThanOrEqual(203) // 200 + '…'
|
||||
expect(result.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns short input unchanged", () => {
|
||||
const result = truncateForPreview({ a: 1 });
|
||||
expect(result).toBe('{"a":1}');
|
||||
expect(result.endsWith("…")).toBe(false);
|
||||
});
|
||||
test('returns short input unchanged', () => {
|
||||
const result = truncateForPreview({ a: 1 })
|
||||
expect(result).toBe('{"a":1}')
|
||||
expect(result.endsWith('…')).toBe(false)
|
||||
})
|
||||
|
||||
test("handles string input", () => {
|
||||
const result = truncateForPreview("hello");
|
||||
expect(result).toBe('"hello"');
|
||||
});
|
||||
test('handles string input', () => {
|
||||
const result = truncateForPreview('hello')
|
||||
expect(result).toBe('"hello"')
|
||||
})
|
||||
|
||||
test("handles null input", () => {
|
||||
const result = truncateForPreview(null);
|
||||
expect(result).toBe("null");
|
||||
});
|
||||
test('handles null input', () => {
|
||||
const result = truncateForPreview(null)
|
||||
expect(result).toBe('null')
|
||||
})
|
||||
|
||||
test("handles undefined input", () => {
|
||||
const result = truncateForPreview(undefined);
|
||||
test('handles undefined input', () => {
|
||||
const result = truncateForPreview(undefined)
|
||||
// JSON.stringify(undefined) returns undefined, then .length throws → catch returns '(unserializable)'
|
||||
expect(result).toBe("(unserializable)");
|
||||
});
|
||||
});
|
||||
expect(result).toBe('(unserializable)')
|
||||
})
|
||||
})
|
||||
|
||||
describe("PERMISSION_REPLY_RE", () => {
|
||||
describe('PERMISSION_REPLY_RE', () => {
|
||||
test("matches 'y abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("y abcde")).toBe(true);
|
||||
});
|
||||
expect(PERMISSION_REPLY_RE.test('y abcde')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'yes abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("yes abcde")).toBe(true);
|
||||
});
|
||||
expect(PERMISSION_REPLY_RE.test('yes abcde')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'n abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("n abcde")).toBe(true);
|
||||
});
|
||||
expect(PERMISSION_REPLY_RE.test('n abcde')).toBe(true)
|
||||
})
|
||||
|
||||
test("matches 'no abcde'", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("no abcde")).toBe(true);
|
||||
});
|
||||
expect(PERMISSION_REPLY_RE.test('no abcde')).toBe(true)
|
||||
})
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("Y abcde")).toBe(true);
|
||||
expect(PERMISSION_REPLY_RE.test("YES abcde")).toBe(true);
|
||||
});
|
||||
test('is case-insensitive', () => {
|
||||
expect(PERMISSION_REPLY_RE.test('Y abcde')).toBe(true)
|
||||
expect(PERMISSION_REPLY_RE.test('YES abcde')).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match without ID", () => {
|
||||
expect(PERMISSION_REPLY_RE.test("yes")).toBe(false);
|
||||
});
|
||||
test('does not match without ID', () => {
|
||||
expect(PERMISSION_REPLY_RE.test('yes')).toBe(false)
|
||||
})
|
||||
|
||||
test("captures the ID from reply", () => {
|
||||
const match = "y abcde".match(PERMISSION_REPLY_RE);
|
||||
expect(match?.[2]).toBe("abcde");
|
||||
});
|
||||
});
|
||||
test('captures the ID from reply', () => {
|
||||
const match = 'y abcde'.match(PERMISSION_REPLY_RE)
|
||||
expect(match?.[2]).toBe('abcde')
|
||||
})
|
||||
})
|
||||
|
||||
describe("createChannelPermissionCallbacks", () => {
|
||||
test("resolve returns false for unknown request ID", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
expect(cb.resolve("unknown-id", "allow", "server")).toBe(false);
|
||||
});
|
||||
describe('createChannelPermissionCallbacks', () => {
|
||||
test('resolve returns false for unknown request ID', () => {
|
||||
const cb = createChannelPermissionCallbacks()
|
||||
expect(cb.resolve('unknown-id', 'allow', 'server')).toBe(false)
|
||||
})
|
||||
|
||||
test("onResponse + resolve triggers handler", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let received: any = null;
|
||||
cb.onResponse("test-id", (response) => {
|
||||
received = response;
|
||||
});
|
||||
expect(cb.resolve("test-id", "allow", "test-server")).toBe(true);
|
||||
test('onResponse + resolve triggers handler', () => {
|
||||
const cb = createChannelPermissionCallbacks()
|
||||
let received: any = null
|
||||
cb.onResponse('test-id', response => {
|
||||
received = response
|
||||
})
|
||||
expect(cb.resolve('test-id', 'allow', 'test-server')).toBe(true)
|
||||
expect(received).toEqual({
|
||||
behavior: "allow",
|
||||
fromServer: "test-server",
|
||||
});
|
||||
});
|
||||
behavior: 'allow',
|
||||
fromServer: 'test-server',
|
||||
})
|
||||
})
|
||||
|
||||
test("onResponse unsubscribe prevents resolve", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let called = false;
|
||||
const unsub = cb.onResponse("test-id", () => {
|
||||
called = true;
|
||||
});
|
||||
unsub();
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
test('onResponse unsubscribe prevents resolve', () => {
|
||||
const cb = createChannelPermissionCallbacks()
|
||||
let called = false
|
||||
const unsub = cb.onResponse('test-id', () => {
|
||||
called = true
|
||||
})
|
||||
unsub()
|
||||
expect(cb.resolve('test-id', 'allow', 'server')).toBe(false)
|
||||
expect(called).toBe(false)
|
||||
})
|
||||
|
||||
test("duplicate resolve returns false (already consumed)", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
cb.onResponse("test-id", () => {});
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(true);
|
||||
expect(cb.resolve("test-id", "allow", "server")).toBe(false);
|
||||
});
|
||||
test('duplicate resolve returns false (already consumed)', () => {
|
||||
const cb = createChannelPermissionCallbacks()
|
||||
cb.onResponse('test-id', () => {})
|
||||
expect(cb.resolve('test-id', 'allow', 'server')).toBe(true)
|
||||
expect(cb.resolve('test-id', 'allow', 'server')).toBe(false)
|
||||
})
|
||||
|
||||
test("is case-insensitive for request IDs", () => {
|
||||
const cb = createChannelPermissionCallbacks();
|
||||
let received: any = null;
|
||||
cb.onResponse("ABC", (response) => {
|
||||
received = response;
|
||||
});
|
||||
expect(cb.resolve("abc", "deny", "server")).toBe(true);
|
||||
expect(received?.behavior).toBe("deny");
|
||||
});
|
||||
});
|
||||
test('is case-insensitive for request IDs', () => {
|
||||
const cb = createChannelPermissionCallbacks()
|
||||
let received: any = null
|
||||
cb.onResponse('ABC', response => {
|
||||
received = response
|
||||
})
|
||||
expect(cb.resolve('abc', 'deny', 'server')).toBe(true)
|
||||
expect(received?.behavior).toBe('deny')
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterPermissionRelayClients", () => {
|
||||
test("requires truthy permission capability", () => {
|
||||
describe('filterPermissionRelayClients', () => {
|
||||
test('requires truthy permission capability', () => {
|
||||
const clients = [
|
||||
{
|
||||
type: "connected",
|
||||
name: "plugin:weixin:weixin",
|
||||
type: 'connected',
|
||||
name: 'plugin:weixin:weixin',
|
||||
capabilities: {
|
||||
experimental: {
|
||||
"claude/channel": {},
|
||||
"claude/channel/permission": false,
|
||||
'claude/channel': {},
|
||||
'claude/channel/permission': false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "connected",
|
||||
name: "plugin:telegram:telegram",
|
||||
type: 'connected',
|
||||
name: 'plugin:telegram:telegram',
|
||||
capabilities: {
|
||||
experimental: {
|
||||
"claude/channel": {},
|
||||
"claude/channel/permission": {},
|
||||
'claude/channel': {},
|
||||
'claude/channel/permission': {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
expect(
|
||||
filterPermissionRelayClients(clients, () => true).map(client => client.name),
|
||||
).toEqual(["plugin:telegram:telegram"]);
|
||||
});
|
||||
});
|
||||
filterPermissionRelayClients(clients, () => true).map(
|
||||
client => client.name,
|
||||
),
|
||||
).toEqual(['plugin:telegram:telegram'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,148 +1,148 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { expandEnvVarsInString } from "../envExpansion";
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { expandEnvVarsInString } from '../envExpansion'
|
||||
|
||||
const ENV_OPEN = "$" + "{";
|
||||
const ENV_CLOSE = "}";
|
||||
const envExpr = (value: string): string => `${ENV_OPEN}${value}${ENV_CLOSE}`;
|
||||
const ENV_OPEN = '$' + '{'
|
||||
const ENV_CLOSE = '}'
|
||||
const envExpr = (value: string): string => `${ENV_OPEN}${value}${ENV_CLOSE}`
|
||||
|
||||
describe("expandEnvVarsInString", () => {
|
||||
describe('expandEnvVarsInString', () => {
|
||||
// Save and restore env vars touched by tests
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
const savedEnv: Record<string, string | undefined> = {}
|
||||
const trackedKeys = [
|
||||
"TEST_HOME",
|
||||
"MISSING",
|
||||
"TEST_A",
|
||||
"TEST_B",
|
||||
"TEST_EMPTY",
|
||||
"TEST_X",
|
||||
"VAR",
|
||||
"TEST_FOUND",
|
||||
];
|
||||
'TEST_HOME',
|
||||
'MISSING',
|
||||
'TEST_A',
|
||||
'TEST_B',
|
||||
'TEST_EMPTY',
|
||||
'TEST_X',
|
||||
'VAR',
|
||||
'TEST_FOUND',
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of trackedKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
savedEnv[key] = process.env[key]
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of trackedKeys) {
|
||||
if (savedEnv[key] === undefined) {
|
||||
delete process.env[key];
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = savedEnv[key];
|
||||
process.env[key] = savedEnv[key]
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("expands a single env var that exists", () => {
|
||||
process.env.TEST_HOME = "/home/user";
|
||||
const result = expandEnvVarsInString(envExpr("TEST_HOME"));
|
||||
expect(result.expanded).toBe("/home/user");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('expands a single env var that exists', () => {
|
||||
process.env.TEST_HOME = '/home/user'
|
||||
const result = expandEnvVarsInString(envExpr('TEST_HOME'))
|
||||
expect(result.expanded).toBe('/home/user')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("returns original placeholder and tracks missing var when not found", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString(envExpr("MISSING"));
|
||||
expect(result.expanded).toBe(envExpr("MISSING"));
|
||||
expect(result.missingVars).toEqual(["MISSING"]);
|
||||
});
|
||||
test('returns original placeholder and tracks missing var when not found', () => {
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(envExpr('MISSING'))
|
||||
expect(result.expanded).toBe(envExpr('MISSING'))
|
||||
expect(result.missingVars).toEqual(['MISSING'])
|
||||
})
|
||||
|
||||
test("uses default value when var is missing and default is provided", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString(envExpr("MISSING:-fallback"));
|
||||
expect(result.expanded).toBe("fallback");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('uses default value when var is missing and default is provided', () => {
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(envExpr('MISSING:-fallback'))
|
||||
expect(result.expanded).toBe('fallback')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("expands multiple vars", () => {
|
||||
process.env.TEST_A = "hello";
|
||||
process.env.TEST_B = "world";
|
||||
test('expands multiple vars', () => {
|
||||
process.env.TEST_A = 'hello'
|
||||
process.env.TEST_B = 'world'
|
||||
const result = expandEnvVarsInString(
|
||||
`${envExpr("TEST_A")}/${envExpr("TEST_B")}`,
|
||||
);
|
||||
expect(result.expanded).toBe("hello/world");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
`${envExpr('TEST_A')}/${envExpr('TEST_B')}`,
|
||||
)
|
||||
expect(result.expanded).toBe('hello/world')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles mix of found and missing vars", () => {
|
||||
process.env.TEST_FOUND = "yes";
|
||||
delete process.env.MISSING;
|
||||
test('handles mix of found and missing vars', () => {
|
||||
process.env.TEST_FOUND = 'yes'
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(
|
||||
`${envExpr("TEST_FOUND")}-${envExpr("MISSING")}`,
|
||||
);
|
||||
expect(result.expanded).toBe(`yes-${envExpr("MISSING")}`);
|
||||
expect(result.missingVars).toEqual(["MISSING"]);
|
||||
});
|
||||
`${envExpr('TEST_FOUND')}-${envExpr('MISSING')}`,
|
||||
)
|
||||
expect(result.expanded).toBe(`yes-${envExpr('MISSING')}`)
|
||||
expect(result.missingVars).toEqual(['MISSING'])
|
||||
})
|
||||
|
||||
test("returns plain string unchanged with empty missingVars", () => {
|
||||
const result = expandEnvVarsInString("plain string");
|
||||
expect(result.expanded).toBe("plain string");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('returns plain string unchanged with empty missingVars', () => {
|
||||
const result = expandEnvVarsInString('plain string')
|
||||
expect(result.expanded).toBe('plain string')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("expands empty env var value", () => {
|
||||
process.env.TEST_EMPTY = "";
|
||||
const result = expandEnvVarsInString(envExpr("TEST_EMPTY"));
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('expands empty env var value', () => {
|
||||
process.env.TEST_EMPTY = ''
|
||||
const result = expandEnvVarsInString(envExpr('TEST_EMPTY'))
|
||||
expect(result.expanded).toBe('')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("prefers env var value over default when var exists", () => {
|
||||
process.env.TEST_X = "real";
|
||||
const result = expandEnvVarsInString(envExpr("TEST_X:-default"));
|
||||
expect(result.expanded).toBe("real");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('prefers env var value over default when var exists', () => {
|
||||
process.env.TEST_X = 'real'
|
||||
const result = expandEnvVarsInString(envExpr('TEST_X:-default'))
|
||||
expect(result.expanded).toBe('real')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles default value containing colons", () => {
|
||||
test('handles default value containing colons', () => {
|
||||
// split(':-', 2) means only the first :- is the delimiter
|
||||
delete process.env.TEST_X;
|
||||
const result = expandEnvVarsInString(envExpr("TEST_X:-value:-with:-colons"));
|
||||
delete process.env.TEST_X
|
||||
const result = expandEnvVarsInString(envExpr('TEST_X:-value:-with:-colons'))
|
||||
// The default is "value" because split(':-', 2) gives ["TEST_X", "value"]
|
||||
// Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives:
|
||||
// ["TEST_X", "value"] because limit=2 stops at 2 pieces
|
||||
expect(result.expanded).toBe("value");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
expect(result.expanded).toBe('value')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles nested-looking syntax as literal (not supported)", () => {
|
||||
test('handles nested-looking syntax as literal (not supported)', () => {
|
||||
// ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first })
|
||||
// so varName would be "${VAR" which won't be found in env
|
||||
delete process.env.VAR;
|
||||
const nestedExpr = `${ENV_OPEN}${envExpr("VAR")}${ENV_CLOSE}`;
|
||||
const result = expandEnvVarsInString(nestedExpr);
|
||||
delete process.env.VAR
|
||||
const nestedExpr = `${ENV_OPEN}${envExpr('VAR')}${ENV_CLOSE}`
|
||||
const result = expandEnvVarsInString(nestedExpr)
|
||||
// The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR"
|
||||
// That env var won't exist, so it stays as "${${VAR}" + remaining "}"
|
||||
expect(result.missingVars).toEqual([`${ENV_OPEN}VAR`]);
|
||||
expect(result.expanded).toBe(nestedExpr);
|
||||
});
|
||||
expect(result.missingVars).toEqual([`${ENV_OPEN}VAR`])
|
||||
expect(result.expanded).toBe(nestedExpr)
|
||||
})
|
||||
|
||||
test("handles empty string input", () => {
|
||||
const result = expandEnvVarsInString("");
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('handles empty string input', () => {
|
||||
const result = expandEnvVarsInString('')
|
||||
expect(result.expanded).toBe('')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles var surrounded by text", () => {
|
||||
process.env.TEST_A = "middle";
|
||||
const result = expandEnvVarsInString(`before-${envExpr("TEST_A")}-after`);
|
||||
expect(result.expanded).toBe("before-middle-after");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('handles var surrounded by text', () => {
|
||||
process.env.TEST_A = 'middle'
|
||||
const result = expandEnvVarsInString(`before-${envExpr('TEST_A')}-after`)
|
||||
expect(result.expanded).toBe('before-middle-after')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("handles default value that is empty string", () => {
|
||||
delete process.env.MISSING;
|
||||
const result = expandEnvVarsInString(envExpr("MISSING:-"));
|
||||
expect(result.expanded).toBe("");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
test('handles default value that is empty string', () => {
|
||||
delete process.env.MISSING
|
||||
const result = expandEnvVarsInString(envExpr('MISSING:-'))
|
||||
expect(result.expanded).toBe('')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
|
||||
test("does not expand $VAR without braces", () => {
|
||||
process.env.TEST_A = "value";
|
||||
const result = expandEnvVarsInString("$TEST_A");
|
||||
expect(result.expanded).toBe("$TEST_A");
|
||||
expect(result.missingVars).toEqual([]);
|
||||
});
|
||||
});
|
||||
test('does not expand $VAR without braces', () => {
|
||||
process.env.TEST_A = 'value'
|
||||
const result = expandEnvVarsInString('$TEST_A')
|
||||
expect(result.expanded).toBe('$TEST_A')
|
||||
expect(result.missingVars).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
// parseHeaders is a pure function from ../utils.ts (line 325)
|
||||
// Copied here to avoid triggering the heavy import chain of utils.ts
|
||||
function parseHeaders(headerArray: string[]): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
for (const header of headerArray) {
|
||||
const colonIndex = header.indexOf(":")
|
||||
const colonIndex = header.indexOf(':')
|
||||
if (colonIndex === -1) {
|
||||
throw new Error(
|
||||
`Invalid header format: "${header}". Expected format: "Header-Name: value"`,
|
||||
@@ -23,43 +23,43 @@ function parseHeaders(headerArray: string[]): Record<string, string> {
|
||||
return headers
|
||||
}
|
||||
|
||||
describe("parseHeaders", () => {
|
||||
describe('parseHeaders', () => {
|
||||
test("parses 'Key: Value' format", () => {
|
||||
expect(parseHeaders(["Content-Type: application/json"])).toEqual({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
});
|
||||
expect(parseHeaders(['Content-Type: application/json'])).toEqual({
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
})
|
||||
|
||||
test("parses multiple headers", () => {
|
||||
expect(parseHeaders(["Key1: val1", "Key2: val2"])).toEqual({
|
||||
Key1: "val1",
|
||||
Key2: "val2",
|
||||
});
|
||||
});
|
||||
test('parses multiple headers', () => {
|
||||
expect(parseHeaders(['Key1: val1', 'Key2: val2'])).toEqual({
|
||||
Key1: 'val1',
|
||||
Key2: 'val2',
|
||||
})
|
||||
})
|
||||
|
||||
test("trims whitespace around key and value", () => {
|
||||
expect(parseHeaders([" Key : Value "])).toEqual({ Key: "Value" });
|
||||
});
|
||||
test('trims whitespace around key and value', () => {
|
||||
expect(parseHeaders([' Key : Value '])).toEqual({ Key: 'Value' })
|
||||
})
|
||||
|
||||
test("throws on missing colon", () => {
|
||||
expect(() => parseHeaders(["no colon here"])).toThrow();
|
||||
});
|
||||
test('throws on missing colon', () => {
|
||||
expect(() => parseHeaders(['no colon here'])).toThrow()
|
||||
})
|
||||
|
||||
test("throws on empty key", () => {
|
||||
expect(() => parseHeaders([": value"])).toThrow();
|
||||
});
|
||||
test('throws on empty key', () => {
|
||||
expect(() => parseHeaders([': value'])).toThrow()
|
||||
})
|
||||
|
||||
test("handles value with colons (like URLs)", () => {
|
||||
expect(parseHeaders(["url: http://example.com:8080"])).toEqual({
|
||||
url: "http://example.com:8080",
|
||||
});
|
||||
});
|
||||
test('handles value with colons (like URLs)', () => {
|
||||
expect(parseHeaders(['url: http://example.com:8080'])).toEqual({
|
||||
url: 'http://example.com:8080',
|
||||
})
|
||||
})
|
||||
|
||||
test("returns empty object for empty array", () => {
|
||||
expect(parseHeaders([])).toEqual({});
|
||||
});
|
||||
test('returns empty object for empty array', () => {
|
||||
expect(parseHeaders([])).toEqual({})
|
||||
})
|
||||
|
||||
test("handles duplicate keys (last wins)", () => {
|
||||
expect(parseHeaders(["K: v1", "K: v2"])).toEqual({ K: "v2" });
|
||||
});
|
||||
});
|
||||
test('handles duplicate keys (last wins)', () => {
|
||||
expect(parseHeaders(['K: v1', 'K: v2'])).toEqual({ K: 'v2' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
mcpInfoFromString,
|
||||
buildMcpToolName,
|
||||
@@ -6,135 +6,133 @@ import {
|
||||
getMcpDisplayName,
|
||||
getToolNameForPermissionCheck,
|
||||
extractMcpToolDisplayName,
|
||||
} from "../mcpStringUtils";
|
||||
} from '../mcpStringUtils'
|
||||
|
||||
// ─── mcpInfoFromString ─────────────────────────────────────────────────
|
||||
|
||||
describe("mcpInfoFromString", () => {
|
||||
test("parses standard mcp tool name", () => {
|
||||
const result = mcpInfoFromString("mcp__github__list_issues");
|
||||
expect(result).toEqual({ serverName: "github", toolName: "list_issues" });
|
||||
});
|
||||
describe('mcpInfoFromString', () => {
|
||||
test('parses standard mcp tool name', () => {
|
||||
const result = mcpInfoFromString('mcp__github__list_issues')
|
||||
expect(result).toEqual({ serverName: 'github', toolName: 'list_issues' })
|
||||
})
|
||||
|
||||
test("returns null for non-mcp string", () => {
|
||||
expect(mcpInfoFromString("Bash")).toBeNull();
|
||||
expect(mcpInfoFromString("grep__pattern")).toBeNull();
|
||||
});
|
||||
test('returns null for non-mcp string', () => {
|
||||
expect(mcpInfoFromString('Bash')).toBeNull()
|
||||
expect(mcpInfoFromString('grep__pattern')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null when no server name", () => {
|
||||
expect(mcpInfoFromString("mcp__")).toBeNull();
|
||||
});
|
||||
test('returns null when no server name', () => {
|
||||
expect(mcpInfoFromString('mcp__')).toBeNull()
|
||||
})
|
||||
|
||||
test("handles server name only (no tool)", () => {
|
||||
const result = mcpInfoFromString("mcp__server");
|
||||
expect(result).toEqual({ serverName: "server", toolName: undefined });
|
||||
});
|
||||
test('handles server name only (no tool)', () => {
|
||||
const result = mcpInfoFromString('mcp__server')
|
||||
expect(result).toEqual({ serverName: 'server', toolName: undefined })
|
||||
})
|
||||
|
||||
test("preserves double underscores in tool name", () => {
|
||||
const result = mcpInfoFromString("mcp__server__tool__with__underscores");
|
||||
test('preserves double underscores in tool name', () => {
|
||||
const result = mcpInfoFromString('mcp__server__tool__with__underscores')
|
||||
expect(result).toEqual({
|
||||
serverName: "server",
|
||||
toolName: "tool__with__underscores",
|
||||
});
|
||||
});
|
||||
serverName: 'server',
|
||||
toolName: 'tool__with__underscores',
|
||||
})
|
||||
})
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(mcpInfoFromString("")).toBeNull();
|
||||
});
|
||||
});
|
||||
test('returns null for empty string', () => {
|
||||
expect(mcpInfoFromString('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getMcpPrefix ──────────────────────────────────────────────────────
|
||||
|
||||
describe("getMcpPrefix", () => {
|
||||
test("creates prefix from server name", () => {
|
||||
expect(getMcpPrefix("github")).toBe("mcp__github__");
|
||||
});
|
||||
describe('getMcpPrefix', () => {
|
||||
test('creates prefix from server name', () => {
|
||||
expect(getMcpPrefix('github')).toBe('mcp__github__')
|
||||
})
|
||||
|
||||
test("normalizes server name with special chars", () => {
|
||||
expect(getMcpPrefix("my-server")).toBe("mcp__my-server__");
|
||||
});
|
||||
test('normalizes server name with special chars', () => {
|
||||
expect(getMcpPrefix('my-server')).toBe('mcp__my-server__')
|
||||
})
|
||||
|
||||
test("normalizes dots to underscores", () => {
|
||||
expect(getMcpPrefix("my.server")).toBe("mcp__my_server__");
|
||||
});
|
||||
});
|
||||
test('normalizes dots to underscores', () => {
|
||||
expect(getMcpPrefix('my.server')).toBe('mcp__my_server__')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── buildMcpToolName ──────────────────────────────────────────────────
|
||||
|
||||
describe("buildMcpToolName", () => {
|
||||
test("builds fully qualified name", () => {
|
||||
expect(buildMcpToolName("github", "list_issues")).toBe(
|
||||
"mcp__github__list_issues"
|
||||
);
|
||||
});
|
||||
describe('buildMcpToolName', () => {
|
||||
test('builds fully qualified name', () => {
|
||||
expect(buildMcpToolName('github', 'list_issues')).toBe(
|
||||
'mcp__github__list_issues',
|
||||
)
|
||||
})
|
||||
|
||||
test("normalizes both server and tool names", () => {
|
||||
expect(buildMcpToolName("my.server", "my.tool")).toBe(
|
||||
"mcp__my_server__my_tool"
|
||||
);
|
||||
});
|
||||
});
|
||||
test('normalizes both server and tool names', () => {
|
||||
expect(buildMcpToolName('my.server', 'my.tool')).toBe(
|
||||
'mcp__my_server__my_tool',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getMcpDisplayName ─────────────────────────────────────────────────
|
||||
|
||||
describe("getMcpDisplayName", () => {
|
||||
test("strips mcp prefix from full name", () => {
|
||||
expect(getMcpDisplayName("mcp__github__list_issues", "github")).toBe(
|
||||
"list_issues"
|
||||
);
|
||||
});
|
||||
describe('getMcpDisplayName', () => {
|
||||
test('strips mcp prefix from full name', () => {
|
||||
expect(getMcpDisplayName('mcp__github__list_issues', 'github')).toBe(
|
||||
'list_issues',
|
||||
)
|
||||
})
|
||||
|
||||
test("returns full name if prefix doesn't match", () => {
|
||||
expect(getMcpDisplayName("mcp__other__tool", "github")).toBe(
|
||||
"mcp__other__tool"
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(getMcpDisplayName('mcp__other__tool', 'github')).toBe(
|
||||
'mcp__other__tool',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getToolNameForPermissionCheck ─────────────────────────────────────
|
||||
|
||||
describe("getToolNameForPermissionCheck", () => {
|
||||
test("returns built MCP name for MCP tools", () => {
|
||||
describe('getToolNameForPermissionCheck', () => {
|
||||
test('returns built MCP name for MCP tools', () => {
|
||||
const tool = {
|
||||
name: "list_issues",
|
||||
mcpInfo: { serverName: "github", toolName: "list_issues" },
|
||||
};
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe(
|
||||
"mcp__github__list_issues"
|
||||
);
|
||||
});
|
||||
name: 'list_issues',
|
||||
mcpInfo: { serverName: 'github', toolName: 'list_issues' },
|
||||
}
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe('mcp__github__list_issues')
|
||||
})
|
||||
|
||||
test("returns tool name for non-MCP tools", () => {
|
||||
const tool = { name: "Bash" };
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe("Bash");
|
||||
});
|
||||
test('returns tool name for non-MCP tools', () => {
|
||||
const tool = { name: 'Bash' }
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe('Bash')
|
||||
})
|
||||
|
||||
test("returns tool name when mcpInfo is undefined", () => {
|
||||
const tool = { name: "Write", mcpInfo: undefined };
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe("Write");
|
||||
});
|
||||
});
|
||||
test('returns tool name when mcpInfo is undefined', () => {
|
||||
const tool = { name: 'Write', mcpInfo: undefined }
|
||||
expect(getToolNameForPermissionCheck(tool)).toBe('Write')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── extractMcpToolDisplayName ─────────────────────────────────────────
|
||||
|
||||
describe("extractMcpToolDisplayName", () => {
|
||||
test("extracts display name from full user-facing name", () => {
|
||||
describe('extractMcpToolDisplayName', () => {
|
||||
test('extracts display name from full user-facing name', () => {
|
||||
expect(
|
||||
extractMcpToolDisplayName("github - Add comment to issue (MCP)")
|
||||
).toBe("Add comment to issue");
|
||||
});
|
||||
extractMcpToolDisplayName('github - Add comment to issue (MCP)'),
|
||||
).toBe('Add comment to issue')
|
||||
})
|
||||
|
||||
test("removes (MCP) suffix only", () => {
|
||||
expect(extractMcpToolDisplayName("simple-tool (MCP)")).toBe("simple-tool");
|
||||
});
|
||||
test('removes (MCP) suffix only', () => {
|
||||
expect(extractMcpToolDisplayName('simple-tool (MCP)')).toBe('simple-tool')
|
||||
})
|
||||
|
||||
test("handles name without (MCP) suffix", () => {
|
||||
expect(extractMcpToolDisplayName("github - List issues")).toBe(
|
||||
"List issues"
|
||||
);
|
||||
});
|
||||
test('handles name without (MCP) suffix', () => {
|
||||
expect(extractMcpToolDisplayName('github - List issues')).toBe(
|
||||
'List issues',
|
||||
)
|
||||
})
|
||||
|
||||
test("handles name without dash separator", () => {
|
||||
expect(extractMcpToolDisplayName("just-a-name")).toBe("just-a-name");
|
||||
});
|
||||
});
|
||||
test('handles name without dash separator', () => {
|
||||
expect(extractMcpToolDisplayName('just-a-name')).toBe('just-a-name')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeNameForMCP } from "../normalization";
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { normalizeNameForMCP } from '../normalization'
|
||||
|
||||
describe("normalizeNameForMCP", () => {
|
||||
test("returns simple valid name unchanged", () => {
|
||||
expect(normalizeNameForMCP("my-server")).toBe("my-server");
|
||||
});
|
||||
describe('normalizeNameForMCP', () => {
|
||||
test('returns simple valid name unchanged', () => {
|
||||
expect(normalizeNameForMCP('my-server')).toBe('my-server')
|
||||
})
|
||||
|
||||
test("replaces dots with underscores", () => {
|
||||
expect(normalizeNameForMCP("my.server.name")).toBe("my_server_name");
|
||||
});
|
||||
test('replaces dots with underscores', () => {
|
||||
expect(normalizeNameForMCP('my.server.name')).toBe('my_server_name')
|
||||
})
|
||||
|
||||
test("replaces spaces with underscores", () => {
|
||||
expect(normalizeNameForMCP("my server")).toBe("my_server");
|
||||
});
|
||||
test('replaces spaces with underscores', () => {
|
||||
expect(normalizeNameForMCP('my server')).toBe('my_server')
|
||||
})
|
||||
|
||||
test("replaces special characters with underscores", () => {
|
||||
expect(normalizeNameForMCP("server@v2!")).toBe("server_v2_");
|
||||
});
|
||||
test('replaces special characters with underscores', () => {
|
||||
expect(normalizeNameForMCP('server@v2!')).toBe('server_v2_')
|
||||
})
|
||||
|
||||
test("returns already valid name unchanged", () => {
|
||||
expect(normalizeNameForMCP("valid_name-123")).toBe("valid_name-123");
|
||||
});
|
||||
test('returns already valid name unchanged', () => {
|
||||
expect(normalizeNameForMCP('valid_name-123')).toBe('valid_name-123')
|
||||
})
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(normalizeNameForMCP("")).toBe("");
|
||||
});
|
||||
test('returns empty string for empty input', () => {
|
||||
expect(normalizeNameForMCP('')).toBe('')
|
||||
})
|
||||
|
||||
test("handles claude.ai prefix: collapses consecutive underscores and strips edges", () => {
|
||||
test('handles claude.ai prefix: collapses consecutive underscores and strips edges', () => {
|
||||
// "claude.ai My Server" -> replace invalid -> "claude_ai_My_Server"
|
||||
// starts with "claude.ai " so collapse + strip -> "claude_ai_My_Server"
|
||||
expect(normalizeNameForMCP("claude.ai My Server")).toBe(
|
||||
"claude_ai_My_Server"
|
||||
);
|
||||
});
|
||||
expect(normalizeNameForMCP('claude.ai My Server')).toBe(
|
||||
'claude_ai_My_Server',
|
||||
)
|
||||
})
|
||||
|
||||
test("handles claude.ai prefix with consecutive invalid chars", () => {
|
||||
test('handles claude.ai prefix with consecutive invalid chars', () => {
|
||||
// "claude.ai ...test..." -> replace invalid -> "claude_ai____test___"
|
||||
// collapse consecutive _ -> "claude_ai_test_"
|
||||
// strip leading/trailing _ -> "claude_ai_test"
|
||||
expect(normalizeNameForMCP("claude.ai ...test...")).toBe("claude_ai_test");
|
||||
});
|
||||
expect(normalizeNameForMCP('claude.ai ...test...')).toBe('claude_ai_test')
|
||||
})
|
||||
|
||||
test("non-claude.ai name preserves consecutive underscores", () => {
|
||||
test('non-claude.ai name preserves consecutive underscores', () => {
|
||||
// "a..b" -> "a__b", no claude.ai prefix so no collapse
|
||||
expect(normalizeNameForMCP("a..b")).toBe("a__b");
|
||||
});
|
||||
expect(normalizeNameForMCP('a..b')).toBe('a__b')
|
||||
})
|
||||
|
||||
test("non-claude.ai name preserves trailing underscores", () => {
|
||||
expect(normalizeNameForMCP("name!")).toBe("name_");
|
||||
});
|
||||
test('non-claude.ai name preserves trailing underscores', () => {
|
||||
expect(normalizeNameForMCP('name!')).toBe('name_')
|
||||
})
|
||||
|
||||
test("handles claude.ai prefix that results in only underscores", () => {
|
||||
test('handles claude.ai prefix that results in only underscores', () => {
|
||||
// "claude.ai ..." -> replace invalid -> "claude_ai____"
|
||||
// collapse -> "claude_ai_"
|
||||
// strip trailing -> "claude_ai"
|
||||
expect(normalizeNameForMCP("claude.ai ...")).toBe("claude_ai");
|
||||
});
|
||||
});
|
||||
expect(normalizeNameForMCP('claude.ai ...')).toBe('claude_ai')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { mock, describe, expect, test, afterEach } from "bun:test";
|
||||
import { debugMock } from "../../../../tests/mocks/debug";
|
||||
import { mock, describe, expect, test, afterEach } from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug'
|
||||
|
||||
mock.module("axios", () => ({
|
||||
mock.module('axios', () => ({
|
||||
default: { get: async () => ({ data: { servers: [] } }) },
|
||||
}));
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
}))
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import(
|
||||
"../officialRegistry"
|
||||
);
|
||||
'../officialRegistry'
|
||||
)
|
||||
|
||||
describe("isOfficialMcpUrl", () => {
|
||||
describe('isOfficialMcpUrl', () => {
|
||||
afterEach(() => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
});
|
||||
resetOfficialMcpUrlsForTesting()
|
||||
})
|
||||
|
||||
test("returns false when registry not loaded (initial state)", () => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
expect(isOfficialMcpUrl("https://example.com")).toBe(false);
|
||||
});
|
||||
test('returns false when registry not loaded (initial state)', () => {
|
||||
resetOfficialMcpUrlsForTesting()
|
||||
expect(isOfficialMcpUrl('https://example.com')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for non-registered URL", () => {
|
||||
expect(isOfficialMcpUrl("https://random-server.com/mcp")).toBe(false);
|
||||
});
|
||||
test('returns false for non-registered URL', () => {
|
||||
expect(isOfficialMcpUrl('https://random-server.com/mcp')).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for empty string", () => {
|
||||
expect(isOfficialMcpUrl("")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for empty string', () => {
|
||||
expect(isOfficialMcpUrl('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetOfficialMcpUrlsForTesting", () => {
|
||||
test("can be called without error", () => {
|
||||
expect(() => resetOfficialMcpUrlsForTesting()).not.toThrow();
|
||||
});
|
||||
describe('resetOfficialMcpUrlsForTesting', () => {
|
||||
test('can be called without error', () => {
|
||||
expect(() => resetOfficialMcpUrlsForTesting()).not.toThrow()
|
||||
})
|
||||
|
||||
test("clears state so subsequent lookups return false", () => {
|
||||
resetOfficialMcpUrlsForTesting();
|
||||
expect(isOfficialMcpUrl("https://anything.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('clears state so subsequent lookups return false', () => {
|
||||
resetOfficialMcpUrlsForTesting()
|
||||
expect(isOfficialMcpUrl('https://anything.com')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,13 @@ import {
|
||||
export function createMcpAnalytics(): AnalyticsSink {
|
||||
return {
|
||||
trackEvent(event: string, metadata: Record<string, unknown>) {
|
||||
logEvent(event, metadata as Record<string, AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS>)
|
||||
logEvent(
|
||||
event,
|
||||
metadata as Record<
|
||||
string,
|
||||
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||||
>,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@ import { maybeResizeAndDownsampleImageBuffer } from '../../../utils/imageResizer
|
||||
export function createMcpImageProcessor(): ImageProcessor {
|
||||
return {
|
||||
async resizeAndDownsample(buffer: Buffer) {
|
||||
const result = await maybeResizeAndDownsampleImageBuffer(buffer, buffer.length, 'png')
|
||||
const result = await maybeResizeAndDownsampleImageBuffer(
|
||||
buffer,
|
||||
buffer.length,
|
||||
'png',
|
||||
)
|
||||
return result.buffer
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import type { ContentStorage } from '@claude-code-best/mcp-client'
|
||||
import { persistBinaryContent } from '../../../utils/mcpOutputStorage.js'
|
||||
import { persistToolResult, isPersistError } from '../../../utils/toolResultStorage.js'
|
||||
import {
|
||||
persistToolResult,
|
||||
isPersistError,
|
||||
} from '../../../utils/toolResultStorage.js'
|
||||
|
||||
/**
|
||||
* Creates a ContentStorage implementation using the host's binary persistence.
|
||||
@@ -10,7 +13,11 @@ import { persistToolResult, isPersistError } from '../../../utils/toolResultStor
|
||||
export function createMcpStorage(): ContentStorage {
|
||||
return {
|
||||
async persistBinaryContent(data: Buffer, ext: string) {
|
||||
const result = await persistBinaryContent(data, ext, `mcp-adapter-${Date.now()}`)
|
||||
const result = await persistBinaryContent(
|
||||
data,
|
||||
ext,
|
||||
`mcp-adapter-${Date.now()}`,
|
||||
)
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function isChannelAllowlisted(
|
||||
if (!pluginSource) return false
|
||||
const { name, marketplace } = parsePluginIdentifier(pluginSource)
|
||||
if (!marketplace) return false
|
||||
if (marketplace === BUILTIN_MARKETPLACE_NAME && name === 'weixin') {
|
||||
if (marketplace === BUILTIN_MARKETPLACE_NAME && name === 'weixin') {
|
||||
return true
|
||||
}
|
||||
return getChannelAllowlist().some(
|
||||
|
||||
@@ -20,9 +20,7 @@ import type { ServerCapabilities } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { z } from 'zod/v4'
|
||||
import { type ChannelEntry, getAllowedChannels } from '../../bootstrap/state.js'
|
||||
import { CHANNEL_TAG } from '../../constants/xml.js'
|
||||
import {
|
||||
getSubscriptionType,
|
||||
} from '../../utils/auth.js'
|
||||
import { getSubscriptionType } from '../../utils/auth.js'
|
||||
import { lazySchema } from '../../utils/lazySchema.js'
|
||||
import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'
|
||||
import { getSettingsForSource } from '../../utils/settings/settings.js'
|
||||
@@ -259,7 +257,6 @@ export function gateChannelServer(
|
||||
reason: `you asked for plugin:${entry.name}@${entry.marketplace} but the installed ${entry.name} plugin is from ${actual ?? 'an unknown source'}`,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return { action: 'register' }
|
||||
|
||||
@@ -51,7 +51,10 @@ import {
|
||||
toolMatchesName,
|
||||
} from '../../Tool.js'
|
||||
import { ListMcpResourcesTool } from '@claude-code-best/builtin-tools/tools/ListMcpResourcesTool/ListMcpResourcesTool.js'
|
||||
import { type MCPProgress, MCPTool } from '@claude-code-best/builtin-tools/tools/MCPTool/MCPTool.js'
|
||||
import {
|
||||
type MCPProgress,
|
||||
MCPTool,
|
||||
} from '@claude-code-best/builtin-tools/tools/MCPTool/MCPTool.js'
|
||||
import { createMcpAuthTool } from '@claude-code-best/builtin-tools/tools/McpAuthTool/McpAuthTool.js'
|
||||
import { ReadMcpResourceTool } from '@claude-code-best/builtin-tools/tools/ReadMcpResourceTool/ReadMcpResourceTool.js'
|
||||
import { createAbortController } from '../../utils/abortController.js'
|
||||
@@ -903,7 +906,8 @@ export const connectToServer = memoize(
|
||||
)
|
||||
logMCPDebug(name, `claude.ai proxy transport created successfully`)
|
||||
} else if (
|
||||
((serverRef as ScopedMcpServerConfig).type === 'stdio' || !(serverRef as ScopedMcpServerConfig).type) &&
|
||||
((serverRef as ScopedMcpServerConfig).type === 'stdio' ||
|
||||
!(serverRef as ScopedMcpServerConfig).type) &&
|
||||
isClaudeInChromeMCPServer(name)
|
||||
) {
|
||||
// Run the Chrome MCP server in-process to avoid spawning a ~325 MB subprocess
|
||||
@@ -916,7 +920,9 @@ export const connectToServer = memoize(
|
||||
const { createLinkedTransportPair } = await import(
|
||||
'./InProcessTransport.js'
|
||||
)
|
||||
const context = createChromeContext((serverRef as McpStdioServerConfig).env)
|
||||
const context = createChromeContext(
|
||||
(serverRef as McpStdioServerConfig).env,
|
||||
)
|
||||
inProcessServer = createClaudeForChromeMcpServer(context)
|
||||
const [clientTransport, serverTransport] = createLinkedTransportPair()
|
||||
await inProcessServer.connect(serverTransport)
|
||||
@@ -924,7 +930,8 @@ export const connectToServer = memoize(
|
||||
logMCPDebug(name, `In-process Chrome MCP server started`)
|
||||
} else if (
|
||||
feature('CHICAGO_MCP') &&
|
||||
((serverRef as ScopedMcpServerConfig).type === 'stdio' || !(serverRef as ScopedMcpServerConfig).type) &&
|
||||
((serverRef as ScopedMcpServerConfig).type === 'stdio' ||
|
||||
!(serverRef as ScopedMcpServerConfig).type) &&
|
||||
isComputerUseMCPServer!(name)
|
||||
) {
|
||||
// Run the Computer Use MCP server in-process — same rationale as
|
||||
@@ -941,7 +948,10 @@ export const connectToServer = memoize(
|
||||
await inProcessServer.connect(serverTransport)
|
||||
transport = clientTransport
|
||||
logMCPDebug(name, `In-process Computer Use MCP server started`)
|
||||
} else if ((serverRef as ScopedMcpServerConfig).type === 'stdio' || !(serverRef as ScopedMcpServerConfig).type) {
|
||||
} else if (
|
||||
(serverRef as ScopedMcpServerConfig).type === 'stdio' ||
|
||||
!(serverRef as ScopedMcpServerConfig).type
|
||||
) {
|
||||
const stdioRef = serverRef as McpStdioServerConfig
|
||||
const finalCommand =
|
||||
process.env.CLAUDE_CODE_SHELL_PREFIX || stdioRef.command
|
||||
@@ -958,7 +968,9 @@ export const connectToServer = memoize(
|
||||
stderr: 'pipe', // prevents error output from the MCP server from printing to the UI
|
||||
})
|
||||
} else {
|
||||
throw new Error(`Unsupported server type: ${(serverRef as ScopedMcpServerConfig).type}`)
|
||||
throw new Error(
|
||||
`Unsupported server type: ${(serverRef as ScopedMcpServerConfig).type}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Set up stderr logging for stdio transport before connecting in case there are any stderr
|
||||
@@ -3247,8 +3259,14 @@ async function callMCPTool({
|
||||
}
|
||||
|
||||
function extractToolUseId(message: AssistantMessage): string | undefined {
|
||||
const firstBlock = (message.message.content as ContentBlockParam[] | undefined)?.[0]
|
||||
if (!firstBlock || typeof firstBlock === 'string' || firstBlock.type !== 'tool_use') {
|
||||
const firstBlock = (
|
||||
message.message.content as ContentBlockParam[] | undefined
|
||||
)?.[0]
|
||||
if (
|
||||
!firstBlock ||
|
||||
typeof firstBlock === 'string' ||
|
||||
firstBlock.type !== 'tool_use'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return firstBlock.id
|
||||
|
||||
@@ -1351,7 +1351,7 @@ export function parseMcpConfig(params: {
|
||||
if (
|
||||
getPlatform() === 'windows' &&
|
||||
(!configToCheck.type || configToCheck.type === 'stdio') &&
|
||||
('command' in configToCheck) &&
|
||||
'command' in configToCheck &&
|
||||
(configToCheck.command === 'npx' ||
|
||||
configToCheck.command.endsWith('\\npx') ||
|
||||
configToCheck.command.endsWith('/npx'))
|
||||
|
||||
@@ -56,8 +56,7 @@ export async function findAvailablePort(): Promise<number> {
|
||||
})
|
||||
})
|
||||
return port
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// If random selection failed, try the fallback port
|
||||
|
||||
@@ -536,9 +536,7 @@ export function useManageMCPConnections(
|
||||
// reply and emits {request_id, behavior}; no regex on our
|
||||
// side, text in the general channel can't accidentally match.
|
||||
if (
|
||||
client.capabilities?.experimental?.[
|
||||
'claude/channel/permission'
|
||||
]
|
||||
client.capabilities?.experimental?.['claude/channel/permission']
|
||||
) {
|
||||
client.client.setNotificationHandler(
|
||||
ChannelPermissionNotificationSchema(),
|
||||
@@ -567,9 +565,7 @@ export function useManageMCPConnections(
|
||||
client.client.removeNotificationHandler(
|
||||
'notifications/claude/channel',
|
||||
)
|
||||
client.client.removeNotificationHandler(
|
||||
CHANNEL_PERMISSION_METHOD,
|
||||
)
|
||||
client.client.removeNotificationHandler(CHANNEL_PERMISSION_METHOD)
|
||||
logMCPDebug(
|
||||
client.name,
|
||||
`Channel notifications skipped: ${gate.reason}`,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React from 'react'
|
||||
import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js'
|
||||
import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js'
|
||||
import type { Root } from '@anthropic/ink'
|
||||
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'
|
||||
import { AppStateProvider } from '../state/AppState.js'
|
||||
import { getMcpConfigsByScope } from './mcp/config.js'
|
||||
import { getProjectMcpServerStatus } from './mcp/utils.js'
|
||||
import React from 'react';
|
||||
import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js';
|
||||
import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js';
|
||||
import type { Root } from '@anthropic/ink';
|
||||
import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js';
|
||||
import { AppStateProvider } from '../state/AppState.js';
|
||||
import { getMcpConfigsByScope } from './mcp/config.js';
|
||||
import { getProjectMcpServerStatus } from './mcp/utils.js';
|
||||
|
||||
/**
|
||||
* Show MCP server approval dialogs for pending project servers.
|
||||
@@ -13,37 +13,34 @@ import { getProjectMcpServerStatus } from './mcp/utils.js'
|
||||
* from main.tsx instead of creating a separate one).
|
||||
*/
|
||||
export async function handleMcpjsonServerApprovals(root: Root): Promise<void> {
|
||||
const { servers: projectServers } = getMcpConfigsByScope('project')
|
||||
const { servers: projectServers } = getMcpConfigsByScope('project');
|
||||
const pendingServers = Object.keys(projectServers).filter(
|
||||
serverName => getProjectMcpServerStatus(serverName) === 'pending',
|
||||
)
|
||||
);
|
||||
|
||||
if (pendingServers.length === 0) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const done = (): void => void resolve()
|
||||
const done = (): void => void resolve();
|
||||
if (pendingServers.length === 1 && pendingServers[0] !== undefined) {
|
||||
const serverName = pendingServers[0]
|
||||
const serverName = pendingServers[0];
|
||||
root.render(
|
||||
<AppStateProvider>
|
||||
<KeybindingSetup>
|
||||
<MCPServerApprovalDialog serverName={serverName} onDone={done} />
|
||||
</KeybindingSetup>
|
||||
</AppStateProvider>,
|
||||
)
|
||||
);
|
||||
} else {
|
||||
root.render(
|
||||
<AppStateProvider>
|
||||
<KeybindingSetup>
|
||||
<MCPServerMultiselectDialog
|
||||
serverNames={pendingServers}
|
||||
onDone={done}
|
||||
/>
|
||||
<MCPServerMultiselectDialog serverNames={pendingServers} onDone={done} />
|
||||
</KeybindingSetup>
|
||||
</AppStateProvider>,
|
||||
)
|
||||
);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,7 +136,9 @@ async function isAppleTerminalBellDisabled(): Promise<boolean> {
|
||||
// Lazy-load plist (~280KB with xmlbuilder+@xmldom) — only hit on
|
||||
// Apple_Terminal with auto-channel, which is a small fraction of users.
|
||||
const plist = await import('plist')
|
||||
const parsed: Record<string, unknown> = plist.parse(defaultsOutput.stdout) as any
|
||||
const parsed: Record<string, unknown> = plist.parse(
|
||||
defaultsOutput.stdout,
|
||||
) as any
|
||||
const windowSettings = parsed?.['Window Settings'] as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type BillingType = any;
|
||||
export type ReferralEligibilityResponse = any;
|
||||
export type OAuthTokens = any;
|
||||
export type SubscriptionType = any;
|
||||
export type ReferralRedemptionsResponse = any;
|
||||
export type ReferrerRewardInfo = any;
|
||||
export type OAuthProfileResponse = any;
|
||||
export type OAuthTokenExchangeResponse = any;
|
||||
export type RateLimitTier = any;
|
||||
export type UserRolesResponse = any;
|
||||
export type ReferralCampaign = any;
|
||||
export type BillingType = any
|
||||
export type ReferralEligibilityResponse = any
|
||||
export type OAuthTokens = any
|
||||
export type SubscriptionType = any
|
||||
export type ReferralRedemptionsResponse = any
|
||||
export type ReferrerRewardInfo = any
|
||||
export type OAuthProfileResponse = any
|
||||
export type OAuthTokenExchangeResponse = any
|
||||
export type RateLimitTier = any
|
||||
export type UserRolesResponse = any
|
||||
export type ReferralCampaign = any
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Stub - security check disabled for open-source build
|
||||
export function checkManagedSettingsSecurity() { return Promise.resolve({ ok: true }) }
|
||||
export function handleSecurityCheckResult() { return true }
|
||||
export function checkManagedSettingsSecurity() {
|
||||
return Promise.resolve({ ok: true })
|
||||
}
|
||||
export function handleSecurityCheckResult() {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import React from 'react'
|
||||
import { getIsInteractive } from '../../bootstrap/state.js'
|
||||
import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js'
|
||||
import React from 'react';
|
||||
import { getIsInteractive } from '../../bootstrap/state.js';
|
||||
import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js';
|
||||
import {
|
||||
extractDangerousSettings,
|
||||
hasDangerousSettings,
|
||||
hasDangerousSettingsChanged,
|
||||
} from '../../components/ManagedSettingsSecurityDialog/utils.js'
|
||||
import { wrappedRender as render } from '@anthropic/ink'
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'
|
||||
import { AppStateProvider } from '../../state/AppState.js'
|
||||
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'
|
||||
import { getBaseRenderOptions } from '../../utils/renderOptions.js'
|
||||
import type { SettingsJson } from '../../utils/settings/types.js'
|
||||
import { logEvent } from '../analytics/index.js'
|
||||
} from '../../components/ManagedSettingsSecurityDialog/utils.js';
|
||||
import { wrappedRender as render } from '@anthropic/ink';
|
||||
import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js';
|
||||
import { AppStateProvider } from '../../state/AppState.js';
|
||||
import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js';
|
||||
import { getBaseRenderOptions } from '../../utils/renderOptions.js';
|
||||
import type { SettingsJson } from '../../utils/settings/types.js';
|
||||
import { logEvent } from '../analytics/index.js';
|
||||
|
||||
export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed'
|
||||
export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed';
|
||||
|
||||
/**
|
||||
* Check if new remote managed settings contain dangerous settings that require user approval.
|
||||
@@ -29,25 +29,22 @@ export async function checkManagedSettingsSecurity(
|
||||
newSettings: SettingsJson | null,
|
||||
): Promise<SecurityCheckResult> {
|
||||
// If new settings don't have dangerous settings, no check needed
|
||||
if (
|
||||
!newSettings ||
|
||||
!hasDangerousSettings(extractDangerousSettings(newSettings))
|
||||
) {
|
||||
return 'no_check_needed'
|
||||
if (!newSettings || !hasDangerousSettings(extractDangerousSettings(newSettings))) {
|
||||
return 'no_check_needed';
|
||||
}
|
||||
|
||||
// If dangerous settings haven't changed, no check needed
|
||||
if (!hasDangerousSettingsChanged(cachedSettings, newSettings)) {
|
||||
return 'no_check_needed'
|
||||
return 'no_check_needed';
|
||||
}
|
||||
|
||||
// Skip dialog in non-interactive mode (consistent with trust dialog behavior)
|
||||
if (!getIsInteractive()) {
|
||||
return 'no_check_needed'
|
||||
return 'no_check_needed';
|
||||
}
|
||||
|
||||
// Log that dialog is being shown
|
||||
logEvent('tengu_managed_settings_security_dialog_shown', {})
|
||||
logEvent('tengu_managed_settings_security_dialog_shown', {});
|
||||
|
||||
// Show blocking dialog
|
||||
return new Promise<SecurityCheckResult>(resolve => {
|
||||
@@ -58,34 +55,32 @@ export async function checkManagedSettingsSecurity(
|
||||
<ManagedSettingsSecurityDialog
|
||||
settings={newSettings}
|
||||
onAccept={() => {
|
||||
logEvent('tengu_managed_settings_security_dialog_accepted', {})
|
||||
unmount()
|
||||
void resolve('approved')
|
||||
logEvent('tengu_managed_settings_security_dialog_accepted', {});
|
||||
unmount();
|
||||
void resolve('approved');
|
||||
}}
|
||||
onReject={() => {
|
||||
logEvent('tengu_managed_settings_security_dialog_rejected', {})
|
||||
unmount()
|
||||
void resolve('rejected')
|
||||
logEvent('tengu_managed_settings_security_dialog_rejected', {});
|
||||
unmount();
|
||||
void resolve('rejected');
|
||||
}}
|
||||
/>
|
||||
</KeybindingSetup>
|
||||
</AppStateProvider>,
|
||||
getBaseRenderOptions(false),
|
||||
)
|
||||
})()
|
||||
})
|
||||
);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the security check result by exiting if rejected
|
||||
* Returns true if we should continue, false if we should stop
|
||||
*/
|
||||
export function handleSecurityCheckResult(
|
||||
result: SecurityCheckResult,
|
||||
): boolean {
|
||||
export function handleSecurityCheckResult(result: SecurityCheckResult): boolean {
|
||||
if (result === 'rejected') {
|
||||
gracefulShutdownSync(1)
|
||||
return false
|
||||
gracefulShutdownSync(1);
|
||||
return false;
|
||||
}
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { Message } from '../../types/message.js';
|
||||
import type { Message } from '../../types/message.js'
|
||||
|
||||
export {};
|
||||
export const writeSessionTranscriptSegment: (messages: Message[]) => void = (() => {});
|
||||
export const flushOnDateChange: (messages: Message[], currentDate: string) => void = (() => {});
|
||||
export {}
|
||||
export const writeSessionTranscriptSegment: (messages: Message[]) => void =
|
||||
() => {}
|
||||
export const flushOnDateChange: (
|
||||
messages: Message[],
|
||||
currentDate: string,
|
||||
) => void = () => {}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'fs'
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
} from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { execFileSync } from 'child_process'
|
||||
|
||||
@@ -267,7 +267,8 @@ export async function startSkillDiscoveryPrefetch(
|
||||
const newResults = results.filter(r => !discoveredThisSession.has(r.name))
|
||||
if (newResults.length === 0) return []
|
||||
|
||||
for (const r of newResults) addBoundedSessionEntry(discoveredThisSession, r.name)
|
||||
for (const r of newResults)
|
||||
addBoundedSessionEntry(discoveredThisSession, r.name)
|
||||
|
||||
const signal: DiscoverySignal = {
|
||||
trigger: 'assistant_turn',
|
||||
@@ -331,7 +332,8 @@ export async function getTurnZeroSkillDiscovery(
|
||||
|
||||
if (results.length === 0 && !gap) return null
|
||||
|
||||
for (const r of results) addBoundedSessionEntry(discoveredThisSession, r.name)
|
||||
for (const r of results)
|
||||
addBoundedSessionEntry(discoveredThisSession, r.name)
|
||||
|
||||
const signal: DiscoverySignal = {
|
||||
trigger: 'user_input',
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export function loadRemoteSkill(_slug: string, _url: string): Promise<{
|
||||
cacheHit: boolean;
|
||||
latencyMs: number;
|
||||
skillPath: string;
|
||||
content: string;
|
||||
fileCount?: number;
|
||||
totalBytes?: number;
|
||||
fetchMethod?: string;
|
||||
export function loadRemoteSkill(
|
||||
_slug: string,
|
||||
_url: string,
|
||||
): Promise<{
|
||||
cacheHit: boolean
|
||||
latencyMs: number
|
||||
skillPath: string
|
||||
content: string
|
||||
fileCount?: number
|
||||
totalBytes?: number
|
||||
fetchMethod?: string
|
||||
}> {
|
||||
return Promise.resolve({
|
||||
cacheHit: false,
|
||||
latencyMs: 0,
|
||||
skillPath: '',
|
||||
content: '',
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export function stripCanonicalPrefix(_name: string): string | null { return null; }
|
||||
export function getDiscoveredRemoteSkill(_slug: string): { url: string } | undefined { return undefined; }
|
||||
export function stripCanonicalPrefix(_name: string): string | null {
|
||||
return null
|
||||
}
|
||||
export function getDiscoveredRemoteSkill(
|
||||
_slug: string,
|
||||
): { url: string } | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export function logRemoteSkillLoaded(_data: {
|
||||
slug: string;
|
||||
cacheHit: boolean;
|
||||
latencyMs: number;
|
||||
urlScheme: string;
|
||||
error?: string;
|
||||
fileCount?: number;
|
||||
totalBytes?: number;
|
||||
fetchMethod?: string;
|
||||
slug: string
|
||||
cacheHit: boolean
|
||||
latencyMs: number
|
||||
urlScheme: string
|
||||
error?: string
|
||||
fileCount?: number
|
||||
totalBytes?: number
|
||||
fetchMethod?: string
|
||||
}): void {}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export type Tip = any;
|
||||
export type TipContext = any;
|
||||
export type Tip = any
|
||||
export type TipContext = any
|
||||
|
||||
@@ -25,7 +25,12 @@ import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import { isToolReferenceBlock } from '../utils/toolSearch.js'
|
||||
import { getAPIMetadata, getExtraBodyParams } from './api/claude.js'
|
||||
import { getAnthropicClient } from './api/client.js'
|
||||
import { createTrace, endTrace, isLangfuseEnabled, recordLLMObservation } from './langfuse/index.js'
|
||||
import {
|
||||
createTrace,
|
||||
endTrace,
|
||||
isLangfuseEnabled,
|
||||
recordLLMObservation,
|
||||
} from './langfuse/index.js'
|
||||
import { getSessionId } from '../bootstrap/state.js'
|
||||
import { withTokenCountVCR } from './vcr.js'
|
||||
|
||||
@@ -354,7 +359,9 @@ export async function countTokensViaHaikuFallback(
|
||||
},
|
||||
startTime: new Date(apiStart),
|
||||
endTime: new Date(),
|
||||
...(containsThinking && { thinking: { type: 'enabled', budgetTokens: TOKEN_COUNT_THINKING_BUDGET } }),
|
||||
...(containsThinking && {
|
||||
thinking: { type: 'enabled', budgetTokens: TOKEN_COUNT_THINKING_BUDGET },
|
||||
}),
|
||||
})
|
||||
endTrace(langfuseTrace)
|
||||
|
||||
|
||||
@@ -80,7 +80,9 @@ export async function generateToolUseSummary({
|
||||
},
|
||||
})
|
||||
|
||||
const summary = (Array.isArray(response.message.content) ? response.message.content : [])
|
||||
const summary = (
|
||||
Array.isArray(response.message.content) ? response.message.content : []
|
||||
)
|
||||
.filter(block => block.type === 'text')
|
||||
.map(block => (block.type === 'text' ? block.text : ''))
|
||||
.join('')
|
||||
|
||||
@@ -95,7 +95,10 @@ export class StreamingToolExecutor {
|
||||
{ toolNames: [block.name], batchIndex: 0 },
|
||||
)
|
||||
if (this.turnSpan) {
|
||||
this.toolUseContext = { ...this.toolUseContext, langfuseBatchSpan: this.turnSpan }
|
||||
this.toolUseContext = {
|
||||
...this.toolUseContext,
|
||||
langfuseBatchSpan: this.turnSpan,
|
||||
}
|
||||
}
|
||||
}
|
||||
const toolDefinition = findToolByName(this.toolDefinitions, block.name)
|
||||
|
||||
@@ -18,7 +18,13 @@ function makeMinimalContext(): ToolUseContext {
|
||||
agentDefinitions: { builtinAgents: [], customAgents: [] },
|
||||
},
|
||||
abortController,
|
||||
readFileState: { get: () => undefined, set: () => {}, delete: () => false, has: () => false, clear: () => {} } as any,
|
||||
readFileState: {
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => false,
|
||||
has: () => false,
|
||||
clear: () => {},
|
||||
} as any,
|
||||
getAppState: () => ({}) as any,
|
||||
setAppState: () => {},
|
||||
setInProgressToolUseIDs: () => {},
|
||||
@@ -48,7 +54,9 @@ describe('StreamingToolExecutor.discard()', () => {
|
||||
const ctx = makeMinimalContext()
|
||||
const executor = new StreamingToolExecutor([], () => true as any, ctx)
|
||||
|
||||
const siblingController = (executor as unknown as { siblingAbortController: AbortController }).siblingAbortController
|
||||
const siblingController = (
|
||||
executor as unknown as { siblingAbortController: AbortController }
|
||||
).siblingAbortController
|
||||
expect(siblingController.signal.aborted).toBe(false)
|
||||
|
||||
executor.discard()
|
||||
@@ -85,7 +93,9 @@ describe('StreamingToolExecutor.discard()', () => {
|
||||
|
||||
executor.discard()
|
||||
|
||||
const resolve = (executor as unknown as { progressAvailableResolve?: () => void }).progressAvailableResolve
|
||||
const resolve = (
|
||||
executor as unknown as { progressAvailableResolve?: () => void }
|
||||
).progressAvailableResolve
|
||||
expect(resolve).toBeUndefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -99,7 +99,11 @@ export async function* runPostToolUseHooks<Input extends AnyObject, Output>(
|
||||
result.message.attachment!.type === 'hook_blocking_error'
|
||||
)
|
||||
) {
|
||||
yield { message: result.message as AttachmentMessage | ProgressMessage<HookProgress> }
|
||||
yield {
|
||||
message: result.message as
|
||||
| AttachmentMessage
|
||||
| ProgressMessage<HookProgress>,
|
||||
}
|
||||
}
|
||||
|
||||
if (result.blockingError) {
|
||||
@@ -251,7 +255,11 @@ export async function* runPostToolUseFailureHooks<Input extends AnyObject>(
|
||||
result.message.attachment!.type === 'hook_blocking_error'
|
||||
)
|
||||
) {
|
||||
yield { message: result.message as AttachmentMessage | ProgressMessage<HookProgress> }
|
||||
yield {
|
||||
message: result.message as
|
||||
| AttachmentMessage
|
||||
| ProgressMessage<HookProgress>,
|
||||
}
|
||||
}
|
||||
|
||||
if (result.blockingError) {
|
||||
@@ -476,7 +484,14 @@ export async function* runPreToolUseHooks(
|
||||
)) {
|
||||
try {
|
||||
if (result.message) {
|
||||
yield { type: 'message', message: { message: result.message as AttachmentMessage | ProgressMessage<HookProgress> } }
|
||||
yield {
|
||||
type: 'message',
|
||||
message: {
|
||||
message: result.message as
|
||||
| AttachmentMessage
|
||||
| ProgressMessage<HookProgress>,
|
||||
},
|
||||
}
|
||||
}
|
||||
if (result.blockingError) {
|
||||
const denialMessage = getPreToolHookBlockingMessage(
|
||||
|
||||
@@ -24,12 +24,13 @@ export async function* runTools(
|
||||
toolUseContext: ToolUseContext,
|
||||
): AsyncGenerator<MessageUpdate, void> {
|
||||
// Wrap all tool calls in this turn under a single Langfuse turn span
|
||||
const turnSpan = toolUseMessages.length > 0
|
||||
? createToolBatchSpan(toolUseContext.langfuseTrace ?? null, {
|
||||
toolNames: toolUseMessages.map(b => b.name),
|
||||
batchIndex: 0,
|
||||
})
|
||||
: null
|
||||
const turnSpan =
|
||||
toolUseMessages.length > 0
|
||||
? createToolBatchSpan(toolUseContext.langfuseTrace ?? null, {
|
||||
toolNames: toolUseMessages.map(b => b.name),
|
||||
batchIndex: 0,
|
||||
})
|
||||
: null
|
||||
const contextWithTurn = turnSpan
|
||||
? { ...toolUseContext, langfuseBatchSpan: turnSpan }
|
||||
: toolUseContext
|
||||
@@ -143,10 +144,12 @@ async function* runToolsSerially(
|
||||
)
|
||||
for await (const update of runToolUse(
|
||||
toolUse,
|
||||
assistantMessages.find(_ =>
|
||||
Array.isArray(_.message.content) && _.message.content.some(
|
||||
_ => _.type === 'tool_use' && _.id === toolUse.id,
|
||||
),
|
||||
assistantMessages.find(
|
||||
_ =>
|
||||
Array.isArray(_.message.content) &&
|
||||
_.message.content.some(
|
||||
_ => _.type === 'tool_use' && _.id === toolUse.id,
|
||||
),
|
||||
)!,
|
||||
canUseTool,
|
||||
currentContext,
|
||||
@@ -176,10 +179,12 @@ async function* runToolsConcurrently(
|
||||
)
|
||||
yield* runToolUse(
|
||||
toolUse,
|
||||
assistantMessages.find(_ =>
|
||||
Array.isArray(_.message.content) && _.message.content.some(
|
||||
_ => _.type === 'tool_use' && _.id === toolUse.id,
|
||||
),
|
||||
assistantMessages.find(
|
||||
_ =>
|
||||
Array.isArray(_.message.content) &&
|
||||
_.message.content.some(
|
||||
_ => _.type === 'tool_use' && _.id === toolUse.id,
|
||||
),
|
||||
)!,
|
||||
canUseTool,
|
||||
toolUseContext,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { BetaContentBlock, BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type {
|
||||
BetaContentBlock,
|
||||
BetaUsage,
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { createHash, randomUUID, type UUID } from 'crypto'
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import isPlainObject from 'lodash-es/isPlainObject.js'
|
||||
|
||||
Reference in New Issue
Block a user