Show bounded operation summaries for Continue, DeepSeek Harness and direct-model tools, retaining input details across incremental updates. Use one canonical workspace path for DeepSeek Host startup and ACP sessions so equivalent Windows paths reuse a working Runtime. Compare persisted subagent tool fields by value, release terminal task caches, and avoid rebuilding caches for duplicate remote events. Upgrade to schema 35 to recompact repeated schema-34 tool blocks while preserving events and provenance. Include regression coverage, validation records and temporary-resource test exclusions. Validation: 4155 tests passed, 66 skipped; typecheck and lint passed. Full desktop real-model validation covered all three tool-summary paths and both Windows path spellings. Storage validation evidence includes local and remote tool workloads and real database upgrade checks. Release note: Tool records now show paths, commands and search details across runtimes. Fix DeepSeek Windows workspace failures and repeated subagent history growth; schema 35 reclaims redundant history data while preserving execution records, and older clients cannot reopen the upgraded database.
6081 lines
176 KiB
TypeScript
6081 lines
176 KiB
TypeScript
import { createHash } from 'node:crypto'
|
||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||
import { tmpdir } from 'node:os'
|
||
import { join, relative } from 'node:path'
|
||
import { rgPath } from '@vscode/ripgrep'
|
||
import { describe, expect, it, vi } from 'vitest'
|
||
import {
|
||
ModelToolProvider,
|
||
RecoverableModelToolError,
|
||
type ModelToolDefinition,
|
||
type ModelToolProviderLike,
|
||
type ModelToolResult
|
||
} from './model-tool-provider'
|
||
import { ModelAgentRuntime } from './model-runtime'
|
||
import type { RuntimeEvent } from './runtime'
|
||
import { SubagentScheduler } from '../assistant/subagent-scheduler'
|
||
import { LocalWorkspaceAccess } from '../workspace'
|
||
import { LocalDirectModelProcessService } from './direct-model-process-service'
|
||
import { browserTabIdSchema } from '../../shared/contracts'
|
||
import type { BrowserToolService } from '../browser/browser-model-tools'
|
||
|
||
const toolPng = Buffer.from([
|
||
0x89, 0x50, 0x4e, 0x47,
|
||
0x0d, 0x0a, 0x1a, 0x0a
|
||
]).toString('base64')
|
||
|
||
it('preserves full supplied and cached history beyond message and byte caps', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async () => new Response(
|
||
createEventStream('reply'),
|
||
{ headers: { 'content-type': 'text/event-stream' } }
|
||
))
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://example.test',
|
||
model: 'test-model',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher
|
||
})
|
||
const history = Array.from({ length: 502 }, (_, index) => ({
|
||
role: index % 2 ? 'assistant' as const : 'user' as const,
|
||
content: `${index}: ${'x'.repeat(4_200)}`
|
||
}))
|
||
for (const suppliedHistory of [history, undefined]) {
|
||
for await (const event of runtime.run({
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: 'full-history',
|
||
prompt: 'next',
|
||
history: suppliedHistory
|
||
}, new AbortController().signal)) {
|
||
expect(event).toBeDefined()
|
||
}
|
||
}
|
||
for (const [, init] of fetcher.mock.calls) {
|
||
const body = JSON.parse(String(init?.body))
|
||
expect(body.messages.slice(0, history.length)).toEqual(history)
|
||
}
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
})
|
||
|
||
function createTextToolResult(text: string): ModelToolResult {
|
||
return {
|
||
parts: [{ type: 'text', text }],
|
||
contextBytes: Buffer.byteLength(text)
|
||
}
|
||
}
|
||
|
||
function createMultimodalToolResult(): ModelToolResult {
|
||
return {
|
||
parts: [
|
||
{ type: 'text', text: 'tool result' },
|
||
{
|
||
type: 'image',
|
||
mimeType: 'image/png',
|
||
data: toolPng
|
||
}
|
||
],
|
||
contextBytes:
|
||
Buffer.byteLength('tool result') + Buffer.byteLength(toolPng)
|
||
}
|
||
}
|
||
|
||
function createEventStream(
|
||
text: string,
|
||
thinking?: string,
|
||
usage: {
|
||
inputTokens?: number
|
||
outputTokens?: number
|
||
cacheReadTokens?: number
|
||
cacheWriteTokens?: number
|
||
} = {}
|
||
): string {
|
||
const {
|
||
inputTokens = 23,
|
||
outputTokens = 11,
|
||
cacheReadTokens = 7,
|
||
cacheWriteTokens = 5
|
||
} = usage
|
||
return [
|
||
'event: message_start',
|
||
`data: ${JSON.stringify({
|
||
type: 'message_start',
|
||
message: {
|
||
id: 'message-1',
|
||
model: 'claude-sonnet-provider',
|
||
usage: {
|
||
input_tokens: inputTokens,
|
||
cache_creation_input_tokens: cacheWriteTokens,
|
||
cache_read_input_tokens: cacheReadTokens
|
||
}
|
||
}
|
||
})}`,
|
||
'',
|
||
...(thinking
|
||
? [
|
||
'event: content_block_delta',
|
||
`data: ${JSON.stringify({
|
||
type: 'content_block_delta',
|
||
delta: { type: 'thinking_delta', thinking }
|
||
})}`,
|
||
''
|
||
]
|
||
: []),
|
||
'event: content_block_delta',
|
||
`data: ${JSON.stringify({
|
||
type: 'content_block_delta',
|
||
delta: { type: 'text_delta', text }
|
||
})}`,
|
||
'',
|
||
'event: message_delta',
|
||
`data: ${JSON.stringify({
|
||
type: 'message_delta',
|
||
usage: { output_tokens: outputTokens }
|
||
})}`,
|
||
'',
|
||
'event: message_stop',
|
||
'data: {"type":"message_stop"}',
|
||
'',
|
||
''
|
||
].join('\n')
|
||
}
|
||
|
||
function createResponsesEventStream(
|
||
text: string,
|
||
reasoning?: string
|
||
): string {
|
||
return [
|
||
...(reasoning
|
||
? [
|
||
'event: response.reasoning_summary_text.delta',
|
||
`data: ${JSON.stringify({
|
||
type: 'response.reasoning_summary_text.delta',
|
||
delta: reasoning
|
||
})}`,
|
||
''
|
||
]
|
||
: []),
|
||
'event: response.output_text.delta',
|
||
`data: ${JSON.stringify({
|
||
type: 'response.output_text.delta',
|
||
delta: text
|
||
})}`,
|
||
'',
|
||
'event: response.completed',
|
||
`data: ${JSON.stringify({
|
||
type: 'response.completed',
|
||
response: {
|
||
id: 'resp-provider-1',
|
||
model: 'gpt-5-provider',
|
||
usage: {
|
||
input_tokens: 29,
|
||
output_tokens: 8,
|
||
total_tokens: 37,
|
||
input_tokens_details: { cached_tokens: 11 }
|
||
}
|
||
}
|
||
})}`,
|
||
'',
|
||
''
|
||
].join('\n')
|
||
}
|
||
|
||
function createSseEventStream(
|
||
events: ReadonlyArray<Record<string, unknown>>
|
||
): string {
|
||
return [
|
||
...events.flatMap((event) => [
|
||
`event: ${event.type as string}`,
|
||
`data: ${JSON.stringify(event)}`,
|
||
''
|
||
]),
|
||
''
|
||
].join('\n')
|
||
}
|
||
|
||
function createToolProvider(
|
||
overrides: Partial<ModelToolProviderLike> = {}
|
||
): ModelToolProviderLike {
|
||
const tool: ModelToolDefinition = {
|
||
name: 'workspace_read_text',
|
||
displayName: '读取工作区文本',
|
||
description: 'Read text',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: { path: { type: 'string' } },
|
||
required: ['path']
|
||
},
|
||
source: 'builtin'
|
||
}
|
||
return {
|
||
listTools: vi.fn(async () => [tool]),
|
||
getApproval: vi.fn((_definition, _arguments, summary) => ({
|
||
scopeKey: 'model:builtin:workspace_read_text',
|
||
title: '允许读取工作区文本?',
|
||
description: '读取文件',
|
||
toolName: '读取工作区文本',
|
||
argumentSummary: summary
|
||
})),
|
||
callTool: vi.fn(async () => createTextToolResult('tool result')),
|
||
releaseConversation: vi.fn(async () => {}),
|
||
dispose: vi.fn(async () => {}),
|
||
...overrides
|
||
}
|
||
}
|
||
|
||
describe('ModelAgentRuntime', () => {
|
||
it('rejects images when the model connection disables image input', async () => {
|
||
const fetcher = vi.fn<typeof fetch>()
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
supportsImageInput: false,
|
||
fetcher
|
||
})
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: '3f496642-f47d-4e0a-8944-a32c77b0d6ef',
|
||
conversationId: 'conversation-1',
|
||
prompt: 'describe',
|
||
images: [
|
||
{
|
||
name: 'screenshot.png',
|
||
mediaType: 'image/png',
|
||
data: 'aW1hZ2U='
|
||
}
|
||
]
|
||
},
|
||
new AbortController().signal
|
||
)
|
||
|
||
await expect(stream.next()).rejects.toThrow(
|
||
'当前模型连接未启用图像输入'
|
||
)
|
||
expect(fetcher).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('keeps provider-reported image usage after the response completes', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(createResponsesEventStream('image described'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-5',
|
||
protocol: 'openai-responses',
|
||
authentication: 'api-key',
|
||
supportsImageInput: true,
|
||
fetcher,
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 20_000,
|
||
recentRawTokens: 4_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve important facts.'
|
||
},
|
||
contextWindowTokens: 32_000
|
||
}
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'describe',
|
||
images: [
|
||
{
|
||
name: 'screenshot.png',
|
||
mediaType: 'image/png',
|
||
data: toolPng
|
||
}
|
||
]
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
const body = JSON.parse(
|
||
fetcher.mock.calls[0]?.[1]?.body as string
|
||
) as { input: unknown[] }
|
||
expect(JSON.stringify(body.input)).toContain('input_image')
|
||
expect(
|
||
events.filter((event) => event.type === 'context-metrics')
|
||
).toEqual([
|
||
expect.objectContaining({
|
||
contextTokens: 37,
|
||
source: 'provider'
|
||
})
|
||
])
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it.each([
|
||
{
|
||
protocol: 'anthropic-messages' as const,
|
||
payloadKey: 'messages' as const,
|
||
response: () =>
|
||
new Response(createEventStream('history accepted'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
},
|
||
{
|
||
protocol: 'openai-chat-completions' as const,
|
||
payloadKey: 'messages' as const,
|
||
response: () =>
|
||
new Response(
|
||
[
|
||
'data: {"choices":[{"delta":{"content":"history accepted"}}]}',
|
||
'',
|
||
'data: [DONE]',
|
||
'',
|
||
''
|
||
].join('\n'),
|
||
{
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
}
|
||
)
|
||
},
|
||
{
|
||
protocol: 'openai-responses' as const,
|
||
payloadKey: 'input' as const,
|
||
response: () =>
|
||
new Response(createResponsesEventStream('history accepted'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
}
|
||
])(
|
||
'omits local message IDs from $protocol history',
|
||
async ({ protocol, payloadKey, response }) => {
|
||
const localMessageIds = [
|
||
'00000000-0000-4000-8000-000000000011',
|
||
'00000000-0000-4000-8000-000000000012'
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () => response())
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://model.example/v1',
|
||
model: 'test-model',
|
||
protocol,
|
||
authentication: 'api-key',
|
||
fetcher
|
||
})
|
||
|
||
try {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: '00000000-0000-4000-8000-000000000010',
|
||
conversationId: 'conversation-local-message-ids',
|
||
prompt: 'current question',
|
||
history: [
|
||
{ role: 'user', content: 'earlier question' },
|
||
{ role: 'assistant', content: 'earlier answer' }
|
||
],
|
||
historyMessageIds: localMessageIds
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
void _event
|
||
}
|
||
} finally {
|
||
await runtime.dispose()
|
||
}
|
||
|
||
const body = JSON.parse(
|
||
fetcher.mock.calls[0]?.[1]?.body as string
|
||
) as Record<string, unknown>
|
||
const messages = body[payloadKey] as Array<
|
||
Record<string, unknown>
|
||
>
|
||
expect(
|
||
messages.filter(
|
||
(message) =>
|
||
message.role === 'user' ||
|
||
message.role === 'assistant'
|
||
)
|
||
).toEqual([
|
||
{ role: 'user', content: 'earlier question' },
|
||
{ role: 'assistant', content: 'earlier answer' },
|
||
{ role: 'user', content: 'current question' }
|
||
])
|
||
expect(JSON.stringify(body)).not.toContain(localMessageIds[0])
|
||
expect(JSON.stringify(body)).not.toContain(localMessageIds[1])
|
||
}
|
||
)
|
||
|
||
it('requires generated text from a real minimal Anthropic request when testing the connection', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
|
||
const body = JSON.parse(init?.body as string) as {
|
||
messages: Array<{ content: string }>
|
||
}
|
||
const marker =
|
||
/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec(
|
||
body.messages[0]?.content ?? ''
|
||
)?.[0]
|
||
if (!marker) {
|
||
throw new Error('missing model test marker')
|
||
}
|
||
return Response.json({
|
||
content: [{ type: 'text', text: marker }]
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher
|
||
})
|
||
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
id: 'model',
|
||
detail: expect.stringContaining('真实模型生成测试')
|
||
})
|
||
const body = JSON.parse(
|
||
fetcher.mock.calls[0]?.[1]?.body as string
|
||
) as { max_tokens: number; stream: boolean }
|
||
expect(body).toMatchObject({ max_tokens: 64, stream: false })
|
||
})
|
||
|
||
it('rejects a successful HTTP response that does not contain generated test text', async () => {
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn(async () =>
|
||
Response.json({
|
||
content: [{ type: 'text', text: 'generic health check' }]
|
||
})
|
||
)
|
||
})
|
||
|
||
await expect(runtime.testConnection()).rejects.toThrow(
|
||
'未完成真实生成测试'
|
||
)
|
||
})
|
||
|
||
it('validates generated test text from an OpenAI Chat Completions response', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
|
||
const body = JSON.parse(init?.body as string) as {
|
||
messages: Array<{ content: string }>
|
||
}
|
||
const marker =
|
||
/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec(
|
||
body.messages[0]?.content ?? ''
|
||
)?.[0]
|
||
if (!marker) {
|
||
throw new Error('missing model test marker')
|
||
}
|
||
return Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: marker
|
||
}
|
||
}
|
||
]
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://model.example/v1',
|
||
model: 'chat-model',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'api-key',
|
||
fetcher
|
||
})
|
||
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
detail: expect.stringContaining('真实模型生成测试')
|
||
})
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it('merges custom headers and body fields into a direct model request without overriding runtime fields', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
|
||
const body = JSON.parse(init?.body as string) as {
|
||
messages: Array<{ content: string }>
|
||
}
|
||
const marker =
|
||
/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec(
|
||
body.messages[0]?.content ?? ''
|
||
)?.[0]
|
||
return Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: marker
|
||
}
|
||
}
|
||
]
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'runtime-key',
|
||
baseUrl: 'https://model.example/v1',
|
||
model: 'runtime-model',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'api-key',
|
||
requestHeaders: {
|
||
'x-tenant-id': 'tenant-a',
|
||
authorization: 'Bearer custom-key'
|
||
},
|
||
requestBody: {
|
||
temperature: 0.2,
|
||
model: 'custom-model',
|
||
stream: true
|
||
},
|
||
fetcher
|
||
})
|
||
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true
|
||
})
|
||
const init = fetcher.mock.calls[0]?.[1]
|
||
const headers = new Headers(init?.headers)
|
||
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
|
||
expect(headers.get('x-tenant-id')).toBe('tenant-a')
|
||
expect(headers.get('authorization')).toBe('Bearer runtime-key')
|
||
expect(body).toMatchObject({
|
||
model: 'runtime-model',
|
||
stream: false,
|
||
temperature: 0.2
|
||
})
|
||
})
|
||
|
||
it('uses the Anthropic messages endpoint and streams text deltas', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||
return new Response(createEventStream('真实模型回答', '先分析问题'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
maximumOutputTokens: 48_000,
|
||
skillInstructions: '# 文档写作',
|
||
fetcher
|
||
})
|
||
const events: Array<{ type: string; state?: string }> = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed125',
|
||
conversationId: 'conversation-1',
|
||
prompt: '你好',
|
||
trustedInstructions: 'Trusted specialist system instruction.'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
const [input, init] = fetcher.mock.calls[0] ?? []
|
||
expect(input?.toString()).toBe('https://bigtoken.ai/v1/messages')
|
||
expect(init?.method).toBe('POST')
|
||
|
||
const body = JSON.parse(init?.body as string) as {
|
||
model: string
|
||
max_tokens: number
|
||
stream: boolean
|
||
system: string
|
||
}
|
||
expect(body).toMatchObject({
|
||
model: 'sonnet-5',
|
||
max_tokens: 48_000,
|
||
stream: true
|
||
})
|
||
expect(body.system).toMatch(
|
||
/Current system time: \d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\./u
|
||
)
|
||
expect(body.system).toContain('# 文档写作')
|
||
expect(body.system).toContain('Trusted specialist system instruction.')
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'reasoning',
|
||
delta: '先分析问题'
|
||
})
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: '真实模型回答'
|
||
})
|
||
)
|
||
expect(events.filter((event) => event.type === 'model-usage')).toEqual([
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed125',
|
||
type: 'model-usage',
|
||
callId: expect.stringMatching(
|
||
/^model-call:[0-9a-f]{32}:message-1$/u
|
||
),
|
||
runtime: 'model',
|
||
provider: 'anthropic',
|
||
model: 'claude-sonnet-provider',
|
||
inputTokens: 23,
|
||
outputTokens: 11,
|
||
cacheReadTokens: 7,
|
||
cacheWriteTokens: 5
|
||
}
|
||
])
|
||
expect(events.at(-2)).toMatchObject({ type: 'model-usage' })
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('uses a hard-limit preflight guard while preserving recent raw turns', async () => {
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(
|
||
new Response(createEventStream('压缩后的摘要'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
new Response(createEventStream('继续回答'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
requestHeaders: { 'x-model-route': 'summary-test' },
|
||
requestBody: { temperature: 0.1 },
|
||
fetcher,
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 15_000,
|
||
recentRawTokens: 5_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Summarize earlier history.'
|
||
},
|
||
contextWindowTokens: 32_000
|
||
}
|
||
})
|
||
const history = [
|
||
{ role: 'user' as const, content: `old-user-${'a'.repeat(16_000)}` },
|
||
{
|
||
role: 'assistant' as const,
|
||
content: `old-assistant-${'b'.repeat(16_000)}`
|
||
},
|
||
{ role: 'user' as const, content: `mid-user-${'c'.repeat(16_000)}` },
|
||
{
|
||
role: 'assistant' as const,
|
||
content: `mid-assistant-${'d'.repeat(16_000)}`
|
||
},
|
||
{ role: 'user' as const, content: `new-user-${'e'.repeat(16_000)}` },
|
||
{
|
||
role: 'assistant' as const,
|
||
content: `new-assistant-${'f'.repeat(16_000)}`
|
||
}
|
||
]
|
||
const historyMessageIds = [
|
||
'00000000-0000-4000-8000-000000000221',
|
||
'00000000-0000-4000-8000-000000000222',
|
||
'00000000-0000-4000-8000-000000000223',
|
||
'00000000-0000-4000-8000-000000000224',
|
||
'00000000-0000-4000-8000-000000000225',
|
||
'00000000-0000-4000-8000-000000000226'
|
||
]
|
||
const events: RuntimeEvent[] = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed222',
|
||
conversationId: 'conversation-compressed',
|
||
prompt: '继续',
|
||
history,
|
||
historyMessageIds
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
for (const [, init] of fetcher.mock.calls) {
|
||
expect(new Headers(init?.headers).get('x-model-route')).toBe(
|
||
'summary-test'
|
||
)
|
||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||
temperature: 0.1
|
||
})
|
||
}
|
||
const summaryBody = JSON.parse(
|
||
fetcher.mock.calls[0]![1]!.body as string
|
||
) as { max_tokens: number; system: string; messages: unknown[] }
|
||
expect(summaryBody.max_tokens).toBe(32_000)
|
||
expect(summaryBody.system).toContain('Summarize earlier history.')
|
||
expect(JSON.stringify(summaryBody.messages)).toContain('old-user-')
|
||
expect(JSON.stringify(summaryBody.messages)).not.toContain('new-user-')
|
||
expect(JSON.stringify(summaryBody)).not.toContain(
|
||
historyMessageIds[0]
|
||
)
|
||
|
||
const answerBody = JSON.parse(
|
||
fetcher.mock.calls[1]![1]!.body as string
|
||
) as { messages: unknown[] }
|
||
const answerMessages = JSON.stringify(answerBody.messages)
|
||
expect(answerMessages).toContain('压缩后的摘要')
|
||
expect(answerMessages).toContain('new-user-')
|
||
expect(answerMessages).not.toContain('old-user-')
|
||
for (const id of historyMessageIds) {
|
||
expect(answerMessages).not.toContain(id)
|
||
}
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'context-compression',
|
||
state: 'started',
|
||
estimatedBeforeTokens: expect.any(Number)
|
||
})
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'context-compression',
|
||
state: 'completed',
|
||
estimatedAfterTokens: expect.any(Number),
|
||
conversationState: expect.objectContaining({
|
||
coveredMessageCount: 4,
|
||
coveredFromMessageId: historyMessageIds[0],
|
||
coveredThroughMessageId: historyMessageIds[3],
|
||
coveredHistoryDigest: expect.stringMatching(/^[0-9a-f]{64}$/u),
|
||
summary: '压缩后的摘要'
|
||
})
|
||
})
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'context-metrics',
|
||
contextTokens: 46,
|
||
source: 'provider'
|
||
})
|
||
)
|
||
expect(events).not.toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'status',
|
||
message: '正在准备直连模型上下文'
|
||
})
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'model-usage',
|
||
callId: expect.stringMatching(
|
||
/^context-summary:model-call:[0-9a-f]{32}:message-1$/u
|
||
)
|
||
})
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({ type: 'text', delta: '继续回答' })
|
||
)
|
||
})
|
||
|
||
it('manually compacts Continue history even when automatic compression is disabled', async () => {
|
||
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||
new Response(createEventStream('手动压缩摘要'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: false,
|
||
triggerTokens: 20_000,
|
||
recentRawTokens: 5_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Summarize earlier history.'
|
||
},
|
||
contextWindowTokens: 32_000
|
||
}
|
||
})
|
||
const history = [
|
||
{ role: 'user' as const, content: 'earlier question' },
|
||
{ role: 'assistant' as const, content: 'earlier answer' },
|
||
{ role: 'user' as const, content: 'recent question' },
|
||
{ role: 'assistant' as const, content: 'recent answer' }
|
||
]
|
||
const outcome = await runtime.compactConversation(
|
||
{
|
||
requestId: '00000000-0000-4000-8000-000000000081',
|
||
conversationId: '00000000-0000-4000-8000-000000000082',
|
||
runtimeSelection: { provider: 'continue' },
|
||
history,
|
||
historyMessageIds: [
|
||
'00000000-0000-4000-8000-000000000083',
|
||
'00000000-0000-4000-8000-000000000084',
|
||
'00000000-0000-4000-8000-000000000085',
|
||
'00000000-0000-4000-8000-000000000086'
|
||
]
|
||
},
|
||
new AbortController().signal
|
||
)
|
||
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
const body = JSON.parse(
|
||
fetcher.mock.calls[0]![1]!.body as string
|
||
) as { messages: unknown[] }
|
||
expect(JSON.stringify(body.messages)).toContain('earlier question')
|
||
expect(JSON.stringify(body.messages)).not.toContain('recent question')
|
||
expect(outcome.result).toMatchObject({
|
||
provider: 'continue',
|
||
strategy: 'goodbuddy-summary',
|
||
compacted: true,
|
||
contextCompressionState: {
|
||
coveredMessageCount: 2,
|
||
coveredFromMessageId:
|
||
'00000000-0000-4000-8000-000000000083',
|
||
coveredThroughMessageId:
|
||
'00000000-0000-4000-8000-000000000084',
|
||
summary: '手动压缩摘要'
|
||
}
|
||
})
|
||
await runtime.dispose()
|
||
})
|
||
|
||
it('waits for completed provider usage before normal threshold compression', async () => {
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(
|
||
new Response(
|
||
createEventStream('正常回答', undefined, {
|
||
inputTokens: 15_500,
|
||
outputTokens: 500,
|
||
cacheReadTokens: 0,
|
||
cacheWriteTokens: 0
|
||
}),
|
||
{
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
}
|
||
)
|
||
)
|
||
.mockResolvedValueOnce(
|
||
new Response(createEventStream('回复后摘要'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const history = [
|
||
{ role: 'user' as const, content: 'a'.repeat(12_000) },
|
||
{ role: 'assistant' as const, content: 'b'.repeat(12_000) },
|
||
{ role: 'user' as const, content: 'c'.repeat(12_000) },
|
||
{ role: 'assistant' as const, content: 'd'.repeat(12_000) }
|
||
]
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 15_000,
|
||
recentRawTokens: 5_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve important facts.'
|
||
},
|
||
contextWindowTokens: 32_000
|
||
}
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: '继续',
|
||
history
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
const firstBody = JSON.parse(
|
||
fetcher.mock.calls[0]?.[1]?.body as string
|
||
) as { system: string; messages: unknown[] }
|
||
expect(firstBody.system).not.toContain(
|
||
'Conversation history and any existing summary'
|
||
)
|
||
expect(JSON.stringify(firstBody.messages)).toContain(
|
||
'a'.repeat(1_000)
|
||
)
|
||
expect(
|
||
events.findIndex(
|
||
(event) =>
|
||
event.type === 'context-compression' &&
|
||
event.state === 'started'
|
||
)
|
||
).toBeGreaterThan(
|
||
events.findIndex(
|
||
(event) =>
|
||
event.type === 'text' && event.delta === '正常回答'
|
||
)
|
||
)
|
||
expect(
|
||
events.filter((event) => event.type === 'context-metrics')
|
||
).toEqual([
|
||
expect.objectContaining({
|
||
contextTokens: 16_000,
|
||
source: 'provider'
|
||
})
|
||
])
|
||
})
|
||
|
||
it('reuses persisted conversation summary state after Runtime restart', async () => {
|
||
const history = [
|
||
{ role: 'user' as const, content: 'old question' },
|
||
{ role: 'assistant' as const, content: 'old answer' },
|
||
{ role: 'user' as const, content: 'recent question' },
|
||
{ role: 'assistant' as const, content: 'recent answer' }
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(createEventStream('continued answer'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 200_000,
|
||
recentRawTokens: 32_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve important facts.'
|
||
}
|
||
}
|
||
})
|
||
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'continue',
|
||
history,
|
||
contextCompressionState: {
|
||
coveredHistoryDigest: createHash('sha256')
|
||
.update(JSON.stringify(history.slice(0, 2)))
|
||
.digest('hex'),
|
||
coveredMessageCount: 2,
|
||
summary: 'persisted summary'
|
||
}
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
void _event
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
const body = JSON.parse(
|
||
fetcher.mock.calls[0]?.[1]?.body as string
|
||
) as { messages: unknown[] }
|
||
const messages = JSON.stringify(body.messages)
|
||
expect(messages).toContain('persisted summary')
|
||
expect(messages).toContain('recent question')
|
||
expect(messages).not.toContain('old question')
|
||
})
|
||
|
||
it('keeps a persisted summary after its covered prefix rolls out of bounded history', async () => {
|
||
const history = [
|
||
{ role: 'user' as const, content: 'recent question' },
|
||
{ role: 'assistant' as const, content: 'recent answer' }
|
||
]
|
||
const historyMessageIds = [
|
||
'00000000-0000-4000-8000-000000000603',
|
||
'00000000-0000-4000-8000-000000000604'
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(createEventStream('continued answer'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 200_000,
|
||
recentRawTokens: 32_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve important facts.'
|
||
}
|
||
}
|
||
})
|
||
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'continue',
|
||
history,
|
||
historyMessageIds,
|
||
contextCompressionState: {
|
||
coveredHistoryDigest: createHash('sha256')
|
||
.update(
|
||
JSON.stringify([
|
||
{ role: 'user', content: 'evicted question' },
|
||
{ role: 'assistant', content: 'evicted answer' }
|
||
])
|
||
)
|
||
.digest('hex'),
|
||
coveredMessageCount: 2,
|
||
coveredFromMessageId:
|
||
'00000000-0000-4000-8000-000000000601',
|
||
coveredThroughMessageId:
|
||
'00000000-0000-4000-8000-000000000602',
|
||
summary: 'persisted evicted summary'
|
||
}
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
void _event
|
||
}
|
||
|
||
const body = JSON.parse(
|
||
fetcher.mock.calls[0]?.[1]?.body as string
|
||
) as { messages: unknown[] }
|
||
const messages = JSON.stringify(body.messages)
|
||
expect(messages).toContain('persisted evicted summary')
|
||
expect(messages).toContain('recent question')
|
||
expect(messages).not.toContain('evicted question')
|
||
})
|
||
|
||
it('compresses a completed oversized response before reporting done', async () => {
|
||
const longAnswer = `最终长回复-${'终'.repeat(12_000)}`
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(
|
||
new Response(
|
||
createEventStream(longAnswer, undefined, {
|
||
inputTokens: 11_500,
|
||
outputTokens: 1_000,
|
||
cacheReadTokens: 0,
|
||
cacheWriteTokens: 0
|
||
}),
|
||
{
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
}
|
||
)
|
||
)
|
||
.mockResolvedValueOnce(
|
||
new Response(createEventStream('最终回合摘要'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 12_000,
|
||
recentRawTokens: 4_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve important facts.'
|
||
}
|
||
}
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: '生成长回复'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
const compressionEvents = events.filter(
|
||
(event) =>
|
||
event.type === 'context-compression' &&
|
||
event.scope === 'conversation'
|
||
)
|
||
expect(compressionEvents).toEqual([
|
||
expect.objectContaining({
|
||
state: 'started',
|
||
estimatedBeforeTokens: expect.any(Number)
|
||
}),
|
||
expect.objectContaining({
|
||
state: 'completed',
|
||
estimatedAfterTokens: expect.any(Number)
|
||
})
|
||
])
|
||
expect(
|
||
events.findIndex(
|
||
(event) =>
|
||
event.type === 'context-compression' &&
|
||
event.state === 'started'
|
||
)
|
||
).toBeGreaterThan(
|
||
events.findIndex(
|
||
(event) =>
|
||
event.type === 'text' && event.delta === longAnswer
|
||
)
|
||
)
|
||
expect(
|
||
events.filter((event) => event.type === 'context-metrics')
|
||
).toEqual([
|
||
expect.objectContaining({
|
||
contextTokens: 12_500,
|
||
source: 'provider'
|
||
})
|
||
])
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('keeps a completed answer when post-response compression fails', async () => {
|
||
const longAnswer = `最终长回复-${'终'.repeat(12_000)}`
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(
|
||
new Response(
|
||
createEventStream(longAnswer, undefined, {
|
||
inputTokens: 11_500,
|
||
outputTokens: 1_000,
|
||
cacheReadTokens: 0,
|
||
cacheWriteTokens: 0
|
||
}),
|
||
{
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
}
|
||
)
|
||
)
|
||
.mockResolvedValueOnce(
|
||
Response.json(
|
||
{ error: { message: 'summary unavailable' } },
|
||
{ status: 503 }
|
||
)
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 12_000,
|
||
recentRawTokens: 4_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve important facts.'
|
||
}
|
||
}
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: '生成长回复'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'context-compression',
|
||
scope: 'conversation',
|
||
state: 'failed'
|
||
})
|
||
)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('does not publish partial usage when a stream ends without message_stop', async () => {
|
||
const completeStream = createEventStream('partial')
|
||
const stream = completeStream.slice(
|
||
0,
|
||
completeStream.indexOf('event: message_stop')
|
||
)
|
||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||
return new Response(stream, {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 20_000,
|
||
recentRawTokens: 4_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve important facts.'
|
||
},
|
||
contextWindowTokens: 32_000
|
||
}
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
|
||
const consume = async (): Promise<void> => {
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed126',
|
||
conversationId: 'conversation-2',
|
||
prompt: '你好'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow('意外中断')
|
||
expect(
|
||
events.filter((event) => event.type === 'context-metrics')
|
||
).toEqual([])
|
||
})
|
||
|
||
it('estimates completed usage only when the provider omits usage', async () => {
|
||
const stream = [
|
||
'event: content_block_delta',
|
||
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"fallback answer"}}',
|
||
'',
|
||
'event: message_stop',
|
||
'data: {"type":"message_stop"}',
|
||
'',
|
||
''
|
||
].join('\n')
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
new Response(stream, {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
),
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 20_000,
|
||
recentRawTokens: 4_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve important facts.'
|
||
},
|
||
contextWindowTokens: 32_000
|
||
}
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'provider omits usage'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(
|
||
events.filter((event) => event.type === 'context-metrics')
|
||
).toEqual([
|
||
expect.objectContaining({
|
||
contextTokens: expect.any(Number),
|
||
source: 'estimated'
|
||
})
|
||
])
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('rejects malformed SSE JSON instead of silently skipping it', async () => {
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
new Response('data: {invalid}\n\n', {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'test'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow('无效的流式 JSON')
|
||
})
|
||
|
||
it('keeps the latest confirmed context usage when a model request is cancelled', async () => {
|
||
const controller = new AbortController()
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async (_input, init) => {
|
||
const requestSignal = init?.signal
|
||
if (!requestSignal) {
|
||
throw new Error('Missing request signal')
|
||
}
|
||
return await new Promise<Response>((_resolve, reject) => {
|
||
requestSignal.addEventListener(
|
||
'abort',
|
||
() => reject(requestSignal.reason),
|
||
{ once: true }
|
||
)
|
||
})
|
||
}),
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 20_000,
|
||
recentRawTokens: 4_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve important facts.'
|
||
},
|
||
contextWindowTokens: 32_000
|
||
}
|
||
})
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: '等待取消'
|
||
},
|
||
controller.signal
|
||
)
|
||
|
||
await expect(stream.next()).resolves.toMatchObject({
|
||
value: { type: 'status' }
|
||
})
|
||
const pending = stream.next()
|
||
controller.abort(new Error('用户取消'))
|
||
|
||
await expect(pending).rejects.toThrow('用户取消')
|
||
})
|
||
|
||
it('keeps the latest confirmed context usage when the model API fails', async () => {
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
Response.json(
|
||
{ error: { message: 'upstream unavailable' } },
|
||
{ status: 503 }
|
||
)
|
||
),
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 20_000,
|
||
recentRawTokens: 4_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve important facts.'
|
||
},
|
||
contextWindowTokens: 32_000
|
||
}
|
||
})
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: '触发失败'
|
||
},
|
||
new AbortController().signal
|
||
)
|
||
|
||
await expect(stream.next()).resolves.toMatchObject({
|
||
value: { type: 'status' }
|
||
})
|
||
await expect(stream.next()).rejects.toThrow('upstream unavailable')
|
||
})
|
||
|
||
it('retries a nested ECONNRESET before streaming output', async () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
const connectionReset = Object.assign(
|
||
new Error(
|
||
'Client network socket disconnected before secure TLS connection was established'
|
||
),
|
||
{ code: 'ECONNRESET', port: 443 }
|
||
)
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockRejectedValueOnce(
|
||
new TypeError('fetch failed', { cause: connectionReset })
|
||
)
|
||
.mockResolvedValueOnce(
|
||
new Response(createEventStream('重试后成功'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
const consume = async (): Promise<void> => {
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'test'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
}
|
||
const completed = consume()
|
||
|
||
await vi.runAllTimersAsync()
|
||
await completed
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'status',
|
||
message: '模型网络请求失败,等待重试(1/3,0.5 秒后)'
|
||
})
|
||
)
|
||
const retryWaiting = events.findIndex((event) =>
|
||
event.type === 'status' && event.message.includes('等待重试'))
|
||
const retryStarted = events.findIndex((event) =>
|
||
event.type === 'status' && event.message.includes('正在重试请求'))
|
||
const responseStarted = events.findIndex((event) => event.type === 'text')
|
||
expect(retryStarted).toBeGreaterThan(retryWaiting)
|
||
expect(responseStarted).toBeGreaterThan(retryStarted)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: '重试后成功'
|
||
})
|
||
)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('does not retry a transient stream failure after publishing output', async () => {
|
||
const connectionReset = Object.assign(
|
||
new Error('socket disconnected'),
|
||
{ code: 'ECONNRESET' }
|
||
)
|
||
let sentOutput = false
|
||
const body = new ReadableStream<Uint8Array>({
|
||
pull(controller) {
|
||
if (sentOutput) {
|
||
controller.error(connectionReset)
|
||
return
|
||
}
|
||
sentOutput = true
|
||
controller.enqueue(
|
||
new TextEncoder().encode(
|
||
[
|
||
'event: content_block_delta',
|
||
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"已经显示"}}',
|
||
'',
|
||
''
|
||
].join('\n')
|
||
)
|
||
)
|
||
}
|
||
})
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(body, {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
const consume = async (): Promise<void> => {
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'test'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow('socket disconnected')
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: '已经显示'
|
||
})
|
||
)
|
||
expect(events).not.toContainEqual(
|
||
expect.objectContaining({
|
||
message: expect.stringContaining('等待重试')
|
||
})
|
||
)
|
||
})
|
||
|
||
it('does not retry a reset whose dispatch outcome is unknown', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||
throw Object.assign(new Error('socket disconnected'), {
|
||
code: 'ECONNRESET'
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
const consume = async (): Promise<void> => {
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'test'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow('socket disconnected')
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
expect(events).not.toContainEqual(
|
||
expect.objectContaining({
|
||
message: expect.stringContaining('等待重试')
|
||
})
|
||
)
|
||
})
|
||
|
||
it('cancels immediately during model retry backoff', async () => {
|
||
const controller = new AbortController()
|
||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||
throw Object.assign(new Error('connect timeout'), {
|
||
code: 'UND_ERR_CONNECT_TIMEOUT'
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher
|
||
})
|
||
const stream = runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'test'
|
||
},
|
||
controller.signal
|
||
)
|
||
|
||
await expect(stream.next()).resolves.toMatchObject({
|
||
value: { type: 'status', message: 'sonnet-5 请求处理中' }
|
||
})
|
||
await expect(stream.next()).resolves.toMatchObject({
|
||
value: {
|
||
type: 'status',
|
||
message: '模型网络请求失败,等待重试(1/3,0.5 秒后)'
|
||
}
|
||
})
|
||
const pendingRetry = stream.next()
|
||
controller.abort(new Error('用户取消'))
|
||
|
||
await expect(pendingRetry).rejects.toThrow('用户取消')
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it('parses CRLF event separators split across response chunks', async () => {
|
||
const payload = createEventStream('split CRLF').replaceAll('\n', '\r\n')
|
||
const splitAt = payload.indexOf('\r\n\r\n') + 3
|
||
const body = new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
controller.enqueue(
|
||
new TextEncoder().encode(payload.slice(0, splitAt))
|
||
)
|
||
controller.enqueue(
|
||
new TextEncoder().encode(payload.slice(splitAt))
|
||
)
|
||
controller.close()
|
||
}
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
new Response(body, {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'test'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: 'split CRLF'
|
||
})
|
||
)
|
||
})
|
||
|
||
it('aborts a model request that exceeds the runtime timeout', async () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
const fetcher = vi.fn<typeof fetch>(
|
||
async (_input, init) =>
|
||
new Promise<Response>((_resolve, reject) => {
|
||
init?.signal?.addEventListener(
|
||
'abort',
|
||
() => reject(init.signal?.reason),
|
||
{ once: true }
|
||
)
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
requestTimeoutMs: 50,
|
||
fetcher
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'test'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
const assertion = expect(consume()).rejects.toThrow(
|
||
'模型接口请求超时'
|
||
)
|
||
|
||
await vi.runAllTimersAsync()
|
||
await assertion
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('aborts a stalled response body after headers arrive', async () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
let responseSignal: AbortSignal | null | undefined
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
requestTimeoutMs: 50,
|
||
fetcher: vi.fn<typeof fetch>(async (_input, init) => {
|
||
responseSignal = init?.signal
|
||
return new Response(
|
||
new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
init?.signal?.addEventListener(
|
||
'abort',
|
||
() => controller.error(init.signal?.reason),
|
||
{ once: true }
|
||
)
|
||
}
|
||
}),
|
||
{
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
}
|
||
)
|
||
})
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'test'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
const assertion = expect(consume()).rejects.toThrow(
|
||
'模型接口请求超时'
|
||
)
|
||
|
||
await vi.runAllTimersAsync()
|
||
await assertion
|
||
expect(responseSignal?.aborted).toBe(true)
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('streams long ordinary responses without a size limit', async () => {
|
||
const encoder = new TextEncoder()
|
||
const deltaChunks = 48
|
||
const deltaText = 'x'.repeat(65_000)
|
||
const body = new ReadableStream<Uint8Array>({
|
||
start(controller) {
|
||
controller.enqueue(
|
||
encoder.encode(
|
||
`data: ${JSON.stringify({
|
||
type: 'message_start',
|
||
message: {
|
||
id: 'message-long',
|
||
model: 'sonnet-5',
|
||
usage: { input_tokens: 5 }
|
||
}
|
||
})}\n\n`
|
||
)
|
||
)
|
||
for (let index = 0; index < deltaChunks; index += 1) {
|
||
controller.enqueue(
|
||
encoder.encode(
|
||
`data: ${JSON.stringify({
|
||
type: 'content_block_delta',
|
||
delta: {
|
||
type: 'text_delta',
|
||
text: deltaText
|
||
}
|
||
})}\n\n`
|
||
)
|
||
)
|
||
}
|
||
controller.enqueue(
|
||
encoder.encode('data: {"type":"message_stop"}\n\n')
|
||
)
|
||
controller.close()
|
||
}
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
new Response(body, {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
})
|
||
let streamedText = ''
|
||
let done = false
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'test'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
if (event.type === 'text') {
|
||
streamedText += event.delta
|
||
}
|
||
if (event.type === 'done') {
|
||
done = true
|
||
}
|
||
}
|
||
|
||
expect(done).toBe(true)
|
||
expect(streamedText.length).toBe(deltaChunks * deltaText.length)
|
||
})
|
||
|
||
it('preserves bounded provider error messages', async () => {
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'claude-sonnet-5',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
Response.json(
|
||
{
|
||
error: {
|
||
message:
|
||
'upstream failed Authorization: Bearer secret-token'
|
||
}
|
||
},
|
||
{ status: 502 }
|
||
)
|
||
)
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'test'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow(
|
||
'upstream failed Authorization: Bearer secret-token'
|
||
)
|
||
})
|
||
|
||
it('uses OpenAI Chat Completions SSE and omits auth for Ollama', async () => {
|
||
const stream = [
|
||
`data: ${JSON.stringify({
|
||
choices: [{ delta: { content: '本机回答' } }]
|
||
})}`,
|
||
'',
|
||
`data: ${JSON.stringify({
|
||
id: 'chatcmpl-provider-1',
|
||
model: 'qwen3-provider',
|
||
choices: [],
|
||
usage: {
|
||
prompt_tokens: 31,
|
||
completion_tokens: 9,
|
||
total_tokens: 40,
|
||
prompt_tokens_details: { cached_tokens: 13 },
|
||
cache_write_tokens: 4
|
||
}
|
||
})}`,
|
||
'',
|
||
'data: [DONE]',
|
||
'',
|
||
''
|
||
].join('\n')
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(stream, {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed127',
|
||
conversationId: 'conversation-3',
|
||
prompt: '你好'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
const [input, init] = fetcher.mock.calls[0] ?? []
|
||
expect(input?.toString()).toBe(
|
||
'http://127.0.0.1:11434/v1/chat/completions'
|
||
)
|
||
expect(init?.headers).toEqual({
|
||
'content-type': 'application/json'
|
||
})
|
||
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||
model: 'qwen3',
|
||
stream: true,
|
||
stream_options: {
|
||
include_usage: true
|
||
},
|
||
messages: [
|
||
expect.objectContaining({ role: 'system' }),
|
||
expect.objectContaining({ role: 'user', content: '你好' })
|
||
]
|
||
})
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: '本机回答'
|
||
})
|
||
)
|
||
expect(events.filter((event) => event.type === 'model-usage')).toEqual([
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed127',
|
||
type: 'model-usage',
|
||
callId: expect.stringMatching(
|
||
/^model-call:[0-9a-f]{32}:chatcmpl-provider-1$/u
|
||
),
|
||
runtime: 'model',
|
||
provider: 'openai',
|
||
model: 'qwen3-provider',
|
||
inputTokens: 31,
|
||
outputTokens: 9,
|
||
cacheReadTokens: 13,
|
||
cacheWriteTokens: 4,
|
||
reportedTotalTokens: 40
|
||
}
|
||
])
|
||
expect(events.at(-2)).toMatchObject({ type: 'model-usage' })
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
expect(toolProvider.listTools).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('retries transient network failures for direct-model tool rounds', async () => {
|
||
vi.useFakeTimers()
|
||
try {
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockRejectedValueOnce(
|
||
Object.assign(new Error('connect timeout'), {
|
||
code: 'UND_ERR_CONNECT_TIMEOUT'
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '工具轮次重试成功'
|
||
}
|
||
}
|
||
]
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider: createToolProvider()
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
const consume = async (): Promise<void> => {
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: '使用工具能力回答',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
}
|
||
const completed = consume()
|
||
|
||
await vi.runAllTimersAsync()
|
||
await completed
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'status',
|
||
message: '模型网络请求失败,等待重试(1/3,0.5 秒后)'
|
||
})
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: '工具轮次重试成功'
|
||
})
|
||
)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
} finally {
|
||
vi.useRealTimers()
|
||
}
|
||
})
|
||
|
||
it('streams OpenAI-compatible reasoning deltas before the answer', async () => {
|
||
const stream = [
|
||
`data: ${JSON.stringify({
|
||
choices: [
|
||
{
|
||
delta: {
|
||
reasoning_content: '先分析'
|
||
}
|
||
}
|
||
]
|
||
})}`,
|
||
'',
|
||
`data: ${JSON.stringify({
|
||
choices: [
|
||
{
|
||
delta: {
|
||
reasoning_content: ',再验证'
|
||
}
|
||
}
|
||
]
|
||
})}`,
|
||
'',
|
||
`data: ${JSON.stringify({
|
||
choices: [
|
||
{
|
||
delta: {
|
||
content: '最终回答'
|
||
}
|
||
}
|
||
]
|
||
})}`,
|
||
'',
|
||
'data: [DONE]',
|
||
'',
|
||
''
|
||
].join('\n')
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.deepseek.com',
|
||
model: 'deepseek-reasoner',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
new Response(stream, {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed128',
|
||
conversationId: 'conversation-deepseek-reasoning',
|
||
prompt: '分析这个问题'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(
|
||
events.filter(
|
||
(event) => event.type === 'reasoning' || event.type === 'text'
|
||
)
|
||
).toEqual([
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed128',
|
||
type: 'reasoning',
|
||
delta: '先分析'
|
||
},
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed128',
|
||
type: 'reasoning',
|
||
delta: ',再验证'
|
||
},
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed128',
|
||
type: 'text',
|
||
delta: '最终回答'
|
||
}
|
||
])
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('runs Ask workspace search, text reads and output paging with the production deny authorizer and no optional capabilities', async () => {
|
||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-ask-tools-'))
|
||
const workspaceAccess = new LocalWorkspaceAccess(workspace)
|
||
const processService = new LocalDirectModelProcessService()
|
||
const toolProvider = new ModelToolProvider(
|
||
workspaceAccess, [], undefined, undefined, false,
|
||
{ processService, ripgrepExecutablePath: rgPath }
|
||
)
|
||
const signal = new AbortController().signal
|
||
const conversationId = 'conversation-ask'
|
||
const responses: unknown[] = []
|
||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'https://example.test/v1',
|
||
model: 'test-model',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider,
|
||
workspaceAccess
|
||
})
|
||
const authorize = vi.fn(async () => 'deny' as const)
|
||
try {
|
||
await writeFile(join(workspace, 'README.md'), 'readonly-marker\n')
|
||
await writeFile(join(workspace, 'output.cjs'),
|
||
"process.stdout.write('x'.repeat(110000) + 'retained-tail')")
|
||
// Seed output as a preceding Execute operation in the same conversation.
|
||
const previous = await processService.execute(
|
||
{ command: 'node output.cjs', timeoutMs: 10_000 },
|
||
{ conversationId, workspace: workspaceAccess, signal }
|
||
)
|
||
expect(previous.exitCode).toBe(0)
|
||
const reference = previous.stdoutReference!
|
||
expect(reference).toBeDefined()
|
||
const calls = [
|
||
{ name: 'workspace_rg', arguments: { pattern: 'readonly-marker', glob: ['README.md'] } },
|
||
{ name: 'workspace_read_text', arguments: { path: 'README.md' } },
|
||
{ name: 'output_read', arguments: { handle: reference.handle, cursor: reference.nextCursor } }
|
||
]
|
||
responses.push({
|
||
choices: [{ message: {
|
||
role: 'assistant', content: null,
|
||
tool_calls: calls.map((call, index) => ({
|
||
id: `ask-${index}`, type: 'function',
|
||
function: { name: call.name, arguments: JSON.stringify(call.arguments) }
|
||
}))
|
||
} }]
|
||
}, { choices: [{ message: { role: 'assistant', content: '只读完成' } }] })
|
||
const events: RuntimeEvent[] = []
|
||
for await (const event of runtime.run({
|
||
requestId: crypto.randomUUID(), conversationId,
|
||
prompt: '搜索并读取文件,续读已有输出', workMode: 'ask'
|
||
}, signal, authorize)) events.push(event)
|
||
|
||
const firstBody = JSON.parse(String(fetcher.mock.calls[0]![1]!.body))
|
||
expect(firstBody.tools.map((tool: { function: { name: string } }) =>
|
||
tool.function.name)).toEqual(['workspace_rg', 'workspace_read_text', 'output_read'])
|
||
const secondBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body))
|
||
const results = secondBody.messages.filter(
|
||
(message: { role: string }) => message.role === 'tool'
|
||
)
|
||
expect(results).toHaveLength(3)
|
||
expect(results[0].content).toContain('README.md:1:1:readonly-marker')
|
||
expect(results[1].content).toContain('readonly-marker')
|
||
expect(JSON.parse(results[2].content)).toMatchObject({
|
||
content: expect.stringContaining('retained-tail'), eof: true
|
||
})
|
||
expect(authorize).not.toHaveBeenCalled()
|
||
expect(events.filter((event) =>
|
||
event.type === 'tool' && event.state === 'completed')).toHaveLength(3)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
expect(await readFile(join(workspace, 'README.md'), 'utf8')).toBe('readonly-marker\n')
|
||
} finally {
|
||
await runtime.dispose()
|
||
await rm(workspace, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it.each(['workspace_apply_patch', 'workspace_write_text', 'process_execute', 'browser_snapshot'])(
|
||
'rejects unlisted %s in Ask at the runtime boundary',
|
||
async (name) => {
|
||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-ask-denial-'))
|
||
const fetcher = vi.fn<typeof fetch>(async () => Response.json({
|
||
choices: [{ message: {
|
||
role: 'assistant', content: null,
|
||
tool_calls: [{ id: 'spoof', type: 'function', function: {
|
||
name, arguments: JSON.stringify({
|
||
path: 'forbidden.txt', content: 'forbidden',
|
||
patch: '*** Begin Patch\n*** Add File: forbidden.txt\n+forbidden\n*** End Patch',
|
||
command: 'echo forbidden'
|
||
})
|
||
} }]
|
||
} }]
|
||
}))
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'https://example.test/v1', model: 'test-model',
|
||
protocol: 'openai-chat-completions', authentication: 'none',
|
||
fetcher, defaultWorkspace: workspace
|
||
})
|
||
const authorize = vi.fn(async () => 'deny' as const)
|
||
try {
|
||
const consume = async () => {
|
||
for await (const event of runtime.run({
|
||
requestId: crypto.randomUUID(), conversationId: 'ask-denied',
|
||
prompt: '只读', workMode: 'ask'
|
||
}, new AbortController().signal, authorize)) void event
|
||
}
|
||
await expect(consume()).rejects.toThrow('未知工具')
|
||
expect(authorize).not.toHaveBeenCalled()
|
||
await expect(readFile(join(workspace, 'forbidden.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||
} finally {
|
||
await runtime.dispose()
|
||
await rm(workspace, { recursive: true, force: true })
|
||
}
|
||
}
|
||
)
|
||
|
||
it.each(['openai-chat-completions', 'openai-responses', 'anthropic-messages'] as const)(
|
||
'lists only workspace reads and retained output in plain Ask via %s',
|
||
async (protocol) => {
|
||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(
|
||
protocol === 'anthropic-messages'
|
||
? { content: [{ type: 'text', text: '只读回答' }], stop_reason: 'end_turn' }
|
||
: protocol === 'openai-responses'
|
||
? { id: 'resp-ask', output: [{ type: 'message', content: [{ type: 'output_text', text: '只读回答' }] }] }
|
||
: { choices: [{ message: { role: 'assistant', content: '只读回答' } }] }
|
||
))
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'https://example.test/v1', model: 'test-model',
|
||
protocol, authentication: 'none', fetcher
|
||
})
|
||
try {
|
||
for await (const event of runtime.run({
|
||
requestId: crypto.randomUUID(), conversationId: 'plain-ask',
|
||
prompt: '只读', workMode: 'ask'
|
||
}, new AbortController().signal, async () => 'deny')) void event
|
||
const body = JSON.parse(String(fetcher.mock.calls[0]![1]!.body))
|
||
expect(body.tools.map((tool: { name?: string; function?: { name: string } }) =>
|
||
tool.function?.name ?? tool.name)).toEqual(['workspace_read_text', 'output_read'])
|
||
} finally {
|
||
await runtime.dispose()
|
||
}
|
||
}
|
||
)
|
||
|
||
it('uses the OpenAI Responses endpoint and streams output text', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(
|
||
createResponsesEventStream('Responses 回答', 'Responses 推理'),
|
||
{
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
}
|
||
)
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-5',
|
||
protocol: 'openai-responses',
|
||
authentication: 'api-key',
|
||
fetcher
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed133',
|
||
conversationId: 'conversation-responses',
|
||
prompt: '你好'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
const [input, init] = fetcher.mock.calls[0] ?? []
|
||
expect(input?.toString()).toBe('https://api.openai.com/v1/responses')
|
||
expect(init?.headers).toEqual({
|
||
authorization: 'Bearer test-key',
|
||
'content-type': 'application/json'
|
||
})
|
||
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||
model: 'gpt-5',
|
||
stream: true,
|
||
instructions: expect.stringContaining('GoodBuddy'),
|
||
input: [
|
||
expect.objectContaining({ role: 'user', content: '你好' })
|
||
]
|
||
})
|
||
expect(JSON.parse(init?.body as string)).not.toHaveProperty(
|
||
'max_output_tokens'
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'reasoning',
|
||
delta: 'Responses 推理'
|
||
})
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: 'Responses 回答'
|
||
})
|
||
)
|
||
expect(events.filter((event) => event.type === 'model-usage')).toEqual([
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed133',
|
||
type: 'model-usage',
|
||
callId: expect.stringMatching(
|
||
/^model-call:[0-9a-f]{32}:resp-provider-1$/u
|
||
),
|
||
runtime: 'model',
|
||
provider: 'openai',
|
||
model: 'gpt-5-provider',
|
||
inputTokens: 29,
|
||
outputTokens: 8,
|
||
cacheReadTokens: 11,
|
||
cacheWriteTokens: 0,
|
||
reportedTotalTokens: 37
|
||
}
|
||
])
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('tests an OpenAI Responses connection with Responses request fields', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async (_input, init) => {
|
||
const body = JSON.parse(init?.body as string) as {
|
||
input: string
|
||
}
|
||
const marker =
|
||
/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u.exec(body.input)?.[0]
|
||
if (!marker) {
|
||
throw new Error('missing model test marker')
|
||
}
|
||
return Response.json({
|
||
id: 'resp-test',
|
||
status: 'completed',
|
||
output: [
|
||
{
|
||
type: 'message',
|
||
content: [{ type: 'output_text', text: marker }]
|
||
}
|
||
]
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.openai.com/v1/',
|
||
model: 'gpt-5',
|
||
protocol: 'openai-responses',
|
||
authentication: 'api-key',
|
||
fetcher
|
||
})
|
||
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
detail: expect.stringContaining('真实模型生成测试')
|
||
})
|
||
expect(fetcher.mock.calls[0]?.[0]?.toString()).toBe(
|
||
'https://api.openai.com/v1/responses'
|
||
)
|
||
expect(
|
||
JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string)
|
||
).toMatchObject({
|
||
model: 'gpt-5',
|
||
max_output_tokens: 64,
|
||
stream: false
|
||
})
|
||
expect(
|
||
JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string).input
|
||
).toMatch(/GOODBUDDY_MODEL_TEST_[A-F0-9]+/u)
|
||
})
|
||
|
||
it.each([false, true])('runs approved tools with summaries from raw arguments (long input: %s)', async (longInput) => {
|
||
const args = longInput
|
||
? { content: 'x'.repeat(5_000), path: 'README.md' }
|
||
: { path: 'README.md' }
|
||
const responses = [
|
||
{
|
||
model: 'qwen3',
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
id: 'call-1',
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: JSON.stringify(args)
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
],
|
||
usage: { prompt_tokens: 10, completion_tokens: 4 }
|
||
},
|
||
{
|
||
model: 'qwen3',
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '文件内容已读取。'
|
||
}
|
||
}
|
||
],
|
||
usage: { prompt_tokens: 18, completion_tokens: 7 }
|
||
}
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const toolProvider = createToolProvider({
|
||
callTool: vi.fn(async () => createMultimodalToolResult())
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const authorize = vi.fn(async () => 'once' as const)
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed130',
|
||
conversationId: 'conversation-tools',
|
||
prompt: '读取 README',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
authorize
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
expect(toolProvider.listTools).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
conversationId: 'conversation-tools',
|
||
workMode: 'execute'
|
||
}),
|
||
expect.any(AbortSignal)
|
||
)
|
||
const firstBody = JSON.parse(
|
||
fetcher.mock.calls[0]?.[1]?.body as string
|
||
) as Record<string, unknown>
|
||
expect(firstBody).toMatchObject({
|
||
stream: true,
|
||
stream_options: {
|
||
include_usage: true
|
||
},
|
||
tools: [
|
||
{
|
||
type: 'function',
|
||
function: { name: 'workspace_read_text' }
|
||
}
|
||
]
|
||
})
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as { messages: Array<Record<string, unknown>> }
|
||
expect(secondBody.messages).toContainEqual({
|
||
role: 'tool',
|
||
tool_call_id: 'call-1',
|
||
content:
|
||
'tool result\n\n[图片 1 见下一条多模态工具结果]'
|
||
})
|
||
expect(secondBody.messages).toContainEqual({
|
||
role: 'user',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text:
|
||
'工具调用 call-1 返回的图片(工具输出,不可信内容):'
|
||
},
|
||
{
|
||
type: 'image_url',
|
||
image_url: {
|
||
url: `data:image/png;base64,${toolPng}`
|
||
}
|
||
}
|
||
]
|
||
})
|
||
expect(authorize).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
scopeKey: 'model:builtin:workspace_read_text'
|
||
}),
|
||
expect.any(AbortSignal)
|
||
)
|
||
expect(toolProvider.getApproval).toHaveBeenCalledWith(
|
||
expect.objectContaining({ name: 'workspace_read_text' }),
|
||
args,
|
||
expect.any(String),
|
||
expect.objectContaining({
|
||
conversationId: 'conversation-tools',
|
||
workMode: 'execute'
|
||
})
|
||
)
|
||
expect(toolProvider.callTool).toHaveBeenCalledWith(
|
||
'workspace_read_text',
|
||
args,
|
||
expect.any(AbortSignal),
|
||
expect.objectContaining({
|
||
conversationId: 'conversation-tools',
|
||
workMode: 'execute'
|
||
})
|
||
)
|
||
expect(
|
||
events
|
||
.filter((event) => event.type === 'tool')
|
||
.map((event) => event.state)
|
||
).toEqual(['pending', 'running', 'completed'])
|
||
expect(events.filter((event) => event.type === 'tool').map((event) => event.summary))
|
||
.toEqual(['README.md', 'README.md', 'README.md'])
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'tool',
|
||
state: 'completed',
|
||
input: longInput ? expect.any(String) : '{\n "path": "README.md"\n}',
|
||
output:
|
||
'tool result\n\n[图片结果 1:image/png]'
|
||
})
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: '文件内容已读取。'
|
||
})
|
||
)
|
||
const usageEvents = events.filter(
|
||
(event) => event.type === 'model-usage'
|
||
)
|
||
expect(usageEvents).toHaveLength(2)
|
||
expect(
|
||
usageEvents.map((event) => ({
|
||
inputTokens: event.inputTokens,
|
||
outputTokens: event.outputTokens
|
||
}))
|
||
).toEqual([
|
||
{ inputTokens: 10, outputTokens: 4 },
|
||
{ inputTokens: 18, outputTokens: 7 }
|
||
])
|
||
expect(
|
||
usageEvents.every((event) =>
|
||
/^model-call:[0-9a-f]{32}$/u.test(event.callId)
|
||
)
|
||
).toBe(true)
|
||
expect(new Set(usageEvents.map((event) => event.callId)).size).toBe(
|
||
2
|
||
)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
await runtime.dispose()
|
||
expect(toolProvider.dispose).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it.each(['absolute', 'relative'])(
|
||
'executes a real command outside the workspace using a %s cwd through the model tool loop',
|
||
async (pathKind) => {
|
||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-model-cwd-'))
|
||
const workspace = join(root, 'workspace')
|
||
const target = join(root, 'target')
|
||
await mkdir(workspace)
|
||
await mkdir(target)
|
||
await writeFile(join(target, 'input.txt'), 'outside-workspace')
|
||
const command = process.platform === 'win32'
|
||
? "Copy-Item 'input.txt' 'output.txt'"
|
||
: 'cp input.txt output.txt'
|
||
const responses = [
|
||
{
|
||
choices: [{
|
||
message: {
|
||
role: 'assistant', content: null,
|
||
tool_calls: [{
|
||
id: 'call-process', type: 'function',
|
||
function: {
|
||
name: 'process_execute',
|
||
arguments: JSON.stringify({
|
||
command,
|
||
cwd: pathKind === 'absolute' ? target : relative(workspace, target)
|
||
})
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
},
|
||
{ choices: [{ message: { role: 'assistant', content: 'done' } }] }
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'test-model',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
defaultWorkspace: workspace
|
||
})
|
||
try {
|
||
const events: RuntimeEvent[] = []
|
||
for await (const event of runtime.run({
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'Copy the file in the requested directory.',
|
||
workMode: 'execute'
|
||
}, new AbortController().signal, async () => 'once')) {
|
||
events.push(event)
|
||
}
|
||
expect(await readFile(join(target, 'output.txt'), 'utf8')).toBe('outside-workspace')
|
||
expect(events).toContainEqual(expect.objectContaining({
|
||
type: 'tool', callId: 'call-process', state: 'completed'
|
||
}))
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
} finally {
|
||
await runtime.dispose()
|
||
await rm(root, { recursive: true, force: true })
|
||
}
|
||
}
|
||
)
|
||
|
||
it('lets an Execute Subagent borrow the parent browser tab without changing tab ownership', async () => {
|
||
const conversationId = 'parent-browser-owner'
|
||
const browserTabId = browserTabIdSchema.parse('00000000-0000-4000-8000-000000000203')
|
||
let parentReleased = false
|
||
const browserService: BrowserToolService = {
|
||
getOrigin: vi.fn(() => 'https://example.test'),
|
||
navigate: vi.fn(), click: vi.fn(), type: vi.fn(),
|
||
select: vi.fn(), back: vi.fn(), screenshot: vi.fn(),
|
||
snapshot: vi.fn(async (owner, _signal, tabId) => {
|
||
// BrowserService resolves tabs under their owning conversation, not
|
||
// merely by tab ID; a child's temporary conversation cannot own it.
|
||
expect(owner).toBe(conversationId)
|
||
expect(tabId).toBe(browserTabId)
|
||
expect(parentReleased).toBe(false)
|
||
return { url: 'https://example.test/', title: 'Parent page', nodes: [], truncated: false }
|
||
}),
|
||
releaseConversation: vi.fn(async (owner) => {
|
||
if (owner === conversationId) parentReleased = true
|
||
})
|
||
}
|
||
const toolResponse = (id: string, name: string, args: Record<string, unknown>) => ({
|
||
choices: [{ message: {
|
||
role: 'assistant', content: null,
|
||
tool_calls: [{ id, type: 'function', function: {
|
||
name, arguments: JSON.stringify(args)
|
||
} }]
|
||
} }]
|
||
})
|
||
const responses = [
|
||
toolResponse('delegate', 'subagent_delegate', { task: '读取父请求浏览器页面' }),
|
||
toolResponse('child-browser', 'browser_snapshot', {}),
|
||
{ choices: [{ message: { role: 'assistant', content: '子级已读页面' } }] },
|
||
toolResponse('parent-browser', 'browser_snapshot', {}),
|
||
{ choices: [{ message: { role: 'assistant', content: '父级继续使用同一页面' } }] }
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
|
||
const scheduler = new SubagentScheduler({ concurrency: 1, queueLimit: 1, timeoutMs: 10_000 })
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'https://example.test/v1', model: 'test-model',
|
||
protocol: 'openai-chat-completions', authentication: 'none',
|
||
fetcher, browserService, directModelSubagentScheduler: scheduler
|
||
})
|
||
const listTools = vi.spyOn(ModelToolProvider.prototype, 'listTools')
|
||
const callTool = vi.spyOn(ModelToolProvider.prototype, 'callTool')
|
||
try {
|
||
const events: RuntimeEvent[] = []
|
||
for await (const event of runtime.run({
|
||
requestId: crypto.randomUUID(), conversationId, browserTabId,
|
||
prompt: '委派读取页面后继续使用页面', workMode: 'execute'
|
||
}, new AbortController().signal, async () => 'once')) events.push(event)
|
||
expect(fetcher).toHaveBeenCalledTimes(5)
|
||
const childContext = listTools.mock.calls.find(
|
||
([context]) => context.delegationDepth === 1
|
||
)![0]
|
||
expect(childContext).toMatchObject({
|
||
workMode: 'execute', browserTabId,
|
||
browserConversationId: conversationId
|
||
})
|
||
expect(childContext.conversationId).not.toBe(conversationId)
|
||
const childBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body))
|
||
const childTools = childBody.tools.map(
|
||
(tool: { function: { name: string } }) => tool.function.name
|
||
)
|
||
expect(childTools).toEqual(expect.arrayContaining(['browser_navigate', 'browser_snapshot', 'browser_click']))
|
||
expect(childTools).not.toContain('subagent_delegate')
|
||
expect(callTool).toHaveBeenCalledWith(
|
||
'browser_snapshot', {}, expect.any(AbortSignal),
|
||
expect.objectContaining({
|
||
conversationId: childContext.conversationId,
|
||
browserConversationId: conversationId, browserTabId
|
||
})
|
||
)
|
||
expect(browserService.snapshot).toHaveBeenCalledTimes(2)
|
||
expect(browserService.releaseConversation).toHaveBeenCalledWith(childContext.conversationId)
|
||
expect(parentReleased).toBe(false)
|
||
expect(events.filter((event) => event.type === 'subagent').at(-1))
|
||
.toMatchObject({ state: 'completed' })
|
||
await runtime.releaseConversation(conversationId)
|
||
expect(parentReleased).toBe(true)
|
||
} finally {
|
||
listTools.mockRestore()
|
||
callTool.mockRestore()
|
||
await runtime.dispose()
|
||
scheduler.dispose()
|
||
await scheduler.waitForIdle()
|
||
}
|
||
})
|
||
|
||
it('delegates one programming Subagent that can run a real project command without recursion', async () => {
|
||
const command =
|
||
process.platform === 'win32'
|
||
? "[Console]::Write('child-ok')"
|
||
: 'printf child-ok'
|
||
const responses = [
|
||
{
|
||
model: 'qwen3',
|
||
choices: [{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [{
|
||
id: 'call-delegate',
|
||
type: 'function',
|
||
function: {
|
||
name: 'subagent_delegate',
|
||
arguments: JSON.stringify({
|
||
task: '运行一个命令确认子代理执行能力'
|
||
})
|
||
}
|
||
}]
|
||
}
|
||
}],
|
||
usage: { prompt_tokens: 10, completion_tokens: 4 }
|
||
},
|
||
{
|
||
model: 'qwen3',
|
||
choices: [{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [{
|
||
id: 'call-child-process',
|
||
type: 'function',
|
||
function: {
|
||
name: 'process_execute',
|
||
arguments: JSON.stringify({ command })
|
||
}
|
||
}]
|
||
}
|
||
}],
|
||
usage: { prompt_tokens: 12, completion_tokens: 5 }
|
||
},
|
||
{
|
||
model: 'qwen3',
|
||
choices: [{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '子代理已验证 child-ok。'
|
||
}
|
||
}],
|
||
usage: { prompt_tokens: 20, completion_tokens: 6 }
|
||
},
|
||
{
|
||
model: 'qwen3',
|
||
choices: [{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '父模型已综合子代理结果。'
|
||
}
|
||
}],
|
||
usage: { prompt_tokens: 25, completion_tokens: 7 }
|
||
}
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const scheduler = new SubagentScheduler({
|
||
concurrency: 3,
|
||
queueLimit: 20,
|
||
timeoutMs: 10_000
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
defaultWorkspace: process.cwd(),
|
||
directModelSubagentScheduler: scheduler
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed177',
|
||
conversationId: 'conversation-direct-subagent',
|
||
prompt: '委派子代理验证进程能力',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(4)
|
||
const parentTools = (
|
||
JSON.parse(
|
||
fetcher.mock.calls[0]?.[1]?.body as string
|
||
) as {
|
||
tools: Array<{ function: { name: string } }>
|
||
}
|
||
).tools.map((tool) => tool.function.name)
|
||
const childTools = (
|
||
JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as {
|
||
tools: Array<{ function: { name: string } }>
|
||
}
|
||
).tools.map((tool) => tool.function.name)
|
||
expect(parentTools).toContain('subagent_delegate')
|
||
expect(parentTools).toContain('process_execute')
|
||
expect(childTools).toContain('process_execute')
|
||
expect(childTools).not.toContain('subagent_delegate')
|
||
expect(
|
||
events
|
||
.filter((event) => event.type === 'subagent')
|
||
.map((event) => event.state)
|
||
).toEqual(['queued', 'running', 'completed'])
|
||
expect(
|
||
events
|
||
.flatMap(
|
||
(event) =>
|
||
event.type === 'tool' &&
|
||
event.callId.includes('call-child-process')
|
||
? [event.state]
|
||
: []
|
||
)
|
||
).toEqual(['pending', 'running', 'completed'])
|
||
expect(
|
||
events.some(
|
||
(event) =>
|
||
event.type === 'text' &&
|
||
event.delta.includes('父模型已综合')
|
||
)
|
||
).toBe(true)
|
||
|
||
await runtime.dispose()
|
||
scheduler.dispose()
|
||
await scheduler.waitForIdle()
|
||
})
|
||
|
||
it('compresses complete earlier tool rounds before a later Agent model call', async () => {
|
||
const firstToolResult = `first-result-${'旧'.repeat(5_000)}`
|
||
const secondToolResult = `second-result-${'新'.repeat(5_000)}`
|
||
const thirdToolResult = `third-result-${'终'.repeat(5_000)}`
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
id: 'message-agent-round-1',
|
||
model: 'claude',
|
||
content: [
|
||
{
|
||
type: 'tool_use',
|
||
id: 'toolu-agent-1',
|
||
name: 'workspace_read_text',
|
||
input: { path: 'first.txt' }
|
||
}
|
||
],
|
||
stop_reason: 'tool_use',
|
||
usage: { input_tokens: 4_000, output_tokens: 20 }
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
id: 'message-agent-round-2',
|
||
model: 'claude',
|
||
content: [
|
||
{
|
||
type: 'tool_use',
|
||
id: 'toolu-agent-2',
|
||
name: 'workspace_read_text',
|
||
input: { path: 'second.txt' }
|
||
}
|
||
],
|
||
stop_reason: 'tool_use',
|
||
usage: { input_tokens: 12_500, output_tokens: 20 }
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
new Response(createEventStream('已完成前两轮读取'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
id: 'message-agent-round-3',
|
||
model: 'claude',
|
||
content: [
|
||
{
|
||
type: 'tool_use',
|
||
id: 'toolu-agent-3',
|
||
name: 'workspace_read_text',
|
||
input: { path: 'third.txt' }
|
||
}
|
||
],
|
||
stop_reason: 'tool_use',
|
||
usage: { input_tokens: 12_500, output_tokens: 20 }
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
new Response(createEventStream('已完成全部三轮读取'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
id: 'message-agent-final',
|
||
model: 'claude',
|
||
content: [{ type: 'text', text: '长任务已经完成。' }],
|
||
stop_reason: 'end_turn',
|
||
usage: { input_tokens: 8_000, output_tokens: 30 }
|
||
})
|
||
)
|
||
const callTool = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(createTextToolResult(firstToolResult))
|
||
.mockResolvedValueOnce(createTextToolResult(secondToolResult))
|
||
.mockResolvedValueOnce(createTextToolResult(thirdToolResult))
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'claude',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider: createToolProvider({ callTool }),
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 12_000,
|
||
recentRawTokens: 4_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve execution progress.'
|
||
}
|
||
}
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed131',
|
||
conversationId: 'conversation-long-agent',
|
||
prompt: '连续读取三个文件',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(6)
|
||
const summaryBody = JSON.parse(
|
||
fetcher.mock.calls[2]?.[1]?.body as string
|
||
) as { system: string; messages: unknown[] }
|
||
expect(summaryBody.system).toContain(
|
||
'Summarize the earlier execution rounds'
|
||
)
|
||
expect(JSON.stringify(summaryBody.messages)).toContain('first.txt')
|
||
expect(JSON.stringify(summaryBody.messages)).toContain('second.txt')
|
||
expect(JSON.stringify(summaryBody.messages)).not.toContain(
|
||
'third-result-'
|
||
)
|
||
|
||
const secondSummaryBody = JSON.parse(
|
||
fetcher.mock.calls[4]?.[1]?.body as string
|
||
) as { messages: unknown[] }
|
||
const secondSummaryMessages = JSON.stringify(
|
||
secondSummaryBody.messages
|
||
)
|
||
expect(secondSummaryMessages).toContain('已完成前两轮读取')
|
||
expect(secondSummaryMessages).toContain('third.txt')
|
||
|
||
const finalBody = JSON.parse(
|
||
fetcher.mock.calls[5]?.[1]?.body as string
|
||
) as { messages: unknown[] }
|
||
const finalMessages = JSON.stringify(finalBody.messages)
|
||
expect(finalMessages).toContain('已完成全部三轮读取')
|
||
expect(finalMessages).not.toContain('first-result-')
|
||
expect(finalMessages).not.toContain('second-result-')
|
||
expect(finalMessages).not.toContain('third-result-')
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'context-compression',
|
||
scope: 'agent-run',
|
||
state: 'started',
|
||
compressionCount: 1
|
||
})
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'context-compression',
|
||
scope: 'agent-run',
|
||
state: 'completed',
|
||
compressionCount: 1,
|
||
estimatedAfterTokens: expect.any(Number)
|
||
})
|
||
)
|
||
expect(
|
||
events
|
||
.flatMap((event) =>
|
||
event.type === 'context-compression' &&
|
||
event.scope === 'agent-run' &&
|
||
event.state === 'completed'
|
||
? [event.compressionCount]
|
||
: []
|
||
)
|
||
).toEqual([1, 2])
|
||
expect(
|
||
events
|
||
.flatMap((event) =>
|
||
event.type === 'context-compression' &&
|
||
event.scope === 'agent-run' &&
|
||
event.state === 'completed'
|
||
? [event]
|
||
: []
|
||
)
|
||
.every(
|
||
(event) =>
|
||
event.estimatedAfterTokens !== undefined &&
|
||
event.estimatedAfterTokens < event.effectiveTriggerTokens
|
||
)
|
||
).toBe(true)
|
||
const contextMetrics = events.filter(
|
||
(event) => event.type === 'context-metrics'
|
||
)
|
||
expect(contextMetrics).toHaveLength(4)
|
||
expect(
|
||
contextMetrics.map((event) => event.contextTokens)
|
||
).toEqual([4_020, 12_520, 12_520, 8_030])
|
||
expect(
|
||
contextMetrics.every((event) => event.source === 'provider')
|
||
).toBe(true)
|
||
const agentSummaryUsageEvents = events.flatMap((event) =>
|
||
event.type === 'model-usage' &&
|
||
event.callId.startsWith('agent-context-summary:')
|
||
? [event]
|
||
: []
|
||
)
|
||
expect(agentSummaryUsageEvents).toHaveLength(2)
|
||
expect(
|
||
agentSummaryUsageEvents.every((event) =>
|
||
/^agent-context-summary:model-call:[0-9a-f]{32}:message-1$/u.test(
|
||
event.callId
|
||
)
|
||
)
|
||
).toBe(true)
|
||
expect(
|
||
new Set(
|
||
agentSummaryUsageEvents.map((event) => event.callId)
|
||
).size
|
||
).toBe(2)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('compresses one oversized completed Agent round before the next call', async () => {
|
||
const toolResult = `single-result-${'巨'.repeat(10_000)}`
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
id: 'message-agent-single-round',
|
||
model: 'claude',
|
||
content: [
|
||
{
|
||
type: 'tool_use',
|
||
id: 'toolu-agent-single',
|
||
name: 'workspace_read_text',
|
||
input: { path: 'single.txt' }
|
||
}
|
||
],
|
||
stop_reason: 'tool_use',
|
||
usage: { input_tokens: 12_500, output_tokens: 20 }
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
new Response(createEventStream('已摘要单轮工具结果'), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
id: 'message-agent-single-final',
|
||
model: 'claude',
|
||
content: [{ type: 'text', text: '单轮长任务已完成。' }],
|
||
stop_reason: 'end_turn',
|
||
usage: { input_tokens: 8_000, output_tokens: 30 }
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'claude',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider: createToolProvider({
|
||
callTool: vi.fn(async () => createTextToolResult(toolResult))
|
||
}),
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 12_000,
|
||
recentRawTokens: 4_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve execution progress.'
|
||
}
|
||
}
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed133',
|
||
conversationId: 'conversation-single-agent-round',
|
||
prompt: '读取单个大文件',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(3)
|
||
const finalBody = JSON.parse(
|
||
fetcher.mock.calls[2]?.[1]?.body as string
|
||
) as { messages: unknown[] }
|
||
const finalMessages = JSON.stringify(finalBody.messages)
|
||
expect(finalMessages).toContain('已摘要单轮工具结果')
|
||
expect(finalMessages).not.toContain('single-result-')
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'context-compression',
|
||
scope: 'agent-run',
|
||
state: 'completed',
|
||
compressionCount: 1
|
||
})
|
||
)
|
||
})
|
||
|
||
it('marks Agent context compression as failed when summarization fails', async () => {
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
id: 'message-agent-failure-1',
|
||
model: 'claude',
|
||
content: [
|
||
{
|
||
type: 'tool_use',
|
||
id: 'toolu-agent-failure-1',
|
||
name: 'workspace_read_text',
|
||
input: { path: 'first.txt' }
|
||
}
|
||
],
|
||
stop_reason: 'tool_use',
|
||
usage: { input_tokens: 4_000, output_tokens: 20 }
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
id: 'message-agent-failure-2',
|
||
model: 'claude',
|
||
content: [
|
||
{
|
||
type: 'tool_use',
|
||
id: 'toolu-agent-failure-2',
|
||
name: 'workspace_read_text',
|
||
input: { path: 'second.txt' }
|
||
}
|
||
],
|
||
stop_reason: 'tool_use',
|
||
usage: { input_tokens: 12_500, output_tokens: 20 }
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
Response.json(
|
||
{ error: { message: 'summary unavailable' } },
|
||
{ status: 503 }
|
||
)
|
||
)
|
||
const callTool = vi
|
||
.fn()
|
||
.mockResolvedValueOnce(
|
||
createTextToolResult(`first-result-${'旧'.repeat(5_000)}`)
|
||
)
|
||
.mockResolvedValueOnce(
|
||
createTextToolResult(`second-result-${'新'.repeat(5_000)}`)
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'claude',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider: createToolProvider({ callTool }),
|
||
contextCompression: {
|
||
settings: {
|
||
enabled: true,
|
||
triggerTokens: 12_000,
|
||
recentRawTokens: 4_000,
|
||
modelSource: { kind: 'current' },
|
||
summaryPrompt: 'Preserve execution progress.'
|
||
}
|
||
}
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
const consume = async (): Promise<void> => {
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed132',
|
||
conversationId: 'conversation-agent-summary-failure',
|
||
prompt: '连续读取两个文件',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow('summary unavailable')
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'context-compression',
|
||
scope: 'agent-run',
|
||
state: 'started',
|
||
compressionCount: 1
|
||
})
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'context-compression',
|
||
scope: 'agent-run',
|
||
state: 'failed',
|
||
compressionCount: 1
|
||
})
|
||
)
|
||
})
|
||
|
||
it('streams reasoning while using OpenAI-compatible tools', async () => {
|
||
const streams = [
|
||
[
|
||
`data: ${JSON.stringify({
|
||
choices: [
|
||
{
|
||
delta: {
|
||
reasoning_content: '先读取文件',
|
||
tool_calls: [
|
||
{
|
||
index: 0,
|
||
id: 'call-streamed',
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
})}`,
|
||
'',
|
||
`data: ${JSON.stringify({
|
||
choices: [
|
||
{
|
||
delta: {
|
||
tool_calls: [
|
||
{
|
||
index: 0,
|
||
id: '',
|
||
function: {
|
||
name: '',
|
||
arguments: '"README.md"}'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
})}`,
|
||
'',
|
||
'data: [DONE]',
|
||
'',
|
||
''
|
||
].join('\n'),
|
||
[
|
||
`data: ${JSON.stringify({
|
||
choices: [
|
||
{
|
||
delta: {
|
||
reasoning_content: '再整理结果'
|
||
}
|
||
}
|
||
]
|
||
})}`,
|
||
'',
|
||
`data: ${JSON.stringify({
|
||
choices: [
|
||
{
|
||
delta: {
|
||
content: '文件内容已读取。'
|
||
}
|
||
}
|
||
]
|
||
})}`,
|
||
'',
|
||
'data: [DONE]',
|
||
'',
|
||
''
|
||
].join('\n')
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(streams.shift(), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.deepseek.com',
|
||
model: 'deepseek-v4-flash',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed140',
|
||
conversationId: 'conversation-streamed-tools',
|
||
prompt: '读取 README',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(
|
||
events
|
||
.filter(
|
||
(event) =>
|
||
event.type === 'reasoning' ||
|
||
event.type === 'tool' ||
|
||
event.type === 'text'
|
||
)
|
||
.map((event) =>
|
||
event.type === 'tool'
|
||
? `${event.type}:${event.state}`
|
||
: `${event.type}:${event.delta}`
|
||
)
|
||
).toEqual([
|
||
'reasoning:先读取文件',
|
||
'tool:pending',
|
||
'tool:running',
|
||
'tool:completed',
|
||
'reasoning:再整理结果',
|
||
'text:文件内容已读取。'
|
||
])
|
||
expect(toolProvider.callTool).toHaveBeenCalledWith(
|
||
'workspace_read_text',
|
||
{ path: 'README.md' },
|
||
expect.any(AbortSignal),
|
||
expect.objectContaining({
|
||
conversationId: 'conversation-streamed-tools',
|
||
workMode: 'execute'
|
||
})
|
||
)
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as { messages: Array<Record<string, unknown>> }
|
||
expect(secondBody.messages).toContainEqual(
|
||
expect.objectContaining({
|
||
role: 'assistant',
|
||
content: null,
|
||
reasoning_content: '先读取文件',
|
||
tool_calls: [
|
||
expect.objectContaining({
|
||
id: 'call-streamed',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"README.md"}'
|
||
}
|
||
})
|
||
]
|
||
})
|
||
)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('synthesizes and pairs a missing OpenAI Chat tool call id', async () => {
|
||
const responses = [
|
||
{
|
||
id: 'chatcmpl-missing-call-id-1',
|
||
model: 'qwen3',
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"README.md"}'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'chatcmpl-missing-call-id-2',
|
||
model: 'qwen3',
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '读取完成。'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider: createToolProvider()
|
||
})
|
||
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed143',
|
||
conversationId: 'conversation-chat-fallback-id',
|
||
prompt: '读取 README',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
void _event
|
||
}
|
||
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as { messages: Array<Record<string, unknown>> }
|
||
const assistant = secondBody.messages.at(-2) as {
|
||
tool_calls: Array<Record<string, unknown>>
|
||
}
|
||
const result = secondBody.messages.at(-1) as {
|
||
tool_call_id: string
|
||
}
|
||
const toolCallId = assistant.tool_calls[0]?.id
|
||
expect(toolCallId).toEqual(
|
||
expect.stringMatching(/^goodbuddy_call_[0-9a-f]{32}$/u)
|
||
)
|
||
expect(result).toMatchObject({
|
||
role: 'tool',
|
||
tool_call_id: toolCallId
|
||
})
|
||
})
|
||
|
||
it('uses refreshed tool definitions in subsequent model rounds', async () => {
|
||
const loadTool: ModelToolDefinition = {
|
||
name: 'mcp_load_tools',
|
||
displayName: 'CRM / load tools',
|
||
description: 'Load CRM tools',
|
||
inputSchema: { type: 'object' },
|
||
source: 'mcp',
|
||
serverName: 'CRM'
|
||
}
|
||
const dynamicTool: ModelToolDefinition = {
|
||
name: 'mcp_list_opportunities',
|
||
displayName: 'CRM / list opportunities',
|
||
description: 'List opportunities',
|
||
inputSchema: { type: 'object' },
|
||
source: 'mcp',
|
||
serverName: 'CRM'
|
||
}
|
||
const listTools = vi
|
||
.fn<ModelToolProviderLike['listTools']>()
|
||
.mockResolvedValueOnce([loadTool])
|
||
.mockResolvedValueOnce([loadTool, dynamicTool])
|
||
.mockResolvedValueOnce([loadTool, dynamicTool])
|
||
const toolProvider = createToolProvider({ listTools })
|
||
const responses = [
|
||
{
|
||
choices: [{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [{
|
||
id: 'call-load',
|
||
type: 'function',
|
||
function: {
|
||
name: loadTool.name,
|
||
arguments: '{}'
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
},
|
||
{
|
||
choices: [{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [{
|
||
id: 'call-list',
|
||
type: 'function',
|
||
function: {
|
||
name: dynamicTool.name,
|
||
arguments: '{}'
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
},
|
||
{
|
||
choices: [{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '已读取商机。'
|
||
}
|
||
}]
|
||
}
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed139',
|
||
conversationId: 'conversation-dynamic-tools',
|
||
prompt: '列出商机',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
vi.fn(async () => 'once' as const)
|
||
)) {
|
||
void _event
|
||
}
|
||
|
||
expect(listTools).toHaveBeenCalledTimes(3)
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as {
|
||
tools: Array<{ function: { name: string } }>
|
||
}
|
||
expect(secondBody.tools.map((tool) => tool.function.name)).toContain(
|
||
dynamicTool.name
|
||
)
|
||
expect(toolProvider.callTool).toHaveBeenCalledTimes(2)
|
||
})
|
||
|
||
it('continues without tools when the refreshed inventory is empty', async () => {
|
||
const progressTool: ModelToolDefinition = {
|
||
name: 'record_progress',
|
||
displayName: 'Record progress',
|
||
description: 'Record the final required step',
|
||
inputSchema: { type: 'object' },
|
||
source: 'builtin'
|
||
}
|
||
const listTools = vi
|
||
.fn<ModelToolProviderLike['listTools']>()
|
||
.mockResolvedValueOnce([progressTool])
|
||
.mockResolvedValueOnce([])
|
||
const toolProvider = createToolProvider({ listTools })
|
||
const responses = [
|
||
{
|
||
choices: [{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [{
|
||
id: 'call-progress',
|
||
type: 'function',
|
||
function: {
|
||
name: progressTool.name,
|
||
arguments: '{}'
|
||
}
|
||
}]
|
||
}
|
||
}]
|
||
},
|
||
{
|
||
choices: [{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '进度记录完成。'
|
||
}
|
||
}]
|
||
}
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
|
||
const events = []
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed151',
|
||
conversationId: 'conversation-empty-refreshed-tools',
|
||
prompt: '记录进度后完成',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
vi.fn(async () => 'once' as const)
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(listTools).toHaveBeenCalledTimes(2)
|
||
expect(toolProvider.callTool).toHaveBeenCalledOnce()
|
||
expect(
|
||
JSON.parse(fetcher.mock.calls[1]?.[1]?.body as string)
|
||
).not.toHaveProperty('tools')
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: '进度记录完成。'
|
||
})
|
||
)
|
||
})
|
||
|
||
it('runs only scoped knowledge in Ask without requesting approval', async () => {
|
||
const responses = [
|
||
{
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
id: 'knowledge-list-call',
|
||
type: 'function',
|
||
function: {
|
||
name: 'knowledge_list',
|
||
arguments: '{}'
|
||
}
|
||
},
|
||
{
|
||
id: 'knowledge-call',
|
||
type: 'function',
|
||
function: {
|
||
name: 'knowledge_search',
|
||
arguments: '{"query":"release notes","limit":3}'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
},
|
||
{
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '基于知识库证据回答。'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
const knowledgeListTool: ModelToolDefinition = {
|
||
name: 'knowledge_list',
|
||
displayName: '知识库列表',
|
||
description: 'Scoped library metadata',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: {},
|
||
additionalProperties: false
|
||
},
|
||
source: 'builtin'
|
||
}
|
||
const knowledgeTool: ModelToolDefinition = {
|
||
name: 'knowledge_search',
|
||
displayName: '知识库搜索',
|
||
description: 'Scoped evidence',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: { query: { type: 'string' } },
|
||
required: ['query'],
|
||
additionalProperties: false
|
||
},
|
||
source: 'builtin'
|
||
}
|
||
const toolProvider = createToolProvider({
|
||
listTools: vi.fn(async () => [
|
||
knowledgeListTool,
|
||
knowledgeTool
|
||
])
|
||
})
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const authorize = vi.fn(async () => 'deny' as const)
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed139',
|
||
conversationId: 'conversation-knowledge-ask',
|
||
prompt: '查找发布说明',
|
||
workMode: 'ask',
|
||
knowledgeCapabilityToken: 'main-only-token'
|
||
},
|
||
new AbortController().signal,
|
||
authorize
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(toolProvider.listTools).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
conversationId: 'conversation-knowledge-ask',
|
||
workMode: 'ask',
|
||
knowledgeCapabilityToken: 'main-only-token'
|
||
}),
|
||
expect.any(AbortSignal)
|
||
)
|
||
expect(toolProvider.callTool).toHaveBeenCalledWith(
|
||
'knowledge_list',
|
||
{},
|
||
expect.any(AbortSignal),
|
||
expect.objectContaining({
|
||
workMode: 'ask',
|
||
knowledgeCapabilityToken: 'main-only-token'
|
||
})
|
||
)
|
||
expect(toolProvider.callTool).toHaveBeenCalledWith(
|
||
'knowledge_search',
|
||
{ query: 'release notes', limit: 3 },
|
||
expect.any(AbortSignal),
|
||
expect.objectContaining({
|
||
workMode: 'ask',
|
||
knowledgeCapabilityToken: 'main-only-token'
|
||
})
|
||
)
|
||
expect(authorize).not.toHaveBeenCalled()
|
||
expect(toolProvider.getApproval).not.toHaveBeenCalled()
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('runs enabled web search in Ask without per-call approval', async () => {
|
||
const responses = [
|
||
{
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
id: 'web-search-call',
|
||
type: 'function',
|
||
function: {
|
||
name: 'web_search',
|
||
arguments: '{"query":"current release","numResults":2}'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
},
|
||
{
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '基于联网搜索结果回答。'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
const webSearchTool: ModelToolDefinition = {
|
||
name: 'web_search',
|
||
displayName: '联网搜索',
|
||
description: 'Search public web',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: { query: { type: 'string' } },
|
||
required: ['query'],
|
||
additionalProperties: false
|
||
},
|
||
source: 'builtin'
|
||
}
|
||
const toolProvider = createToolProvider({
|
||
listTools: vi.fn(async () => [webSearchTool])
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
),
|
||
toolProvider,
|
||
webSearchEnabled: true
|
||
})
|
||
const authorize = vi.fn(async () => 'deny' as const)
|
||
|
||
const events = []
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'f0370284-5933-4743-892c-98263b8a44ae',
|
||
conversationId: 'conversation-web-search-ask',
|
||
prompt: '查找当前版本',
|
||
workMode: 'ask'
|
||
},
|
||
new AbortController().signal,
|
||
authorize
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(toolProvider.callTool).toHaveBeenCalledWith(
|
||
'web_search',
|
||
{ query: 'current release', numResults: 2 },
|
||
expect.any(AbortSignal),
|
||
expect.objectContaining({ workMode: 'ask' })
|
||
)
|
||
expect(authorize).not.toHaveBeenCalled()
|
||
expect(toolProvider.getApproval).not.toHaveBeenCalled()
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('returns recoverable tool failures to the model instead of aborting the run', async () => {
|
||
const responses = [
|
||
{
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
id: 'call-stale-ref',
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"README.md"}'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
},
|
||
{
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '已获取新快照并继续。'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const toolProvider = createToolProvider({
|
||
callTool: vi.fn(async () => {
|
||
throw new RecoverableModelToolError(
|
||
'浏览器元素引用已失效,请重新获取快照',
|
||
'调用 browser_snapshot 后重试'
|
||
)
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed130',
|
||
conversationId: 'conversation-recoverable-tool-error',
|
||
prompt: '继续浏览器操作',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as { messages: Array<Record<string, unknown>> }
|
||
const toolMessage = secondBody.messages.find(
|
||
(message) => message.role === 'tool'
|
||
)
|
||
expect(JSON.parse(toolMessage?.content as string)).toEqual({
|
||
ok: false,
|
||
recoverable: true,
|
||
error: '浏览器元素引用已失效,请重新获取快照',
|
||
nextAction: '调用 browser_snapshot 后重试'
|
||
})
|
||
expect(
|
||
events
|
||
.filter((event) => event.type === 'tool')
|
||
.map((event) => event.state)
|
||
).toEqual(['pending', 'running', 'recoverable'])
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: '已获取新快照并继续。'
|
||
})
|
||
)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('recovers an absolute workspace read by retrying a relative path against the real filesystem', async () => {
|
||
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-read-recovery-'))
|
||
const workspaceAccess = new LocalWorkspaceAccess(workspace)
|
||
const toolProvider = new ModelToolProvider(workspaceAccess, [])
|
||
const absolutePath = join(workspace, 'README.md')
|
||
const marker = `read-recovery-${crypto.randomUUID()}`
|
||
const responses = [absolutePath, 'README.md'].map((path, index) => ({
|
||
choices: [{ message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [{
|
||
id: `read-${index}`,
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: JSON.stringify({ path })
|
||
}
|
||
}]
|
||
} }]
|
||
}))
|
||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||
const response = responses.shift()
|
||
return Response.json(response ?? {
|
||
choices: [{ message: { role: 'assistant', content: 'Read completed.' } }]
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'https://example.test/v1',
|
||
model: 'test-model',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider,
|
||
workspaceAccess
|
||
})
|
||
const events: RuntimeEvent[] = []
|
||
|
||
try {
|
||
await writeFile(absolutePath, `${marker}\n`)
|
||
for await (const event of runtime.run({
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: 'conversation-workspace-read-recovery',
|
||
prompt: 'Read README.md.',
|
||
workMode: 'ask'
|
||
}, new AbortController().signal, async () => 'deny')) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(3)
|
||
const secondBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body)) as {
|
||
messages: Array<{ role: string; tool_call_id?: string; content: string }>
|
||
}
|
||
const correction = secondBody.messages.filter((message) => message.role === 'tool')
|
||
expect(correction).toHaveLength(1)
|
||
expect(correction[0]).toMatchObject({ tool_call_id: 'read-0' })
|
||
expect(JSON.parse(correction[0]!.content)).toEqual({
|
||
ok: false,
|
||
recoverable: true,
|
||
error: expect.stringMatching(/^workspace_read_text: [\s\S]*路径必须相对于工作区/u),
|
||
nextAction: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed. Do not retry identical arguments.'
|
||
})
|
||
expect(correction[0]!.content).not.toContain(marker)
|
||
|
||
const thirdBody = JSON.parse(String(fetcher.mock.calls[2]![1]!.body)) as {
|
||
messages: Array<{ role: string; tool_call_id?: string; content: string }>
|
||
}
|
||
expect(thirdBody.messages.filter((message) => message.role === 'tool')).toEqual([
|
||
correction[0],
|
||
expect.objectContaining({
|
||
tool_call_id: 'read-1',
|
||
content: expect.stringContaining(marker)
|
||
})
|
||
])
|
||
const toolEvents = events.filter((event) => event.type === 'tool')
|
||
expect(toolEvents.map((event) => [event.callId, event.state])).toEqual([
|
||
['read-0', 'pending'],
|
||
['read-0', 'running'],
|
||
['read-0', 'recoverable'],
|
||
['read-1', 'pending'],
|
||
['read-1', 'running'],
|
||
['read-1', 'completed']
|
||
])
|
||
expect(JSON.parse(toolEvents[0]!.input!)).toEqual({ path: absolutePath })
|
||
expect(toolEvents[2]).toMatchObject({
|
||
error: expect.stringContaining('路径必须相对于工作区')
|
||
})
|
||
expect(JSON.parse(toolEvents[3]!.input!)).toEqual({ path: 'README.md' })
|
||
expect(toolEvents[5]).toMatchObject({ output: expect.stringContaining(marker) })
|
||
expect(events).toContainEqual(expect.objectContaining({
|
||
type: 'text', delta: 'Read completed.'
|
||
}))
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
} finally {
|
||
await runtime.dispose()
|
||
await rm(workspace, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
it('streams OpenAI Responses text and reasoning through tool rounds', async () => {
|
||
const streams = [
|
||
createSseEventStream([
|
||
{
|
||
type: 'response.reasoning_summary_text.delta',
|
||
delta: '先分析。'
|
||
},
|
||
{
|
||
type: 'response.output_text.delta',
|
||
delta: '准备读取。'
|
||
},
|
||
{
|
||
type: 'response.completed',
|
||
response: {
|
||
id: 'resp-stream-tool-1',
|
||
model: 'gpt-5',
|
||
status: 'completed',
|
||
output: [
|
||
{
|
||
id: 'reasoning-stream-1',
|
||
type: 'reasoning',
|
||
summary: [
|
||
{ type: 'summary_text', text: '先分析。' }
|
||
]
|
||
},
|
||
{
|
||
id: 'message-stream-1',
|
||
type: 'message',
|
||
role: 'assistant',
|
||
status: 'completed',
|
||
content: [
|
||
{ type: 'output_text', text: '准备读取。' }
|
||
]
|
||
},
|
||
{
|
||
id: 'function-stream-1',
|
||
type: 'function_call',
|
||
call_id: 'call-stream-1',
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"README.md"}'
|
||
}
|
||
],
|
||
usage: { input_tokens: 12, output_tokens: 4 }
|
||
}
|
||
}
|
||
]),
|
||
createSseEventStream([
|
||
{
|
||
type: 'response.output_text.delta',
|
||
delta: '读取完成。'
|
||
},
|
||
{
|
||
type: 'response.completed',
|
||
response: {
|
||
id: 'resp-stream-tool-2',
|
||
model: 'gpt-5',
|
||
status: 'completed',
|
||
output: [
|
||
{
|
||
id: 'message-stream-2',
|
||
type: 'message',
|
||
role: 'assistant',
|
||
status: 'completed',
|
||
content: [
|
||
{ type: 'output_text', text: '读取完成。' }
|
||
]
|
||
}
|
||
],
|
||
usage: { input_tokens: 20, output_tokens: 5 }
|
||
}
|
||
}
|
||
])
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(streams.shift(), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-5',
|
||
protocol: 'openai-responses',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed151',
|
||
conversationId: 'conversation-responses-streaming-tools',
|
||
prompt: '读取 README',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
for (const [, init] of fetcher.mock.calls) {
|
||
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||
stream: true
|
||
})
|
||
}
|
||
expect(
|
||
events
|
||
.filter((event) => event.type === 'reasoning')
|
||
.map((event) => event.delta)
|
||
).toEqual(['先分析。'])
|
||
expect(
|
||
events
|
||
.filter((event) => event.type === 'text')
|
||
.map((event) => event.delta)
|
||
).toEqual(['准备读取。', '读取完成。'])
|
||
expect(
|
||
events.findIndex((event) => event.type === 'text')
|
||
).toBeLessThan(
|
||
events.findIndex(
|
||
(event) =>
|
||
event.type === 'tool' && event.state === 'running'
|
||
)
|
||
)
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as { input: Array<Record<string, unknown>> }
|
||
expect(secondBody.input).toEqual([
|
||
{
|
||
role: 'user',
|
||
content: '读取 README'
|
||
},
|
||
{
|
||
id: 'reasoning-stream-1',
|
||
type: 'reasoning',
|
||
summary: [{ type: 'summary_text', text: '先分析。' }]
|
||
},
|
||
{
|
||
id: 'message-stream-1',
|
||
type: 'message',
|
||
role: 'assistant',
|
||
status: 'completed',
|
||
content: [
|
||
{ type: 'output_text', text: '准备读取。' }
|
||
]
|
||
},
|
||
{
|
||
id: 'function-stream-1',
|
||
type: 'function_call',
|
||
call_id: 'call-stream-1',
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"README.md"}'
|
||
},
|
||
{
|
||
type: 'function_call_output',
|
||
call_id: 'call-stream-1',
|
||
output: [
|
||
{
|
||
type: 'input_text',
|
||
text: 'tool result'
|
||
}
|
||
]
|
||
}
|
||
])
|
||
expect(toolProvider.callTool).toHaveBeenCalledOnce()
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('rejects an incomplete OpenAI Responses tool stream', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(
|
||
[
|
||
'event: response.output_text.delta',
|
||
`data: ${JSON.stringify({
|
||
type: 'response.output_text.delta',
|
||
delta: 'partial'
|
||
})}`,
|
||
'',
|
||
'data: [DONE]',
|
||
'',
|
||
''
|
||
].join('\n'),
|
||
{
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
}
|
||
)
|
||
)
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-5',
|
||
protocol: 'openai-responses',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed153',
|
||
conversationId: 'conversation-responses-incomplete-tools',
|
||
prompt: '读取 README',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow('流式响应意外中断')
|
||
expect(toolProvider.callTool).not.toHaveBeenCalled()
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it('continues OpenAI Responses with function_call_output', async () => {
|
||
const responses = [
|
||
{
|
||
id: 'resp-tool-1',
|
||
model: 'gpt-5',
|
||
output: [
|
||
{
|
||
id: 'msg-responses-1',
|
||
type: 'message',
|
||
role: 'assistant',
|
||
status: 'completed',
|
||
content: [
|
||
{
|
||
type: 'output_text',
|
||
text: '先读取 README。'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'fc-responses-1',
|
||
type: 'function_call',
|
||
call_id: 'call-responses-1',
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"README.md"}'
|
||
}
|
||
],
|
||
usage: { input_tokens: 14, output_tokens: 3 }
|
||
},
|
||
{
|
||
id: 'resp-tool-2',
|
||
model: 'gpt-5',
|
||
output: [
|
||
{
|
||
id: 'fc-responses-2',
|
||
type: 'function_call',
|
||
call_id: 'call-responses-2',
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"DESIGN.md"}'
|
||
}
|
||
],
|
||
usage: { input_tokens: 21, output_tokens: 4 }
|
||
},
|
||
{
|
||
id: 'resp-tool-3',
|
||
model: 'gpt-5',
|
||
output: [
|
||
{
|
||
type: 'message',
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'output_text',
|
||
text: 'Responses 工具调用完成。'
|
||
}
|
||
]
|
||
}
|
||
],
|
||
usage: { input_tokens: 30, output_tokens: 6 }
|
||
}
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-5',
|
||
protocol: 'openai-responses',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider: createToolProvider({
|
||
callTool: vi.fn(async () => createMultimodalToolResult())
|
||
})
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed134',
|
||
conversationId: 'conversation-responses-tools',
|
||
prompt: '读取 README',
|
||
history: [
|
||
{ role: 'user', content: '此前问题' },
|
||
{ role: 'assistant', content: '此前回答' }
|
||
],
|
||
historyMessageIds: [
|
||
'00000000-0000-4000-8000-000000000341',
|
||
'00000000-0000-4000-8000-000000000342'
|
||
],
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
const firstBody = JSON.parse(
|
||
fetcher.mock.calls[0]?.[1]?.body as string
|
||
) as Record<string, unknown>
|
||
expect(firstBody).toMatchObject({
|
||
model: 'gpt-5',
|
||
stream: true,
|
||
input: [
|
||
{
|
||
role: 'user',
|
||
content: '此前问题'
|
||
},
|
||
{
|
||
role: 'assistant',
|
||
content: '此前回答'
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: '读取 README'
|
||
}
|
||
],
|
||
tools: [
|
||
{
|
||
type: 'function',
|
||
name: 'workspace_read_text',
|
||
strict: false
|
||
}
|
||
]
|
||
})
|
||
expect(firstBody).not.toHaveProperty('previous_response_id')
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as Record<string, unknown>
|
||
expect(secondBody).toMatchObject({
|
||
input: [
|
||
{
|
||
role: 'user',
|
||
content: '此前问题'
|
||
},
|
||
{
|
||
role: 'assistant',
|
||
content: '此前回答'
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: '读取 README'
|
||
},
|
||
{
|
||
id: 'msg-responses-1',
|
||
type: 'message',
|
||
role: 'assistant',
|
||
status: 'completed',
|
||
content: [
|
||
{
|
||
type: 'output_text',
|
||
text: '先读取 README。'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'fc-responses-1',
|
||
type: 'function_call',
|
||
call_id: 'call-responses-1',
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"README.md"}'
|
||
},
|
||
{
|
||
type: 'function_call_output',
|
||
call_id: 'call-responses-1',
|
||
output: [
|
||
{
|
||
type: 'input_text',
|
||
text: 'tool result'
|
||
},
|
||
{
|
||
type: 'input_image',
|
||
image_url: `data:image/png;base64,${toolPng}`
|
||
}
|
||
]
|
||
}
|
||
]
|
||
})
|
||
const thirdBody = JSON.parse(
|
||
fetcher.mock.calls[2]?.[1]?.body as string
|
||
) as {
|
||
input: Array<Record<string, unknown>>
|
||
}
|
||
expect(thirdBody.input).toEqual([
|
||
...(secondBody.input as Array<Record<string, unknown>>),
|
||
{
|
||
id: 'fc-responses-2',
|
||
type: 'function_call',
|
||
call_id: 'call-responses-2',
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"DESIGN.md"}'
|
||
},
|
||
{
|
||
type: 'function_call_output',
|
||
call_id: 'call-responses-2',
|
||
output: [
|
||
{
|
||
type: 'input_text',
|
||
text: 'tool result'
|
||
},
|
||
{
|
||
type: 'input_image',
|
||
image_url: `data:image/png;base64,${toolPng}`
|
||
}
|
||
]
|
||
}
|
||
])
|
||
for (const [, init] of fetcher.mock.calls) {
|
||
const body = JSON.parse(init?.body as string)
|
||
expect(body).not.toHaveProperty(
|
||
'previous_response_id'
|
||
)
|
||
expect(JSON.stringify(body)).not.toContain(
|
||
'00000000-0000-4000-8000-000000000341'
|
||
)
|
||
expect(JSON.stringify(body)).not.toContain(
|
||
'00000000-0000-4000-8000-000000000342'
|
||
)
|
||
}
|
||
expect(
|
||
events
|
||
.filter((event) => event.type === 'tool')
|
||
.map((event) => event.state)
|
||
).toEqual([
|
||
'pending',
|
||
'running',
|
||
'completed',
|
||
'pending',
|
||
'running',
|
||
'completed'
|
||
])
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'text',
|
||
delta: 'Responses 工具调用完成。'
|
||
})
|
||
)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('pairs a missing Responses call_id with the function-call item id', async () => {
|
||
const responses = [
|
||
{
|
||
id: 'resp-tool-fallback-1',
|
||
model: 'gpt-5',
|
||
output: [
|
||
{
|
||
id: 'fc-responses-fallback-1',
|
||
type: 'function_call',
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"README.md"}'
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'resp-tool-fallback-2',
|
||
model: 'gpt-5',
|
||
output: [
|
||
{
|
||
type: 'message',
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'output_text',
|
||
text: '读取完成。'
|
||
}
|
||
]
|
||
}
|
||
]
|
||
}
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.openai.com/v1',
|
||
model: 'gpt-5',
|
||
protocol: 'openai-responses',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider: createToolProvider()
|
||
})
|
||
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed141',
|
||
conversationId: 'conversation-responses-fallback-id',
|
||
prompt: '读取 README',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
void _event
|
||
}
|
||
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as { input: Array<Record<string, unknown>> }
|
||
expect(secondBody.input).toContainEqual(
|
||
expect.objectContaining({
|
||
id: 'fc-responses-fallback-1',
|
||
type: 'function_call',
|
||
call_id: 'fc-responses-fallback-1'
|
||
})
|
||
)
|
||
expect(secondBody.input).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'function_call_output',
|
||
call_id: 'fc-responses-fallback-1'
|
||
})
|
||
)
|
||
})
|
||
|
||
it('fails closed when a direct-model tool is denied', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
id: 'call-denied',
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"secret.txt"}'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
})
|
||
)
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const events: Array<{ type: string; state?: string }> = []
|
||
const consume = async (): Promise<void> => {
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed131',
|
||
conversationId: 'conversation-denied',
|
||
prompt: '读取 secret',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'deny'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow('用户拒绝')
|
||
expect(
|
||
events
|
||
.filter((event) => event.type === 'tool')
|
||
.map((event) => event.state)
|
||
).toEqual(['pending', 'failed'])
|
||
expect(toolProvider.callTool).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('uses Anthropic tool_use and tool_result messages in Execute mode', async () => {
|
||
const responses = [
|
||
{
|
||
id: 'message-tool-1',
|
||
model: 'claude',
|
||
content: [
|
||
{
|
||
type: 'tool_use',
|
||
id: 'toolu-1',
|
||
name: 'workspace_read_text',
|
||
input: { path: 'notes.md' }
|
||
}
|
||
],
|
||
usage: { input_tokens: 12, output_tokens: 3 }
|
||
},
|
||
{
|
||
id: 'message-tool-2',
|
||
model: 'claude',
|
||
content: [{ type: 'text', text: '读取完成。' }],
|
||
usage: { input_tokens: 20, output_tokens: 5 }
|
||
}
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'claude',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider: createToolProvider({
|
||
callTool: vi.fn(async () => createMultimodalToolResult())
|
||
})
|
||
})
|
||
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed132',
|
||
conversationId: 'conversation-anthropic-tools',
|
||
prompt: '读取 notes',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
void _event
|
||
}
|
||
|
||
const firstBody = JSON.parse(
|
||
fetcher.mock.calls[0]?.[1]?.body as string
|
||
) as Record<string, unknown>
|
||
expect(firstBody).toMatchObject({
|
||
stream: true,
|
||
tools: [
|
||
{
|
||
name: 'workspace_read_text',
|
||
input_schema: expect.objectContaining({ type: 'object' })
|
||
}
|
||
]
|
||
})
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as { messages: Array<Record<string, unknown>> }
|
||
expect(secondBody.messages.at(-1)).toEqual({
|
||
role: 'user',
|
||
content: [
|
||
{
|
||
type: 'tool_result',
|
||
tool_use_id: 'toolu-1',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: 'tool result'
|
||
},
|
||
{
|
||
type: 'image',
|
||
source: {
|
||
type: 'base64',
|
||
media_type: 'image/png',
|
||
data: toolPng
|
||
}
|
||
}
|
||
]
|
||
}
|
||
]
|
||
})
|
||
})
|
||
|
||
it('streams Anthropic text and thinking through tool rounds', async () => {
|
||
const streams = [
|
||
createSseEventStream([
|
||
{
|
||
type: 'message_start',
|
||
message: {
|
||
id: 'message-stream-tool-1',
|
||
model: 'claude',
|
||
usage: { input_tokens: 10 }
|
||
}
|
||
},
|
||
{
|
||
type: 'content_block_start',
|
||
index: 0,
|
||
content_block: { type: 'thinking', thinking: '' }
|
||
},
|
||
{
|
||
type: 'content_block_delta',
|
||
index: 0,
|
||
delta: { type: 'thinking_delta', thinking: '先分析。' }
|
||
},
|
||
{
|
||
type: 'content_block_delta',
|
||
index: 0,
|
||
delta: { type: 'signature_delta', signature: 'signed' }
|
||
},
|
||
{ type: 'content_block_stop', index: 0 },
|
||
{
|
||
type: 'content_block_start',
|
||
index: 1,
|
||
content_block: { type: 'text', text: '' }
|
||
},
|
||
{
|
||
type: 'content_block_delta',
|
||
index: 1,
|
||
delta: { type: 'text_delta', text: '准备读取。' }
|
||
},
|
||
{ type: 'content_block_stop', index: 1 },
|
||
{
|
||
type: 'content_block_start',
|
||
index: 2,
|
||
content_block: {
|
||
type: 'tool_use',
|
||
id: 'toolu-stream-1',
|
||
name: 'workspace_read_text',
|
||
input: {}
|
||
}
|
||
},
|
||
{
|
||
type: 'content_block_delta',
|
||
index: 2,
|
||
delta: {
|
||
type: 'input_json_delta',
|
||
partial_json: '{"path":'
|
||
}
|
||
},
|
||
{
|
||
type: 'content_block_delta',
|
||
index: 2,
|
||
delta: {
|
||
type: 'input_json_delta',
|
||
partial_json: '"notes.md"}'
|
||
}
|
||
},
|
||
{ type: 'content_block_stop', index: 2 },
|
||
{
|
||
type: 'message_delta',
|
||
delta: { stop_reason: 'tool_use' },
|
||
usage: { output_tokens: 4 }
|
||
},
|
||
{ type: 'message_stop' }
|
||
]),
|
||
createSseEventStream([
|
||
{
|
||
type: 'message_start',
|
||
message: {
|
||
id: 'message-stream-tool-2',
|
||
model: 'claude',
|
||
usage: { input_tokens: 18 }
|
||
}
|
||
},
|
||
{
|
||
type: 'content_block_start',
|
||
index: 0,
|
||
content_block: { type: 'text', text: '' }
|
||
},
|
||
{
|
||
type: 'content_block_delta',
|
||
index: 0,
|
||
delta: { type: 'text_delta', text: '读取完成。' }
|
||
},
|
||
{ type: 'content_block_stop', index: 0 },
|
||
{
|
||
type: 'message_delta',
|
||
delta: { stop_reason: 'end_turn' },
|
||
usage: { output_tokens: 5 }
|
||
},
|
||
{ type: 'message_stop' }
|
||
])
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(streams.shift(), {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.anthropic.com',
|
||
model: 'claude',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed152',
|
||
conversationId: 'conversation-anthropic-streaming-tools',
|
||
prompt: '读取 notes',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
for (const [, init] of fetcher.mock.calls) {
|
||
expect(JSON.parse(init?.body as string)).toMatchObject({
|
||
stream: true
|
||
})
|
||
}
|
||
expect(
|
||
events
|
||
.filter((event) => event.type === 'reasoning')
|
||
.map((event) => event.delta)
|
||
).toEqual(['先分析。'])
|
||
expect(
|
||
events
|
||
.filter((event) => event.type === 'text')
|
||
.map((event) => event.delta)
|
||
).toEqual(['准备读取。', '读取完成。'])
|
||
expect(
|
||
events.findIndex((event) => event.type === 'text')
|
||
).toBeLessThan(
|
||
events.findIndex(
|
||
(event) =>
|
||
event.type === 'tool' && event.state === 'running'
|
||
)
|
||
)
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as { messages: Array<Record<string, unknown>> }
|
||
expect(secondBody.messages.at(-2)).toEqual({
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'thinking',
|
||
thinking: '先分析。',
|
||
signature: 'signed'
|
||
},
|
||
{
|
||
type: 'text',
|
||
text: '准备读取。'
|
||
},
|
||
{
|
||
type: 'tool_use',
|
||
id: 'toolu-stream-1',
|
||
name: 'workspace_read_text',
|
||
input: { path: 'notes.md' }
|
||
}
|
||
]
|
||
})
|
||
expect(secondBody.messages.at(-1)).toEqual({
|
||
role: 'user',
|
||
content: [
|
||
{
|
||
type: 'tool_result',
|
||
tool_use_id: 'toolu-stream-1',
|
||
content: [{ type: 'text', text: 'tool result' }]
|
||
}
|
||
]
|
||
})
|
||
expect(toolProvider.callTool).toHaveBeenCalledOnce()
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('rejects malformed streamed Anthropic tool arguments', async () => {
|
||
const stream = createSseEventStream([
|
||
{
|
||
type: 'message_start',
|
||
message: {
|
||
id: 'message-invalid-tool-1',
|
||
model: 'claude',
|
||
usage: { input_tokens: 8 }
|
||
}
|
||
},
|
||
{
|
||
type: 'content_block_start',
|
||
index: 0,
|
||
content_block: {
|
||
type: 'tool_use',
|
||
id: 'toolu-invalid-1',
|
||
name: 'workspace_read_text',
|
||
input: {}
|
||
}
|
||
},
|
||
{
|
||
type: 'content_block_delta',
|
||
index: 0,
|
||
delta: {
|
||
type: 'input_json_delta',
|
||
partial_json: '{"path":'
|
||
}
|
||
},
|
||
{ type: 'content_block_stop', index: 0 },
|
||
{ type: 'message_stop' }
|
||
])
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(stream, {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.anthropic.com',
|
||
model: 'claude',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed154',
|
||
conversationId: 'conversation-anthropic-invalid-tools',
|
||
prompt: '读取 notes',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow(
|
||
'模型返回了无效的工具参数 JSON'
|
||
)
|
||
expect(toolProvider.callTool).not.toHaveBeenCalled()
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it('rejects a truncated streamed Anthropic tool round', async () => {
|
||
const stream = createSseEventStream([
|
||
{
|
||
type: 'message_start',
|
||
message: {
|
||
id: 'message-truncated-stream-1',
|
||
model: 'claude',
|
||
usage: { input_tokens: 8 }
|
||
}
|
||
},
|
||
{
|
||
type: 'content_block_start',
|
||
index: 0,
|
||
content_block: { type: 'text', text: '' }
|
||
},
|
||
{
|
||
type: 'content_block_delta',
|
||
index: 0,
|
||
delta: { type: 'text_delta', text: 'partial' }
|
||
},
|
||
{ type: 'content_block_stop', index: 0 },
|
||
{
|
||
type: 'message_delta',
|
||
delta: { stop_reason: 'max_tokens' },
|
||
usage: { output_tokens: 4 }
|
||
},
|
||
{ type: 'message_stop' }
|
||
])
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
new Response(stream, {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
})
|
||
)
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.anthropic.com',
|
||
model: 'claude',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed155',
|
||
conversationId: 'conversation-anthropic-truncated-stream',
|
||
prompt: '读取 notes',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow(
|
||
'Anthropic 返回未完成结果:max_tokens'
|
||
)
|
||
expect(toolProvider.callTool).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('rejects a truncated Anthropic JSON fallback', async () => {
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json({
|
||
id: 'message-truncated-json-1',
|
||
model: 'claude',
|
||
content: [{ type: 'text', text: 'partial' }],
|
||
stop_reason: 'model_context_window_exceeded',
|
||
usage: { input_tokens: 8, output_tokens: 4 }
|
||
})
|
||
)
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://api.anthropic.com',
|
||
model: 'claude',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed156',
|
||
conversationId: 'conversation-anthropic-truncated-json',
|
||
prompt: '读取 notes',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow(
|
||
'Anthropic 返回未完成结果:model_context_window_exceeded'
|
||
)
|
||
expect(toolProvider.callTool).not.toHaveBeenCalled()
|
||
})
|
||
|
||
it('synthesizes and pairs a missing Anthropic tool_use id', async () => {
|
||
const responses = [
|
||
{
|
||
id: 'message-tool-missing-id-1',
|
||
model: 'claude',
|
||
content: [
|
||
{
|
||
type: 'tool_use',
|
||
name: 'workspace_read_text',
|
||
input: { path: 'notes.md' }
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: 'message-tool-missing-id-2',
|
||
model: 'claude',
|
||
content: [{ type: 'text', text: '读取完成。' }]
|
||
}
|
||
]
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json(responses.shift())
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai',
|
||
model: 'claude',
|
||
protocol: 'anthropic-messages',
|
||
authentication: 'api-key',
|
||
fetcher,
|
||
toolProvider: createToolProvider()
|
||
})
|
||
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed142',
|
||
conversationId: 'conversation-anthropic-fallback-id',
|
||
prompt: '读取 notes',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
void _event
|
||
}
|
||
|
||
const secondBody = JSON.parse(
|
||
fetcher.mock.calls[1]?.[1]?.body as string
|
||
) as { messages: Array<Record<string, unknown>> }
|
||
const assistant = secondBody.messages.at(-2) as {
|
||
content: Array<Record<string, unknown>>
|
||
}
|
||
const result = secondBody.messages.at(-1) as {
|
||
content: Array<Record<string, unknown>>
|
||
}
|
||
const toolUseId = assistant.content[0]?.id
|
||
expect(toolUseId).toEqual(
|
||
expect.stringMatching(/^goodbuddy_call_[0-9a-f]{32}$/u)
|
||
)
|
||
expect(result.content[0]).toMatchObject({
|
||
type: 'tool_result',
|
||
tool_use_id: toolUseId
|
||
})
|
||
})
|
||
|
||
it('does not issue a follow-up model request after tool cancellation', async () => {
|
||
const response = {
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
id: 'call-aborted',
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: '{}'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
}
|
||
const fetcher = vi.fn<typeof fetch>(async () => Response.json(response))
|
||
const controller = new AbortController()
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider: createToolProvider({
|
||
callTool: vi.fn(async () => {
|
||
controller.abort()
|
||
return createTextToolResult('late result')
|
||
})
|
||
})
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'run',
|
||
workMode: 'execute'
|
||
},
|
||
controller.signal,
|
||
async () => 'once'
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow()
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
})
|
||
|
||
it('allows repeated tool rounds until the run is cancelled', async () => {
|
||
let callId = 0
|
||
const controller = new AbortController()
|
||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||
callId += 1
|
||
return Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
id: `call-repeat-${callId}`,
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: '{"path":"README.md"}'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
})
|
||
})
|
||
let completedCalls = 0
|
||
const toolProvider = createToolProvider({
|
||
callTool: vi.fn(async () => {
|
||
completedCalls += 1
|
||
if (completedCalls === 5) {
|
||
controller.abort()
|
||
}
|
||
return createTextToolResult('same result')
|
||
})
|
||
})
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: 'conversation-repeat',
|
||
prompt: 'repeat',
|
||
workMode: 'execute'
|
||
},
|
||
controller.signal,
|
||
async () => 'once'
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow()
|
||
expect(fetcher).toHaveBeenCalledTimes(5)
|
||
expect(toolProvider.callTool).toHaveBeenCalledTimes(5)
|
||
})
|
||
|
||
it('allows more than the legacy 40 tool calls in one model round', async () => {
|
||
const toolCalls = Array.from({ length: 45 }, (_, index) => ({
|
||
id: `call-bulk-${index}`,
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: JSON.stringify({ path: `file-${index}.txt` })
|
||
}
|
||
}))
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: toolCalls
|
||
}
|
||
}
|
||
]
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: 'all tools completed'
|
||
}
|
||
}
|
||
]
|
||
})
|
||
)
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: 'conversation-bulk-tools',
|
||
prompt: 'run all tools',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(toolProvider.callTool).toHaveBeenCalledTimes(45)
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('continues beyond the legacy 24 model tool rounds', async () => {
|
||
let modelRound = 0
|
||
const fetcher = vi.fn<typeof fetch>(async () => {
|
||
modelRound += 1
|
||
if (modelRound <= 25) {
|
||
return Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
id: `call-round-${modelRound}`,
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: JSON.stringify({
|
||
path: `round-${modelRound}.txt`
|
||
})
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
})
|
||
}
|
||
return Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: 'completed after 25 rounds'
|
||
}
|
||
}
|
||
]
|
||
})
|
||
})
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: 'conversation-many-rounds',
|
||
prompt: 'continue until done',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(toolProvider.callTool).toHaveBeenCalledTimes(25)
|
||
expect(fetcher).toHaveBeenCalledTimes(26)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('releases provider state for only the requested conversation', async () => {
|
||
const toolProvider = createToolProvider()
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
toolProvider
|
||
})
|
||
|
||
await runtime.releaseConversation('conversation-release')
|
||
|
||
expect(toolProvider.releaseConversation).toHaveBeenCalledOnce()
|
||
expect(toolProvider.releaseConversation).toHaveBeenCalledWith(
|
||
'conversation-release'
|
||
)
|
||
})
|
||
|
||
it('releases known conversations before provider disposal and permits replacement reuse', async () => {
|
||
const lifecycle: string[] = []
|
||
const released = new Set<string>()
|
||
const createProvider = (): ModelToolProviderLike =>
|
||
createToolProvider({
|
||
releaseConversation: vi.fn(async (conversationId) => {
|
||
lifecycle.push(`release:${conversationId}`)
|
||
released.add(conversationId)
|
||
}),
|
||
dispose: vi.fn(async () => {
|
||
lifecycle.push('dispose')
|
||
})
|
||
})
|
||
const createRuntime = (toolProvider: ModelToolProviderLike) =>
|
||
new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
new Response(
|
||
'data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n',
|
||
{
|
||
status: 200,
|
||
headers: { 'content-type': 'text/event-stream' }
|
||
}
|
||
)
|
||
),
|
||
toolProvider
|
||
})
|
||
const request = {
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: 'conversation-replacement',
|
||
prompt: 'hello',
|
||
workMode: 'ask' as const
|
||
}
|
||
const firstProvider = createProvider()
|
||
const firstRuntime = createRuntime(firstProvider)
|
||
for await (const _event of firstRuntime.run(
|
||
request,
|
||
new AbortController().signal
|
||
)) {
|
||
void _event
|
||
}
|
||
|
||
await firstRuntime.dispose()
|
||
expect(lifecycle).toEqual([
|
||
'release:conversation-replacement',
|
||
'dispose'
|
||
])
|
||
expect(released).toContain('conversation-replacement')
|
||
|
||
const replacement = createRuntime(createProvider())
|
||
const replacementEvents = []
|
||
for await (const event of replacement.run(
|
||
{ ...request, requestId: crypto.randomUUID() },
|
||
new AbortController().signal
|
||
)) {
|
||
replacementEvents.push(event)
|
||
}
|
||
expect(replacementEvents.at(-1)).toMatchObject({ type: 'done' })
|
||
await replacement.dispose()
|
||
})
|
||
|
||
it('keeps an oversized image tool result in the model round', async () => {
|
||
const imageData = Buffer.alloc(1024 * 1024 + 1).toString('base64')
|
||
const fetcher = vi
|
||
.fn<typeof fetch>()
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: null,
|
||
tool_calls: [
|
||
{
|
||
id: 'call-large-image',
|
||
type: 'function',
|
||
function: {
|
||
name: 'workspace_read_text',
|
||
arguments: '{}'
|
||
}
|
||
}
|
||
]
|
||
}
|
||
}
|
||
]
|
||
})
|
||
)
|
||
.mockResolvedValueOnce(
|
||
Response.json({
|
||
choices: [
|
||
{
|
||
message: {
|
||
role: 'assistant',
|
||
content: '图片已读取。'
|
||
}
|
||
}
|
||
]
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||
model: 'qwen3',
|
||
protocol: 'openai-chat-completions',
|
||
authentication: 'none',
|
||
fetcher,
|
||
toolProvider: createToolProvider({
|
||
callTool: vi.fn(async () => ({
|
||
parts: [
|
||
{
|
||
type: 'image' as const,
|
||
mimeType: 'image/png' as const,
|
||
data: imageData
|
||
}
|
||
],
|
||
contextBytes: Buffer.byteLength(imageData)
|
||
}))
|
||
})
|
||
})
|
||
const events = []
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: 'run',
|
||
workMode: 'execute'
|
||
},
|
||
new AbortController().signal,
|
||
async () => 'once'
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
expect(fetcher.mock.calls[1]?.[1]?.body as string).toContain(
|
||
imageData
|
||
)
|
||
})
|
||
|
||
it('performs and validates a real image generation when testing an image connection', async () => {
|
||
const png = Buffer.from([
|
||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||
0x00
|
||
]).toString('base64')
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json({ data: [{ b64_json: png }] })
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai/v1',
|
||
model: 'gpt-image-2',
|
||
protocol: 'openai-images-generations',
|
||
authentication: 'api-key',
|
||
imageGenerationQuality: 'medium',
|
||
requestHeaders: { 'x-image-route': 'internal' },
|
||
requestBody: {
|
||
background: 'transparent',
|
||
quality: 'high'
|
||
},
|
||
fetcher
|
||
})
|
||
|
||
await expect(runtime.testConnection()).resolves.toMatchObject({
|
||
available: true,
|
||
capability: 'image-generation',
|
||
detail: expect.stringContaining(
|
||
'已完成真实图像生成测试'
|
||
)
|
||
})
|
||
expect(fetcher).toHaveBeenCalledOnce()
|
||
expect(
|
||
JSON.parse(fetcher.mock.calls[0]?.[1]?.body as string)
|
||
).toMatchObject({
|
||
model: 'gpt-image-2',
|
||
n: 1,
|
||
quality: 'medium',
|
||
background: 'transparent',
|
||
response_format: 'b64_json'
|
||
})
|
||
expect(
|
||
new Headers(fetcher.mock.calls[0]?.[1]?.headers).get(
|
||
'x-image-route'
|
||
)
|
||
).toBe('internal')
|
||
})
|
||
|
||
it('generates a bounded image through the BigToken-compatible endpoint', async () => {
|
||
const png = Buffer.from([
|
||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||
0x00
|
||
]).toString('base64')
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json({
|
||
id: 'image-provider-1',
|
||
model: 'gpt-image-provider',
|
||
usage: {
|
||
input_tokens: 17,
|
||
output_tokens: 29,
|
||
total_tokens: 46
|
||
},
|
||
data: [{ b64_json: png }]
|
||
})
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai/v1',
|
||
model: 'gpt-image-2',
|
||
protocol: 'openai-images-generations',
|
||
authentication: 'api-key',
|
||
imageGenerationQuality: 'high',
|
||
fetcher
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed128',
|
||
conversationId: 'conversation-image',
|
||
prompt: '一只在窗边睡觉的猫'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
const [input, init] = fetcher.mock.calls[0] ?? []
|
||
expect(input?.toString()).toBe(
|
||
'https://bigtoken.ai/v1/images/generations'
|
||
)
|
||
expect(init?.headers).toEqual({
|
||
authorization: 'Bearer test-key',
|
||
'content-type': 'application/json'
|
||
})
|
||
expect(JSON.parse(init?.body as string)).toEqual({
|
||
model: 'gpt-image-2',
|
||
prompt: '一只在窗边睡觉的猫',
|
||
n: 1,
|
||
quality: 'high',
|
||
response_format: 'b64_json'
|
||
})
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'generated-image',
|
||
mimeType: 'image/png',
|
||
data: png
|
||
})
|
||
)
|
||
expect(events.filter((event) => event.type === 'model-usage')).toEqual([
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed128',
|
||
type: 'model-usage',
|
||
callId: expect.stringMatching(
|
||
/^model-call:[0-9a-f]{32}:image-provider-1$/u
|
||
),
|
||
runtime: 'model',
|
||
provider: 'openai',
|
||
model: 'gpt-image-provider',
|
||
inputTokens: 17,
|
||
outputTokens: 29,
|
||
cacheReadTokens: 0,
|
||
cacheWriteTokens: 0,
|
||
reportedTotalTokens: 46
|
||
}
|
||
])
|
||
expect(events.findIndex((event) => event.type === 'model-usage')).toBeLessThan(
|
||
events.findIndex((event) => event.type === 'generated-image')
|
||
)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('edits reference images through the multipart image edits endpoint', async () => {
|
||
const source = Buffer.from([
|
||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||
0x00
|
||
])
|
||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||
Response.json({ data: [{ b64_json: source.toString('base64') }] })
|
||
)
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai/v1',
|
||
model: 'gpt-image-2',
|
||
protocol: 'openai-images-generations',
|
||
authentication: 'api-key',
|
||
imageGenerationQuality: 'medium',
|
||
requestHeaders: { 'x-image-route': 'internal' },
|
||
requestBody: { background: 'transparent' },
|
||
fetcher
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: '把背景改成白色',
|
||
images: [
|
||
{
|
||
name: 'source.png',
|
||
mediaType: 'image/png',
|
||
data: source.toString('base64')
|
||
}
|
||
]
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
const [input, init] = fetcher.mock.calls[0] ?? []
|
||
expect(input?.toString()).toBe(
|
||
'https://bigtoken.ai/v1/images/edits'
|
||
)
|
||
expect(new Headers(init?.headers).get('authorization')).toBe(
|
||
'Bearer test-key'
|
||
)
|
||
expect(new Headers(init?.headers).get('content-type')).toBeNull()
|
||
expect(new Headers(init?.headers).get('x-image-route')).toBe(
|
||
'internal'
|
||
)
|
||
const form = init?.body as FormData
|
||
expect(form.get('model')).toBe('gpt-image-2')
|
||
expect(form.get('prompt')).toBe('把背景改成白色')
|
||
expect(form.get('n')).toBe('1')
|
||
expect(form.get('quality')).toBe('medium')
|
||
expect(form.get('response_format')).toBeNull()
|
||
expect(form.get('background')).toBe('transparent')
|
||
const uploaded = form.get('image')
|
||
expect(uploaded).toBeInstanceOf(Blob)
|
||
expect((uploaded as Blob).type).toBe('image/png')
|
||
expect(Buffer.from(await (uploaded as Blob).arrayBuffer())).toEqual(
|
||
source
|
||
)
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({ type: 'generated-image' })
|
||
)
|
||
expect(events.at(-1)).toMatchObject({ type: 'done' })
|
||
})
|
||
|
||
it('rejects remote image URLs instead of fetching provider output', async () => {
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai/v1',
|
||
model: 'gpt-image-2',
|
||
protocol: 'openai-images-generations',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
Response.json({
|
||
data: [{ url: 'https://untrusted.example/image.png' }]
|
||
})
|
||
)
|
||
})
|
||
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: 'a431666e-5ec8-45e6-beb4-654132eed129',
|
||
conversationId: 'conversation-image-url',
|
||
prompt: '测试图片'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow('未返回 base64 图片')
|
||
})
|
||
|
||
it('accepts a bounded inline image data URL from compatible gateways', async () => {
|
||
const png = Buffer.from([
|
||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||
0x00
|
||
]).toString('base64')
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai/v1',
|
||
model: 'gpt-image-2',
|
||
protocol: 'openai-images-generations',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
Response.json({
|
||
data: [{ url: `data:image/png;base64,${png}` }]
|
||
})
|
||
)
|
||
})
|
||
const events = []
|
||
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: '测试内联图片'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'generated-image',
|
||
mimeType: 'image/png',
|
||
data: png
|
||
})
|
||
)
|
||
})
|
||
|
||
it.each([
|
||
{
|
||
body: JSON.stringify({
|
||
error: {
|
||
message:
|
||
'upstream unavailable Authorization: Bearer secret-token'
|
||
}
|
||
}),
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
'x-request-id': 'image-request-502'
|
||
},
|
||
expected:
|
||
'upstream unavailable Authorization: Bearer secret-token(HTTP 502,请求 ID image-request-502)'
|
||
},
|
||
{
|
||
body: '<html>Bad Gateway</html>',
|
||
headers: { 'content-type': 'text/html' },
|
||
expected: '图像生成请求失败(HTTP 502)'
|
||
},
|
||
{
|
||
body: JSON.stringify({
|
||
error: '模型接口请求失败(HTTP 502)'
|
||
}),
|
||
headers: { 'content-type': 'application/json' },
|
||
expected:
|
||
'上游图像服务暂时不可用,请稍后重试或联系服务商(HTTP 502)'
|
||
}
|
||
])(
|
||
'retains HTTP status for image gateway failures',
|
||
async ({ body, headers, expected }) => {
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey: 'test-key',
|
||
baseUrl: 'https://bigtoken.ai/v1',
|
||
model: 'gpt-image-2',
|
||
protocol: 'openai-images-generations',
|
||
authentication: 'api-key',
|
||
fetcher: vi.fn<typeof fetch>(async () =>
|
||
new Response(body, { status: 502, headers })
|
||
)
|
||
})
|
||
const consume = async (): Promise<void> => {
|
||
for await (const _event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt: '测试网关错误'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
void _event
|
||
}
|
||
}
|
||
|
||
await expect(consume()).rejects.toThrow(expected)
|
||
}
|
||
)
|
||
|
||
it.runIf(
|
||
process.env.GOODBUDDY_BIGTOKEN_IMAGE_INTEGRATION === '1'
|
||
)(
|
||
'generates a real synthetic image with BigToken gpt-image-2',
|
||
async () => {
|
||
const apiKey = process.env.GOODBUDDY_BIGTOKEN_API_KEY
|
||
if (!apiKey) {
|
||
throw new Error('GOODBUDDY_BIGTOKEN_API_KEY is required')
|
||
}
|
||
const runtime = new ModelAgentRuntime({
|
||
apiKey,
|
||
baseUrl: 'https://bigtoken.ai/v1',
|
||
model: 'gpt-image-2',
|
||
protocol: 'openai-images-generations',
|
||
authentication: 'api-key'
|
||
})
|
||
const events = []
|
||
for await (const event of runtime.run(
|
||
{
|
||
requestId: crypto.randomUUID(),
|
||
conversationId: crypto.randomUUID(),
|
||
prompt:
|
||
'A simple solid blue circle centered on a plain white background.'
|
||
},
|
||
new AbortController().signal
|
||
)) {
|
||
events.push(event)
|
||
}
|
||
expect(events).toContainEqual(
|
||
expect.objectContaining({
|
||
type: 'generated-image',
|
||
mimeType: expect.stringMatching(/^image\//u)
|
||
})
|
||
)
|
||
await runtime.dispose()
|
||
},
|
||
180_000
|
||
)
|
||
})
|