fix(code-mode): generalize failures and bound diagnostics
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, runWorkerMain, ToolCallError, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
|
||||
import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runWorkerMain, wireReplies } from '../src/bootstrap.ts'
|
||||
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
|
||||
import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
|
||||
import { decodeWorkerJson, encodeWorkerJson } from '../src/worker-json.ts'
|
||||
@@ -60,23 +60,30 @@ async function rejectionOf(promise: Promise<unknown>): Promise<unknown> {
|
||||
}
|
||||
|
||||
const BOOT = { maxOutputBytes: 65_536 }
|
||||
const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const
|
||||
|
||||
/** One worker declaration for the Code Mode tools namespace. */
|
||||
function toolNamespace(names: string[]) {
|
||||
return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS }
|
||||
}
|
||||
|
||||
describe('LogBuffer', () => {
|
||||
it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => {
|
||||
const seen: string[] = []
|
||||
let limits = 0
|
||||
const buffer = new LogBuffer(10, text => seen.push(text), () => { limits += 1 })
|
||||
const buffer = new LogBuffer(15, text => seen.push(text), () => { limits += 1 })
|
||||
buffer.push('12345')
|
||||
buffer.push('123456')
|
||||
buffer.push('dropped')
|
||||
expect(seen).toEqual(['12345', '12345'])
|
||||
expect(seen).toEqual(['12345', '123'])
|
||||
expect(limits).toBe(1)
|
||||
expect(buffer.remainingOutputBytes()).toBe(0)
|
||||
|
||||
const exactlyFull: string[] = []
|
||||
const fullBuffer = new LogBuffer(4, text => exactlyFull.push(text))
|
||||
fullBuffer.push('1234')
|
||||
const fullBuffer = new LogBuffer(6, text => exactlyFull.push(text))
|
||||
fullBuffer.push('12')
|
||||
fullBuffer.push('no-prefix-fits')
|
||||
expect(exactlyFull).toEqual(['1234'])
|
||||
expect(exactlyFull).toEqual(['12'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -167,19 +174,32 @@ describe('prepareCompletion', () => {
|
||||
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the remaining combined budget for invalid-output diagnostics', () => {
|
||||
expect(prepareCompletion(() => 1, 4, 64)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('truncateUtf8Bytes', () => {
|
||||
it('returns a fitting string whole', () => {
|
||||
expect(truncateUtf8Bytes('fits', 4)).toBe('fits')
|
||||
describe('prepareException', () => {
|
||||
it('passes a fitting diagnostic and rejects one byte over without carrying its text', () => {
|
||||
expect(prepareException('boom', 6, 64)).toEqual({ error: { kind: 'exception', message: 'boom' } })
|
||||
expect(prepareException('boom', 5, 64)).toEqual({
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('cuts at a code-point boundary, never mid-surrogate-pair', () => {
|
||||
// Each 😀 is one code point, two code units, four UTF-8 bytes: a 5-byte
|
||||
// budget fits exactly one — and never leaves a lone surrogate behind.
|
||||
const cut = truncateUtf8Bytes('😀😀', 5)
|
||||
expect(cut).toBe('😀')
|
||||
expect(Buffer.byteLength(truncateUtf8Bytes('😀😀', 3), 'utf8')).toBe(0)
|
||||
it('contains a thrown value whose string conversion fails', () => {
|
||||
const thrown = { toString() { throw new Error('cannot render') } }
|
||||
expect(prepareException(thrown, 1_000)).toEqual({
|
||||
error: { kind: 'exception', message: 'program threw an unrenderable value' },
|
||||
})
|
||||
|
||||
const strangeStack = Object.defineProperty(new Error('ignored'), 'stack', { value: 42 })
|
||||
expect(prepareException(strangeStack, 1_000)).toEqual({
|
||||
error: { kind: 'exception', message: '42' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -219,7 +239,16 @@ describe('makeNamespaces', () => {
|
||||
on: () => {},
|
||||
}
|
||||
const pending = new Map<number, PendingCall>()
|
||||
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const data = { namespaces: [toolNamespace(['x'])] }
|
||||
const errorClasses = makeBindingErrorClasses(data)
|
||||
const ToolCallError = errorClasses.get('tools')
|
||||
const [tools] = makeNamespaces(
|
||||
data,
|
||||
throwingPort,
|
||||
pending,
|
||||
{ value: 1 },
|
||||
errorClasses,
|
||||
) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve())
|
||||
const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve())
|
||||
expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' })
|
||||
@@ -237,7 +266,7 @@ describe('makeNamespaces', () => {
|
||||
const pending = new Map<number, PendingCall>()
|
||||
const nextId = { value: 1 }
|
||||
const [tools] = makeNamespaces(
|
||||
{ namespaces: [{ global: 'tools', names: ['x'] }] }, port, pending, nextId,
|
||||
{ namespaces: [toolNamespace(['x'])] }, port, pending, nextId,
|
||||
) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const decorated = [1]
|
||||
Object.defineProperty(decorated, 'extra', { value: true })
|
||||
@@ -267,18 +296,18 @@ describe('makeNamespaces', () => {
|
||||
const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve())
|
||||
expect(denied).toBeInstanceOf(Error)
|
||||
expect(denied).not.toBeInstanceOf(ToolCallError)
|
||||
expect(denied).toMatchObject({ name: 'Error', message: 'helper denied' })
|
||||
expect(denied).not.toHaveProperty('toolName')
|
||||
|
||||
const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve())
|
||||
expect(invalid).toBeInstanceOf(Error)
|
||||
expect(invalid).not.toBeInstanceOf(ToolCallError)
|
||||
expect((invalid as Error).message).toBe('binding arguments must be lossless JSON')
|
||||
|
||||
const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} }
|
||||
const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
|
||||
const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve())
|
||||
expect(cloneFailure).toBeInstanceOf(Error)
|
||||
expect(cloneFailure).not.toBeInstanceOf(ToolCallError)
|
||||
expect(cloneFailure).not.toHaveProperty('toolName')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -306,9 +335,12 @@ describe('runWorkerMain', () => {
|
||||
code: 'console.log("12345"); return null',
|
||||
namespaces: [],
|
||||
}, fakeStreams())
|
||||
expect(port.sent).toContainEqual({ type: 'log', text: '1234' })
|
||||
expect(port.logs()).toEqual([])
|
||||
expect(port.sent).toContainEqual({ type: 'output-limit' })
|
||||
expect(port.doneValue()).toBeNull()
|
||||
expect(port.done()).toEqual({
|
||||
type: 'done',
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a thrown program error on the done message', async () => {
|
||||
@@ -331,16 +363,56 @@ describe('runWorkerMain', () => {
|
||||
expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } })
|
||||
})
|
||||
|
||||
it('replaces giant thrown strings and Error stacks before posting the done message', async () => {
|
||||
const rawPort = new FakePort()
|
||||
await runWorkerMain(rawPort, {
|
||||
maxOutputBytes: 64,
|
||||
code: 'throw "x".repeat(1_000_000)',
|
||||
namespaces: [],
|
||||
}, fakeStreams())
|
||||
expect(rawPort.done()).toEqual({
|
||||
type: 'done',
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
|
||||
const stackPort = new FakePort()
|
||||
await runWorkerMain(stackPort, {
|
||||
maxOutputBytes: 64,
|
||||
code: 'throw new Error("x".repeat(1_000_000))',
|
||||
namespaces: [],
|
||||
}, fakeStreams())
|
||||
expect(stackPort.done()).toEqual({
|
||||
type: 'done',
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
|
||||
await runWorkerMain(port, {
|
||||
...BOOT,
|
||||
code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }',
|
||||
namespaces: [{ global: 'tools', names: ['x'] }],
|
||||
namespaces: [toolNamespace(['x'])],
|
||||
}, fakeStreams())
|
||||
expect(port.doneValue()).toEqual({ caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' })
|
||||
expect(new ToolCallError('x', 'nope')).toMatchObject({ name: 'ToolCallError', toolName: 'x', message: 'nope' })
|
||||
})
|
||||
|
||||
it('materializes a consumer-declared rejection class without knowing the namespace', async () => {
|
||||
const port = new FakePort()
|
||||
port.respond = message => message.type === 'call'
|
||||
? { type: 'reply', id: message.id, ok: false, message: 'helper denied' }
|
||||
: undefined
|
||||
await runWorkerMain(port, {
|
||||
...BOOT,
|
||||
code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }',
|
||||
namespaces: [{
|
||||
global: 'helpers',
|
||||
names: ['x'],
|
||||
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
|
||||
}],
|
||||
}, fakeStreams())
|
||||
expect(port.doneValue()).toEqual({ caught: true, name: 'HelperCallError', helperName: 'x', message: 'helper denied' })
|
||||
})
|
||||
|
||||
it('ignores replies for unknown pending ids', async () => {
|
||||
|
||||
@@ -23,8 +23,15 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WorkerCodeRuntime, {})
|
||||
const result = await ctx.codeRuntime.run({
|
||||
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); return doubled;',
|
||||
bindings: [{ global: 'tools', functions: { double: async args => args.n * 2 } }],
|
||||
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); let failure; try { await tools.fail({}) } catch (error) { failure = { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } } return { doubled, failure };',
|
||||
bindings: [{
|
||||
global: 'tools',
|
||||
functions: {
|
||||
double: async args => args.n * 2,
|
||||
fail: async () => { throw new Error('denied') },
|
||||
},
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}],
|
||||
})
|
||||
console.log(JSON.stringify(result))
|
||||
process.exit(0)
|
||||
@@ -40,7 +47,10 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown }
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.value).toBe(42)
|
||||
expect(result.value).toEqual({
|
||||
doubled: 42,
|
||||
failure: { typed: true, name: 'ToolCallError', toolName: 'fail', message: 'denied' },
|
||||
})
|
||||
expect(result.logs).toContain('halfway 42')
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,11 @@ async function setup(config: Config = {}) {
|
||||
|
||||
/** Convenience: one namespace `tools` with the given functions. */
|
||||
function tools(functions: Record<string, (args: unknown) => Promise<unknown>>): CodeBindingNamespace[] {
|
||||
return [{ global: 'tools', functions: functions as Record<string, CodeBindingFunction> }]
|
||||
return [{
|
||||
global: 'tools',
|
||||
functions: functions as Record<string, CodeBindingFunction>,
|
||||
errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' },
|
||||
}]
|
||||
}
|
||||
|
||||
describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
|
||||
@@ -74,6 +78,33 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
|
||||
expect(calls).toEqual([{ n: 1 }])
|
||||
})
|
||||
|
||||
it('materializes a typed rejection from a generic namespace descriptor', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
try { await helpers.fail({}) } catch (error) {
|
||||
return {
|
||||
isTyped: error instanceof HelperCallError,
|
||||
name: error.name,
|
||||
helperName: error.helperName,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
`,
|
||||
bindings: [{
|
||||
global: 'helpers',
|
||||
functions: { fail: async () => { throw new Error('nope') } },
|
||||
errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' },
|
||||
}],
|
||||
})
|
||||
expect(result.value).toEqual({
|
||||
isTyped: true,
|
||||
name: 'HelperCallError',
|
||||
helperName: 'fail',
|
||||
message: 'nope',
|
||||
})
|
||||
})
|
||||
|
||||
it('bridges a deeply nested lossless JSON argument, resolution, and completion', async () => {
|
||||
const { runtime } = await setup()
|
||||
const result = await runtime.run({
|
||||
@@ -304,6 +335,31 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('accounts logs and exception diagnostics before the worker port boundary', async () => {
|
||||
// JSON(["abc"]) is seven bytes and JSON("xy") is four.
|
||||
const exact = await setup({ maxOutputBytes: 11 })
|
||||
expect(await exact.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] }))
|
||||
.toEqual({ logs: ['abc'], error: { kind: 'exception', message: 'xy' } })
|
||||
|
||||
const over = await setup({ maxOutputBytes: 10 })
|
||||
const result = await over.runtime.run({ program: 'console.log("abc"); throw "xy"', bindings: [] })
|
||||
expect(result.error?.kind).toBe('output-limit')
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')
|
||||
+ Buffer.byteLength(JSON.stringify(result.error?.message), 'utf8')).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('does not send a giant Error stack across the worker port', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: 'throw new Error("x".repeat(1_000_000))',
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('completes a program that awaits its write callback, capturing the chunk', async () => {
|
||||
// Node's write(chunk[, encoding][, callback]) contract: dropping the
|
||||
// callback would leave this promise pending until the wall ceiling and
|
||||
@@ -448,6 +504,22 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('re-caps an oversized forged done value at the host boundary', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 64 })
|
||||
const result = await runtime.run({
|
||||
program: `
|
||||
const { parentPort } = await import('node:worker_threads');
|
||||
parentPort.postMessage({ type: 'done', value: ['V'.repeat(100_000)] });
|
||||
for (;;) {}
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
expect(result).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' },
|
||||
})
|
||||
})
|
||||
|
||||
it('bounds one oversized forged log while retaining its fitting escaped prefix', async () => {
|
||||
const { runtime } = await setup({ maxOutputBytes: 96 })
|
||||
const result = await runtime.run({
|
||||
@@ -634,13 +706,12 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
||||
})
|
||||
|
||||
describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, reserved injected globals)', async () => {
|
||||
it('rejects invalid and duplicate binding globals loudly', async () => {
|
||||
const { runtime } = await setup()
|
||||
const cases: [string, RegExp][] = [
|
||||
['not valid!', /not a usable identifier/],
|
||||
['await', /not a usable identifier/],
|
||||
['console', /duplicate binding global/],
|
||||
['ToolCallError', /duplicate binding global/],
|
||||
]
|
||||
for (const [global, message] of cases) {
|
||||
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
|
||||
@@ -649,6 +720,32 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
|
||||
program: 'return 1',
|
||||
bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
|
||||
})).rejects.toThrow(/duplicate binding global/)
|
||||
|
||||
await expect(runtime.run({
|
||||
program: 'return typeof ToolCallError',
|
||||
bindings: [{ global: 'ToolCallError', functions: {} }],
|
||||
})).resolves.toMatchObject({ value: 'object' })
|
||||
})
|
||||
|
||||
it('rejects malformed or colliding binding error-class declarations', async () => {
|
||||
const { runtime } = await setup()
|
||||
const run = async (bindings: CodeBindingNamespace[]) => await runtime.run({ program: 'return 1', bindings })
|
||||
const namespace = (global: string, name: string, memberNameProperty = 'memberName'): CodeBindingNamespace => ({
|
||||
global,
|
||||
functions: {},
|
||||
errorClass: { name, memberNameProperty },
|
||||
})
|
||||
|
||||
await expect(run([namespace('tools', 'not valid!')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'await')])).rejects.toThrow(/error class.*not a usable identifier/)
|
||||
await expect(run([namespace('tools', 'console')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'tools')])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([
|
||||
namespace('tools', 'CallError'),
|
||||
namespace('helpers', 'CallError'),
|
||||
])).rejects.toThrow(/duplicate injected global/)
|
||||
await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/)
|
||||
await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/)
|
||||
})
|
||||
|
||||
it('rejects config values that are not positive numbers', async () => {
|
||||
|
||||
Reference in New Issue
Block a user