mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Responding to ds-review-bot round 2 on #661: - logWork is bounded: past maxParallelSubCalls pending shaped-append tasks the ordered commit lane holds (Promise.race drains one), so a slow spill backend backpressures the run instead of accumulating unbounded pending I/O and retained results. Tasks self-remove on settlement; run settlement still drains every task inside the open turn. New spill test drives three oversized reads against a hung backend at cap 1 and proves the third dispatch cannot start until a save drains. - shapeDispatchLog's catch uses errorMessage() (total), so a thrown value with a throwing toString cannot escape the containment and lose the settle event. - CodeDispatchLog.content documented as the RENDERED result projection (native tool/result vocabulary), not what the program received — the program gets the structured value; doc pair + type-equiv re-synced. - New RECORDED tui-agent snapshot scenario code-mode-dispatch-spill: the real Loader-visible composition (worker runtime + spill-local + policy) drives an oversized bash sub-call end-to-end; replay proves the durable dispatch copy is bounded to preview + locator while the program value stays whole (the outer result carries just the line count). Agent Note updated (both languages).
602 lines
26 KiB
TypeScript
602 lines
26 KiB
TypeScript
/**
|
|
* Tests for the spill-policy PLUGIN. It registers no service, only the
|
|
* `tools/post-execute` transformer. We drive real tools through
|
|
* `ctx.tools.execute(...)` and assert: disabled mode is a true no-op, an
|
|
* oversized plain-text result is spilled and replaced with a preview + locator,
|
|
* a small result and a non-text result pass through, `read` is skipped, and a
|
|
* `saveText` failure / missing backend / missing owner all preserve the original
|
|
* result without an `isError`.
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import Loader from '@cordisjs/plugin-loader'
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
|
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
|
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
import type { PostToolDecision, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
|
import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill'
|
|
import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
|
import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
|
|
import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
|
|
|
|
const testToolSignal = new AbortController().signal
|
|
|
|
/** A stub spill backend recording its saves; `fail` exercises the best-effort fallback. */
|
|
class StubStore extends SpillStore {
|
|
saves: SaveTextSpill[] = []
|
|
fail = false
|
|
/** Per-save hang hook: each call awaits the returned promise before completing. */
|
|
gate: (() => Promise<void>) | undefined
|
|
|
|
async saveText(input: SaveTextSpill): Promise<SpillRef> {
|
|
if (this.fail) throw new Error('disk full')
|
|
await this.gate?.()
|
|
this.saves.push(input)
|
|
return {
|
|
locator: SpillLocator(`/spill/${input.suggestedName}`),
|
|
bytes: Buffer.byteLength(input.content, 'utf8'),
|
|
retrievalHint: 'Use the stub retrieval path.',
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A tool returning `text` verbatim (name configurable so we can register `read`). */
|
|
function textTool(name: string, text: string) {
|
|
return defineContentToolFixture({
|
|
name,
|
|
description: name,
|
|
parameters: {},
|
|
async execute(): Promise<ContentBlock[]> { return [{ type: 'text', text }] },
|
|
})
|
|
}
|
|
|
|
/** A minimal exec carrying a session header id (the spill owner). */
|
|
function exec(name: string, session = 's1'): ToolExecution {
|
|
// Only agent.session.header.id is read by the policy; a structural stub suffices.
|
|
const agent = { session: { header: { id: SessionId(session) } } }
|
|
return { callId: CallId(`call-${name}`), name, arguments: {}, agent, signal: testToolSignal } as unknown as ToolExecution
|
|
}
|
|
|
|
/**
|
|
* Build a context with tools + the policy, and optionally a spill backend.
|
|
* Returns the context and the backend handle (undefined when `withSpill` false).
|
|
*/
|
|
async function setup(
|
|
config: SpillPolicy.Config,
|
|
withSpill = true,
|
|
beforePolicy?: (ctx: Context) => void,
|
|
): Promise<{ ctx: Context; spill?: StubStore; fiber: Awaited<ReturnType<Context['plugin']>> }> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry)
|
|
let spill: StubStore | undefined
|
|
if (withSpill) {
|
|
await ctx.plugin(StubStore)
|
|
spill = ctx.spillStore as StubStore
|
|
}
|
|
beforePolicy?.(ctx)
|
|
const fiber = await ctx.plugin(SpillPolicy, config)
|
|
return { ctx, fiber, ...spill ? { spill } : {} }
|
|
}
|
|
|
|
/** Flatten a result's text blocks. */
|
|
function textOf(content: ContentBlock[]): string {
|
|
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
|
|
}
|
|
|
|
describe('disabled mode', () => {
|
|
it('registers no post-execute listener when maxInlineBytes is omitted', async () => {
|
|
const { ctx, spill } = await setup({})
|
|
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
|
|
const result = await ctx.tools.execute(exec('big'))
|
|
expect(textOf(result.content)).toBe('x'.repeat(1000))
|
|
expect(result.isError).toBe(false)
|
|
expect(spill?.saves).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('loader export shape', () => {
|
|
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
|
|
expect('default' in SpillPolicy).toBe(false)
|
|
|
|
const loader = Object.create(Loader.prototype) as Loader
|
|
const unwrapped = loader.unwrapExports(SpillPolicy) as Record<string, unknown>
|
|
expect(unwrapped).toBe(SpillPolicy)
|
|
expect(unwrapped.name).toBe('spill-policy')
|
|
expect(unwrapped.inject).toEqual(['tools'])
|
|
expect(unwrapped.Config).toBeDefined()
|
|
expect(typeof unwrapped.apply).toBe('function')
|
|
})
|
|
})
|
|
|
|
describe('config validation', () => {
|
|
it('rejects a negative maxInlineBytes at load', async () => {
|
|
await expect(setup({ maxInlineBytes: -1 })).rejects.toThrow(/non-negative integer/)
|
|
})
|
|
|
|
it('rejects a fractional maxInlineBytes at load', async () => {
|
|
await expect(setup({ maxInlineBytes: 1.5 })).rejects.toThrow(/non-negative integer/)
|
|
})
|
|
|
|
})
|
|
|
|
describe('oversized plain-text replacement', () => {
|
|
it('spills the full text and replaces the result with a preview + locator within the cap', async () => {
|
|
const { ctx, spill } = await setup({ maxInlineBytes: 200 })
|
|
const body = 'HEAD'.repeat(200) + 'TAIL'.repeat(200) // 1600 bytes > 200
|
|
ctx.tools.register(textTool('big', body))
|
|
const result = await ctx.tools.execute(exec('big'))
|
|
|
|
expect(result.isError).toBe(false)
|
|
expect(spill?.saves).toHaveLength(1)
|
|
expect(spill?.saves[0]?.content).toBe(body)
|
|
expect(spill?.saves[0]?.source.toolName).toBe('big')
|
|
expect(spill?.saves[0]?.suggestedName).toBe('big.txt')
|
|
expect(spill?.saves[0]?.owner.sessionId).toBe('s1')
|
|
|
|
const text = textOf(result.content)
|
|
expect(text).not.toBe(body)
|
|
expect(text.startsWith('HEAD')).toBe(true)
|
|
expect(text).toContain('Full formatted result stored at: /spill/big.txt')
|
|
expect(text).toContain('Use the stub retrieval path.')
|
|
expect(text).toContain('Omitted')
|
|
// The replacement (preview + blank line + notice) stays within the cap and
|
|
// is smaller than the original — the whole point of spilling.
|
|
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(200)
|
|
expect(Buffer.byteLength(text, 'utf8')).toBeLessThan(body.length)
|
|
})
|
|
|
|
it('keeps the inline result when the notice-only replacement would exceed the cap', async () => {
|
|
// A body just over a tiny cap: the notice alone is larger than the cap, so
|
|
// there is no within-cap replacement — the policy keeps the inline result.
|
|
const { ctx } = await setup({ maxInlineBytes: 4 })
|
|
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
|
const body = 'xxxxx' // 5 bytes > 4, but far shorter than the notice
|
|
ctx.tools.register(textTool('big', body))
|
|
const result = await ctx.tools.execute(exec('big'))
|
|
expect(textOf(result.content)).toBe(body)
|
|
expect(warn).toHaveBeenCalled()
|
|
})
|
|
|
|
it('leaves a small plain-text result unchanged', async () => {
|
|
const { ctx, spill } = await setup({ maxInlineBytes: 1000 })
|
|
ctx.tools.register(textTool('small', 'tiny'))
|
|
const result = await ctx.tools.execute(exec('small'))
|
|
expect(textOf(result.content)).toBe('tiny')
|
|
expect(spill?.saves).toHaveLength(0)
|
|
})
|
|
|
|
it('leaves a result with a non-text block unchanged', async () => {
|
|
const { ctx, spill } = await setup({ maxInlineBytes: 5 })
|
|
ctx.tools.register(defineContentToolFixture({
|
|
name: 'mixed',
|
|
description: 'mixed',
|
|
parameters: {},
|
|
async execute(): Promise<ContentBlock[]> {
|
|
return [{ type: 'text', text: 'x'.repeat(100) }, { type: 'reasoning', text: 'why' }]
|
|
},
|
|
}))
|
|
const result = await ctx.tools.execute(exec('mixed'))
|
|
expect(spill?.saves).toHaveLength(0)
|
|
expect(result.content).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
describe('outer Code Mode failure capture', () => {
|
|
it('spills the bounded output-limit diagnostic through the ordinary outer-result policy', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
|
await ctx.plugin(StubStore)
|
|
await ctx.plugin(SpillPolicy, { maxInlineBytes: 200 })
|
|
await ctx.plugin(WorkerCodeRuntime, { maxOutputBytes: 500 })
|
|
const events: unknown[] = []
|
|
const agent = {
|
|
session: {
|
|
header: { id: SessionId('code-spill'), cwd: '/workspace' },
|
|
append: (_type: string, data: unknown) => { events.push(data) },
|
|
},
|
|
}
|
|
|
|
const result = await ctx.tools.execute({
|
|
signal: testToolSignal,
|
|
callId: CallId('code-output-limit'),
|
|
name: 'run_code',
|
|
arguments: {
|
|
code: 'console.log("HEAD-" + "x".repeat(300)); console.log("TAIL-" + "y".repeat(300)); return "unreachable";',
|
|
description: 'Print oversized head and tail lines',
|
|
},
|
|
agent: agent as never,
|
|
})
|
|
|
|
expect(result.isError).toBe(true)
|
|
const saved = (ctx.spillStore as StubStore).saves
|
|
expect(saved).toHaveLength(1)
|
|
expect(saved[0]?.source.toolName).toBe('run_code')
|
|
expect(saved[0]?.content).toContain('code run failed (output-limit)')
|
|
expect(saved[0]?.content).toContain('HEAD-')
|
|
expect(textOf(result.content)).toContain('Full formatted result stored at: /spill/run_code.txt')
|
|
expect(events).toEqual([])
|
|
})
|
|
})
|
|
|
|
describe('read skip', () => {
|
|
it('never spills the read tool result (avoids a read → spill → read loop)', async () => {
|
|
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
|
|
ctx.tools.register(textTool('read', 'x'.repeat(1000)))
|
|
const result = await ctx.tools.execute(exec('read'))
|
|
expect(textOf(result.content)).toBe('x'.repeat(1000))
|
|
expect(spill?.saves).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('the durable dispatch-log arm', () => {
|
|
/** Boot code mode + the policy + the worker runtime; run one program via the real bridge. */
|
|
async function runCodeWith(program: string, maxInlineBytes: number, extraTools: ToolDefinition[] = []) {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
|
await ctx.plugin(StubStore)
|
|
await ctx.plugin(SpillPolicy, { maxInlineBytes })
|
|
await ctx.plugin(WorkerCodeRuntime, {})
|
|
const events: { type: string; data: unknown }[] = []
|
|
const agent = {
|
|
session: {
|
|
header: { id: SessionId('dispatch-spill'), cwd: '/workspace' },
|
|
append: (type: string, data: unknown) => { events.push({ type, data }) },
|
|
},
|
|
}
|
|
ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000)))
|
|
ctx.tools.register(textTool('small_read', 'tiny'))
|
|
for (const tool of extraTools) ctx.tools.register(tool)
|
|
const result = await ctx.tools.execute({
|
|
signal: testToolSignal,
|
|
callId: CallId('parent-1'),
|
|
name: 'run_code',
|
|
arguments: { code: program, description: 'Drive dispatch-log spilling' },
|
|
agent: agent as never,
|
|
})
|
|
return { ctx, result, events, spill: ctx.spillStore as StubStore }
|
|
}
|
|
|
|
it('bounds the tool/code-dispatch copy of an oversized sub-result while the program value stays whole', async () => {
|
|
const { result, events, spill } = await runCodeWith(
|
|
'const blocks = await tools.huge_read({});\nreturn blocks[0].text.length', 200)
|
|
expect(result.isError).toBe(false)
|
|
if (result.isError) throw new Error('expected success')
|
|
// The program received the COMPLETE text (length 2000), untouched by spill.
|
|
expect(result.value).toMatchObject({ result: 2_000 })
|
|
// The durable settle event carries the bounded projection + locator.
|
|
const settle = events.find(event => event.type === 'tool/code-dispatch')
|
|
expect(settle).toBeDefined()
|
|
const logged = (settle!.data as { content: { type: string; text: string }[] }).content
|
|
expect(logged).toHaveLength(1)
|
|
const loggedText = logged[0]!.text
|
|
expect(Buffer.byteLength(loggedText, 'utf8')).toBeLessThanOrEqual(200)
|
|
expect(loggedText).toContain('Full formatted result stored at: /spill/huge_read.txt')
|
|
// The artifact holds the full text under the dispatch label and sub-call id.
|
|
const save = spill.saves.find(entry => entry.source.label === 'dispatch')
|
|
expect(save).toMatchObject({
|
|
source: { toolName: 'huge_read', callId: 'parent-1:code:1', label: 'dispatch' },
|
|
})
|
|
expect(save?.content).toBe('H'.repeat(2_000))
|
|
})
|
|
|
|
it('leaves a non-text sub-result log unchanged (flatten declines)', async () => {
|
|
const { events, spill } = await runCodeWith(
|
|
'return await tools.mixed_read({})', 5, [defineContentToolFixture({
|
|
name: 'mixed_read',
|
|
description: 'mixed_read',
|
|
parameters: {},
|
|
async execute(): Promise<ContentBlock[]> {
|
|
return [{ type: 'text', text: 'x'.repeat(100) }, { type: 'reasoning', text: 'why' }]
|
|
},
|
|
})])
|
|
const settle = events.find(event => event.type === 'tool/code-dispatch')
|
|
expect((settle!.data as { content: unknown[] }).content).toHaveLength(2)
|
|
expect(spill.saves.filter(entry => entry.source.label === 'dispatch')).toHaveLength(0)
|
|
})
|
|
|
|
it('leaves a within-cap sub-result log untouched and saves nothing for it', async () => {
|
|
const { events, spill } = await runCodeWith(
|
|
'return await tools.small_read({})', 200)
|
|
const settle = events.find(event => event.type === 'tool/code-dispatch')
|
|
expect((settle!.data as { content: { type: string; text: string }[] }).content)
|
|
.toEqual([{ type: 'text', text: 'tiny' }])
|
|
expect(spill.saves.filter(entry => entry.source.label === 'dispatch')).toHaveLength(0)
|
|
})
|
|
|
|
it('a slow spill backend never delays the program value or a later dispatch slot', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
|
await ctx.plugin(StubStore)
|
|
await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 })
|
|
await ctx.plugin(WorkerCodeRuntime, {})
|
|
// A spill backend that hangs until released.
|
|
let releaseSave!: () => void
|
|
const gate = new Promise<void>((resolve) => { releaseSave = resolve })
|
|
const store = ctx.spillStore as StubStore
|
|
const realSave = store.saveText.bind(store)
|
|
store.saveText = async (input) => {
|
|
await gate
|
|
return realSave(input)
|
|
}
|
|
const events: { type: string; data: unknown }[] = []
|
|
const agent = {
|
|
session: {
|
|
header: { id: SessionId('dispatch-slow-spill'), cwd: '/workspace' },
|
|
append: (type: string, data: unknown) => { events.push({ type, data }) },
|
|
},
|
|
}
|
|
ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000)))
|
|
ctx.tools.register(textTool('small_read', 'tiny'))
|
|
let smallAfterHuge = false
|
|
const runPromise = ctx.tools.execute({
|
|
signal: testToolSignal,
|
|
callId: CallId('parent-3'),
|
|
name: 'run_code',
|
|
arguments: {
|
|
// The program takes BOTH values while the spill backend hangs: the
|
|
// huge read's binding resolves immediately (its logged copy is side
|
|
// work), so the small read proceeds without waiting.
|
|
code: 'const big = await tools.huge_read({});\nconst small = await tools.small_read({});\nreturn big[0].text.length + small[0].text.length',
|
|
description: 'Prove log shaping is off the program path',
|
|
},
|
|
agent: agent as never,
|
|
}).then((result) => {
|
|
return result
|
|
})
|
|
// The run cannot COMPLETE while the settle append is gated (drain waits
|
|
// for logWork), but the program itself already ran both calls; release
|
|
// the backend and observe the settle events land inside the turn.
|
|
await vi.waitFor(() => {
|
|
// The second dispatch STARTED while the first one's spill hung.
|
|
smallAfterHuge = events.some(event => event.type === 'tool/code-dispatch-start'
|
|
&& (event.data as { name: string }).name === 'small_read')
|
|
if (!smallAfterHuge) throw new Error('small_read not started yet')
|
|
})
|
|
releaseSave()
|
|
const result = await runPromise
|
|
expect(result.isError).toBe(false)
|
|
if (result.isError) throw new Error('expected success')
|
|
expect(result.value).toMatchObject({ result: 2_004 })
|
|
const settles = events.filter(event => event.type === 'tool/code-dispatch')
|
|
expect(settles).toHaveLength(2)
|
|
expect(smallAfterHuge).toBe(true)
|
|
})
|
|
|
|
it('a sustained slow backend backpressures the run instead of accumulating unbounded log tasks', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
// Cap 1: once the hung shaped-append backlog exceeds the cap, the ordered
|
|
// lane holds inside the second commit, so the THIRD dispatch cannot start
|
|
// until a pending save drains — the bound is observable as its missing
|
|
// start event.
|
|
await ctx.plugin(ToolRegistry, { mode: 'code', maxParallelSubCalls: 1 })
|
|
await ctx.plugin(StubStore)
|
|
await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 })
|
|
await ctx.plugin(WorkerCodeRuntime, {})
|
|
const store = ctx.spillStore as StubStore
|
|
const releases: (() => void)[] = []
|
|
store.gate = () => new Promise<void>((resolve) => { releases.push(resolve) })
|
|
const events: { type: string; data: unknown }[] = []
|
|
const agent = {
|
|
session: {
|
|
header: { id: SessionId('dispatch-spill-bound'), cwd: '/workspace' },
|
|
append: (type: string, data: unknown) => { events.push({ type, data }) },
|
|
},
|
|
}
|
|
ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000)))
|
|
const started = (n: number): boolean => events.some(event => event.type === 'tool/code-dispatch-start'
|
|
&& (event.data as { subCallId: string }).subCallId.endsWith(`:code:${n}`))
|
|
const runPromise = ctx.tools.execute({
|
|
signal: testToolSignal,
|
|
callId: CallId('parent-bound'),
|
|
name: 'run_code',
|
|
arguments: {
|
|
code: 'await tools.huge_read({}); await tools.huge_read({}); await tools.huge_read({}); return "done"',
|
|
description: 'Three oversized reads against a hung backend',
|
|
},
|
|
agent: agent as never,
|
|
})
|
|
// Two hung saves = backlog above the cap: the lane must hold before
|
|
// starting dispatch 3.
|
|
await vi.waitFor(() => {
|
|
if (releases.length < 2) throw new Error('second hung save not reached yet')
|
|
})
|
|
expect(started(2)).toBe(true)
|
|
expect(started(3)).toBe(false)
|
|
releases.shift()!()
|
|
// Draining one pending save releases the lane; dispatch 3 starts.
|
|
await vi.waitFor(() => {
|
|
if (!started(3)) throw new Error('third dispatch not started yet')
|
|
})
|
|
while (releases.length > 0) releases.shift()!()
|
|
const result = await runPromise
|
|
expect(result.isError).toBe(false)
|
|
await vi.waitFor(() => {
|
|
if (releases.length > 0) { while (releases.length > 0) releases.shift()!() }
|
|
if (events.filter(event => event.type === 'tool/code-dispatch').length !== 3) {
|
|
throw new Error('settle events still pending')
|
|
}
|
|
})
|
|
})
|
|
|
|
it('a saveText failure keeps the complete content in the durable log (best-effort)', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SystemPrompt)
|
|
await ctx.plugin(ToolRegistry, { mode: 'code' })
|
|
await ctx.plugin(StubStore)
|
|
await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 })
|
|
await ctx.plugin(WorkerCodeRuntime, {})
|
|
;(ctx.spillStore as StubStore).fail = true
|
|
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
|
const events: { type: string; data: unknown }[] = []
|
|
const agent = {
|
|
session: {
|
|
header: { id: SessionId('dispatch-spill-fail'), cwd: '/workspace' },
|
|
append: (type: string, data: unknown) => { events.push({ type, data }) },
|
|
},
|
|
}
|
|
ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000)))
|
|
const result = await ctx.tools.execute({
|
|
signal: testToolSignal,
|
|
callId: CallId('parent-2'),
|
|
name: 'run_code',
|
|
arguments: { code: 'return (await tools.huge_read({}))[0].text.length', description: 'Fail the spill backend' },
|
|
agent: agent as never,
|
|
})
|
|
expect(result.isError).toBe(false)
|
|
const settle = events.find(event => event.type === 'tool/code-dispatch')
|
|
expect((settle!.data as { content: { text: string }[] }).content[0]!.text).toBe('H'.repeat(2_000))
|
|
expect(warn).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('nested-call skip', () => {
|
|
it('leaves nested composite results complete and spillable only through their outer call', async () => {
|
|
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
|
|
const body = 'x'.repeat(1000)
|
|
ctx.tools.register(textTool('nested', body))
|
|
const nested = {
|
|
...exec('nested'),
|
|
parent: Symbol('outer') as ToolExecutionToken,
|
|
}
|
|
const result = await ctx.tools.execute(nested)
|
|
expect(textOf(result.content)).toBe(body)
|
|
expect(spill?.saves).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('best-effort fallback', () => {
|
|
it('keeps the original result when saveText fails', async () => {
|
|
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
|
|
spill!.fail = true
|
|
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
|
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
|
|
const result = await ctx.tools.execute(exec('big'))
|
|
expect(textOf(result.content)).toBe('x'.repeat(1000))
|
|
expect(result.isError).toBe(false)
|
|
expect(warn).toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the original result when no spill backend is loaded', async () => {
|
|
const { ctx } = await setup({ maxInlineBytes: 10 }, false)
|
|
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
|
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
|
|
const result = await ctx.tools.execute(exec('big'))
|
|
expect(textOf(result.content)).toBe('x'.repeat(1000))
|
|
expect(warn).toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the original result when the call has no session owner', async () => {
|
|
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
|
|
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
|
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
|
|
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c'), name: 'big', arguments: {} })
|
|
expect(textOf(result.content)).toBe('x'.repeat(1000))
|
|
expect(spill?.saves).toHaveLength(0)
|
|
expect(warn).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('composition', () => {
|
|
it('wraps an earlier tool-owned projection before applying the generic cap', async () => {
|
|
let downstreamDecision: PostToolDecision | undefined
|
|
const { ctx, spill } = await setup({ maxInlineBytes: 200 }, true, (target) => {
|
|
target.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
|
|
downstreamDecision = await next()
|
|
return {
|
|
kind: 'accept',
|
|
content: [{ type: 'text', text: `first page\n\nFull canonical result stored at /spill/search-results.txt.\n${'z'.repeat(500)}` }],
|
|
}
|
|
})
|
|
})
|
|
ctx.tools.register(textTool('search', 'initial capped page'))
|
|
|
|
const result = await ctx.tools.execute(exec('search'))
|
|
|
|
expect(downstreamDecision).toEqual({ kind: 'accept' })
|
|
expect(spill?.saves[0]?.content).toContain('Full canonical result stored at /spill/search-results.txt.')
|
|
expect(textOf(result.content)).toContain('Full formatted result stored at')
|
|
})
|
|
|
|
it('bounds content a downstream post-execute listener replaced', async () => {
|
|
const { ctx, spill } = await setup({ maxInlineBytes: 200 })
|
|
// A later-registered listener replaces the (small) tool result with a big one;
|
|
// the policy delegated via next(), so it bounds the replacement.
|
|
ctx.on('tools/post-execute', async (_e, _r, _next) =>
|
|
({ kind: 'accept', content: [{ type: 'text', text: 'z'.repeat(500) }] }))
|
|
ctx.tools.register(textTool('small', 'tiny'))
|
|
const result = await ctx.tools.execute(exec('small'))
|
|
expect(spill?.saves[0]?.content).toBe('z'.repeat(500))
|
|
expect(textOf(result.content)).toContain('Full formatted result stored at')
|
|
})
|
|
|
|
it('preserves downstream accept-decision contexts when spilling', async () => {
|
|
const { ctx } = await setup({ maxInlineBytes: 200 })
|
|
const context = { content: [{ type: 'text' as const, text: 'note' }], source: { kind: 'plugin' as const, plugin: 'test' } }
|
|
ctx.on('tools/post-execute', async (_e, _r, _next) =>
|
|
({ kind: 'accept', additionalContexts: [context] }))
|
|
ctx.tools.register(textTool('big', 'x'.repeat(1000)))
|
|
const result = await ctx.tools.execute(exec('big'))
|
|
expect(textOf(result.content)).toContain('Full formatted result stored at')
|
|
expect(result.additionalContexts).toEqual([context])
|
|
})
|
|
|
|
it('passes a downstream value replacement through for registry rendering', async () => {
|
|
const { ctx, spill } = await setup({ maxInlineBytes: 10 })
|
|
const replacement = [{ type: 'text' as const, text: 'z'.repeat(500) }]
|
|
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: replacement }))
|
|
ctx.tools.register(textTool('small', 'tiny'))
|
|
|
|
const result = await ctx.tools.execute(exec('small'))
|
|
|
|
expect(result.isError).toBe(false)
|
|
if (result.isError) throw new Error('expected replacement success')
|
|
expect(result.value).toEqual(replacement)
|
|
expect(textOf(result.content)).toBe('z'.repeat(500))
|
|
expect(spill?.saves).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('cap invariant', () => {
|
|
it('keeps the inline result when the notice alone exceeds the cap, even for a large original', async () => {
|
|
// A large body (so it is well over the cap) but a cap smaller than the
|
|
// notice itself: there is no within-cap replacement, so the policy must keep
|
|
// the inline result rather than emit content over maxInlineBytes.
|
|
const { ctx } = await setup({ maxInlineBytes: 8 })
|
|
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
|
const body = 'x'.repeat(5000)
|
|
ctx.tools.register(textTool('big', body))
|
|
const result = await ctx.tools.execute(exec('big'))
|
|
expect(textOf(result.content)).toBe(body)
|
|
expect(warn).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('disposal (HMR safety)', () => {
|
|
it('stops transforming oversized results after the plugin fiber is disposed', async () => {
|
|
const { ctx, spill, fiber } = await setup({ maxInlineBytes: 200 })
|
|
const body = 'HEAD'.repeat(200) + 'TAIL'.repeat(200)
|
|
ctx.tools.register(textTool('big', body))
|
|
|
|
// Live: the listener spills and replaces.
|
|
const before = await ctx.tools.execute(exec('big'))
|
|
expect(textOf(before.content)).toContain('Full formatted result stored at')
|
|
expect(spill?.saves).toHaveLength(1)
|
|
|
|
// After disposal the listener is gone — the result passes through untouched
|
|
// and nothing more is spilled (no leaked registration across reload).
|
|
await fiber.dispose()
|
|
const after = await ctx.tools.execute(exec('big'))
|
|
expect(textOf(after.content)).toBe(body)
|
|
expect(spill?.saves).toHaveLength(1)
|
|
})
|
|
})
|