feat: add external knowledge retrieval and cross-project activity

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.
This commit is contained in:
mesalogo
2026-09-13 13:10:01 +08:00
parent b84250f62a
commit 3b560c9a5e
102 changed files with 6716 additions and 1484 deletions
@@ -10,6 +10,7 @@ import {
type SessionNotification
} from '@agentclientprotocol/sdk'
import { canonicalJson } from '../shared/agent-protocol/canonical'
import { remoteQuestionSchema, type RemoteQuestionResponse } from '../shared/remote-question-contracts'
import type {
RemoteOwnedPromptStartRequest,
RemoteOwnedPromptStartResult
@@ -57,6 +58,10 @@ export class AgentOwnedAcpPrompt {
#promptPromise?: Promise<void>
#closed = false
#initialized = false
readonly #questions = new Map<string, {
endpoint: string; operationId: string; questionCount: number; notification: SessionNotification
}>()
#acceptQuestions = false
#active?: {
operationId: string
}
@@ -94,6 +99,8 @@ export class AgentOwnedAcpPrompt {
return
}
const error = new Error('ACP Runtime process exited')
this.#acceptQuestions = false
this.#questions.clear()
controller?.error(error)
controller = undefined
rejectProcessExit(error)
@@ -213,6 +220,7 @@ export class AgentOwnedAcpPrompt {
this.#active = {
operationId: request.operationId
}
this.#acceptQuestions = true
const begun = this.#options.transcript.begin({
bindingId: this.#options.bindingId,
operationId: request.operationId,
@@ -277,6 +285,8 @@ export class AgentOwnedAcpPrompt {
}
)
.finally(() => {
this.#questions.clear()
this.#acceptQuestions = false
this.#active = undefined
})
void this.#promptPromise.catch(() => undefined)
@@ -284,6 +294,8 @@ export class AgentOwnedAcpPrompt {
}
async cancel(): Promise<void> {
this.#acceptQuestions = false
this.#questions.clear()
if (this.#sessionId !== undefined && !this.#closed) {
await this.#connection.cancel({ sessionId: this.#sessionId })
}
@@ -293,6 +305,29 @@ export class AgentOwnedAcpPrompt {
this.#clear()
}
async respondToQuestion(request: RemoteQuestionResponse): Promise<void> {
const pending = this.#questions.get(request.questionId)
if (this.#closed || !pending || this.#active?.operationId !== request.operationId ||
pending.operationId !== request.operationId || request.bindingId !== this.#options.bindingId) {
throw new Error('Remote question is no longer pending for this prompt')
}
if (request.answers.length !== 0 && request.answers.length !== pending.questionCount) {
throw new Error('Question answer count does not match')
}
const response = await fetch(pending.endpoint, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ ...request, sessionId: this.#sessionId }),
signal: AbortSignal.timeout(30_000), redirect: 'error'
})
if (!response.ok) throw new Error(`OpenCode question response failed (${response.status})`)
this.#questions.delete(request.questionId)
}
pendingQuestions(operationId: string): SessionNotification[] {
if (!this.#acceptQuestions || this.#active?.operationId !== operationId) return []
return [...this.#questions.values()].map(question => question.notification)
}
#client(): Client {
return {
requestPermission: async (request) =>
@@ -306,12 +341,38 @@ export class AgentOwnedAcpPrompt {
}
#handleSessionUpdate(notification: SessionNotification): void {
if (this.#closed || !this.#active) return
if (
notification.sessionId !== this.#sessionId &&
this.#sessionId !== undefined
) {
throw new Error('ACP session notification identity changed')
}
const resolved = notification.update._meta?.goodbuddyQuestionResolved
if (typeof resolved === 'string') {
this.#questions.delete(resolved)
return
}
const extension = notification.update._meta?.goodbuddyQuestion
if (extension !== undefined) {
if (!this.#acceptQuestions) return
if (!extension || typeof extension !== 'object') throw new Error('Invalid remote question')
const value = extension as { question?: unknown; endpoint?: unknown; childCallId?: unknown }
const question = remoteQuestionSchema.parse(value.question)
if (typeof value.endpoint !== 'string' ||
!/^http:\/\/127\.0\.0\.1:\d+\/[A-Za-z0-9_-]{43}$/u.test(value.endpoint)) {
throw new Error('Invalid remote question endpoint')
}
if (this.#questions.has(question.id)) return
// The process-local reply capability never enters the durable transcript.
notification = { ...notification, update: {
...notification.update, _meta: { goodbuddyQuestion: {
question, ...(typeof value.childCallId === 'string' ? { childCallId: value.childCallId } : {})
} }
} }
this.#questions.set(question.id, { endpoint: value.endpoint, operationId: this.#active.operationId,
questionCount: question.questions.length, notification })
}
this.#options.transcript.append({
bindingId: this.#options.bindingId,
operationId: this.#requireActive().operationId,
@@ -371,6 +432,8 @@ export class AgentOwnedAcpPrompt {
return
}
this.#closed = true
this.#acceptQuestions = false
this.#questions.clear()
this.#unsubscribeOutput()
this.#unsubscribeExit()
}
+2 -1
View File
@@ -871,7 +871,8 @@ function credentialFreeHelperEnvironment(
'XDG_STATE_HOME'
] as const
const environment: NodeJS.ProcessEnv = {
OPENCODE_CONFIG_CONTENT: config
OPENCODE_CONFIG_CONTENT: config,
OPENCODE_ENABLE_QUESTION_TOOL: 'true'
}
for (const name of allowed) {
const value = source[name]
+1
View File
@@ -117,6 +117,7 @@ describe('model bridge loopback helper', () => {
expect(args).toEqual(['acp'])
expect(options).toMatchObject({ shell: false, stdio: 'inherit' })
expect(options.env).toMatchObject({
OPENCODE_ENABLE_QUESTION_TOOL: 'true',
...resources,
LANG: profile.env.LANG,
LC_ALL: profile.env.LC_ALL,
@@ -1,13 +1,74 @@
import { runInNewContext } from 'node:vm'
import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest'
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
describe('OpenCode ACP child event plugin', () => {
it('authenticates native replies, checks live ownership, rejects stale events and closes its endpoint', async () => {
const write = vi.fn()
const question = { id: 'q', sessionID: 'grandchild', questions: [
{ header: 'Input', question: 'Choose', options: [] }
] }
let listed: unknown[] = [question]
const get = vi.fn(async () => ({ data: listed }))
const post = vi.fn(async () => ({}))
const factory = runInNewContext(
`(${openCodeSubagentPluginSource().replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
{ process: { stdout: { write } }, createServer, randomBytes }
)
const hooks = await factory({ client: {
_client: { get, post }, session: { get: async ({ path }: { path: { id: string } }) => ({
data: { id: path.id, parentID: path.id === 'grandchild' ? 'child' : path.id === 'child' ? 'parent' : undefined }
}) }
} })
try {
for (const [sessionID, child, callID] of [['parent', 'child', 'task'], ['child', 'grandchild', 'nested']]) {
await hooks.event({ event: { type: 'message.part.updated', properties: { part: {
type: 'tool', tool: 'task', sessionID, callID, state: { metadata: { sessionId: child } }
} } } })
}
write.mockClear()
await Promise.all([
hooks.event({ event: { type: 'question.asked', properties: question } }),
hooks.event({ event: { type: 'question.asked', properties: question } })
])
expect(write).toHaveBeenCalledOnce()
const notification = JSON.parse(write.mock.calls[0]![0]).params
expect(notification.sessionId).toBe('parent')
const extension = notification.update._meta.goodbuddyQuestion
expect(extension.childCallId).toBe('task')
const send = (url: string, sessionId: string, answers: string[][]) => fetch(url, {
method: 'POST', body: JSON.stringify({ questionId: 'q', sessionId, answers })
})
expect((await send(extension.endpoint.replace(/.$/u, '!'), 'parent', [['A']])).status).toBe(404)
expect((await send(extension.endpoint, 'other', [['A']])).status).toBe(409)
expect(post).not.toHaveBeenCalled()
expect((await send(extension.endpoint, 'parent', [['A']])).status).toBe(200)
expect(post).toHaveBeenLastCalledWith({ url: '/question/q/reply', body: { answers: [['A']] }, throwOnError: true })
expect((await send(extension.endpoint, 'parent', [['A']])).status).toBe(409)
listed = []
await hooks.event({ event: { type: 'question.asked', properties: question } })
expect(write).toHaveBeenCalledOnce()
listed = [question]
await hooks.event({ event: { type: 'question.asked', properties: question } })
expect((await send(extension.endpoint, 'parent', [])).status).toBe(200)
expect(post).toHaveBeenLastCalledWith({ url: '/question/q/reject', throwOnError: true })
await hooks.dispose()
await expect(send(extension.endpoint, 'parent', [])).rejects.toThrow()
write.mockClear()
await hooks.event({ event: { type: 'question.asked', properties: question } })
expect(write).not.toHaveBeenCalled()
} finally {
await hooks.dispose()
}
})
it('emits valid newline-delimited ACP updates only for owned child sessions', async () => {
const write = vi.fn()
const factory = runInNewContext(
`(${openCodeSubagentPluginSource().replace('export default ', '')})`,
{ process: { stdout: { write } } }
`(${openCodeSubagentPluginSource().replace(/^import .*\n/gmu, '').replace('export default ', '')})`,
{ process: { stdout: { write } }, createServer: undefined, randomBytes: undefined }
)
const hooks = await factory()
const event = {
+142 -9
View File
@@ -1,20 +1,98 @@
/**
* OpenCode's ACP adapter omits native child sessions. This plugin forwards
* their SDK events as ACP metadata on the owning Task, using the same stdio
* stream and prompt lifetime as the built-in adapter.
* OpenCode's ACP adapter omits native questions and child sessions. Native
* events use ACP metadata; question replies use a process-local capability.
*/
export function openCodeSubagentPluginSource(): string {
return `export default ${plugin.toString()}`
return `import { createServer } from 'node:http'
import { randomBytes } from 'node:crypto'
export default (input) => (${plugin.toString()})(input, { createServer, randomBytes })`
}
function plugin() {
async function plugin(input?: {
client: {
_client: {
get(input: { url: string; throwOnError: true }): Promise<{ data: unknown }>
post(input: { url: string; body?: unknown; throwOnError: true }): Promise<unknown>
}
session: { get(input: { path: { id: string }; throwOnError: true }): Promise<{
data: { id: string; parentID?: string }
}> }
}
}, modules?: {
createServer: typeof import('node:http').createServer
randomBytes: typeof import('node:crypto').randomBytes
}) {
const tasks = new Map<string, { sessionId: string; callId: string }>()
const pending = new Map<string, { sessionId: string; root: string }>()
let endpoint: string | undefined
let closed = false
let closeServer: (() => void) | undefined
if (input) {
const { createServer, randomBytes } = modules!
const token = randomBytes(32).toString('base64url')
const server = createServer(async (request, response) => {
try {
if (request.method !== 'POST' || request.url !== `/${token}`) {
response.writeHead(404).end()
return
}
let body = ''
for await (const chunk of request) {
body += chunk
if (body.length > 1024 * 1024) throw new Error('Question answer too large')
}
const answer = JSON.parse(body) as {
questionId: string; sessionId: string; answers: string[][]
}
const question = pending.get(answer.questionId)
if (!question || question.root !== answer.sessionId) {
response.writeHead(409).end()
return
}
const listed = await input.client._client.get({ url: '/question', throwOnError: true })
if (!Array.isArray(listed.data) || !listed.data.some((item) =>
item.id === answer.questionId && item.sessionID === question.sessionId
)) {
pending.delete(answer.questionId)
response.writeHead(409).end()
return
}
await input.client._client.post({
url: `/question/${encodeURIComponent(answer.questionId)}/${answer.answers.length ? 'reply' : 'reject'}`,
...(answer.answers.length ? { body: { answers: answer.answers } } : {}),
throwOnError: true
})
pending.delete(answer.questionId)
response.writeHead(200).end('{}')
} catch {
response.writeHead(500).end()
}
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
server.unref()
closeServer = () => { server.closeAllConnections(); server.close() }
const address = server.address()
if (address && typeof address !== 'string') endpoint = `http://127.0.0.1:${address.port}/${token}`
}
return Promise.resolve({
dispose: async () => {
closed = true
pending.clear()
tasks.clear()
closeServer?.()
},
event: async ({ event }: { event: {
type: string
properties: {
id?: string
requestID?: string
status?: { type?: string }
sessionID?: string
part?: {
sessionID?: string
type: string
tool?: string
callID?: string
@@ -25,14 +103,69 @@ function plugin() {
}
} }) => {
const properties = event.properties
if (closed) return
if (event.type === 'session.status' && properties.status?.type === 'idle' && properties.sessionID) {
for (const [id, question] of pending) {
if (question.root !== properties.sessionID && question.sessionId !== properties.sessionID) continue
pending.delete(id)
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {
sessionId: question.root, update: { sessionUpdate: 'tool_call_update', toolCallId: id,
_meta: { goodbuddyQuestionResolved: id } }
} }) + '\n')
}
for (const [child, owner] of tasks) {
if (owner.sessionId === properties.sessionID) tasks.delete(child)
}
return
}
if (event.type === 'question.replied' || event.type === 'question.rejected') {
const question = properties.requestID ? pending.get(properties.requestID) : undefined
if (question) {
pending.delete(properties.requestID!)
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {
sessionId: question.root, update: { sessionUpdate: 'tool_call_update', toolCallId: properties.requestID,
_meta: { goodbuddyQuestionResolved: properties.requestID } }
} }) + '\n')
}
return
}
if (event.type === 'question.asked' && input && endpoint && properties.id && properties.sessionID) {
if (pending.has(properties.id)) return
let root = properties.sessionID
const seen = new Set<string>()
for (;;) {
if (seen.has(root)) return
seen.add(root)
const session = await input.client.session.get({ path: { id: root }, throwOnError: true })
if (session.data.id !== root) return
if (!session.data.parentID) break
root = session.data.parentID
}
const listed = await input.client._client.get({ url: '/question', throwOnError: true })
if (closed || pending.has(properties.id) || !Array.isArray(listed.data) || !listed.data.some((item) =>
item.id === properties.id && item.sessionID === properties.sessionID
)) return
pending.set(properties.id, { sessionId: properties.sessionID, root })
process.stdout.write(JSON.stringify({
jsonrpc: '2.0', method: 'session/update', params: {
sessionId: root, update: {
sessionUpdate: 'tool_call_update', toolCallId: properties.id,
_meta: { goodbuddyQuestion: { question: properties, endpoint,
childCallId: tasks.get(properties.sessionID)?.callId } }
}
}
}) + '\n')
return
}
const part = properties.part
let owner = properties.sessionID ? tasks.get(properties.sessionID) : undefined
const sessionId = properties.sessionID ?? part?.sessionID
let owner = sessionId ? tasks.get(sessionId) : undefined
if (
!owner && event.type === 'message.part.updated' &&
event.type === 'message.part.updated' &&
part?.type === 'tool' && part.tool === 'task' &&
part.callID && properties.sessionID
part.callID && sessionId
) {
owner = { sessionId: properties.sessionID, callId: part.callID }
owner ??= { sessionId, callId: part.callID }
const child = part.state?.metadata?.sessionId
if (child) tasks.set(child, owner)
}
+48 -1
View File
@@ -1,5 +1,5 @@
import { EventEmitter } from 'node:events'
import { chmodSync, mkdtempSync, rmSync } from 'node:fs'
import { chmodSync, mkdtempSync, rmSync, statSync } from 'node:fs'
import type { Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
@@ -18,6 +18,8 @@ import {
import { ControllerRegistry, type ControllerLease } from './controller-registry'
import { EventJournal } from './event-journal'
import { AgentProtocolServer } from './protocol-server'
import { WorkspacePathAccess } from './workspace-path-access'
import { remoteWorkspaceReadTextRequestSchema } from '../shared/remote-agent-contracts'
const temporaryPaths: string[] = []
const closeMethod = 'channel/close'
@@ -341,6 +343,51 @@ describe('AgentProtocolServer connection bounds', () => {
harness.close()
})
it('preserves ENOENT from a real workspace missing-file read', async () => {
const root = temporaryDirectory()
const metadata = statSync(root)
const workspace = new WorkspacePathAccess({
canonicalPath: root,
device: metadata.dev.toString(),
inode: metadata.ino.toString(),
workspaceIdentity: 'workspace-test'
})
const harness = createHarness({
methods: {
'workspace/readText': (params) =>
workspace.readText(remoteWorkspaceReadTextRequestSchema.parse(params))
}
})
try {
harness.socket.receive(
controlFrame(harness.controller, 'missing-file', '1', 1, {
jsonrpc: '2.0',
id: 1,
method: 'workspace/readText',
params: {
workspaceId: 'workspace-test',
generation: 1,
relativePath: 'missing.txt',
offsetBytes: 0,
maximumBytes: 100
}
})
)
await waitFor(() => harness.socket.writes.length === 2)
expect(jsonPayload(decodeAgentFrame(harness.socket.writes[0]!))).toEqual({
jsonrpc: '2.0',
id: 1,
error: {
code: -32000,
message: expect.stringContaining('ENOENT'),
data: { code: 'ENOENT' }
}
})
} finally {
harness.close()
}
})
it('returns strict status, doctor, and exact implemented capabilities', async () => {
const methods = Object.fromEntries(
[
+1 -1
View File
@@ -1137,7 +1137,7 @@ function typedErrorCode(error: unknown): string | undefined {
typeof error !== 'object' ||
!('code' in error) ||
typeof error.code !== 'string' ||
!/^[a-z][a-z0-9-]{0,63}$/u.test(error.code)
!/^(?:[a-z][a-z0-9-]{0,63}|E[A-Z0-9]{1,63})$/u.test(error.code)
) {
return undefined
}
@@ -0,0 +1,143 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { expect, it } from 'vitest'
import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt'
import { SemanticPromptStore } from './semantic-prompt-store'
import { openCodeSubagentPluginSource } from './opencode-subagent-plugin'
import { createOpenCodeModelBridgeProviderConfig } from './model-bridge-helper'
import type { RuntimeAcpProcessOwner } from './runtime-acp-backend'
const binary = process.env.GOODBUDDY_TEST_OPENCODE_BINARY ?? join(process.cwd(),
'.runtime-resources', process.arch, process.platform === 'win32' ? 'opencode.exe' : 'opencode')
it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as const)(
'round trips native ACP %s questions through the production plugin and Agent owner', async (scenario) => {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-remote-question-'))
await mkdir(join(root, 'state'))
const pluginPath = join(root, 'plugin.mjs')
await writeFile(pluginPath, openCodeSubagentPluginSource())
let calls = 0
let answered = false
let tools: string[] = []
const model = createServer((request, response) => {
void (async () => {
let body = ''
for await (const chunk of request) body += chunk.toString()
const input = JSON.parse(body)
tools = (input.tools ?? []).map((tool: { function: { name: string } }) => tool.function.name)
if (++calls > 4) { response.writeHead(500).end(); return }
const messages = input.messages as Array<{ role: string; content: unknown; tool_call_id?: string }>
const results = messages.filter(message => message.role === 'tool')
answered ||= JSON.stringify(results).includes('REMOTE_ANSWER') ||
(scenario === 'reject' && JSON.stringify(results).includes('dismissed'))
const childPrompt = JSON.stringify(messages.filter(message => message.role === 'user').at(-1)).includes('CHILD_DECISION')
const task = scenario === 'child' && !childPrompt && results.length === 0
const finished = results.length > 0
response.writeHead(200, { 'content-type': 'text/event-stream' })
for (const [delta, finish] of [
[finished ? { role: 'assistant', content: 'QUESTION_OK' } : {
role: 'assistant', tool_calls: [{ index: 0, id: task ? 'native-task' : 'native-question', type: 'function',
function: { name: task ? 'task' : 'question', arguments: JSON.stringify(task ? {
subagent_type: 'general', description: 'Ask child question', prompt: 'CHILD_DECISION: Ask a question.'
} : { questions: [
{ header: 'Details', question: 'Remote decision?', options: [], custom: true }
] }) } }]
}, null], [{}, finished ? 'stop' : 'tool_calls']
]) response.write(`data: ${JSON.stringify({ id: 'fixture', object: 'chat.completion.chunk',
created: 1, model: 'fixture', choices: [{ index: 0, delta, finish_reason: finish }] })}\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 fixture port')
const config = createOpenCodeModelBridgeProviderConfig({ protocol: 'openai-chat-completions',
model: 'fixture', loopbackOrigin: `http://127.0.0.1:${address.port}/${'a'.repeat(43)}`, workMode: 'execute' })
const child = spawn(binary, ['acp'], { cwd: root, stdio: 'pipe', env: {
...process.env, OPENCODE_CONFIG_CONTENT: JSON.stringify({ ...config, plugin: [pathToFileURL(pluginPath).href] }),
OPENCODE_CONFIG_DIR: join(process.cwd(), '.runtime-resources', 'opencode-config'),
OPENCODE_DISABLE_PROJECT_CONFIG: 'true', OPENCODE_DISABLE_MODELS_FETCH: 'true',
OPENCODE_ENABLE_QUESTION_TOOL: 'true',
OPENCODE_DISABLE_AUTOUPDATE: 'true', XDG_DATA_HOME: join(root, 'data'),
XDG_STATE_HOME: join(root, 'state'), XDG_CONFIG_HOME: join(root, 'config')
} })
let stderr = ''
child.stderr.on('data', data => { stderr += data.toString() })
const transcript = new SemanticPromptStore(join(root, 'state', 'prompts.sqlite'))
transcript.prepare({ bindingId: 'binding', operationId: 'operation', requestId: 'operation',
controllerId: 'controller', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 })
let complete = false
const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding', controllerId: 'controller',
workspaceDirectory: root, workMode: 'execute', transcript,
process: {
writeStdin: async (data: Uint8Array) => { child.stdin.write(data) },
subscribeOutput: (listener: (event: { stream: string; data: Uint8Array }) => void) => {
const onData = (data: Uint8Array) => listener({ stream: 'stdout', data })
child.stdout.on('data', onData)
return () => { child.stdout.off('data', onData) }
},
subscribeExit: (listener: () => void) => { child.on('exit', listener); return () => { child.off('exit', listener) } }
} as unknown as RuntimeAcpProcessOwner,
completePrompt: () => { complete = true }
})
try {
await owner.start({ bindingId: 'binding', operationId: 'operation', requestId: 'operation',
prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] })
let questionId: string | undefined
await expect.poll(async () => {
const page = transcript.page({ bindingId: 'binding', operationId: 'operation',
controllerId: 'controller', afterSequence: '0', limit: 100 })
for (const event of page.events) {
const notification = event.payload as { update?: { _meta?: { goodbuddyQuestion?: {
question: { id: string }; endpoint?: string; childCallId?: string
} } } }
const extension = notification.update?._meta?.goodbuddyQuestion
if (extension && !questionId) {
expect(extension.endpoint).toBeUndefined()
if (scenario === 'child') expect(extension.childCallId).toBe('native-task')
questionId = extension.question.id
expect(owner.pendingQuestions('other')).toEqual([])
expect(owner.pendingQuestions('operation')).toHaveLength(1)
await expect(owner.respondToQuestion({ bindingId: 'other', operationId: 'operation', questionId,
answers: [['WRONG']] })).rejects.toThrow('no longer pending')
if (scenario === 'cancel') {
await owner.cancel()
expect(owner.pendingQuestions('operation')).toEqual([])
continue
}
await owner.respondToQuestion({ bindingId: 'binding', operationId: 'operation', questionId,
answers: scenario === 'reject' ? [] : [['REMOTE_ANSWER']] })
}
}
return complete
}, { timeout: 30_000, interval: 50 }).toBe(true)
expect(tools).toContain('question')
expect(questionId, stderr).toBeDefined()
if (scenario === 'reject') {
expect(JSON.stringify(transcript.page({ bindingId: 'binding', operationId: 'operation',
controllerId: 'controller', afterSequence: '0', limit: 100 }).events)).toContain('dismissed')
} else if (scenario !== 'cancel') expect(answered).toBe(true)
expect(owner.pendingQuestions('operation')).toEqual([])
expect(calls).toBe(scenario === 'child' ? 4 : scenario === 'reject' || scenario === 'cancel' ? 1 : 2)
await expect(owner.respondToQuestion({ bindingId: 'binding', operationId: 'operation',
questionId: questionId!, answers: [['LATE']] })).rejects.toThrow('no longer pending')
} catch (error) {
console.error({ calls, tools, stderr, events: transcript.page({ bindingId: 'binding',
operationId: 'operation', controllerId: 'controller', afterSequence: '0', limit: 100 }).events })
throw error
} finally {
owner.close()
child.kill()
await new Promise<void>(resolve => { if (child.exitCode !== null) resolve(); else child.once('exit', () => resolve()) })
transcript.close()
model.closeAllConnections()
await new Promise<void>(resolve => model.close(() => resolve()))
await rm(root, { recursive: true, force: true })
}
}, 60_000)
+18 -1
View File
@@ -1,4 +1,5 @@
import { z } from 'zod'
import { remoteQuestionResponseSchema } from '../shared/remote-question-contracts'
import { createHash } from 'node:crypto'
import {
AGENT_PROTOCOL_LIMITS,
@@ -421,6 +422,20 @@ export class RuntimeAcpBackend {
this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)),
'runtime/attachPrompt': (params, context) =>
this.#enqueuePublicControl(() => this.#attachOwnedPrompt(params, context)),
'runtime/respondToQuestion': (params, context) =>
this.#enqueuePublicControl(async () => {
const request = remoteQuestionResponseSchema.parse(params)
this.#assertControllerLive(context)
const binding = this.#bindings.get(request.bindingId)
if (!binding?.ownedAcp || binding.activeOperationId !== request.operationId ||
binding.controllerId !== context.controller.controllerId ||
binding.controllerGeneration !== context.controller.generation ||
binding.connectionId !== context.controller.connectionId) {
throw new RuntimeAcpBackendError('Question reply identity does not match', 'identity')
}
await binding.ownedAcp.respondToQuestion(request)
return {}
}),
'runtime/pagePromptTranscript': (params, context) =>
this.#enqueuePublicControl(() => this.#pageOwnedPrompt(params, context)),
'runtime/ackPromptTranscript': (params, context) =>
@@ -1476,10 +1491,12 @@ export class RuntimeAcpBackend {
'process'
)
}
return this.#options.semanticPrompts.page({
const page = this.#options.semanticPrompts.page({
...request,
controllerId: context.controller.controllerId
})
const binding = this.#bindings.get(request.bindingId)
return { ...page, pendingQuestions: binding?.ownedAcp?.pendingQuestions(request.operationId) ?? [] }
}
#ackOwnedPrompt(
@@ -170,6 +170,7 @@ describe('production Runtime composition', () => {
'runtime/preparePrompt',
'runtime/reconcilePrompt',
'runtime/replayAcpChannel',
'runtime/respondToQuestion',
'runtime/resumeAcpChannel',
'runtime/startPrompt'
])