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
+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',