Files
goodbuddy/src/main/agent/native-dsh-web-client.test.ts
T
mesalogo f81f342cde feat: open local runtime clients from the composer
Add composer shortcuts for local Continue and OpenCode terminals and the official DeepSeek Harness Web client, using the current project and model bridge. Manage browser service reuse, stop actions and terminal cleanup. Upgrade DeepSeek Harness to 0.1.7-rc.2 and Cordis to 4.0.4.

Include the coordinated IPC handler extraction and related tests and documentation. Remote native-client coordinator wiring, managed Node consolidation, Ask MCP mappings and full desktop/package acceptance remain unfinished; this commit records the implemented local workflow and supporting SSH groundwork.

Validation: typecheck and lint passed; 267 focused tests passed and 5 skipped. No new real-provider calls were made for this commit. Prior real-model evidence and remaining acceptance work are documented in the feature progress files.

Release note: 本地项目选择 Continue 或 OpenCode 后,可从输入框下方打开原生终端;选择 DS 后,可打开使用当前项目和模型的官方 Web 客户端。客户端使用独立会话,DS 网页关闭后服务继续运行,可在 GoodBuddy 中停止;远程入口尚未完成。
2026-09-27 08:38:00 +08:00

227 lines
13 KiB
TypeScript

// @vitest-environment node
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { createServer } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeDshWebClientService, resolveNativeDshCli, type NativeDshWebRequest } from './native-dsh-web-client'
import { nativeDshMcpToolName, nativeDshWebPolicySource } from './native-dsh-web-policy'
const roots: string[] = []
const services: NativeDshWebClientService[] = []
afterEach(async () => {
await Promise.all(services.splice(0).map(service => service.dispose()))
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
async function temporary(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-native-dsh-'))
roots.push(root)
return root
}
const profile: NativeDshWebRequest['profile'] = {
id: 'test', name: 'Test', protocol: 'openai-chat-completions',
authentication: 'api-key', apiKey: 'provider-secret-must-stay-in-main',
baseUrl: 'http://127.0.0.1:1', modelName: 'test-model'
}
describe('native DS Web mode', () => {
it('enforces Ask tool names independently of agent mode and permits Execute', async () => {
const root = await temporary()
const file = join(root, 'policy.mjs')
await writeFile(file, nativeDshWebPolicySource)
const plugin = await import(/* @vite-ignore */ pathToFileURL(file).href)
for (const mode of ['ask', 'execute']) {
let execute!: (execution: unknown, next: () => string) => string
plugin.apply({ on: (_name: string, callback: typeof execute) => { execute = callback } }, {
workMode: mode, readOnlyTools: ['mcp__docs__lookup']
})
for (const name of ['read', 'skill', 'mcp__docs__lookup']) {
expect(execute({ name, agent: { mode: 'execute' } }, () => 'ran')).toBe('ran')
}
for (const name of ['write', 'edit', 'pwsh', 'bash', 'subagent', 'mcp__docs__delete']) {
const run = () => execute({ name, agent: { mode: 'execute' } }, () => 'ran')
if (mode === 'ask') expect(run).toThrow('GoodBuddy Ask mode')
else expect(run()).toBe('ran')
}
}
})
it('matches official MCP names without collisions after normalization', () => {
expect(nativeDshMcpToolName('docs', 'lookup')).toBe('mcp__docs__lookup')
expect(nativeDshMcpToolName('docs', 'a.b')).not.toBe(nativeDshMcpToolName('docs', 'a_b'))
expect(nativeDshMcpToolName('docs', 'a'.repeat(100))).toHaveLength(64)
})
it('resolves the packaged CLI outside ASAR', () => {
expect(resolveNativeDshCli('/resources')).toBe(join('/resources', 'runtimes/dsh/node_modules/@deepseek-ai/dsh/lib/bin.js'))
})
})
const standardNode = process.env.NATIVE_DSH_TEST_NODE ?? (!process.versions.electron ? process.execPath : undefined)
describe.skipIf(!standardNode)('official DS Web process', () => {
it('initializes the workspace, coalesces starts, isolates modes and preserves history on stop', async () => {
const root = await temporary()
const workspace = join(root, 'workspace')
await mkdir(workspace)
const service = new NativeDshWebClientService({
rootDirectory: join(root, 'homes'),
cliPath: process.env.NATIVE_DSH_TEST_CLI,
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env })
})
services.push(service)
const request: NativeDshWebRequest = { projectId: 'one', workspace, profile, workMode: 'ask' }
const [first, repeated] = await Promise.all([service.start(request), service.start(request)])
expect(first).toEqual(repeated)
const config = await readFile(join(root, 'homes', first.id, 'cordis.patch.yml'), 'utf8')
expect(config).toContain('test-model')
expect(config).not.toContain(profile.apiKey)
const auth = await fetch(first.url, { redirect: 'manual' })
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
const page = await fetch(new URL('/', first.url), { headers: { cookie } })
expect(page.status).toBe(200)
expect(await page.text()).toContain('<html')
expect(await service.start(request)).toEqual(first)
const history = join(root, 'homes', first.id, 'history-marker')
await writeFile(history, 'retained')
const execute = await service.start({ ...request, workMode: 'execute' })
expect(execute.id).not.toBe(first.id)
const changedConfiguration = await service.start({ ...request, configurationKey: 'updated-skill-digest' })
expect(changedConfiguration.id).not.toBe(first.id)
expect(await service.start({ ...request, configurationKey: 'updated-skill-digest' })).toEqual(changedConfiguration)
await service.stop(changedConfiguration.id)
await service.stop(first.id)
await expect(fetch(first.url)).rejects.toThrow()
expect(await readFile(history, 'utf8')).toBe('retained')
await expect(readFile(join(root, 'homes', first.id, 'cordis.patch.yml'))).rejects.toThrow()
expect((await fetch(execute.url, { redirect: 'manual' })).status).toBe(303)
const restarted = await service.start(request)
expect(restarted.id).toBe(first.id)
expect(restarted.url).not.toBe(first.url)
await service.dispose()
await expect(service.start(request)).rejects.toThrow('disposed')
}, 120_000)
it('routes official Web tool calls through the model bridge and enforces Ask at execution', async () => {
const root = await temporary()
const skills = join(root, 'skills')
await mkdir(skills)
await writeFile(join(skills, 'SKILL.md'), '---\nname: native-test\ndescription: Native test skill\n---\nSKILL_LOADED_MARKER')
const toolResults: string[] = []
let mcpCalls = 0
const mcp = createServer(async (request, response) => {
if (request.method !== 'POST') { response.writeHead(405).end(); return }
let body = ''
for await (const chunk of request) body += chunk
const message = JSON.parse(body)
if (message.id === undefined) { response.writeHead(202).end(); return }
let result: unknown = {}
if (message.method === 'initialize') result = { protocolVersion: message.params.protocolVersion,
capabilities: { tools: {} }, serverInfo: { name: 'test', version: '1' } }
if (message.method === 'tools/list') result = { tools: [
{ name: 'lookup', description: 'Read test data', inputSchema: { type: 'object', properties: {} } },
{ name: 'mutate', description: 'Write test data', inputSchema: { type: 'object', properties: {} } }
] }
if (message.method === 'tools/call') { mcpCalls++; result = { content: [{ type: 'text', text: 'MCP_CALLED_MARKER' }] } }
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }))
})
await new Promise<void>(resolve => mcp.listen(0, '127.0.0.1', resolve))
const address = mcp.address() as { port: number }
const fetcher: typeof fetch = async (_url, init) => {
expect(new Headers(init?.headers).get('authorization')).toBe(`Bearer ${profile.apiKey}`)
const body = JSON.parse(String(init?.body))
expect(body.model).toBe(profile.modelName)
const toolResult = body.messages.findLast((message: { role: string }) => message.role === 'tool')
const user = JSON.stringify(body.messages)
const toolName = user.includes('Load skill') ? 'skill' : user.includes('MCP read') ? 'mcp__test__lookup'
: user.includes('MCP write') ? 'mcp__test__mutate' : 'write'
const write = !toolResult && body.tools?.some((tool: { function: { name: string } }) => tool.function.name === toolName)
if (toolResult) toolResults.push(JSON.stringify(toolResult.content))
const delta = write ? { role: 'assistant', tool_calls: [{ index: 0, id: 'call_write', type: 'function',
function: { name: toolName, arguments: JSON.stringify(toolName === 'write'
? { file_path: join(root, 'written.txt'), content: 'EXECUTED' } : toolName === 'skill' ? { name: 'native-test' } : {}) } }] }
: { role: 'assistant', content: 'Finished' }
const event = (value: unknown) => `data: ${JSON.stringify(value)}\n\n`
return new Response(event({ id: 'test', object: 'chat.completion.chunk', model: profile.modelName,
choices: [{ index: 0, delta, finish_reason: null }] }) + event({ id: 'test', object: 'chat.completion.chunk',
choices: [{ index: 0, delta: {}, finish_reason: write ? 'tool_calls' : 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 } }) + 'data: [DONE]\n\n',
{ headers: { 'content-type': 'text/event-stream' } })
}
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), fetcher,
cliPath: process.env.NATIVE_DSH_TEST_CLI,
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
services.push(service)
try {
for (const workMode of ['ask', 'execute'] as const) {
const handle = await service.start({ projectId: 'tools', workspace: root, profile, workMode,
skillDirectories: [skills], mcpServers: [{ serverName: 'test', transport: 'streamable-http',
url: `http://127.0.0.1:${address.port}/mcp`, readOnlyTools: ['lookup'] }] })
const auth = await fetch(handle.url, { redirect: 'manual' })
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
async function rpc(method: string, request: unknown): Promise<Record<string, unknown>> {
const response = await fetch(new URL(`/api/${method}`, handle.url), { method: 'POST',
headers: { 'content-type': 'application/json', cookie, origin: new URL(handle.url).origin },
body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method, payload: { args: { request } } }) })
const payload = await response.json() as { result?: { ok?: boolean; value: Record<string, unknown> } }
expect(payload.result?.ok, JSON.stringify(payload)).toBe(true)
return payload.result!.value
}
const session = await rpc('session/create', { cwd: root })
const previous = toolResults.length
await rpc('session/prompt', { sessionId: session.sessionId, requestId: crypto.randomUUID(), mode: 'queue',
content: [{ type: 'text', text: 'Write the requested marker file.' }] })
await vi.waitFor(() => expect(toolResults.length).toBeGreaterThan(previous), { timeout: 15_000 })
if (workMode === 'ask') {
expect(toolResults.at(-1)).toContain('GoodBuddy Ask mode')
await expect(readFile(join(root, 'written.txt'))).rejects.toThrow()
} else {
expect(await readFile(join(root, 'written.txt'), 'utf8')).toBe('EXECUTED')
}
for (const prompt of ['Load skill', 'MCP read', 'MCP write']) {
const next = await rpc('session/create', { cwd: root })
const before = toolResults.length
const callsBefore = mcpCalls
await rpc('session/prompt', { sessionId: next.sessionId, requestId: crypto.randomUUID(), mode: 'queue',
content: [{ type: 'text', text: prompt }] })
await vi.waitFor(() => expect(toolResults.length).toBeGreaterThan(before), { timeout: 15_000 })
if (prompt === 'Load skill') await vi.waitFor(() => expect(toolResults.slice(before).join()).toContain('SKILL_LOADED_MARKER'), { timeout: 15_000 })
else if (prompt === 'MCP write' && workMode === 'ask') {
await vi.waitFor(() => expect(toolResults.slice(before).join()).toContain('does not allow tool: mcp__test__mutate'), { timeout: 15_000 })
expect(mcpCalls).toBe(callsBefore)
} else {
await vi.waitFor(() => expect(toolResults.slice(before).join()).toContain('MCP_CALLED_MARKER'), { timeout: 15_000 })
expect(mcpCalls).toBe(callsBefore + 1)
}
}
}
} finally {
await service.dispose()
mcp.closeAllConnections()
await new Promise<void>(resolve => mcp.close(() => resolve()))
}
}, 60_000)
it('cleans failed launches and permits retry', async () => {
const root = await temporary()
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), cliPath: join(root, 'missing.js'),
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
services.push(service)
const request: NativeDshWebRequest = { projectId: 'one', workspace: root, profile, workMode: 'ask' }
await expect(service.start(request)).rejects.toThrow()
await expect(service.start(request)).rejects.toThrow()
})
it('cancels an in-progress launch when disposed', async () => {
const root = await temporary()
const cliPath = join(root, 'waiting.mjs')
await writeFile(cliPath, 'setInterval(() => {}, 1000)')
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), cliPath,
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
services.push(service)
const starting = service.start({ projectId: 'pending', workspace: root, profile, workMode: 'ask' })
const rejected = expect(starting).rejects.toThrow()
await new Promise(resolve => setTimeout(resolve, 200))
await service.dispose()
await rejected
})
})