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:
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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: '' }
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}-`))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user