import { describe, expect, it, vi } from 'vitest' const remoteHarness = vi.hoisted(() => ({ instances: [] as Array<{ options: { identity: { controllerGeneration: number daemonBootIdAtOpen: string } channelFactory: (bindingId: string) => Promise } dispose: ReturnType beginDrain: ReturnType forceShutdown: ReturnType runCalls: number }>, runGate: undefined as Promise | 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 } ) { 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 = {} ) { 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((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((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( values: AsyncGenerator ): Promise { const collected: T[] = [] for await (const value of values) { collected.push(value) } return collected }