feat: add DeepSeek Harness runtime
This commit is contained in:
@@ -0,0 +1,920 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user