feat(storage): move desktop SQLite ownership into a storage utility process

Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
This commit is contained in:
mesalogo
2026-10-06 15:40:18 +08:00
parent bc677a3718
commit d80d42b032
229 changed files with 13632 additions and 3800 deletions
+74 -2
View File
@@ -7,6 +7,7 @@ import {
writeFile
} from 'node:fs/promises'
import { existsSync, readFileSync } from 'node:fs'
import fs from 'node:fs/promises'
import { createHash, randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
@@ -106,6 +107,7 @@ async function createDistribution(version = '1.5.47'): Promise<{
}
afterEach(async () => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
for (const restoreEnvironment of environmentRestorations.splice(0)) {
restoreEnvironment()
@@ -309,7 +311,7 @@ describe('ContinueHostAdapter', () => {
).rejects.toThrow('未通过宿主兼容性校验')
await expect(readdir(distribution.cacheRoot)).resolves.not.toEqual(
expect.arrayContaining([
expect.stringMatching(/^model-config-/u)
expect.stringMatching(/^goodbuddy-model-config-/u)
])
)
})
@@ -355,11 +357,81 @@ describe('ContinueHostAdapter', () => {
expect(launchHost).not.toHaveBeenCalled()
await expect(readdir(distribution.cacheRoot)).resolves.not.toEqual(
expect.arrayContaining([
expect.stringMatching(/^model-config-/u)
expect.stringMatching(/^goodbuddy-model-config-/u)
])
)
})
it('removes a partially written owned config and preserves external files', async () => {
const distribution = await createDistribution()
await mkdir(distribution.cacheRoot)
const external = join(distribution.cacheRoot, 'external.yaml')
await writeFile(external, 'keep')
const adapter = new ContinueHostAdapter({ binaryPath: distribution.entryPath,
configPath: external, workspace: process.cwd(), cacheRoot: distribution.cacheRoot })
const realOpen = fs.open
vi.spyOn(fs, 'open').mockImplementation(async (...args) => {
const handle = await realOpen(...args)
vi.spyOn(handle, 'writeFile').mockImplementation(async () => {
await handle.write('partial')
throw new Error('disk full')
})
return handle
})
await expect((adapter as unknown as {
writeTemporaryConfig(prefix: string, value: object, root: string): Promise<string>
}).writeTemporaryConfig('model-config', {}, distribution.cacheRoot)).rejects.toThrow('disk full')
expect(await readdir(distribution.cacheRoot)).toEqual(['external.yaml'])
expect(await readFile(external, 'utf8')).toBe('keep')
})
it('preserves an existing config when exclusive creation fails', async () => {
const distribution = await createDistribution()
await mkdir(distribution.cacheRoot)
const id = crypto.randomUUID()
vi.spyOn(crypto, 'randomUUID').mockReturnValue(id)
const path = join(distribution.cacheRoot, `goodbuddy-model-config-${id}.yaml`)
await writeFile(path, 'existing')
const adapter = new ContinueHostAdapter({ binaryPath: distribution.entryPath,
configPath: '', workspace: process.cwd(), cacheRoot: distribution.cacheRoot })
await expect((adapter as unknown as {
writeTemporaryConfig(prefix: string, value: object, root: string): Promise<string>
}).writeTemporaryConfig('model-config', {}, distribution.cacheRoot)).rejects.toMatchObject({ code: 'EEXIST' })
expect(await readFile(path, 'utf8')).toBe('existing')
})
it('preserves an existing global directory when exclusive creation fails', async () => {
const distribution = await createDistribution()
const id = crypto.randomUUID()
vi.spyOn(crypto, 'randomUUID').mockReturnValue(id)
const path = join(distribution.cacheRoot, `goodbuddy-isolated-global-${id}`)
await mkdir(path, { recursive: true })
await writeFile(join(path, 'history'), 'existing')
const adapter = new ContinueHostAdapter({ binaryPath: distribution.entryPath,
configPath: '', workspace: process.cwd(), cacheRoot: distribution.cacheRoot })
await expect((adapter as unknown as {
createRunGlobalDirectory(root: string): Promise<string>
}).createRunGlobalDirectory(distribution.cacheRoot)).rejects.toMatchObject({ code: 'EEXIST' })
expect(await readFile(join(path, 'history'), 'utf8')).toBe('existing')
})
it('preserves the prepared cache and external config after skill setup fails', async () => {
const distribution = await createDistribution()
const configPath = join(distribution.cacheRoot, 'external.json')
await mkdir(distribution.cacheRoot)
await writeFile(configPath, '{}')
const adapter = new ContinueHostAdapter({ binaryPath: distribution.entryPath,
configPath, workspace: process.cwd(), cacheRoot: distribution.cacheRoot,
trustedBundleHashes: [distribution.sourceHash],
skillPackages: [{ id: 'missing', directory: join(distribution.cacheRoot, 'missing') }] })
const prepared = await adapter.getPreparedHost()
const before = await readdir(distribution.cacheRoot)
await expect(adapter.run('test', new AbortController().signal, async () => 'once')).rejects.toThrow('Skill')
expect(await readdir(distribution.cacheRoot)).toEqual(before)
expect(existsSync(prepared.entryPath)).toBe(true)
expect(await readFile(configPath, 'utf8')).toBe('{}')
})
it('blocks runs without an explicit model profile or config file', async () => {
const launchHost = vi.fn()
const adapter = new ContinueHostAdapter({
+49 -42
View File
@@ -2,7 +2,7 @@ import spawn from 'cross-spawn'
import { parseContinueChecklist, runtimeChecklistSchema, type RuntimeChecklist } from '../../shared/runtime-checklist'
import { toolOperationSummary } from './tool-operation-summary'
import { createHash, randomBytes } from 'node:crypto'
import {
import fs, {
copyFile,
mkdir,
readFile,
@@ -55,6 +55,7 @@ import { readBoundedResponseText } from './bounded-response'
import { readBoundedFile } from '../workspace-file-access'
import { terminateProcessTreeAndWait } from './child-process-termination'
import { CONTINUE_HOST_LAYOUT_VERSION } from './continue-host-layout'
import { createRuntimeTemporaryDirectory, ensureRuntimeTemporaryRoot, recordRuntimeTemporaryChild, removeRuntimeTemporaryDirectory } from '../runtime-temporary-directory'
const supportedVersion = '1.5.47'
const supportedBundleHashes = new Set([
@@ -1066,8 +1067,9 @@ export class ContinueHostAdapter {
const stagingRoot = `${targetRoot}.staging-${crypto.randomUUID()}`
const stagingDist = join(stagingRoot, 'dist')
try {
await ensureRuntimeTemporaryRoot(this.options.cacheRoot)
await mkdir(stagingDist, { recursive: true })
await Promise.all([
const writes = await Promise.allSettled([
writeFile(join(stagingDist, 'index.js'), patched, 'utf8'),
copyFile(join(distribution, 'cn.js'), join(stagingDist, 'cn.js')),
copyFile(
@@ -1081,6 +1083,8 @@ export class ContinueHostAdapter {
),
copyFile(packagePath, join(stagingRoot, 'package.json'))
])
const failedWrite = writes.find(result => result.status === 'rejected')
if (failedWrite?.status === 'rejected') throw failedWrite.reason
await writeFile(
join(stagingRoot, '.ready'),
JSON.stringify({ sourceHash, patchedHash }),
@@ -1233,23 +1237,32 @@ export class ContinueHostAdapter {
private async writeTemporaryConfig(
prefix: string,
config: Record<string, unknown>
config: Record<string, unknown>,
runRoot: string
): Promise<string> {
await mkdir(this.options.cacheRoot, { recursive: true })
const configPath = join(
this.options.cacheRoot,
`${prefix}-${crypto.randomUUID()}.yaml`
runRoot,
`goodbuddy-${prefix}-${crypto.randomUUID()}.yaml`
)
await writeFile(configPath, JSON.stringify(config), {
encoding: 'utf8',
mode: 0o600,
flag: 'wx'
})
const contents = JSON.stringify(config)
const handle = await fs.open(configPath, 'wx', 0o600)
try {
try {
await handle.writeFile(contents, 'utf8')
} finally {
await handle.close()
}
} catch (error) {
await rm(configPath, { force: true })
throw error
}
return configPath
}
private async createRunConfig(
runOptions: ContinueHostRunOptions
runOptions: ContinueHostRunOptions,
runRoot: string
): Promise<string | undefined> {
const knowledgeCapability = runOptions.knowledgeCapability
const customMcpCapability = runOptions.customMcpCapability
@@ -1357,7 +1370,7 @@ export class ContinueHostAdapter {
...retainedServers,
...capabilityServers
]
}
}, runRoot
)
}
@@ -1447,21 +1460,23 @@ export class ContinueHostAdapter {
mcpServers: capabilityServers
}
: {})
})
}, runRoot)
}
private async createRunGlobalDirectory(): Promise<string> {
private async createRunGlobalDirectory(runRoot: string): Promise<string> {
const root = join(
this.options.cacheRoot,
`isolated-global-${crypto.randomUUID()}`
runRoot,
`goodbuddy-isolated-global-${crypto.randomUUID()}`
)
await mkdir(root, { recursive: false, mode: 0o700 })
const skillPackages = this.options.skillPackages ?? []
if (skillPackages.length === 0) {
try {
const skillPackages = this.options.skillPackages ?? []
if (skillPackages.length) await stageRuntimeSkillPackages(root, skillPackages, 'Continue')
return root
} catch (error) {
await rm(root, { recursive: true, force: true })
throw error
}
await stageRuntimeSkillPackages(root, skillPackages, 'Continue')
return root
}
async run(
@@ -1481,20 +1496,18 @@ export class ContinueHostAdapter {
}
let generatedConfigPath: string | undefined
let isolatedGlobalDirectory: string | undefined
const runRoot = await createRuntimeTemporaryDirectory(this.options.cacheRoot, 'goodbuddy-continue-')
let launchedChild: ContinueHostChild | undefined
let childClosed = false
try {
generatedConfigPath = await this.createRunConfig(runOptions)
generatedConfigPath = await this.createRunConfig(runOptions, runRoot)
const [{ entryPath }, port] = await Promise.all([
this.getPreparedHost(),
getAvailableLoopbackPort()
]).catch(async (error) => {
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
}
throw error
})
])
const token = randomBytes(32).toString('base64url')
const origin = `http://127.0.0.1:${port}`
isolatedGlobalDirectory = await this.createRunGlobalDirectory()
isolatedGlobalDirectory = await this.createRunGlobalDirectory(runRoot)
const args: string[] = []
const configPath =
generatedConfigPath ?? this.options.configPath.trim()
@@ -1574,12 +1587,15 @@ export class ContinueHostAdapter {
throw error
}
this.children.add(child)
launchedChild = child
child.once('close', () => { childClosed = true })
let childFailure: Error | undefined
child.once('error', (error) => {
childFailure = new Error('Continue 宿主进程启动失败', {
cause: error
})
})
await recordRuntimeTemporaryChild(runRoot, child.pid)
let stderrBytes = 0
child.stderr?.on('data', (chunk: Buffer | string) => {
stderrBytes += Buffer.byteLength(chunk)
@@ -1867,24 +1883,15 @@ export class ContinueHostAdapter {
} finally {
await this.terminate(child)
this.children.delete(child)
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
}
await rm(isolatedGlobalDirectory, {
recursive: true,
force: true
})
}
}
} finally {
if (generatedConfigPath) {
await rm(generatedConfigPath, { force: true })
if (launchedChild) {
await this.terminate(launchedChild)
this.children.delete(launchedChild)
}
if (isolatedGlobalDirectory) {
await rm(isolatedGlobalDirectory, {
recursive: true,
force: true
})
if (!launchedChild || childClosed || launchedChild.exitCode !== null || !launchedChild.pid) {
await removeRuntimeTemporaryDirectory(runRoot)
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { conversationOutputReadSchema, type ConversationOutputPage } from '../../shared/conversation-output'
import type { AttachmentStorageAccess } from '../desktop-storage-files'
export async function readConversationOutput(
storage: Pick<AttachmentStorageAccess, 'get' | 'outputRead'>,
input: unknown
): Promise<ConversationOutputPage> {
const { conversationId, handle, cursor, limitBytes } = conversationOutputReadSchema.parse(input)
const { size: totalBytes } = await storage.get(handle.slice(handle.lastIndexOf(':') + 1))
if (cursor > totalBytes) throw new Error('Output cursor exceeds stored content')
const requested = Math.min(limitBytes + 3, totalBytes - cursor)
const bytes = Buffer.from(await storage.outputRead(conversationId, handle, cursor, requested))
if (bytes.length !== requested) throw new Error('Incomplete stored output')
if (bytes.length && (bytes[0]! & 0xc0) === 0x80) throw new Error('Output cursor must be a UTF-8 boundary')
let end = Math.min(limitBytes, bytes.length)
if (end && end < bytes.length) {
let start = end - 1
while (start > 0 && (bytes[start]! & 0xc0) === 0x80) start--
const lead = bytes[start]!
const width = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1
if (start + width > end) end = start || Math.min(width, bytes.length)
}
const nextCursor = cursor + end
return { content: bytes.subarray(0, end).toString('utf8'), cursor, nextCursor, totalBytes, eof: nextCursor === totalBytes }
}
+13
View File
@@ -72,6 +72,19 @@ function settings(
}
describe('createAgentRuntime model compatibility', () => {
it('forwards the existing attachment owner to direct-model output consumers', async () => {
const backingStore = {
create: vi.fn(), append: vi.fn(), finish: vi.fn(), read: vi.fn(), release: vi.fn()
}
const outputAdopt = vi.fn()
const outputStore = { backingStore }
const runtime = createAgentRuntime(process.cwd(), settings(), { outputStore, outputAdopt })
try {
expect(runtime).toMatchObject({ options: { outputStore, outputAdopt } })
expect(backingStore.create).not.toHaveBeenCalled()
} finally { await runtime.dispose() }
})
it('passes the desktop failure observer to local OpenCode', async () => {
const observeFailure = vi.fn()
const runtime = createAgentRuntime(process.cwd(), settings({ provider: 'opencode' }), { observeFailure })
+9 -1
View File
@@ -46,6 +46,8 @@ import {
import type { LaunchEnvironmentProvider } from '../local-tool-environment/launch-environment-provider'
import type { DesktopDiagnosticFailureObserver } from '../desktop-diagnostics'
export type AgentCapabilityContext = {
outputStore?: ModelRuntimeOptions['outputStore']
outputAdopt?: ModelRuntimeOptions['outputAdopt']
observeFailure?: DesktopDiagnosticFailureObserver
localRuntimeRegistry?: LocalRuntimeRegistry
skillInstructions?: string
@@ -219,7 +221,11 @@ export function createAgentRuntime(
createToolProvider: (directory) => new ModelToolProvider(
new LocalWorkspaceAccess(directory), capabilities.mcpServers, undefined,
capabilities.knowledgeGateway, capabilities.webSearchEnabled === true,
{ runtimeTarget: 'deepseek-harness' }, capabilities.launchEnvironmentProvider
{
runtimeTarget: 'deepseek-harness',
outputStore: capabilities.outputStore,
outputAdopt: capabilities.outputAdopt
}, capabilities.launchEnvironmentProvider
)
}
const create = () => new DeepSeekHarnessRuntime(options)
@@ -353,6 +359,8 @@ export function createAgentRuntime(
(modelAuthentication === 'none' || modelApiKey))
) {
return new ModelAgentRuntime({
outputStore: capabilities.outputStore,
outputAdopt: capabilities.outputAdopt,
apiKey: modelApiKey ?? '',
baseUrl:
defaultModelProfile?.baseUrl ||
@@ -1,6 +1,7 @@
import { EventEmitter } from 'node:events'
import { spawn } from 'node:child_process'
import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { FileOutputBacking, outputFixtureRoot } from '../../../tests/support/paged-output-backing'
import { join, relative } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LocalWorkspaceAccess } from '../workspace'
@@ -31,9 +32,21 @@ class FakeChild extends EventEmitter implements DirectModelProcessChild {
}
const temporaryDirectories: string[] = []
const backings: FileOutputBacking[] = []
function outputStore() {
const backingStore = new FileOutputBacking()
backings.push(backingStore)
return { backingStore }
}
// The development shell may run Vitest using Electron's Node mode.
const spawnNode: DirectModelProcessSpawn = (executable, args, options) =>
spawn(executable, args, { ...options, env: { ...options.env, ELECTRON_RUN_AS_NODE: '1' } })
afterEach(async () => {
vi.useRealTimers()
await Promise.all(backings.splice(0).map((backing) => backing.dispose()))
for (const directory of temporaryDirectories.splice(0)) {
await import('node:fs/promises').then(({ rm }) =>
rm(directory, { recursive: true, force: true })
@@ -45,7 +58,8 @@ async function workspace(): Promise<{
root: string
access: LocalWorkspaceAccess
}> {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-process-'))
await mkdir(outputFixtureRoot, { recursive: true })
const root = await mkdtemp(join(outputFixtureRoot, 'process-'))
temporaryDirectories.push(root)
return { root, access: new LocalWorkspaceAccess(root) }
}
@@ -65,6 +79,7 @@ function fakeService(
) {
const spawnProcess = vi.fn<DirectModelProcessSpawn>(() => child)
const service = new LocalDirectModelProcessService({
outputStore: outputStore(),
platform: options.platform ?? 'linux',
environment: options.environment ?? { PATH: '/usr/bin' },
executableExists:
@@ -201,7 +216,7 @@ describe('LocalDirectModelProcessService', () => {
'resolves an existing %s working directory without workspace containment',
async (kind) => {
const { root, access } = await workspace()
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-outside-'))
const outside = await mkdtemp(join(outputFixtureRoot, 'outside-'))
temporaryDirectories.push(outside)
const nested = join(root, 'nested')
await mkdir(nested)
@@ -425,6 +440,7 @@ describe('LocalDirectModelProcessService', () => {
)
const terminated: DirectModelProcessChild[] = []
const service = new LocalDirectModelProcessService({
outputStore: outputStore(),
platform: 'linux',
executableExists: async (path) => path === '/bin/bash',
spawnProcess,
@@ -510,7 +526,7 @@ describe('LocalDirectModelProcessService', () => {
it('pages complete stdout and stderr from a real shell', async () => {
const { access } = await workspace()
const service = new LocalDirectModelProcessService()
const service = new LocalDirectModelProcessService({ outputStore: outputStore() })
try {
expect((await service.getCapability()).available).toBe(true)
const command = process.platform === 'win32'
@@ -540,4 +556,71 @@ describe('LocalDirectModelProcessService', () => {
await service.dispose()
}
})
it('backpressures real child output larger than the capture budget and retains failed output', async () => {
const { access } = await workspace()
const storage = outputStore()
const service = new LocalDirectModelProcessService({ outputStore: storage, spawnProcess: spawnNode })
try {
const result = await service.executeFile(process.execPath, ['-e', `
const { once } = require('node:events');
(async () => {
for (let i = 0; i < 160; i++) {
if (!process.stdout.write('x'.repeat(65536))) await once(process.stdout, 'drain');
}
process.exitCode = 7;
})();
`], { conversationId: 'large', workspace: access, signal: new AbortController().signal })
expect(result.exitCode).toBe(7)
expect(result.stderr).toBe('')
expect(storage.backingStore.count).toBe(1)
expect(storage.backingStore.maximumWriteBytes).toBeLessThanOrEqual(65536)
expect(result.stdoutReference?.totalBytes).toBe(10 * 1024 * 1024)
let cursor = result.stdoutReference!.nextCursor
while (cursor < result.stdoutReference!.totalBytes) {
const page = await service.readOutput('large', result.stdoutReference!.handle, cursor)
expect(page.content).toBe('x'.repeat(page.nextCursor - cursor))
cursor = page.nextCursor
}
vi.spyOn(storage.backingStore, 'release').mockRejectedValueOnce(new Error('file busy'))
await expect(service.dispose()).rejects.toThrow('file busy')
} finally {
await service.dispose()
}
expect(storage.backingStore.count).toBe(0)
})
it('awaits pending file IO when a real child is cancelled and retains captured bytes', async () => {
const { access } = await workspace()
const storage = outputStore()
const controller = new AbortController()
const append = storage.backingStore.append.bind(storage.backingStore)
let resume!: () => void
const gate = new Promise<void>((resolve) => { resume = resolve })
const writing = vi.spyOn(storage.backingStore, 'append').mockImplementationOnce(async (...args) => {
controller.abort()
await gate
await append(...args)
})
const service = new LocalDirectModelProcessService({ outputStore: storage, spawnProcess: spawnNode })
try {
let settled = false
const execution = service.executeFile(process.execPath, ['-e',
"process.stdout.write('x'.repeat(200000)); setInterval(() => {}, 1000)"
], { conversationId: 'cancel-io', workspace: access, signal: controller.signal })
.finally(() => { settled = true })
await vi.waitFor(() => expect(writing).toHaveBeenCalled())
expect(settled).toBe(false)
resume()
const result = await execution
expect(result.terminationReason).toBe('cancelled')
expect(result.stdoutReference).toBeDefined()
const page = await service.readOutput('cancel-io', result.stdoutReference!.handle,
result.stdoutReference!.nextCursor)
expect(page.content).toMatch(/^x+$/)
} finally {
resume()
await service.dispose()
}
})
})
+37 -6
View File
@@ -19,7 +19,8 @@ import {
PagedOutputStore,
type PagedOutputPage,
type PagedOutputReference,
type PagedOutputWriter
type PagedOutputWriter,
type PagedOutputStoreOptions
} from './paged-output-store'
const DEFAULT_TIMEOUT_MS = 120_000
@@ -100,12 +101,18 @@ export interface DirectModelProcessService {
}
type ProcessOutput = {
pause?(): unknown
resume?(): unknown
on(event: 'data', listener: (chunk: Buffer | string) => void): unknown
once(event: 'error', listener: (error: Error) => void): unknown
removeListener?(
event: 'error',
listener: (error: Error) => void
): unknown
removeListener?(
event: 'data',
listener: (chunk: Buffer | string) => void
): unknown
}
export type DirectModelProcessChild = WaitableProcessTreeChild & {
@@ -128,6 +135,7 @@ export type DirectModelProcessSpawn = (
) => DirectModelProcessChild
export type LocalDirectModelProcessServiceOptions = {
outputStore?: PagedOutputStoreOptions
outputPrefix?: string
platform?: NodeJS.Platform
environment?: NodeJS.ProcessEnv
@@ -348,7 +356,7 @@ export class LocalDirectModelProcessService
constructor(
private readonly options: LocalDirectModelProcessServiceOptions = {}
) {
this.outputs = new PagedOutputStore(options.outputPrefix ?? 'process')
this.outputs = new PagedOutputStore(options.outputPrefix ?? 'process', options.outputStore)
this.shell = resolveProcessShell(options)
}
@@ -515,9 +523,6 @@ export class LocalDirectModelProcessService
stdout: PagedOutputWriter,
stderr: PagedOutputWriter
): Promise<ProcessExecuteResult> {
child.stdout?.on('data', (chunk) => stdout.append(chunk))
child.stderr?.on('data', (chunk) => stderr.append(chunk))
return new Promise((resolveResult, rejectResult) => {
let settled = false
let cleanupStarted = false
@@ -525,12 +530,16 @@ export class LocalDirectModelProcessService
let terminationReason: 'timeout' | 'cancelled' | undefined
let exitCode: number | null = null
let exitSignal: string | undefined
const outputListeners: Array<{ stream: ProcessOutput; listener: (chunk: Buffer | string) => void }> = []
const removeListeners = (): void => {
clearTimeout(timeout)
cancellationSignal.removeEventListener('abort', cancel)
child.stdout?.removeListener?.('error', failFromOutput)
child.stderr?.removeListener?.('error', failFromOutput)
for (const { stream, listener } of outputListeners) {
stream.removeListener?.('data', listener)
}
}
const result = async (): Promise<ProcessExecuteResult> => {
const stdoutResult = await stdout.finish()
@@ -618,6 +627,23 @@ export class LocalDirectModelProcessService
failure = error
startCleanup()
}
const capture = (stream: ProcessOutput | null | undefined, writer: PagedOutputWriter): void => {
if (!stream) return
const listener = (chunk: Buffer | string): void => {
if (settled) return
stream.pause?.()
try {
void writer.append(chunk).then(
() => { if (!settled) stream.resume?.() },
(error: Error) => { failFromOutput(error) }
)
} catch (error) {
failFromOutput(error instanceof Error ? error : new Error('Unable to capture tool output'))
}
}
outputListeners.push({ stream, listener })
stream.on('data', listener)
}
const timeout = timeoutMs === undefined ? undefined : setTimeout(() => {
if (settled || terminationReason === 'cancelled') {
return
@@ -627,6 +653,8 @@ export class LocalDirectModelProcessService
}, timeoutMs)
timeout?.unref?.()
capture(child.stdout, stdout)
capture(child.stderr, stderr)
child.stdout?.once('error', failFromOutput)
child.stderr?.once('error', failFromOutput)
child.once('error', (error: unknown) => {
@@ -674,7 +702,10 @@ export class LocalDirectModelProcessService
}
dispose(): Promise<void> {
this.disposePromise ??= this.disposeOnce()
this.disposePromise ??= this.disposeOnce().catch((error: unknown) => {
this.disposePromise = undefined
throw error
})
return this.disposePromise
}
+8 -4
View File
@@ -1,5 +1,5 @@
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { FileOutputBacking, outputFixtureRoot } from '../../../tests/support/paged-output-backing'
import { join } from 'node:path'
import { rgPath } from '@vscode/ripgrep'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -10,14 +10,18 @@ import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
let root: string
let workspace: LocalWorkspaceAccess
let service: LocalDirectModelProcessService
let backingStore: FileOutputBacking
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'goodbuddy-rg-'))
await mkdir(outputFixtureRoot, { recursive: true })
root = await mkdtemp(join(outputFixtureRoot, 'rg-'))
workspace = new LocalWorkspaceAccess(root)
service = new LocalDirectModelProcessService()
backingStore = new FileOutputBacking()
service = new LocalDirectModelProcessService({ outputStore: { backingStore } })
await writeFile(join(root, 'valid.txt'), 'before\ntarget\nafter\n')
})
afterEach(async () => {
await service.dispose()
await backingStore.dispose()
await rm(root, { recursive: true, force: true })
})
const search = (args: string[], signal = new AbortController().signal) =>
@@ -71,7 +75,7 @@ describe('native ripgrep', () => {
} finally { await configured.dispose() }
})
it('allows external paths and directory links with current-user permissions', async () => {
const outside = await mkdtemp(join(tmpdir(), 'goodbuddy-rg-outside-'))
const outside = await mkdtemp(join(outputFixtureRoot, 'rg-outside-'))
try {
await writeFile(join(outside, 'outside.txt'), 'target\n')
await symlink(outside, join(root, 'linked'), process.platform === 'win32' ? 'junction' : 'dir')
+59 -15
View File
@@ -33,23 +33,64 @@ function setup(fetcher = vi.fn<typeof fetch>(async () => Response.json({ data: [
allowConversationInvocation: true, baseUrl: 'https://provider.test/v1', authentication: 'api-key' as const, apiKey: 'test-secret' }
const settings: ImageServiceSettings = { modelProfiles: [profile], defaultImageModelProfileId: profile.id }
const events: ImageOperation[] = []
const service = new ImageGenerationService({ database, getSettings: async () => settings, fetcher,
const storage = {
getConversation: async (...args: Parameters<AssistantDatabase['getConversation']>) => database.getConversation(...args),
getArtifact: async (...args: Parameters<AssistantDatabase['getArtifact']>) => database.getArtifact(...args),
saveConversationImageOperation: async (...args: Parameters<AssistantDatabase['saveConversationImageOperation']>) => database.saveConversationImageOperation(...args),
saveConversationImageSources: async (...args: Parameters<AssistantDatabase['saveConversationImageSources']>) => database.saveConversationImageSources(...args),
markUnfinishedImageOperationsUnconfirmed: async () => database.markUnfinishedImageOperationsUnconfirmed()
}
const service = new ImageGenerationService({ database: storage, getSettings: async () => settings, fetcher,
onOperation: operation => { imageOperationSchema.parse(operation); events.push(operation); observers.onOperation?.(operation) }, onUsage: observers.onUsage, onError: observers.onError })
service.initialize()
cleanups.push(() => service.dispose())
return { database, service, context, fetcher, settings, profile, events, header, message }
return { database, storage, service, context, fetcher, settings, profile, events, header, message }
}
describe('Main conversation image service', () => {
it('does not let a destroyed Renderer or usage observer prevent submission or overwrite completion', async () => {
it('awaits usage persistence and reports its failure to callers and shutdown', async () => {
let rejectUsage!: (error: Error) => void
const onUsage = vi.fn(() => new Promise<void>((_resolve, reject) => { rejectUsage = reject }))
const h = setup(vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: png }], usage: { input_tokens: 2, output_tokens: 3 } })), { onUsage })
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'usage')
const rejected = expect(pending).rejects.toThrow('Usage storage failed')
await vi.waitFor(() => expect(onUsage).toHaveBeenCalledOnce())
expect(h.events.at(-1)?.state).not.toBe('completed')
rejectUsage(new Error('Usage storage failed'))
await rejected
await expect(h.service.dispose()).rejects.toThrow('Usage storage failed')
cleanups.pop()
})
it('does not notify completion or finish shutdown before the image write commits', async () => {
const h = setup()
const save = h.database.saveConversationImageOperation.bind(h.database)
let commit!: () => void
vi.spyOn(h.storage, 'saveConversationImageOperation').mockImplementation((operation, image) => {
if (!image) return Promise.resolve(save(operation, image))
return new Promise(resolve => { commit = () => resolve(save(operation, image)) })
})
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'delayed-write')
await vi.waitFor(() => expect(commit).toBeTypeOf('function'))
expect(h.events.at(-1)?.state).toBe('saving')
const closed = vi.fn()
const closing = h.service.dispose().then(closed)
await Promise.resolve()
expect(closed).not.toHaveBeenCalled()
commit()
expect((await pending).state).toBe('completed')
await closing
})
it('does not let a destroyed Renderer prevent submission or overwrite completion', async () => {
const onError = vi.fn()
const h = setup(vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: png }], usage: { input_tokens: 2, output_tokens: 3 } })), {
onOperation: () => { throw new Error('Renderer is destroyed') },
onUsage: () => { throw new Error('Usage observer failed') }, onError
onUsage: async () => {}, onError
})
const operation = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'observer')
expect(operation.state).toBe('completed')
expect(h.service.getOperation(h.context.conversationId, operation.id).state).toBe('completed')
expect((await h.service.getOperation(h.context.conversationId, operation.id)).state).toBe('completed')
expect(h.fetcher).toHaveBeenCalledOnce()
expect(onError).toHaveBeenCalled()
})
@@ -61,8 +102,9 @@ describe('Main conversation image service', () => {
vi.spyOn(h.database, 'saveConversationImageOperation').mockImplementation(() => { throw new Error('Disk full') })
return Response.json({ data: [{ b64_json: png }] })
})
const operation = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'disk-full')
expect(operation).toMatchObject({ state: 'failed', error: 'Disk full', artifactIds: [] })
await expect(h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'disk-full')).rejects.toThrow('Disk full')
await expect(h.service.dispose()).rejects.toThrow('Disk full')
cleanups.pop()
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'Disk full' }))
expect(h.fetcher).toHaveBeenCalledOnce()
})
@@ -72,7 +114,7 @@ describe('Main conversation image service', () => {
expect(first.state).toBe('completed')
expect(h.database.getArtifact(first.artifactIds[0]!).content).toContain(png)
h.database.saveLocalConversations([{ header: h.header, messages: [h.message] }])
expect(h.service.getOperation(h.context.conversationId, first.id)).toEqual(first)
expect(await h.service.getOperation(h.context.conversationId, first.id)).toEqual(first)
expect(h.database.getConversation(h.context.conversationId).messages[0]!.artifactIds).toEqual(first.artifactIds)
h.settings.defaultImageModelProfileId = randomUUID()
const second = await h.service.bind({ ...h.context, requestId: randomUUID() }).call({ intent: 'edit', prompt: 'Turn red', sourceArtifactIds: first.artifactIds }, 'edit')
@@ -84,7 +126,7 @@ describe('Main conversation image service', () => {
it('persists upload bytes and rejects cross-conversation or missing source references', async () => {
const h = setup()
const ids = h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
const ids = await h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
h.database.saveLocalConversations([{ header: h.header, messages: [h.message] }])
expect(h.database.getConversation(h.context.conversationId).messages[0]!.imageSourceArtifactIds).toEqual(ids)
const binding = h.service.bind(h.context)
@@ -136,7 +178,7 @@ describe('Main conversation image service', () => {
const one = binding.call(input, 'same')
const two = binding.call(input, 'same')
await vi.waitFor(() => expect(h.events).toHaveLength(1))
h.service.cancel(h.context.conversationId, h.events[0]!.id)
await h.service.cancel(h.context.conversationId, h.events[0]!.id)
resolve(Response.json({ data: [{ b64_json: png }] }))
const completed = await one
expect(await two).toEqual(completed)
@@ -150,7 +192,7 @@ describe('Main conversation image service', () => {
it('never falls back to a paid generation after an edit rejection and redacts provider errors', async () => {
const h = setup(vi.fn<typeof fetch>(async () => Response.json({ error: { message: 'Image editing not supported Authorization: Bearer private-key' } }, { status: 405 })))
const ids = h.service.persistUploads(h.context, [{ name: 'source', mediaType: 'image/png', data: png }])
const ids = await h.service.persistUploads(h.context, [{ name: 'source', mediaType: 'image/png', data: png }])
const result = await h.service.bind(h.context).call({ intent: 'edit', prompt: 'Blue', sourceArtifactIds: ids }, 'edit')
expect(result.state).toBe('failed')
expect(result.error).toContain('405')
@@ -165,7 +207,9 @@ describe('Main conversation image service', () => {
await vi.waitFor(() => expect(h.fetcher).toHaveBeenCalledOnce())
h.database.deleteLocalConversation(h.context.conversationId)
resolve(Response.json({ data: [{ b64_json: png }] }))
await pending
await expect(pending).rejects.toThrow()
await expect(h.service.dispose()).rejects.toThrow()
cleanups.pop()
expect(() => h.database.getConversation(h.context.conversationId)).toThrow()
expect(h.database.listArtifacts()).toHaveLength(0)
})
@@ -189,7 +233,7 @@ describe('Main conversation image service', () => {
}))
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'cancel')
await vi.waitFor(() => expect(h.fetcher).toHaveBeenCalledTimes(2))
h.service.cancel(h.context.conversationId, h.events.at(-1)!.id)
await h.service.cancel(h.context.conversationId, h.events.at(-1)!.id)
expect(await pending).toMatchObject({ state: 'stopped', cancellationRequested: true })
expect(h.fetcher).toHaveBeenCalledTimes(2)
})
@@ -278,7 +322,7 @@ describe('Main conversation image service', () => {
const text = h.database.createTextArtifact({ title: 'Text reply', content: 'Not an image' })
h.database.saveLocalConversations([{ header: h.header, messages: [{ ...h.message, artifactIds: [text.id, randomUUID()] }] }])
expect(await binding.describeSave!()).toBeUndefined()
const [uploadId] = h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
const [uploadId] = await h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
// IDs are listed so the model can save images even when it cannot generate.
expect(await binding.describeSave!()).toContain(uploadId)
expect(await h.service.bind(h.context).describeSave!()).toContain(uploadId)
@@ -315,7 +359,7 @@ describe('Main conversation image service', () => {
it('filters missing and non-image references and deduplicates before limiting recent images', async () => {
const h = setup()
const generated = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'catalog')
const [upload] = h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
const [upload] = await h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
h.profile.allowConversationInvocation = false
const text = h.database.createTextArtifact({ title: 'Text', content: 'Not an image' })
const missing = randomUUID()
+72 -36
View File
@@ -5,7 +5,7 @@ import {
imageSaveMaximumBytes, imageSaveMimeTypeForPath, imageSaveToolDescription, imageSaveToolInputSchema, imageToolDescription, imageToolInputSchema,
type ImageOperation, type ImageRequestContext, type ImageSaveResult, type ImageSaveToolInput, type ImageToolInput
} from '../../shared/image-generation-contracts'
import type { AssistantDatabase } from '../assistant/assistant-database'
import type { AssistantStoragePort, Awaitable } from '../assistant-storage-port'
import { ModelAgentRuntime, type ModelRuntimeOptions } from './model-runtime'
import { withImageConversationContext } from './image-conversation-context'
import type { AgentImage, RuntimeEvent } from './runtime'
@@ -26,10 +26,10 @@ export type ImageServiceSettings = {
defaultImageModelProfileId?: string | null
}
export type ImageGenerationServiceOptions = {
database: Pick<AssistantDatabase, 'getConversation' | 'getArtifact' | 'saveConversationImageOperation' | 'saveConversationImageSources' | 'markUnfinishedImageOperationsUnconfirmed'>
database: Pick<AssistantStoragePort, 'getConversation' | 'getArtifact' | 'saveConversationImageOperation' | 'saveConversationImageSources' | 'markUnfinishedImageOperationsUnconfirmed'>
getSettings(): Promise<ImageServiceSettings>
onOperation?(operation: ImageOperation): void
onUsage?(event: Extract<RuntimeEvent, { type: 'model-usage' }>): void
onUsage?(event: Extract<RuntimeEvent, { type: 'model-usage' }>): Awaitable<void>
/** Diagnostic sink for observer or terminal-persistence failures; must not throw. */
onError?(error: Error): void
fetcher?: typeof fetch
@@ -41,11 +41,13 @@ export type ImageGenerationServiceOptions = {
export class ImageGenerationService {
private readonly active = new Map<string, { operation: ImageOperation; controller: AbortController; done: Promise<void> }>()
private closing = false
private persistenceFailure?: unknown
private readonly pending = new Set<Promise<ImageOperation>>()
constructor(private readonly options: ImageGenerationServiceOptions) {}
initialize(): void {
this.options.database.markUnfinishedImageOperationsUnconfirmed()
async initialize(): Promise<void> {
await this.options.database.markUnfinishedImageOperationsUnconfirmed()
}
bind(context: ImageRequestContext): ImageToolBinding {
@@ -57,7 +59,7 @@ export class ImageGenerationService {
const settings = await this.options.getSettings()
const profiles = settings.modelProfiles.filter(profile => profile.protocol === 'openai-images-generations' && profile.allowConversationInvocation === true)
if (!profiles.length) return undefined
const conversation = this.options.database.getConversation(bound.conversationId)
const conversation = await this.options.database.getConversation(bound.conversationId)
const references = conversation.messages.flatMap(message => [
...(message.imageSourceArtifactIds ?? []).map(id => ({ id, kind: 'upload' })),
...(message.artifactIds ?? []).map(id => ({ id, kind: 'artifact' }))
@@ -72,18 +74,18 @@ export class ImageGenerationService {
const key = JSON.stringify([bound.conversationId, bound.requestId, callId])
let submitted = submissions.get(key)
if (!submitted) {
submitted = this.submit(bound, imageToolInputSchema.parse(input), callId, signal)
submitted = this.track(this.submit(bound, imageToolInputSchema.parse(input), callId, signal))
submissions.set(key, submitted)
void submitted.catch(() => submissions.delete(key))
}
const operation = await submitted
const active = this.active.get(operation.id)
if (active) {
await new Promise<void>(resolve => {
await new Promise<void>((resolve, reject) => {
const finish = (): void => { clearTimeout(timer); signal?.removeEventListener('abort', finish); resolve() }
const timer = setTimeout(finish, this.options.toolWaitMs ?? 15_000)
signal?.addEventListener('abort', finish, { once: true })
void active.done.then(finish)
void active.done.then(finish, error => { clearTimeout(timer); signal?.removeEventListener('abort', finish); reject(error) })
if (signal?.aborted) finish()
})
return active.operation
@@ -91,7 +93,7 @@ export class ImageGenerationService {
return this.getOperation(bound.conversationId, operation.id)
},
describeSave: async () => {
const conversation = this.options.database.getConversation(bound.conversationId)
const conversation = await this.options.database.getConversation(bound.conversationId)
// The model can only save IDs it has seen, so list them even when generate_image is unavailable.
const references = conversation.messages.flatMap(message => [
...(message.imageSourceArtifactIds ?? []).map(id => ({ artifactId: id, kind: 'upload' })),
@@ -104,7 +106,7 @@ export class ImageGenerationService {
if (seen.has(reference.artifactId)) continue
seen.add(reference.artifactId)
try {
if (this.options.database.getArtifact(reference.artifactId).kind !== 'image') continue
if ((await this.options.database.getArtifact(reference.artifactId)).kind !== 'image') continue
} catch (error) {
if (error instanceof Error && error.message === '成果不存在') continue
throw error
@@ -149,17 +151,18 @@ export class ImageGenerationService {
/** Returns a conversation-visible image encoded as the requested format; used for local and remote saves. */
async readImageForSave(conversationId: string, artifactId: string, targetMimeType: ImageSaveResult['mimeType']): Promise<Buffer> {
const conversation = this.options.database.getConversation(conversationId)
const conversation = await this.options.database.getConversation(conversationId)
const allowed = new Set(conversation.messages.flatMap(message => [
...(message.artifactIds ?? []), ...(message.imageSourceArtifactIds ?? []),
...(message.imageOperations?.flatMap(operation => operation.artifactIds) ?? [])
]))
if (!allowed.has(artifactId)) throw new Error('Image is not available in this conversation')
let artifact: ReturnType<AssistantDatabase['getArtifact']>
let artifact: Awaited<ReturnType<AssistantStoragePort['getArtifact']>>
try {
artifact = this.options.database.getArtifact(artifactId)
} catch {
throw new Error('Image is not available in this conversation')
artifact = await this.options.database.getArtifact(artifactId)
} catch (error) {
if (error instanceof Error && error.message === '成果不存在') throw new Error('Image is not available in this conversation', { cause: error })
throw error
}
const match = artifact.kind === 'image'
? artifact.content?.match(/^data:(image\/(?:png|jpeg|webp));base64,([\s\S]+)$/u)
@@ -177,48 +180,63 @@ export class ImageGenerationService {
return bytes
}
persistUploads(context: Pick<ImageRequestContext, 'conversationId' | 'messageId'>, images: readonly AgentImage[]): string[] {
async persistUploads(context: Pick<ImageRequestContext, 'conversationId' | 'messageId'>, images: readonly AgentImage[]): Promise<string[]> {
return this.options.database.saveConversationImageSources({ ...context, images })
}
getOperation(conversationId: string, operationId: string): ImageOperation {
const operation = this.options.database.getConversation(conversationId).messages
async getOperation(conversationId: string, operationId: string): Promise<ImageOperation> {
const operation = (await this.options.database.getConversation(conversationId)).messages
.flatMap(message => message.imageOperations ?? []).find(item => item.id === operationId)
if (!operation) throw new Error('Image operation does not exist in this conversation')
return operation
}
cancel(conversationId: string, operationId: string): ImageOperation {
const previous = this.getOperation(conversationId, operationId)
cancel(conversationId: string, operationId: string): Promise<ImageOperation> {
return this.track(this.cancelOperation(conversationId, operationId))
}
private async cancelOperation(conversationId: string, operationId: string): Promise<ImageOperation> {
const previous = await this.getOperation(conversationId, operationId)
const active = this.active.get(operationId)
if (!active) return previous
active.operation = this.save({ ...active.operation, state: 'cancelling', cancellationRequested: true })
active.operation = { ...active.operation, state: 'cancelling', cancellationRequested: true }
await this.save(active.operation)
active.controller.abort(new Error('Stopped waiting; the image provider may still generate the image'))
return active.operation
}
regenerate(context: ImageRequestContext, operationId: string): Promise<ImageOperation> {
const previous = this.getOperation(context.conversationId, operationId)
async regenerate(context: ImageRequestContext, operationId: string): Promise<ImageOperation> {
const previous = await this.getOperation(context.conversationId, operationId)
// Keep the originating request so usage stays attached to its task row;
// the fresh call ID keeps the regenerated operation distinct.
return this.bind({ ...context, requestId: previous.requestId })
.call({ ...previous.input, modelProfileId: previous.modelProfileId }, randomUUID())
}
cancelConversation(conversationId: string): void {
async cancelConversation(conversationId: string): Promise<void> {
for (const active of this.active.values()) {
if (active.operation.conversationId === conversationId) this.cancel(conversationId, active.operation.id)
if (active.operation.conversationId === conversationId) await this.cancel(conversationId, active.operation.id)
}
}
async dispose(): Promise<void> {
this.closing = true
await Promise.allSettled(this.pending)
for (const active of this.active.values()) active.controller.abort(new Error('Application is closing; image result is unconfirmed'))
await Promise.all([...this.active.values()].map(active => active.done))
await Promise.allSettled([...this.active.values()].map(active => active.done))
if (this.persistenceFailure) throw this.persistenceFailure
}
private save(operation: ImageOperation, image?: { mimeType: 'image/png' | 'image/jpeg' | 'image/webp'; data: string }): ImageOperation {
const saved = this.options.database.saveConversationImageOperation({ ...operation, updatedAt: Date.now() }, image)
private track(operation: Promise<ImageOperation>): Promise<ImageOperation> {
this.pending.add(operation)
void operation.then(() => this.pending.delete(operation), () => this.pending.delete(operation))
return operation
}
private async save(operation: ImageOperation, image?: { mimeType: 'image/png' | 'image/jpeg' | 'image/webp'; data: string }): Promise<ImageOperation> {
let saved: ImageOperation
try { saved = await this.options.database.saveConversationImageOperation({ ...operation, updatedAt: Date.now() }, image) }
catch (error) { this.persistenceFailure = error; throw error }
try {
this.options.onOperation?.(saved)
} catch (error) {
@@ -235,7 +253,7 @@ export class ImageGenerationService {
private async submit(context: ImageRequestContext, input: ImageToolInput, callId: string, signal?: AbortSignal): Promise<ImageOperation> {
if (this.closing) throw new Error('Image service is closing')
const settings = await this.options.getSettings()
const conversation = this.options.database.getConversation(context.conversationId)
const conversation = await this.options.database.getConversation(context.conversationId)
const existing = conversation.messages.flatMap(message => message.imageOperations ?? [])
.find(operation => operation.requestId === context.requestId && operation.callId === callId)
if (existing) return existing
@@ -255,11 +273,22 @@ export class ImageGenerationService {
if (!profile || !profile.allowConversationInvocation || profile.protocol !== 'openai-images-generations') throw new Error('Selected image model is unavailable; select another model')
if (profile.authentication === 'api-key' && !profile.apiKey) throw new Error('Selected image model has no API key configured')
const connection = structuredClone(profile)
const request = withImageConversationContext({ requestId: context.requestId, conversationId: context.conversationId, prompt: input.prompt, imageContextArtifactIds: input.sourceArtifactIds }, id => this.options.database.getArtifact(id))
const artifacts = new Map<string, Awaited<ReturnType<AssistantStoragePort['getArtifact']>>>()
for (const id of input.sourceArtifactIds) {
try { artifacts.set(id, await this.options.database.getArtifact(id)) }
catch (error) {
if (!(error instanceof Error) || error.message !== '成果不存在') throw error
}
}
const request = withImageConversationContext({ requestId: context.requestId, conversationId: context.conversationId, prompt: input.prompt, imageContextArtifactIds: input.sourceArtifactIds }, id => {
const artifact = artifacts.get(id)
if (!artifact) throw new Error('成果不存在')
return artifact
})
if (input.intent === 'edit' && (request.imageContextNotice || request.images?.length !== input.sourceArtifactIds.length)) throw new Error('Source image is missing or unsupported; select the image again')
signal?.throwIfAborted()
if (this.closing) throw new Error('Image service is closing')
const operation = this.save({ id: randomUUID(), conversationId: context.conversationId, messageId: context.messageId, requestId: context.requestId,
const operation = await this.save({ id: randomUUID(), conversationId: context.conversationId, messageId: context.messageId, requestId: context.requestId,
callId, modelProfileId: connection.id, modelName: connection.modelName, modelProfileName: connection.name,
input: { ...input, quality: input.quality ?? connection.imageGenerationQuality ?? 'auto' },
state: 'running', artifactIds: [], createdAt: Date.now(), updatedAt: Date.now() })
@@ -268,14 +297,19 @@ export class ImageGenerationService {
const active = { operation, controller: new AbortController(), done: Promise.resolve() }
this.active.set(operation.id, active)
active.done = (async () => {
let persistenceError: unknown
try {
for await (const event of runtime.generateImage(request, active.controller.signal)) {
if (event.type === 'model-usage') {
try { this.options.onUsage?.(event) } catch (error) { this.report(error) }
try { await this.options.onUsage?.(event) }
catch (error) { persistenceError = error; throw error }
}
if (event.type === 'generated-image') {
active.operation = this.save({ ...active.operation, state: 'saving' })
active.operation = this.save(active.operation, event)
try {
const saving = await this.save({ ...active.operation, state: 'saving' })
active.operation = { ...saving, cancellationRequested: active.operation.cancellationRequested ?? saving.cancellationRequested }
active.operation = await this.save(active.operation, event)
} catch (error) { persistenceError = error; throw error }
}
}
} catch (error) {
@@ -284,12 +318,14 @@ export class ImageGenerationService {
const suffix = detail.match(/(HTTP [^)]+)$/u)?.[0] ?? ''
const publicDetail = redactSensitiveText(suffix ? detail.slice(0, -suffix.length) : detail) + suffix
active.operation = { ...active.operation, state, error: publicDetail.slice(0, 2_000), updatedAt: Date.now() }
try { active.operation = this.save(active.operation) } catch (storageError) { this.report(storageError) }
active.operation = await this.save(active.operation)
if (persistenceError) throw persistenceError
} finally {
await runtime.dispose()
this.active.delete(operation.id)
}
})()
void active.done.catch(error => { this.persistenceFailure = error; this.report(error) })
return operation
}
}
+42 -31
View File
@@ -54,7 +54,7 @@ function createService() {
)
const service = {
database: {
listKnowledgeBases: () => [
listKnowledgeBases: async () => [
{
id: firstLibraryId,
name: '一号知识库',
@@ -765,18 +765,18 @@ describe('KnowledgeMcpGateway', () => {
'knowledge_list',
'knowledge_search'
])
expect(gateway.listLibraries(token!)).toEqual([
expect(await gateway.listLibraries(token!)).toEqual([
{
id: secondLibraryId,
name: '二号知识库',
description: '已授权知识'
}
])
expect(() =>
await expect(
gateway.listLibraries(token!, {
libraryIds: [firstLibraryId]
})
).toThrow()
).rejects.toThrow()
const references = await gateway.search(token!, {
query: ' 要找什么 ',
limit: 1
@@ -852,10 +852,10 @@ describe('KnowledgeMcpGateway', () => {
clock.mockRestore()
})
it('keeps Magic Notes available during long requests without a knowledge scope', () => {
it('keeps Magic Notes available during long requests without a knowledge scope', async () => {
const { service } = createService()
const clock = vi.spyOn(Date, 'now').mockReturnValue(1_000)
const searchMagicNotes = vi.fn(() => [
const searchMagicNotes = vi.fn(async () => [
{
noteId: '00000000-0000-4000-8000-000000000701',
noteTitle: '发布计划',
@@ -907,7 +907,7 @@ describe('KnowledgeMcpGateway', () => {
'note_search'
])
expect(
gateway.searchMagicNotes(token, {
await gateway.searchMagicNotes(token, {
query: ' 发布 ',
limit: 3
})
@@ -918,12 +918,12 @@ describe('KnowledgeMcpGateway', () => {
})
])
expect(searchMagicNotes).toHaveBeenCalledWith('发布', 3)
expect(() =>
await expect(
gateway.searchMagicNotes(token, {
query: '发布',
noteIds: ['not-allowed']
})
).toThrow()
).rejects.toThrow()
gateway.revoke(token)
expect(() => gateway.getAvailableToolNames(token)).toThrow('Tool authorization is unavailable')
clock.mockRestore()
@@ -941,7 +941,18 @@ describe('KnowledgeMcpGateway', () => {
databases.push(database)
database.initialize('C:\\Workspace')
const gateway = new KnowledgeMcpGateway(service, {
magicNotesDatabase: database
magicNotesDatabase: {
listMagicNotes: async (...args) => database.listMagicNotes(...args),
getMagicNote: async (...args) => database.getMagicNote(...args),
getMagicNoteEntry: async (...args) => database.getMagicNoteEntry(...args),
searchMagicNotes: async (...args) => database.searchMagicNotes(...args),
createMagicNote: async (...args) => { await Promise.resolve(); return database.createMagicNote(...args) },
updateMagicNote: async (...args) => { await Promise.resolve(); return database.updateMagicNote(...args) },
deleteMagicNote: async (...args) => { await Promise.resolve(); database.deleteMagicNote(...args) },
createMagicNoteEntry: async (...args) => { await Promise.resolve(); return database.createMagicNoteEntry(...args) },
updateMagicNoteEntry: async (...args) => { await Promise.resolve(); return database.updateMagicNoteEntry(...args) },
deleteMagicNoteEntry: async (...args) => { await Promise.resolve(); return database.deleteMagicNoteEntry(...args) }
}
})
gateways.push(gateway)
const readToken = gateway.grant(
@@ -973,15 +984,15 @@ describe('KnowledgeMcpGateway', () => {
'note_entry_delete',
'note_delete'
])
expect(() =>
await expect(
gateway.createMagicNote(readToken, { title: '不允许创建' })
).toThrow('unavailable')
).rejects.toThrow('unavailable')
const created = gateway.createMagicNote(writeToken, {
const created = await gateway.createMagicNote(writeToken, {
title: '发布计划',
content: '核对构建产物'
})
expect(gateway.listMagicNotes(readToken)).toEqual([
expect(await gateway.listMagicNotes(readToken)).toEqual([
expect.objectContaining({
id: created.id,
title: '发布计划',
@@ -991,13 +1002,13 @@ describe('KnowledgeMcpGateway', () => {
])
expect(created.entries[0]?.content).toBe('核对构建产物')
expect(onMagicNotesChanged).toHaveBeenCalledTimes(1)
gateway.updateMagicNote(writeToken, {
await gateway.updateMagicNote(writeToken, {
noteId: created.id,
title: 'Updated plan',
expectedRevision: created.revision
})
expect(onMagicNotesChanged).toHaveBeenCalledTimes(2)
const withEntry = gateway.createMagicNoteEntry(writeToken, {
const withEntry = await gateway.createMagicNoteEntry(writeToken, {
noteId: created.id,
content: '通知发布负责人'
})
@@ -1005,7 +1016,7 @@ describe('KnowledgeMcpGateway', () => {
expect(entry.content).toBe('通知发布负责人')
expect(onMagicNotesChanged).toHaveBeenCalledTimes(3)
const updatedEntry = gateway.updateMagicNoteEntry(writeToken, {
const updatedEntry = await gateway.updateMagicNoteEntry(writeToken, {
entryId: entry.id,
content: '核对六个平台构建产物',
expectedRevision: entry.revision
@@ -1013,15 +1024,15 @@ describe('KnowledgeMcpGateway', () => {
expect(updatedEntry.entries[1]?.content).toBe(
'核对六个平台构建产物'
)
expect(() =>
await expect(
gateway.deleteMagicNoteEntry(writeToken, {
entryId: entry.id,
expectedRevision: entry.revision
})
).toThrow('已被更新')
).rejects.toThrow('已被更新')
expect(onMagicNotesChanged).toHaveBeenCalledTimes(4)
const withoutEntry = gateway.deleteMagicNoteEntry(writeToken, {
const withoutEntry = await gateway.deleteMagicNoteEntry(writeToken, {
entryId: entry.id,
expectedRevision: updatedEntry.entries[1]!.revision
})
@@ -1029,34 +1040,34 @@ describe('KnowledgeMcpGateway', () => {
expect.objectContaining({ content: '核对构建产物' })
])
expect(
gateway.deleteMagicNote(writeToken, {
await gateway.deleteMagicNote(writeToken, {
noteId: created.id,
expectedRevision: withoutEntry.revision
})
).toEqual({ deleted: true, noteId: created.id })
expect(onMagicNotesChanged).toHaveBeenCalledTimes(6)
expect(() =>
await expect(
gateway.getMagicNote(readToken, { noteId: created.id })
).toThrow('笔记不存在')
).rejects.toThrow('笔记不存在')
const canvas = database.getMagicNote(database.createMagicNote({ title: 'Canvas', content: {
version: 2, kind: 'paged-canvas', assets: [],
pages: [{ id: 'page', width: 794, height: 1123, background: { type: 'template', template: 'blank' }, objects: [{ type: 'IText', text: 'Canvas text' }] }]
} }).id)
const canvasEntry = gateway.getMagicNote(readToken, { noteId: canvas.id }).entries[0]!
const canvasEntry = (await gateway.getMagicNote(readToken, { noteId: canvas.id })).entries[0]!
expect(canvasEntry).toMatchObject({ content: 'Canvas text', contentKind: 'paged-canvas', contentVersion: 2, plainTextEditable: false })
expect(() => gateway.updateMagicNoteEntry(writeToken, { entryId: canvasEntry.id, expectedRevision: canvasEntry.revision, content: 'overwrite' })).toThrow('画布记录不能')
await expect(gateway.updateMagicNoteEntry(writeToken, { entryId: canvasEntry.id, expectedRevision: canvasEntry.revision, content: 'overwrite' })).rejects.toThrow('画布记录不能')
expect(database.getMagicNote(canvas.id)).toEqual(canvas)
// Tags: normalized on write, AND-filtered on list, replaced as a whole on update.
const tagged = gateway.createMagicNote(writeToken, { title: 'Tagged', tags: [' Work ', 'work', 'Q4'] })
const tagged = await gateway.createMagicNote(writeToken, { title: 'Tagged', tags: [' Work ', 'work', 'Q4'] })
expect(tagged.tags).toEqual(['Work', 'Q4'])
expect(gateway.listMagicNotes(readToken, { tags: ['WORK', 'q4'] })).toEqual([
expect(await gateway.listMagicNotes(readToken, { tags: ['WORK', 'q4'] })).toEqual([
expect.objectContaining({ id: tagged.id, tags: ['Work', 'Q4'] })
])
expect(gateway.listMagicNotes(readToken, { tags: ['Work', 'missing'] })).toEqual([])
expect(() => gateway.createMagicNote(writeToken, { title: 'Bad', tags: ['a,b'] })).toThrow()
const cleared = gateway.updateMagicNote(writeToken, { noteId: tagged.id, tags: [], expectedRevision: tagged.revision })
expect(await gateway.listMagicNotes(readToken, { tags: ['Work', 'missing'] })).toEqual([])
await expect(gateway.createMagicNote(writeToken, { title: 'Bad', tags: ['a,b'] })).rejects.toThrow()
const cleared = await gateway.updateMagicNote(writeToken, { noteId: tagged.id, tags: [], expectedRevision: tagged.revision })
expect(cleared.tags).toEqual([])
expect(database.listMagicNoteTags()).toEqual([])
})
@@ -1072,7 +1083,7 @@ describe('KnowledgeMcpGateway', () => {
gateways.push(gateway)
const token = gateway.grant('notes-search-http', [], new AbortController().signal, 'write')!
for (let index = 0; index < 12; index += 1) {
gateway.createMagicNote(token, { title: `Search regression ${index}`, content: 'Matching entry' })
await gateway.createMagicNote(token, { title: `Search regression ${index}`, content: 'Matching entry' })
}
await gateway.start()
const client = new Client({ name: 'note-search-test', version: '1.0.0' })
+54 -52
View File
@@ -3,6 +3,7 @@ import type { DesktopDiagnosticFailureObserver, DesktopMcpDiagnosticMetadata } f
import { isStoryGraphTool, storyGraphToolNames, type StoryGraphToolName } from '../../shared/story-graph-tools'
import type { RuntimeTarget } from '../../shared/capability-contracts'
import { builtinModelTools } from '../../shared/builtin-model-tools'
import type { Awaitable } from '../assistant-storage-port'
export type StoryGraphBinding = { projectId?: string; conversationId?: string; runtimeTarget: RuntimeTarget }
export type StoryGraphRemoteBinding = {
@@ -143,35 +144,35 @@ const {
} = magicNoteScopedDataToolCatalog
export type MagicNotesDatabase = {
listMagicNotes(input?: { tags?: string[] }): MagicNoteSummary[]
getMagicNote(noteId: string): MagicNoteDetail
getMagicNoteEntry(entryId: string): MagicNoteEntry
searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[]
listMagicNotes(input?: { tags?: string[] }): Awaitable<MagicNoteSummary[]>
getMagicNote(noteId: string): Awaitable<MagicNoteDetail>
getMagicNoteEntry(entryId: string): Awaitable<MagicNoteEntry>
searchMagicNotes(query: string, limit: number): Awaitable<MagicNoteSearchResult[]>
createMagicNote(input: {
title: string
content?: MagicNoteContent
tags?: string[]
}): MagicNoteDetail
}): Awaitable<MagicNoteDetail>
updateMagicNote(input: {
noteId: string
title?: string
pinned?: boolean
tags?: string[]
expectedRevision: number
}): MagicNoteDetail
deleteMagicNote(noteId: string): void
}): Awaitable<MagicNoteDetail>
deleteMagicNote(noteId: string): Awaitable<void>
createMagicNoteEntry(input: {
noteId: string
content: MagicNoteContent
plainText: string
}): MagicNoteDetail
}): Awaitable<MagicNoteDetail>
updateMagicNoteEntry(input: {
entryId: string
content: MagicNoteContent
plainText: string
expectedRevision: number
}): MagicNoteDetail
deleteMagicNoteEntry(entryId: string): MagicNoteDetail
}): Awaitable<MagicNoteDetail>
deleteMagicNoteEntry(entryId: string): Awaitable<MagicNoteDetail>
}
export type MagicNotesCapabilityAccess = 'none' | 'read' | 'write'
@@ -757,7 +758,7 @@ export class KnowledgeMcpGateway {
)
const effectiveSignal = AbortSignal.any([capability.signal, capability.brokerController.signal, ...(signal ? [signal] : [])])
effectiveSignal.throwIfAborted()
const libraries = this.knowledgeService.database.listKnowledgeBases(500)
const libraries = await this.knowledgeService.database.listKnowledgeBases(500)
const libraryNames = new Map(
libraries.map((library) => [library.id, library.name])
)
@@ -801,15 +802,15 @@ export class KnowledgeMcpGateway {
return references
}
listLibraries(
async listLibraries(
token: string,
input: unknown = {}
): KnowledgeLibraryListItem[] {
): Promise<KnowledgeLibraryListItem[]> {
const capability = this.getCapability(token)
knowledgeListTool.inputSchema.parse(input)
const librariesById = new Map(
this.knowledgeService.database
.listKnowledgeBases(500)
(await this.knowledgeService.database
.listKnowledgeBases(500))
.map((library) => [library.id, library])
)
const libraries: KnowledgeLibraryListItem[] = []
@@ -1431,15 +1432,15 @@ export class KnowledgeMcpGateway {
return { capability, database: this.magicNotesDatabase }
}
listMagicNotes(
async listMagicNotes(
token: string,
input: unknown = {}
): MagicNoteToolSummary[] {
): Promise<MagicNoteToolSummary[]> {
const { database } = this.requireMagicNotes(token, magicNoteListTool.access)
const { limit, tags } = magicNoteListTool.inputSchema.parse(input)
const filter = tags ? magicNoteTagListSchema.parse(tags) : undefined
const notes: MagicNoteToolSummary[] = []
const listed = database.listMagicNotes(filter?.length ? { tags: filter } : {})
const listed = await database.listMagicNotes(filter?.length ? { tags: filter } : {})
for (const note of listed.slice(0, limit)) {
const item = toMagicNoteToolSummary(note)
if (
@@ -1453,10 +1454,10 @@ export class KnowledgeMcpGateway {
return notes
}
getMagicNote(token: string, input: unknown): MagicNoteToolDetail {
async getMagicNote(token: string, input: unknown): Promise<MagicNoteToolDetail> {
const { database } = this.requireMagicNotes(token, magicNoteGetTool.access)
const { noteId } = magicNoteGetTool.inputSchema.parse(input)
const detail = database.getMagicNote(noteId)
const detail = await database.getMagicNote(noteId)
const result: MagicNoteToolDetail = {
...toMagicNoteToolSummary(detail),
entries: [],
@@ -1491,18 +1492,19 @@ export class KnowledgeMcpGateway {
return result
}
searchMagicNotes(
async searchMagicNotes(
token: string,
input: unknown,
signal?: AbortSignal
): MagicNoteSearchResult[] {
): Promise<MagicNoteSearchResult[]> {
const { capability, database } = this.requireMagicNotes(token, magicNoteSearchTool.access)
const { query, limit } = magicNoteSearchTool.inputSchema.parse(input)
const effectiveSignal = signal
? AbortSignal.any([signal, capability.signal])
: capability.signal
effectiveSignal.throwIfAborted()
const notes = database.searchMagicNotes(query, limit)
const notes = await database.searchMagicNotes(query, limit)
effectiveSignal.throwIfAborted()
const bounded: MagicNoteSearchResult[] = []
for (const note of notes) {
const candidate = [...bounded, note]
@@ -1517,7 +1519,7 @@ export class KnowledgeMcpGateway {
return bounded
}
createMagicNote(token: string, input: unknown): MagicNoteToolDetail {
async createMagicNote(token: string, input: unknown): Promise<MagicNoteToolDetail> {
const { database } = this.requireMagicNotes(token, magicNoteCreateTool.access)
const parsed = magicNoteCreateTool.inputSchema.parse(input)
const content =
@@ -1527,33 +1529,33 @@ export class KnowledgeMcpGateway {
return this.getMagicNote(
token,
{
noteId: database.createMagicNote({
noteId: (await database.createMagicNote({
title: parsed.title,
...(content ? { content } : {}),
...(parsed.tags ? { tags: magicNoteTagListSchema.parse(parsed.tags) } : {})
}).id
})).id
}
)
}
updateMagicNote(token: string, input: unknown): MagicNoteToolDetail {
async updateMagicNote(token: string, input: unknown): Promise<MagicNoteToolDetail> {
const { database } = this.requireMagicNotes(token, magicNoteUpdateTool.access)
const parsed = magicNoteUpdateTool.inputSchema.parse(input)
database.updateMagicNote({
await database.updateMagicNote({
...parsed,
...(parsed.tags ? { tags: magicNoteTagListSchema.parse(parsed.tags) } : {})
})
return this.getMagicNote(token, { noteId: parsed.noteId })
}
createMagicNoteEntry(
async createMagicNoteEntry(
token: string,
input: unknown
): MagicNoteToolDetail {
): Promise<MagicNoteToolDetail> {
const { database } = this.requireMagicNotes(token, magicNoteEntryCreateTool.access)
const parsed = magicNoteEntryCreateTool.inputSchema.parse(input)
const content = textContent(parsed.content)
database.createMagicNoteEntry({
await database.createMagicNoteEntry({
noteId: parsed.noteId,
content,
plainText: magicNotePlainText(content)
@@ -1561,17 +1563,17 @@ export class KnowledgeMcpGateway {
return this.getMagicNote(token, { noteId: parsed.noteId })
}
updateMagicNoteEntry(
async updateMagicNoteEntry(
token: string,
input: unknown
): MagicNoteToolDetail {
): Promise<MagicNoteToolDetail> {
const { database } = this.requireMagicNotes(token, magicNoteEntryUpdateTool.access)
const parsed = magicNoteEntryUpdateTool.inputSchema.parse(input)
if (database.getMagicNoteEntry(parsed.entryId).content.version === 2) {
if ((await database.getMagicNoteEntry(parsed.entryId)).content.version === 2) {
throw new Error('画布记录不能通过纯文本工具覆盖,请在画布编辑器中修改,或追加新的纯文本记录')
}
const content = textContent(parsed.content)
const detail = database.updateMagicNoteEntry({
const detail = await database.updateMagicNoteEntry({
entryId: parsed.entryId,
content,
plainText: magicNotePlainText(content),
@@ -1580,31 +1582,31 @@ export class KnowledgeMcpGateway {
return this.getMagicNote(token, { noteId: detail.id })
}
deleteMagicNoteEntry(
async deleteMagicNoteEntry(
token: string,
input: unknown
): MagicNoteToolDetail {
): Promise<MagicNoteToolDetail> {
const { database } = this.requireMagicNotes(token, magicNoteEntryDeleteTool.access)
const parsed = magicNoteEntryDeleteTool.inputSchema.parse(input)
const entry = database.getMagicNoteEntry(parsed.entryId)
const entry = await database.getMagicNoteEntry(parsed.entryId)
if (entry.revision !== parsed.expectedRevision) {
throw new Error('记录已被更新,请重新读取后重试')
}
const detail = database.deleteMagicNoteEntry(parsed.entryId)
const detail = await database.deleteMagicNoteEntry(parsed.entryId)
return this.getMagicNote(token, { noteId: detail.id })
}
deleteMagicNote(
async deleteMagicNote(
token: string,
input: unknown
): { deleted: true; noteId: string } {
): Promise<{ deleted: true; noteId: string }> {
const { database } = this.requireMagicNotes(token, magicNoteDeleteTool.access)
const parsed = magicNoteDeleteTool.inputSchema.parse(input)
const note = database.getMagicNote(parsed.noteId)
const note = await database.getMagicNote(parsed.noteId)
if (note.revision !== parsed.expectedRevision) {
throw new Error('笔记已被更新,请重新读取后重试')
}
database.deleteMagicNote(parsed.noteId)
await database.deleteMagicNote(parsed.noteId)
return { deleted: true, noteId: parsed.noteId }
}
@@ -1643,25 +1645,25 @@ export class KnowledgeMcpGateway {
case 'story_graph_read_source':
return this.callStoryGraphTool(token, name, input, signal)
case 'knowledge_list':
return { libraries: this.listLibraries(token, input) }
return { libraries: await this.listLibraries(token, input) }
case 'knowledge_search':
return { references: await this.search(token, input, signal) }
case 'note_list':
return { notes: this.listMagicNotes(token, input) }
return { notes: await this.listMagicNotes(token, input) }
case 'note_get':
return { note: this.getMagicNote(token, input) }
return { note: await this.getMagicNote(token, input) }
case 'note_search':
return { notes: this.searchMagicNotes(token, input) }
return { notes: await this.searchMagicNotes(token, input, signal) }
case 'note_create':
return { note: this.createMagicNote(token, input) }
return { note: await this.createMagicNote(token, input) }
case 'note_update':
return { note: this.updateMagicNote(token, input) }
return { note: await this.updateMagicNote(token, input) }
case 'note_entry_create':
return { note: this.createMagicNoteEntry(token, input) }
return { note: await this.createMagicNoteEntry(token, input) }
case 'note_entry_update':
return { note: this.updateMagicNoteEntry(token, input) }
return { note: await this.updateMagicNoteEntry(token, input) }
case 'note_entry_delete':
return { note: this.deleteMagicNoteEntry(token, input) }
return { note: await this.deleteMagicNoteEntry(token, input) }
case 'note_delete':
return this.deleteMagicNote(token, input)
case 'goodbuddy_config_capabilities':
+1 -1
View File
@@ -14,7 +14,7 @@ import { LocalRuntimeRegistry } from './local-runtime-registry'
import { OpenCodeRuntime } from './opencode-runtime'
import { SelectedRuntimeManager } from './selected-runtime-manager'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { KnowledgeService } from '../knowledge/knowledge-service'
import { TestKnowledgeService as KnowledgeService } from '../../../tests/support/knowledge-test-service'
const binaryPath = join(
process.cwd(), '.runtime-resources', process.arch,
+170
View File
@@ -0,0 +1,170 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { rgPath } from '@vscode/ripgrep'
import { expect, it, vi } from 'vitest'
import { ModelAgentRuntime } from './model-runtime'
import type { RuntimeEvent } from './runtime'
import { SubagentScheduler } from '../assistant/subagent-scheduler'
import { createDesktopStorageFiles, openDesktopStorageFiles, type DesktopFilesCaller } from '../desktop-storage-files'
import { conversationSubagentActivitySchema, conversationToolActivitySchema } from '../../shared/assistant-contracts'
import { AssistantDatabase } from '../assistant/assistant-database'
import { conversationMessageResourceIds } from '../../shared/conversation-output'
import { readConversationOutput } from './conversation-output'
import { storageDataBytes, STORAGE_MAX_BYTES } from '../desktop-storage-contracts'
it.each(['completed', 'failed', 'cancelled'] as const)('adopts process, search and %s subagent output into persisted history before release without copying', async (state) => {
const parent = resolve('temp/goodbuddy-model-output')
await mkdir(parent, { recursive: true })
const root = await mkdtemp(join(parent, 'runtime-'))
const databasePath = join(root, 'assistant.sqlite')
let database = new AssistantDatabase(databasePath)
await database.initialize(root)
const hasOwner = (conversationId: string, kind: string, id: string) => database.hasAttachmentOwner(conversationId, kind, id)
let owner = await openDesktopStorageFiles(root, hasOwner)
const call: DesktopFilesCaller['call'] = async (domain, method, args) =>
structuredClone(await owner.dispatch(domain, method, structuredClone(args))) as never
const { attachments, backingStore } = createDesktopStorageFiles({ call })
const controller = new AbortController()
const captureBacking = { ...backingStore, append: async (...args: Parameters<typeof backingStore.append>) => {
await backingStore.append(...args)
if (state === 'cancelled' && args[1].startsWith('subagent:')) controller.abort(new Error('Fixture cancelled'))
} }
const conversationId = randomUUID(), messageId = randomUUID()
const requestId = randomUUID()
const project = database.createSshProject({
project: { name: 'Recovery fixture', description: '', rootPath: '/srv/fixture', runtimeSelection: { provider: 'opencode' } },
executionSpace: { kind: 'ssh', hostId: randomUUID(), remoteRootPath: '/srv/fixture' }, assertCurrent: () => {}
})
const header = { id: conversationId, projectId: project.id, title: 'Output', updatedAt: Date.now() }
database.saveLocalConversations([{ header, messages: [] }])
database.createTask({ id: requestId, projectId: header.projectId, conversationId,
title: 'Output', instructions: 'Run fixtures', remoteRecovery: {
recoverable: true, currentUserMessageId: randomUUID(), currentAssistantMessageId: messageId
} })
const adopted: string[] = []
const outputAdopt = vi.fn(async (...args: Parameters<typeof attachments.outputAdopt>) => {
expect(args.slice(2)).toEqual([conversationId, 'message', messageId])
const id = await attachments.outputAdopt(...args)
adopted.push(args[1])
return id
})
const scheduler = new SubagentScheduler({ concurrency: 1, queueLimit: 2, timeoutMs: 30_000 })
const text = 'match-\u4e2d\ud83d\ude00'.repeat(20_000) + '-tail'
const childText = 'child-\u4e2d\ud83d\ude00'.repeat(20_000) + '-child-tail'
const inlineStderr = 'inline'.repeat(4_000) + '-stderr-tail'
const tool = (id: string, name: string, args: unknown) => ({
choices: [{ message: { role: 'assistant', content: null, tool_calls: [
{ id, type: 'function', function: { name, arguments: JSON.stringify(args) } }
] } }]
})
const reply = (content: string) => ({ choices: [{ message: { role: 'assistant', content } }] })
const responses = [
tool('process', 'process_execute', { command: 'node output.cjs' }),
tool('rg', 'workspace_rg', { args: ['match', 'search.txt'] }),
tool('delegate', 'subagent_delegate', { task: 'Return child output' }),
tool('child-process', 'process_execute', { command: 'node output.cjs' }),
state === 'failed' ? { choices: [{ message: { ...tool('invalid', 'unknown-tool', {}).choices[0]!.message, content: childText } }] } : reply(childText),
reply('complete')
]
const fetcher = vi.fn<typeof fetch>(async () => Response.json(responses.shift()))
const runtime = new ModelAgentRuntime({
baseUrl: 'https://example.test/v1', model: 'fixture', authentication: 'none',
protocol: 'openai-chat-completions', defaultWorkspace: root, fetcher,
directModelSubagentScheduler: scheduler, ripgrepExecutablePath: rgPath,
outputStore: { backingStore: captureBacking }, outputAdopt
})
const events: RuntimeEvent[] = []
try {
await writeFile(join(root, 'output.cjs'), `process.stdout.write(${JSON.stringify(text)}); process.stderr.write(${JSON.stringify(inlineStderr)})`)
await writeFile(join(root, 'search.txt'), text + '\n')
try {
for await (const event of runtime.run({
requestId, conversationId, currentAssistantMessageId: messageId, prompt: 'Run fixtures'
}, controller.signal)) {
if (event.type === 'tool') {
if (event.output) expect(Buffer.byteLength(event.output)).toBeLessThanOrEqual(256 * 1024)
for (const reference of event.outputReferences ?? []) expect(adopted).toContain(reference.handle)
const { requestId, type, ...activity } = event
void requestId; void type
expect(conversationToolActivitySchema.parse(activity)).toEqual(activity)
} else if (event.type === 'subagent') {
if (event.outputReference) expect(adopted).toContain(event.outputReference.handle)
const { requestId, type, ...activity } = event
void requestId; void type
expect(conversationSubagentActivitySchema.parse(activity)).toEqual(activity)
}
events.push(event)
if (event.type === 'tool' || event.type === 'subagent' || event.type === 'done') {
const input = { taskId: requestId, bindingId: 'output-binding', operationId: 'output-operation',
conversationId, assistantMessageId: messageId, semanticSequence: String(events.length), eventIndex: 0, event }
expect(database.appendRemoteConversationTaskEventOnce(input)).toBe(true)
expect(database.appendRemoteConversationTaskEventOnce(input)).toBe(false)
}
}
} catch (error) {
if (state !== 'cancelled') throw error
expect(error).toBe(controller.signal.reason)
const event = { requestId, type: 'error' as const, status: 'cancelled' as const, message: 'Fixture cancelled' }
events.push(event)
database.appendRemoteConversationTaskEventOnce({ taskId: requestId, bindingId: 'output-binding', operationId: 'output-operation',
conversationId, assistantMessageId: messageId, semanticSequence: String(events.length), eventIndex: 0, event })
}
expect(events.at(-1)).toMatchObject({ type: state === 'cancelled' ? 'error' : 'done' })
expect(fetcher).toHaveBeenCalledTimes(state === 'cancelled' ? 5 : 6)
expect(outputAdopt).toHaveBeenCalledTimes(4)
const processEvent = events.find(event => event.type === 'tool' && event.callId === 'process' && event.state === 'completed')
expect(processEvent?.type).toBe('tool')
if (processEvent?.type === 'tool') {
expect(JSON.parse(processEvent.output!).stderr).toBe(inlineStderr)
}
expect(outputAdopt.mock.calls.some(([captureOwner]) => captureOwner !== conversationId)).toBe(true)
const references = events.flatMap(event => event.type === 'tool' ? event.outputReferences ?? []
: event.type === 'subagent' && event.outputReference ? [event.outputReference] : [])
expect(references).toHaveLength(4)
const message = database.getConversation(conversationId).messages.find(message => message.id === messageId)!
expect(message.tools?.flatMap(tool => tool.outputReferences ?? [])).toHaveLength(3)
expect(message.subagents?.[0]?.outputReference).toEqual(references.at(-1))
expect(message.subagents?.[0]?.state).toBe(state)
const save = [{ header, messages: [message] }]
expect(storageDataBytes(save)).toBeLessThan(STORAGE_MAX_BYTES)
await attachments.reference(conversationId, 'message', messageId, conversationMessageResourceIds(message))
database.saveLocalConversations(save)
await runtime.dispose()
await owner.close()
database.close()
database = new AssistantDatabase(databasePath)
await database.initialize(root)
owner = await openDesktopStorageFiles(root, hasOwner)
owner.attachments.reconcile(hasOwner, true)
expect(database.getConversation(conversationId).messages.find(message => message.id === messageId)).toEqual(message)
for (const reference of references) {
let restored = ''
for (let cursor = 0; cursor < reference.totalBytes;) {
const page = await readConversationOutput(attachments, { conversationId, handle: reference.handle, cursor })
expect(Buffer.byteLength(page.content)).toBeLessThanOrEqual(32768)
expect(page.nextCursor).toBeGreaterThan(cursor)
restored += page.content
cursor = page.nextCursor
}
expect(restored).toBe(reference.handle.startsWith('subagent:') ? childText
: reference.handle.startsWith('rg:') ? `1:${text}\n` : text)
const id = reference.handle.split(':').at(-1)!
expect((await readdir(join(root, 'conversation-assets', conversationId, 'documents', id))).sort())
.toEqual(['original.txt', 'request.json'])
}
await expect(readConversationOutput(attachments, { conversationId: randomUUID(), handle: references[0]!.handle })).rejects.toThrow('owner')
await expect(readConversationOutput(attachments, { conversationId, handle: references[0]!.handle, limitBytes: 32769 })).rejects.toThrow()
await attachments.deleteConversation(conversationId)
for (const reference of references) {
await expect(attachments.outputRead(conversationId, reference.handle, 0, 1)).rejects.toThrow()
}
} finally {
await runtime.dispose()
scheduler.dispose()
await owner.close()
database.close()
await rm(root, { recursive: true, force: true })
}
})
+4 -1
View File
@@ -18,6 +18,7 @@ import { LocalWorkspaceAccess } from '../workspace'
import { LocalDirectModelProcessService } from './direct-model-process-service'
import { browserTabIdSchema } from '../../shared/contracts'
import type { BrowserToolService } from '../browser/browser-model-tools'
import { FileOutputBacking } from '../../../tests/support/paged-output-backing'
const toolPng = Buffer.from([
0x89, 0x50, 0x4e, 0x47,
@@ -2254,7 +2255,8 @@ describe('ModelAgentRuntime', () => {
it('runs workspace search, text reads and output paging without generic approval or optional capabilities', async () => {
const workspace = await mkdtemp(join(tmpdir(), 'goodbuddy-ask-tools-'))
const workspaceAccess = new LocalWorkspaceAccess(workspace)
const processService = new LocalDirectModelProcessService()
const backingStore = new FileOutputBacking()
const processService = new LocalDirectModelProcessService({ outputStore: { backingStore } })
const toolProvider = new ModelToolProvider(
workspaceAccess, [], undefined, undefined, false,
{ processService, ripgrepExecutablePath: rgPath }
@@ -2325,6 +2327,7 @@ describe('ModelAgentRuntime', () => {
expect(await readFile(join(workspace, 'README.md'), 'utf8')).toBe('readonly-marker\n')
} finally {
await runtime.dispose()
await backingStore.dispose()
await rm(workspace, { recursive: true, force: true })
}
})
+22 -4
View File
@@ -32,6 +32,7 @@ import {
type ModelToolCallContext,
type ModelToolDefinition,
type ModelToolProviderLike,
type ModelToolProviderProgrammingOptions,
type ModelToolResult,
type ModelToolResultPart,
type ModelSubagentRequestContext
@@ -87,6 +88,7 @@ type ConversationMessage = {
// the parent's browser tab and its owning conversation. They acquire no lease.
type ModelExecutionRequest = AgentExecutionRequest & {
browserConversationId?: string
outputHistory?: ModelToolCallContext['outputHistory']
}
type ProviderConversationMessage = Pick<
@@ -233,6 +235,8 @@ function getCurrentTimeInstruction(now = new Date()): string {
}
export type ModelRuntimeOptions = {
outputStore?: ModelToolProviderProgrammingOptions['outputStore']
outputAdopt?: ModelToolProviderProgrammingOptions['outputAdopt']
apiKey?: string
baseUrl: string
model: string
@@ -867,7 +871,7 @@ function getChatToolResultText(parts: ModelToolResultPart[]): string {
.join('\n\n')
}
function getToolResultPreview(parts: ModelToolResultPart[]): string {
function getToolResultPreview(parts: ModelToolResultPart[], limit = 16_000): string {
let imageNumber = 0
return parts
.map((part) => {
@@ -879,7 +883,7 @@ function getToolResultPreview(parts: ModelToolResultPart[]): string {
})
.filter(Boolean)
.join('\n\n')
.slice(0, 16_000)
.slice(0, limit)
}
function createToolErrorResult(
@@ -1666,6 +1670,7 @@ export class ModelAgentRuntime implements AgentRuntime {
const directModelSubagentService =
options.directModelSubagentScheduler
? new DirectModelSubagentService<ModelSubagentRequestContext>({
outputStore: options.outputStore,
scheduler: options.directModelSubagentScheduler,
runChild: (input) =>
this.runDirectModelSubagent(input),
@@ -1682,7 +1687,10 @@ export class ModelAgentRuntime implements AgentRuntime {
options.knowledgeGateway,
options.webSearchEnabled,
{
outputStore: options.outputStore,
outputAdopt: options.outputAdopt,
processService: new LocalDirectModelProcessService({
outputStore: options.outputStore,
environment: options.launchEnvironmentProvider?.(),
...(options.ripgrepExecutablePath
? { toolBinDirectory: dirname(options.ripgrepExecutablePath) }
@@ -1704,6 +1712,7 @@ export class ModelAgentRuntime implements AgentRuntime {
{
requestId: input.context.childRunId,
conversationId: input.context.conversationId,
outputHistory: input.requestContext.outputHistory,
browserTabId: input.requestContext.browserTabId,
browserConversationId:
input.requestContext.browserConversationId,
@@ -1722,7 +1731,7 @@ export class ModelAgentRuntime implements AgentRuntime {
input.requestContext.authorize
)) {
if (event.type === 'text') {
input.onOutput(event.delta)
await input.onOutput(event.delta)
} else if (event.type === 'model-usage') {
input.onModelUsage(event)
} else if (event.type === 'tool') {
@@ -3415,7 +3424,11 @@ export class ModelAgentRuntime implements AgentRuntime {
wakeNestedEvents?.()
wakeNestedEvents = undefined
}
const outputHistory = request.outputHistory ?? (request.currentAssistantMessageId
? { conversationId: request.conversationId, messageId: request.currentAssistantMessageId }
: undefined)
const toolContext: ModelToolCallContext = {
outputHistory,
imageToolBinding: request.imageToolBinding,
conversationId: request.conversationId,
browserTabId: request.browserTabId,
@@ -3430,6 +3443,7 @@ export class ModelAgentRuntime implements AgentRuntime {
? {
subagentBridge: {
requestContext: {
outputHistory,
authorize,
browserTabId: request.browserTabId,
browserConversationId:
@@ -3879,7 +3893,11 @@ export class ModelAgentRuntime implements AgentRuntime {
state: 'completed',
summary,
input,
output: getToolResultPreview(result.parts)
// These results already bound previews and reference spilled streams.
// A second truncation would discard inline stdout/stderr from history.
output: getToolResultPreview(result.parts,
tool.name === 'process_execute' || tool.name === 'workspace_rg' ? 256 * 1024 : 16_000),
...(result.outputReferences ? { outputReferences: result.outputReferences } : {})
}
}
}
+76 -3
View File
@@ -31,6 +31,7 @@ import {
} from '../assistant/direct-model-subagent-service'
import { SubagentScheduler } from '../assistant/subagent-scheduler'
import type { PagedOutputPage } from './paged-output-store'
import { FileOutputBacking } from '../../../tests/support/paged-output-backing'
const mocks = vi.hoisted(() => {
const tasks = {
@@ -903,6 +904,71 @@ describe('ModelToolProvider', () => {
})
})
it('awaits asynchronous scoped gateway results before serializing tool output', async () => {
const note = { id: 'note', title: 'Stored note' }
const gateway = {
listLibraries: async () => [{ id: 'library' }],
listMagicNotes: async () => [note],
searchMagicNotes: async () => [note],
getMagicNote: async () => note,
createMagicNote: async () => note,
updateMagicNote: async () => note,
createMagicNoteEntry: async () => note,
updateMagicNoteEntry: async () => note,
deleteMagicNoteEntry: async () => note,
deleteMagicNote: async () => ({ deleted: true })
} as unknown as KnowledgeMcpGateway
const provider = new ModelToolProvider(await createWorkspace(), [], undefined, gateway)
try {
for (const [name, expected] of [
['knowledge_list', { libraries: [{ id: 'library' }] }],
['note_list', { notes: [note] }], ['note_search', { notes: [note] }],
...['note_get', 'note_create', 'note_update', 'note_entry_create', 'note_entry_update', 'note_entry_delete']
.map(name => [name, { note }] as const),
['note_delete', { deleted: true }]
] as const) {
const result = await provider.callTool(name, {}, new AbortController().signal,
{ ...toolContext, knowledgeCapabilityToken: 'token' })
expect(result.parts).toEqual([{ type: 'text', text: JSON.stringify(expected) }])
}
} finally { await provider.dispose() }
})
it.each(['release', 'dispose'] as const)('waits for output adoption before %s drops the call reference', async (cleanup) => {
const workspace = new LocalWorkspaceAccess(await createWorkspace())
const service = new LocalDirectModelProcessService()
const reference = { handle: 'process:retained', totalBytes: 100000, nextCursor: 100 }
vi.spyOn(service, 'execute').mockResolvedValue({
shell: { kind: 'powershell', label: 'pwsh' }, cwd: '.', exitCode: 0, durationMs: 1,
stdout: 'preview', stderr: '', stdoutTruncated: true, stderrTruncated: false, stdoutReference: reference
})
const release = vi.spyOn(service, 'releaseConversation')
const dispose = vi.spyOn(service, 'dispose')
let finishAdoption!: () => void
let adoptionStarted!: () => void
const started = new Promise<void>(resolve => { adoptionStarted = resolve })
const pending = new Promise<void>(resolve => { finishAdoption = resolve })
const outputAdopt = vi.fn(async () => { adoptionStarted(); await pending; return 'resource' })
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false,
{ processService: service, outputAdopt })
try {
const result = provider.callTool('process_execute', { command: 'fixture' }, new AbortController().signal, {
conversationId: 'owner', runtimeTarget: 'model',
outputHistory: { conversationId: 'parent', messageId: 'message' }
})
await started
const closing = cleanup === 'release' ? provider.releaseConversation('owner') : provider.dispose()
await Promise.resolve()
expect(release).not.toHaveBeenCalled()
expect(dispose).not.toHaveBeenCalled()
finishAdoption()
expect((await result).outputReferences).toEqual([reference])
await closing
expect(outputAdopt).toHaveBeenCalledWith('owner', reference.handle, 'parent', 'message', 'message')
expect(cleanup === 'release' ? release : dispose).toHaveBeenCalledOnce()
} finally { finishAdoption(); await provider.dispose() }
})
it('reserves the granted scoped data tool slots for Execute', async () => {
const workspace = await createWorkspace()
const gateway = {
@@ -1242,17 +1308,20 @@ describe('ModelToolProvider', () => {
it('preserves complete escaped process and Subagent output through provider pagination and owner cleanup', async () => {
const workspace = await createWorkspace()
const processService = new LocalDirectModelProcessService()
const backingStore = new FileOutputBacking()
const outputStore = { backingStore }
const processService = new LocalDirectModelProcessService({ outputStore })
const scheduler = new SubagentScheduler({ concurrency: 1, queueLimit: 2, timeoutMs: 10000 })
const fullStdout = '\0'.repeat(110000) + '\u4f60\ud83d\ude00\ufffdstdout-tail'
const fullStderr = 'y'.repeat(110000) + 'stderr-tail'
const fullSubagent = '\u4f60'.repeat(70000) + 'subagent-tail'
const subagentService = new DirectModelSubagentService<ModelSubagentRequestContext>({
outputStore,
scheduler,
runChild: async (input) => { input.onOutput(fullSubagent) },
runChild: async (input) => { await input.onOutput(fullSubagent) },
releaseConversation: async (conversationId) => { await provider.releaseConversation(conversationId) }
})
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, { processService, subagentService })
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, { processService, subagentService, outputStore })
const context = {
...toolContext, runtimeTarget: 'model' as const, requestId: 'paged-provider-request',
executionSpaceIdentity: (await new LocalWorkspaceAccess(workspace).getIdentity()).id,
@@ -1274,6 +1343,9 @@ describe('ModelToolProvider', () => {
expect(processResult.exitCode).toBe(0)
const subagentResult = parse(await provider.callTool('subagent_delegate', { task: 'Return the fixture output' }, signal, askContext)) as DirectModelSubagentResult
expect(subagentResult.status).toBe('completed')
expect(context.subagentBridge.requestContext.emitEvent).toHaveBeenCalledWith(expect.objectContaining({
type: 'subagent', outputReference: subagentResult.outputReference
}))
for (const [preview, reference, expected] of [
[processResult.stdout, processResult.stdoutReference!, fullStdout],
[processResult.stderr, processResult.stderrReference!, fullStderr],
@@ -1309,6 +1381,7 @@ describe('ModelToolProvider', () => {
}
} finally {
await provider.dispose()
await backingStore.dispose()
scheduler.dispose()
}
})
+97 -19
View File
@@ -47,6 +47,8 @@ import {
directModelSubagentInputSchema
} from '../assistant/direct-model-subagent-service'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
import type { AttachmentStorageAccess } from '../desktop-storage-files'
import type { PagedOutputReference, PagedOutputStoreOptions } from './paged-output-store'
import type { BrowserTabId } from '../../shared/contracts'
import { ripgrepInputSchema, searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
import { applyWorkspacePatch } from './workspace-apply-patch'
@@ -254,12 +256,14 @@ export type ModelToolResultPart =
export type ModelToolResult = {
parts: ModelToolResultPart[]
contextBytes: number
outputReferences?: PagedOutputReference[]
}
export type ModelToolCallContext = {
imageToolBinding?: ImageToolBinding
toolCallId?: string
conversationId: string
outputHistory?: { conversationId: string; messageId: string }
browserTabId?: BrowserTabId
browserConversationId?: string
requestId?: string
@@ -271,6 +275,7 @@ export type ModelToolCallContext = {
}
export type ModelSubagentRequestContext = {
outputHistory?: { conversationId: string; messageId: string }
authorize?: RuntimeAuthorizer
browserTabId?: BrowserTabId
browserConversationId?: string
@@ -283,6 +288,8 @@ export type ModelSubagentBridge = {
}
export type ModelToolProviderProgrammingOptions = {
outputStore?: PagedOutputStoreOptions
outputAdopt?: AttachmentStorageAccess['outputAdopt']
runtimeTarget?: RuntimeTarget
processService?: DirectModelProcessService
subagentService?: DirectModelSubagentService<ModelSubagentRequestContext>
@@ -632,6 +639,11 @@ export class ModelToolProvider implements ModelToolProviderLike {
private readonly clients = new Set<Client>()
private readonly customMcpClients = new Set<Client>()
private readonly webSearchClients = new Set<Client>()
private readonly outputCalls = new Set<{
ownerId: string
controller: AbortController
settled: Promise<ModelToolResult>
}>()
constructor(
workspace: string | WorkspaceAccess,
@@ -643,13 +655,16 @@ export class ModelToolProvider implements ModelToolProviderLike {
ModelToolProviderProgrammingOptions = {},
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
) {
this.ripgrepService = new LocalDirectModelProcessService({
outputPrefix: 'rg', outputStore: programming.outputStore
})
this.workspaceAccess =
typeof workspace === 'string'
? new LocalWorkspaceAccess(workspace)
: workspace
}
private readonly ripgrepService = new LocalDirectModelProcessService({ outputPrefix: 'rg' })
private readonly ripgrepService: LocalDirectModelProcessService
private readonly workspaceAccess: WorkspaceAccess
private getScopedTools(
@@ -1373,6 +1388,29 @@ export class ModelToolProvider implements ModelToolProviderLike {
argumentsValue: Record<string, unknown>,
signal: AbortSignal,
context: ModelToolCallContext
): Promise<ModelToolResult> {
if (!['process_execute', 'workspace_rg', 'subagent_delegate'].includes(name)) {
return this.callToolOnce(name, argumentsValue, signal, context)
}
const controller = new AbortController()
const call = {
ownerId: context.conversationId,
controller,
settled: this.callToolOnce(name, argumentsValue, AbortSignal.any([signal, controller.signal]), context)
}
this.outputCalls.add(call)
try {
return await call.settled
} finally {
this.outputCalls.delete(call)
}
}
private async callToolOnce(
name: string,
argumentsValue: Record<string, unknown>,
signal: AbortSignal,
context: ModelToolCallContext
): Promise<ModelToolResult> {
signal.throwIfAborted()
if (name.startsWith('story_graph_') && scopedDataToolByName.has(name as ScopedDataToolName)) {
@@ -1410,7 +1448,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
libraries: this.knowledgeGateway.listLibraries(
libraries: await this.knowledgeGateway.listLibraries(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1449,7 +1487,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
notes: this.knowledgeGateway.searchMagicNotes(
notes: await this.knowledgeGateway.searchMagicNotes(
context.knowledgeCapabilityToken,
argumentsValue,
signal
@@ -1469,7 +1507,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
notes: this.knowledgeGateway.listMagicNotes(
notes: await this.knowledgeGateway.listMagicNotes(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1488,7 +1526,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.getMagicNote(
note: await this.knowledgeGateway.getMagicNote(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1507,7 +1545,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.createMagicNote(
note: await this.knowledgeGateway.createMagicNote(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1526,7 +1564,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.updateMagicNote(
note: await this.knowledgeGateway.updateMagicNote(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1545,7 +1583,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.createMagicNoteEntry(
note: await this.knowledgeGateway.createMagicNoteEntry(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1564,7 +1602,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.updateMagicNoteEntry(
note: await this.knowledgeGateway.updateMagicNoteEntry(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1583,7 +1621,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.deleteMagicNoteEntry(
note: await this.knowledgeGateway.deleteMagicNoteEntry(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1601,7 +1639,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
return createTextToolResult(
boundedJson(
this.knowledgeGateway.deleteMagicNote(
await this.knowledgeGateway.deleteMagicNote(
context.knowledgeCapabilityToken,
argumentsValue
),
@@ -1699,7 +1737,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
throw new Error('GoodBuddy 内置 ripgrep 不可用')
}
const input = ripgrepInputSchema.parse(argumentsValue)
return createBoundedJsonObjectToolResult(
return await this.createOutputResult(
await searchWorkspaceWithRipgrep(
this.programming.ripgrepExecutablePath,
input,
@@ -1709,7 +1747,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
context.conversationId
),
['stdout', 'stderr'],
'ripgrep output could not be serialized'
'ripgrep output could not be serialized',
context
)
}
const input = readInputSchema.parse(argumentsValue)
@@ -1802,7 +1841,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
) {
throw new Error('进程执行空间与当前工作区不匹配')
}
return createBoundedJsonObjectToolResult(
return this.createOutputResult(
await this.programming.processService.execute(
processExecuteInputSchema.parse(argumentsValue),
{
@@ -1812,7 +1851,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
),
['stdout', 'stderr'],
'进程执行结果无法序列化'
'进程执行结果无法序列化',
context
)
}
if (name === 'subagent_delegate') {
@@ -1827,6 +1867,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
throw new Error('当前请求不允许编程 Subagent 委派')
}
const input = directModelSubagentInputSchema.parse(argumentsValue)
let finalEvent: DirectModelSubagentEvent | undefined
const result = await this.programming.subagentService.run({
ownerId: context.conversationId,
task: input.task,
@@ -1835,22 +1876,30 @@ export class ModelToolProvider implements ModelToolProviderLike {
requestContext: context.subagentBridge.requestContext
},
signal,
onEvent: (event) =>
onEvent: (event) => {
if (event.outputReference) {
finalEvent = event
return
}
emitDirectModelSubagentEvent(
event,
context.subagentBridge!.requestContext
),
)
},
onModelUsage: (event) =>
emitDirectModelSubagentUsage(
event,
context.subagentBridge!.requestContext
)
})
return createBoundedJsonObjectToolResult(
const toolResult = await this.createOutputResult(
result,
['output', 'error'],
'编程 Subagent 结果无法序列化'
'编程 Subagent 结果无法序列化',
context
)
if (finalEvent) emitDirectModelSubagentEvent(finalEvent, context.subagentBridge.requestContext)
return toolResult
}
// Calls use the discovered catalog; only listTools retries failed servers.
@@ -1906,7 +1955,31 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
}
private async createOutputResult(
value: { stdoutReference?: PagedOutputReference; stderrReference?: PagedOutputReference; outputReference?: PagedOutputReference },
textKeys: string[],
errorMessage: string,
context: ModelToolCallContext
): Promise<ModelToolResult> {
const outputReferences = [value.stdoutReference, value.stderrReference, value.outputReference]
.filter((reference): reference is PagedOutputReference => reference !== undefined)
if (context.outputHistory && outputReferences.length) {
if (!this.programming.outputAdopt) throw new Error('Output history storage owner is not configured')
for (const reference of outputReferences) {
await this.programming.outputAdopt(context.conversationId, reference.handle,
context.outputHistory.conversationId, 'message', context.outputHistory.messageId)
}
}
return {
...createBoundedJsonObjectToolResult(value, textKeys, errorMessage),
...(outputReferences.length ? { outputReferences } : {})
}
}
async dispose(): Promise<void> {
const calls = [...this.outputCalls]
for (const call of calls) call.controller.abort(new Error('Tool provider disposed'))
await Promise.allSettled(calls.map(call => call.settled))
const clients = [...this.clients]
this.clients.clear()
this.customMcpClients.clear()
@@ -1924,6 +1997,10 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
async releaseConversation(conversationId: string): Promise<void> {
const calls = [...this.outputCalls].filter(call => call.ownerId === conversationId)
for (const call of calls) call.controller.abort(new Error('Conversation released'))
// Adoption is part of the producing call; releasing capture first loses history.
await Promise.allSettled(calls.map(call => call.settled))
await Promise.allSettled([
this.ripgrepService.releaseConversation(conversationId),
this.browserService?.releaseConversation(conversationId),
@@ -1963,6 +2040,7 @@ function emitDirectModelSubagentEvent(
state: event.state,
reason: event.reason,
...(output !== undefined ? { output } : {}),
...(event.outputReference ? { outputReference: event.outputReference } : {}),
...(error !== undefined
? { error: error.slice(0, 1_000) }
: {})
@@ -2,6 +2,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeClientCoordinator } from './native-client-coordinator'
import { NativeDshWebClientService, type NativeDshWebClientOptions } from './native-dsh-web-client'
import { NativeTerminalClient } from './native-terminal-client'
import { join } from 'node:path'
import type { LocalToolRuntimeSelection } from '../../shared/local-tool-environment-contracts'
const coordinators: NativeClientCoordinator[] = []
@@ -11,11 +13,11 @@ afterEach(async () => {
})
function fixture(whenReady: () => Promise<void> = async () => undefined) {
const profile = { id: 'selected', name: 'Selected', protocol: 'openai-chat-completions',
const profile = { id: '11111111-1111-4111-8111-111111111111', name: 'Selected', protocol: 'openai-chat-completions',
authentication: 'api-key', apiKey: 'secret', baseUrl: 'https://example.com/v1', modelName: 'selected-model' }
const settings = { provider: 'deepseek-harness', modelProfiles: [profile], deepseekHarnessModelProfile: profile }
const conversation = { projectId: 'project', knowledgeLibraryIds: ['library'], storyGraphEnabled: true }
const project = { id: 'project', runtimeSelection: { provider: 'deepseek-harness', profileId: profile.id } }
const project = { id: 'project', runtimeSelection: { provider: 'deepseek-harness', model: { kind: 'profile', profileId: profile.id } } }
const application = { heartbeatEnabled: false, magicNotesEnabled: true, localToolEnvironment: { node: { source: 'managed' } as LocalToolRuntimeSelection } }
const gateway = { start: vi.fn(), dispose: vi.fn(), grant: vi.fn(() => 'builtin-token'),
grantCustomMcp: vi.fn(() => 'custom-token'), getEndpoint: () => 'http://127.0.0.1:1234/mcp',
@@ -30,7 +32,7 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
const stop = vi.spyOn(NativeDshWebClientService.prototype, 'stop').mockImplementation(async id => { handles.delete(id) })
const openExternal = vi.fn(async () => {})
const coordinator = new NativeClientCoordinator({
database: { getConversation: (id: string) => ({ ...conversation, id }), getProject: () => project },
database: { getConversation: async (id: string) => ({ ...conversation, id }), getProject: async () => project },
settingsStore: { getResolvedSettings: async () => settings },
applicationSettingsStore: { get: async () => application },
capabilities: { getRuntimeSkillContext: async () => ({ packages: [{ directory: '/skills/test', id: 'test', digest: 'one' }] }),
@@ -38,13 +40,37 @@ function fixture(whenReady: () => Promise<void> = async () => undefined) {
getObsidianSettings: async () => ({}) },
executionSpaceResolver: { resolveProject: () => ({ kind: 'local', rootPath: '/workspace', cacheIdentity: '/workspace' }) },
terminalManager: { closeOwner: vi.fn() }, localEnvironment: { launchEnvironmentProvider: () => process.env, whenReady },
rootDirectory: '/clients', createGateway: () => gateway, openExternal
rootDirectory: '/clients', temporaryRoot: '/runtime-launch', createGateway: () => gateway, openExternal
} as unknown as ConstructorParameters<typeof NativeClientCoordinator>[0])
coordinators.push(coordinator)
return { coordinator, profile, conversation, application, gateway, start, stop, openExternal }
return { coordinator, profile, project, conversation, application, gateway, start, stop, openExternal }
}
describe('native client coordinator', () => {
it('forwards the same temporary root to browser and terminal clients without moving retained roots', async () => {
const { coordinator, project, start } = fixture()
let browserOptions: NativeDshWebClientOptions | undefined
start.mockImplementation(async function (this: NativeDshWebClientService) {
browserOptions = (this as unknown as { options: NativeDshWebClientOptions }).options
return { id: 'browser', url: 'http://127.0.0.1:1234/' }
})
await coordinator.open(1, 'browser-conversation')
expect(browserOptions).toMatchObject({
temporaryRoot: '/runtime-launch', rootDirectory: join('/clients', 'dsh')
})
let terminalOptions: ConstructorParameters<typeof NativeTerminalClient>[0] | undefined
const terminalOpen = vi.spyOn(NativeTerminalClient.prototype, 'open').mockImplementation(async function (this: NativeTerminalClient) {
terminalOptions = (this as unknown as { options: ConstructorParameters<typeof NativeTerminalClient>[0] }).options
return { sessionId: 'terminal', state: 'running' } as Awaited<ReturnType<NativeTerminalClient['open']>>
})
project.runtimeSelection.provider = 'opencode'
await coordinator.open(1, 'terminal-conversation')
expect(terminalOpen).toHaveBeenCalledOnce()
expect(terminalOptions).toMatchObject({
temporaryRoot: '/runtime-launch', rootDirectory: join('/clients', 'terminal')
})
})
it('binds story graph to its conversation and does not reuse another conversation or disabled configuration', async () => {
const { coordinator, conversation, application, gateway } = fixture()
application.heartbeatEnabled = true
+11 -5
View File
@@ -4,7 +4,7 @@ import { mkdir, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { promisify } from 'node:util'
import type { RuntimeNativeClientResult } from '../../shared/runtime-native-client-contracts'
import type { AssistantDatabase } from '../assistant/assistant-database'
import type { AssistantStoragePort } from '../assistant-storage-port'
import type { ApplicationSettingsStore } from '../application-settings-store'
import type { CapabilityService } from '../capabilities/capability-service'
import type { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver'
@@ -13,12 +13,12 @@ import type { RuntimeSettingsStore } from '../runtime-settings-store'
import type { TerminalSessionManager } from '../terminal/terminal-session-manager'
import type { BundledRuntimePaths } from './bundled-runtimes'
import type { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { NativeDshWebClientService } from './native-dsh-web-client'
import { NativeDshWebClientService, type NativeDshWebClientOptions } from './native-dsh-web-client'
import { NativeTerminalClient } from './native-terminal-client'
import { applyRuntimeSelection, resolveLayeredRuntimeSelection } from './runtime-selection'
type Options = {
database: AssistantDatabase
database: Pick<AssistantStoragePort, 'getConversation' | 'getProject'>
settingsStore: RuntimeSettingsStore
applicationSettingsStore: ApplicationSettingsStore
capabilities: CapabilityService
@@ -27,8 +27,10 @@ type Options = {
localEnvironment: Pick<LocalToolEnvironmentService, 'launchEnvironmentProvider' | 'whenReady'>
bundledRuntimePaths: BundledRuntimePaths
rootDirectory: string
temporaryRoot?: string
managedNodeDirectory: string
npmCliPath: string
openModelCallLedger: NativeDshWebClientOptions['openModelCallLedger']
resourcesPath?: string
createGateway: () => KnowledgeMcpGateway
openExternal: (url: string) => Promise<void>
@@ -45,7 +47,9 @@ export class NativeClientCoordinator {
constructor(private readonly options: Options) {
this.browser = new NativeDshWebClientService({
rootDirectory: join(options.rootDirectory, 'dsh'),
temporaryRoot: options.temporaryRoot,
resourcesPath: options.resourcesPath,
openModelCallLedger: options.openModelCallLedger,
resolveLaunchEnvironment: async () => ({
nodeExecutablePath: await this.prepareNode(),
environment: options.localEnvironment.launchEnvironmentProvider()
@@ -80,9 +84,9 @@ export class NativeClientCoordinator {
}
private async resolve(conversationId: string) {
const conversation = this.options.database.getConversation(conversationId)
const conversation = await this.options.database.getConversation(conversationId)
if (!conversation.projectId) throw new Error('Native clients require a saved project conversation')
const project = this.options.database.getProject(conversation.projectId)
const project = await this.options.database.getProject(conversation.projectId)
const settings = await this.options.settingsStore.getResolvedSettings()
const selected = applyRuntimeSelection(settings, resolveLayeredRuntimeSelection(
settings,
@@ -184,6 +188,8 @@ export class NativeClientCoordinator {
const nodeExecutable = selected.target === 'continue' ? await this.prepareNode() : undefined
this.assertOwner(ownerId)
const terminal = await new NativeTerminalClient({
temporaryRoot: this.options.temporaryRoot,
openModelCallLedger: this.options.openModelCallLedger,
rootDirectory: join(this.options.rootDirectory, 'terminal'), bundledRuntimePaths: this.options.bundledRuntimePaths,
nodeExecutable, launchEnvironmentProvider: this.options.localEnvironment.launchEnvironmentProvider,
terminalManager: { create: async (owner, input, launch) => {
+78 -7
View File
@@ -1,11 +1,15 @@
// @vitest-environment node
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { mkdtemp, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import { createServer } from 'node:http'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeDshWebClientService, resolveNativeDshCli, type NativeDshWebRequest } from './native-dsh-web-client'
import { nativeDshWebPatch } from './native-dsh-web-policy'
import { AgentModelCallLedger } from '../../agent-daemon/agent-model-gateway'
import { runtimeTemporaryRoot, runtimeTemporaryProcessIsActive } from '../runtime-temporary-directory'
const openModelCallLedger = async (path: string) => new AgentModelCallLedger(path)
const roots: string[] = []
const services: NativeDshWebClientService[] = []
@@ -42,11 +46,58 @@ describe('native DS Web configuration', () => {
const standardNode = process.env.NATIVE_DSH_TEST_NODE ?? (!process.versions.electron ? process.execPath : undefined)
describe.skipIf(!standardNode)('official DS Web process', () => {
it('awaits host ledger close before removing launch material', async () => {
const root = await temporary()
let release!: () => void
const closed = new Promise<void>(resolve => { release = resolve })
const closing = vi.fn()
let delayClose = false
let ledgerPath = ''
const scratch = runtimeTemporaryRoot(root, 'test', 'native')
const service = new NativeDshWebClientService({
temporaryRoot: scratch,
rootDirectory: join(root, 'homes'), cliPath: process.env.NATIVE_DSH_TEST_CLI,
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }),
openModelCallLedger: async path => {
ledgerPath = path
const ledger = new AgentModelCallLedger(path)
return {
claim: ledger.claim.bind(ledger), complete: ledger.complete.bind(ledger),
delivered: ledger.delivered.bind(ledger), outcomeUnknown: ledger.outcomeUnknown.bind(ledger),
get: ledger.get.bind(ledger), close: async () => { closing(); if (delayClose) await closed; ledger.close() }
}
}
})
services.push(service)
try {
const handle = await service.start({ projectId: 'close-order', workspace: root, profile })
delayClose = true
expect(ledgerPath.startsWith(scratch)).toBe(true)
const launch = dirname(ledgerPath)
const ownerFile = join(dirname(launch), `.${basename(launch)}.owner.json`)
const owner = JSON.parse(await readFile(ownerFile, 'utf8'))
expect(owner.creatorPid).toBe(process.pid)
expect(runtimeTemporaryProcessIsActive(owner.childPid)).toBe(true)
const stopping = service.stop(handle.id)
await vi.waitFor(() => expect(closing).toHaveBeenCalledOnce())
expect(runtimeTemporaryProcessIsActive(owner.childPid)).toBe(false)
expect(await readFile(join(launch, 'cordis.patch.yml'), 'utf8')).toContain('test-model')
expect((await readFile(ledgerPath)).length).toBeGreaterThan(0)
release()
await stopping
await expect(readFile(ledgerPath)).rejects.toThrow()
await expect(readFile(ownerFile)).rejects.toThrow()
} finally { release() }
}, 60_000)
it('initializes the workspace, coalesces starts, isolates configuration and preserves history on stop', async () => {
const root = await temporary()
const workspace = join(root, 'workspace')
await mkdir(workspace)
let ledgerPath = ''
const service = new NativeDshWebClientService({
openModelCallLedger: async path => { ledgerPath = path; return openModelCallLedger(path) },
temporaryRoot: runtimeTemporaryRoot(root, 'test', 'history'),
rootDirectory: join(root, 'homes'),
cliPath: process.env.NATIVE_DSH_TEST_CLI,
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env })
@@ -55,7 +106,7 @@ describe.skipIf(!standardNode)('official DS Web process', () => {
const request: NativeDshWebRequest = { projectId: 'one', workspace, profile }
const [first, repeated] = await Promise.all([service.start(request), service.start(request)])
expect(first).toEqual(repeated)
const config = await readFile(join(root, 'homes', first.id, 'cordis.patch.yml'), 'utf8')
const config = await readFile(join(dirname(ledgerPath), 'cordis.patch.yml'), 'utf8')
expect(config).toContain('test-model')
expect(config).not.toContain(profile.apiKey)
const auth = await fetch(first.url, { redirect: 'manual' })
@@ -63,9 +114,17 @@ describe.skipIf(!standardNode)('official DS Web process', () => {
const page = await fetch(new URL('/', first.url), { headers: { cookie } })
expect(page.status).toBe(200)
expect(await page.text()).toContain('<html')
const created = await fetch(new URL('/api/session/create', first.url), {
method: 'POST', headers: { 'content-type': 'application/json', cookie, origin: new URL(first.url).origin },
body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'session/create', payload: { args: { request: { cwd: workspace } } } })
})
const session = await created.json() as { result: { ok: boolean; value: { sessionId: string } } }
expect(session.result.ok).toBe(true)
expect(await service.start(request)).toEqual(first)
const history = join(root, 'homes', first.id, 'history-marker')
await writeFile(history, 'retained')
const preferences = join(root, 'homes', first.id, 'cordis.patch.yml')
await writeFile(preferences, '[]\n')
const changedConfiguration = await service.start({ ...request, configurationKey: 'updated-skill-digest' })
expect(changedConfiguration.id).not.toBe(first.id)
expect(await service.start({ ...request, configurationKey: 'updated-skill-digest' })).toEqual(changedConfiguration)
@@ -73,10 +132,18 @@ describe.skipIf(!standardNode)('official DS Web process', () => {
await service.stop(first.id)
await expect(fetch(first.url)).rejects.toThrow()
expect(await readFile(history, 'utf8')).toBe('retained')
await expect(readFile(join(root, 'homes', first.id, 'cordis.patch.yml'))).rejects.toThrow()
expect(await readFile(preferences, 'utf8')).toBe('[]\n')
const restarted = await service.start(request)
expect(restarted.id).toBe(first.id)
expect(restarted.url).not.toBe(first.url)
const nextAuth = await fetch(restarted.url, { redirect: 'manual' })
const nextCookie = nextAuth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
const listed = await fetch(new URL('/api/session/list', restarted.url), {
method: 'POST', headers: { 'content-type': 'application/json', cookie: nextCookie, origin: new URL(restarted.url).origin },
body: JSON.stringify({ type: 'client-request', rpcId: crypto.randomUUID(), method: 'session/list', payload: { args: { _request: {} } } })
})
expect(await listed.text()).toContain(session.result.value.sessionId)
expect(await readFile(preferences, 'utf8')).toBe('[]\n')
await service.dispose()
await expect(service.start(request)).rejects.toThrow('disposed')
}, 120_000)
@@ -127,7 +194,7 @@ describe.skipIf(!standardNode)('official DS Web process', () => {
usage: { prompt_tokens: 10, completion_tokens: 10, total_tokens: 20 } }) + 'data: [DONE]\n\n',
{ headers: { 'content-type': 'text/event-stream' } })
}
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), fetcher,
const service = new NativeDshWebClientService({ openModelCallLedger, rootDirectory: join(root, 'homes'), fetcher,
cliPath: process.env.NATIVE_DSH_TEST_CLI,
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
services.push(service)
@@ -173,19 +240,23 @@ describe.skipIf(!standardNode)('official DS Web process', () => {
it('cleans failed launches and permits retry', async () => {
const root = await temporary()
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), cliPath: join(root, 'missing.js'),
const cliPath = join(root, 'failed.mjs')
await writeFile(cliPath, 'process.exit(1)')
const scratch = runtimeTemporaryRoot(root, 'test', 'failure')
const service = new NativeDshWebClientService({ openModelCallLedger, rootDirectory: join(root, 'homes'), cliPath, temporaryRoot: scratch,
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
services.push(service)
const request: NativeDshWebRequest = { projectId: 'one', workspace: root, profile }
await expect(service.start(request)).rejects.toThrow()
await expect(service.start(request)).rejects.toThrow()
expect(await readdir(scratch)).toEqual([])
})
it('cancels an in-progress launch when disposed', async () => {
const root = await temporary()
const cliPath = join(root, 'waiting.mjs')
await writeFile(cliPath, 'setInterval(() => {}, 1000)')
const service = new NativeDshWebClientService({ rootDirectory: join(root, 'homes'), cliPath,
const service = new NativeDshWebClientService({ openModelCallLedger, rootDirectory: join(root, 'homes'), cliPath,
resolveLaunchEnvironment: async () => ({ nodeExecutablePath: standardNode!, environment: process.env }) })
services.push(service)
const starting = service.start({ projectId: 'pending', workspace: root, profile })
+34 -11
View File
@@ -1,10 +1,10 @@
import { execFile, spawn, type ChildProcess } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, realpath, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { promisify } from 'node:util'
import { AgentModelCallLedger, AgentModelGateway } from '../../agent-daemon/agent-model-gateway'
import { AgentModelGateway, type ModelCallLedger, type ModelCallLedgerFactory } from '../../agent-daemon/agent-model-gateway'
import { MODEL_BRIDGE_SDK_AUTH_SENTINEL, ModelBridgeLoopbackProxy } from '../../agent-daemon/model-bridge-helper'
import { canonicalJson } from '../../shared/agent-protocol/canonical'
import type { ResolvedModelProfile } from '../runtime-settings-store'
@@ -12,6 +12,7 @@ import { createManagedModelBridge } from '../remote-agent/managed-model-bridge'
import { terminateProcessTreeAndWait } from './child-process-termination'
import { buildCredentialFilteredUserEnvironment, runtimePrivacyEnvironment } from './process-environment'
import { nativeDshWebPatch, type NativeDshMcpServer } from './native-dsh-web-policy'
import { createRuntimeTemporaryDirectory, recordRuntimeTemporaryChild, removeRuntimeTemporaryDirectory } from '../runtime-temporary-directory'
export type { NativeDshMcpServer } from './native-dsh-web-policy'
@@ -30,7 +31,10 @@ export type NativeDshWebRequest = {
export type NativeDshWebHandle = { id: string; url: string }
export type NativeDshWebClientOptions = {
openModelCallLedger: ModelCallLedgerFactory
rootDirectory: string
/** Application version/run launch root; DSH_HOME remains persistent. */
temporaryRoot?: string
resolveLaunchEnvironment: () => Promise<{
nodeExecutablePath: string
environment: Readonly<NodeJS.ProcessEnv>
@@ -49,9 +53,11 @@ type Instance = {
ready: Promise<NativeDshWebHandle>
child?: ChildProcess
proxy?: ModelBridgeLoopbackProxy
ledger?: AgentModelCallLedger
ledger?: ModelCallLedger
ledgerOpening?: boolean
cleanup?: Promise<void>
home: string
temporary?: string
}
export function resolveNativeDshCli(resourcesPath?: string): string {
@@ -125,11 +131,16 @@ export class NativeDshWebClientService {
if (instance.child && instance.child.signalCode === null) {
await terminateProcessTreeAndWait(instance.child, { processGroup: process.platform !== 'win32' })
}
if (instance.child?.pid && instance.child.exitCode === null && instance.child.signalCode === null) {
throw new Error('DS Web child exit is unconfirmed')
}
await instance.proxy?.close()
instance.ledger?.close()
await instance.ledger?.close()
// Native history and profile settings are persistent; only launch material is disposable.
await Promise.all(['cordis.patch.yml', 'bridge.sqlite', 'bridge.sqlite-wal', 'bridge.sqlite-shm']
.map(name => rm(join(instance.home, name), { force: true })))
// A lost open reply must not cause deletion beneath the host's connection.
if (instance.temporary && (!instance.ledgerOpening || instance.ledger)) {
await removeRuntimeTemporaryDirectory(instance.temporary)
}
if (this.instances.get(instance.id) === instance) this.instances.delete(instance.id)
})()
return instance.cleanup
@@ -149,8 +160,11 @@ export class NativeDshWebClientService {
const cli = this.options.cliPath ?? resolveNativeDshCli(this.options.resourcesPath)
await stat(cli)
await mkdir(instance.home, { recursive: true, mode: 0o700 })
instance.temporary = await createRuntimeTemporaryDirectory(
this.options.temporaryRoot ?? join(this.options.rootDirectory, 'goodbuddy-runtime-launch'), 'goodbuddy-native-dsh-')
const managed = createManagedModelBridge({ profile: request.profile })
instance.ledger = new AgentModelCallLedger(join(instance.home, 'bridge.sqlite'))
instance.ledgerOpening = true
instance.ledger = await this.options.openModelCallLedger(join(instance.temporary, 'bridge.sqlite'))
const gateway = new AgentModelGateway({ ledger: instance.ledger, fetcher: this.options.fetcher })
const operationId = randomUUID()
let roundIndex = 0
@@ -167,17 +181,18 @@ export class NativeDshWebClientService {
baseURL: `${origin}/v1`,
skillDirectories: request.skillDirectories ?? [], mcpServers
})
await writeFile(join(instance.home, 'cordis.patch.yml'), JSON.stringify(patch), 'utf8')
const patchPath = join(instance.temporary, 'cordis.patch.yml')
await writeFile(patchPath, JSON.stringify(patch), { mode: 0o600 })
signal.throwIfAborted()
for (const name of Object.keys(environment)) if (name.startsWith('DSH_')) delete environment[name]
instance.child = spawn(launch.nodeExecutablePath, [cli, 'web', '--no-open', '--host', '127.0.0.1', '--port', '0'], {
instance.child = spawn(launch.nodeExecutablePath, [cli, 'web', '--patch', patchPath, '--no-open', '--host', '127.0.0.1', '--port', '0'], {
cwd: request.workspace, shell: false, windowsHide: true,
detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'],
env: { ...environment, ...runtimePrivacyEnvironment, DSH_HOME: instance.home, DSH_TELEMETRY_DISABLED: '1',
DSH_PERMISSION_MODE: 'danger-full-access', GOODBUDDY_DSH_MODEL_KEY: MODEL_BRIDGE_SDK_AUTH_SENTINEL }
})
const child = instance.child
const url = await new Promise<string>((resolve, reject) => {
const ready = new Promise<string>((resolve, reject) => {
let output = ''
const finish = (error?: Error, address?: string): void => {
signal.removeEventListener('abort', onAbort)
@@ -205,10 +220,18 @@ export class NativeDshWebClientService {
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) onAbort()
})
// Attach startup listeners before the asynchronous PID write.
const [startup, ownership] = await Promise.allSettled([ready, recordRuntimeTemporaryChild(instance.temporary, child.pid)])
if (ownership.status === 'rejected') throw ownership.reason
if (startup.status === 'rejected') throw startup.reason
const url = startup.value
// Drain subsequent logs without retaining token-bearing output.
child.stdout?.resume()
child.stderr?.resume()
child.once('exit', () => { void this.cleanup(instance) })
child.once('exit', () => {
// stop/dispose observes the retained cleanup failure and leaves files intact.
void this.cleanup(instance).catch(() => undefined)
})
const auth = await fetch(url, { redirect: 'manual', signal })
if (auth.status !== 303) throw new Error(`DS Web authentication failed (HTTP ${auth.status})`)
const cookie = auth.headers.getSetCookie().map(value => value.split(';')[0]).join('; ')
@@ -10,6 +10,7 @@ import type { NativeTerminalLaunch } from '../terminal/terminal-session-manager'
import { LocalTerminalSession } from '../terminal/local-terminal-session'
import { resolveBundledRuntimePaths } from './bundled-runtimes'
import { NativeTerminalClient } from './native-terminal-client'
import { AgentModelCallLedger } from '../../agent-daemon/agent-model-gateway'
// Opt-in because these tests execute the installed native clients and a real PTY.
describe.skipIf(!process.env.GOODBUDDY_NATIVE_NODE)('installed native terminal clients', () => {
@@ -21,6 +22,7 @@ describe.skipIf(!process.env.GOODBUDDY_NATIVE_NODE)('installed native terminal c
const requests: Array<Record<string, unknown>> = []
const profile = { id: 'native-test', name: 'Native Test Model', modelName: 'native-test-model', baseUrl: 'https://native-test.invalid/v1', protocol: 'openai-chat-completions' as const, authentication: 'api-key' as const, apiKey: 'test-provider-secret' }
const client = new NativeTerminalClient({
openModelCallLedger: async path => new AgentModelCallLedger(path),
rootDirectory: root,
nodeExecutable: process.env.GOODBUDDY_NATIVE_NODE,
bundledRuntimePaths: resolveBundledRuntimePaths({ appPath: process.cwd(), resourcesPath: '', packaged: false }),
+78 -8
View File
@@ -1,12 +1,15 @@
// @vitest-environment node
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { basename, dirname, join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import type { NativeTerminalLaunch } from '../terminal/terminal-session-manager'
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
import { NativeTerminalClient, type NativeTerminalClientInput } from './native-terminal-client'
import { AgentModelCallLedger, type ModelCallLedgerFactory } from '../../agent-daemon/agent-model-gateway'
import { runtimeTemporaryRoot } from '../runtime-temporary-directory'
import { LocalTerminalSession } from '../terminal/local-terminal-session'
const roots: string[] = []
const launches: NativeTerminalLaunch[] = []
@@ -15,16 +18,18 @@ afterEach(async () => {
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
async function fixture() {
async function fixture(openModelCallLedger: ModelCallLedgerFactory = async path => new AgentModelCallLedger(path)) {
const root = await mkdtemp(join(tmpdir(), 'native-terminal-test-'))
roots.push(root)
const scratch = runtimeTemporaryRoot(root, 'test', 'native')
const create = vi.fn(async (_owner, _request, launch: NativeTerminalLaunch | undefined) => {
launches.push(launch!)
return { sessionId: 'terminal', state: 'running' } as TerminalSnapshot
})
const fetcher = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), { headers: { 'content-type': 'application/json' } }))
const client = new NativeTerminalClient({
terminalManager: { create }, rootDirectory: root,
openModelCallLedger,
terminalManager: { create }, rootDirectory: root, temporaryRoot: scratch,
bundledRuntimePaths: { opencode: process.execPath, continue: process.execPath, ripgrep: '', deepseekHarness: '' }, fetcher
})
const profile = { id: 'selected', name: 'Selected model', modelName: 'selected-model', baseUrl: 'https://provider.example/v1', authentication: 'api-key' as const, protocol: 'openai-chat-completions' as const, apiKey: 'provider-secret', requestHeaders: { 'x-provider-option': 'configured' } }
@@ -32,12 +37,74 @@ async function fixture() {
projectId: 'project', projectName: 'Project', directory: root, runtime: 'opencode',
settings: { opencodeBinaryPath: '', opencodeModelProfile: profile } as unknown as ResolvedRuntimeSettings
}
return { root, client, create, fetcher, input }
return { root, scratch, client, create, fetcher, input }
}
describe('native terminal client', () => {
it.skipIf(!process.env.NATIVE_DSH_TEST_NODE)('records a real PTY child and preserves native history after exit and scratch cleanup', async () => {
const { client, input, root, scratch, fetcher } = await fixture()
await client.open(1, input)
const launch = launches[0]!
const history = launch.spawnSpec.env.XDG_DATA_HOME!
await mkdir(history, { recursive: true })
await writeFile(join(history, 'session'), 'retained')
const session = await LocalTerminalSession.create({
target: { type: 'project', projectId: 'project' }, targetLabel: 'Native test', title: 'Native test',
size: { cols: 80, rows: 24 }, projectDirectory: root,
spawnSpec: { ...launch.spawnSpec, executable: process.env.NATIVE_DSH_TEST_NODE!, args: ['-e', 'setInterval(() => {}, 1000)'] }
})
try {
expect(session.snapshot().state).toBe('running')
const directory = dirname(launch.spawnSpec.env.OPENCODE_CONFIG_DIR!)
const owner = JSON.parse(await readFile(join(scratch, `.${basename(directory)}.owner.json`), 'utf8'))
expect(owner.creatorPid).toBe(process.pid)
expect(owner.childPid).toBeGreaterThan(0)
expect(() => process.kill(owner.childPid, 0)).not.toThrow()
} finally { await session.close() }
await launch.dispose()
expect(await readdir(scratch)).toEqual([])
expect(await readFile(join(history, 'session'), 'utf8')).toBe('retained')
expect(fetcher).not.toHaveBeenCalled()
})
it('retains the launch directory until asynchronous ledger close settles', async () => {
let resolveClose!: () => void
const closing = new Promise<void>(resolve => { resolveClose = resolve })
let path = ''
const close = vi.fn(() => closing)
const { client, input, scratch } = await fixture(async databasePath => {
path = databasePath
return { claim: vi.fn(), complete: vi.fn(), delivered: vi.fn(), outcomeUnknown: vi.fn(), get: vi.fn(), close }
})
await client.open(1, input)
const disposal = launches[0]!.dispose()
await vi.waitFor(() => expect(close).toHaveBeenCalledOnce())
expect((await readdir(scratch)).some(name => path.includes(name) && name.startsWith('goodbuddy-opencode-'))).toBe(true)
resolveClose()
await disposal
expect(await readdir(scratch)).toEqual([])
})
it('retains launch files after unconfirmed ledger open instead of deleting a possibly open database', async () => {
const { client, input, scratch, create } = await fixture(async () => { throw new Error('unconfirmed storage open') })
await expect(client.open(1, input)).rejects.toThrow('unconfirmed storage open')
expect(create).not.toHaveBeenCalled()
expect((await readdir(scratch)).filter(name => name.startsWith('goodbuddy-opencode-'))).toHaveLength(1)
})
it('does not delete launch files when host close rejects', async () => {
const { client, input, scratch } = await fixture(async () => ({
claim: vi.fn(), complete: vi.fn(), delivered: vi.fn(), outcomeUnknown: vi.fn(), get: vi.fn(),
close: async () => { throw new Error('unconfirmed storage close') }
}))
await client.open(1, input)
const launch = launches.pop()!
await expect(launch.dispose()).rejects.toThrow('unconfirmed storage close')
expect((await readdir(scratch)).filter(name => name.startsWith('goodbuddy-opencode-'))).toHaveLength(1)
})
it('bridges successive native requests with the selected model and keeps credentials in Main', async () => {
const { client, input, create, fetcher, root } = await fixture()
const { client, input, create, fetcher, root, scratch } = await fixture()
const first = client.open(1, input)
expect(client.open(1, input)).toBe(first)
await first
@@ -58,6 +125,8 @@ describe('native terminal client', () => {
expect(headers.get('x-provider-option')).toBe('configured')
const configDirectory = launch.spawnSpec.env.OPENCODE_CONFIG_DIR!
expect(config.permission).toBe('allow')
expect(configDirectory.startsWith(scratch)).toBe(true)
expect(launch.spawnSpec.env.XDG_DATA_HOME!.startsWith(join(root, 'opencode'))).toBe(true)
expect(config.plugin).toBeUndefined()
await launch.dispose()
await expect(readdir(configDirectory)).rejects.toThrow()
@@ -68,13 +137,13 @@ describe('native terminal client', () => {
})
it('releases prepared resources after terminal creation fails and permits retry', async () => {
const { client, input, create, root } = await fixture()
const { client, input, create, scratch } = await fixture()
create.mockImplementationOnce(async (_owner, _request, launch) => {
launches.push(launch!)
throw new Error('PTY unavailable')
})
await expect(client.open(1, input)).rejects.toThrow('PTY unavailable')
expect((await readdir(root)).filter(name => name.startsWith('launch-'))).toEqual([])
expect(await readdir(scratch)).toEqual([])
await expect(client.open(1, input)).resolves.toMatchObject({ state: 'running' })
})
@@ -85,6 +154,7 @@ describe('native terminal client', () => {
await writeFile(join(bundled, '.goodbuddy-ready.json'), '{"version":"1"}')
await writeFile(join(bundled, 'node_modules', 'plugin', 'index.js'), 'export {}')
const client = new NativeTerminalClient({
openModelCallLedger: async path => new AgentModelCallLedger(path),
terminalManager: { create }, rootDirectory: root, fetcher,
bundledRuntimePaths: { opencode: process.execPath, opencodeConfig: bundled, continue: process.execPath, ripgrep: '', deepseekHarness: '' }
})
+20 -8
View File
@@ -1,9 +1,9 @@
import { randomUUID } from 'node:crypto'
import { execFile } from 'node:child_process'
import { cp, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { cp, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { AgentModelCallLedger, AgentModelGateway } from '../../agent-daemon/agent-model-gateway'
import { AgentModelGateway, type ModelCallLedger, type ModelCallLedgerFactory } from '../../agent-daemon/agent-model-gateway'
import {
ModelBridgeLoopbackProxy,
MODEL_BRIDGE_SDK_AUTH_SENTINEL,
@@ -20,6 +20,7 @@ import { ContinueHostAdapter, loadContinueConfig } from './continue-host-adapter
import { detectRuntimeBinary } from './runtime-discovery'
import { applyLaunchEnvironmentPath, buildCredentialFilteredUserEnvironment, runtimePrivacyEnvironment } from './process-environment'
import { stageRuntimeSkillPackages } from './runtime-skill-packages'
import { createRuntimeTemporaryDirectory, recordRuntimeTemporaryChild, removeRuntimeTemporaryDirectory, runtimeTemporaryProcessIsActive } from '../runtime-temporary-directory'
export type NativeTerminalClientInput = {
projectId: string
@@ -34,9 +35,12 @@ export type NativeTerminalClientInput = {
}
export type NativeTerminalClientOptions = {
openModelCallLedger: ModelCallLedgerFactory
terminalManager: Pick<TerminalSessionManager, 'create'>
/** GoodBuddy user-data directory; native history is retained here. */
rootDirectory: string
/** Application version/run launch root; retained histories stay in rootDirectory. */
temporaryRoot?: string
bundledRuntimePaths: BundledRuntimePaths
/** Standard Node resolved by the local tool environment, never Electron. */
nodeExecutable?: string
@@ -137,9 +141,12 @@ export class NativeTerminalClient {
}
const root = this.options.rootDirectory
await mkdir(root, { recursive: true })
const temporary = await mkdtemp(join(root, 'launch-'))
const temporary = await createRuntimeTemporaryDirectory(
this.options.temporaryRoot ?? join(root, 'goodbuddy-runtime-launch'), `goodbuddy-${input.runtime}-`)
let childPid: number | undefined
const history = join(root, input.runtime, randomUUID())
let ledger: AgentModelCallLedger | undefined
let ledger: ModelCallLedger | undefined
let ledgerOpening = false
let gateway: AgentModelGateway
const bindingId = randomUUID()
const proxy = new ModelBridgeLoopbackProxy({ exchange: (request, context) => gateway.dispatch({
@@ -148,13 +155,16 @@ export class NativeTerminalClient {
}, request, context.signal) })
let disposal: Promise<void> | undefined
const dispose = (): Promise<void> => disposal ??= (async () => {
if (childPid && runtimeTemporaryProcessIsActive(childPid)) throw new Error('Native terminal child exit is unconfirmed')
try { await proxy.close() } finally {
ledger?.close()
await rm(temporary, { recursive: true, force: true })
await ledger?.close()
// A lost open reply can leave a live connection in the host.
if (!ledgerOpening || ledger) await removeRuntimeTemporaryDirectory(temporary)
}
})()
try {
ledger = new AgentModelCallLedger(join(temporary, 'model-calls.sqlite'))
ledgerOpening = true
ledger = await this.options.openModelCallLedger(join(temporary, 'model-calls.sqlite'))
gateway = new AgentModelGateway({ ledger, fetcher: this.options.fetcher })
await mkdir(history, { recursive: true })
const origin = await proxy.listen()
@@ -212,7 +222,9 @@ export class NativeTerminalClient {
})
args.push('--model', config.model)
}
return { spawnSpec: { executable, args, cwd: input.directory, env, label: input.runtime }, title: `${input.runtime} · ${input.projectName}`, dispose }
return { spawnSpec: { executable, args, cwd: input.directory, env, label: input.runtime,
onSpawn: async pid => { childPid = pid; await recordRuntimeTemporaryChild(temporary, pid) }
}, title: `${input.runtime} · ${input.projectName}`, dispose }
} catch (error) {
await dispose()
throw error
+7 -1
View File
@@ -11,7 +11,7 @@ import {
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { basename, dirname, join, resolve } from "node:path";
import { PassThrough } from "node:stream";
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
import type spawn from "cross-spawn";
@@ -857,6 +857,10 @@ describe("OpenCodeRuntime embedded launcher", () => {
available: true,
});
const firstEnvironment = first.spawnMock.mock.calls[0]?.[2]?.env;
const launchDirectory = dirname(firstEnvironment?.XDG_DATA_HOME ?? "");
expect(dirname(launchDirectory)).toBe(sharedCacheRoot);
const ownerPath = join(sharedCacheRoot, `.${basename(launchDirectory)}.owner.json`);
expect(JSON.parse(await readFile(ownerPath, "utf8"))).toEqual({ creatorPid: process.pid, childPid: firstChild.pid });
const firstConfigDirectory = firstEnvironment?.OPENCODE_CONFIG_DIR ?? "";
const firstConfig = JSON.parse(
firstEnvironment?.OPENCODE_CONFIG_CONTENT ?? "{}",
@@ -887,6 +891,8 @@ describe("OpenCodeRuntime embedded launcher", () => {
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "1",
});
await firstRuntime.dispose();
await expect(stat(launchDirectory)).rejects.toMatchObject({ code: "ENOENT" });
await expect(stat(ownerPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(
readFile(join(firstSkillsRoot, "shared-skill", "SKILL.md"), "utf8"),
).resolves.toContain("name: shared-skill");
+10 -10
View File
@@ -20,6 +20,7 @@ import {
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { createRuntimeTemporaryDirectory, recordRuntimeTemporaryChild, removeRuntimeTemporaryDirectory } from "../runtime-temporary-directory";
import { dirname, join, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
@@ -1035,7 +1036,7 @@ export class OpenCodeRuntime implements AgentRuntime {
}
private async createSkillRegistration(): Promise<OpenCodeSkillRegistration> {
const root = await mkdtemp(join(tmpdir(), "goodbuddy-opencode-"));
const root = await createRuntimeTemporaryDirectory(this.options.sharedCacheRoot?.trim() || tmpdir(), "goodbuddy-opencode-");
try {
const sharedRoot = this.options.sharedCacheRoot?.trim();
if (sharedRoot) {
@@ -1053,7 +1054,7 @@ export class OpenCodeRuntime implements AgentRuntime {
await this.normalizeSkillPackages(skillsRoot);
return { root, configDirectory, skillsRoot };
} catch (error) {
await rm(root, { recursive: true, force: true });
await removeRuntimeTemporaryDirectory(root);
throw error;
}
}
@@ -1154,6 +1155,7 @@ export class OpenCodeRuntime implements AgentRuntime {
const skillIds = this.getNativeSkillIds();
const registration = await this.createSkillRegistration();
let launchedChild: SpawnedProcess | undefined;
try {
if (signal?.aborted) {
throw new Error("OpenCode Server 启动已取消");
@@ -1227,6 +1229,8 @@ export class OpenCodeRuntime implements AgentRuntime {
windowsHide: true,
});
this.startingChild = child;
launchedChild = child;
const ownership = recordRuntimeTemporaryChild(registration.root, child.pid);
const { stdout, stderr } = child;
let settled = false;
let pollTimer: NodeJS.Timeout | undefined;
@@ -1300,10 +1304,7 @@ export class OpenCodeRuntime implements AgentRuntime {
this.terminate(child);
await exited;
} finally {
await rm(registration.root, {
recursive: true,
force: true,
});
if (child.exitCode !== null || child.signalCode != null) await removeRuntimeTemporaryDirectory(registration.root);
}
},
});
@@ -1319,6 +1320,7 @@ export class OpenCodeRuntime implements AgentRuntime {
};
const probe = async (): Promise<void> => {
try {
await ownership;
const attemptSignal = AbortSignal.any([
probeSignal,
AbortSignal.timeout(STARTUP_PROBE_TIMEOUT_MS),
@@ -1347,6 +1349,7 @@ export class OpenCodeRuntime implements AgentRuntime {
fail("OpenCode Server 启动超时(30 秒)");
}, this.dependencies.startupTimeoutMs);
void ownership.catch(() => fail("OpenCode launch ownership could not be recorded"));
if (!stdout || !stderr) {
fail("OpenCode Server 管道初始化失败");
return;
@@ -1363,10 +1366,7 @@ export class OpenCodeRuntime implements AgentRuntime {
void probe();
});
} catch (error) {
await rm(registration.root, {
recursive: true,
force: true,
}).catch(() => undefined);
if (!launchedChild || launchedChild.exitCode !== null || launchedChild.signalCode != null || !launchedChild.pid) await removeRuntimeTemporaryDirectory(registration.root).catch(() => undefined);
throw error;
}
}
+105 -27
View File
@@ -1,22 +1,20 @@
import { mkdtemp, readdir, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { FileOutputBacking } from '../../../tests/support/paged-output-backing'
import { PagedOutputStore, PAGED_OUTPUT_MAX_PAGE_BYTES } from './paged-output-store'
const stores: PagedOutputStore[] = []
const directories: string[] = []
const backings: FileOutputBacking[] = []
afterEach(async () => {
await Promise.all(stores.splice(0).map((store) => store.dispose()))
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))
await Promise.all(backings.splice(0).map((backing) => backing.dispose()))
})
async function createStore() {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-output-test-'))
directories.push(directory)
const store = new PagedOutputStore('test', { temporaryParentDirectory: directory })
const backing = new FileOutputBacking()
backings.push(backing)
const store = new PagedOutputStore('test', { backingStore: backing })
stores.push(store)
return { store, directory }
return { store, backing }
}
describe('PagedOutputStore', () => {
@@ -24,7 +22,7 @@ describe('PagedOutputStore', () => {
const { store } = await createStore()
const text = 'a\u4f60\ud83d\ude00\ufffd\u597dZ'
const writer = await store.create('owner', 2)
for (const byte of Buffer.from(text)) writer.append(Buffer.from([byte]))
for (const byte of Buffer.from(text)) await writer.append(Buffer.from([byte]))
const saved = await writer.finish()
expect(saved.text).toBe('a')
let output = saved.text
@@ -51,7 +49,7 @@ describe('PagedOutputStore', () => {
it('keeps literal replacement characters in previews', async () => {
const { store } = await createStore()
const writer = await store.create('owner', 3)
writer.append('\ufffdrest')
await writer.append('\ufffdrest')
const saved = await writer.finish()
expect(saved.text).toBe('\ufffd')
expect(saved.reference?.nextCursor).toBe(3)
@@ -60,7 +58,7 @@ describe('PagedOutputStore', () => {
it('retains output omitted to keep JSON-escaped previews bounded', async () => {
const { store } = await createStore()
const writer = await store.create('owner', 12)
writer.append('\u0000'.repeat(12))
await writer.append('\u0000'.repeat(12))
const saved = await writer.finish()
expect(saved.text).toBe('\u0000\u0000')
expect(saved.reference?.nextCursor).toBe(2)
@@ -69,36 +67,116 @@ describe('PagedOutputStore', () => {
})
it('removes small, aborted, released and disposed output files', async () => {
const { store, directory } = await createStore()
const { store, backing } = await createStore()
const small = await store.create('owner', 10)
small.append('small')
await small.append('small')
expect(await small.finish()).toEqual({ text: 'small', truncated: false })
const empty = await store.create('owner', 10)
expect(await empty.finish()).toEqual({ text: '', truncated: false })
expect(backing.createdDirectory).toBe(false)
const retained = await store.create('owner', 1)
retained.append('retained')
await retained.append('retained')
await retained.finish()
const active = await store.create('owner', 1)
active.append('active')
await active.append('active')
const other = await store.create('other', 1)
other.append('other')
await other.append('other')
await other.finish()
const [childDirectory] = await readdir(directory)
await store.releaseOwner('owner')
expect(await readdir(join(directory, childDirectory!))).toHaveLength(1)
expect(backing.count).toBe(1)
await expect(active.finish()).rejects.toThrow()
await expect(store.read('owner', retained.handle)).rejects.toThrow()
await expect(store.read('other', other.handle)).resolves.toMatchObject({ content: 'other' })
await store.create('other', 1)
await store.dispose()
expect(await readdir(directory)).toEqual([])
expect(backing.count).toBe(0)
await expect(store.create('owner', 1)).rejects.toThrow()
})
it('cleans up when disposed during directory creation', async () => {
const { store, directory } = await createStore()
const pending = store.create('owner', 1)
const rejected = expect(pending).rejects.toThrow()
it('waits for in-flight creation and writes before disposal', async () => {
const { store, backing } = await createStore()
let resume!: () => void
const gate = new Promise<void>((resolve) => { resume = resolve })
const create = backing.create.bind(backing)
const creating = vi.spyOn(backing, 'create').mockImplementation(async (...args) => {
await gate
await create(...args)
})
const writer = await store.create('owner', 1)
const pending = writer.append('complete output')
await vi.waitFor(() => expect(creating).toHaveBeenCalledOnce())
let disposed = false
const disposal = store.dispose().then(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
resume()
await pending
await disposal
expect(backing.count).toBe(0)
})
it.each(['create', 'append', 'finish'] as const)('cleans partial backing content after %s failure', async (method) => {
const { store, backing } = await createStore()
const original = backing[method].bind(backing)
vi.spyOn(backing, method).mockImplementationOnce(async (...args: unknown[]) => {
await (original as (...args: unknown[]) => Promise<void>)(...args)
throw new Error('disk failure')
})
const writer = await store.create('owner', 1)
await writer.append('complete output').catch(() => undefined)
await expect(writer.finish()).rejects.toThrow()
expect(backing.count).toBe(0)
})
it('does not return a successful partial result when the owner is missing', async () => {
const store = new PagedOutputStore('unconfigured')
stores.push(store)
const writer = await store.create('owner', 2)
await writer.append('ok')
expect(() => writer.append('lost')).toThrow('not configured')
await expect(writer.finish()).rejects.toThrow()
})
it('bounds capture across stores and releases admission after settlement', async () => {
const { store, backing } = await createStore()
const second = new PagedOutputStore('second', { backingStore: backing })
stores.push(second)
const writer = await store.create('owner', 8 * 1024 * 1024)
await expect(second.create('other', 1)).rejects.toThrow('busy')
await writer.abort()
const next = await second.create('other', 1)
await next.append('recovered')
await next.finish()
})
it('rejects an unbounded producer and retries failed cleanup', async () => {
const { store, backing } = await createStore()
const writer = await store.create('owner', 1)
expect(() => writer.append(Buffer.alloc(8 * 1024 * 1024))).toThrow('busy')
await expect(writer.finish()).rejects.toThrow()
const next = await store.create('owner', 1)
await next.append('retained')
await next.finish()
vi.spyOn(backing, 'release').mockRejectedValueOnce(new Error('file busy'))
await expect(store.dispose()).rejects.toThrow('file busy')
await store.dispose()
await rejected
expect(await readdir(directory)).toEqual([])
expect(backing.count).toBe(0)
})
it('streams more than the memory budget to one backing object in bounded writes', async () => {
const { store, backing } = await createStore()
const writer = await store.create('owner', 1024)
const chunk = 'x'.repeat(64 * 1024)
for (let i = 0; i < 160; i += 1) await writer.append(chunk)
const saved = await writer.finish()
expect(saved.reference?.totalBytes).toBe(10 * 1024 * 1024)
expect(backing.count).toBe(1)
expect(backing.maximumWriteBytes).toBeLessThanOrEqual(64 * 1024)
let cursor = 0
while (cursor < saved.reference!.totalBytes) {
const page = await store.read('owner', writer.handle, cursor)
expect(page.content).toBe('x'.repeat(page.nextCursor - cursor))
cursor = page.nextCursor
}
})
})
+138 -81
View File
@@ -1,13 +1,4 @@
import { randomUUID } from 'node:crypto'
import {
closeSync,
openSync,
readSync,
writeSync
} from 'node:fs'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
export const PAGED_OUTPUT_DEFAULT_PAGE_BYTES = 32 * 1024
export const PAGED_OUTPUT_MAX_PAGE_BYTES = 32 * 1024
@@ -29,56 +20,111 @@ export type PagedOutputPage = {
type OutputEntry = {
ownerId: string
path: string
totalBytes: number
}
export type PagedOutputStoreOptions = {
temporaryParentDirectory?: string
// Implement in the existing attachment/storage owner. The handle identifies the
// same content during capture, paging and history adoption; release drops only
// the call's reference, never a reference already adopted by persisted history.
export interface PagedOutputBackingStore {
create(ownerId: string, handle: string): Promise<void>
append(ownerId: string, handle: string, bytes: Uint8Array): Promise<void>
finish(ownerId: string, handle: string, totalBytes: number): Promise<void>
read(ownerId: string, handle: string, cursor: number, length: number): Promise<Uint8Array>
release(ownerId: string, handle: string): Promise<void>
}
export type PagedOutputStoreOptions = {
backingStore?: PagedOutputBackingStore
}
const MAX_CAPTURE_BYTES = 8 * 1024 * 1024
const MAX_OUTPUTS = 4096
const WRITE_BYTES = 64 * 1024
// Shared across process, search and subagent stores, including in-flight writes.
let captureBytes = 0
export class PagedOutputWriter {
private preview = Buffer.alloc(0)
private preview: Buffer
private previewLength = 0
private totalBytes = 0
private failure?: Error
private closed = false
private pending = Promise.resolve()
private pendingCount = 0
private backed = false
private released = false
private finishing?: Promise<{ text: string; truncated: boolean; reference?: PagedOutputReference }>
constructor(
private readonly store: PagedOutputStore,
readonly handle: string,
readonly ownerId: string,
readonly path: string,
private readonly descriptor: number,
private readonly previewBytes: number
) {}
) {
if (captureBytes + previewBytes > MAX_CAPTURE_BYTES) throw new Error('Tool output capture is busy')
this.preview = Buffer.alloc(previewBytes)
captureBytes += previewBytes
}
append(chunk: Buffer | string): void {
append(chunk: Buffer | string): Promise<void> {
if (this.closed || this.failure) {
return
throw this.failure ?? new Error('分页输出已经关闭')
}
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
try {
let offset = 0
while (offset < value.byteLength) {
const written = writeSync(this.descriptor, value, offset)
if (written === 0) throw new Error('Unable to write tool output')
offset += written
const length = Buffer.byteLength(chunk)
if (!length) return Promise.resolve()
const previousLength = this.previewLength
const spill = this.backed || this.totalBytes + length > this.previewBytes
if (spill) {
try {
void this.store.backing
} catch (error) {
this.failure = error as Error
throw error
}
this.totalBytes += value.byteLength
if (this.preview.byteLength < this.previewBytes) {
const remaining = this.previewBytes - this.preview.byteLength
this.preview = Buffer.concat([
this.preview,
value.subarray(0, remaining)
])
}
if (spill && (captureBytes + length > MAX_CAPTURE_BYTES || this.pendingCount >= 1024)) {
this.failure = new Error('Tool output capture is busy; producer must await append')
throw this.failure
}
const value = Buffer.from(chunk)
this.previewLength += value.copy(this.preview, this.previewLength, 0, this.previewBytes - this.previewLength)
this.totalBytes += length
if (!spill) return Promise.resolve()
const create = !this.backed
this.backed = true
captureBytes += length
this.pendingCount += 1
const write = this.pending.then(async () => {
if (this.failure) throw this.failure
if (create) {
await this.store.backing.create(this.ownerId, this.handle)
await this.write(this.preview.subarray(0, previousLength))
}
} catch (error) {
this.failure =
error instanceof Error ? error : new Error('无法保存分页输出')
await this.write(value)
}).finally(() => {
captureBytes -= length
this.pendingCount -= 1
})
this.pending = write.catch((error: unknown) => {
this.failure = error instanceof Error ? error : new Error('无法保存分页输出')
})
return write
}
private async write(value: Buffer): Promise<void> {
for (let offset = 0; offset < value.length; offset += WRITE_BYTES) {
await this.store.backing.append(this.ownerId, this.handle, value.subarray(offset, offset + WRITE_BYTES))
}
}
async finish(): Promise<{
finish(): Promise<{ text: string; truncated: boolean; reference?: PagedOutputReference }> {
if (this.closed) return Promise.reject(new Error('分页输出已经关闭'))
this.finishing = this.finishOnce()
return this.finishing
}
private async finishOnce(): Promise<{
text: string
truncated: boolean
reference?: PagedOutputReference
@@ -87,12 +133,12 @@ export class PagedOutputWriter {
throw new Error('分页输出已经关闭')
}
this.closed = true
closeSync(this.descriptor)
await this.pending
if (this.failure) {
await this.store.discard(this)
throw new Error('无法保存完整工具输出', { cause: this.failure })
}
let previewLength = utf8PrefixLength(this.preview)
let previewLength = utf8PrefixLength(this.preview, this.previewLength)
let text = this.preview.subarray(0, previewLength).toString('utf8')
// Tool results are JSON: escaped control characters also consume context.
if (Buffer.byteLength(JSON.stringify(text)) - 2 > this.previewBytes) {
@@ -114,7 +160,21 @@ export class PagedOutputWriter {
await this.store.discard(this)
return { text, truncated: false }
}
this.store.retain(this, this.totalBytes)
try {
if (!this.backed) {
void this.store.backing
this.backed = true
await this.store.backing.create(this.ownerId, this.handle)
await this.write(this.preview.subarray(0, this.previewLength))
}
await this.store.backing.finish(this.ownerId, this.handle, this.totalBytes)
this.store.retain(this, this.totalBytes)
} catch (error) {
await this.store.discard(this)
throw error
} finally {
this.releaseMemory()
}
return {
text,
truncated: true,
@@ -127,12 +187,24 @@ export class PagedOutputWriter {
}
async abort(): Promise<void> {
if (!this.closed) {
this.closed = true
closeSync(this.descriptor)
}
this.closed = true
await this.finishing?.catch(() => undefined)
await this.pending
await this.store.discard(this)
}
releaseMemory(): void {
captureBytes -= this.preview.byteLength
this.preview = Buffer.alloc(0)
}
async releaseBacking(): Promise<void> {
this.releaseMemory()
if (this.backed && !this.released) {
await this.store.backing.release(this.ownerId, this.handle)
this.released = true
}
}
}
function utf8PrefixLength(value: Buffer, end = value.byteLength): number {
@@ -151,7 +223,6 @@ function utf8Prefix(value: Buffer): string {
export class PagedOutputStore {
private readonly entries = new Map<string, OutputEntry>()
private readonly writers = new Set<PagedOutputWriter>()
private directoryPromise?: Promise<string>
private disposed = false
private disposePromise?: Promise<void>
@@ -160,6 +231,11 @@ export class PagedOutputStore {
private readonly options: PagedOutputStoreOptions = {}
) {}
get backing(): PagedOutputBackingStore {
if (!this.options.backingStore) throw new Error('Full output storage owner is not configured')
return this.options.backingStore
}
async create(
ownerId: string,
previewBytes: number
@@ -167,17 +243,15 @@ export class PagedOutputStore {
if (this.disposed) {
throw new Error('分页输出存储已关闭')
}
const directory = await this.getDirectory()
if (this.disposed) throw new Error('分页输出存储已关闭')
if (!Number.isSafeInteger(previewBytes) || previewBytes < 1 || previewBytes > MAX_CAPTURE_BYTES) {
throw new Error('Invalid output preview size')
}
if (this.writers.size + this.entries.size >= MAX_OUTPUTS) throw new Error('Tool output store is busy')
const id = randomUUID()
const path = join(directory, id)
const descriptor = openSync(path, 'wx')
const writer = new PagedOutputWriter(
this,
`${this.handlePrefix}:${id}`,
ownerId,
path,
descriptor,
previewBytes
)
this.writers.add(writer)
@@ -206,14 +280,11 @@ export class PagedOutputStore {
}
const requestedBytes = Math.min(limitBytes + 3, entry.totalBytes - cursor)
const buffer = Buffer.alloc(requestedBytes)
const descriptor = openSync(entry.path, 'r')
let bytesRead: number
try {
bytesRead = readSync(descriptor, buffer, 0, requestedBytes, cursor)
} finally {
closeSync(descriptor)
}
const buffer = requestedBytes === 0 ? Buffer.alloc(0) : Buffer.from(
await this.backing.read(ownerId, handle, cursor, requestedBytes)
)
const bytesRead = buffer.byteLength
if (bytesRead !== requestedBytes) throw new Error('Incomplete stored tool output')
if (bytesRead && (buffer[0]! & 0xc0) === 0x80) {
throw new Error('分页输出 cursor 必须位于 UTF-8 字符边界')
}
@@ -244,27 +315,25 @@ export class PagedOutputStore {
([, entry]) => entry.ownerId === ownerId
)
for (const [handle] of owned) {
await this.backing.release(ownerId, handle)
this.entries.delete(handle)
}
await Promise.allSettled(
owned.map(([, entry]) => rm(entry.path, { force: true }))
)
}
dispose(): Promise<void> {
this.disposePromise ??= this.disposeOnce()
this.disposePromise ??= this.disposeOnce().catch((error: unknown) => {
this.disposePromise = undefined
throw error
})
return this.disposePromise
}
private async disposeOnce(): Promise<void> {
this.disposed = true
await Promise.all([...this.writers].map((writer) => writer.abort()))
this.entries.clear()
if (this.directoryPromise) {
const directory = await this.directoryPromise.catch(() => undefined)
if (directory) {
await rm(directory, { recursive: true, force: true })
}
for (const [handle, entry] of this.entries) {
await this.backing.release(entry.ownerId, handle)
this.entries.delete(handle)
}
}
@@ -272,25 +341,13 @@ export class PagedOutputStore {
this.writers.delete(writer)
this.entries.set(writer.handle, {
ownerId: writer.ownerId,
path: writer.path,
totalBytes
})
}
async discard(writer: PagedOutputWriter): Promise<void> {
await writer.releaseBacking()
this.writers.delete(writer)
this.entries.delete(writer.handle)
await rm(writer.path, { force: true })
}
private getDirectory(): Promise<string> {
this.directoryPromise ??= this.createDirectory()
return this.directoryPromise
}
private async createDirectory(): Promise<string> {
const parent = this.options.temporaryParentDirectory ?? tmpdir()
await mkdir(parent, { recursive: true })
return await mkdtemp(join(parent, `goodbuddy-${this.handlePrefix}-`))
}
}
+9 -2
View File
@@ -1,13 +1,19 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { rgPath } from '@vscode/ripgrep'
import { expect, it } from 'vitest'
import { FileOutputBacking, outputFixtureRoot } from '../../../tests/support/paged-output-backing'
import { ModelToolProvider, type ModelToolResult } from './model-tool-provider'
it('pages native rg output through the production provider and releases it', async () => {
await mkdir(outputFixtureRoot, { recursive: true })
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-rg-provider-'))
const provider = new ModelToolProvider(root, [], undefined, undefined, false, { ripgrepExecutablePath: rgPath })
const backingStore = new FileOutputBacking()
const provider = new ModelToolProvider(root, [], undefined, undefined, false, {
ripgrepExecutablePath: rgPath,
outputStore: { backingStore }
})
const context = { conversationId: 'rg-pages', runtimeTarget: 'model' as const, }
const signal = new AbortController().signal
const parse = (result: ModelToolResult) => {
@@ -45,6 +51,7 @@ it('pages native rg output through the production provider and releases it', asy
await expect(provider.callTool('output_read', { handle: reference.handle }, signal, context)).rejects.toThrow('不存在')
} finally {
await provider.dispose()
await backingStore.dispose()
await rm(root, { recursive: true, force: true })
}
})
+16 -4
View File
@@ -45,6 +45,7 @@ import { createModelRequestProbe } from '../../../tests/support/model-request-pr
import { ModelToolProvider } from './model-tool-provider'
import { LocalDirectModelProcessService, type ProcessExecuteResult } from './direct-model-process-service'
import type { PagedOutputPage } from './paged-output-store'
import { FileOutputBacking } from '../../../tests/support/paged-output-backing'
const enabled = process.env.GOODBUDDY_RUN_RUNTIME_E2E === '1'
const apiKey =
@@ -549,12 +550,19 @@ describe.runIf(enabled)('runtime end-to-end', () => {
? createOpenAIResponsesUrl(baseUrl)
: createOpenAIChatCompletionsUrl(baseUrl)
const probe = await createModelRequestProbe({ upstreamUrl, headerName: 'x-goodbuddy-output-test' })
// Production injects the storage-owned backing (index.ts); output beyond the
// preview requires one, otherwise the tool fails before the model can page.
const backingStore = new FileOutputBacking()
const provider = new ModelToolProvider(workspace, [], undefined, undefined, false, {
processService: new LocalDirectModelProcessService()
processService: new LocalDirectModelProcessService({ outputStore: { backingStore } })
})
const listTools = provider.listTools.bind(provider)
vi.spyOn(provider, 'listTools').mockImplementation(async (context, signal) =>
(await listTools(context, signal)).filter((tool) => ['process_execute', 'output_read'].includes(tool.name)))
let offeredTools: string[] = []
vi.spyOn(provider, 'listTools').mockImplementation(async (context, signal) => {
const tools = (await listTools(context, signal)).filter((tool) => ['process_execute', 'output_read'].includes(tool.name))
offeredTools = tools.map(tool => tool.name)
return tools
})
const callTool = provider.callTool.bind(provider)
const observed: Array<{ name: string; result: ProcessExecuteResult | PagedOutputPage }> = []
vi.spyOn(provider, 'callTool').mockImplementation(async (name, args, signal, context) => {
@@ -614,12 +622,16 @@ describe.runIf(enabled)('runtime end-to-end', () => {
for (const marker of markers) expect(answer).toContain(marker)
expect(events.at(-1)).toMatchObject({ type: 'done' })
} finally {
const report = { boundary: 'process-output-pagination', realModelCalls: probe.observations.length,
const report = { boundary: 'process-output-pagination', realModelCalls: probe.observations.length, offeredTools,
eventTypes: [...new Set(events.map(event => event.type))],
toolEvents: events.filter(event => event.type === 'tool').map(event => JSON.stringify(event).slice(0, 400)),
errors: events.filter(event => event.type === 'error').map(event => String((event as { message?: string }).message).slice(0, 200)),
processCalls: observed.filter((item) => item.name === 'process_execute').length,
outputReadCalls: observed.filter((item) => item.name === 'output_read').length }
console.info(JSON.stringify(report))
await runtime.dispose()
await probe.close()
await backingStore.dispose()
if (process.env.GOODBUDDY_E2E_OUTPUT_REPORT) {
await writeFile(process.env.GOODBUDDY_E2E_OUTPUT_REPORT, JSON.stringify(report))
}
@@ -57,7 +57,8 @@ function resolveListOptions(
}
/**
* Durable Main-side index for ACP session ownership. Implementations must make
* Durable index for ACP session ownership. Desktop storage owns persistence;
* Main consumes this asynchronous interface. Implementations must make
* each put atomic; callers never mutate an object returned by this interface.
*/
export interface RuntimeSessionBindingStore {