The native adapter's route was named deepseek, colliding with pi-ai's catalog provider of the same name, so the two DeepSeek paths could never be mounted side by side. The web settings page needs both configurable at once. Compositions, fixtures, goldens, scaffolding defaults, and docs all move together (pre-release, no shim); TUI/session-query-spill/ missing-credential goldens re-recorded through their keyless refresh modes because provider-name length shifts box padding and spill truncation points.
190 lines
7.3 KiB
TypeScript
190 lines
7.3 KiB
TypeScript
import { createServer } from 'node:http'
|
|
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { promisify } from 'node:util'
|
|
import { zstdDecompress } from 'node:zlib'
|
|
import { execa } from 'execa'
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
const binScript = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url))
|
|
const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url))
|
|
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url))
|
|
const decompress = promisify(zstdDecompress)
|
|
|
|
function waitForLine(
|
|
lines: string[],
|
|
predicate: (value: Record<string, unknown>) => boolean,
|
|
stderr: () => string,
|
|
): Promise<Record<string, unknown>> {
|
|
return new Promise((resolve, reject) => {
|
|
const deadline = Date.now() + 30_000
|
|
const poll = (): void => {
|
|
while (lines.length > 0) {
|
|
const line = lines.shift()!
|
|
if (!line.trim()) continue
|
|
try {
|
|
const value = JSON.parse(line) as Record<string, unknown>
|
|
if (predicate(value)) {
|
|
resolve(value)
|
|
return
|
|
}
|
|
} catch {
|
|
reject(new Error(`non-JSON stdout from JSON-RPC agent runtime: ${line}`))
|
|
return
|
|
}
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
reject(new Error(`timed out waiting for JSON-RPC response; stderr=${stderr()}`))
|
|
return
|
|
}
|
|
setTimeout(poll, 10)
|
|
}
|
|
poll()
|
|
})
|
|
}
|
|
|
|
describe('jsonrpc-agent keyless smoke', () => {
|
|
it.each([
|
|
{ label: 'accepts max-token results by default', envValue: undefined, expectedStatus: 'ok' },
|
|
{ label: 'accepts max-token results when enabled through env', envValue: 'true', expectedStatus: 'ok' },
|
|
{ label: 'reports max-token results as errors when disabled through env', envValue: 'false', expectedStatus: 'error' },
|
|
])('$label', async ({ envValue, expectedStatus }) => {
|
|
const root = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-agent-smoke-'))
|
|
const modelRequests: Record<string, unknown>[] = []
|
|
const modelServer = createServer((request, response) => {
|
|
let body = ''
|
|
request.setEncoding('utf8')
|
|
request.on('data', (chunk: string) => { body += chunk })
|
|
request.on('end', () => {
|
|
modelRequests.push(JSON.parse(body) as Record<string, unknown>)
|
|
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n')
|
|
response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
|
|
response.write('data: {"choices":[{"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
|
|
response.end('data: [DONE]\n\n')
|
|
})
|
|
})
|
|
await new Promise<void>(resolve => modelServer.listen(0, '127.0.0.1', resolve))
|
|
const address = modelServer.address()
|
|
if (address === null || typeof address === 'string') throw new Error('model server did not bind a TCP port')
|
|
// The line-predicate protocol driving below is the genuinely custom part;
|
|
// execa owns spawn, the deadline, and exit settlement around it.
|
|
const child = execa(process.execPath, [
|
|
'--import',
|
|
'tsx',
|
|
binScript,
|
|
configPath,
|
|
], {
|
|
cwd: repoRoot,
|
|
env: {
|
|
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
|
|
DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
|
|
DSH_CWD: root,
|
|
DSH_SESSION_ROOT: join(root, '.sessions'),
|
|
...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }),
|
|
},
|
|
timeout: 35_000,
|
|
killSignal: 'SIGKILL',
|
|
reject: false,
|
|
})
|
|
const lines: string[] = []
|
|
let stdoutBuffer = ''
|
|
let stderr = ''
|
|
child.stdout.on('data', (chunk: Buffer) => {
|
|
stdoutBuffer += chunk.toString('utf8')
|
|
const parts = stdoutBuffer.split('\n')
|
|
stdoutBuffer = parts.pop() ?? ''
|
|
lines.push(...parts)
|
|
})
|
|
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
|
|
|
try {
|
|
child.stdin.write(`${JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
method: 'initialize',
|
|
params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro', maxTokens: 1234 },
|
|
})}\n`)
|
|
const initialized = await waitForLine(lines, value => value.id === 1, () => stderr)
|
|
expect(initialized).toMatchObject({
|
|
jsonrpc: '2.0',
|
|
id: 1,
|
|
result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } },
|
|
})
|
|
|
|
child.stdin.write(`${JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: 2,
|
|
method: 'session/prompt',
|
|
params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'inspect tools' }] },
|
|
})}\n`)
|
|
const finished = await waitForLine(lines, value => value.method === 'session.finished', () => stderr)
|
|
expect(finished).toMatchObject({
|
|
jsonrpc: '2.0',
|
|
method: 'session.finished',
|
|
params: {
|
|
sessionId: 'main',
|
|
status: expectedStatus,
|
|
reason: { kind: 'max-tokens' },
|
|
},
|
|
})
|
|
const prompt = await waitForLine(lines, value => value.id === 2, () => stderr)
|
|
expect(prompt).toMatchObject({ jsonrpc: '2.0', id: 2, result: { accepted: true } })
|
|
const tools = modelRequests[0]?.tools as { function?: { name?: string } }[]
|
|
expect(modelRequests[0]?.max_tokens).toBe(1234)
|
|
expect(tools.map(tool => tool.function?.name).sort()).toEqual([
|
|
'bash',
|
|
'edit',
|
|
'read',
|
|
'subagent',
|
|
'todo_write',
|
|
'write',
|
|
])
|
|
|
|
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`)
|
|
const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr)
|
|
expect(shutdown).toMatchObject({ jsonrpc: '2.0', id: 3, result: {} })
|
|
const exit = await child
|
|
expect(exit.exitCode, `signal=${String(exit.signal)}; stderr=${stderr}`).toBe(0)
|
|
const sessionsRoot = join(root, '.sessions')
|
|
const files = await readdir(sessionsRoot, { recursive: true })
|
|
const log = files.find(file => file.endsWith('.jsonl.zstd'))
|
|
expect(log).toBeDefined()
|
|
const compressed = await readFile(join(sessionsRoot, log!))
|
|
expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd')
|
|
expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' })
|
|
} finally {
|
|
// No-op after exit; reject: false settles on every outcome, so cleanup never races teardown.
|
|
child.kill('SIGKILL')
|
|
await child
|
|
await new Promise<void>(resolve => modelServer.close(() => { resolve() }))
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
}, 40_000)
|
|
|
|
it('rejects an invalid max-token success env value', async () => {
|
|
const { exitCode, stdout, stderr } = await execa(process.execPath, [
|
|
'--import',
|
|
'tsx',
|
|
binScript,
|
|
configPath,
|
|
], {
|
|
cwd: repoRoot,
|
|
env: {
|
|
DEEPSEEK_API_KEY: 'keyless-smoke-no-call',
|
|
DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes',
|
|
},
|
|
stdin: 'ignore',
|
|
timeout: 25_000,
|
|
killSignal: 'SIGKILL',
|
|
reject: false,
|
|
})
|
|
|
|
expect(exitCode, stderr).toBe(1)
|
|
expect(stdout).toBe('')
|
|
expect(stderr).toContain('plugin(s) failed to load: @deepseek-ai/dsh-jsonrpc')
|
|
}, 30_000)
|
|
})
|