Files
goodbuddy/src/main/agent/native-terminal-client.e2e.test.ts
T
mesalogo d80d42b032 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.
2026-10-06 15:40:18 +08:00

94 lines
6.6 KiB
TypeScript

// @vitest-environment node
import { spawn } from 'node:child_process'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { ResolvedRuntimeSettings } from '../runtime-settings-store'
import type { TerminalSnapshot } from '../../shared/terminal-contracts'
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', () => {
for (const runtime of ['continue', 'opencode'] as const) {
it(`${runtime} writes without a mode and starts its interactive PTY`, async () => {
const root = await mkdtemp(join(tmpdir(), 'native-client-e2e-'))
let launch: NativeTerminalLaunch | undefined
let pty: LocalTerminalSession | undefined
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 }),
fetcher: async (_url, options) => {
const request = JSON.parse(String(options?.body))
requests.push(request)
if (!request.stream) return new Response(JSON.stringify({ id: 'native-test', object: 'chat.completion', created: 1, model: profile.modelName, choices: [{ index: 0, message: { role: 'assistant', content: 'NATIVE_BRIDGE_OK' }, finish_reason: 'stop' }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }), { headers: { 'content-type': 'application/json' } })
const chunk = (delta: object, finish_reason: string | null) => `data: ${JSON.stringify({ id: 'native-test', object: 'chat.completion.chunk', created: 1, model: profile.modelName, choices: [{ index: 0, delta, finish_reason }] })}\n\n`
if (requests.length === 1) return new Response(chunk({ role: 'assistant', tool_calls: [{ index: 0, id: 'write-attempt', type: 'function', function: { name: runtime === 'continue' ? 'Bash' : 'bash', arguments: JSON.stringify({ command: 'echo NATIVE_WRITE > native-write-probe.txt', description: 'Write the test-owned marker' }) } }] }, null) + chunk({}, 'tool_calls') + 'data: [DONE]\n\n', { headers: { 'content-type': 'text/event-stream' } })
return new Response(chunk({ role: 'assistant', content: 'NATIVE_BRIDGE_OK' }, null) + chunk({}, 'stop') + 'data: [DONE]\n\n', { headers: { 'content-type': 'text/event-stream' } })
},
terminalManager: { create: async (_owner, _request, prepared) => {
launch = prepared
return { sessionId: 'native-test', state: 'running' } as TerminalSnapshot
} }
})
try {
await client.open(1, {
projectId: 'project', projectName: 'Native Test', directory: root, runtime,
settings: { continueBinaryPath: '', opencodeBinaryPath: '', continueConfigPath: '', continueModelProfile: profile, opencodeModelProfile: profile } as ResolvedRuntimeSettings
})
const spec = launch!.spawnSpec
spec.env.NODE_ENV = 'production'
delete spec.env.VITEST
delete spec.env.VITEST_WORKER_ID
delete spec.env.VITEST_POOL_ID
const output = await new Promise<string>((resolve, reject) => {
const child = spawn(spec.executable, [...spec.args, ...(runtime === 'continue' ? ['-p', 'Reply with NATIVE_BRIDGE_OK'] : ['--print-logs', 'run', 'Reply with NATIVE_BRIDGE_OK'])], { cwd: root, env: spec.env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let text = ''
const timeout = setTimeout(() => { child.kill(); reject(new Error(`Native CLI timeout: ${text.slice(-2000)}`)) }, 45_000)
child.stdout.on('data', data => { text += data })
child.stderr.on('data', data => { text += data })
child.on('error', error => { clearTimeout(timeout); reject(error) })
child.on('close', code => { clearTimeout(timeout); if (code === 0) resolve(text); else reject(new Error(`Native CLI exit ${code}: ${text.slice(-2000)}`)) })
})
expect(output, `requests: ${requests.length}; executable: ${spec.executable}; args: ${JSON.stringify(spec.args)}`).toContain('NATIVE_BRIDGE_OK')
expect(requests.length).toBeGreaterThan(0)
expect(requests.every(request => request.model === profile.modelName)).toBe(true)
if (runtime === 'continue') {
const tools = requests.flatMap(request => (request.tools ?? []) as Array<{ function: { name: string } }>).map(tool => tool.function.name)
expect(tools).toContain('Read')
expect(tools).toContain('Write')
}
expect((await stat(join(root, 'native-write-probe.txt'))).isFile()).toBe(true)
expect(requests.length).toBeGreaterThan(1)
pty = await LocalTerminalSession.create({ target: { type: 'project', projectId: 'project' }, targetLabel: 'Native Test', title: runtime, size: { cols: 100, rows: 30 }, projectDirectory: root, spawnSpec: spec })
expect(pty.snapshot().state).toBe('running')
const session = pty
await new Promise<void>((resolve, reject) => {
let text = ''
const timeout = setTimeout(() => { remove(); reject(new Error(`Interactive PTY did not become ready: ${text.slice(-1000)}`)) }, 15_000)
const remove = session.onEvent(event => {
session.acknowledge(event.sequence)
if (event.type === 'output') {
text += event.data
if (/Continue|OpenCode|opencode|Native Test Model|native-test-model/u.test(text)) { clearTimeout(timeout); resolve() }
}
if (event.type === 'exit') { clearTimeout(timeout); reject(new Error(`Interactive PTY exited: ${text.slice(-1000)}`)) }
})
})
} finally {
await pty?.close()
await launch?.dispose()
await rm(root, { recursive: true, force: true })
}
}, 70_000)
}
})