|
|
@@ -0,0 +1,196 @@
|
|
|
+/**
|
|
|
+ * 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 + path,
|
|
|
+ * 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 { 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, { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
+import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
|
|
+import { SpillFiles, SpillPath } from '@deepseek-ai/dsh-spill'
|
|
|
+import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill'
|
|
|
+import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy'
|
|
|
+
|
|
|
+/** A stub spill backend recording its saves; `fail` exercises the best-effort fallback. */
|
|
|
+class StubSpill extends SpillFiles {
|
|
|
+ saves: SaveTextSpill[] = []
|
|
|
+ fail = false
|
|
|
+
|
|
|
+ async saveText(input: SaveTextSpill): Promise<SpillRef> {
|
|
|
+ if (this.fail) throw new Error('disk full')
|
|
|
+ this.saves.push(input)
|
|
|
+ return { path: SpillPath(`/spill/${input.suggestedName}`), bytes: Buffer.byteLength(input.content, 'utf8') }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** A tool returning `text` verbatim (name configurable so we can register `read`). */
|
|
|
+function textTool(name: string, text: string) {
|
|
|
+ return defineTool({
|
|
|
+ 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 } 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): Promise<{ ctx: Context; spill?: StubSpill }> {
|
|
|
+ const ctx = new Context()
|
|
|
+ await ctx.plugin(SystemPrompt)
|
|
|
+ await ctx.plugin(ToolRegistry)
|
|
|
+ let spill: StubSpill | undefined
|
|
|
+ if (withSpill) {
|
|
|
+ await ctx.plugin(StubSpill)
|
|
|
+ spill = ctx.spillFiles as StubSpill
|
|
|
+ }
|
|
|
+ await ctx.plugin(SpillPolicy, config)
|
|
|
+ return { ctx, ...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('oversized plain-text replacement', () => {
|
|
|
+ it('spills the full text and replaces the result with a preview + path', async () => {
|
|
|
+ const { ctx, spill } = await setup({ maxInlineBytes: 20 })
|
|
|
+ const body = 'HEAD'.repeat(20) + 'TAIL'.repeat(20) // 160 bytes > 20
|
|
|
+ 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 saved to: /spill/big.txt')
|
|
|
+ expect(text).toContain('Use read with offset/limit')
|
|
|
+ expect(text).toContain('Omitted')
|
|
|
+ })
|
|
|
+
|
|
|
+ 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(defineTool({
|
|
|
+ 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('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('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({ 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('bounds content a downstream post-execute listener replaced', async () => {
|
|
|
+ const { ctx, spill } = await setup({ maxInlineBytes: 10 })
|
|
|
+ // 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 saved to')
|
|
|
+ })
|
|
|
+
|
|
|
+ it('preserves a downstream accept decision additionalContext when spilling', async () => {
|
|
|
+ const { ctx } = await setup({ maxInlineBytes: 10 })
|
|
|
+ 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', additionalContext: 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 saved to')
|
|
|
+ expect(result.additionalContext).toEqual(context)
|
|
|
+ })
|
|
|
+})
|