import { describe, expect, it, vi } from 'vitest' import { resolve } from 'node:path' import { realpath } from 'node:fs/promises' import type { RuntimeEvent } from './runtime' import { ModelToolProvider, type ModelToolDefinition, type ModelToolProviderLike } from './model-tool-provider' import { deepSeekHarnessStartupBudget } from './deepseek-harness-control-protocol' 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() { let resolve!: (value: T) => void let reject!: (error: unknown) => void const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise reject = rejectPromise }) return { promise, resolve, reject } } function setup( options: { toolProvider?: ModelToolProviderLike skillPackages?: Array<{ id: string; directory: string }> nativeSkills?: Array> nativeTools?: Array> toolsSupported?: boolean promptTimeoutMs?: number useProductionPromptTimeout?: boolean maxEventCharacters?: number supportsImageInput?: boolean requestHeaders?: Record advertisedImageInput?: boolean initializationTimeoutMs?: number useDefaultInitializationTimeout?: boolean launchDelayMs?: number prepare?: () => Promise> extensionPackages?: Array<{ id: string entrypoint: string configuration: Record }> launch?: ( options: Parameters< ConstructorParameters[0]['launch'] >[0] ) => Promise } = {} ) { const exit = deferred<{ exitCode: number | null signal?: string | null }>() const stderr = new TransformStream() const child: DeepSeekHarnessChild = { stdin: new WritableStream(), stdout: new ReadableStream(), stderr: stderr.readable, exited: exit.promise, terminate: vi.fn() } let permissionHandler: | ((params: Permission) => Promise) | undefined let updateHandler: | ((context: { sessionId: string update: Record }) => Promise) | undefined let extensionHandler: | (( method: string, params: Record ) => Promise>) | undefined const requests: Array<{ method: string params: Record }> = [] const notifications: Array<{ method: string params: Record }> = [] const promptGates: Array>> = [] let sessionIndex = 0 const connectionClosed = deferred() const connectionController = new AbortController() const requestAgent = async ( method: string, params: Record ) => { requests.push({ method, params }) if (method === 'initialize') { return { protocolVersion: 1, agentCapabilities: { promptCapabilities: { image: options.advertisedImageInput ?? (options.supportsImageInput === true) } } } } 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 ) => { notifications.push({ method, params }) } const agent = { initialize: vi.fn((params: Record) => requestAgent('initialize', params) ), newSession: vi.fn((params: Record) => requestAgent('session/new', params) ), prompt: vi.fn((params: Record) => requestAgent('session/prompt', params) ), cancel: vi.fn((params: Record) => notifyAgent('session/cancel', params) ), extMethod: vi.fn( async (method: string, params: Record) => { requests.push({ method, params }) if (method === 'goodbuddy/handshake') { return { controlProtocolVersion: 2, harnessVersion: '0.1.7-rc.2', acpProtocolVersion: 1, supports: { cancellation: true, sessionRelease: true, reasoningEvents: true, toolEvents: true, usageEvents: true, credentialResolution: true }, execution: { mode: 'host' } } } if (method === 'goodbuddy/session/prepare') { return options.prepare?.() ?? { prepared: true } } if (method === 'goodbuddy/session/release') { return { released: true } } if (method === 'goodbuddy/native/snapshot') { return { skills: options.nativeSkills ?? [], tools: options.nativeTools ?? [], toolsSupported: options.toolsSupported ?? 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 ) => Promise> extNotification: ( method: string, params: Record ) => Promise } ) { const client = toClient(agent) permissionHandler = client.requestPermission updateHandler = client.sessionUpdate extensionHandler = client.extMethod agent.extNotification.mockImplementation( async ( method: string, params: Record ) => 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( options.launch ?? (async () => { if (options.launchDelayMs !== undefined) { await new Promise((resolve) => setTimeout(resolve, options.launchDelayMs) ) } return child }) ) const runtime = new DeepSeekHarnessRuntime({ defaultWorkspace: process.cwd(), baseUrl: 'https://api.deepseek.com', model: 'deepseek-test', supportsImageInput: options.supportsImageInput, requestHeaders: options.requestHeaders, launch, loadAcpSdk: async () => sdk, ...(options.useDefaultInitializationTimeout ? {} : { initializationTimeoutMs: options.initializationTimeoutMs ?? 100 }), ...(options.useProductionPromptTimeout ? {} : { promptTimeoutMs: options.promptTimeoutMs ?? 100 }), shutdownTimeoutMs: 10, maxStderrBytes: 16, maxEventCharacters: options.maxEventCharacters, toolProvider: options.toolProvider, skillPackages: options.skillPackages, extensionPackages: options.extensionPackages }) const emit = async ( sessionId: string, update: Record ): Promise => { 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 ) => extensionHandler?.(method, params), notify: ( method: string, params: Record ) => agent.extNotification(method, params), emit } } async function collect( stream: AsyncGenerator ): Promise { const events: RuntimeEvent[] = [] for await (const event of stream) { events.push(event) } return events } function request(conversationId: string) { return { requestId: `request-${conversationId}`, conversationId, prompt: 'hello', } 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 webTool( name: 'web_search' | 'web_fetch' = 'web_search' ): ModelToolDefinition { return { name, displayName: name === 'web_search' ? '联网搜索' : '网页读取', description: `Main-owned ${name}`, inputSchema: { type: 'object', properties: name === 'web_search' ? { query: { type: 'string', minLength: 1, maxLength: 1_000 }, numResults: { type: 'integer', minimum: 1, maximum: 10, default: 6 } } : { urls: { type: 'array', minItems: 1, maxItems: 5, items: { type: 'string', format: 'uri' } }, maxCharacters: { type: 'integer', minimum: 1, maximum: 12_000, default: 4_000 } }, required: [name === 'web_search' ? 'query' : 'urls'], additionalProperties: false }, source: 'builtin' } } 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('reports passive readiness without launching a Host or reading a model', async () => { const harness = setup() await expect(harness.runtime.getStatus()).resolves.toMatchObject({ available: true, detail: expect.stringContaining('首次使用') }) expect(harness.launch).not.toHaveBeenCalled() await harness.runtime.dispose() expect(harness.launch).not.toHaveBeenCalled() }) it('includes bounded failed-extension cleanup in the startup budget', () => { expect(deepSeekHarnessStartupBudget(11)).toEqual({ hostTimeoutMs: 76_000, mainTimeoutMs: 78_000 }) expect(deepSeekHarnessStartupBudget(64)).toEqual({ hostTimeoutMs: 101_000, mainTimeoutMs: 103_000 }) }) it('expands the default launcher deadline for enabled extensions', async () => { vi.useFakeTimers() try { const harness = setup({ useDefaultInitializationTimeout: true, launchDelayMs: 10_001, extensionPackages: [ { id: 'slow-one', entrypoint: 'C:\\extensions\\slow-one.js', configuration: {} }, { id: 'slow-two', entrypoint: 'C:\\extensions\\slow-two.js', configuration: {} } ] }) const status = harness.runtime.testConnection() await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(10_001) await expect(status).resolves.toMatchObject({ available: true }) expect(harness.child.terminate).not.toHaveBeenCalled() const disposal = harness.runtime.dispose() await vi.advanceTimersByTimeAsync(10) await disposal } finally { vi.useRealTimers() } }) it('keeps the default no-extension launcher deadline bounded', async () => { vi.useFakeTimers() try { let launchSignal: AbortSignal | undefined const harness = setup({ useDefaultInitializationTimeout: true, launch: (options) => { launchSignal = options.signal return new Promise( () => undefined ) } }) const status = harness.runtime.testConnection() await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(12_000) await expect(status).resolves.toMatchObject({ available: false, detail: 'DeepSeek Harness 启动超时' }) expect(launchSignal?.aborted).toBe(true) } finally { vi.useRealTimers() } }) it('aborts a pending launch and terminates a child returned after disposal', async () => { const launch = deferred() let launchSignal: AbortSignal | undefined const harness = setup({ launch: (options) => { launchSignal = options.signal return launch.promise } }) const status = harness.runtime.testConnection() await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce()) await harness.runtime.dispose() expect(launchSignal?.aborted).toBe(true) launch.resolve(harness.child) await expect(status).resolves.toMatchObject({ available: false, detail: 'DeepSeek Harness Runtime 已关闭' }) expect(harness.child.terminate).toHaveBeenCalledOnce() }) 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('rejects images before launch when the selected model is text-only', async () => { const harness = setup() await expect( collect( harness.runtime.run( { ...request('text-only-image'), images: [ { name: 'reference.png', mediaType: 'image/png', data: 'aW1hZ2U=' } ] }, new AbortController().signal ) ) ).rejects.toThrow('未启用图像输入') expect(harness.launch).not.toHaveBeenCalled() }) it('forwards inline images when the selected model supports them', async () => { const harness = setup({ supportsImageInput: true }) const running = collect( harness.runtime.run( { ...request('vision'), images: [ { name: 'reference.png', mediaType: 'image/png', data: 'aW1hZ2U=' } ] }, new AbortController().signal ) ) await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) expect(harness.launch).toHaveBeenCalledWith( expect.objectContaining({ supportsImageInput: true }) ) expect( harness.requests.find( (entry) => entry.method === 'session/prompt' )?.params ).toMatchObject({ prompt: [ { type: 'text', text: 'hello' }, { type: 'image', mimeType: 'image/png', data: 'aW1hZ2U=' } ] }) harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) await expect(running).resolves.toContainEqual( expect.objectContaining({ type: 'done' }) ) }) it('fails closed when Host image capability disagrees with the model', async () => { const harness = setup({ supportsImageInput: true, advertisedImageInput: false }) await expect(harness.runtime.testConnection()).resolves.toMatchObject({ available: false, detail: expect.stringContaining('图片能力') }) expect(harness.child.terminate).toHaveBeenCalled() }) it('uses ACP stdio, maps conversations to sessions, and streams text', async () => { const harness = setup({ requestHeaders: { 'x-tenant-id': 'harness-tenant' } }) 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: await realpath(process.cwd()), signal: expect.any(AbortSignal), baseUrl: 'https://api.deepseek.com', model: 'deepseek-test', supportsImageInput: false, requestHeaders: { 'x-tenant-id': 'harness-tenant' }, credentialRefs: [], skillPackages: [], extensionPackages: [] }) expect(harness.requests).toContainEqual({ method: 'goodbuddy/session/prepare', params: { sessionId: 'session-1', requestId: 'request-one', } }) 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.each([ ['completed', undefined, 'GoodBuddy'], ['failed', undefined, 'GoodBuddy'], ['completed', 'Search the release notes', 'Search the release notes'], ['failed', 'Search the release notes', 'Search the release notes'] ] as const)('keeps tool input and summary on incremental %s updates with title %s', async (status, title, summary) => { const harness = setup() const running = collect( harness.runtime.run( request('tool-events'), new AbortController().signal ) ) await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) await harness.emit('session-1', { sessionUpdate: 'tool_call', toolCallId: 'call-web-search', name: 'web_search', title, status: 'pending', rawInput: { query: 'GoodBuddy' } }) await harness.emit('session-1', { sessionUpdate: 'tool_call_update', toolCallId: 'call-web-search', name: 'tool', status, rawOutput: 'search result' }) harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) expect(await running).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'tool', callId: 'call-web-search', name: 'web_search', state: 'pending', summary }), expect.objectContaining({ type: 'tool', callId: 'call-web-search', name: 'web_search', state: status, summary, input: expect.stringContaining('GoodBuddy') }) ]) ) await harness.runtime.dispose() }) it('streams beyond 4 MiB without cancelling the task', async () => { const harness = setup({ maxEventCharacters: 100_000 }) const running = collect( harness.runtime.run( request('output-limit'), new AbortController().signal ) ) await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) for (let index = 0; index < 65; index++) { await harness.notify('goodbuddy/session/event', { sessionId: 'session-1', requestId: 'request-output-limit', type: 'reasoning', delta: 'x'.repeat(65_536) }) } harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) const events = await running expect(events.filter((event) => event.type === 'reasoning')).toHaveLength(65) 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('sends GoodBuddy history only when it creates the native session', async () => { const harness = setup() const history = [ { role: 'user' as const, content: 'earlier question' }, { role: 'assistant' as const, content: 'earlier answer' } ] for (const [index, prompt] of ['first', 'second'].entries()) { const running = collect( harness.runtime.run( { ...request('reused'), requestId: `request-${prompt}`, prompt, history }, new AbortController().signal ) ) await vi.waitFor(() => expect(harness.promptGates).toHaveLength(index + 1) ) harness.promptGates[index]!.resolve({ stopReason: 'end_turn' }) await running } const texts = harness.requests .filter(({ method }) => method === 'session/prompt') .map(({ params }) => (params.prompt as Array<{ text?: string }>)[0]?.text ) expect(texts[0]).toContain('') expect(texts[0]).toContain('earlier answer') expect(texts[1]).toBe('second') await harness.runtime.dispose() }) it('allows owned session permissions without calling the authorizer', async () => { const harness = setup() const authorize = vi.fn().mockResolvedValue('once') const running = collect( harness.runtime.run( request('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: 'allow-once' } }) expect(authorize).not.toHaveBeenCalled() expect(harness.requests).toContainEqual({ method: 'goodbuddy/session/prepare', params: { sessionId: 'session-1', requestId: 'request-ask', } }) harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) await running await harness.runtime.dispose() }) it('answers permissions without waiting for a second authorizer', async () => { const harness = setup() const authorize = vi.fn(() => new Promise(() => {})) 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).not.toHaveBeenCalled() const persistentOnly = permission('session-1') persistentOnly.options = persistentOnly.options.filter(option => option.kind !== 'allow_once') await expect(harness.permission(persistentOnly)).resolves.toEqual({ outcome: { outcome: 'selected', optionId: 'allow-always' } }) harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) await running await harness.runtime.dispose() }) it('lists only bounded Main proxy schemas without exposing server secrets', async () => { const provider = toolProvider([ mcpTool(), webTool(), { ...mcpTool('workspace_read_text'), source: 'builtin' } ]) const harness = setup({ toolProvider: provider }) const running = collect(harness.runtime.run( request('catalog'), new AbortController().signal )) await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1)) await expect( harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' }) ).resolves.toEqual({ tools: [ { name: mcpTool().name, description: mcpTool().description, inputSchema: mcpTool().inputSchema }, { name: 'web_search', description: webTool().description, inputSchema: { type: 'object', properties: { query: { type: 'string' }, numResults: { type: 'integer', default: 6 } }, required: ['query'], additionalProperties: false } } ] }) expect( JSON.stringify( await harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' }) ) ).not.toContain('secret') harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) await running await harness.runtime.dispose() }) it('exposes all registered proxy tools and calls web tools without approval', async () => { const provider = toolProvider([ webTool('web_search'), webTool('web_fetch'), mcpTool() ]) const harness = setup({ toolProvider: provider }) const authorize = vi.fn().mockResolvedValue('once') const running = collect( harness.runtime.run( request('web-ask'), new AbortController().signal, authorize ) ) await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) await expect( harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' }) ).resolves.toEqual({ tools: [ expect.objectContaining({ name: 'web_search' }), expect.objectContaining({ name: 'web_fetch' }), expect.objectContaining({ name: mcpTool().name }) ] }) await expect( harness.extension('goodbuddy/tools/call', { sessionId: 'session-1', name: 'web_search', arguments: { query: 'GoodBuddy' } }) ).resolves.toEqual({ content: [ { type: 'text', text: '{"asset":"cube"}' } ] }) expect(authorize).not.toHaveBeenCalled() expect(provider.getApproval).not.toHaveBeenCalled() expect(provider.callTool).toHaveBeenCalledWith( 'web_search', { query: 'GoodBuddy' }, expect.any(AbortSignal), expect.objectContaining({ conversationId: 'web-ask', }) ) harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) await running await harness.runtime.dispose() }) it('filters GoodBuddy assignments from the native Host inventory', async () => { const harness = setup({ skillPackages: [ { id: 'assigned-skill', directory: 'C:\\assigned' } ], nativeSkills: [ { id: 'assigned-skill', name: 'Assigned Skill', description: 'GoodBuddy assignment', source: 'bundled', provider: 'runtime' }, { id: 'plugin-skill', name: 'Plugin Skill', description: 'Host plugin contribution', source: 'custom', provider: 'third-party-plugin' } ], nativeTools: [ { id: 'read', name: 'read', description: 'Read a workspace file' }, { id: 'edit', name: 'edit', description: 'Edit a workspace file' }, { id: 'plugin_tool', name: 'plugin_tool', description: 'Plugin capability' } ] }) await expect(harness.runtime.getNativeSnapshot()).resolves.toEqual({ provider: 'deepseek-harness', available: true, inventoryStatus: 'available', detail: expect.stringContaining('GoodBuddy'), agents: [], toolsSupported: true, tools: [ { id: 'read', name: 'read', description: 'Read a workspace file', kind: 'read', source: 'runtime', }, { id: 'edit', name: 'edit', description: 'Edit a workspace file', kind: 'write', source: 'runtime', }, { id: 'plugin_tool', name: 'plugin_tool', description: 'Plugin capability', kind: 'other', source: 'plugin', } ], commands: [], lsp: [], formatters: [], mcpServers: [], skills: [ { id: 'plugin-skill', name: 'Plugin Skill', description: 'Host plugin contribution', source: 'plugin' } ], rules: [], prompts: [], resources: [], resourcesSupported: false, context: { strategy: 'unsupported', manualCompact: false, detail: expect.any(String) } }) await harness.runtime.dispose() }) it('reports a partial native inventory when Host tool discovery is unavailable', async () => { const harness = setup({ toolsSupported: false }) await expect(harness.runtime.getNativeSnapshot()).resolves.toMatchObject({ available: true, inventoryStatus: 'partial', tools: [], toolsSupported: false }) await harness.runtime.dispose() }) it('executes registered MCP calls without approval', async () => { const provider = toolProvider() const harness = setup({ toolProvider: provider }) const authorize = vi.fn().mockResolvedValue('once') const running = collect( harness.runtime.run( request('mcp-ask'), new AbortController().signal, authorize ) ) await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) await harness.extension('goodbuddy/tools/list', { sessionId: 'session-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).not.toHaveBeenCalled() expect(provider.callTool).toHaveBeenCalledOnce() harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) await running await harness.runtime.dispose() }) it('calls an assigned MCP tool from the registered catalog without approval', 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 harness.extension('goodbuddy/tools/list', { sessionId: 'session-1' }) vi.mocked(provider.listTools).mockRejectedValue(new Error('unrelated server unavailable')) 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).not.toHaveBeenCalled() expect(provider.listTools).toHaveBeenCalledOnce() expect(provider.callTool).toHaveBeenCalledWith( mcpTool().name, { kind: 'cube' }, expect.any(AbortSignal), expect.objectContaining({ conversationId: 'mcp-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', // Electron's execPath needs an env flag that the MCP SDK does not inherit. command: process.versions.electron ? 'node' : process.execPath, args: [ resolve('tests', 'fixtures', 'web-3d-game-mcp.mjs') ] } satisfies ResolvedMcpServer ], undefined, undefined, false, { runtimeTarget: 'deepseek-harness' }) 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 }> } ).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 expect(blueprint).toMatchObject({ acceptance: { testSurface: 'window.__GOODBUDDY_GAME__' } }) expect(authorize).not.toHaveBeenCalled() } finally { harness.promptGates[0]?.resolve({ stopReason: 'end_turn' }) await running.catch(() => undefined) await harness.runtime.dispose() } }) it('does not inherit generic approval blocking for registered MCP tools', 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 harness.extension('goodbuddy/tools/list', { sessionId: 'session-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(provider.callTool).toHaveBeenCalledOnce() expect(authorize).not.toHaveBeenCalled() harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) await running await harness.runtime.dispose() }) it('validates MCP arguments before execution', 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 harness.extension('goodbuddy/tools/list', { sessionId: 'session-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('does not submit a prompt after cancellation during preparation', async () => { const prepare = deferred>() const harness = setup({ prepare: () => prepare.promise }) const controller = new AbortController() const running = collect( harness.runtime.run( request('cancel-during-prepare'), controller.signal ) ) await vi.waitFor(() => expect( harness.requests.some( ({ method }) => method === 'goodbuddy/session/prepare' ) ).toBe(true) ) controller.abort(new Error('cancelled during preparation')) prepare.resolve({ prepared: true }) await expect(running).rejects.toThrow( 'cancelled during preparation' ) expect(harness.agent.prompt).not.toHaveBeenCalled() expect(harness.notifications).toContainEqual({ method: 'session/cancel', params: { sessionId: 'session-1' } }) await harness.runtime.dispose() }) it('releases an uncooperative cancelled session without terminating its peer', async () => { const provider = toolProvider() const harness = setup({ useProductionPromptTimeout: true, toolProvider: provider }) const controller = new AbortController() const running = collect( harness.runtime.run( request('uncooperative-cancel'), controller.signal ) ) await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) const peer = collect(harness.runtime.run( request('cancel-peer'), new AbortController().signal )) await vi.waitFor(() => expect(harness.promptGates).toHaveLength(2)) controller.abort(new Error('cancelled by user')) await expect(running).rejects.toThrow('cancelled by user') expect(harness.notifications).toContainEqual({ method: 'session/cancel', params: { sessionId: 'session-1' } }) expect(harness.requests).toContainEqual({ method: 'goodbuddy/session/release', params: { sessionId: 'session-1' } }) expect(harness.child.terminate).not.toHaveBeenCalled() expect(provider.releaseConversation).toHaveBeenCalledWith(request('uncooperative-cancel').conversationId) harness.promptGates[1]!.resolve({ stopReason: 'end_turn' }) expect((await peer).at(-1)?.type).toBe('done') 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.testConnection()).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.testConnection()).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() }) it('does not impose a default wall-clock deadline on prompts', async () => { vi.useFakeTimers() try { const harness = setup({ useProductionPromptTimeout: true }) const running = collect( harness.runtime.run( request('long-running'), new AbortController().signal ) ) await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1) ) await vi.advanceTimersByTimeAsync(25 * 60 * 60_000) expect(harness.notifications).not.toContainEqual({ method: 'session/cancel', params: { sessionId: 'session-1' } }) harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) await expect(running).resolves.toEqual([ expect.objectContaining({ type: 'status' }), expect.objectContaining({ type: 'done' }) ]) } finally { vi.useRealTimers() } }) })