fix: bound model streaming

This commit is contained in:
lofyer
2026-08-13 03:18:55 +08:00
parent 980f3a0c8f
commit 5b579ae100
8 changed files with 1081 additions and 475 deletions
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { readBoundedResponseText } from './bounded-response'
describe('readBoundedResponseText', () => {
it('cancels an oversized response as soon as it crosses the byte limit', async () => {
const chunk = new Uint8Array(1024 * 1024)
let pulls = 0
const response = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1
controller.enqueue(chunk)
}
})
)
await expect(
readBoundedResponseText(response, {
maxBytes: 8 * 1024 * 1024,
tooLargeMessage: 'response too large'
})
).rejects.toThrow('response too large')
expect(pulls).toBeLessThan(20)
})
it('rejects an invalid declared response length without reading the body', async () => {
let pulls = 0
const response = new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1
controller.enqueue(new Uint8Array([1]))
}
}),
{
headers: { 'content-length': 'invalid' }
}
)
await expect(
readBoundedResponseText(response, {
maxBytes: 1024,
tooLargeMessage: 'response too large'
})
).rejects.toThrow('response too large')
expect(pulls).toBe(0)
})
})
+54
View File
@@ -0,0 +1,54 @@
export type BoundedResponseTextOptions = {
maxBytes: number
missingBodyMessage?: string
tooLargeMessage: string
}
export async function readBoundedResponseText(
response: Response,
options: BoundedResponseTextOptions
): Promise<string> {
const declaredLength = response.headers.get('content-length')
if (declaredLength !== null) {
const parsedLength = Number(declaredLength)
if (
!Number.isSafeInteger(parsedLength) ||
parsedLength < 0 ||
parsedLength > options.maxBytes
) {
await response.body?.cancel().catch(() => undefined)
throw new Error(options.tooLargeMessage)
}
}
if (!response.body) {
if (options.missingBodyMessage) {
throw new Error(options.missingBodyMessage)
}
return ''
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let completed = false
let total = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
completed = true
break
}
total += value.byteLength
if (total > options.maxBytes) {
throw new Error(options.tooLargeMessage)
}
chunks.push(value)
}
} finally {
if (!completed) {
await reader.cancel().catch(() => undefined)
}
reader.releaseLock()
}
return Buffer.concat(chunks, total).toString('utf8')
}
+59 -2
View File
@@ -7,7 +7,7 @@ import {
writeFile
} from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { createHash, randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -165,8 +165,14 @@ describe('ContinueHostAdapter', () => {
'let r=[eS.join(hu.continueHome,AKt)],o='
)
expect(bundle).toContain('goodbuddyEvents:[]')
expect(bundle).toContain('goodbuddyEventsBytes:0')
expect(bundle).toContain('goodbuddyEventsBytes+=Buffer.byteLength')
expect(bundle).toContain('goodbuddyEventsBytes<=2097152')
expect(bundle).toContain('l.length<=1e5')
expect(bundle).toContain('goodbuddyEventsOverflow:!1')
expect(bundle).toContain('goodbuddyEventsOverflow=!0')
expect(bundle).toContain('goodbuddyEvents:ce')
expect(bundle).toContain('type:"text",delta:u')
expect(bundle).toContain('type:"text",delta:l')
expect(bundle).toContain('onToolStart?.(c.name,c.arguments,c.id)')
expect(bundle).toContain(
'function ZZo(e){let t=[];if(e.allow)'
@@ -994,6 +1000,57 @@ describe('ContinueHostAdapter', () => {
expect(killed).toBe(true)
})
it('fails when the patched host reports dropped stream events', async () => {
const distribution = await createDistribution()
let stateRequests = 0
vi.stubGlobal(
'fetch',
vi.fn(async (input: string | URL | Request) => {
if (String(input).endsWith('/state')) {
stateRequests += 1
return Response.json({
session: { history: [] },
isProcessing: stateRequests > 1,
messageQueueLength: 0,
pendingPermission: null,
goodbuddyEventsOverflow: stateRequests > 1
})
}
return Response.json({})
})
)
const adapter = new ContinueHostAdapter({
binaryPath: distribution.entryPath,
configPath: '',
workspace: process.cwd(),
cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
launchHost: () => ({
exitCode: null,
killed: false,
stderr: null,
once: () => undefined,
kill: () => true
}),
modelProfile: {
id: randomUUID(),
name: 'Local model',
baseUrl: 'http://127.0.0.1:11434/v1',
modelName: 'qwen3',
protocol: 'openai-chat-completions',
authentication: 'none'
}
})
await expect(
adapter.run(
'hello',
new AbortController().signal,
async () => 'deny'
)
).rejects.toThrow('流式事件超过安全限制')
})
it('uses auto mode and returns audit metadata for agent tools', async () => {
const distribution = await createDistribution()
let launchArgs: string[] = []
+70 -34
View File
@@ -38,6 +38,7 @@ import {
safeToolErrorDetail
} from './approval-summary'
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import { readBoundedResponseText } from './bounded-response'
const supportedVersion = '1.5.47'
const supportedBundleHashes = new Set([
@@ -49,6 +50,8 @@ const maximumMessageBytes = 20 * 1024 * 1024
const maximumConfigBytes = 1024 * 1024
const maximumConfiguredMcpServers = 100
const maximumStreamEvents = 5_000
const maximumStreamEventBytes = 2 * 1024 * 1024
const maximumExecutionMilliseconds = 10 * 60_000
const knowledgeMcpName = 'goodbuddy-knowledge'
export const continueConfigurationRequiredMessage =
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
@@ -116,7 +119,8 @@ const stateSchema = z.object({
goodbuddyEvents: z
.array(continueHostStreamEventSchema)
.max(maximumStreamEvents)
.optional()
.optional(),
goodbuddyEventsOverflow: z.boolean().optional()
})
type ContinueHostState = z.infer<typeof stateSchema>
@@ -704,17 +708,17 @@ export class ContinueHostAdapter {
patched = replaceExactly(
patched,
streamCallbacksMarker,
'a={onContent:u=>{u&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"text",delta:u})},onContentComplete:u=>{},onToolStart:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:u,state:"running",input:(()=>{try{return JSON.stringify(l).slice(0,4e3)}catch{return"[无法序列化]"}})()})},onToolResult:(u,l,c,d)=>{d&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed",output:String(u).slice(0,16e3)})},onToolError:(u,l,c)=>{c&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEvents.push({type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)})},onToolPermissionRequest:'
'a={onContent:u=>{if(!u)return;let l=String(u);e.goodbuddyEventsBytes+=Buffer.byteLength(l);l.length<=1e5&&e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push({type:"text",delta:l}):e.goodbuddyEventsOverflow=!0},onContentComplete:u=>{},onToolStart:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:u,state:"running",input:(()=>{try{return JSON.stringify(l).slice(0,4e3)}catch{return"[无法序列化]"}})()};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolResult:(u,l,c,d)=>{if(!d)return;let p={type:"tool",callId:d,name:l,state:c==="done"?"completed":"failed",output:String(u).slice(0,16e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(p));e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(p):e.goodbuddyEventsOverflow=!0},onToolError:(u,l,c)=>{if(!c)return;let d={type:"tool",callId:c,name:l??"unknown",state:"failed",error:String(u).slice(0,1e3)};e.goodbuddyEventsBytes+=Buffer.byteLength(JSON.stringify(d));e.goodbuddyEvents.length<5e3&&e.goodbuddyEventsBytes<=2097152?e.goodbuddyEvents.push(d):e.goodbuddyEventsOverflow=!0},onToolPermissionRequest:'
)
patched = replaceExactly(
patched,
serverStateMarker,
'pendingPermission:null,goodbuddyEvents:[]},B='
'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B='
)
patched = replaceExactly(
patched,
serverStateEndpointMarker,
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission),ce=M.goodbuddyEvents.splice(0);Te.json({...ue,goodbuddyEvents:ce})})'
'j.get("/state",(we,Te)=>{M.lastActivity=Date.now(),B();let ue=e7e(M.session,M.isProcessing,rS.getQueueLength(),M.pendingPermission),ce=M.goodbuddyEvents.splice(0),de=M.goodbuddyEventsOverflow;M.goodbuddyEventsBytes=0,M.goodbuddyEventsOverflow=!1;Te.json({...ue,goodbuddyEvents:ce,goodbuddyEventsOverflow:de})})'
)
patched = replaceExactly(
patched,
@@ -839,14 +843,10 @@ export class ContinueHostAdapter {
redirect: 'error',
signal: init.signal
})
const contentLength = Number(response.headers.get('content-length') ?? 0)
if (contentLength > maximumStateBytes) {
throw new Error('Continue 宿主响应超过安全大小限制')
}
const body = await response.text()
if (Buffer.byteLength(body) > maximumStateBytes) {
throw new Error('Continue 宿主响应超过安全大小限制')
}
const body = await readBoundedResponseText(response, {
maxBytes: maximumStateBytes,
tooLargeMessage: 'Continue 宿主响应超过安全大小限制'
})
if (!response.ok) {
throw new Error(`Continue 宿主请求失败(HTTP ${response.status}`)
}
@@ -861,22 +861,33 @@ export class ContinueHostAdapter {
signal: AbortSignal
): Promise<ContinueHostState> {
const expiresAt = Date.now() + 30_000
while (Date.now() < expiresAt) {
signal.throwIfAborted()
const childFailure = getChildFailure()
if (childFailure) {
throw childFailure
const timeoutSignal = AbortSignal.timeout(30_000)
const startupSignal = AbortSignal.any([signal, timeoutSignal])
try {
while (Date.now() < expiresAt) {
startupSignal.throwIfAborted()
const childFailure = getChildFailure()
if (childFailure) {
throw childFailure
}
if (child.exitCode !== null) {
throw new Error('Continue 宿主在启动期间退出')
}
try {
return stateSchema.parse(
await this.request(origin, token, '/state', {
signal: startupSignal
})
)
} catch {
await delay(150, startupSignal)
}
}
if (child.exitCode !== null) {
throw new Error('Continue 宿主在启动期间退出')
}
try {
return stateSchema.parse(
await this.request(origin, token, '/state', { signal })
)
} catch {
await delay(150, signal)
} catch (error) {
if (timeoutSignal.aborted && !signal.aborted) {
throw new Error('Continue 宿主启动超时', { cause: error })
}
throw error
}
throw new Error('Continue 宿主启动超时')
}
@@ -1150,6 +1161,7 @@ export class ContinueHostAdapter {
let observedTools: ContinueHostTool[] = []
let streamedText = false
let executionTimeoutSignal: AbortSignal | undefined
try {
const initialState = await this.waitForStartup(
child,
@@ -1159,6 +1171,13 @@ export class ContinueHostAdapter {
signal
)
const startIndex = initialState.session.history.length
executionTimeoutSignal = AbortSignal.timeout(
maximumExecutionMilliseconds
)
const executionSignal = AbortSignal.any([
signal,
executionTimeoutSignal
])
const message =
runOptions.images && runOptions.images.length > 0
? [
@@ -1178,13 +1197,13 @@ export class ContinueHostAdapter {
await this.request(origin, token, '/message', {
method: 'POST',
body: messageBody,
signal
signal: executionSignal
})
const expiresAt = Date.now() + 10 * 60_000
const expiresAt = Date.now() + maximumExecutionMilliseconds
const handledPermissionIds = new Set<string>()
while (Date.now() < expiresAt) {
signal.throwIfAborted()
executionSignal.throwIfAborted()
if (childFailure) {
throw childFailure
}
@@ -1194,8 +1213,19 @@ export class ContinueHostAdapter {
)
}
const state = stateSchema.parse(
await this.request(origin, token, '/state', { signal })
await this.request(origin, token, '/state', {
signal: executionSignal
})
)
if (state.goodbuddyEventsOverflow) {
throw new Error('Continue 宿主流式事件超过安全限制')
}
const streamEventBytes = Buffer.byteLength(
JSON.stringify(state.goodbuddyEvents ?? [])
)
if (streamEventBytes > maximumStreamEventBytes) {
throw new Error('Continue 宿主流式事件超过安全限制')
}
observedTools = mergeContinueTools(
observedTools,
extractContinueTools(state.session.history, startIndex)
@@ -1265,7 +1295,7 @@ export class ContinueHostAdapter {
requestId: pending.requestId,
approved: decision !== 'deny'
}),
signal
signal: executionSignal
})
}
if (
@@ -1308,16 +1338,22 @@ export class ContinueHostAdapter {
: {})
}
}
await delay(150, signal)
await delay(150, executionSignal)
}
throw new Error('Continue 宿主执行超时')
} catch (error) {
if (error instanceof ContinueHostRunError) {
throw error
}
const normalizedError =
executionTimeoutSignal?.aborted && !signal.aborted
? new Error('Continue 宿主执行超时', { cause: error })
: error
throw new ContinueHostRunError(
error instanceof Error ? error.message : 'Continue 宿主执行失败',
{ cause: error, tools: observedTools }
normalizedError instanceof Error
? normalizedError.message
: 'Continue 宿主执行失败',
{ cause: normalizedError, tools: observedTools }
)
} finally {
signal.removeEventListener('abort', abort)
+85
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeEvent } from './runtime'
import { randomUUID } from 'node:crypto'
import {
ContinueHostRunError,
type ContinueHostAdapterOptions
@@ -632,6 +633,90 @@ describe('ContinueAgentRuntime', () => {
])
})
it('fails instead of silently dropping an overflowing stream queue', async () => {
mocks.runHost.mockImplementation(
async (
_prompt,
_signal,
_authorize,
options
) => {
for (let index = 0; index < 1_001; index += 1) {
options?.onEvent?.({
type: 'text',
delta: String(index)
})
}
return { text: 'done', streamedText: true }
}
)
const stream = createRuntime().run(
{
requestId: randomUUID(),
conversationId: 'overflow-conversation',
prompt: 'test'
},
new AbortController().signal
)
await expect(async () => {
for await (const _event of stream) {
void _event
}
}).rejects.toThrow('流式事件积压超过安全限制')
})
it('aborts the host run when stream consumption ends early', async () => {
let resolveHost: (() => void) | undefined
const hostFinished = new Promise<void>((resolve) => {
resolveHost = resolve
})
let hostSignal: AbortSignal | undefined
mocks.runHost.mockImplementation(
async (
_prompt,
signal,
_authorize,
options
) => {
hostSignal = signal
await options?.onEvent?.({
type: 'text',
delta: 'partial'
})
await new Promise<void>((resolve) => {
signal.addEventListener(
'abort',
() => {
resolve()
resolveHost?.()
},
{ once: true }
)
})
throw signal.reason
}
)
const stream = createRuntime().run(
{
requestId: randomUUID(),
conversationId: 'early-close-conversation',
prompt: 'test'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'text', delta: 'partial' }
})
await stream.return()
await hostFinished
expect(hostSignal?.aborted).toBe(true)
})
it('emits terminal tool audits before a failed Continue run', async () => {
mocks.runHost.mockRejectedValue(
new ContinueHostRunError('Continue failed', {
+48 -25
View File
@@ -51,6 +51,7 @@ export type ContinueRuntimeOptions = {
// The prompt reaches the Continue host through a local HTTP POST body, so no
// platform command-line limit applies to it.
const MAX_CONTINUE_PROMPT_CHARACTERS = 128_000
const MAX_QUEUED_STREAM_EVENTS = 1_000
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
function continueToolFailureMessage(tool: ContinueHostTool): string {
@@ -331,7 +332,12 @@ export class ContinueAgentRuntime implements AgentRuntime {
let streamFinished = false
let streamResult: ContinueHostRunResult | undefined
let streamError: unknown
const hostController = new AbortController()
const hostSignal = AbortSignal.any([signal, hostController.signal])
const onEvent = (event: ContinueHostStreamEvent): void => {
if (queuedEvents.length >= MAX_QUEUED_STREAM_EVENTS) {
throw new Error('Continue 流式事件积压超过安全限制')
}
queuedEvents.push(event)
wakeStream?.()
wakeStream = undefined
@@ -339,7 +345,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
const hostRun = host
.run(
conversationContext,
signal,
hostSignal,
authorize,
{
workMode: request.workMode,
@@ -361,31 +367,36 @@ export class ContinueAgentRuntime implements AgentRuntime {
wakeStream?.()
wakeStream = undefined
})
while (!streamFinished || queuedEvents.length > 0) {
if (queuedEvents.length === 0) {
await new Promise<void>((resolve) => {
wakeStream = resolve
})
continue
try {
while (!streamFinished || queuedEvents.length > 0) {
if (queuedEvents.length === 0) {
await new Promise<void>((resolve) => {
wakeStream = resolve
})
continue
}
const event = queuedEvents.shift()!
if (event.type === 'tool') {
emittedTools.set(event.tool.callId, event.tool)
}
yield event.type === 'text'
? {
requestId: request.requestId,
type: 'text',
delta: event.delta
}
: toContinueToolEvent(
request.requestId,
event.tool,
false
)
}
const event = queuedEvents.shift()!
if (event.type === 'tool') {
emittedTools.set(event.tool.callId, event.tool)
}
yield event.type === 'text'
? {
requestId: request.requestId,
type: 'text',
delta: event.delta
}
: toContinueToolEvent(
request.requestId,
event.tool,
false
)
} finally {
hostController.abort(new Error('Continue 流式消费已结束'))
wakeStream?.()
wakeStream = undefined
await hostRun
}
await hostRun
if (streamError) {
throw streamError
}
@@ -396,7 +407,19 @@ export class ContinueAgentRuntime implements AgentRuntime {
} catch (error) {
if (error instanceof ContinueHostRunError) {
for (const tool of error.tools) {
yield toContinueToolEvent(request.requestId, tool, true)
const terminalEvent = toContinueToolEvent(
request.requestId,
tool,
true
)
const previous = emittedTools.get(tool.callId)
if (
!previous ||
previous.state !== tool.state ||
previous.error !== tool.error
) {
yield terminalEvent
}
}
}
throw error
+222
View File
@@ -321,6 +321,228 @@ describe('ModelAgentRuntime', () => {
await expect(consume()).rejects.toThrow('意外中断')
})
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('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 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) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason),
{ once: true }
)
})
)
})
const stream = runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
const result = stream.next()
const assertion = expect(result).rejects.toThrow(
'模型接口请求超时'
)
await vi.advanceTimersByTimeAsync(50)
await assertion
} 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 stream = runtime.run(
{
requestId: crypto.randomUUID(),
conversationId: crypto.randomUUID(),
prompt: 'test'
},
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
const result = stream.next()
const assertion = expect(result).rejects.toThrow(
'模型接口请求超时'
)
await vi.advanceTimersByTimeAsync(50)
await assertion
expect(responseSignal?.aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('bounds the total ordinary streaming response size', async () => {
const chunk = new TextEncoder().encode(
`data: ${JSON.stringify({
type: 'content_block_delta',
delta: {
type: 'text_delta',
text: 'x'.repeat(65_000)
}
})}\n\n`
)
const body = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(chunk)
}
})
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 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(
'流式响应超过安全限制'
)
})
it('preserves bounded provider error messages', async () => {
const runtime = new ModelAgentRuntime({
apiKey: 'test-key',
File diff suppressed because it is too large Load Diff