91 lines
5.1 KiB
TypeScript
91 lines
5.1 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { EventEmitter } from 'node:events'
|
|
import { runOmpTurn } from '../src/main/agent/omp-turn'
|
|
import type { OmpKernel } from '../src/main/omp'
|
|
import type { ChatEvent } from '../src/shared/types'
|
|
|
|
class Kernel extends EventEmitter {
|
|
abortCalls = 0
|
|
disposeCalls = 0
|
|
rejectAbort = false
|
|
async prompt(): Promise<{ type: string; success: boolean }> { return { type: 'response', success: true } }
|
|
async abort(): Promise<void> { this.abortCalls++; if (this.rejectAbort) throw new Error('abort unavailable') }
|
|
async dispose(): Promise<void> { this.disposeCalls++ }
|
|
}
|
|
function begin(kernel = new Kernel(), controller = new AbortController()) {
|
|
const events: ChatEvent[] = []
|
|
const outcome = runOmpTurn({ kernel: kernel as unknown as OmpKernel, runId: 'provider-error',
|
|
userText: 'fixture', signal: controller.signal, emit: event => events.push(event) })
|
|
.then(value => ({ value, error: undefined }), (error: Error) => ({ value: undefined, error }))
|
|
return { kernel, controller, events, outcome }
|
|
}
|
|
const errorMessage = { role: 'assistant', stopReason: 'error', content: [], errorMessage: 'Unable to connect. Is the computer able to access the url?' }
|
|
const emit = (kernel: Kernel, frame: Record<string, unknown>): void => { kernel.emit('event', frame) }
|
|
const delay = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 15))
|
|
|
|
{
|
|
const test = begin()
|
|
emit(test.kernel, { type: 'message_end', message: errorMessage })
|
|
const result = await test.outcome
|
|
assert.equal(result.error?.message, errorMessage.errorMessage)
|
|
assert.equal(test.kernel.abortCalls, 1)
|
|
assert.equal(test.events.filter(event => event.type === 'error').length, 1)
|
|
emit(test.kernel, { type: 'agent_end', yielded: false, messages: [errorMessage] })
|
|
emit(test.kernel, { type: 'agent_end', yielded: true })
|
|
assert.equal(test.events.filter(event => ['done', 'cancelled'].includes(event.type)).length, 0)
|
|
assert.equal(test.kernel.listenerCount('event'), 0)
|
|
assert.equal(test.kernel.listenerCount('error'), 0)
|
|
}
|
|
{
|
|
const test = begin()
|
|
emit(test.kernel, { type: 'agent_end', yielded: false, messages: [{ role: 'user' }, errorMessage] })
|
|
assert.equal((await test.outcome).error?.message, errorMessage.errorMessage)
|
|
assert.equal(test.kernel.abortCalls, 1)
|
|
}
|
|
{
|
|
const test = begin()
|
|
emit(test.kernel, { type: 'message_end', message: { ...errorMessage, errorMessage: 'Failed https://account:secret@example.invalid/v1?key=url-secret API Key: field-secret Authorization: Bearer bearer-secret sk-plainsecret123' } })
|
|
const message = (await test.outcome).error!.message
|
|
for (const secret of ['account', 'secret@example', 'url-secret', 'field-secret', 'bearer-secret', 'plainsecret123']) assert.equal(message.includes(secret), false)
|
|
assert.match(message, /已隐藏/)
|
|
}
|
|
{
|
|
const test = begin()
|
|
test.controller.abort()
|
|
emit(test.kernel, { type: 'message_end', message: errorMessage })
|
|
assert.equal((await test.outcome).value?.status, 'cancelled')
|
|
assert.equal(test.events.some(event => event.type === 'error'), false)
|
|
assert.equal(test.events.filter(event => event.type === 'cancelled').length, 1)
|
|
assert.equal(test.kernel.abortCalls, 1)
|
|
}
|
|
{
|
|
const test = begin()
|
|
let ended = false
|
|
void test.outcome.then(() => { ended = true })
|
|
// There is no first-byte deadline: long thinking and tool-level failures remain live.
|
|
emit(test.kernel, { type: 'message_update', assistantMessageEvent: { type: 'thinking_delta', delta: 'thinking', contentIndex: 0 } })
|
|
emit(test.kernel, { type: 'message_end', message: { ...errorMessage, role: 'toolResult' } })
|
|
emit(test.kernel, { type: 'tool_execution_start', toolCallId: 'tool', toolName: 'shell', args: {} })
|
|
emit(test.kernel, { type: 'tool_execution_end', toolCallId: 'tool', toolName: 'shell', isError: true, result: { content: [{ type: 'text', text: 'command failed' }] } })
|
|
emit(test.kernel, { type: 'agent_end', yielded: false, messages: [{ role: 'assistant', stopReason: 'toolUse' }] })
|
|
await delay()
|
|
assert.equal(ended, false)
|
|
assert.equal(test.kernel.abortCalls, 0)
|
|
assert.equal(test.events.some(event => event.type === 'error'), false)
|
|
emit(test.kernel, { type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: 'recovered' } })
|
|
emit(test.kernel, { type: 'agent_end', yielded: true, messages: [errorMessage, { role: 'assistant', stopReason: 'stop' }] })
|
|
const result = (await test.outcome).value!
|
|
assert.equal(result.status, 'done')
|
|
assert.equal(result.content, 'recovered')
|
|
assert.equal(result.tools[0].status, 'error')
|
|
}
|
|
{
|
|
const test = begin()
|
|
test.kernel.rejectAbort = true
|
|
emit(test.kernel, { type: 'message_end', message: { role: 'assistant', stopReason: 'error' } })
|
|
assert.equal((await test.outcome).error?.message, 'OMP 模型服务请求失败')
|
|
await delay()
|
|
assert.equal(test.kernel.disposeCalls, 1, 'failed abort disposes the sidecar instead of leaving automatic retries running')
|
|
}
|
|
console.log('omp-provider-error-regression: provider error, retry stop, sanitization, cancellation, long thinking, and tool error isolation passed (protocol fixtures; no real upstream request)')
|