mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
Merge branch 'main' into feature/exa-search
This commit is contained in:
@@ -21,26 +21,22 @@ function makeAssistantMsg(content: string | any[]): AssistantMessage {
|
||||
|
||||
describe('anthropicMessagesToOpenAI', () => {
|
||||
test('converts system prompt to system message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hello')],
|
||||
['You are helpful.'] as any,
|
||||
)
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hello')], [
|
||||
'You are helpful.',
|
||||
] as any)
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
|
||||
})
|
||||
|
||||
test('joins multiple system prompt strings', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hi')],
|
||||
['Part 1', 'Part 2'] as any,
|
||||
)
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [
|
||||
'Part 1',
|
||||
'Part 2',
|
||||
] as any)
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' })
|
||||
})
|
||||
|
||||
test('skips empty system prompt', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hi')],
|
||||
[] as any,
|
||||
)
|
||||
const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [] as any)
|
||||
expect(result[0].role).toBe('user')
|
||||
})
|
||||
|
||||
@@ -54,10 +50,12 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
|
||||
test('converts user message with content array', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{ type: 'text', text: 'line 1' },
|
||||
{ type: 'text', text: 'line 2' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg([
|
||||
{ type: 'text', text: 'line 1' },
|
||||
{ type: 'text', text: 'line 2' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }])
|
||||
@@ -73,52 +71,64 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
|
||||
test('converts assistant message with tool_use', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'text', text: 'Let me help.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
])],
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'text', text: 'Let me help.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'assistant',
|
||||
content: 'Let me help.',
|
||||
tool_calls: [{
|
||||
id: 'toolu_123',
|
||||
type: 'function',
|
||||
function: { name: 'bash', arguments: '{"command":"ls"}' },
|
||||
}],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me help.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'toolu_123',
|
||||
type: 'function',
|
||||
function: { name: 'bash', arguments: '{"command":"ls"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts tool_result to tool message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
])],
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('strips thinking blocks', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
])],
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
|
||||
@@ -157,91 +167,105 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
|
||||
test('converts base64 image to image_url', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'iVBORw0KGgo=',
|
||||
[
|
||||
makeUserMsg([
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'iVBORw0KGgo=',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'what is this?' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts url image to image_url', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/img.png',
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/img.png',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/img.png' },
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'https://example.com/img.png' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts image-only message without text', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/jpeg',
|
||||
data: '/9j/4AAQ',
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/jpeg',
|
||||
data: '/9j/4AAQ',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
|
||||
},
|
||||
],
|
||||
}])
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('defaults to image/png when media_type is missing', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
data: 'ABC123',
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64',
|
||||
data: 'ABC123',
|
||||
},
|
||||
},
|
||||
},
|
||||
])],
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
expect((result[0].content as any[])[0].image_url.url).toBe(
|
||||
@@ -253,10 +277,16 @@ describe('anthropicMessagesToOpenAI', () => {
|
||||
describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
test('preserves thinking block as reasoning_content when enabled', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Let me reason about this...' },
|
||||
{ type: 'text', text: 'The answer is 42.' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'thinking' as const,
|
||||
thinking: 'Let me reason about this...',
|
||||
},
|
||||
{ type: 'text', text: 'The answer is 42.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
@@ -271,10 +301,12 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
|
||||
test('drops thinking block when enableThinking is false (default)', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
])],
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
const assistant = result[0] as any
|
||||
@@ -287,7 +319,10 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
[
|
||||
makeUserMsg('what is the weather?'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'I need to call the weather tool.' },
|
||||
{
|
||||
type: 'thinking' as const,
|
||||
thinking: 'I need to call the weather tool.',
|
||||
},
|
||||
{ type: 'text', text: '' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
@@ -399,18 +434,27 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
const assistants = result.filter(m => m.role === 'assistant')
|
||||
expect(assistants.length).toBe(3)
|
||||
// All iterations within the same turn preserve reasoning
|
||||
expect((assistants[0] as any).reasoning_content).toBe('I need the date first.')
|
||||
expect((assistants[1] as any).reasoning_content).toBe('Now I can get the weather.')
|
||||
expect((assistants[2] as any).reasoning_content).toBe('I have the info now.')
|
||||
expect((assistants[0] as any).reasoning_content).toBe(
|
||||
'I need the date first.',
|
||||
)
|
||||
expect((assistants[1] as any).reasoning_content).toBe(
|
||||
'Now I can get the weather.',
|
||||
)
|
||||
expect((assistants[2] as any).reasoning_content).toBe(
|
||||
'I have the info now.',
|
||||
)
|
||||
})
|
||||
|
||||
test('handles multiple thinking blocks in single assistant message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'First thought.' },
|
||||
{ type: 'thinking' as const, thinking: 'Second thought.' },
|
||||
{ type: 'text', text: 'Final answer.' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'First thought.' },
|
||||
{ type: 'thinking' as const, thinking: 'Second thought.' },
|
||||
{ type: 'text', text: 'Final answer.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
@@ -420,10 +464,13 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
|
||||
test('skips empty thinking blocks', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: '' },
|
||||
{ type: 'text', text: 'Answer.' },
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: '' },
|
||||
{ type: 'text', text: 'Answer.' },
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
@@ -481,15 +528,18 @@ describe('DeepSeek thinking mode (enableThinking)', () => {
|
||||
|
||||
test('sets content to null when only thinking and tool_calls present', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('question'), makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_001',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
])],
|
||||
[
|
||||
makeUserMsg('question'),
|
||||
makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_001',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
{ enableThinking: true },
|
||||
)
|
||||
|
||||
@@ -18,25 +18,29 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect(result).toEqual([{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}])
|
||||
])
|
||||
})
|
||||
|
||||
test('uses empty schema when input_schema missing', () => {
|
||||
const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect((result[0] as { function: { parameters: unknown } }).function.parameters).toEqual({ type: 'object', properties: {} })
|
||||
expect(
|
||||
(result[0] as { function: { parameters: unknown } }).function.parameters,
|
||||
).toEqual({ type: 'object', properties: {} })
|
||||
})
|
||||
|
||||
test('strips Anthropic-specific fields', () => {
|
||||
@@ -76,7 +80,8 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const props = (result[0] as { function: { parameters: any } }).function.parameters as any
|
||||
const props = (result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
expect(props.properties.mode).toEqual({ enum: ['read'] })
|
||||
expect(props.properties.mode.const).toBeUndefined()
|
||||
expect(props.properties.name).toEqual({ type: 'string' })
|
||||
@@ -110,8 +115,11 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const params = (result[0] as { function: { parameters: any } }).function.parameters as any
|
||||
expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] })
|
||||
const params = (result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
expect(params.properties.outer.properties.inner).toEqual({
|
||||
enum: ['fixed'],
|
||||
})
|
||||
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
|
||||
})
|
||||
|
||||
@@ -125,18 +133,17 @@ describe('anthropicToolsToOpenAI', () => {
|
||||
type: 'object',
|
||||
properties: {
|
||||
val: {
|
||||
anyOf: [
|
||||
{ const: 'a' },
|
||||
{ const: 'b' },
|
||||
{ type: 'string' },
|
||||
],
|
||||
anyOf: [{ const: 'a' }, { const: 'b' }, { type: 'string' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
const anyOf = ((result[0] as { function: { parameters: any } }).function.parameters as any).properties.val.anyOf
|
||||
const anyOf = (
|
||||
(result[0] as { function: { parameters: any } }).function
|
||||
.parameters as any
|
||||
).properties.val.anyOf
|
||||
expect(anyOf[0]).toEqual({ enum: ['a'] })
|
||||
expect(anyOf[1]).toEqual({ enum: ['b'] })
|
||||
expect(anyOf[2]).toEqual({ type: 'string' })
|
||||
|
||||
@@ -62,16 +62,18 @@ export function anthropicMessagesToOpenAI(
|
||||
// A user message starts a new turn if it contains any non-tool_result content
|
||||
// (text, image, or other media). Tool results alone do NOT start a new turn
|
||||
// because they are continuations of the previous assistant tool call.
|
||||
const startsNewUserTurn = typeof content === 'string'
|
||||
? content.length > 0
|
||||
: Array.isArray(content) && content.some(
|
||||
(b: any) =>
|
||||
typeof b === 'string' ||
|
||||
(b &&
|
||||
typeof b === 'object' &&
|
||||
'type' in b &&
|
||||
b.type !== 'tool_result'),
|
||||
)
|
||||
const startsNewUserTurn =
|
||||
typeof content === 'string'
|
||||
? content.length > 0
|
||||
: Array.isArray(content) &&
|
||||
content.some(
|
||||
(b: any) =>
|
||||
typeof b === 'string' ||
|
||||
(b &&
|
||||
typeof b === 'object' &&
|
||||
'type' in b &&
|
||||
b.type !== 'tool_result'),
|
||||
)
|
||||
if (startsNewUserTurn) {
|
||||
turnBoundaries.add(i)
|
||||
}
|
||||
@@ -88,7 +90,8 @@ export function anthropicMessagesToOpenAI(
|
||||
case 'assistant':
|
||||
// Preserve reasoning_content unless we're before a turn boundary
|
||||
// (i.e., from a previous user Q&A round)
|
||||
const preserveReasoning = enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
|
||||
const preserveReasoning =
|
||||
enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries)
|
||||
result.push(...convertInternalAssistantMessage(msg, preserveReasoning))
|
||||
break
|
||||
default:
|
||||
@@ -101,9 +104,7 @@ export function anthropicMessagesToOpenAI(
|
||||
|
||||
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||
if (!systemPrompt || systemPrompt.length === 0) return ''
|
||||
return systemPrompt
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
return systemPrompt.filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -131,7 +132,8 @@ function convertInternalUserMessage(
|
||||
} else if (Array.isArray(content)) {
|
||||
const textParts: string[] = []
|
||||
const toolResults: BetaToolResultBlockParam[] = []
|
||||
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = []
|
||||
const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> =
|
||||
[]
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
@@ -141,7 +143,9 @@ function convertInternalUserMessage(
|
||||
} else if (block.type === 'tool_result') {
|
||||
toolResults.push(block as BetaToolResultBlockParam)
|
||||
} else if (block.type === 'image') {
|
||||
const imagePart = convertImageBlockToOpenAI(block as unknown as Record<string, unknown>)
|
||||
const imagePart = convertImageBlockToOpenAI(
|
||||
block as unknown as Record<string, unknown>,
|
||||
)
|
||||
if (imagePart) {
|
||||
imageParts.push(imagePart)
|
||||
}
|
||||
@@ -158,7 +162,10 @@ function convertInternalUserMessage(
|
||||
|
||||
// 如果有图片,构建多模态 content 数组
|
||||
if (imageParts.length > 0) {
|
||||
const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = []
|
||||
const multiContent: Array<
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image_url'; image_url: { url: string } }
|
||||
> = []
|
||||
if (textParts.length > 0) {
|
||||
multiContent.push({ type: 'text', text: textParts.join('\n') })
|
||||
}
|
||||
@@ -229,7 +236,9 @@ function convertInternalAssistantMessage(
|
||||
}
|
||||
|
||||
const textParts: string[] = []
|
||||
const toolCalls: NonNullable<ChatCompletionAssistantMessageParam['tool_calls']> = []
|
||||
const toolCalls: NonNullable<
|
||||
ChatCompletionAssistantMessageParam['tool_calls']
|
||||
> = []
|
||||
const reasoningParts: string[] = []
|
||||
|
||||
for (const block of content) {
|
||||
@@ -250,7 +259,8 @@ function convertInternalAssistantMessage(
|
||||
})
|
||||
} else if (block.type === 'thinking' && preserveReasoning) {
|
||||
// DeepSeek thinking mode: preserve reasoning_content for tool call iterations
|
||||
const thinkingText = (block as unknown as Record<string, unknown>).thinking
|
||||
const thinkingText = (block as unknown as Record<string, unknown>)
|
||||
.thinking
|
||||
if (typeof thinkingText === 'string' && thinkingText) {
|
||||
reasoningParts.push(thinkingText)
|
||||
}
|
||||
@@ -262,7 +272,9 @@ function convertInternalAssistantMessage(
|
||||
role: 'assistant',
|
||||
content: textParts.length > 0 ? textParts.join('\n') : null,
|
||||
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
||||
...(reasoningParts.length > 0 && { reasoning_content: reasoningParts.join('\n') }),
|
||||
...(reasoningParts.length > 0 && {
|
||||
reasoning_content: reasoningParts.join('\n'),
|
||||
}),
|
||||
}
|
||||
|
||||
return [result]
|
||||
|
||||
@@ -16,21 +16,27 @@ export function anthropicToolsToOpenAI(
|
||||
.filter(tool => {
|
||||
// Only convert standard tools (skip server tools like computer_use, etc.)
|
||||
const toolType = (tool as unknown as { type?: string }).type
|
||||
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||
return (
|
||||
tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||
)
|
||||
})
|
||||
.map(tool => {
|
||||
// Handle the various tool shapes from Anthropic SDK
|
||||
const anyTool = tool as unknown as Record<string, unknown>
|
||||
const name = (anyTool.name as string) || ''
|
||||
const description = (anyTool.description as string) || ''
|
||||
const inputSchema = anyTool.input_schema as Record<string, unknown> | undefined
|
||||
const inputSchema = anyTool.input_schema as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name,
|
||||
description,
|
||||
parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }),
|
||||
parameters: sanitizeJsonSchema(
|
||||
inputSchema || { type: 'object', properties: {} },
|
||||
),
|
||||
},
|
||||
} satisfies ChatCompletionTool
|
||||
})
|
||||
@@ -43,7 +49,9 @@ export function anthropicToolsToOpenAI(
|
||||
* support the `const` keyword in JSON Schema. Convert it to `enum` with a
|
||||
* single-element array, which is semantically equivalent.
|
||||
*/
|
||||
function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
||||
function sanitizeJsonSchema(
|
||||
schema: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== 'object') return schema
|
||||
|
||||
const result = { ...schema }
|
||||
@@ -55,20 +63,37 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
|
||||
}
|
||||
|
||||
// Recursively process nested schemas
|
||||
const objectKeys = ['properties', 'definitions', '$defs', 'patternProperties'] as const
|
||||
const objectKeys = [
|
||||
'properties',
|
||||
'definitions',
|
||||
'$defs',
|
||||
'patternProperties',
|
||||
] as const
|
||||
for (const key of objectKeys) {
|
||||
const nested = result[key]
|
||||
if (nested && typeof nested === 'object') {
|
||||
const sanitized: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
|
||||
sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record<string, unknown>) : v
|
||||
sanitized[k] =
|
||||
v && typeof v === 'object'
|
||||
? sanitizeJsonSchema(v as Record<string, unknown>)
|
||||
: v
|
||||
}
|
||||
result[key] = sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively process single-schema keys
|
||||
const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const
|
||||
const singleKeys = [
|
||||
'items',
|
||||
'additionalProperties',
|
||||
'not',
|
||||
'if',
|
||||
'then',
|
||||
'else',
|
||||
'contains',
|
||||
'propertyNames',
|
||||
] as const
|
||||
for (const key of singleKeys) {
|
||||
const nested = result[key]
|
||||
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
|
||||
@@ -82,7 +107,9 @@ function sanitizeJsonSchema(schema: Record<string, unknown>): Record<string, unk
|
||||
const nested = result[key]
|
||||
if (Array.isArray(nested)) {
|
||||
result[key] = nested.map(item =>
|
||||
item && typeof item === 'object' ? sanitizeJsonSchema(item as Record<string, unknown>) : item
|
||||
item && typeof item === 'object'
|
||||
? sanitizeJsonSchema(item as Record<string, unknown>)
|
||||
: item,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,10 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
let currentContentIndex = -1
|
||||
|
||||
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
|
||||
const toolBlocks = new Map<number, { contentIndex: number; id: string; name: string; arguments: string }>()
|
||||
const toolBlocks = new Map<
|
||||
number,
|
||||
{ contentIndex: number; id: string; name: string; arguments: string }
|
||||
>()
|
||||
|
||||
// Track thinking block state
|
||||
let thinkingBlockOpen = false
|
||||
@@ -197,7 +200,8 @@ export async function* adaptOpenAIStreamToAnthropic(
|
||||
|
||||
// Start new tool_use block
|
||||
currentContentIndex++
|
||||
const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
const toolId =
|
||||
tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
const toolName = tc.function?.name || ''
|
||||
|
||||
toolBlocks.set(tcIndex, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { debugMock } from "../../../../../../tests/mocks/debug";
|
||||
|
||||
// ─── Mocks for agentToolUtils.ts dependencies ───
|
||||
// Only mock modules that are truly unavailable or cause side effects.
|
||||
@@ -87,20 +88,7 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({
|
||||
updateProgressFromMessage: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/debug.ts", () => ({
|
||||
getMinDebugLogLevel: () => "warn",
|
||||
isDebugMode: () => false,
|
||||
enableDebugLogging: () => false,
|
||||
getDebugFilter: () => null,
|
||||
isDebugToStdErr: () => false,
|
||||
getDebugFilePath: () => null,
|
||||
setHasFormattedOutput: noop,
|
||||
getHasFormattedOutput: () => false,
|
||||
flushDebugLogs: async () => {},
|
||||
logForDebugging: noop,
|
||||
getDebugLogPath: () => "",
|
||||
logAntError: noop,
|
||||
}));
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
|
||||
mock.module("src/utils/errors.js", () => ({
|
||||
ClaudeError: class extends Error {},
|
||||
|
||||
@@ -2,6 +2,12 @@ import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { tokenCountWithEstimation } from 'src/utils/tokens.js'
|
||||
import {
|
||||
getStats,
|
||||
isContextCollapseEnabled,
|
||||
} from 'src/services/contextCollapse/index.js'
|
||||
import { isSessionMemoryInitialized } from 'src/services/SessionMemory/sessionMemoryUtils.js'
|
||||
|
||||
const CTX_INSPECT_TOOL_NAME = 'CtxInspect'
|
||||
|
||||
@@ -19,6 +25,10 @@ type CtxInput = z.infer<InputSchema>
|
||||
type CtxOutput = {
|
||||
total_tokens: number
|
||||
message_count: number
|
||||
context_window_model: string
|
||||
prompt_caching_enabled: boolean
|
||||
session_memory_enabled: boolean
|
||||
context_collapse_enabled: boolean
|
||||
summary: string
|
||||
}
|
||||
|
||||
@@ -67,13 +77,45 @@ Use this to understand your context budget before deciding whether to snip old m
|
||||
}
|
||||
},
|
||||
|
||||
async call() {
|
||||
// Context inspection is wired into the context collapse system.
|
||||
async call(input: CtxInput, context) {
|
||||
const messages = context.messages ?? []
|
||||
const model = context.options?.mainLoopModel ?? 'unknown'
|
||||
const totalTokens = tokenCountWithEstimation(messages)
|
||||
const collapseEnabled = isContextCollapseEnabled()
|
||||
const collapseStats = getStats()
|
||||
const focused = input.query?.trim()
|
||||
|
||||
const sessionMemoryEnabled = isSessionMemoryInitialized()
|
||||
// Prompt caching is an API-level feature controlled by the provider, not
|
||||
// a user-facing toggle. Report as enabled only for providers known to
|
||||
// support Anthropic-style prompt caching (first-party, Bedrock, Vertex).
|
||||
const promptCachingEnabled = !model.startsWith('openai/') &&
|
||||
!model.startsWith('grok/') &&
|
||||
!model.startsWith('gemini/')
|
||||
|
||||
const summaryParts = [
|
||||
focused ? `Focus: ${focused}` : 'Overall context summary',
|
||||
`Model context: ${model}`,
|
||||
`Prompt caching: ${promptCachingEnabled ? 'enabled' : 'disabled'}`,
|
||||
`Session memory: ${sessionMemoryEnabled ? 'enabled' : 'disabled'}`,
|
||||
`Context collapse: ${collapseEnabled ? 'enabled' : 'disabled'}`,
|
||||
]
|
||||
|
||||
if (collapseEnabled) {
|
||||
summaryParts.push(
|
||||
`Collapse spans: ${collapseStats.collapsedSpans} committed, ${collapseStats.stagedSpans} staged, ${collapseStats.collapsedMessages} messages summarized`,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
total_tokens: 0,
|
||||
message_count: 0,
|
||||
summary: 'Context inspection requires the CONTEXT_COLLAPSE runtime.',
|
||||
total_tokens: totalTokens,
|
||||
message_count: messages.length,
|
||||
context_window_model: model,
|
||||
prompt_caching_enabled: promptCachingEnabled,
|
||||
session_memory_enabled: sessionMemoryEnabled,
|
||||
context_collapse_enabled: collapseEnabled,
|
||||
summary: summaryParts.join('\n'),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { logMock } from '../../../../../../tests/mocks/log'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
|
||||
mock.module('src/services/tokenEstimation.ts', () => ({
|
||||
roughTokenCountEstimation: (text: string) => Math.ceil(text.length / 4),
|
||||
roughTokenCountEstimationForMessages: (msgs: unknown[]) => msgs.length * 64,
|
||||
roughTokenCountEstimationForMessage: () => 64,
|
||||
roughTokenCountEstimationForFileType: () => 64,
|
||||
bytesPerTokenForFileType: () => 4,
|
||||
countTokensWithAPI: async () => 0,
|
||||
countMessagesTokensWithAPI: async () => 0,
|
||||
countTokensViaHaikuFallback: async () => 0,
|
||||
}))
|
||||
|
||||
let sessionMemoryInitialized = false
|
||||
mock.module('src/services/SessionMemory/sessionMemoryUtils.ts', () => ({
|
||||
isSessionMemoryInitialized: () => sessionMemoryInitialized,
|
||||
waitForSessionMemoryExtraction: async () => {},
|
||||
getLastSummarizedMessageId: () => undefined,
|
||||
getSessionMemoryContent: async () => null,
|
||||
setLastSummarizedMessageId: () => {},
|
||||
markExtractionStarted: () => {},
|
||||
markExtractionCompleted: () => {},
|
||||
setSessionMemoryConfig: () => {},
|
||||
getSessionMemoryConfig: () => ({}),
|
||||
recordExtractionTokenCount: () => {},
|
||||
markSessionMemoryInitialized: () => {},
|
||||
hasMetInitializationThreshold: () => false,
|
||||
hasMetUpdateThreshold: () => false,
|
||||
getToolCallsBetweenUpdates: () => 0,
|
||||
resetSessionMemoryState: () => {},
|
||||
DEFAULT_SESSION_MEMORY_CONFIG: {},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/slowOperations.ts', () => ({
|
||||
jsonStringify: JSON.stringify,
|
||||
jsonParse: JSON.parse,
|
||||
slowLogging: { enabled: false },
|
||||
clone: (value: unknown) => structuredClone(value),
|
||||
cloneDeep: (value: unknown) => structuredClone(value),
|
||||
callerFrame: () => '',
|
||||
SLOW_OPERATION_THRESHOLD_MS: 100,
|
||||
writeFileSync_DEPRECATED: () => {},
|
||||
}))
|
||||
|
||||
const { initContextCollapse, resetContextCollapse } = await import(
|
||||
'src/services/contextCollapse/index.js'
|
||||
)
|
||||
const { tokenCountWithEstimation } = await import('src/utils/tokens.js')
|
||||
const { CtxInspectTool } = await import('../CtxInspectTool.js')
|
||||
|
||||
function makeUserMessage(text: string) {
|
||||
return {
|
||||
type: 'user' as const,
|
||||
uuid: `user-${text}`,
|
||||
message: { role: 'user' as const, content: text },
|
||||
}
|
||||
}
|
||||
|
||||
function makeAssistantMessage(text: string) {
|
||||
return {
|
||||
type: 'assistant' as const,
|
||||
uuid: `assistant-${text}`,
|
||||
message: {
|
||||
role: 'assistant' as const,
|
||||
content: [{ type: 'text' as const, text }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function makeContext(messages: unknown[], mainLoopModel = 'claude-sonnet-4-6') {
|
||||
return {
|
||||
messages,
|
||||
options: {
|
||||
mainLoopModel,
|
||||
},
|
||||
getAppState: () => ({}),
|
||||
} as any
|
||||
}
|
||||
|
||||
const allowTool = async (input: Record<string, unknown>) => ({
|
||||
behavior: 'allow' as const,
|
||||
updatedInput: input,
|
||||
})
|
||||
|
||||
const parentMessage = makeAssistantMessage('Parent tool call')
|
||||
|
||||
beforeEach(() => {
|
||||
resetContextCollapse()
|
||||
sessionMemoryInitialized = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetContextCollapse()
|
||||
sessionMemoryInitialized = false
|
||||
})
|
||||
|
||||
describe('CtxInspectTool', () => {
|
||||
test('tool exports and metadata remain stable', async () => {
|
||||
expect(CtxInspectTool).toBeDefined()
|
||||
expect(CtxInspectTool.name).toBe('CtxInspect')
|
||||
expect(typeof CtxInspectTool.call).toBe('function')
|
||||
expect(await CtxInspectTool.description()).toContain('context')
|
||||
expect(CtxInspectTool.userFacingName()).toBe('CtxInspect')
|
||||
expect(CtxInspectTool.isReadOnly()).toBe(true)
|
||||
expect(CtxInspectTool.isConcurrencySafe()).toBe(true)
|
||||
})
|
||||
|
||||
test('formats tool results for transcript rendering', () => {
|
||||
const block = CtxInspectTool.mapToolResultToToolResultBlockParam(
|
||||
{
|
||||
total_tokens: 192,
|
||||
message_count: 3,
|
||||
context_window_model: 'claude-sonnet-4-6',
|
||||
prompt_caching_enabled: true,
|
||||
session_memory_enabled: true,
|
||||
context_collapse_enabled: false,
|
||||
summary: 'Context collapse: disabled',
|
||||
},
|
||||
'tool-use-id',
|
||||
)
|
||||
|
||||
expect(block.tool_use_id).toBe('tool-use-id')
|
||||
expect(block.content).toContain('192 tokens')
|
||||
expect(block.content).toContain('3 messages')
|
||||
expect(block.content).toContain('Context collapse: disabled')
|
||||
})
|
||||
|
||||
test('returns live context counts and mechanism state', async () => {
|
||||
const messages = [
|
||||
makeUserMessage('Inspect the current context budget.'),
|
||||
makeAssistantMessage('Looking at the current conversation state.'),
|
||||
]
|
||||
const context = makeContext(messages, 'claude-sonnet-4-6')
|
||||
|
||||
const result = await (CtxInspectTool as any).call(
|
||||
{},
|
||||
context,
|
||||
allowTool,
|
||||
parentMessage,
|
||||
)
|
||||
|
||||
expect(Object.keys(result.data).sort()).toEqual([
|
||||
'context_collapse_enabled',
|
||||
'context_window_model',
|
||||
'message_count',
|
||||
'prompt_caching_enabled',
|
||||
'session_memory_enabled',
|
||||
'summary',
|
||||
'total_tokens',
|
||||
])
|
||||
expect(result.data.message_count).toBe(messages.length)
|
||||
expect(result.data.total_tokens).toBe(tokenCountWithEstimation(messages as any))
|
||||
expect(result.data.context_window_model).toBe('claude-sonnet-4-6')
|
||||
expect(result.data.prompt_caching_enabled).toBe(true)
|
||||
expect(result.data.session_memory_enabled).toBe(false)
|
||||
expect(result.data.context_collapse_enabled).toBe(false)
|
||||
expect(result.data.summary).toContain('Overall context summary')
|
||||
expect(result.data.summary).toContain('Session memory: disabled')
|
||||
expect(result.data.summary).toContain('Context collapse: disabled')
|
||||
})
|
||||
|
||||
test('query input focuses summary and collapse runtime changes the reported state', async () => {
|
||||
const messages = [
|
||||
makeUserMessage('Show me tool usage pressure in this thread.'),
|
||||
makeAssistantMessage('Summarizing tool-heavy context now.'),
|
||||
]
|
||||
const context = makeContext(messages, 'claude-sonnet-4-6')
|
||||
|
||||
const disabledResult = await (CtxInspectTool as any).call(
|
||||
{ query: 'tool usage' },
|
||||
context,
|
||||
allowTool,
|
||||
parentMessage,
|
||||
)
|
||||
|
||||
initContextCollapse()
|
||||
|
||||
const enabledResult = await (CtxInspectTool as any).call(
|
||||
{ query: 'tool usage' },
|
||||
context,
|
||||
allowTool,
|
||||
parentMessage,
|
||||
)
|
||||
|
||||
expect(disabledResult.data.message_count).toBe(messages.length)
|
||||
expect(enabledResult.data.message_count).toBe(messages.length)
|
||||
expect(disabledResult.data.total_tokens).toBe(
|
||||
tokenCountWithEstimation(messages as any),
|
||||
)
|
||||
expect(enabledResult.data.total_tokens).toBe(
|
||||
tokenCountWithEstimation(messages as any),
|
||||
)
|
||||
expect(disabledResult.data.summary).toContain('Focus: tool usage')
|
||||
expect(disabledResult.data.context_collapse_enabled).toBe(false)
|
||||
expect(enabledResult.data.context_collapse_enabled).toBe(true)
|
||||
expect(enabledResult.data.summary).toContain('Context collapse: enabled')
|
||||
expect(enabledResult.data.summary).toContain('Collapse spans:')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import {
|
||||
DISCOVER_SKILLS_TOOL_NAME,
|
||||
DESCRIPTION,
|
||||
DISCOVER_SKILLS_PROMPT,
|
||||
} from './prompt.js'
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
'Description of what you want to do. Be specific — e.g. "deploy a Next.js app to Cloudflare Workers" rather than just "deploy".',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe('Maximum number of results to return (default: 5)'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
type DiscoverInput = z.infer<InputSchema>
|
||||
|
||||
type DiscoverOutput = {
|
||||
results: Array<{ name: string; description: string; score: number }>
|
||||
count: number
|
||||
}
|
||||
|
||||
export const DiscoverSkillsTool = buildTool({
|
||||
name: DISCOVER_SKILLS_TOOL_NAME,
|
||||
searchHint: 'find search discover skills commands tools capabilities',
|
||||
maxResultSizeChars: 10_000,
|
||||
strict: true,
|
||||
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
return DISCOVER_SKILLS_PROMPT
|
||||
},
|
||||
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
|
||||
userFacingName() {
|
||||
return 'Discover Skills'
|
||||
},
|
||||
|
||||
renderToolUseMessage(input: Partial<DiscoverInput>) {
|
||||
return `Searching skills: ${input.description?.slice(0, 80) ?? '...'}`
|
||||
},
|
||||
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: DiscoverOutput,
|
||||
toolUseID: string,
|
||||
): ToolResultBlockParam {
|
||||
if (content.count === 0) {
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: 'No matching skills found for that description.',
|
||||
}
|
||||
}
|
||||
const lines = content.results.map(
|
||||
(r, i) =>
|
||||
`${i + 1}. **${r.name}** (score: ${r.score.toFixed(2)})\n ${r.description}`,
|
||||
)
|
||||
return {
|
||||
tool_use_id: toolUseID,
|
||||
type: 'tool_result',
|
||||
content: `Found ${content.count} relevant skill(s):\n\n${lines.join('\n\n')}`,
|
||||
}
|
||||
},
|
||||
|
||||
async call(input: DiscoverInput, context) {
|
||||
const { getSkillIndex, searchSkills } = await import(
|
||||
'src/services/skillSearch/localSearch.js'
|
||||
)
|
||||
const { getCwd } = await import('src/utils/cwd.js')
|
||||
const cwd = getCwd()
|
||||
|
||||
const index = await getSkillIndex(cwd)
|
||||
const results = searchSkills(input.description, index, input.limit ?? 5)
|
||||
|
||||
return {
|
||||
data: {
|
||||
results: results.map(r => ({
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
score: r.score,
|
||||
})),
|
||||
count: results.length,
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { DISCOVER_SKILLS_TOOL_NAME } from '../prompt.js'
|
||||
|
||||
describe('DiscoverSkillsTool', () => {
|
||||
test('DISCOVER_SKILLS_TOOL_NAME is not empty', () => {
|
||||
expect(DISCOVER_SKILLS_TOOL_NAME).toBe('DiscoverSkills')
|
||||
expect(DISCOVER_SKILLS_TOOL_NAME.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('tool exports are functions', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
expect(DiscoverSkillsTool).toBeDefined()
|
||||
expect(DiscoverSkillsTool.name).toBe('DiscoverSkills')
|
||||
expect(typeof DiscoverSkillsTool.call).toBe('function')
|
||||
})
|
||||
|
||||
test('tool has correct metadata', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
expect(await DiscoverSkillsTool.description()).toContain('skill')
|
||||
expect(DiscoverSkillsTool.userFacingName()).toBe('Discover Skills')
|
||||
expect(DiscoverSkillsTool.isReadOnly()).toBe(true)
|
||||
expect(DiscoverSkillsTool.isConcurrencySafe()).toBe(true)
|
||||
})
|
||||
|
||||
test('renderToolUseMessage formats input', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
const msg = DiscoverSkillsTool.renderToolUseMessage({
|
||||
description: 'deploy to cloudflare',
|
||||
})
|
||||
expect(msg).toContain('deploy to cloudflare')
|
||||
})
|
||||
|
||||
test('mapToolResultToToolResultBlockParam formats empty results', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
const result = DiscoverSkillsTool.mapToolResultToToolResultBlockParam(
|
||||
{ results: [], count: 0 },
|
||||
'test-id',
|
||||
)
|
||||
expect(result.content).toContain('No matching skills')
|
||||
})
|
||||
|
||||
test('mapToolResultToToolResultBlockParam formats results', async () => {
|
||||
const { DiscoverSkillsTool } = await import('../DiscoverSkillsTool.js')
|
||||
const result = DiscoverSkillsTool.mapToolResultToToolResultBlockParam(
|
||||
{
|
||||
results: [{ name: 'test-skill', description: 'A test skill', score: 0.85 }],
|
||||
count: 1,
|
||||
},
|
||||
'test-id',
|
||||
)
|
||||
expect(result.content).toContain('test-skill')
|
||||
expect(result.content).toContain('0.85')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,13 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {};
|
||||
export const DISCOVER_SKILLS_TOOL_NAME: string = '';
|
||||
export const DISCOVER_SKILLS_TOOL_NAME = 'DiscoverSkills'
|
||||
|
||||
export const DESCRIPTION =
|
||||
'Search for relevant skills by describing what you want to do'
|
||||
|
||||
export const DISCOVER_SKILLS_PROMPT = `Search for skills relevant to a task description. Returns matching skills ranked by relevance.
|
||||
|
||||
Use this when:
|
||||
- The auto-surfaced skills don't cover your current task
|
||||
- You're pivoting to a different kind of work mid-conversation
|
||||
- You want to find specialized skills for an unusual workflow
|
||||
|
||||
The search uses TF-IDF keyword matching against all registered skills (bundled, user-defined, and MCP-provided). Results include skill name, description, and relevance score.`
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { logMock } from "../../../../../../tests/mocks/log";
|
||||
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
mock.module("src/utils/log.ts", () => ({
|
||||
logError: () => {},
|
||||
logToFile: () => {},
|
||||
getLogDisplayTitle: () => "",
|
||||
logEvent: () => {},
|
||||
logMCPError: () => {},
|
||||
logMCPDebug: () => {},
|
||||
dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"),
|
||||
getLogFilePath: () => "/tmp/mock-log",
|
||||
attachErrorLogSink: () => {},
|
||||
getInMemoryErrors: () => [],
|
||||
loadErrorLogs: async () => [],
|
||||
getErrorLogByIndex: async () => null,
|
||||
captureAPIRequest: () => {},
|
||||
_resetErrorLogForTesting: () => {},
|
||||
}));
|
||||
mock.module("src/utils/log.ts", logMock);
|
||||
|
||||
const {
|
||||
normalizeQuotes,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
import { debugMock } from "../../../../../../tests/mocks/debug";
|
||||
|
||||
mock.module("src/utils/debug.ts", () => ({
|
||||
logForDebugging: () => {},
|
||||
isDebugMode: () => false,
|
||||
}));
|
||||
mock.module("src/utils/debug.ts", debugMock);
|
||||
|
||||
const {
|
||||
formatGoToDefinitionResult,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getClaudeAIOAuthTokens,
|
||||
} from 'src/utils/auth.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { appendRemoteTriggerAuditRecord } from 'src/utils/remoteTriggerAudit.js'
|
||||
import { jsonStringify } from 'src/utils/slowOperations.js'
|
||||
import { DESCRIPTION, PROMPT, REMOTE_TRIGGER_TOOL_NAME } from './prompt.js'
|
||||
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
|
||||
@@ -36,6 +37,7 @@ const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
status: z.number(),
|
||||
json: z.string(),
|
||||
audit_id: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
@@ -76,77 +78,96 @@ export const RemoteTriggerTool = buildTool({
|
||||
return PROMPT
|
||||
},
|
||||
async call(input: Input, context: ToolUseContext) {
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
'Not authenticated with a claude.ai account. Run /login and try again.',
|
||||
)
|
||||
}
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
throw new Error('Unable to resolve organization UUID.')
|
||||
const auditBase = {
|
||||
action: input.action,
|
||||
...(input.trigger_id ? { triggerId: input.trigger_id } : {}),
|
||||
}
|
||||
try {
|
||||
await checkAndRefreshOAuthTokenIfNeeded()
|
||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
'Not authenticated with a claude.ai account. Run /login and try again.',
|
||||
)
|
||||
}
|
||||
const orgUUID = await getOrganizationUUID()
|
||||
if (!orgUUID) {
|
||||
throw new Error('Unable to resolve organization UUID.')
|
||||
}
|
||||
|
||||
const base = `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': TRIGGERS_BETA,
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
const base = `${getOauthConfig().BASE_API_URL}/v1/code/triggers`
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': TRIGGERS_BETA,
|
||||
'x-organization-uuid': orgUUID,
|
||||
}
|
||||
|
||||
const { action, trigger_id, body } = input
|
||||
let method: 'GET' | 'POST'
|
||||
let url: string
|
||||
let data: unknown
|
||||
switch (action) {
|
||||
case 'list':
|
||||
method = 'GET'
|
||||
url = base
|
||||
break
|
||||
case 'get':
|
||||
if (!trigger_id) throw new Error('get requires trigger_id')
|
||||
method = 'GET'
|
||||
url = `${base}/${trigger_id}`
|
||||
break
|
||||
case 'create':
|
||||
if (!body) throw new Error('create requires body')
|
||||
method = 'POST'
|
||||
url = base
|
||||
data = body
|
||||
break
|
||||
case 'update':
|
||||
if (!trigger_id) throw new Error('update requires trigger_id')
|
||||
if (!body) throw new Error('update requires body')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}`
|
||||
data = body
|
||||
break
|
||||
case 'run':
|
||||
if (!trigger_id) throw new Error('run requires trigger_id')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}/run`
|
||||
data = {}
|
||||
break
|
||||
}
|
||||
const { action, trigger_id, body } = input
|
||||
let method: 'GET' | 'POST'
|
||||
let url: string
|
||||
let data: unknown
|
||||
switch (action) {
|
||||
case 'list':
|
||||
method = 'GET'
|
||||
url = base
|
||||
break
|
||||
case 'get':
|
||||
if (!trigger_id) throw new Error('get requires trigger_id')
|
||||
method = 'GET'
|
||||
url = `${base}/${trigger_id}`
|
||||
break
|
||||
case 'create':
|
||||
if (!body) throw new Error('create requires body')
|
||||
method = 'POST'
|
||||
url = base
|
||||
data = body
|
||||
break
|
||||
case 'update':
|
||||
if (!trigger_id) throw new Error('update requires trigger_id')
|
||||
if (!body) throw new Error('update requires body')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}`
|
||||
data = body
|
||||
break
|
||||
case 'run':
|
||||
if (!trigger_id) throw new Error('run requires trigger_id')
|
||||
method = 'POST'
|
||||
url = `${base}/${trigger_id}/run`
|
||||
data = {}
|
||||
break
|
||||
}
|
||||
|
||||
const res = await axios.request({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
data,
|
||||
timeout: 20_000,
|
||||
signal: context.abortController.signal,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
|
||||
return {
|
||||
data: {
|
||||
const res = await axios.request({
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
data,
|
||||
timeout: 20_000,
|
||||
signal: context.abortController.signal,
|
||||
validateStatus: () => true,
|
||||
})
|
||||
const audit = await appendRemoteTriggerAuditRecord({
|
||||
...auditBase,
|
||||
ok: res.status >= 200 && res.status < 300,
|
||||
status: res.status,
|
||||
json: jsonStringify(res.data),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
data: {
|
||||
status: res.status,
|
||||
json: jsonStringify(res.data),
|
||||
audit_id: audit.auditId,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
await appendRemoteTriggerAuditRecord({
|
||||
...auditBase,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
mapToolResultToToolResultBlockParam(output, toolUseID) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { mkdir, readFile, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from 'src/bootstrap/state.js'
|
||||
|
||||
let requestStatus = 200
|
||||
|
||||
mock.module('axios', () => ({
|
||||
default: {
|
||||
request: async () => ({
|
||||
status: requestStatus,
|
||||
data: { ok: requestStatus >= 200 && requestStatus < 300 },
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
checkAndRefreshOAuthTokenIfNeeded: async () => {},
|
||||
getClaudeAIOAuthTokens: () => ({ accessToken: 'token' }),
|
||||
}))
|
||||
|
||||
mock.module('src/services/oauth/client.js', () => ({
|
||||
getOrganizationUUID: async () => 'org',
|
||||
}))
|
||||
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://example.test' }),
|
||||
}))
|
||||
|
||||
let cwd = ''
|
||||
let previousCwd = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
requestStatus = 200
|
||||
previousCwd = process.cwd()
|
||||
cwd = join(tmpdir(), `remote-trigger-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
await mkdir(cwd, { recursive: true })
|
||||
process.chdir(cwd)
|
||||
resetStateForTests()
|
||||
setOriginalCwd(cwd)
|
||||
setProjectRoot(cwd)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
process.chdir(previousCwd)
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('RemoteTriggerTool audit', () => {
|
||||
test('writes an audit record for successful remote calls', async () => {
|
||||
const { RemoteTriggerTool } = await import('../RemoteTriggerTool')
|
||||
const result = await RemoteTriggerTool.call(
|
||||
{ action: 'run', trigger_id: 'trigger-1' },
|
||||
{ abortController: new AbortController() } as any,
|
||||
)
|
||||
|
||||
expect(result.data.audit_id).toBeString()
|
||||
const raw = await readFile(
|
||||
join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
|
||||
'utf-8',
|
||||
)
|
||||
expect(raw).toContain('"action":"run"')
|
||||
expect(raw).toContain('"triggerId":"trigger-1"')
|
||||
expect(raw).toContain('"ok":true')
|
||||
})
|
||||
|
||||
test('writes an audit record before rethrowing validation failures', async () => {
|
||||
const { RemoteTriggerTool } = await import('../RemoteTriggerTool')
|
||||
|
||||
await expect(
|
||||
RemoteTriggerTool.call(
|
||||
{ action: 'run' },
|
||||
{ abortController: new AbortController() } as any,
|
||||
),
|
||||
).rejects.toThrow('run requires trigger_id')
|
||||
|
||||
const raw = await readFile(
|
||||
join(cwd, '.claude', 'remote-trigger-audit.jsonl'),
|
||||
'utf-8',
|
||||
)
|
||||
expect(raw).toContain('"action":"run"')
|
||||
expect(raw).toContain('"ok":false')
|
||||
expect(raw).toContain('run requires trigger_id')
|
||||
})
|
||||
})
|
||||
@@ -14,11 +14,26 @@ import {
|
||||
} from 'src/utils/swarm/teamHelpers.js'
|
||||
import { clearTeammateColors } from 'src/utils/swarm/teammateLayoutManager.js'
|
||||
import { clearLeaderTeamName } from 'src/utils/tasks.js'
|
||||
import { ensureBackendsRegistered, getBackendByType, getInProcessBackend } from 'src/utils/swarm/backends/registry.js'
|
||||
import { createPaneBackendExecutor } from 'src/utils/swarm/backends/PaneBackendExecutor.js'
|
||||
import { isPaneBackend } from 'src/utils/swarm/backends/types.js'
|
||||
import { sleep } from 'src/utils/sleep.js'
|
||||
import { TEAM_DELETE_TOOL_NAME } from './constants.js'
|
||||
import { getPrompt } from './prompt.js'
|
||||
import { renderToolResultMessage, renderToolUseMessage } from './UI.js'
|
||||
|
||||
const inputSchema = lazySchema(() => z.strictObject({}))
|
||||
const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
wait_ms: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(30_000)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional time to wait for active teammates to acknowledge shutdown before cleanup.',
|
||||
),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
export type Output = {
|
||||
@@ -68,7 +83,7 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
|
||||
}
|
||||
},
|
||||
|
||||
async call(_input, context) {
|
||||
async call(input, context) {
|
||||
const { setAppState, getAppState } = context
|
||||
const appState = getAppState()
|
||||
const teamName = appState.teamContext?.teamName
|
||||
@@ -87,13 +102,82 @@ export const TeamDeleteTool: Tool<InputSchema, Output> = buildTool({
|
||||
const activeMembers = nonLeadMembers.filter(m => m.isActive !== false)
|
||||
|
||||
if (activeMembers.length > 0) {
|
||||
const memberNames = activeMembers.map(m => m.name).join(', ')
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message: `Cannot cleanup team with ${activeMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`,
|
||||
team_name: teamName,
|
||||
},
|
||||
const requested: string[] = []
|
||||
for (const member of activeMembers) {
|
||||
let sent = false
|
||||
if (member.backendType === 'in-process') {
|
||||
const executor = getInProcessBackend()
|
||||
executor.setContext?.(context)
|
||||
sent = await executor.terminate(
|
||||
member.agentId,
|
||||
'Team cleanup requested by team lead',
|
||||
)
|
||||
} else if (member.backendType && isPaneBackend(member.backendType)) {
|
||||
await ensureBackendsRegistered()
|
||||
const executor = createPaneBackendExecutor(
|
||||
getBackendByType(member.backendType),
|
||||
)
|
||||
executor.setContext?.(context)
|
||||
sent = await executor.terminate(
|
||||
member.agentId,
|
||||
'Team cleanup requested by team lead',
|
||||
)
|
||||
}
|
||||
if (sent) {
|
||||
requested.push(member.name)
|
||||
}
|
||||
}
|
||||
const waitMs = input.wait_ms ?? 0
|
||||
if (waitMs > 0 && requested.length > 0) {
|
||||
const deadline = Date.now() + waitMs
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(Math.min(250, Math.max(0, deadline - Date.now())))
|
||||
const refreshed = readTeamFile(teamName)
|
||||
const stillActive =
|
||||
refreshed?.members.filter(
|
||||
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
|
||||
) ?? []
|
||||
if (stillActive.length === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
const refreshed = readTeamFile(teamName)
|
||||
const stillActive =
|
||||
refreshed?.members.filter(
|
||||
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
|
||||
) ?? []
|
||||
if (stillActive.length === 0) {
|
||||
// Fall through to cleanup with the refreshed team file state.
|
||||
} else {
|
||||
const memberNames = stillActive.map(m => m.name).join(', ')
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message: `Shutdown requested for active teammate(s): ${requested.join(', ')}. Cleanup is still blocked after waiting ${waitMs}ms: ${memberNames}.`,
|
||||
team_name: teamName,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
const latestTeamFile = readTeamFile(teamName)
|
||||
const latestActiveMembers =
|
||||
latestTeamFile?.members.filter(
|
||||
m => m.name !== TEAM_LEAD_NAME && m.isActive !== false,
|
||||
) ?? []
|
||||
if (latestActiveMembers.length === 0) {
|
||||
// Continue to cleanup below.
|
||||
} else {
|
||||
const memberNames = latestActiveMembers.map(m => m.name).join(', ')
|
||||
return {
|
||||
data: {
|
||||
success: false,
|
||||
message:
|
||||
requested.length > 0
|
||||
? `Shutdown requested for active teammate(s): ${requested.join(', ')}. Cleanup is blocked until they exit: ${memberNames}.`
|
||||
: `Cannot cleanup team with ${latestActiveMembers.length} active member(s): ${memberNames}. Use requestShutdown to gracefully terminate teammates first.`,
|
||||
team_name: teamName,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,11 @@ const inputSchema = lazySchema(() =>
|
||||
z.strictObject({
|
||||
url: z
|
||||
.string()
|
||||
.describe('URL to navigate to in the browser.'),
|
||||
.describe('URL to fetch and extract content from.'),
|
||||
action: z
|
||||
.enum(['navigate', 'screenshot', 'click', 'type', 'scroll'])
|
||||
.enum(['navigate', 'screenshot'])
|
||||
.optional()
|
||||
.describe('Browser action to perform. Defaults to "navigate".'),
|
||||
selector: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('CSS selector for click/type actions.'),
|
||||
text: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Text to type when action is "type".'),
|
||||
.describe('Action to perform. "navigate" fetches page content (default). "screenshot" returns a text snapshot of the page.'),
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
@@ -45,16 +37,24 @@ export const WebBrowserTool = buildTool({
|
||||
},
|
||||
|
||||
async description() {
|
||||
return 'Browse the web using an embedded browser'
|
||||
return 'Fetch and read web page content via HTTP'
|
||||
},
|
||||
async prompt() {
|
||||
return `Open and interact with web pages in an embedded browser. Supports navigation, screenshots, clicking, typing, and scrolling.
|
||||
return `Fetch web pages via HTTP and extract their text content. This is a lightweight browser tool (HTTP fetch, not a full browser engine).
|
||||
|
||||
Supported actions:
|
||||
- navigate: Fetch a URL and extract page title + text content
|
||||
- screenshot: Same as navigate (returns text snapshot, not a visual screenshot)
|
||||
|
||||
Limitations:
|
||||
- No JavaScript execution — only sees server-rendered HTML
|
||||
- click/type/scroll require a full browser runtime (not available)
|
||||
- For full browser interaction, use the Claude-in-Chrome MCP tools instead
|
||||
|
||||
Use this for:
|
||||
- Viewing web pages and their content
|
||||
- Taking screenshots of UI
|
||||
- Interacting with web applications
|
||||
- Testing web endpoints with full browser rendering`
|
||||
- Reading web page content and documentation
|
||||
- Checking API endpoints that return HTML
|
||||
- Quick page title/content extraction`
|
||||
},
|
||||
|
||||
isConcurrencySafe() {
|
||||
@@ -85,12 +85,84 @@ Use this for:
|
||||
},
|
||||
|
||||
async call(input: BrowserInput) {
|
||||
// Browser integration requires the WEB_BROWSER_TOOL runtime (Bun WebView).
|
||||
const action = input.action ?? 'navigate'
|
||||
|
||||
if (action === 'navigate' || action === 'screenshot') {
|
||||
// Fetch the page content via HTTP
|
||||
try {
|
||||
const response = await fetch(input.url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
},
|
||||
redirect: 'follow',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
data: {
|
||||
title: `HTTP ${response.status}`,
|
||||
url: input.url,
|
||||
content: `Error: ${response.status} ${response.statusText}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]*)<\/title>/i)
|
||||
const title = titleMatch?.[1]?.trim() ?? ''
|
||||
|
||||
// Extract text content (strip HTML tags, scripts, styles)
|
||||
let textContent = html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
// Truncate to reasonable size
|
||||
if (textContent.length > 50_000) {
|
||||
textContent = textContent.slice(0, 50_000) + '\n[truncated]'
|
||||
}
|
||||
|
||||
if (action === 'screenshot') {
|
||||
return {
|
||||
data: {
|
||||
title,
|
||||
url: response.url,
|
||||
content: `[Text snapshot — visual screenshots require Chrome browser tools]\n\n${textContent}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
title,
|
||||
url: response.url,
|
||||
content: textContent,
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
data: {
|
||||
title: 'Error',
|
||||
url: input.url,
|
||||
content: `Failed to fetch: ${err instanceof Error ? err.message : String(err)}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable — schema only allows navigate/screenshot
|
||||
return {
|
||||
data: {
|
||||
title: '',
|
||||
url: input.url,
|
||||
content: 'Web browser requires the WEB_BROWSER_TOOL runtime.',
|
||||
content: `Unknown action "${action}".`,
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
|
||||
|
||||
// Mock fetch directly — avoids flaky dependency on external hosts AND
|
||||
// pollution by other tests that call setGlobalDispatcher (proxy agents make
|
||||
// localhost fetches return 500 in the full-suite run).
|
||||
const realFetch = globalThis.fetch
|
||||
|
||||
beforeAll(() => {
|
||||
globalThis.fetch = (async (
|
||||
input: string | URL | Request,
|
||||
_init?: RequestInit,
|
||||
) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
if (url === 'not-a-url' || !url.startsWith('http')) {
|
||||
throw new TypeError('Failed to fetch')
|
||||
}
|
||||
const body =
|
||||
'<!doctype html><html><head><title>Example Domain</title></head>' +
|
||||
'<body><h1>Example Domain</h1><p>Sample content.</p></body></html>'
|
||||
const res = new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/html' },
|
||||
})
|
||||
// Make response.url match the request URL so tests can assert on it.
|
||||
Object.defineProperty(res, 'url', { value: url, configurable: true })
|
||||
return res
|
||||
}) as typeof fetch
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = realFetch
|
||||
})
|
||||
|
||||
describe('WebBrowserTool', () => {
|
||||
test('tool exports and metadata', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
expect(WebBrowserTool).toBeDefined()
|
||||
expect(WebBrowserTool.name).toBe('WebBrowser')
|
||||
expect(typeof WebBrowserTool.call).toBe('function')
|
||||
expect(WebBrowserTool.userFacingName()).toBe('Browser')
|
||||
expect(WebBrowserTool.isReadOnly()).toBe(true)
|
||||
})
|
||||
|
||||
test('description reflects browser-lite', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const desc = await WebBrowserTool.description()
|
||||
expect(desc).toContain('HTTP')
|
||||
expect(desc).not.toContain('embedded browser')
|
||||
})
|
||||
|
||||
test('prompt mentions limitations', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const prompt = await WebBrowserTool.prompt()
|
||||
expect(prompt).toContain('Limitations')
|
||||
expect(prompt).toContain('No JavaScript')
|
||||
expect(prompt).toContain('Claude-in-Chrome')
|
||||
})
|
||||
|
||||
test('navigate fetches URL', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const result = await WebBrowserTool.call({
|
||||
url: 'https://example.com',
|
||||
} as any)
|
||||
expect(result.data.title).toBe('Example Domain')
|
||||
expect(result.data.url).toContain('example.com')
|
||||
expect(result.data.content).toContain('Example Domain')
|
||||
}, 15000)
|
||||
|
||||
test('screenshot returns text snapshot', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const result = await WebBrowserTool.call({
|
||||
url: 'https://example.com',
|
||||
action: 'screenshot',
|
||||
} as any)
|
||||
expect(result.data.content).toContain('Text snapshot')
|
||||
expect(result.data.content).toContain('Example Domain')
|
||||
}, 15000)
|
||||
|
||||
test('schema only allows navigate and screenshot', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const schema = WebBrowserTool.inputSchema
|
||||
const parseResult = schema.safeParse({
|
||||
url: 'https://example.com',
|
||||
action: 'click',
|
||||
})
|
||||
expect(parseResult.success).toBe(false)
|
||||
})
|
||||
|
||||
test('invalid URL returns error', async () => {
|
||||
const { WebBrowserTool } = await import('../WebBrowserTool.js')
|
||||
const result = await WebBrowserTool.call({ url: 'not-a-url' } as any)
|
||||
expect(result.data.content).toContain('Failed to fetch')
|
||||
})
|
||||
})
|
||||
@@ -17,17 +17,37 @@ export type {
|
||||
WebSearchAdapter,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Check if the current session uses a third-party (non-Anthropic) API provider.
|
||||
* These providers don't support Anthropic's server_tools (server-side web search),
|
||||
* so they must fall back to the Bing scraper adapter.
|
||||
*/
|
||||
function isThirdPartyProvider(): boolean {
|
||||
return !!(
|
||||
process.env.CLAUDE_CODE_USE_OPENAI ||
|
||||
process.env.CLAUDE_CODE_USE_GEMINI ||
|
||||
process.env.CLAUDE_CODE_USE_GROK
|
||||
)
|
||||
}
|
||||
|
||||
let cachedAdapter: WebSearchAdapter | null = null
|
||||
let cachedAdapterKey: 'api' | 'bing' | 'brave' | 'exa' | null = null
|
||||
|
||||
export function createAdapter(): WebSearchAdapter {
|
||||
const envAdapter = process.env.WEB_SEARCH_ADAPTER
|
||||
// Priority:
|
||||
// 1. Explicit env override (WEB_SEARCH_ADAPTER=api|bing|brave)
|
||||
// 2. Third-party provider (OpenAI/Gemini/Grok) → bing (no server_tools support)
|
||||
// 3. First-party Anthropic API → api (server-side web search + connector_text)
|
||||
// 4. Fallback → bing
|
||||
const adapterKey =
|
||||
envAdapter === 'api' || envAdapter === 'bing' || envAdapter === 'brave' || envAdapter === 'exa'
|
||||
? envAdapter
|
||||
: isFirstPartyAnthropicBaseUrl()
|
||||
? 'api'
|
||||
: 'exa'
|
||||
: isThirdPartyProvider()
|
||||
? 'bing'
|
||||
: isFirstPartyAnthropicBaseUrl()
|
||||
? 'api'
|
||||
: 'bing'
|
||||
|
||||
if (cachedAdapter && cachedAdapterKey === adapterKey) return cachedAdapter
|
||||
|
||||
|
||||
@@ -1,18 +1,358 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { mkdir, readdir, readFile, writeFile } from 'fs/promises'
|
||||
import { join, parse } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import type { ToolResultBlockParam } from 'src/Tool.js'
|
||||
import { buildTool } from 'src/Tool.js'
|
||||
import { truncate } from 'src/utils/format.js'
|
||||
import { WORKFLOW_TOOL_NAME } from './constants.js'
|
||||
import { safeParseJSON } from 'src/utils/json.js'
|
||||
import {
|
||||
WORKFLOW_DIR_NAME,
|
||||
WORKFLOW_FILE_EXTENSIONS,
|
||||
WORKFLOW_TOOL_NAME,
|
||||
} from './constants.js'
|
||||
|
||||
const WORKFLOW_RUNS_DIR = '.claude/workflow-runs'
|
||||
|
||||
const inputSchema = z.object({
|
||||
workflow: z.string().describe('Name of the workflow to execute'),
|
||||
args: z.string().optional().describe('Arguments to pass to the workflow'),
|
||||
action: z
|
||||
.enum(['start', 'status', 'advance', 'cancel', 'list'])
|
||||
.optional()
|
||||
.describe('Workflow action. Defaults to start.'),
|
||||
run_id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Workflow run id for status, advance, or cancel.'),
|
||||
})
|
||||
type Input = typeof inputSchema
|
||||
type WorkflowInput = z.infer<Input>
|
||||
|
||||
type WorkflowStepStatus = 'pending' | 'running' | 'completed' | 'cancelled'
|
||||
|
||||
type WorkflowStep = {
|
||||
name: string
|
||||
prompt: string
|
||||
status: WorkflowStepStatus
|
||||
startedAt?: number
|
||||
completedAt?: number
|
||||
}
|
||||
|
||||
type WorkflowRun = {
|
||||
runId: string
|
||||
workflow: string
|
||||
args?: string
|
||||
status: 'running' | 'completed' | 'cancelled'
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
currentStepIndex: number
|
||||
steps: WorkflowStep[]
|
||||
}
|
||||
|
||||
type WorkflowOutput = { output: string }
|
||||
|
||||
async function findWorkflowFile(
|
||||
workflowDir: string,
|
||||
workflow: string,
|
||||
): Promise<{ path: string; content: string } | null> {
|
||||
for (const ext of WORKFLOW_FILE_EXTENSIONS) {
|
||||
const path = join(workflowDir, `${workflow}${ext}`)
|
||||
try {
|
||||
return { path, content: await readFile(path, 'utf-8') }
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function listAvailableWorkflows(workflowDir: string): Promise<string[]> {
|
||||
try {
|
||||
const files = await readdir(workflowDir)
|
||||
return files
|
||||
.filter(f => WORKFLOW_FILE_EXTENSIONS.includes(parse(f).ext.toLowerCase()))
|
||||
.map(f => parse(f).name)
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function workflowRunPath(cwd: string, runId: string): string {
|
||||
return join(cwd, WORKFLOW_RUNS_DIR, `${runId}.json`)
|
||||
}
|
||||
|
||||
async function readWorkflowRun(
|
||||
cwd: string,
|
||||
runId: string,
|
||||
): Promise<WorkflowRun | null> {
|
||||
try {
|
||||
const parsed = safeParseJSON(
|
||||
await readFile(workflowRunPath(cwd, runId), 'utf-8'),
|
||||
false,
|
||||
) as Partial<WorkflowRun> | null
|
||||
if (
|
||||
!parsed ||
|
||||
typeof parsed.runId !== 'string' ||
|
||||
typeof parsed.workflow !== 'string' ||
|
||||
!Array.isArray(parsed.steps)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return parsed as WorkflowRun
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeWorkflowRun(cwd: string, run: WorkflowRun): Promise<void> {
|
||||
await mkdir(join(cwd, WORKFLOW_RUNS_DIR), { recursive: true })
|
||||
await writeFile(
|
||||
workflowRunPath(cwd, run.runId),
|
||||
JSON.stringify(run, null, 2) + '\n',
|
||||
'utf-8',
|
||||
)
|
||||
}
|
||||
|
||||
async function listWorkflowRuns(cwd: string): Promise<WorkflowRun[]> {
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(join(cwd, WORKFLOW_RUNS_DIR))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const runs = await Promise.all(
|
||||
files
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => readWorkflowRun(cwd, f.slice(0, -'.json'.length))),
|
||||
)
|
||||
return runs
|
||||
.filter((run): run is WorkflowRun => run !== null)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
function parseMarkdownSteps(content: string): WorkflowStep[] {
|
||||
const steps: WorkflowStep[] = []
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
const taskMatch = line.match(/^[-*]\s+\[[ xX]\]\s+(.+)$/)
|
||||
const bulletMatch = line.match(/^[-*]\s+(.+)$/)
|
||||
const numberedMatch = line.match(/^\d+[.)]\s+(.+)$/)
|
||||
const text = taskMatch?.[1] ?? bulletMatch?.[1] ?? numberedMatch?.[1]
|
||||
if (!text) continue
|
||||
steps.push({ name: text.slice(0, 80), prompt: text, status: 'pending' })
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
function parseYamlSteps(content: string): WorkflowStep[] {
|
||||
const steps: WorkflowStep[] = []
|
||||
let current: Partial<WorkflowStep> | null = null
|
||||
const flush = () => {
|
||||
if (!current) return
|
||||
const prompt = current.prompt ?? current.name
|
||||
if (current.name && prompt) {
|
||||
steps.push({
|
||||
name: current.name,
|
||||
prompt,
|
||||
status: 'pending',
|
||||
})
|
||||
}
|
||||
current = null
|
||||
}
|
||||
|
||||
for (const rawLine of content.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
const stepText = line.match(/^-\s+(.+)$/)?.[1]
|
||||
if (stepText) {
|
||||
flush()
|
||||
const inlineName = stepText.match(/^name:\s*(.+)$/)?.[1]
|
||||
current = {
|
||||
name: inlineName ?? stepText,
|
||||
prompt: inlineName ? undefined : stepText,
|
||||
}
|
||||
continue
|
||||
}
|
||||
const name = line.match(/^name:\s*(.+)$/)?.[1]
|
||||
if (name) {
|
||||
if (!current) current = {}
|
||||
current.name = name
|
||||
continue
|
||||
}
|
||||
const prompt = line.match(/^(prompt|run|command):\s*(.+)$/)?.[2]
|
||||
if (prompt) {
|
||||
if (!current) current = {}
|
||||
current.prompt = prompt
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return steps
|
||||
}
|
||||
|
||||
function parseWorkflowSteps(filePath: string, content: string): WorkflowStep[] {
|
||||
const ext = parse(filePath).ext.toLowerCase()
|
||||
const steps =
|
||||
ext === '.md' ? parseMarkdownSteps(content) : parseYamlSteps(content)
|
||||
if (steps.length > 0) {
|
||||
return steps
|
||||
}
|
||||
return [
|
||||
{
|
||||
name: 'Execute workflow',
|
||||
prompt: content.trim(),
|
||||
status: 'pending',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function formatStep(step: WorkflowStep, index: number): string {
|
||||
return `Step ${index + 1}: ${step.name}\n${step.prompt}`
|
||||
}
|
||||
|
||||
function formatRunStatus(run: WorkflowRun): string {
|
||||
const lines = [
|
||||
`Workflow run: ${run.runId}`,
|
||||
`Workflow: ${run.workflow}`,
|
||||
`Status: ${run.status}`,
|
||||
`Current step: ${run.steps[run.currentStepIndex]?.name ?? 'none'}`,
|
||||
`Steps: ${run.steps.length}`,
|
||||
]
|
||||
for (let i = 0; i < run.steps.length; i += 1) {
|
||||
const step = run.steps[i]!
|
||||
lines.push(` ${i + 1}. [${step.status}] ${step.name}`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
async function startWorkflow(
|
||||
input: WorkflowInput,
|
||||
cwd: string,
|
||||
): Promise<WorkflowOutput> {
|
||||
const workflowDir = join(cwd, WORKFLOW_DIR_NAME)
|
||||
const found = await findWorkflowFile(workflowDir, input.workflow)
|
||||
if (!found) {
|
||||
const available = await listAvailableWorkflows(workflowDir)
|
||||
const hint =
|
||||
available.length > 0
|
||||
? `\nAvailable workflows: ${available.join(', ')}`
|
||||
: `\nNo workflows found in ${WORKFLOW_DIR_NAME}/. Create .md or .yaml files there.`
|
||||
return { output: `Error: Workflow "${input.workflow}" not found.${hint}` }
|
||||
}
|
||||
|
||||
const steps = parseWorkflowSteps(found.path, found.content)
|
||||
const now = Date.now()
|
||||
steps[0] = { ...steps[0]!, status: 'running', startedAt: now }
|
||||
const run: WorkflowRun = {
|
||||
runId: randomUUID(),
|
||||
workflow: input.workflow,
|
||||
...(input.args ? { args: input.args } : {}),
|
||||
status: 'running',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
currentStepIndex: 0,
|
||||
steps,
|
||||
}
|
||||
await writeWorkflowRun(cwd, run)
|
||||
|
||||
const argsSection = input.args ? `\n\nArguments:\n${input.args}` : ''
|
||||
return {
|
||||
output: [
|
||||
`Workflow run started`,
|
||||
`run_id: ${run.runId}`,
|
||||
`workflow: ${run.workflow}`,
|
||||
'',
|
||||
formatStep(steps[0]!, 0),
|
||||
argsSection,
|
||||
'',
|
||||
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
async function getRunOrError(
|
||||
cwd: string,
|
||||
runId: string | undefined,
|
||||
): Promise<{ run?: WorkflowRun; output?: string }> {
|
||||
if (!runId) return { output: 'Error: run_id is required for this action.' }
|
||||
const run = await readWorkflowRun(cwd, runId)
|
||||
if (!run) return { output: `Error: Workflow run "${runId}" not found.` }
|
||||
return { run }
|
||||
}
|
||||
|
||||
async function advanceWorkflow(
|
||||
cwd: string,
|
||||
runId: string | undefined,
|
||||
): Promise<WorkflowOutput> {
|
||||
const found = await getRunOrError(cwd, runId)
|
||||
if (!found.run) return { output: found.output! }
|
||||
const run = found.run
|
||||
const now = Date.now()
|
||||
const current = run.steps[run.currentStepIndex]
|
||||
if (current && current.status === 'running') {
|
||||
current.status = 'completed'
|
||||
current.completedAt = now
|
||||
}
|
||||
const nextIndex = run.currentStepIndex + 1
|
||||
if (nextIndex >= run.steps.length) {
|
||||
run.status = 'completed'
|
||||
run.updatedAt = now
|
||||
await writeWorkflowRun(cwd, run)
|
||||
return { output: `Workflow completed\nrun_id: ${run.runId}` }
|
||||
}
|
||||
run.currentStepIndex = nextIndex
|
||||
run.steps[nextIndex] = {
|
||||
...run.steps[nextIndex]!,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
}
|
||||
run.updatedAt = now
|
||||
await writeWorkflowRun(cwd, run)
|
||||
return {
|
||||
output: [
|
||||
`Next workflow step`,
|
||||
`run_id: ${run.runId}`,
|
||||
'',
|
||||
formatStep(run.steps[nextIndex]!, nextIndex),
|
||||
'',
|
||||
`When this step is complete, call Workflow with action="advance" and run_id="${run.runId}".`,
|
||||
].join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelWorkflow(
|
||||
cwd: string,
|
||||
runId: string | undefined,
|
||||
): Promise<WorkflowOutput> {
|
||||
const found = await getRunOrError(cwd, runId)
|
||||
if (!found.run) return { output: found.output! }
|
||||
const run = found.run
|
||||
const now = Date.now()
|
||||
run.status = 'cancelled'
|
||||
run.updatedAt = now
|
||||
for (const step of run.steps) {
|
||||
if (step.status === 'pending' || step.status === 'running') {
|
||||
step.status = 'cancelled'
|
||||
}
|
||||
}
|
||||
await writeWorkflowRun(cwd, run)
|
||||
return { output: `Workflow cancelled\nrun_id: ${run.runId}` }
|
||||
}
|
||||
|
||||
async function listWorkflowRunsForOutput(cwd: string): Promise<WorkflowOutput> {
|
||||
const runs = await listWorkflowRuns(cwd)
|
||||
if (runs.length === 0) return { output: 'No workflow runs recorded.' }
|
||||
return {
|
||||
output: runs
|
||||
.slice(0, 20)
|
||||
.map(
|
||||
run =>
|
||||
`${run.runId} | ${run.workflow} | ${run.status} | step=${run.steps[run.currentStepIndex]?.name ?? 'none'} | updated=${new Date(run.updatedAt).toLocaleString()}`,
|
||||
)
|
||||
.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
export const WorkflowTool = buildTool({
|
||||
name: WORKFLOW_TOOL_NAME,
|
||||
searchHint: 'execute user-defined workflow scripts',
|
||||
@@ -22,21 +362,25 @@ export const WorkflowTool = buildTool({
|
||||
inputSchema,
|
||||
|
||||
async description() {
|
||||
return 'Execute a user-defined workflow script from .claude/workflows/'
|
||||
return 'Execute and track a user-defined workflow from .claude/workflows/'
|
||||
},
|
||||
async prompt() {
|
||||
return `Use the Workflow tool to execute user-defined workflow scripts located in .claude/workflows/. Workflows are YAML or Markdown files that define a sequence of steps for common development tasks.
|
||||
return `Use the Workflow tool to run user-defined workflows located in .claude/workflows/. Workflows may be Markdown checklists/lists or YAML files with steps.
|
||||
|
||||
Guidelines:
|
||||
- Specify the workflow name to execute (must match a file in .claude/workflows/)
|
||||
- Optionally pass arguments that the workflow can use
|
||||
- Workflows run in the context of the current project`
|
||||
Actions:
|
||||
- start (default): create a persisted workflow run and return the first step to execute
|
||||
- advance: mark the current step complete and return the next step
|
||||
- status: inspect a workflow run by run_id
|
||||
- cancel: cancel a workflow run
|
||||
- list: list recent workflow runs
|
||||
|
||||
Workflow run state is persisted in .claude/workflow-runs/.`
|
||||
},
|
||||
userFacingName() {
|
||||
return 'Workflow'
|
||||
},
|
||||
isReadOnly() {
|
||||
return false
|
||||
isReadOnly(input) {
|
||||
return input.action === 'status' || input.action === 'list'
|
||||
},
|
||||
isEnabled() {
|
||||
return true
|
||||
@@ -44,10 +388,10 @@ Guidelines:
|
||||
|
||||
renderToolUseMessage(input: Partial<WorkflowInput>) {
|
||||
const name = input.workflow ?? 'unknown'
|
||||
if (input.args) {
|
||||
return `Workflow: ${name} ${input.args}`
|
||||
}
|
||||
return `Workflow: ${name}`
|
||||
const action = input.action ?? 'start'
|
||||
return input.args
|
||||
? `Workflow: ${action} ${name} ${input.args}`
|
||||
: `Workflow: ${action} ${name}`
|
||||
},
|
||||
|
||||
mapToolResultToToolResultBlockParam(
|
||||
@@ -61,14 +405,26 @@ Guidelines:
|
||||
}
|
||||
},
|
||||
|
||||
async call(_input: WorkflowInput, _context, _progress) {
|
||||
// Workflow execution is wired by the WORKFLOW_SCRIPTS feature bootstrap.
|
||||
// Without it, this tool is not functional.
|
||||
return {
|
||||
data: {
|
||||
output:
|
||||
'Error: Workflow execution requires the WORKFLOW_SCRIPTS runtime.',
|
||||
},
|
||||
async call(input: WorkflowInput) {
|
||||
const cwd = process.cwd()
|
||||
const action = input.action ?? 'start'
|
||||
switch (action) {
|
||||
case 'start':
|
||||
return { data: await startWorkflow(input, cwd) }
|
||||
case 'status': {
|
||||
const found = await getRunOrError(cwd, input.run_id)
|
||||
return {
|
||||
data: {
|
||||
output: found.run ? formatRunStatus(found.run) : found.output!,
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'advance':
|
||||
return { data: await advanceWorkflow(cwd, input.run_id) }
|
||||
case 'cancel':
|
||||
return { data: await cancelWorkflow(cwd, input.run_id) }
|
||||
case 'list':
|
||||
return { data: await listWorkflowRunsForOutput(cwd) }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { WorkflowTool } from '../WorkflowTool'
|
||||
|
||||
let cwd: string
|
||||
let previousCwd: string
|
||||
|
||||
beforeEach(async () => {
|
||||
previousCwd = process.cwd()
|
||||
cwd = join(tmpdir(), `workflow-tool-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
await mkdir(join(cwd, '.claude', 'workflows'), { recursive: true })
|
||||
process.chdir(cwd)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(previousCwd)
|
||||
await rm(cwd, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('WorkflowTool', () => {
|
||||
test('starts a workflow run and persists step state', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'release.md'),
|
||||
[
|
||||
'# Release',
|
||||
'',
|
||||
'- [ ] Run tests',
|
||||
'- [ ] Build package',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const result = await WorkflowTool.call({ workflow: 'release' })
|
||||
|
||||
expect(result.data.output).toContain('Workflow run started')
|
||||
expect(result.data.output).toContain('Run tests')
|
||||
const match = result.data.output.match(/run_id: ([a-f0-9-]+)/)
|
||||
expect(match?.[1]).toBeString()
|
||||
|
||||
const raw = await readFile(
|
||||
join(cwd, '.claude', 'workflow-runs', `${match![1]}.json`),
|
||||
'utf-8',
|
||||
)
|
||||
const run = JSON.parse(raw)
|
||||
expect(run.workflow).toBe('release')
|
||||
expect(run.status).toBe('running')
|
||||
expect(run.steps).toHaveLength(2)
|
||||
expect(run.steps[0].status).toBe('running')
|
||||
expect(run.steps[1].status).toBe('pending')
|
||||
})
|
||||
|
||||
test('advances a workflow run through completion', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'audit.yaml'),
|
||||
[
|
||||
'steps:',
|
||||
' - name: Inspect',
|
||||
' prompt: Inspect the code',
|
||||
' - name: Verify',
|
||||
' prompt: Run focused tests',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const started = await WorkflowTool.call({ workflow: 'audit' })
|
||||
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
|
||||
|
||||
const next = await WorkflowTool.call(
|
||||
{ workflow: 'audit', action: 'advance', run_id: runId },
|
||||
)
|
||||
expect(next.data.output).toContain('Next workflow step')
|
||||
expect(next.data.output).toContain('Run focused tests')
|
||||
|
||||
const done = await WorkflowTool.call(
|
||||
{ workflow: 'audit', action: 'advance', run_id: runId },
|
||||
)
|
||||
expect(done.data.output).toContain('Workflow completed')
|
||||
})
|
||||
|
||||
test('lists and cancels workflow runs', async () => {
|
||||
await writeFile(
|
||||
join(cwd, '.claude', 'workflows', 'cleanup.md'),
|
||||
'- Remove stale files',
|
||||
)
|
||||
|
||||
const started = await WorkflowTool.call({ workflow: 'cleanup' })
|
||||
const runId = started.data.output.match(/run_id: ([a-f0-9-]+)/)![1]!
|
||||
|
||||
const listed = await WorkflowTool.call(
|
||||
{ workflow: 'cleanup', action: 'list' },
|
||||
)
|
||||
expect(listed.data.output).toContain(runId)
|
||||
|
||||
const cancelled = await WorkflowTool.call(
|
||||
{ workflow: 'cleanup', action: 'cancel', run_id: runId },
|
||||
)
|
||||
expect(cancelled.data.output).toContain('Workflow cancelled')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnTeammate } from '../spawnMultiAgent'
|
||||
|
||||
let tempHome: string
|
||||
let previousConfigDir: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
tempHome = join(tmpdir(), `spawn-multi-agent-${Date.now()}-${Math.random().toString(16).slice(2)}`)
|
||||
process.env.CLAUDE_CONFIG_DIR = tempHome
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
}
|
||||
rmSync(tempHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('spawnTeammate', () => {
|
||||
test('fails before spawn side effects when the team file is missing', async () => {
|
||||
let setAppStateCalled = false
|
||||
const context = {
|
||||
getAppState: () => ({
|
||||
teamContext: undefined,
|
||||
}),
|
||||
setAppState: () => {
|
||||
setAppStateCalled = true
|
||||
},
|
||||
options: {
|
||||
agentDefinitions: {
|
||||
activeAgents: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await expect(
|
||||
spawnTeammate(
|
||||
{
|
||||
name: 'worker',
|
||||
prompt: 'do work',
|
||||
team_name: 'missing-team',
|
||||
},
|
||||
context as any,
|
||||
),
|
||||
).rejects.toThrow('Team "missing-team" does not exist')
|
||||
expect(setAppStateCalled).toBe(false)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
112
packages/modifiers-napi/src/__tests__/index.test.ts
Normal file
112
packages/modifiers-napi/src/__tests__/index.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
let ffiShouldThrow = false
|
||||
let nativeFlags = 0
|
||||
let dlopenCalls = 0
|
||||
|
||||
mock.module('bun:ffi', () => ({
|
||||
FFIType: {
|
||||
i32: 0,
|
||||
u64: 0,
|
||||
},
|
||||
dlopen: () => {
|
||||
dlopenCalls++
|
||||
if (ffiShouldThrow) {
|
||||
throw new Error('ffi load failed')
|
||||
}
|
||||
return {
|
||||
symbols: {
|
||||
CGEventSourceFlagsState: () => nativeFlags,
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const originalPlatform = process.platform
|
||||
|
||||
async function loadModule() {
|
||||
return import(`../index.ts?case=${Math.random()}`)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
ffiShouldThrow = false
|
||||
nativeFlags = 0
|
||||
dlopenCalls = 0
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('modifiers-napi', () => {
|
||||
test('returns false for non-darwin platforms', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
configurable: true,
|
||||
})
|
||||
const mod = await loadModule()
|
||||
|
||||
await mod.prewarm()
|
||||
expect(dlopenCalls).toBe(0)
|
||||
expect(mod.isModifierPressed('shift')).toBe(false)
|
||||
expect(mod.isModifierPressed('command')).toBe(false)
|
||||
})
|
||||
|
||||
test('prewarm is idempotent on darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
})
|
||||
const mod = await loadModule()
|
||||
|
||||
await mod.prewarm()
|
||||
await mod.prewarm()
|
||||
|
||||
expect(dlopenCalls).toBe(1)
|
||||
})
|
||||
|
||||
test('returns false when ffi loading fails on darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
})
|
||||
ffiShouldThrow = true
|
||||
const mod = await loadModule()
|
||||
|
||||
await mod.prewarm()
|
||||
expect(mod.isModifierPressed('shift')).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false for unknown modifier names on darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
})
|
||||
nativeFlags = 0x20000
|
||||
const mod = await loadModule()
|
||||
|
||||
await mod.prewarm()
|
||||
expect(mod.isModifierPressed('unknown')).toBe(false)
|
||||
})
|
||||
|
||||
test('uses native flag bits for known modifiers on darwin', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
configurable: true,
|
||||
})
|
||||
nativeFlags = 0x20000 | 0x40000
|
||||
const mod = await loadModule()
|
||||
|
||||
await mod.prewarm()
|
||||
expect(mod.isModifierPressed('shift')).toBe(true)
|
||||
expect(mod.isModifierPressed('control')).toBe(true)
|
||||
expect(mod.isModifierPressed('option')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -14,14 +14,16 @@ const modifierFlags: Record<string, number> = {
|
||||
const kCGEventSourceStateCombinedSessionState = 0;
|
||||
|
||||
let cgEventSourceFlagsState: ((stateID: number) => number) | null = null;
|
||||
let ffiLoadAttempted = false;
|
||||
|
||||
function loadFFI(): void {
|
||||
if (cgEventSourceFlagsState !== null || process.platform !== "darwin") {
|
||||
async function loadFFI(): Promise<void> {
|
||||
if (ffiLoadAttempted || process.platform !== "darwin") {
|
||||
return;
|
||||
}
|
||||
ffiLoadAttempted = true;
|
||||
|
||||
try {
|
||||
const ffi = require("bun:ffi") as typeof import("bun:ffi");
|
||||
const ffi = await import("bun:ffi");
|
||||
const lib = ffi.dlopen(
|
||||
`/System/Library/Frameworks/Carbon.framework/Carbon`,
|
||||
{
|
||||
@@ -35,13 +37,12 @@ function loadFFI(): void {
|
||||
return Number(lib.symbols.CGEventSourceFlagsState(stateID));
|
||||
};
|
||||
} catch {
|
||||
// If loading fails, keep the function null so isModifierPressed returns false
|
||||
cgEventSourceFlagsState = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function prewarm(): void {
|
||||
loadFFI();
|
||||
export async function prewarm(): Promise<void> {
|
||||
await loadFFI();
|
||||
}
|
||||
|
||||
export function isModifierPressed(modifier: string): boolean {
|
||||
@@ -49,8 +50,6 @@ export function isModifierPressed(modifier: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
loadFFI();
|
||||
|
||||
if (cgEventSourceFlagsState === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
50
packages/url-handler-napi/src/__tests__/index.test.ts
Normal file
50
packages/url-handler-napi/src/__tests__/index.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { waitForUrlEvent } from '../index'
|
||||
|
||||
const originalEnv = {
|
||||
CLAUDE_CODE_URL_EVENT: process.env.CLAUDE_CODE_URL_EVENT,
|
||||
CLAUDE_CODE_DEEP_LINK_URL: process.env.CLAUDE_CODE_DEEP_LINK_URL,
|
||||
CLAUDE_CODE_URL: process.env.CLAUDE_CODE_URL,
|
||||
}
|
||||
const originalArgv = process.argv.slice()
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = value
|
||||
}
|
||||
}
|
||||
process.argv = originalArgv.slice()
|
||||
})
|
||||
|
||||
describe('waitForUrlEvent', () => {
|
||||
test('resolves to null without a timeout', async () => {
|
||||
await expect(waitForUrlEvent()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('resolves to null with an explicit timeout', async () => {
|
||||
await expect(waitForUrlEvent(1)).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('returns a Claude URL from environment variables', async () => {
|
||||
process.env.CLAUDE_CODE_URL_EVENT = 'claude-cli://prompt?q=hello'
|
||||
|
||||
await expect(waitForUrlEvent()).resolves.toBe(
|
||||
'claude-cli://prompt?q=hello',
|
||||
)
|
||||
})
|
||||
|
||||
test('returns a Claude URL from argv', async () => {
|
||||
process.argv = [...originalArgv, 'claude://prompt?q=hello']
|
||||
|
||||
await expect(waitForUrlEvent()).resolves.toBe('claude://prompt?q=hello')
|
||||
})
|
||||
|
||||
test('rejects URLs exceeding the maximum length', async () => {
|
||||
process.env.CLAUDE_CODE_URL_EVENT = `claude-cli://${'x'.repeat(2048)}`
|
||||
|
||||
await expect(waitForUrlEvent()).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,48 @@
|
||||
const MAX_URL_LENGTH = 2048
|
||||
|
||||
/**
|
||||
* Check for a pending URL event from environment variables or CLI arguments.
|
||||
*
|
||||
* This is a synchronous snapshot check, not an event listener. The optional
|
||||
* timeout parameter is retained for API compatibility but has no practical
|
||||
* effect since process.env and process.argv do not change at runtime.
|
||||
* Callers that need to wait for an OS-level deep link activation should use
|
||||
* an IPC channel or platform-specific event listener instead.
|
||||
*/
|
||||
export async function waitForUrlEvent(timeoutMs?: number): Promise<string | null> {
|
||||
return null
|
||||
return findUrlEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks three env var sources (set by the OS URL scheme handler or installer)
|
||||
* and then CLI arguments for a claude:// deep link URL.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. CLAUDE_CODE_URL_EVENT — set by the OS URL scheme handler on activation
|
||||
* 2. CLAUDE_CODE_DEEP_LINK_URL — set by the desktop app launcher
|
||||
* 3. CLAUDE_CODE_URL — legacy / manual override
|
||||
* 4. CLI arguments — e.g. `claude claude://...`
|
||||
*/
|
||||
function findUrlEvent(): string | null {
|
||||
for (const key of [
|
||||
'CLAUDE_CODE_URL_EVENT',
|
||||
'CLAUDE_CODE_DEEP_LINK_URL',
|
||||
'CLAUDE_CODE_URL',
|
||||
]) {
|
||||
const value = process.env[key]
|
||||
if (isClaudeUrl(value)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const arg = process.argv.find(isClaudeUrl)
|
||||
return arg ?? null
|
||||
}
|
||||
|
||||
function isClaudeUrl(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length <= MAX_URL_LENGTH &&
|
||||
(value.startsWith('claude-cli://') || value.startsWith('claude://'))
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user