Files
deepseek-harness/apps/cli/tests/profiles/sdk/office-cli.e2e.ts
T

128 lines
7.7 KiB
TypeScript

/**
* PDF export through the shipped SDK profile: the agent loads an Office skill, converts with `office_convert`, and
* copies the temporary PDF into the workspace with the shell, with no Node on PATH.
* Defaults to built Node packages. DSH_OFFICE_TEST_EXECUTABLE/ENTRY select a relocated Node or Electron carrier;
* CARRIER=sdk selects the standalone executable. API=openai-completions selects a development gateway instead of the
* default Messages API.
*/
import { access, chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createInterface } from 'node:readline'
import { execa } from 'execa'
import { strToU8, zipSync } from 'fflate'
import { expect, it } from 'vitest'
const repo = fileURLToPath(new URL('../../../../../', import.meta.url))
const carrier = process.env.DSH_OFFICE_TEST_CARRIER ?? 'npm'
const executable = process.env.DSH_OFFICE_TEST_EXECUTABLE ?? process.execPath
const entry = process.env.DSH_OFFICE_TEST_ENTRY ?? join(repo, 'apps/cli/lib/bin.js')
/** One model tool call reported by the SDK event stream. */
interface ToolCall {
readonly name?: string
readonly arguments?: unknown
}
function callArguments(call: ToolCall): Record<string, unknown> {
if (typeof call.arguments !== 'string') throw new Error(`Tool call ${call.name} did not contain serialized arguments`)
return JSON.parse(call.arguments) as Record<string, unknown>
}
it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent exports a PDF with office_convert in the ${carrier} carrier without Node on PATH`, async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh office export '))
try {
const source = zipSync({
'[Content_Types].xml': strToU8('<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>'),
'_rels/.rels': strToU8('<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>'),
'word/document.xml': strToU8('<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Office PDF export</w:t></w:r></w:p><w:sectPr/></w:body></w:document>'),
})
await writeFile(join(root, 'input.docx'), source)
const patch = join(root, 'office.patch.yml')
await writeFile(patch, JSON.stringify([
...(process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? [{ id: 'llm-deepseek', disabled: true }, { id: 'llm-pi-ai', config: { providers: { 'office-test': { api: 'openai-completions', apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: process.env.DEEPSEEK_BASE_URL, compat: { thinkingFormat: 'deepseek' }, models: [{ id: process.env.MODEL_NAME ?? 'deepseek-v4-flash', contextWindow: 128000, maxTokens: 8000 }] } } } }] : []),
{ id: 'session-persistence-jsonl', config: { root: join(root, 'sessions'), compression: 'none' } },
]))
// A private POSIX tool directory also excludes /usr/bin/node on Linux.
const path = join(root, 'bin')
await mkdir(path)
for (const name of ['bash', 'sh', 'cat', 'cp', 'ls', 'head', 'tail', 'mkdir', 'od', 'wc']) {
const binary = (existsSync('/bin/' + name) ? '/bin/' : '/usr/bin/') + name
await access(binary)
await symlink(binary, join(path, name))
}
const decoyMarker = join(root, 'system-office-invoked')
for (const name of ['libreoffice', 'soffice']) {
const decoy = join(path, name)
await writeFile(decoy, '#!/bin/sh\nprintf invoked > ' + "'" + decoyMarker.replaceAll("'", "'\\''") + "'" + '\nexit 86\n')
await chmod(decoy, 0o755)
}
const lookup = await execa('/bin/sh', ['-c', 'command -v node'], { env: { PATH: path }, reject: false })
expect(lookup.exitCode, 'the fixture must not expose Node through PATH').not.toBe(0)
const child = execa(executable, [
...carrier === 'sdk' ? [] : [entry], '--profile', 'sdk', '--patch', patch,
], {
cwd: root, env: {
PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'),
DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : process.env.DSH_PRIMARY_RUNTIME,
},
timeout: 110_000, killSignal: 'SIGKILL', reject: false,
})
const lines = createInterface({ input: child.stdout })
const records: Record<string, unknown>[] = []
let protocolError: Error | undefined
const send = (id: number, method: string, params?: object): void => {
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n')
}
lines.on('line', (line) => {
try {
const value = JSON.parse(line) as Record<string, unknown>
records.push(value)
if (value.id === 1) send(2, 'session/prompt', { sessionId: 'main', contentBlocks: [{ type: 'text', text:
'Export input.docx to result.pdf in this directory. Use the supplied tools; do not install anything. Report briefly after the file exists.',
}] })
const params = value.params as { event?: { type?: string } } | undefined
if (value.error !== undefined || params?.event?.type === 'turn/end') send(3, 'shutdown')
} catch (error) {
protocolError = error instanceof Error ? error : new Error(String(error))
child.kill('SIGKILL')
}
})
try {
send(1, 'initialize', { cwd: root, provider: process.env.DSH_OFFICE_TEST_API === 'openai-completions' ? 'office-test' : 'deepseek-official', model: process.env.MODEL_NAME ?? 'deepseek-v4-flash', maxTokens: 8000 })
const result = await child
const diagnostic = `${result.stderr}\n${result.stdout}`
expect(protocolError, diagnostic).toBeUndefined()
expect(result.timedOut, diagnostic).toBe(false)
expect(result.signal, diagnostic).toBeUndefined()
expect(result.exitCode, diagnostic).toBe(0)
expect(records.filter(record => record.error !== undefined), diagnostic).toEqual([])
const calls = records.flatMap((record) => {
const params = record.params as { event?: { type: string; data: ToolCall } } | undefined
return params?.event?.type === 'tool/call' ? [params.event.data] : []
})
const skill = calls.findIndex(call => call.name === 'skill')
const convert = calls.findIndex(call => call.name === 'office_convert' && callArguments(call).format === 'pdf')
const copy = calls.findIndex(call => call.name === 'bash' && /\bcp\b/u.test(String(callArguments(call).command)))
expect(skill, diagnostic).toBeGreaterThanOrEqual(0)
expect(convert, diagnostic).toBeGreaterThan(skill)
expect(copy, diagnostic).toBeGreaterThan(convert)
expect(existsSync(decoyMarker), 'the agent must not invoke system Office').toBe(false)
const pdf = await readFile(join(root, 'result.pdf'))
expect(pdf.subarray(0, 5).toString()).toBe('%PDF-')
expect(pdf.length).toBeGreaterThan(1000)
expect(await readFile(join(root, 'input.docx'))).toEqual(Buffer.from(source))
} finally {
lines.close()
child.stdin.end()
child.kill('SIGKILL')
await child
}
} finally {
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
}
})