Files
goodbuddy/src/main/remote-agent/managed-remote-acp-runtime.test.ts
T

550 lines
16 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
const remoteHarness = vi.hoisted(() => ({
instances: [] as Array<{
options: {
identity: {
controllerGeneration: number
daemonBootIdAtOpen: string
}
channelFactory: (bindingId: string) => Promise<unknown>
}
dispose: ReturnType<typeof vi.fn>
beginDrain: ReturnType<typeof vi.fn>
forceShutdown: ReturnType<typeof vi.fn>
runCalls: number
}>,
runGate: undefined as Promise<void> | undefined
}))
vi.mock('../agent/acp-remote-runtime', () => ({
AcpRemoteRuntime: class {
readonly requiresToolApproval = false
readonly supportsScopedDataTools = false
readonly capability = 'chat'
readonly runtimeId = 'opencode'
readonly supportsToolExecution = true
readonly dispose = vi.fn(async () => undefined)
readonly beginDrain = vi.fn(async () => undefined)
readonly forceShutdown = vi.fn(async () => undefined)
runCalls = 0
constructor(
readonly options: {
identity: {
controllerGeneration: number
daemonBootIdAtOpen: string
}
channelFactory: (bindingId: string) => Promise<unknown>
}
) {
remoteHarness.instances.push(this)
}
async getStatus() {
return {
id: 'opencode',
label: 'OpenCode(远程托管)',
available: true
}
}
async *run(request: { requestId: string }) {
this.runCalls += 1
yield {
requestId: request.requestId,
type: 'status',
message: 'running'
}
await remoteHarness.runGate
yield {
requestId: request.requestId,
type: 'done',
sessionId: 'session-1'
}
}
async releaseConversation() {}
async waitForDrain() {}
}
}))
import {
MemoryRuntimeSessionBindingStore
} from '../agent/runtime-session-binding-store'
import type { SshExecutionSpaceDescriptor } from '../execution-space'
import {
createManagedRemoteAcpRuntime,
type ManagedRemoteAcpRuntimeOptions
} from './managed-remote-acp-runtime'
const digest = (value: string): string =>
`sha256:${value.repeat(64)}`
function descriptor(): SshExecutionSpaceDescriptor {
return {
kind: 'ssh',
hostId: 'host-1',
remoteRootPath: '/srv/project',
cacheIdentity: 'cache-1',
routeIdentity: 'route-1',
workspaceAccess: {
getIdentity: vi.fn(),
listDirectory: vi.fn(),
stat: vi.fn(),
readText: vi.fn(),
writeTextAtomic: vi.fn(),
search: vi.fn(),
getChanges: vi.fn(),
dispose: vi.fn()
}
}
}
function harness(
overrides: Partial<ManagedRemoteAcpRuntimeOptions> = {}
) {
remoteHarness.instances.length = 0
remoteHarness.runGate = undefined
const releaseConnection = vi.fn()
const workspaceValidate = vi.fn(async () => ({
handle: {
workspaceId: 'workspace-id',
workspaceIdentity: 'workspace-1',
canonicalDisplayPath: '/srv/project',
access: 'read-only' as const,
git: 'available' as const,
capabilities: ['list', 'stat', 'read-text', 'search'],
generation: 1
},
validatedAt: '2030-01-01T00:00:00.000Z'
}))
const workspaceClose = vi.fn(async (params: {
workspaceId: string
generation: number
}) => ({
...params,
closed: true as const
}))
const connection = {
identity: {
cacheKey: 'connection-cache',
hostId: 'host-1',
hostRevision: 2,
hostKeyGeneration: 3,
remoteUsername: 'alice',
installationId: 'agent-1',
binaryDigest: digest('a'),
protocolMajor: 1,
protocolMinor: 0
},
status: {
state: 'ready',
draining: false,
installationId: 'agent-1',
binaryDigest: digest('a'),
agentVersion: '1.0.0',
protocol: { major: 1, minor: 0 },
daemonBootId: 'boot-1',
platform: 'linux',
architecture: 'x64',
supervisor: 'detached-on-demand'
},
capabilities: capabilities(),
client: {
generation: 5,
request: vi.fn(async (method: string, params: unknown) => {
if (method === 'workspace/validate') {
return await workspaceValidate()
}
if (method === 'workspace/close') {
return await workspaceClose(
params as {
workspaceId: string
generation: number
}
)
}
throw new Error(`Unexpected method: ${method}`)
})
},
state: 'ready',
refreshCapabilities: vi.fn(async () => capabilities()),
reconnect: vi.fn(async () => undefined),
release: releaseConnection
}
const options = {
executionSpace: descriptor(),
selection: { provider: 'opencode' as const },
agentServices: {
connectionManager: {
acquire: vi.fn(async () => connection)
},
controllerState: {
getControllerId: vi.fn(async () => 'controller-1')
},
installationManager: {
activateInstalled: vi.fn(async () => ({
installationId: 'agent-1',
binaryDigest: digest('a'),
agentVersion: '1.0.0',
protocol: { major: 1, minor: 0 },
platform: 'linux',
architecture: 'x64',
supervisor: 'detached-on-demand'
}))
}
},
runtimeInstallationManager: {
activateInstalled: vi.fn(async () => ({
runtimeId: 'opencode',
runtimeVersion: '1.18.29',
bundleDigest: digest('b'),
manifestDigest: digest('d'),
runtimeAdapterDigest: digest('c'),
acpCapabilitiesDigest: digest('f'),
platform: 'linux',
architecture: 'x64'
}))
},
bindingStore: new MemoryRuntimeSessionBindingStore(),
modelBridge: {
policy: {
protocol: 'openai-responses' as const,
model: 'private-model',
modelProfileDigest: digest('9'),
supportsImageInput: false
},
profile: {
profileId: 'profile-1',
modelProfileDigest: digest('9'),
provider: 'openai',
baseUrl: 'https://provider.example/v1',
model: 'private-model',
protocol: 'openai-responses',
authentication: 'none',
capabilities: { imageInput: false },
limits: {
maximumOutputTokens: 4_096,
requestTimeoutMilliseconds: 60_000
}
}
},
...overrides
} as unknown as ManagedRemoteAcpRuntimeOptions
return {
options,
connection,
releaseConnection,
workspaceValidate,
workspaceClose
}
}
describe('createManagedRemoteAcpRuntime', () => {
it('accepts the current OpenCode model profile independently of persisted validation', async () => {
const fixture = harness({
selection: {
provider: 'opencode',
profileId: '00000000-0000-4000-8000-000000000099'
}
})
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
await expect(runtime.getStatus()).resolves.toMatchObject({
id: 'opencode',
available: true
})
await runtime.dispose()
})
it('rejects non-OpenCode providers before acquiring remote resources', async () => {
const fixture = harness({
selection: { provider: 'continue' }
})
await expect(
createManagedRemoteAcpRuntime(fixture.options)
).rejects.toThrow(/OpenCode Runtime/iu)
expect(
fixture.options.agentServices.connectionManager.acquire
).not.toHaveBeenCalled()
})
it('binds the current Agent and Runtime and owns its connection lease', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
await expect(runtime.getStatus()).resolves.toMatchObject({
id: 'opencode',
available: true
})
expect(fixture.workspaceValidate).toHaveBeenCalledOnce()
expect(
fixture.connection.refreshCapabilities
).toHaveBeenCalledOnce()
expect(
fixture.options.runtimeInstallationManager.activateInstalled
).toHaveBeenCalledWith('host-1', {
agentInstallationId: 'agent-1'
})
expect(
fixture.options.agentServices.connectionManager.acquire
).toHaveBeenCalledWith('host-1', expect.objectContaining({
requiredCapabilities: [
{ name: 'runtime/acp', exactVersion: 5, critical: true }
]
}))
await runtime.dispose()
await runtime.dispose()
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
})
it('uses the current Host-management transport identity after Host edits', async () => {
const fixture = harness()
fixture.connection.identity.hostRevision = 9
fixture.connection.identity.hostKeyGeneration = 10
fixture.connection.identity.remoteUsername = 'current-user'
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
expect(remoteHarness.instances[0]!.options.identity).toMatchObject({
hostRevision: 9,
hostKeyGeneration: 10
})
await runtime.dispose()
})
it('recreates the Runtime and reopens the Workspace after reconnect identity changes', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
await collect(runtime.run(request('request-1'), new AbortController().signal))
expect(remoteHarness.instances).toHaveLength(1)
expect(remoteHarness.instances[0]!.options.identity).toMatchObject({
controllerGeneration: 5,
daemonBootIdAtOpen: 'boot-1'
})
expect(fixture.workspaceValidate).toHaveBeenCalledTimes(1)
fixture.connection.state = 'offline'
vi.mocked(fixture.connection.reconnect).mockImplementationOnce(async () => {
fixture.connection.client.generation = 6
fixture.connection.status.daemonBootId = 'boot-2'
fixture.connection.state = 'ready'
})
await collect(runtime.run(request('request-2'), new AbortController().signal))
expect(fixture.connection.reconnect).toHaveBeenCalledOnce()
expect(remoteHarness.instances).toHaveLength(2)
expect(remoteHarness.instances[0]!.dispose).toHaveBeenCalledOnce()
expect(remoteHarness.instances[1]!.options.identity).toMatchObject({
controllerGeneration: 6,
daemonBootIdAtOpen: 'boot-2'
})
expect(fixture.workspaceValidate).toHaveBeenCalledTimes(2)
expect(fixture.workspaceClose).toHaveBeenCalledTimes(1)
await runtime.dispose()
expect(fixture.workspaceClose).toHaveBeenCalledTimes(2)
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
})
it('rejects a stale channel factory lease after a transport change', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
const staleFactory =
remoteHarness.instances[0]!.options.channelFactory
fixture.connection.client.generation = 6
fixture.connection.status.daemonBootId = 'boot-2'
await expect(staleFactory('binding-1')).rejects.toThrow(
/transport lease is stale/iu
)
expect(fixture.workspaceValidate).toHaveBeenCalledTimes(2)
await runtime.dispose()
})
it('recreates the Runtime when only the daemon boot identity changes', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
await collect(runtime.run(request('request-1'), new AbortController().signal))
fixture.connection.status.daemonBootId = 'boot-2'
await collect(runtime.run(request('request-2'), new AbortController().signal))
expect(remoteHarness.instances).toHaveLength(2)
expect(remoteHarness.instances[1]!.options.identity).toMatchObject({
controllerGeneration: 5,
daemonBootIdAtOpen: 'boot-2'
})
expect(fixture.workspaceValidate).toHaveBeenCalledTimes(2)
await runtime.dispose()
})
it('fails closed on reconnect during an active request without replaying it', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
let releaseGate = (): void => undefined
remoteHarness.runGate = new Promise<void>((resolve) => {
releaseGate = resolve
})
const first = runtime.run(
request('request-1'),
new AbortController().signal
)
await expect(first.next()).resolves.toMatchObject({
value: { type: 'status' },
done: false
})
fixture.connection.client.generation = 6
fixture.connection.status.daemonBootId = 'boot-2'
await expect(
runtime
.run(request('request-2'), new AbortController().signal)
.next()
).rejects.toThrow(/another Runtime request was active/iu)
expect(remoteHarness.instances).toHaveLength(1)
expect(remoteHarness.instances[0]!.runCalls).toBe(1)
releaseGate()
await collect(first)
await runtime.dispose()
})
it('abandons connected runtime ownership on application exit without remote cleanup', async () => {
const fixture = harness()
const runtime = await createManagedRemoteAcpRuntime(
fixture.options
)
let finishRun = (): void => undefined
remoteHarness.runGate = new Promise<void>((resolve) => {
finishRun = resolve
})
const stream = runtime.run(
request('request-detach'),
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
await runtime.detachForApplicationExit?.()
await runtime.dispose()
expect(remoteHarness.instances[0]!.beginDrain).not.toHaveBeenCalled()
expect(remoteHarness.instances[0]!.forceShutdown).not.toHaveBeenCalled()
expect(remoteHarness.instances[0]!.dispose).not.toHaveBeenCalled()
expect(fixture.workspaceClose).not.toHaveBeenCalled()
expect(fixture.releaseConnection).not.toHaveBeenCalled()
finishRun()
await collect(stream)
})
it('rejects capabilities that differ from the current Runtime', async () => {
const fixture = harness()
vi.mocked(
fixture.connection.refreshCapabilities
).mockResolvedValueOnce({
...capabilities(),
runtimes: [
{
...capabilities().runtimes[0]!,
bundleDigest: digest('9')
}
]
})
await expect(
createManagedRemoteAcpRuntime(fixture.options)
).rejects.toThrow(/current OpenCode Runtime/iu)
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
})
it('releases both leases when the live Agent identity is stale', async () => {
const fixture = harness()
fixture.connection.status.agentVersion = '2.0.0'
await expect(
createManagedRemoteAcpRuntime(fixture.options)
).rejects.toThrow(/connection is not current/iu)
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
})
it('rejects the obsolete Runtime ACP v1 capability', async () => {
const fixture = harness()
vi.mocked(
fixture.connection.refreshCapabilities
).mockResolvedValueOnce({
...capabilities(),
capabilities: [
{ name: 'runtime/acp', version: 1, critical: true }
]
})
await expect(
createManagedRemoteAcpRuntime(fixture.options)
).rejects.toThrow(/does not advertise/iu)
expect(fixture.releaseConnection).toHaveBeenCalledOnce()
})
})
function capabilities() {
return {
generation: 7,
capabilities: [
{ name: 'runtime/acp', version: 5, critical: true }
],
runtimes: [
{
runtimeId: 'opencode',
version: '1.18.29',
bundleDigest: digest('b'),
acpCapabilitiesDigest: digest('f'),
sessionLoad: true,
sessionResume: true
}
]
}
}
function request(requestId: string) {
return {
requestId,
conversationId: 'conversation-1',
prompt: 'hello',
workMode: 'ask' as const
}
}
async function collect<T>(
values: AsyncGenerator<T, void, void>
): Promise<T[]> {
const collected: T[] = []
for await (const value of values) {
collected.push(value)
}
return collected
}