Connect Dify, FastGPT and RAGFlow through read-only bindings, encrypted local credentials, shared retrieval and persistent citations. Configuration and removal affect GoodBuddy only. Show active conversations across projects, retain older live work, and improve project dialog scrolling. Queue concurrent questions and route managed SSH OpenCode questions through the Agent. Preserve recoverable read/search errors and partial search results. Document the Electron UI automation standard, real model and external knowledge validation, and remaining acceptance limits. Validation: earlier full regression passed 4081 tests with 66 skipped; subsequent focused remote-question checks and real Linux evidence are recorded in feature progress. Precommit typecheck and lint passed; latest project dialog and shared UI suites passed 58 tests. The full suite was not rerun for the final dialog changes. Release note: Use external knowledge in conversations, find active work across projects, and answer concurrent local or managed SSH OpenCode questions. Managed SSH questions require the updated Agent adapter.
144 lines
6.9 KiB
TypeScript
144 lines
6.9 KiB
TypeScript
// @vitest-environment node
|
|
import { existsSync } from 'node:fs'
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { createServer } from 'node:http'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { expect, it } from 'vitest'
|
|
import { OpenCodeRuntime } from './opencode-runtime'
|
|
|
|
const cachedBinary = join(
|
|
process.cwd(), '.runtime-resources', process.arch,
|
|
process.platform === 'win32' ? 'opencode.exe' : 'opencode'
|
|
)
|
|
const binaryPath = process.env.GOODBUDDY_TEST_OPENCODE_BINARY || cachedBinary
|
|
|
|
it.skipIf(!existsSync(binaryPath))(
|
|
'completes native Task external reads and concurrent structured questions while Ask disables tools',
|
|
async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-permissions-'))
|
|
const workspace = join(root, 'workspace')
|
|
const externalFile = join(root, 'external.txt')
|
|
await mkdir(workspace)
|
|
await writeFile(externalFile, 'EXTERNAL_DIRECTORY_READ_OK\n')
|
|
let requests = 0
|
|
let readVerified = false
|
|
let answerVerified = false
|
|
let askTools: string[] | undefined
|
|
const model = createServer((request, response) => {
|
|
void (async () => {
|
|
let body = ''
|
|
for await (const chunk of request) body += chunk.toString()
|
|
const input = JSON.parse(body)
|
|
if (++requests > 7) {
|
|
response.writeHead(500).end('Unexpected extra model request')
|
|
return
|
|
}
|
|
const messages = input.messages as Array<{ role: string; content: unknown; tool_call_id?: string }>
|
|
const user = JSON.stringify(messages.filter(message => message.role === 'user').at(-1)?.content)
|
|
const results = messages.filter(message => message.role === 'tool')
|
|
let content: string | undefined
|
|
let tool: { name: string; arguments: object; id: string } | undefined
|
|
if (user.includes('ASK_WITHOUT_TOOLS')) {
|
|
askTools = (input.tools ?? []).map((entry: { function: { name: string } }) => entry.function.name)
|
|
content = 'ASK_OK'
|
|
} else if (user.includes('READ_EXTERNAL_SENTINEL')) {
|
|
if (results.some(result => result.tool_call_id === 'external-read')) {
|
|
readVerified = JSON.stringify(results).includes('EXTERNAL_DIRECTORY_READ_OK')
|
|
content = 'CHILD_OK'
|
|
} else {
|
|
tool = { id: 'external-read', name: 'read', arguments: { filePath: externalFile } }
|
|
}
|
|
} else if (results.some(result => result.tool_call_id === 'native-question')) {
|
|
answerVerified = JSON.stringify(results).includes('Yes') && JSON.stringify(results).includes('Custom answer') &&
|
|
results.some(result => result.tool_call_id === 'parallel-question' && JSON.stringify(result.content).includes('Parallel answer'))
|
|
content = 'PARENT_OK'
|
|
} else if (results.some(result => result.tool_call_id === 'native-task')) {
|
|
tool = { id: 'native-question', name: 'question', arguments: { questions: [
|
|
{ header: 'Confirm', question: 'Continue?', options: [
|
|
{ label: 'Yes', description: 'Continue' }, { label: 'No', description: 'Stop' }
|
|
], custom: false },
|
|
{ header: 'Details', question: 'Add details', options: [], custom: true }
|
|
] } }
|
|
} else {
|
|
tool = {
|
|
id: 'native-task', name: 'task',
|
|
arguments: {
|
|
subagent_type: 'general', description: 'Read external test file',
|
|
prompt: `READ_EXTERNAL_SENTINEL: read ${externalFile}, then reply CHILD_OK.`
|
|
}
|
|
}
|
|
}
|
|
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
|
for (const [delta, finishReason] of [
|
|
[tool ? {
|
|
role: 'assistant',
|
|
tool_calls: [{ index: 0, id: tool.id, type: 'function', function: {
|
|
name: tool.name, arguments: JSON.stringify(tool.arguments)
|
|
} }, ...(tool.id === 'native-question' ? [{ index: 1, id: 'parallel-question', type: 'function', function: {
|
|
name: 'question', arguments: JSON.stringify({ questions: [
|
|
{ header: 'Parallel', question: 'Another decision?', options: [], custom: true }
|
|
] })
|
|
} }] : [])]
|
|
} : { role: 'assistant', content }, null],
|
|
[{}, tool ? 'tool_calls' : 'stop']
|
|
]) {
|
|
response.write(`data: ${JSON.stringify({
|
|
id: `permissions-${requests}`, object: 'chat.completion.chunk', created: 1,
|
|
model: 'permissions', choices: [{ index: 0, delta, finish_reason: finishReason }]
|
|
})}\n\n`)
|
|
}
|
|
response.end('data: [DONE]\n\n')
|
|
})().catch(() => response.destroy())
|
|
})
|
|
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
|
|
const address = model.address()
|
|
if (!address || typeof address === 'string') throw new Error('No model port')
|
|
const runtime = new OpenCodeRuntime({
|
|
embedded: true, binaryPath: '', bundledBinaryPath: binaryPath, configPath: '',
|
|
bundledConfigPath: join(process.cwd(), '.runtime-resources', 'opencode-config'),
|
|
defaultWorkspace: workspace,
|
|
modelProfile: {
|
|
id: crypto.randomUUID(), name: 'Permissions fixture', modelName: 'permissions',
|
|
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
|
protocol: 'openai-chat-completions', authentication: 'none'
|
|
}
|
|
})
|
|
try {
|
|
for (const mode of ['execute', 'ask'] as const) {
|
|
let text = ''
|
|
const pending: Array<{ id: string; answers: string[][] }> = []
|
|
for await (const event of runtime.run({
|
|
requestId: crypto.randomUUID(), conversationId: crypto.randomUUID(),
|
|
workMode: mode, prompt: mode === 'execute' ? 'USE_NATIVE_TASK' : 'ASK_WITHOUT_TOOLS'
|
|
}, AbortSignal.timeout(30_000))) {
|
|
if (event.type === 'text') text += event.delta
|
|
if (event.type === 'question') {
|
|
pending.push({ id: event.questionId, answers: event.questions.length === 2
|
|
? [['Yes'], ['Custom answer']] : [['Parallel answer']] })
|
|
// Both native calls must reach the desktop before either is answered.
|
|
if (pending.length === 2) {
|
|
expect(pending[0]!.id).not.toBe(pending[1]!.id)
|
|
for (const question of pending) await runtime.respondToQuestion(question.id, question.answers)
|
|
}
|
|
}
|
|
}
|
|
expect(text).toBe(mode === 'execute' ? 'PARENT_OK' : 'ASK_OK')
|
|
expect(pending).toHaveLength(mode === 'execute' ? 2 : 0)
|
|
}
|
|
expect(readVerified).toBe(true)
|
|
expect(answerVerified).toBe(true)
|
|
expect(askTools).toBeDefined()
|
|
expect(askTools).not.toEqual(expect.arrayContaining(['task']))
|
|
expect(askTools?.filter(name => ['bash', 'write', 'edit', 'read'].includes(name))).toEqual([])
|
|
expect(requests).toBe(6)
|
|
} finally {
|
|
await runtime.dispose()
|
|
model.closeAllConnections()
|
|
await new Promise<void>(resolve => model.close(() => resolve()))
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
},
|
|
90_000
|
|
)
|