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
+76
View File
@@ -2872,6 +2872,82 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
).not.toHaveProperty('modelBridge')
})
it('restores live questions after transcript ACK, scopes replies and ignores answered history', async () => {
const fixture = ownedChannel()
const reply = vi.fn<NonNullable<RemoteRuntimeChannel['respondToQuestion']>>()
.mockRejectedValueOnce(new Error('temporary response failure'))
.mockResolvedValue(undefined)
fixture.channel.respondToQuestion = reply
let answered = false
let pages = 0
const question = { sessionId: 'owned-session', update: {
sessionUpdate: 'tool_call_update', toolCallId: 'native-question', _meta: {
goodbuddyQuestion: { question: { id: 'native-question', sessionID: 'owned-session',
questions: [{ header: 'Input', question: 'Which?', options: [] }] } }
}
} }
fixture.channel.pageOwnedPromptTranscript = async ({ bindingId, operationId }) => ({
bindingId, operationId, state: answered ? 'completed' : 'running', sessionId: 'owned-session',
latestSequence: answered ? '2' : '1', acknowledgedSequence: '1', hasMore: false,
pendingQuestions: answered ? [] : [question],
events: answered ? [
{ sequence: '1', kind: 'session-update', payload: question, createdAt: 1 },
{ sequence: '2', kind: 'prompt-terminal', payload: { status: 'completed', response: { stopReason: 'end_turn' } }, createdAt: 2 }
] : (++pages === 1 ? [] : [{ sequence: '1', kind: 'session-update', payload: question, createdAt: 1 }])
})
let id: string | undefined
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
if (event.type !== 'question') continue
expect(id).toBeUndefined()
id = event.questionId
expect(id).not.toBe('native-question')
expect(event.questions[0]).toMatchObject({ custom: true, multiple: false })
await expect(fixture.instance.respondToQuestion(id, [['A']])).rejects.toThrow('temporary response failure')
await fixture.instance.respondToQuestion(id, [['A']])
answered = true
}
expect(id).toBeDefined()
expect(reply).toHaveBeenLastCalledWith({ bindingId: expect.any(String), operationId: request.requestId,
questionId: 'native-question', answers: [['A']] })
await expect(fixture.instance.respondToQuestion(id!, [['late']])).rejects.toThrow('no longer pending')
expect(reply).toHaveBeenCalledTimes(2)
await fixture.instance.dispose()
})
it('drops reply mappings when the Agent resolves a question while the prompt remains active', async () => {
const fixture = ownedChannel()
const reply = vi.fn(async () => undefined)
fixture.channel.respondToQuestion = reply
let pages = 0
let answered = false
fixture.channel.pageOwnedPromptTranscript = async ({ bindingId, operationId }) => ({
bindingId, operationId, state: answered ? 'completed' : 'running', sessionId: 'owned-session',
latestSequence: answered ? '1' : '0', acknowledgedSequence: '0', hasMore: false,
pendingQuestions: answered ? [] : [{ sessionId: 'owned-session', update: {
sessionUpdate: 'tool_call_update', toolCallId: 'question', _meta: { goodbuddyQuestion: {
question: { id: ++pages === 1 ? 'first' : 'second', sessionID: 'owned-session',
questions: [{ header: 'Input', question: 'Which?', options: [] }] }
} }
} }],
events: answered ? [{ sequence: '1', kind: 'prompt-terminal', createdAt: 1,
payload: { status: 'completed', response: { stopReason: 'end_turn' } } }] : []
})
let previous: string | undefined
const resolved: string[] = []
for await (const event of fixture.instance.run(request, new AbortController().signal)) {
if (event.type === 'question-resolved') resolved.push(event.questionId)
if (event.type !== 'question') continue
if (!previous) { previous = event.questionId; continue }
await expect(fixture.instance.respondToQuestion(previous, [['stale']])).rejects.toThrow('no longer pending')
expect(reply).not.toHaveBeenCalled()
await fixture.instance.respondToQuestion(event.questionId)
answered = true
}
expect(reply).toHaveBeenCalledOnce()
expect(resolved).toEqual([previous])
await fixture.instance.dispose()
})
it('freshly attaches an accepted prompt without resending it', async () => {
const fixture = ownedChannel()
const first = fixture.instance.run(
+60 -1
View File
@@ -1,4 +1,6 @@
import { createHash, randomUUID } from 'node:crypto'
import { remoteQuestionSchema } from '../../shared/remote-question-contracts'
import type { AgentQuestionAnswer } from '../../shared/contracts'
import { isDeepStrictEqual } from 'node:util'
import {
ClientSideConnection,
@@ -137,6 +139,7 @@ export class RemotePromptRecoveryUnavailableError extends Error {
}
type ActivePrompt = {
reportedQuestions?: Set<string>
subagentProgress?: OpenCodeSubagentProgress
requestId: string
operationId: string
@@ -352,6 +355,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
Promise<SessionRecord>
>()
private readonly activePrompts = new Map<string, ActivePrompt>()
private readonly pendingQuestions = new Map<string, { prompt: ActivePrompt; nativeId: string }>()
private readonly sessionReservations = new Set<string>()
private readonly drainWaiters = new Set<() => void>()
private readonly contexts = new Map<string, ChannelContext>()
@@ -2070,6 +2074,21 @@ export class AcpRemoteRuntime implements AgentRuntime {
prompt: ActivePrompt,
update: SessionUpdate
): RuntimePublicEvent | undefined {
const extension = update._meta?.goodbuddyQuestion
if (this.options.runtimeId === 'opencode' && extension && typeof extension === 'object' &&
prompt.open && prompt.context.channel.respondToQuestion) {
const value = extension as { question?: unknown; childCallId?: unknown }
const question = remoteQuestionSchema.parse(value.question)
const questionId = JSON.stringify([prompt.bindingId, prompt.operationId, question.id])
prompt.reportedQuestions ??= new Set()
if (prompt.reportedQuestions.has(questionId)) return undefined
prompt.reportedQuestions.add(questionId)
this.pendingQuestions.set(questionId, { prompt, nativeId: question.id })
const childTaskId = typeof value.childCallId === 'string'
? prompt.toolCalls.get(value.childCallId)?.retainedSubagent?.childTaskId : undefined
return { requestId: prompt.requestId, type: 'question', questionId,
questions: question.questions, ...(childTaskId ? { childTaskId } : {}) }
}
if (this.options.runtimeId === 'opencode') {
if (!prompt.subagentProgress) {
prompt.subagentProgress = new OpenCodeSubagentProgress(
@@ -2295,6 +2314,25 @@ export class AcpRemoteRuntime implements AgentRuntime {
return undefined
}
async respondToQuestion(questionId: string, answers: AgentQuestionAnswer[] = []): Promise<void> {
const pending = this.pendingQuestions.get(questionId)
if (!pending || !pending.prompt.open || !pending.prompt.context.channel.respondToQuestion) {
throw new Error('Remote question is no longer pending')
}
const { prompt, nativeId } = pending
this.assertUsable(prompt.context, true)
await this.awaitOperation(prompt.context, '回答问题', prompt.context.channel.respondToQuestion!({
bindingId: prompt.bindingId, operationId: prompt.operationId, questionId: nativeId, answers
}), undefined, false)
this.pendingQuestions.delete(questionId)
}
private clearPromptQuestions(prompt: ActivePrompt): void {
for (const [id, pending] of this.pendingQuestions) {
if (pending.prompt === prompt) this.pendingQuestions.delete(id)
}
}
private withRemoteProvenance(
event: RuntimePublicEvent,
bindingId: string,
@@ -2553,7 +2591,8 @@ export class AcpRemoteRuntime implements AgentRuntime {
transcriptEvent.payload,
session.sessionId
)
const mapped = this.mapUpdate(prompt, notification.update)
const mapped = page.pendingQuestions !== undefined && notification.update._meta?.goodbuddyQuestion
? undefined : this.mapUpdate(prompt, notification.update)
if (mapped !== undefined) {
publicEvents.push(this.limitEvent(mapped))
}
@@ -2672,6 +2711,23 @@ export class AcpRemoteRuntime implements AgentRuntime {
transcriptPollDelayMs = 100
continue
}
const pendingNotifications = (page.pendingQuestions ?? []).map(question =>
transcriptSessionNotification(question, session.sessionId))
if (page.pendingQuestions !== undefined) {
const liveIds = new Set(pendingNotifications.map(notification => remoteQuestionSchema.parse(
(notification.update._meta?.goodbuddyQuestion as { question?: unknown } | undefined)?.question
).id))
for (const [id, pending] of this.pendingQuestions) {
if (pending.prompt === prompt && !liveIds.has(pending.nativeId)) {
this.pendingQuestions.delete(id)
yield { requestId: prompt.requestId, type: 'question-resolved', questionId: id }
}
}
}
for (const notification of pendingNotifications) {
const event = this.mapUpdate(prompt, notification.update)
if (event?.type === 'question') yield event
}
if (
page.state === 'completed' ||
page.state === 'failed' ||
@@ -2699,6 +2755,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
}
signal.removeEventListener('abort', cancel)
prompt.open = false
this.clearPromptQuestions(prompt)
this.activePrompts.delete(binding.bindingId)
}
}
@@ -3043,6 +3100,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
session.binding = binding
}
prompt.open = false
this.clearPromptQuestions(prompt)
prompt.interrupt = undefined
prompt.wake?.()
prompt.wake = undefined
@@ -3251,6 +3309,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
new Error('远端 Runtime 已关闭,活动请求结果需要核对')
)
prompt.open = false
this.clearPromptQuestions(prompt)
prompt.wake?.()
this.discardUpdates(prompt)
}
+138
View File
@@ -0,0 +1,138 @@
import { execFile, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { rgPath } from '@vscode/ripgrep'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LocalWorkspaceAccess } from '../workspace'
import { searchWorkspaceWithRipgrep, type WorkspaceRipgrepInput } from './direct-model-ripgrep'
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>()
const mocked = { ...actual, execFile: vi.fn(actual.execFile) }
return { ...mocked, default: mocked }
})
const input: WorkspaceRipgrepInput = {
path: '.', pattern: 'target', glob: [], fixedStrings: false,
ignoreCase: false, filesOnly: false, maxResults: 100
}
const match = JSON.stringify({
type: 'match',
data: {
path: { text: './valid.txt' }, lines: { text: 'target\n' },
line_number: 1, submatches: [{ start: 0 }]
}
}) + '\n'
const accessError = 'blocked: Permission denied (os error 13)'
const directories: string[] = []
const actual = await vi.importActual<typeof import('node:child_process')>('node:child_process')
function mockResult(code: number | string, stdout: string, stderr: string) {
const error = Object.assign(new Error(`process failed: ${code}`), { code })
vi.mocked(execFile).mockImplementation((file, args, options, callback) => {
if (file !== rgPath) return actual.execFile(file, args, options, callback)
const complete = callback as (error: Error, stdout: string, stderr: string) => void
complete(error, stdout, stderr)
return {} as ChildProcess
})
return error
}
function search(overrides: Partial<WorkspaceRipgrepInput> = {}, signal = new AbortController().signal) {
return searchWorkspaceWithRipgrep(
rgPath, { ...input, ...overrides }, new LocalWorkspaceAccess(process.cwd()), signal
)
}
afterEach(async () => {
vi.mocked(execFile).mockReset()
await Promise.all(directories.splice(0).map((path) => rm(path, { recursive: true, force: true })))
})
describe('searchWorkspaceWithRipgrep', () => {
it('preserves formatted matches with bounded traversal diagnostics and an explicit warning', async () => {
mockResult(2, match, accessError + '\n' + 'x'.repeat(10_000))
const result = await search()
expect(vi.mocked(execFile).mock.calls.map((call) => call[0])).toContain(rgPath)
expect(result).toContain('valid.txt:1:1:target\\n')
expect(result).toContain('incomplete search coverage')
expect(result).toContain('missing results do not establish absence')
expect(result).toContain(accessError)
expect(result.length).toBeLessThan(2_500)
})
it('preserves file listings and existing result truncation', async () => {
mockResult(2, './valid.txt\0./other.txt\0', accessError)
const result = await search({ filesOnly: true, maxResults: 1 })
expect(result).toContain('incomplete search coverage')
expect(result).toContain('\nvalid.txt\n...[truncated after 1 files]')
expect(result).not.toContain('other.txt')
})
it('qualifies a no-match summary when traversal was incomplete', async () => {
mockResult(2, JSON.stringify({ type: 'summary', data: {} }) + '\n', accessError)
const result = await search()
expect(result).toContain('incomplete search coverage')
expect(result).toContain('[no matches found in searched files]')
expect(result).not.toContain('[no matches found]')
})
it.each([false, true])('throws detailed errors with no stdout (filesOnly=%s)', async (filesOnly) => {
mockResult(2, '', accessError)
await expect(search({ filesOnly })).rejects.toThrow(accessError)
})
it('does not turn invalid regex diagnostics into successful partial output', async () => {
mockResult(2, match, 'regex parse error:\n [\nerror: unclosed character class')
await expect(search({ pattern: '[' })).rejects.toThrow('unclosed character class')
})
it('keeps launch failures as errors even with stdout', async () => {
const error = mockResult('ENOENT', match, '')
await expect(search()).rejects.toMatchObject({ cause: error })
})
it('preserves the cancellation reason even when partial stdout is available', async () => {
const controller = new AbortController()
const reason = new Error('cancel search')
controller.abort(reason)
mockResult(2, match, accessError)
await expect(search({}, controller.signal)).rejects.toBe(reason)
})
it('keeps exit 1 as a normal no-match result', async () => {
mockResult(1, '', '')
await expect(search()).resolves.toBe('[no matches found]')
})
it('preserves capture-limit truncation', async () => {
mockResult('ERR_CHILD_PROCESS_STDIO_MAXBUFFER', match, '')
await expect(search()).resolves.toBe('valid.txt:1:1:target\\n\n...[truncated after 1 matches]')
})
it.each([
{ filesOnly: false, pattern: 'target', expected: 'valid.txt:1:1:target\\n' },
{ filesOnly: true, pattern: 'target', expected: '\nvalid.txt' },
{ filesOnly: false, pattern: 'absent', expected: '[no matches found in searched files]' }
])('handles real bundled rg with a valid file plus a missing target ($filesOnly, $pattern)', async ({ filesOnly, pattern, expected }) => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-ripgrep-'))
directories.push(directory)
await writeFile(join(directory, 'valid.txt'), 'target\n')
// Add a second target at the process boundary: the public API stats its single target first.
vi.mocked(execFile).mockImplementation((file, args, options, callback) =>
actual.execFile(file, file === rgPath ? [...args as string[], 'missing.txt'] : args, options, callback)
)
const result = await searchWorkspaceWithRipgrep(
rgPath, { ...input, path: 'valid.txt', filesOnly, pattern },
new LocalWorkspaceAccess(directory), new AbortController().signal
)
expect(result).toContain(expected)
expect(result).toContain('incomplete search coverage (ripgrep exit code 2)')
expect(result).toContain('missing.txt')
})
it('rejects an invalid regex using the real bundled rg', async () => {
await expect(search({ pattern: '[' })).rejects.toThrow(/regex parse error/u)
})
})
+21 -1
View File
@@ -52,6 +52,7 @@ function runRipgrep(
if (
processError &&
processError.code !== 1 &&
processError.code !== 2 &&
processError.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
) {
reject(
@@ -225,7 +226,17 @@ export async function searchWorkspaceWithRipgrep(
identity.canonicalDisplayPath,
signal
)
return input.filesOnly
const incomplete = result.code === 2
if (
incomplete &&
(!result.stdout.trim() ||
/regex parse error|error parsing (?:regex|glob)|PCRE2: error compiling/iu.test(result.stderr))
) {
throw new Error(
`ripgrep failed (exit code 2); search coverage is incomplete.\n${result.stderr.trim().slice(0, 2_000) || 'No search output was available.'}`
)
}
const formatted = input.filesOnly
? formatFileResults(
result.stdout,
input.maxResults,
@@ -236,4 +247,13 @@ export async function searchWorkspaceWithRipgrep(
input.maxResults,
result.truncated
)
if (incomplete) {
const warning = '[warning: incomplete search coverage (ripgrep exit code 2). Some files or directories could not be searched; missing results do not establish absence.]'
const details = result.stderr.trim().slice(0, 2_000)
const partial = formatted
.replace(/^\[no matches found\]$/u, '[no matches found in searched files]')
.replace(/^\[no files found\]$/u, '[no files found in searched locations]')
return `${warning}\n${details ? `${details}\n` : ''}${partial}`
}
return formatted
}
+22 -38
View File
@@ -29,32 +29,22 @@ const firstLibraryId = '11111111-1111-4111-8111-111111111111'
const secondLibraryId = '22222222-2222-4222-8222-222222222222'
function createService() {
const searchHybridMany = vi.fn(
const retrieveMany = vi.fn(
async (libraryIds: readonly string[]) =>
libraryIds.map((knowledgeBaseId, index) => ({
knowledgeBaseId,
result: {
document: {
id: `33333333-3333-4333-8333-33333333333${index}`,
title: `文档 ${index}`
},
source: {
displayName: `来源 ${index}`,
location: `/private/${index}`
},
chunk: {
id: `44444444-4444-4444-8444-44444444444${index}`,
location: `第 ${index + 1} 段`
},
response: { diagnostics: {}, results: [{
knowledgeBaseId,
documentId: `33333333-3333-4333-8333-33333333333${index}`,
documentTitle: `文档 ${index}`,
sourceDisplayName: `来源 ${index}`,
chunkId: `44444444-4444-4444-8444-444444444440`,
location: `第 ${index + 1} 段`,
snippet: `<mark>匹配</mark> ${index}`,
rank: index + 1,
retrieval: {
score: 0.5,
channels: ['fts'] as const,
lexicalRank: 1,
evidenceIds: []
}
}
channels: ['fts'] as const,
scores: { fusedScore: 0.5, ftsRank: 1 }
}] }
}))
)
const service = {
@@ -72,9 +62,9 @@ function createService() {
}
]
},
searchHybridMany
retrieveMany
} as unknown as KnowledgeService
return { service, searchHybridMany }
return { service, retrieveMany }
}
function customMcpServer(
@@ -189,24 +179,19 @@ function testTool(name: string): Tool {
const gateways: KnowledgeMcpGateway[] = []
it('honors a requested knowledge result count above eight', async () => {
const { service, searchHybridMany } = createService()
const [sample] = await searchHybridMany([firstLibraryId])
searchHybridMany.mockClear()
searchHybridMany.mockResolvedValue(Array.from({ length: 12 }, (_, index) => ({
...sample!,
result: {
...sample!.result,
chunk: { ...sample!.result.chunk, id: crypto.randomUUID() },
rank: index + 1
}
})))
const { service, retrieveMany } = createService()
const [sample] = await retrieveMany([firstLibraryId])
retrieveMany.mockClear()
retrieveMany.mockResolvedValue([{ ...sample!, response: { diagnostics: {}, results: Array.from({ length: 12 }, (_, index) => ({
...sample!.response.results[0]!, chunkId: crypto.randomUUID(), rank: index + 1
})) } }])
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const controller = new AbortController()
const token = gateway.grant('large-search', [firstLibraryId], controller.signal)!
const references = await gateway.search(token, { query: 'evidence', limit: 12 })
expect(references).toHaveLength(12)
expect(searchHybridMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', 12, controller.signal)
expect(retrieveMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', expect.any(AbortSignal))
await expect(gateway.search(token, { query: 'evidence', limit: 101 })).rejects.toThrow()
})
const databases: AssistantDatabase[] = []
@@ -621,7 +606,7 @@ describe('KnowledgeMcpGateway', () => {
})
it('keeps scope server-side, strips markup, bounds model arguments, and drains references', async () => {
const { service, searchHybridMany } = createService()
const { service, retrieveMany } = createService()
const gateway = new KnowledgeMcpGateway(service)
gateways.push(gateway)
const token = gateway.grant(
@@ -652,10 +637,9 @@ describe('KnowledgeMcpGateway', () => {
limit: 1
})
expect(searchHybridMany).toHaveBeenCalledWith(
expect(retrieveMany).toHaveBeenCalledWith(
[secondLibraryId],
'要找什么',
1,
expect.any(AbortSignal)
)
expect(references).toEqual([
+16 -32
View File
@@ -1,4 +1,5 @@
import { randomBytes } from 'node:crypto'
import { knowledgeReferenceKey, toKnowledgeReference } from '../../shared/knowledge-reference'
import {
createServer,
type IncomingMessage,
@@ -24,7 +25,6 @@ import type {
BrowserTabSummary,
KnowledgeSearchReference
} from '../../shared/contracts'
import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text'
import {
knowledgeToolNames,
knowledgeScopedDataToolCatalog,
@@ -273,13 +273,7 @@ function textContent(value: string): MagicNoteRichContent {
}
function referenceKey(reference: KnowledgeSearchReference): string {
return [
reference.libraryId,
reference.documentId,
reference.chunkId ?? '',
reference.locator ?? '',
reference.snippet
].join('\0')
return knowledgeReferenceKey(reference)
}
function ensureBoundedCustomMcpResult(result: unknown): CallToolResult {
@@ -681,20 +675,22 @@ export class KnowledgeMcpGateway {
const { query, limit } = knowledgeSearchTool.inputSchema.parse(
input
)
const effectiveSignal = signal
? AbortSignal.any([signal, capability.signal])
: capability.signal
const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])])
effectiveSignal.throwIfAborted()
const libraries = this.knowledgeService.database.listKnowledgeBases(500)
const libraryNames = new Map(
libraries.map((library) => [library.id, library.name])
)
const results = await this.knowledgeService.searchHybridMany(
const outcomes = await this.knowledgeService.retrieveMany(
capability.libraryIds,
query,
limit,
effectiveSignal
)
effectiveSignal.throwIfAborted()
const warnings = outcomes.flatMap(item => item.response.diagnostics.failure ? [item.response.diagnostics.failure] : [])
if (outcomes.length && warnings.length === outcomes.length) throw new Error(warnings.join('; '))
const results = outcomes.flatMap(item => item.response.results.map(result => ({ knowledgeBaseId: item.knowledgeBaseId, result })))
if (!results.length && warnings.length) throw new Error(warnings.join('; '))
const references: KnowledgeSearchReference[] = []
const seen = new Set<string>()
for (const { knowledgeBaseId, result } of results.sort(
@@ -704,22 +700,8 @@ export class KnowledgeMcpGateway {
break
}
const reference: KnowledgeSearchReference = {
libraryId: knowledgeBaseId,
libraryName: libraryNames.get(knowledgeBaseId) ?? '知识库',
documentId: result.document.id,
chunkId: result.chunk.id,
documentName: result.document.title.slice(0, 500),
sourceName: result.source.displayName.slice(0, 500),
locator: result.chunk.location?.slice(0, 1_000),
snippet: stripKnowledgeHighlightTags(result.snippet).slice(0, 12_000),
rank: result.rank,
score: result.retrieval.score,
lexicalRank: result.retrieval.lexicalRank,
vectorRank: result.retrieval.vectorRank,
graphRank: result.retrieval.graphRank,
similarity: result.retrieval.similarity,
retrievalChannels: result.retrieval.channels,
evidenceIds: result.retrieval.evidenceIds?.slice(0, 100)
...toKnowledgeReference(result, libraryNames.get(knowledgeBaseId) ?? '知识库'),
...(warnings.length ? { warnings } : {})
}
const key = referenceKey(reference)
if (seen.has(key)) {
@@ -1473,13 +1455,14 @@ export class KnowledgeMcpGateway {
private async callScopedTool(
token: string,
name: ScopedDataToolName,
input: unknown
input: unknown,
signal?: AbortSignal
): Promise<Record<string, unknown>> {
switch (name) {
case 'knowledge_list':
return { libraries: this.listLibraries(token, input) }
case 'knowledge_search':
return { references: await this.search(token, input) }
return { references: await this.search(token, input, signal) }
case 'note_list':
return { notes: this.listMagicNotes(token, input) }
case 'note_get':
@@ -1641,7 +1624,8 @@ export class KnowledgeMcpGateway {
await this.callScopedTool(
token,
name as ScopedDataToolName,
parsedInput
parsedInput,
extra.signal
)
)
}
+98
View File
@@ -4084,6 +4084,104 @@ describe('ModelAgentRuntime', () => {
expect(events.at(-1)).toMatchObject({ type: 'done' })
})
it('recovers an absolute workspace read by retrying a relative path against the real filesystem', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-read-recovery-'))
const workspaceAccess = new LocalWorkspaceAccess(workspace)
const toolProvider = new ModelToolProvider(workspaceAccess, [])
const absolutePath = join(workspace, 'README.md')
const marker = `read-recovery-${crypto.randomUUID()}`
const responses = [absolutePath, 'README.md'].map((path, index) => ({
choices: [{ message: {
role: 'assistant',
content: null,
tool_calls: [{
id: `read-${index}`,
type: 'function',
function: {
name: 'workspace_read_text',
arguments: JSON.stringify({ path })
}
}]
} }]
}))
const fetcher = vi.fn<typeof fetch>(async () => {
const response = responses.shift()
return Response.json(response ?? {
choices: [{ message: { role: 'assistant', content: 'Read completed.' } }]
})
})
const runtime = new ModelAgentRuntime({
baseUrl: 'https://example.test/v1',
model: 'test-model',
protocol: 'openai-chat-completions',
authentication: 'none',
fetcher,
toolProvider,
workspaceAccess
})
const events: RuntimeEvent[] = []
try {
await writeFile(absolutePath, `${marker}\n`)
for await (const event of runtime.run({
requestId: crypto.randomUUID(),
conversationId: 'conversation-workspace-read-recovery',
prompt: 'Read README.md.',
workMode: 'ask'
}, new AbortController().signal, async () => 'deny')) {
events.push(event)
}
expect(fetcher).toHaveBeenCalledTimes(3)
const secondBody = JSON.parse(String(fetcher.mock.calls[1]![1]!.body)) as {
messages: Array<{ role: string; tool_call_id?: string; content: string }>
}
const correction = secondBody.messages.filter((message) => message.role === 'tool')
expect(correction).toHaveLength(1)
expect(correction[0]).toMatchObject({ tool_call_id: 'read-0' })
expect(JSON.parse(correction[0]!.content)).toEqual({
ok: false,
recoverable: true,
error: expect.stringMatching(/^workspace_read_text: [\s\S]*路径必须相对于工作区/u),
nextAction: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed. Do not retry identical arguments.'
})
expect(correction[0]!.content).not.toContain(marker)
const thirdBody = JSON.parse(String(fetcher.mock.calls[2]![1]!.body)) as {
messages: Array<{ role: string; tool_call_id?: string; content: string }>
}
expect(thirdBody.messages.filter((message) => message.role === 'tool')).toEqual([
correction[0],
expect.objectContaining({
tool_call_id: 'read-1',
content: expect.stringContaining(marker)
})
])
const toolEvents = events.filter((event) => event.type === 'tool')
expect(toolEvents.map((event) => [event.callId, event.state])).toEqual([
['read-0', 'pending'],
['read-0', 'running'],
['read-0', 'recoverable'],
['read-1', 'pending'],
['read-1', 'running'],
['read-1', 'completed']
])
expect(JSON.parse(toolEvents[0]!.input!)).toEqual({ path: absolutePath })
expect(toolEvents[2]).toMatchObject({
error: expect.stringContaining('路径必须相对于工作区')
})
expect(JSON.parse(toolEvents[3]!.input!)).toEqual({ path: 'README.md' })
expect(toolEvents[5]).toMatchObject({ output: expect.stringContaining(marker) })
expect(events).toContainEqual(expect.objectContaining({
type: 'text', delta: 'Read completed.'
}))
expect(events.at(-1)).toMatchObject({ type: 'done' })
} finally {
await runtime.dispose()
await rm(workspace, { recursive: true, force: true })
}
})
it('streams OpenAI Responses text and reasoning through tool rounds', async () => {
const streams = [
createSseEventStream([
+153
View File
@@ -68,6 +68,7 @@ vi.mock('../capabilities/mcp-client-transport', () => ({
import {
ModelToolProvider,
RecoverableModelToolError,
type ModelToolCallContext,
type ModelSubagentRequestContext,
type ModelToolResult
@@ -461,6 +462,158 @@ describe('ModelToolProvider', () => {
).resolves.toBe('saved')
})
it('recovers an absolute local path and reads the corrected relative path', async () => {
const workspace = await createWorkspace()
await writeFile(join(workspace, 'note.txt'), 'corrected read')
const provider = new ModelToolProvider(workspace)
const signal = new AbortController().signal
try {
await expect(provider.callTool('workspace_read_text', {
path: join(workspace, 'note.txt')
}, signal, toolContext)).rejects.toMatchObject({
name: 'RecoverableModelToolError',
nextAction: expect.stringContaining('workspace-relative path')
})
await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext))
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('corrected read') }] })
await expect(provider.callTool('workspace_read_text', { path: 'missing.txt' }, signal, toolContext))
.rejects.toMatchObject({
name: 'RecoverableModelToolError', cause: { code: 'ENOENT' },
nextAction: expect.stringContaining('Do not retry identical arguments')
})
} finally {
await provider.dispose()
}
})
it('recovers invalid search inputs and actual rg regex failures', async () => {
const workspace = await createWorkspace()
await writeFile(join(workspace, 'note.txt'), '[literal')
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
const signal = new AbortController().signal
try {
for (const args of [
{ pattern: 'x', path: workspace }, { pattern: 'x', path: 'missing' },
{ pattern: '[' }, { pattern: 'x', glob: ['['] }, { maxResults: 0 }
]) {
await expect(provider.callTool('workspace_rg', args, signal, toolContext)).rejects.toMatchObject({
name: 'RecoverableModelToolError',
nextAction: expect.stringContaining('adjust the search path/globs')
})
}
await expect(provider.callTool('workspace_rg', { pattern: '[', fixedStrings: true }, signal, toolContext))
.resolves.toMatchObject({ parts: [{ type: 'text', text: expect.stringContaining('note.txt:1:1:') }] })
} finally {
await provider.dispose()
}
})
it('bounds expected remote read errors without recovering disconnects or programmer errors', async () => {
const access = new LocalWorkspaceAccess(await createWorkspace())
const readText = vi.spyOn(access, 'readText')
const provider = new ModelToolProvider(access)
const signal = new AbortController().signal
try {
for (const code of ['ENOENT', 'EACCES', 'invalid-path', 'invalid-utf8', 'special-file']) {
const failure = Object.assign(new Error('detail'.repeat(1_000)), { data: { code } })
readText.mockRejectedValueOnce(failure)
const result = await provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext)
.catch((error: unknown) => error)
expect(result).toBeInstanceOf(RecoverableModelToolError)
const error = result as RecoverableModelToolError
expect(Buffer.byteLength(error.message)).toBeLessThanOrEqual(2_021)
expect(error.cause).toBe(failure)
expect(error.nextAction).not.toContain('process_execute')
}
for (const failure of [
new TypeError('programmer error'), new Error('remote disconnected'),
Object.assign(new Error('closed'), { code: 'closed' }),
Object.assign(new Error('stale workspace'), { data: { code: 'stale-workspace' } })
]) {
readText.mockRejectedValueOnce(failure)
await expect(provider.callTool('workspace_read_text', { path: 'note.txt' }, signal, toolContext))
.rejects.toBe(failure)
}
} finally {
await provider.dispose()
}
})
it('recovers the wrapped rg exit code 2 but leaves a missing executable fatal', async () => {
const workspace = await createWorkspace()
const access = new LocalWorkspaceAccess(workspace)
const failure = new Error('regex parse error', {
cause: Object.assign(new Error('process failed'), { code: 2 })
})
vi.spyOn(access, 'stat').mockRejectedValueOnce(failure)
const provider = new ModelToolProvider(access, [], undefined, undefined, false, {
ripgrepExecutablePath: join(workspace, 'missing-rg.exe')
})
try {
await expect(provider.callTool('workspace_rg', { path: 'note.txt', pattern: '[' }, new AbortController().signal, toolContext))
.rejects.toMatchObject({ name: 'RecoverableModelToolError', cause: failure })
const error = await provider.callTool('workspace_rg', { pattern: 'x' }, new AbortController().signal, toolContext)
.catch((error: unknown) => error)
expect(error).toBeInstanceOf(Error)
expect(error).not.toBeInstanceOf(RecoverableModelToolError)
expect(error).toMatchObject({ cause: { code: 'ENOENT' } })
} finally {
await provider.dispose()
}
})
it.each(['workspace_read_text', 'workspace_rg'])('keeps %s cancellation fatal', async (name) => {
const access = new LocalWorkspaceAccess(await createWorkspace())
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
const operation = name === 'workspace_rg' ? vi.spyOn(access, 'stat') : vi.spyOn(access, 'readText')
const args = { path: 'note.txt', ...(name === 'workspace_rg' ? { pattern: 'x' } : {}) }
try {
const abortError = new DOMException('cancelled', 'AbortError')
operation.mockRejectedValueOnce(abortError)
await expect(provider.callTool(name, args, new AbortController().signal, toolContext)).rejects.toBe(abortError)
const controller = new AbortController()
const reason = new Error('cancelled during access')
operation.mockImplementationOnce(async () => {
controller.abort(reason)
throw Object.assign(new Error('missing'), { code: 'ENOENT' })
})
await expect(provider.callTool(name, args, controller.signal, toolContext)).rejects.toBe(reason)
await expect(provider.callTool(name, {}, controller.signal, toolContext)).rejects.toBe(reason)
} finally {
await provider.dispose()
}
})
it('suggests process_execute only when available in local model Execute', async () => {
const workspace = await createWorkspace()
const access = new LocalWorkspaceAccess(workspace)
const identity = await access.getIdentity()
const processService = {
getCapability: vi.fn(async () => ({ available: true, shell: { kind: 'powershell', label: 'pwsh' } })),
dispose: vi.fn(async () => {})
} as unknown as DirectModelProcessService
const provider = new ModelToolProvider(access, [], undefined, undefined, false, { processService })
try {
for (const [context, available] of [
[{ ...toolContext, runtimeTarget: 'model' as const }, true],
[{ ...toolContext, runtimeTarget: 'model' as const, workMode: 'ask' as const }, false],
[toolContext, false],
[{ ...toolContext, runtimeTarget: 'model' as const, executionSpaceIdentity: `${identity.id}-other` }, false]
] as const) {
const error = await provider.callTool('workspace_read_text', { path: workspace }, new AbortController().signal, context)
.catch((error: unknown) => error) as RecoverableModelToolError
expect(error).toBeInstanceOf(RecoverableModelToolError)
expect(error.nextAction.includes('process_execute')).toBe(available)
}
vi.mocked(processService.getCapability).mockResolvedValueOnce({ available: false, reason: 'unavailable' })
await expect(provider.callTool('workspace_read_text', { path: workspace }, new AbortController().signal, {
...toolContext, runtimeTarget: 'model'
})).rejects.toMatchObject({ nextAction: expect.not.stringContaining('process_execute') })
} finally {
await provider.dispose()
}
})
it('delegates paged workspace reads and disposal to WorkspaceAccess', async () => {
const readText = vi.fn(async () => ({
path: 'remote.txt',
+67 -18
View File
@@ -1731,25 +1731,74 @@ export class ModelToolProvider implements ModelToolProviderLike {
throw error
}
}
if (name === 'workspace_rg') {
if (!this.programming.ripgrepExecutablePath) {
throw new Error('GoodBuddy 内置 ripgrep 不可用')
}
const input = ripgrepInputSchema.parse(argumentsValue)
return createTextToolResult(
await searchWorkspaceWithRipgrep(
this.programming.ripgrepExecutablePath,
input,
this.workspaceAccess,
signal
if (name === 'workspace_rg' || name === 'workspace_read_text') {
try {
if (name === 'workspace_rg') {
if (!this.programming.ripgrepExecutablePath) {
throw new Error('GoodBuddy 内置 ripgrep 不可用')
}
const input = ripgrepInputSchema.parse(argumentsValue)
return createTextToolResult(
await searchWorkspaceWithRipgrep(
this.programming.ripgrepExecutablePath,
input,
this.workspaceAccess,
signal
)
)
}
const input = readInputSchema.parse(argumentsValue)
return createTextToolResult(
await readWorkspaceLines(this.workspaceAccess, input, signal)
)
)
}
if (name === 'workspace_read_text') {
const input = readInputSchema.parse(argumentsValue)
return createTextToolResult(
await readWorkspaceLines(this.workspaceAccess, input, signal)
)
} catch (error) {
signal.throwIfAborted()
if (!(error instanceof Error) || error.name === 'AbortError' ||
(error.cause instanceof Error && error.cause.name === 'AbortError')) {
throw error
}
const code = 'code' in error ? error.code : undefined
const data = 'data' in error ? error.data : undefined
const remoteCode = data && typeof data === 'object' && 'code' in data
? data.code : undefined
const expectedOsCodes = ['ENOENT', 'ENOTDIR', 'EISDIR', 'EACCES', 'EPERM', 'ELOOP', 'ENAMETOOLONG']
const expectedWorkspaceError = [
'路径必须是工作区内的相对路径',
'路径必须是工作区内的相对路径,不能超出工作区',
'文件路径不能超出项目工作区',
'文件路径不能通过符号链接超出项目工作区',
'目标不是普通文件',
'目标不是目录',
'工作区读取目标不是有效 UTF-8 文本',
'工作区读取范围无效'
].includes(error.message)
const rgExecutionError = name === 'workspace_rg' && (
(error.cause instanceof Error && 'code' in error.cause && error.cause.code === 2) ||
error.message.startsWith('ripgrep failed (exit code 2); search coverage is incomplete.\n')
)
if (!(error instanceof z.ZodError) &&
!(typeof code === 'string' && expectedOsCodes.includes(code)) &&
!(typeof remoteCode === 'string' && [
...expectedOsCodes, 'invalid-path', 'invalid-utf8',
'not-directory', 'special-file', 'symlink-rejected'
].includes(remoteCode)) &&
!expectedWorkspaceError && !rgExecutionError) {
throw error
}
let nextAction = name === 'workspace_rg'
? 'Use workspace-relative paths; adjust the search path/globs or correct the pattern (use fixedStrings for literal text).'
: 'Use a workspace-relative path to an existing readable UTF-8 file; correct the path or offset/limit, and discover the filename if needed.'
nextAction += ' Do not retry identical arguments.'
if ((await this.getProcessTool(context, signal)).length > 0) {
nextAction += ' For files outside the workspace, use process_execute in local Execute mode.'
}
signal.throwIfAborted()
throw new RecoverableModelToolError(
`${name}: ${utf8Prefix(error.message, 2_000)}`,
nextAction,
{ cause: error }
)
}
}
if (name === 'workspace_apply_patch') {
const input = applyPatchInputSchema.parse(argumentsValue)
@@ -14,7 +14,7 @@ const cachedBinary = join(
const binaryPath = process.env.GOODBUDDY_TEST_OPENCODE_BINARY || cachedBinary
it.skipIf(!existsSync(binaryPath))(
'completes native Task external reads and structured yes/no and text questions while Ask disables tools',
'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')
@@ -50,7 +50,8 @@ it.skipIf(!existsSync(binaryPath))(
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')
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: [
@@ -74,7 +75,11 @@ it.skipIf(!existsSync(binaryPath))(
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']
]) {
@@ -102,17 +107,24 @@ it.skipIf(!existsSync(binaryPath))(
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') {
expect(event.questions).toHaveLength(2)
await runtime.respondToQuestion(event.questionId, [['Yes'], ['Custom answer']])
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)
+47
View File
@@ -2281,6 +2281,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => {
await runtime.dispose();
});
it("keeps concurrent child questions independently replyable and attributes only explicit task sessions", async () => {
const question = (id: string, sessionID: string) => ({
type: "question.asked", properties: { id, sessionID,
questions: [{ header: "Scope", question: `Choose ${id}`, options: [] }] },
});
const setup = runClient([
{ type: "message.part.updated", properties: { sessionID: "session-1", part: {
id: "task-part", callID: "task-call", type: "tool", tool: "task",
state: { status: "running", input: { subagent_type: "general", prompt: "Inspect", description: "Inspect task" },
metadata: { sessionId: "child-session" }, time: { start: 1 } },
} } },
question("first", "child-session"),
question("second", "other-child"),
question("first", "child-session"),
{ type: "message.part.updated", properties: { sessionID: "session-1", part: {
id: "task-part", callID: "task-call", type: "tool", tool: "task",
state: { status: "completed", input: { subagent_type: "general", prompt: "Inspect" },
output: "Finished", time: { start: 1, end: 2 } },
} } },
{ type: "session.idle", properties: { sessionID: "session-1" } },
]);
vi.mocked(setup.client.session.get).mockResolvedValue({ data: { parentID: "session-1" } } as never);
const runtime = embeddedRuntime(setup.client);
const stream = runtime.run({ requestId: crypto.randomUUID(), conversationId: "parallel",
prompt: "test", workMode: "execute" }, new AbortController().signal);
await stream.next();
const task = (await stream.next()).value;
expect(task?.type).toBe("subagent");
const first = (await stream.next()).value;
const second = (await stream.next()).value;
if (task?.type !== "subagent" || first?.type !== "question" || second?.type !== "question") throw new Error("Missing questions");
expect(first.childTaskId).toBe(task.childTaskId);
expect(second.childTaskId).toBeUndefined();
expect(first.questionId).not.toBe(second.questionId);
setup.questionReply.mockRejectedValueOnce(new Error("Reply failed"));
await expect(runtime.respondToQuestion(first.questionId, [["Answer"]])).rejects.toThrow("Reply failed");
await runtime.respondToQuestion(first.questionId, [["Answer"]]);
await runtime.respondToQuestion(second.questionId);
expect(setup.questionReply).toHaveBeenLastCalledWith(expect.objectContaining({ requestID: "first", answers: [["Answer"]] }), expect.anything());
expect(setup.questionReject).toHaveBeenCalledWith(expect.objectContaining({ requestID: "second" }), expect.anything());
const remaining = [];
for await (const event of stream) remaining.push(event);
expect(remaining.some((event) => event.type === "question")).toBe(false);
await expect(runtime.respondToQuestion(first.questionId, [["Late"]])).rejects.toThrow();
await runtime.dispose();
});
it("namespaces identical upstream question IDs across concurrent external conversations", async () => {
const setup = runClient([]);
(
+6
View File
@@ -2464,6 +2464,7 @@ export class OpenCodeRuntime implements AgentRuntime {
return false;
};
const reportedMessageIds = new Set<string>();
const childTaskIdsBySession = new Map<string, string>();
let waitingForRetry = false;
for await (const event of subscription.stream) {
const childProgress = subagentProgress.update(event);
@@ -2621,6 +2622,10 @@ export class OpenCodeRuntime implements AgentRuntime {
...(subagent ? { subagent } : {}),
});
if (subagent) {
const metadata = "metadata" in part.state ? part.state.metadata : undefined;
if (isRecord(metadata) && typeof metadata.sessionId === "string") {
childTaskIdsBySession.set(metadata.sessionId, subagent.childTaskId);
}
const retained = subagentProgress.retain(subagent);
toolStates.get(callId)!.subagent = retained;
yield retained;
@@ -2693,6 +2698,7 @@ export class OpenCodeRuntime implements AgentRuntime {
requestId: request.requestId,
type: "question",
questionId: publicQuestionId,
childTaskId: childTaskIdsBySession.get(event.properties.sessionID),
questions: questionRequest.questions.map((question) => ({
header: question.header,
question: question.question,
+2
View File
@@ -18,6 +18,7 @@ import type {
remoteSemanticTranscriptPageResultSchema,
} from '../../shared/remote-agent-contracts'
import type { z } from 'zod'
import type { RemoteQuestionResponse } from '../../shared/remote-question-contracts'
import type {
ModelBridgePolicy
} from '../../shared/model-bridge-contracts'
@@ -91,6 +92,7 @@ export interface RemoteModelBridgeSession {
* The runtime intentionally receives no SSH client, socket or command API.
*/
export interface RemoteRuntimeChannel {
respondToQuestion?(request: RemoteQuestionResponse): Promise<void>
readonly input: ReadableStream<Uint8Array>
readonly output: WritableStream<Uint8Array>
readonly generation: number