test: exercise image recovery and real PTC retention
This commit is contained in:
1 parent
de45804084
commit
b74bef5d40
3 files changed
+222
-1
No files matched your search
@@ -65,6 +65,9 @@
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-ptc-runtime": "workspace:^"
|
||||
"@deepseek-ai/dsh-ptc-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"sharp": "^0.35.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/** Real attachments, filesystem recovery, Node PTC, and DeepSeek image request bytes without a desktop or API key. */
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import LocalAttachments from '@deepseek-ai/dsh-attachment-local'
|
||||
import FileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import {
|
||||
createAssistantMessage, createToolResultMessage, createUserMessage, LlmAdapter, LlmRuntime,
|
||||
resolveImageAttachmentAccess, ToolCallId,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, ImageBlock, StreamChunk, UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { deepSeekImageRequestPricing, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { prepareImages } from '@deepseek-ai/dsh-llm-deepseek/src/images.ts'
|
||||
import { serialize } from '@deepseek-ai/dsh-llm-deepseek/src/serialize.ts'
|
||||
import { createMcpToolDefinition } from '@deepseek-ai/dsh-mcp-client'
|
||||
import NodeRuntime from '@deepseek-ai/dsh-ptc-runtime-node'
|
||||
import Sandbox from '@deepseek-ai/dsh-sandbox-local'
|
||||
import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjections from '@deepseek-ai/dsh-session-projection'
|
||||
import LocalSpillStore from '@deepseek-ai/dsh-spill-local'
|
||||
import Subprocess from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { estimateContent } from '@deepseek-ai/dsh-token-meter/estimate'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import sharp from 'sharp'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import * as SpillPolicy from '../src/index.ts'
|
||||
|
||||
const CAP = 12500
|
||||
const MODEL = 'vision-fixture'
|
||||
const LONG = `${'X'.repeat(100)}\n`.repeat(600)
|
||||
const SMALL = 'S'.repeat(36000)
|
||||
const connection = resolveAdapterOptions({ models: [{ id: MODEL, inputModalities: ['text', 'image'] }] })
|
||||
const text = (value: string): ContentBlock => ({ type: 'text', text: value })
|
||||
const textOf = (content: readonly ContentBlock[]): string => content.filter(block => block.type === 'text').map(block => block.text).join('')
|
||||
const imagesOf = (content: readonly ContentBlock[]): ImageBlock[] => content.filter((block): block is ImageBlock => block.type === 'image')
|
||||
|
||||
async function setup() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-image-recovery-'))
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => {
|
||||
try { await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRuntime, { mode: 'both' })
|
||||
await ctx.plugin(FileSystem, { cwd: root })
|
||||
await ctx.plugin(LocalAttachments, { dshHome: join(root, 'home') })
|
||||
await ctx.plugin(ToolFs)
|
||||
await ctx.plugin(LocalSpillStore, { root: join(root, 'spill'), cleanupPeriodDays: 0 })
|
||||
await ctx.plugin(SessionProjections)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
const access = (ref: ImageAttachmentRef) => resolveImageAttachmentAccess(
|
||||
ctx.attachments, path => ctx.fs.processPathFromHostPath(path), ref,
|
||||
)
|
||||
const pricing = deepSeekImageRequestPricing(connection, MODEL, access)
|
||||
class VisionRoute extends LlmAdapter {
|
||||
override async resolveModel(provider: string, model: string) {
|
||||
return { provider, id: model, name: model, inputModalities: ['text', 'image'] as Array<'text' | 'image'> }
|
||||
}
|
||||
override imageRequestPricing() { return pricing }
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> { throw new Error('keyless fixture does not infer') }
|
||||
}
|
||||
ctx.llm.registerAdapter(['vision'], new VisionRoute())
|
||||
await ctx.plugin(SpillPolicy, { maxInlineTokens: CAP })
|
||||
const session = ctx.sessions.create(SessionId('recovery'), { meta: { cwd: root } })
|
||||
const agent = { session, options: { provider: 'vision', model: MODEL }, ctx } as Agent
|
||||
let ordinal = 0
|
||||
const execute = (name: string, args: JsonValue = {}) => ctx.tools.execute({
|
||||
name, arguments: args, callId: ToolCallId(`recovery-${++ordinal}`), agent, signal: new AbortController().signal,
|
||||
})
|
||||
const red = await sharp({ create: { width: 1920, height: 1080, channels: 3, background: '#ff0000' } }).png().toBuffer()
|
||||
const blue = await sharp({ create: { width: 1080, height: 1920, channels: 3, background: '#0000ff' } }).png().toBuffer()
|
||||
const green = await sharp({ create: { width: 2048, height: 2048, channels: 3, background: '#00ff00' } }).png().toBuffer()
|
||||
const image = (data: Buffer): JsonValue => ({ type: 'image', mimeType: 'image/png', data: data.toString('base64') })
|
||||
const originals: Record<string, JsonValue[]> = {
|
||||
ends: [{ type: 'text', text: 'A' }, image(red), { type: 'text', text: LONG }, image(blue), { type: 'text', text: 'C' }],
|
||||
middle: [{ type: 'text', text: LONG }, image(green), { type: 'text', text: LONG }],
|
||||
small: [{ type: 'text', text: SMALL }],
|
||||
}
|
||||
const gates = new Map<string, () => Promise<void>>()
|
||||
for (const [name, content] of Object.entries(originals)) {
|
||||
ctx.tools.register({
|
||||
...createMcpToolDefinition(ctx, {
|
||||
name, rawName: name, description: 'Return a fixed text and image sequence.', inputSchema: { type: 'object' },
|
||||
call: async () => { await gates.get(name)?.(); return { content } },
|
||||
}),
|
||||
isConcurrencySafe: () => true,
|
||||
})
|
||||
}
|
||||
const cost = (content: ContentBlock[]) => estimateContent(content.filter(block => block.type === 'text'))
|
||||
+ pricing.priceImages(imagesOf(content)).reduce((sum, price) => sum + price.visualTokens + estimateContent([text(price.text)]), 0)
|
||||
const requestImages = async (content: ContentBlock[], contexts: UserMessage[] = []) => {
|
||||
const callId = ToolCallId('visible-result')
|
||||
const history = [
|
||||
createUserMessage({ source: { kind: 'user' }, content: [text('Inspect the supplied images.')] }),
|
||||
createAssistantMessage({ source: { provider: 'vision', model: MODEL }, content: [
|
||||
{ type: 'tool-call', id: callId, name: 'inspect', arguments: '{}' },
|
||||
] }),
|
||||
createToolResultMessage({ callId, content, isError: false }),
|
||||
...contexts,
|
||||
]
|
||||
const prepared = await prepareImages(history, connection, MODEL, ctx.attachments, access, new AbortController().signal)
|
||||
const body = serialize({ provider: 'vision', model: MODEL, messages: history }, connection, prepared.messages, prepared.versions, access)
|
||||
const blocks = body.messages.flatMap(message => message.content.flatMap(block => block.type === 'tool_result' ? block.content : [block]))
|
||||
const images = blocks.filter(block => block.type === 'image')
|
||||
return Promise.all(images.map(async (block) => {
|
||||
if (block.source.type !== 'base64') throw new Error('expected prepared inline image bytes')
|
||||
const data = Buffer.from(block.source.data, 'base64')
|
||||
const { dominant } = await sharp(data).stats()
|
||||
return dominant.r > 200 ? 'red' : dominant.g > 200 ? 'green' : dominant.b > 200 ? 'blue' : 'unexpected'
|
||||
}))
|
||||
}
|
||||
return { ctx, root, session, execute, originals, green, cost, requestImages, gates }
|
||||
}
|
||||
|
||||
describe('multimodal recovery through real providers', () => {
|
||||
it('keeps a large single-image read inside a fresh 12500-token budget', async () => {
|
||||
const { ctx, root, green, execute, cost, requestImages } = await setup()
|
||||
const saves = vi.spyOn(ctx.spillStore, 'saveText')
|
||||
const path = join(root, 'large.png')
|
||||
await writeFile(path, green)
|
||||
const result = await execute('read_image', { file_path: path })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(imagesOf(result.content)).toHaveLength(1)
|
||||
expect(cost(result.content)).toBeLessThanOrEqual(CAP)
|
||||
expect(saves).not.toHaveBeenCalled()
|
||||
expect(await requestImages(result.content)).toEqual(['green'])
|
||||
})
|
||||
|
||||
it('retains two distinct end images in both the tool result and DeepSeek request', async () => {
|
||||
const { execute, originals, cost, requestImages } = await setup()
|
||||
const result = await execute('ends')
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.value).toEqual({ content: originals.ends })
|
||||
expect(result.content.map(block => block.type)).toEqual(['text', 'image', 'text', 'image', 'text'])
|
||||
expect(textOf(result.content)).toContain('Omitted')
|
||||
expect(cost(result.content)).toBeLessThanOrEqual(CAP)
|
||||
expect(await requestImages(result.content)).toEqual(['red', 'blue'])
|
||||
})
|
||||
|
||||
it('recovers an omitted middle image through read and read_image into the next request', async () => {
|
||||
const { ctx, execute, cost, requestImages } = await setup()
|
||||
const saves = vi.spyOn(ctx.spillStore, 'saveText')
|
||||
const omitted = await execute('middle')
|
||||
expect(omitted.isError).toBe(false)
|
||||
expect(textOf(omitted.content)).toContain('Omitted 1 images.')
|
||||
expect(await requestImages(omitted.content)).toEqual([])
|
||||
const locator = /Full formatted result stored at: (.+?)\. Use read/.exec(textOf(omitted.content))?.[1]
|
||||
if (locator === undefined) throw new Error('missing complete result locator')
|
||||
const full = await readFile(locator, 'utf8')
|
||||
expect(full.startsWith(LONG)).toBe(true)
|
||||
expect(full.endsWith(LONG)).toBe(true)
|
||||
expect(full).not.toContain('base64')
|
||||
const read = await execute('read', { file_path: locator, offset: 600, limit: 8 })
|
||||
expect(read.isError).toBe(false)
|
||||
const address = /\[Image: ("[^"\n]+")/.exec(textOf(read.content))?.[1]
|
||||
if (address === undefined) throw new Error('read did not expose the omitted image address')
|
||||
const path = JSON.parse(address) as string
|
||||
const recovered = await execute('read_image', { file_path: path })
|
||||
expect(recovered.isError).toBe(false)
|
||||
const images = imagesOf(recovered.content)
|
||||
expect(images).toHaveLength(1)
|
||||
const stored = await ctx.attachments.readImage(images[0]!.attachment)
|
||||
expect(Buffer.from(stored.data)).toEqual(await readFile(path))
|
||||
expect(cost(recovered.content)).toBeLessThanOrEqual(CAP)
|
||||
expect(saves).toHaveBeenCalledTimes(1)
|
||||
expect(await requestImages(recovered.content)).toEqual(['green'])
|
||||
})
|
||||
|
||||
it.each(['sequential', 'parallel'] as const)('keeps budgets and image identities separate across %s Node PTC calls', async (mode) => {
|
||||
const { ctx, session, execute, cost, requestImages, gates } = await setup()
|
||||
await ctx.plugin(Subprocess)
|
||||
await ctx.plugin(Sandbox, {})
|
||||
await ctx.plugin(SandboxPolicy, { mode: 'danger-full-access' })
|
||||
await ctx.plugin(NodeRuntime)
|
||||
if (mode === 'parallel') {
|
||||
const overlap = Promise.withResolvers<undefined>()
|
||||
onTestFinished(() => overlap.resolve(undefined))
|
||||
let entered = 0
|
||||
const gate = () => { if (++entered === 2) overlap.resolve(undefined); return overlap.promise }
|
||||
gates.set('ends', gate)
|
||||
gates.set('middle', gate)
|
||||
}
|
||||
const calls = mode === 'parallel'
|
||||
? 'await Promise.all([tools.ends({}), tools.middle({}), tools.small({})])'
|
||||
: '[await tools.ends({}), await tools.middle({}), await tools.small({})]'
|
||||
const result = await execute('run_code', {
|
||||
code: `const [ends, middle, small] = ${calls}; return { ends: ends.content[2].text.length, middle: middle.content[0].text.length, small: small.content[0].text.length };`,
|
||||
description: 'Inspect independent image result budgets',
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.value).toMatchObject({ result: { ends: LONG.length, middle: LONG.length, small: SMALL.length } })
|
||||
const dispatches = session.snapshotEvents().filter(event => event.type === 'tool/ptc-dispatch').map(event => event.data)
|
||||
expect(dispatches).toHaveLength(3)
|
||||
for (const dispatch of dispatches) expect(cost(dispatch.content)).toBeLessThanOrEqual(CAP)
|
||||
expect(dispatches.find(event => event.name === 'ends')?.content.filter(block => block.type === 'image')).toHaveLength(2)
|
||||
expect(textOf(dispatches.find(event => event.name === 'middle')!.content)).toContain('Omitted 1 images.')
|
||||
expect(dispatches.find(event => event.name === 'small')?.content).toEqual([text(SMALL)])
|
||||
expect(result.additionalContexts).toHaveLength(2)
|
||||
expect(await requestImages(result.content, result.additionalContexts)).toEqual(['red', 'blue'])
|
||||
})
|
||||
})
|
||||
Generated
+9
@@ -11366,6 +11366,9 @@ importers:
|
||||
'@deepseek-ai/dsh-attachment':
|
||||
specifier: workspace:^
|
||||
version: link:../../attachment/attachment
|
||||
'@deepseek-ai/dsh-attachment-local':
|
||||
specifier: workspace:^
|
||||
version: link:../../attachment/attachment-local
|
||||
'@deepseek-ai/dsh-fs':
|
||||
specifier: workspace:^
|
||||
version: link:../../fs/fs
|
||||
@@ -11414,9 +11417,15 @@ importers:
|
||||
'@deepseek-ai/dsh-token-meter':
|
||||
specifier: workspace:^
|
||||
version: link:../../llm/token-meter
|
||||
'@deepseek-ai/dsh-tool-fs':
|
||||
specifier: workspace:^
|
||||
version: link:../../fs/tool-fs
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
sharp:
|
||||
specifier: ^0.35.3
|
||||
version: 0.35.3(@types/[email protected])
|
||||
|
||||
packages/ssh/fs-ssh:
|
||||
dependencies:
|
||||
|
||||
Reference in new issue
Block a user