fix: bound model streaming
This commit is contained in:
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
writeFile
|
writeFile
|
||||||
} from 'node:fs/promises'
|
} from 'node:fs/promises'
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import { createHash } from 'node:crypto'
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
@@ -165,8 +165,14 @@ describe('ContinueHostAdapter', () => {
|
|||||||
'let r=[eS.join(hu.continueHome,AKt)],o='
|
'let r=[eS.join(hu.continueHome,AKt)],o='
|
||||||
)
|
)
|
||||||
expect(bundle).toContain('goodbuddyEvents:[]')
|
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('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('onToolStart?.(c.name,c.arguments,c.id)')
|
||||||
expect(bundle).toContain(
|
expect(bundle).toContain(
|
||||||
'function ZZo(e){let t=[];if(e.allow)'
|
'function ZZo(e){let t=[];if(e.allow)'
|
||||||
@@ -994,6 +1000,57 @@ describe('ContinueHostAdapter', () => {
|
|||||||
expect(killed).toBe(true)
|
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 () => {
|
it('uses auto mode and returns audit metadata for agent tools', async () => {
|
||||||
const distribution = await createDistribution()
|
const distribution = await createDistribution()
|
||||||
let launchArgs: string[] = []
|
let launchArgs: string[] = []
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
safeToolErrorDetail
|
safeToolErrorDetail
|
||||||
} from './approval-summary'
|
} from './approval-summary'
|
||||||
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
|
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
|
||||||
|
import { readBoundedResponseText } from './bounded-response'
|
||||||
|
|
||||||
const supportedVersion = '1.5.47'
|
const supportedVersion = '1.5.47'
|
||||||
const supportedBundleHashes = new Set([
|
const supportedBundleHashes = new Set([
|
||||||
@@ -49,6 +50,8 @@ const maximumMessageBytes = 20 * 1024 * 1024
|
|||||||
const maximumConfigBytes = 1024 * 1024
|
const maximumConfigBytes = 1024 * 1024
|
||||||
const maximumConfiguredMcpServers = 100
|
const maximumConfiguredMcpServers = 100
|
||||||
const maximumStreamEvents = 5_000
|
const maximumStreamEvents = 5_000
|
||||||
|
const maximumStreamEventBytes = 2 * 1024 * 1024
|
||||||
|
const maximumExecutionMilliseconds = 10 * 60_000
|
||||||
const knowledgeMcpName = 'goodbuddy-knowledge'
|
const knowledgeMcpName = 'goodbuddy-knowledge'
|
||||||
export const continueConfigurationRequiredMessage =
|
export const continueConfigurationRequiredMessage =
|
||||||
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
|
'Continue 尚未配置模型连接,请在设置中选择 GoodBuddy 模型连接或指定 Continue 配置文件'
|
||||||
@@ -116,7 +119,8 @@ const stateSchema = z.object({
|
|||||||
goodbuddyEvents: z
|
goodbuddyEvents: z
|
||||||
.array(continueHostStreamEventSchema)
|
.array(continueHostStreamEventSchema)
|
||||||
.max(maximumStreamEvents)
|
.max(maximumStreamEvents)
|
||||||
.optional()
|
.optional(),
|
||||||
|
goodbuddyEventsOverflow: z.boolean().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
type ContinueHostState = z.infer<typeof stateSchema>
|
type ContinueHostState = z.infer<typeof stateSchema>
|
||||||
@@ -704,17 +708,17 @@ export class ContinueHostAdapter {
|
|||||||
patched = replaceExactly(
|
patched = replaceExactly(
|
||||||
patched,
|
patched,
|
||||||
streamCallbacksMarker,
|
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 = replaceExactly(
|
||||||
patched,
|
patched,
|
||||||
serverStateMarker,
|
serverStateMarker,
|
||||||
'pendingPermission:null,goodbuddyEvents:[]},B='
|
'pendingPermission:null,goodbuddyEvents:[],goodbuddyEventsBytes:0,goodbuddyEventsOverflow:!1},B='
|
||||||
)
|
)
|
||||||
patched = replaceExactly(
|
patched = replaceExactly(
|
||||||
patched,
|
patched,
|
||||||
serverStateEndpointMarker,
|
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 = replaceExactly(
|
||||||
patched,
|
patched,
|
||||||
@@ -839,14 +843,10 @@ export class ContinueHostAdapter {
|
|||||||
redirect: 'error',
|
redirect: 'error',
|
||||||
signal: init.signal
|
signal: init.signal
|
||||||
})
|
})
|
||||||
const contentLength = Number(response.headers.get('content-length') ?? 0)
|
const body = await readBoundedResponseText(response, {
|
||||||
if (contentLength > maximumStateBytes) {
|
maxBytes: maximumStateBytes,
|
||||||
throw new Error('Continue 宿主响应超过安全大小限制')
|
tooLargeMessage: 'Continue 宿主响应超过安全大小限制'
|
||||||
}
|
})
|
||||||
const body = await response.text()
|
|
||||||
if (Buffer.byteLength(body) > maximumStateBytes) {
|
|
||||||
throw new Error('Continue 宿主响应超过安全大小限制')
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Continue 宿主请求失败(HTTP ${response.status})`)
|
throw new Error(`Continue 宿主请求失败(HTTP ${response.status})`)
|
||||||
}
|
}
|
||||||
@@ -861,22 +861,33 @@ export class ContinueHostAdapter {
|
|||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
): Promise<ContinueHostState> {
|
): Promise<ContinueHostState> {
|
||||||
const expiresAt = Date.now() + 30_000
|
const expiresAt = Date.now() + 30_000
|
||||||
while (Date.now() < expiresAt) {
|
const timeoutSignal = AbortSignal.timeout(30_000)
|
||||||
signal.throwIfAborted()
|
const startupSignal = AbortSignal.any([signal, timeoutSignal])
|
||||||
const childFailure = getChildFailure()
|
try {
|
||||||
if (childFailure) {
|
while (Date.now() < expiresAt) {
|
||||||
throw childFailure
|
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) {
|
} catch (error) {
|
||||||
throw new Error('Continue 宿主在启动期间退出')
|
if (timeoutSignal.aborted && !signal.aborted) {
|
||||||
}
|
throw new Error('Continue 宿主启动超时', { cause: error })
|
||||||
try {
|
|
||||||
return stateSchema.parse(
|
|
||||||
await this.request(origin, token, '/state', { signal })
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
await delay(150, signal)
|
|
||||||
}
|
}
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
throw new Error('Continue 宿主启动超时')
|
throw new Error('Continue 宿主启动超时')
|
||||||
}
|
}
|
||||||
@@ -1150,6 +1161,7 @@ export class ContinueHostAdapter {
|
|||||||
|
|
||||||
let observedTools: ContinueHostTool[] = []
|
let observedTools: ContinueHostTool[] = []
|
||||||
let streamedText = false
|
let streamedText = false
|
||||||
|
let executionTimeoutSignal: AbortSignal | undefined
|
||||||
try {
|
try {
|
||||||
const initialState = await this.waitForStartup(
|
const initialState = await this.waitForStartup(
|
||||||
child,
|
child,
|
||||||
@@ -1159,6 +1171,13 @@ export class ContinueHostAdapter {
|
|||||||
signal
|
signal
|
||||||
)
|
)
|
||||||
const startIndex = initialState.session.history.length
|
const startIndex = initialState.session.history.length
|
||||||
|
executionTimeoutSignal = AbortSignal.timeout(
|
||||||
|
maximumExecutionMilliseconds
|
||||||
|
)
|
||||||
|
const executionSignal = AbortSignal.any([
|
||||||
|
signal,
|
||||||
|
executionTimeoutSignal
|
||||||
|
])
|
||||||
const message =
|
const message =
|
||||||
runOptions.images && runOptions.images.length > 0
|
runOptions.images && runOptions.images.length > 0
|
||||||
? [
|
? [
|
||||||
@@ -1178,13 +1197,13 @@ export class ContinueHostAdapter {
|
|||||||
await this.request(origin, token, '/message', {
|
await this.request(origin, token, '/message', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: messageBody,
|
body: messageBody,
|
||||||
signal
|
signal: executionSignal
|
||||||
})
|
})
|
||||||
|
|
||||||
const expiresAt = Date.now() + 10 * 60_000
|
const expiresAt = Date.now() + maximumExecutionMilliseconds
|
||||||
const handledPermissionIds = new Set<string>()
|
const handledPermissionIds = new Set<string>()
|
||||||
while (Date.now() < expiresAt) {
|
while (Date.now() < expiresAt) {
|
||||||
signal.throwIfAborted()
|
executionSignal.throwIfAborted()
|
||||||
if (childFailure) {
|
if (childFailure) {
|
||||||
throw childFailure
|
throw childFailure
|
||||||
}
|
}
|
||||||
@@ -1194,8 +1213,19 @@ export class ContinueHostAdapter {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const state = stateSchema.parse(
|
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 = mergeContinueTools(
|
||||||
observedTools,
|
observedTools,
|
||||||
extractContinueTools(state.session.history, startIndex)
|
extractContinueTools(state.session.history, startIndex)
|
||||||
@@ -1265,7 +1295,7 @@ export class ContinueHostAdapter {
|
|||||||
requestId: pending.requestId,
|
requestId: pending.requestId,
|
||||||
approved: decision !== 'deny'
|
approved: decision !== 'deny'
|
||||||
}),
|
}),
|
||||||
signal
|
signal: executionSignal
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -1308,16 +1338,22 @@ export class ContinueHostAdapter {
|
|||||||
: {})
|
: {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await delay(150, signal)
|
await delay(150, executionSignal)
|
||||||
}
|
}
|
||||||
throw new Error('Continue 宿主执行超时')
|
throw new Error('Continue 宿主执行超时')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ContinueHostRunError) {
|
if (error instanceof ContinueHostRunError) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
const normalizedError =
|
||||||
|
executionTimeoutSignal?.aborted && !signal.aborted
|
||||||
|
? new Error('Continue 宿主执行超时', { cause: error })
|
||||||
|
: error
|
||||||
throw new ContinueHostRunError(
|
throw new ContinueHostRunError(
|
||||||
error instanceof Error ? error.message : 'Continue 宿主执行失败',
|
normalizedError instanceof Error
|
||||||
{ cause: error, tools: observedTools }
|
? normalizedError.message
|
||||||
|
: 'Continue 宿主执行失败',
|
||||||
|
{ cause: normalizedError, tools: observedTools }
|
||||||
)
|
)
|
||||||
} finally {
|
} finally {
|
||||||
signal.removeEventListener('abort', abort)
|
signal.removeEventListener('abort', abort)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import type { RuntimeEvent } from './runtime'
|
import type { RuntimeEvent } from './runtime'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
import {
|
import {
|
||||||
ContinueHostRunError,
|
ContinueHostRunError,
|
||||||
type ContinueHostAdapterOptions
|
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 () => {
|
it('emits terminal tool audits before a failed Continue run', async () => {
|
||||||
mocks.runHost.mockRejectedValue(
|
mocks.runHost.mockRejectedValue(
|
||||||
new ContinueHostRunError('Continue failed', {
|
new ContinueHostRunError('Continue failed', {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export type ContinueRuntimeOptions = {
|
|||||||
// The prompt reaches the Continue host through a local HTTP POST body, so no
|
// The prompt reaches the Continue host through a local HTTP POST body, so no
|
||||||
// platform command-line limit applies to it.
|
// platform command-line limit applies to it.
|
||||||
const MAX_CONTINUE_PROMPT_CHARACTERS = 128_000
|
const MAX_CONTINUE_PROMPT_CHARACTERS = 128_000
|
||||||
|
const MAX_QUEUED_STREAM_EVENTS = 1_000
|
||||||
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
|
const scopedReadToolNameSet = new Set<string>(scopedReadToolNames)
|
||||||
|
|
||||||
function continueToolFailureMessage(tool: ContinueHostTool): string {
|
function continueToolFailureMessage(tool: ContinueHostTool): string {
|
||||||
@@ -331,7 +332,12 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
|||||||
let streamFinished = false
|
let streamFinished = false
|
||||||
let streamResult: ContinueHostRunResult | undefined
|
let streamResult: ContinueHostRunResult | undefined
|
||||||
let streamError: unknown
|
let streamError: unknown
|
||||||
|
const hostController = new AbortController()
|
||||||
|
const hostSignal = AbortSignal.any([signal, hostController.signal])
|
||||||
const onEvent = (event: ContinueHostStreamEvent): void => {
|
const onEvent = (event: ContinueHostStreamEvent): void => {
|
||||||
|
if (queuedEvents.length >= MAX_QUEUED_STREAM_EVENTS) {
|
||||||
|
throw new Error('Continue 流式事件积压超过安全限制')
|
||||||
|
}
|
||||||
queuedEvents.push(event)
|
queuedEvents.push(event)
|
||||||
wakeStream?.()
|
wakeStream?.()
|
||||||
wakeStream = undefined
|
wakeStream = undefined
|
||||||
@@ -339,7 +345,7 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
|||||||
const hostRun = host
|
const hostRun = host
|
||||||
.run(
|
.run(
|
||||||
conversationContext,
|
conversationContext,
|
||||||
signal,
|
hostSignal,
|
||||||
authorize,
|
authorize,
|
||||||
{
|
{
|
||||||
workMode: request.workMode,
|
workMode: request.workMode,
|
||||||
@@ -361,31 +367,36 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
|||||||
wakeStream?.()
|
wakeStream?.()
|
||||||
wakeStream = undefined
|
wakeStream = undefined
|
||||||
})
|
})
|
||||||
|
try {
|
||||||
while (!streamFinished || queuedEvents.length > 0) {
|
while (!streamFinished || queuedEvents.length > 0) {
|
||||||
if (queuedEvents.length === 0) {
|
if (queuedEvents.length === 0) {
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
wakeStream = resolve
|
wakeStream = resolve
|
||||||
})
|
})
|
||||||
continue
|
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()!
|
} finally {
|
||||||
if (event.type === 'tool') {
|
hostController.abort(new Error('Continue 流式消费已结束'))
|
||||||
emittedTools.set(event.tool.callId, event.tool)
|
wakeStream?.()
|
||||||
}
|
wakeStream = undefined
|
||||||
yield event.type === 'text'
|
await hostRun
|
||||||
? {
|
|
||||||
requestId: request.requestId,
|
|
||||||
type: 'text',
|
|
||||||
delta: event.delta
|
|
||||||
}
|
|
||||||
: toContinueToolEvent(
|
|
||||||
request.requestId,
|
|
||||||
event.tool,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
await hostRun
|
|
||||||
if (streamError) {
|
if (streamError) {
|
||||||
throw streamError
|
throw streamError
|
||||||
}
|
}
|
||||||
@@ -396,7 +407,19 @@ export class ContinueAgentRuntime implements AgentRuntime {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ContinueHostRunError) {
|
if (error instanceof ContinueHostRunError) {
|
||||||
for (const tool of error.tools) {
|
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
|
throw error
|
||||||
|
|||||||
@@ -321,6 +321,228 @@ describe('ModelAgentRuntime', () => {
|
|||||||
await expect(consume()).rejects.toThrow('意外中断')
|
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 () => {
|
it('preserves bounded provider error messages', async () => {
|
||||||
const runtime = new ModelAgentRuntime({
|
const runtime = new ModelAgentRuntime({
|
||||||
apiKey: 'test-key',
|
apiKey: 'test-key',
|
||||||
|
|||||||
+495
-414
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user