Files
goodbuddy/src/main/agent/deepseek-harness-runtime.test.ts
T

921 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi } from 'vitest'
import { resolve } from 'node:path'
import type { RuntimeEvent } from './runtime'
import {
ModelToolProvider,
type ModelToolDefinition,
type ModelToolProviderLike
} from './model-tool-provider'
import type {
ResolvedMcpServer
} from '../capabilities/capability-service'
import {
DeepSeekHarnessRuntime,
harnessPromptError,
type DeepSeekHarnessAcpSdk,
type DeepSeekHarnessChild
} from './deepseek-harness-runtime'
import { RequestError } from '@agentclientprotocol/sdk'
type Permission = Parameters<
ReturnType<
ConstructorParameters<
DeepSeekHarnessAcpSdk['ClientSideConnection']
>[0]
>['requestPermission']
>[0]
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (error: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function setup(
options: {
toolProvider?: ModelToolProviderLike
promptTimeoutMs?: number
maxEventCharacters?: number
maxRequestOutputCharacters?: number
} = {}
) {
const exit = deferred<{
exitCode: number | null
signal?: string | null
}>()
const stderr = new TransformStream<Uint8Array, Uint8Array>()
const child: DeepSeekHarnessChild = {
stdin: new WritableStream<Uint8Array>(),
stdout: new ReadableStream<Uint8Array>(),
stderr: stderr.readable,
exited: exit.promise,
terminate: vi.fn()
}
let permissionHandler:
| ((params: Permission) => Promise<unknown>)
| undefined
let updateHandler:
| ((context: {
sessionId: string
update: Record<string, unknown>
}) => Promise<void>)
| undefined
let extensionHandler:
| ((
method: string,
params: Record<string, unknown>
) => Promise<Record<string, unknown>>)
| undefined
const requests: Array<{
method: string
params: Record<string, unknown>
}> = []
const notifications: Array<{
method: string
params: Record<string, unknown>
}> = []
const promptGates: Array<ReturnType<typeof deferred<{ stopReason: string }>>> =
[]
let sessionIndex = 0
const connectionClosed = deferred<void>()
const connectionController = new AbortController()
const requestAgent = async (
method: string,
params: Record<string, unknown>
) => {
requests.push({ method, params })
if (method === 'initialize') {
return {
protocolVersion: 1,
agentCapabilities: {}
}
}
if (method === 'session/new') {
sessionIndex += 1
return { sessionId: `session-${sessionIndex}` }
}
if (method === 'session/prompt') {
const gate = deferred<{ stopReason: string }>()
promptGates.push(gate)
return gate.promise
}
throw new Error(`unexpected request: ${method}`)
}
const notifyAgent = async (
method: string,
params: Record<string, unknown>
) => {
notifications.push({ method, params })
}
const agent = {
initialize: vi.fn((params: Record<string, unknown>) =>
requestAgent('initialize', params)
),
newSession: vi.fn((params: Record<string, unknown>) =>
requestAgent('session/new', params)
),
prompt: vi.fn((params: Record<string, unknown>) =>
requestAgent('session/prompt', params)
),
cancel: vi.fn((params: Record<string, unknown>) =>
notifyAgent('session/cancel', params)
),
extMethod: vi.fn(
async (method: string, params: Record<string, unknown>) => {
requests.push({ method, params })
if (method === 'goodbuddy/handshake') {
return {
controlProtocolVersion: 1,
harnessVersion: '0.1.0-rc.6',
acpProtocolVersion: 1,
supports: {
cancellation: true,
sessionRelease: true,
oneShotApproval: true,
reasoningEvents: true,
toolEvents: true,
usageEvents: true,
credentialResolution: true
},
sandbox: {
provider: 'test',
enforcement: 'full'
}
}
}
if (method === 'goodbuddy/session/prepare') {
return { prepared: true }
}
if (method === 'goodbuddy/session/release') {
return { released: true }
}
if (method === 'goodbuddy/shutdown') {
return { shutdown: true }
}
throw new Error(`unexpected extension: ${method}`)
}
),
extNotification: vi.fn()
}
const connection = {
...agent,
signal: connectionController.signal,
closed: connectionClosed.promise
}
const ClientSideConnection = vi.fn(function (
this: unknown,
toClient: (
connectedAgent: typeof agent
) => {
requestPermission: typeof permissionHandler
sessionUpdate: typeof updateHandler
extMethod: (
method: string,
params: Record<string, unknown>
) => Promise<Record<string, unknown>>
extNotification: (
method: string,
params: Record<string, unknown>
) => Promise<void>
}
) {
const client = toClient(agent)
permissionHandler = client.requestPermission
updateHandler = client.sessionUpdate
extensionHandler = client.extMethod
agent.extNotification.mockImplementation(
async (
method: string,
params: Record<string, unknown>
) => client.extNotification(method, params)
)
return connection
})
const sdk = {
PROTOCOL_VERSION: 1,
ClientSideConnection,
ndJsonStream: vi.fn(() => ({ stream: true }))
} as unknown as DeepSeekHarnessAcpSdk
const launch = vi.fn(async () => child)
const runtime = new DeepSeekHarnessRuntime({
defaultWorkspace: 'C:\\workspace',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
launch,
loadAcpSdk: async () => sdk,
initializationTimeoutMs: 100,
promptTimeoutMs: options.promptTimeoutMs ?? 100,
shutdownTimeoutMs: 10,
maxStderrBytes: 16,
maxEventCharacters: options.maxEventCharacters,
maxRequestOutputCharacters:
options.maxRequestOutputCharacters,
toolProvider: options.toolProvider
})
const emit = async (
sessionId: string,
update: Record<string, unknown>
): Promise<void> => {
await updateHandler?.({ sessionId, update })
}
return {
runtime,
child,
stderr,
exit,
sdk,
launch,
requests,
notifications,
promptGates,
agent,
permission: async (request: Permission) =>
permissionHandler?.(request),
extension: (
method: string,
params: Record<string, unknown>
) => extensionHandler?.(method, params),
notify: (
method: string,
params: Record<string, unknown>
) => agent.extNotification(method, params),
emit
}
}
async function collect(
stream: AsyncGenerator<RuntimeEvent, void, void>
): Promise<RuntimeEvent[]> {
const events: RuntimeEvent[] = []
for await (const event of stream) {
events.push(event)
}
return events
}
function request(
conversationId: string,
workMode: 'ask' | 'execute' = 'execute'
) {
return {
requestId: `request-${conversationId}`,
conversationId,
prompt: 'hello',
workMode
} as const
}
function permission(sessionId: string): Permission {
return {
sessionId,
toolCall: {
toolCallId: 'call-1',
title: 'Run tests',
name: 'shell',
kind: 'execute',
rawInput: { command: 'npm test' }
},
options: [
{
optionId: 'allow-once',
name: 'Allow once',
kind: 'allow_once'
},
{
optionId: 'allow-always',
name: 'Always allow',
kind: 'allow_always'
},
{
optionId: 'reject',
name: 'Reject',
kind: 'reject_once'
}
]
}
}
function mcpTool(
name = 'mcp_deadbeef_cafebabe_game_asset'
): ModelToolDefinition {
return {
name,
displayName: 'Local Game Assets / game_asset',
description: 'Returns a deterministic local game asset manifest.',
inputSchema: {
type: 'object',
properties: {
kind: { type: 'string' }
},
required: ['kind'],
additionalProperties: false
},
source: 'mcp',
serverName: 'Local Game Assets'
}
}
function toolProvider(
tools: ModelToolDefinition[] = [mcpTool()]
): ModelToolProviderLike {
return {
listTools: vi.fn(async () => tools),
getApproval: vi.fn((tool, _arguments, summary) => ({
scopeKey: `model:mcp:${tool.name}`,
title: `允许调用 MCP 工具「${tool.displayName}」?`,
description: '调用本地测试 MCP。',
toolName: tool.displayName,
argumentSummary: summary,
allowPermanent: false
})),
callTool: vi.fn(async () => ({
parts: [
{
type: 'text' as const,
text: '{"asset":"cube"}'
}
],
contextBytes: 16
})),
releaseConversation: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined)
}
}
describe('DeepSeekHarnessRuntime', () => {
it('surfaces bounded internal Harness details from ACP errors', () => {
expect(
harnessPromptError(
RequestError.internalError({
details: 'DeepSeek provider rejected the request'
})
)
).toEqual(
new Error('DeepSeek provider rejected the request')
)
expect(
harnessPromptError(
RequestError.internalError({ unrelated: 'hidden' })
)
).toBeInstanceOf(RequestError)
})
it('uses ACP stdio, maps conversations to sessions, and streams text', async () => {
const harness = setup()
const first = collect(
harness.runtime.run(
request('one'),
new AbortController().signal
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await harness.emit('session-1', {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'hello ' }
})
await harness.emit('session-1', {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'world' }
})
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
expect(await first).toEqual([
expect.objectContaining({ type: 'status' }),
expect.objectContaining({ type: 'text', delta: 'hello ' }),
expect.objectContaining({ type: 'text', delta: 'world' }),
expect.objectContaining({
type: 'done',
sessionId: 'session-1'
})
])
expect(harness.sdk.ndJsonStream).toHaveBeenCalledWith(
harness.child.stdin,
harness.child.stdout
)
expect(harness.launch).toHaveBeenCalledWith({
cwd: 'C:\\workspace',
signal: expect.any(AbortSignal),
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-test',
credentialRefs: [],
requiredSandboxEnforcement: undefined,
skillPackages: []
})
expect(harness.requests).toContainEqual({
method: 'goodbuddy/session/prepare',
params: {
sessionId: 'session-1',
requestId: 'request-one',
mode: 'execute'
}
})
const second = collect(
harness.runtime.run(
request('one'),
new AbortController().signal
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(2)
)
harness.promptGates[1]!.resolve({ stopReason: 'end_turn' })
await second
expect(
harness.requests.filter(({ method }) => method === 'session/new')
).toHaveLength(1)
await harness.runtime.dispose()
})
it('enforces the cumulative bridge limit against complete wire events', async () => {
const harness = setup({
maxEventCharacters: 1_000,
maxRequestOutputCharacters: 180
})
const running = collect(
harness.runtime.run(
request('output-limit'),
new AbortController().signal
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await harness.notify('goodbuddy/session/event', {
sessionId: 'session-1',
requestId: 'request-output-limit',
type: 'reasoning',
delta: 'x'.repeat(40)
})
await harness.notify('goodbuddy/session/event', {
sessionId: 'session-1',
requestId: 'request-output-limit',
type: 'reasoning',
delta: 'y'.repeat(40)
})
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
await expect(running).rejects.toThrow(
'请求累计输出超过安全限制'
)
await harness.runtime.dispose()
})
it('keeps independent conversation sessions distinct', async () => {
const harness = setup()
const first = collect(
harness.runtime.run(
request('one'),
new AbortController().signal
)
)
const second = collect(
harness.runtime.run(
request('two'),
new AbortController().signal
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(2)
)
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
harness.promptGates[1]!.resolve({ stopReason: 'end_turn' })
await Promise.all([first, second])
const prompts = harness.requests.filter(
({ method }) => method === 'session/prompt'
)
expect(prompts.map(({ params }) => params.sessionId).sort()).toEqual([
'session-1',
'session-2'
])
await harness.runtime.dispose()
})
it('fails Ask closed and never calls the authorizer', async () => {
const harness = setup()
const authorize = vi.fn().mockResolvedValue('once')
const running = collect(
harness.runtime.run(
request('ask', 'ask'),
new AbortController().signal,
authorize
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await expect(
harness.permission(permission('session-1'))
).resolves.toEqual({
outcome: { outcome: 'selected', optionId: 'reject' }
})
expect(authorize).not.toHaveBeenCalled()
expect(harness.requests).toContainEqual({
method: 'goodbuddy/session/prepare',
params: {
sessionId: 'session-1',
requestId: 'request-ask',
mode: 'ask'
}
})
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
await running
await harness.runtime.dispose()
})
it('authorizes Execute but can select only allow-once', async () => {
const harness = setup()
const authorize = vi.fn().mockResolvedValue('always')
const running = collect(
harness.runtime.run(
request('execute'),
new AbortController().signal,
authorize
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await expect(
harness.permission(permission('session-1'))
).resolves.toEqual({
outcome: {
outcome: 'selected',
optionId: 'allow-once'
}
})
expect(authorize).toHaveBeenCalledWith({
scopeKey: 'deepseek-harness:shell',
title: 'Run tests',
description: 'DeepSeek Harness 请求一次性执行此工具',
toolName: 'shell',
argumentSummary: '{\n "command": "npm test"\n}',
allowPermanent: false
})
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
await running
await harness.runtime.dispose()
})
it('lists only bounded MCP schemas without exposing server secrets', async () => {
const provider = toolProvider([
mcpTool(),
{
...mcpTool('workspace_read_text'),
source: 'builtin'
}
])
const harness = setup({ toolProvider: provider })
await harness.runtime.getStatus()
await expect(
harness.extension('goodbuddy/tools/list', {
sessionId: 'session-catalog'
})
).resolves.toEqual({
tools: [
{
name: mcpTool().name,
description: mcpTool().description,
inputSchema: mcpTool().inputSchema
}
]
})
expect(
JSON.stringify(
await harness.extension('goodbuddy/tools/list', {
sessionId: 'session-catalog'
})
)
).not.toContain('secret')
await harness.runtime.dispose()
})
it('rejects MCP calls in Ask mode without approval or execution', async () => {
const provider = toolProvider()
const harness = setup({ toolProvider: provider })
const authorize = vi.fn().mockResolvedValue('once')
const running = collect(
harness.runtime.run(
request('mcp-ask', 'ask'),
new AbortController().signal,
authorize
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await expect(
harness.extension('goodbuddy/tools/call', {
sessionId: 'session-1',
name: mcpTool().name,
arguments: { kind: 'cube' }
})
).rejects.toThrow('需要 Execute 模式')
expect(authorize).not.toHaveBeenCalled()
expect(provider.callTool).not.toHaveBeenCalled()
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
await running
await harness.runtime.dispose()
})
it('requires one-time approval before calling an assigned MCP tool', async () => {
const provider = toolProvider()
const harness = setup({ toolProvider: provider })
const authorize = vi.fn().mockResolvedValue('once')
const running = collect(
harness.runtime.run(
request('mcp-execute'),
new AbortController().signal,
authorize
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await expect(
harness.extension('goodbuddy/tools/call', {
sessionId: 'session-1',
name: mcpTool().name,
arguments: { kind: 'cube' }
})
).resolves.toEqual({
content: [
{ type: 'text', text: '{"asset":"cube"}' }
]
})
expect(authorize).toHaveBeenCalledTimes(1)
expect(provider.callTool).toHaveBeenCalledWith(
mcpTool().name,
{ kind: 'cube' },
expect.any(AbortSignal),
expect.objectContaining({
conversationId: 'mcp-execute',
workMode: 'execute'
})
)
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
await running
await harness.runtime.dispose()
})
it('lists and calls a real local stdio MCP through the Main proxy', async () => {
const provider = new ModelToolProvider(process.cwd(), [
{
id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54',
name: 'Local 3D Game Blueprint',
description: 'Deterministic integration fixture',
enabled: true,
allowDynamicTools: false,
assignments: ['deepseek-harness'],
secretConfigured: false,
transport: 'stdio',
command: process.execPath,
args: [
resolve('tests', 'fixtures', 'web-3d-game-mcp.mjs')
]
} satisfies ResolvedMcpServer
])
const harness = setup({
toolProvider: provider,
promptTimeoutMs: 10_000
})
const authorize = vi.fn().mockResolvedValue('once')
const running = collect(
harness.runtime.run(
request('real-mcp'),
new AbortController().signal,
authorize
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
try {
const catalog = await harness.extension(
'goodbuddy/tools/list',
{ sessionId: 'session-1' }
)
const tool = (
catalog as {
tools: Array<{
name: string
description: string
inputSchema: Record<string, unknown>
}>
}
).tools.find((candidate) =>
candidate.name.endsWith('_create_game_blueprint')
)
expect(tool).toMatchObject({
description: expect.stringContaining(
'offline WebGL game design'
),
inputSchema: expect.objectContaining({ type: 'object' })
})
const result = await harness.extension(
'goodbuddy/tools/call',
{
sessionId: 'session-1',
name: tool!.name,
arguments: {
theme: 'neon-ruins',
seed: 'goodbuddy-0.9.0',
targetCount: 5
}
}
)
expect(result).toMatchObject({
content: [
{
type: 'text',
text: expect.stringContaining('"title":"Prism Relay"')
}
]
})
const blueprint = JSON.parse(
(
result as {
content: [{ type: 'text'; text: string }]
}
).content[0].text
) as Record<string, unknown>
expect(blueprint).toMatchObject({
acceptance: {
testSurface: 'window.__GOODBUDDY_GAME__'
}
})
expect(authorize).toHaveBeenCalledOnce()
} finally {
harness.promptGates[0]?.resolve({ stopReason: 'end_turn' })
await running.catch(() => undefined)
await harness.runtime.dispose()
}
})
it('does not execute an MCP tool when authorization is denied', async () => {
const provider = toolProvider()
const harness = setup({ toolProvider: provider })
const authorize = vi.fn().mockResolvedValue('deny')
const running = collect(
harness.runtime.run(
request('mcp-denied'),
new AbortController().signal,
authorize
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await expect(
harness.extension('goodbuddy/tools/call', {
sessionId: 'session-1',
name: mcpTool().name,
arguments: { kind: 'cube' }
})
).rejects.toThrow('未获执行授权')
expect(provider.callTool).not.toHaveBeenCalled()
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
await running
await harness.runtime.dispose()
})
it('validates MCP arguments before requesting authorization', async () => {
const provider = toolProvider()
const harness = setup({ toolProvider: provider })
const authorize = vi.fn().mockResolvedValue('once')
const running = collect(
harness.runtime.run(
request('mcp-invalid'),
new AbortController().signal,
authorize
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
await expect(
harness.extension('goodbuddy/tools/call', {
sessionId: 'session-1',
name: mcpTool().name,
arguments: {}
})
).rejects.toThrow('MCP 工具参数无效')
expect(authorize).not.toHaveBeenCalled()
expect(provider.callTool).not.toHaveBeenCalled()
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
await running
await harness.runtime.dispose()
})
it('translates AbortSignal to session/cancel', async () => {
const harness = setup()
const controller = new AbortController()
const running = collect(
harness.runtime.run(request('abort'), controller.signal)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
controller.abort(new Error('cancelled by user'))
harness.promptGates[0]!.resolve({ stopReason: 'cancelled' })
await expect(running).rejects.toThrow('cancelled by user')
expect(harness.notifications).toContainEqual({
method: 'session/cancel',
params: { sessionId: 'session-1' }
})
await harness.runtime.dispose()
})
it('fails on bounded stderr overflow without exposing stderr text', async () => {
const harness = setup()
const running = collect(
harness.runtime.run(
request('stderr'),
new AbortController().signal
)
)
await vi.waitFor(() =>
expect(harness.promptGates).toHaveLength(1)
)
const writer = harness.stderr.writable.getWriter()
await writer.write(
new TextEncoder().encode('private-secret-is-too-long')
)
await vi.waitFor(() =>
expect(harness.child.terminate).toHaveBeenCalled()
)
harness.promptGates[0]!.resolve({ stopReason: 'end_turn' })
await expect(running).rejects.toThrow('stderr 超过 16 字节')
await expect(running).rejects.not.toThrow('private-secret')
await harness.runtime.dispose()
})
it('reports process exit and fully disposes the connection and child', async () => {
const harness = setup()
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
available: true
})
harness.exit.resolve({ exitCode: 9 })
await vi.waitFor(async () => {
const status = await harness.runtime.getStatus()
expect(status).toMatchObject({
available: false,
detail: 'DeepSeek Harness 进程意外退出(code 9'
})
})
await harness.runtime.dispose()
expect(harness.child.terminate).toHaveBeenCalled()
})
it('fails closed when the required bridge handshake is unavailable', async () => {
const harness = setup()
harness.agent.extMethod.mockRejectedValueOnce(
new Error('method not found')
)
await expect(harness.runtime.getStatus()).resolves.toMatchObject({
available: false,
detail: 'method not found'
})
expect(harness.child.terminate).toHaveBeenCalled()
})
it('times out a prompt, cancels it, and bounds disposal wait', async () => {
const harness = setup()
const running = collect(
harness.runtime.run(
request('timeout'),
new AbortController().signal
)
)
await expect(running).rejects.toThrow(
'DeepSeek Harness 请求超时'
)
expect(harness.notifications).toContainEqual({
method: 'session/cancel',
params: { sessionId: 'session-1' }
})
await expect(harness.runtime.dispose()).resolves.toBeUndefined()
})
})