style: 完成所有文件的lint

This commit is contained in:
claude-code-best
2026-05-01 21:39:30 +08:00
parent d136872cc9
commit 6182015005
1333 changed files with 68255 additions and 77882 deletions

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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 =

View File

@@ -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,
)
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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,
)
}

View File

@@ -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' })

View File

@@ -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',

View File

@@ -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)
})
})

View File

@@ -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[]
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -742,7 +742,6 @@ export async function getEventMetadata(
return metadata
}
/**
* Core event metadata for 1P event logging (snake_case format).
*/

View File

@@ -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,

View File

@@ -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,
})
}
}

View File

@@ -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'

View File

@@ -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,
})
}
}

View File

@@ -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

View File

@@ -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()
})
})
})

View File

@@ -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,
})

View File

@@ -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,
})
}
}

View File

@@ -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,
}),
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
})
})

View File

@@ -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')
})
})

View File

@@ -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)', () => {

View File

@@ -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', () => {

View File

@@ -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
} = () => ({})

View File

@@ -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 &&

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}

View File

@@ -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

View File

@@ -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 = () => {}

View File

@@ -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
}

View File

@@ -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:

View File

@@ -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()
})
})
})

View File

@@ -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),
})

View File

@@ -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 {

View File

@@ -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'

View File

@@ -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') {

View File

@@ -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' })
}

View File

@@ -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'),

View File

@@ -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

View File

@@ -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>;
}

View File

@@ -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()
})
})

View File

@@ -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'])
})
})

View File

@@ -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([])
})
})

View File

@@ -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' })
})
})

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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)
})
})

View File

@@ -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
>,
)
},
}
}

View File

@@ -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
},
}

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -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' }

View File

@@ -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

View File

@@ -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'))

View File

@@ -56,8 +56,7 @@ export async function findAvailablePort(): Promise<number> {
})
})
return port
} catch {
}
} catch {}
}
// If random selection failed, try the fallback port

View File

@@ -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}`,

View File

@@ -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>,
)
);
}
})
});
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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 = () => {}

View File

@@ -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'

View File

@@ -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',

View File

@@ -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: '',
});
})
}

View File

@@ -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
}

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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)

View File

@@ -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('')

View File

@@ -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)

View File

@@ -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()
})

View File

@@ -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(

View File

@@ -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,

View File

@@ -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'