import type { AgentExecutionRequest, AgentRuntime, RuntimeEvent } from './runtime' import { SelectedRuntimeManager } from './selected-runtime-manager' import { describe, expect, it, vi } from 'vitest' import type { ExecutionSpaceDescriptor } from '../execution-space' function executionSpace( cacheIdentity: string ): ExecutionSpaceDescriptor { return { kind: 'local', rootPath: cacheIdentity, cacheIdentity, routeIdentity: cacheIdentity, workspaceAccess: {} as ExecutionSpaceDescriptor['workspaceAccess'] } } function remoteExecutionSpace( hostId: string, cacheIdentity: string ): ExecutionSpaceDescriptor { return { kind: 'ssh', hostId, remoteRootPath: '/srv/project', cacheIdentity, routeIdentity: cacheIdentity, workspaceAccess: {} as ExecutionSpaceDescriptor['workspaceAccess'] } } function runtime() { const releaseConversation = vi.fn(async () => undefined) const dispose = vi.fn(async () => undefined) const testConnection = vi.fn(async () => ({ id: 'model' as const, label: 'model', available: true, supportsToolExecution: true, detail: 'ready' })) const getNativeSnapshot = vi.fn(async () => ({ provider: 'opencode' as const, available: true, inventoryStatus: 'available' as const, detail: 'ready', agents: [], tools: [], toolsSupported: true, commands: [], lsp: [], formatters: [], mcpServers: [], skills: [], rules: [], prompts: [], resources: [], resourcesSupported: true, context: { strategy: 'native' as const, manualCompact: true, detail: 'ready' } })) const value: AgentRuntime = { runtimeId: 'model', requiresToolApproval: false, supportsToolExecution: true, capability: 'chat', getStatus: vi.fn(async () => ({ id: 'model' as const, label: 'model', available: true, supportsToolExecution: true, detail: 'ready' })), testConnection, getNativeSnapshot, async *run( request: AgentExecutionRequest ): AsyncGenerator { yield { requestId: request.requestId, type: 'done' } }, releaseConversation, dispose } return { value, releaseConversation, dispose, testConnection, getNativeSnapshot } } describe('SelectedRuntimeManager', () => { it('caches one controller per runtime and profile selection', async () => { const first = runtime() const second = runtime() const create = vi .fn() .mockResolvedValueOnce(first.value) .mockResolvedValueOnce(second.value) const manager = new SelectedRuntimeManager(create) const [left, right] = await Promise.all([ manager.getRuntime({ provider: 'model', profileId: '00000000-0000-4000-8000-000000000001' }), manager.getRuntime({ provider: 'model', profileId: '00000000-0000-4000-8000-000000000001' }) ]) expect(left).toBe(right) expect(create).toHaveBeenCalledOnce() await manager.getRuntime({ provider: 'continue' }) expect(create).toHaveBeenCalledTimes(2) await manager.dispose() }) it('isolates cached runtimes by effective project workspace', async () => { const first = runtime() const second = runtime() const create = vi .fn() .mockResolvedValueOnce(first.value) .mockResolvedValueOnce(second.value) const manager = new SelectedRuntimeManager(create) const selection = { provider: 'opencode' as const } const firstSpace = executionSpace('local:project-one') const secondSpace = executionSpace('local:project-two') const projectOne = await manager.getRuntime(selection, firstSpace) const projectOneAgain = await manager.getRuntime( selection, executionSpace('local:project-one') ) const projectTwo = await manager.getRuntime(selection, secondSpace) expect(projectOneAgain).toBe(projectOne) expect(projectTwo).not.toBe(projectOne) expect(create).toHaveBeenNthCalledWith( 1, selection, firstSpace ) expect(create).toHaveBeenNthCalledWith( 2, selection, secondSpace ) await manager.dispose() }) it('retires cached runtimes when settings change', async () => { const first = runtime() const second = runtime() const create = vi .fn() .mockResolvedValueOnce(first.value) .mockResolvedValueOnce(second.value) const manager = new SelectedRuntimeManager(create) const selection = { provider: 'model' as const, profileId: '00000000-0000-4000-8000-000000000001' } await manager.getRuntime(selection) await manager.reset() expect(first.dispose).toHaveBeenCalledOnce() await manager.getRuntime(selection) expect(create).toHaveBeenCalledTimes(2) await manager.dispose() expect(second.dispose).toHaveBeenCalledOnce() }) it('retires only cached runtimes for an edited SSH Host', async () => { const firstHost = runtime() const secondHost = runtime() const local = runtime() const replacement = runtime() const create = vi .fn() .mockResolvedValueOnce(firstHost.value) .mockResolvedValueOnce(secondHost.value) .mockResolvedValueOnce(local.value) .mockResolvedValueOnce(replacement.value) const manager = new SelectedRuntimeManager(create) const selection = { provider: 'opencode' as const } const firstSpace = remoteExecutionSpace( 'host-one', 'ssh:host-one:revision-one' ) const secondSpace = remoteExecutionSpace( 'host-two', 'ssh:host-two:revision-one' ) const localSpace = executionSpace('local:project') const firstController = await manager.getRuntime( selection, firstSpace ) const secondController = await manager.getRuntime( selection, secondSpace ) const localController = await manager.getRuntime( selection, localSpace ) await manager.invalidateHost('host-one') expect(firstHost.dispose).toHaveBeenCalledOnce() expect(secondHost.dispose).not.toHaveBeenCalled() expect(local.dispose).not.toHaveBeenCalled() expect( await manager.getRuntime(selection, secondSpace) ).toBe(secondController) expect( await manager.getRuntime(selection, localSpace) ).toBe(localController) expect( await manager.getRuntime(selection, firstSpace) ).not.toBe(firstController) expect(create).toHaveBeenCalledTimes(4) await manager.dispose() expect(secondHost.dispose).toHaveBeenCalledOnce() expect(local.dispose).toHaveBeenCalledOnce() expect(replacement.dispose).toHaveBeenCalledOnce() }) it('disposes a connection-test runtime without caching it', async () => { const tested = runtime() const cached = runtime() const create = vi .fn() .mockResolvedValueOnce(tested.value) .mockResolvedValueOnce(cached.value) const manager = new SelectedRuntimeManager(create) const selection = { provider: 'opencode' as const } await expect(manager.testStatus(selection)).resolves.toMatchObject({ available: true }) expect(tested.testConnection).toHaveBeenCalledOnce() expect(tested.dispose).toHaveBeenCalledOnce() await manager.getRuntime(selection) expect(create).toHaveBeenCalledTimes(2) await manager.dispose() }) it('disposes a status-probe runtime without caching it', async () => { const probed = runtime() const cached = runtime() const create = vi .fn() .mockResolvedValueOnce(probed.value) .mockResolvedValueOnce(cached.value) const manager = new SelectedRuntimeManager(create) const selection = { provider: 'opencode' as const } await expect(manager.getStatus(selection)).resolves.toMatchObject({ available: true }) expect(probed.value.getStatus).toHaveBeenCalledOnce() expect(probed.testConnection).not.toHaveBeenCalled() expect(probed.dispose).toHaveBeenCalledOnce() await manager.getRuntime(selection) expect(create).toHaveBeenCalledTimes(2) await manager.dispose() expect(cached.dispose).toHaveBeenCalledOnce() }) it('uses a lightweight factory only for passive status probes', async () => { const probed = runtime() const tested = runtime() const create = vi.fn(async () => tested.value) const createStatus = vi.fn(async () => probed.value) const manager = new SelectedRuntimeManager( create, undefined, undefined, undefined, createStatus ) const selection = { provider: 'opencode' as const } await expect(manager.getStatus(selection)).resolves.toMatchObject({ available: true }) expect(createStatus).toHaveBeenCalledWith(selection) expect(create).not.toHaveBeenCalled() expect(probed.dispose).toHaveBeenCalledOnce() await expect(manager.testStatus(selection)).resolves.toMatchObject({ available: true }) expect(create).toHaveBeenCalledWith(selection) expect(tested.testConnection).toHaveBeenCalledOnce() expect(tested.dispose).toHaveBeenCalledOnce() await manager.dispose() }) it('serves the native inventory from the cached execution runtime', async () => { const shared = runtime() const create = vi.fn(async () => shared.value) const manager = new SelectedRuntimeManager(create) const selection = { provider: 'opencode' as const } await expect( manager.getNativeSnapshot( selection, executionSpace('local:project-one') ) ).resolves.toMatchObject({ provider: 'opencode', inventoryStatus: 'available' }) expect(shared.getNativeSnapshot).toHaveBeenCalledOnce() expect(shared.dispose).not.toHaveBeenCalled() const controller = await manager.getRuntime( selection, executionSpace('local:project-one') ) expect(create).toHaveBeenCalledOnce() await expect(controller.getStatus()).resolves.toMatchObject({ available: true }) await manager.getNativeSnapshot( selection, executionSpace('local:project-two') ) expect(create).toHaveBeenCalledTimes(2) await manager.dispose() expect(shared.dispose).toHaveBeenCalled() }) it('rejects the native inventory when the runtime lacks it', async () => { const plain = runtime() const withoutInventory: AgentRuntime = { ...plain.value } delete withoutInventory.getNativeSnapshot const manager = new SelectedRuntimeManager( vi.fn(async () => withoutInventory) ) await expect( manager.getNativeSnapshot({ provider: 'model', profileId: '00000000-0000-4000-8000-000000000001' }) ).rejects.toThrow('当前 Runtime 不支持原生能力清单') await manager.dispose() }) it('waits for a pending connection-test runtime during shutdown', async () => { let finishCreate!: (value: AgentRuntime) => void const pendingCreate = new Promise((resolve) => { finishCreate = resolve }) const tested = runtime() const manager = new SelectedRuntimeManager( vi.fn(async () => pendingCreate) ) const test = manager.testStatus({ provider: 'opencode' }) const disposal = manager.dispose() finishCreate(tested.value) await expect(test).rejects.toThrow('正在关闭') await disposal expect(tested.testConnection).not.toHaveBeenCalled() expect(tested.dispose).toHaveBeenCalledOnce() }) it('lets active work finish while settings changes retire its runtime', async () => { let markStarted!: () => void let finishRun!: () => void const started = new Promise((resolve) => { markStarted = resolve }) const finish = new Promise((resolve) => { finishRun = resolve }) const active = runtime() active.value.run = async function* ( request: AgentExecutionRequest ): AsyncGenerator { markStarted() await finish yield { requestId: request.requestId, type: 'done' } } const manager = new SelectedRuntimeManager( vi.fn(async () => active.value) ) const selection = { provider: 'model' as const, profileId: '00000000-0000-4000-8000-000000000001' } const controller = await manager.getRuntime(selection) const stream = controller.run( { requestId: '00000000-0000-4000-8000-000000000011', conversationId: 'conversation-one', prompt: 'keep working', }, new AbortController().signal ) const firstEvent = stream.next() await started await manager.reset() expect(active.dispose).not.toHaveBeenCalled() await expect( controller .run( { requestId: '00000000-0000-4000-8000-000000000012', conversationId: 'conversation-two', prompt: 'new work', }, new AbortController().signal ) .next() ).rejects.toThrow('正在关闭') finishRun() await expect(firstEvent).resolves.toEqual( expect.objectContaining({ value: expect.objectContaining({ type: 'done' }), done: false }) ) await stream.next() await vi.waitFor(() => expect(active.dispose).toHaveBeenCalledOnce() ) await manager.dispose() }) it('retires a drainable Runtime without forcing it and keeps routing conversation release', async () => { let finishDrain!: () => void const drain = new Promise((resolve) => { finishDrain = resolve }) const active = runtime() const beginDrain = vi.fn(async () => undefined) const waitForDrain = vi.fn(async () => await drain) const forceShutdown = vi.fn(async () => undefined) Object.assign(active.value, { beginDrain, waitForDrain, forceShutdown }) const manager = new SelectedRuntimeManager( vi.fn(async () => active.value) ) const controller = await manager.getRuntime({ provider: 'opencode' }) const stream = controller.run( { requestId: '00000000-0000-4000-8000-000000000021', conversationId: 'conversation-draining', prompt: 'finish normally', }, new AbortController().signal ) await stream.next() await stream.next() await manager.reset() expect(beginDrain).toHaveBeenCalledOnce() expect(forceShutdown).not.toHaveBeenCalled() expect(active.dispose).not.toHaveBeenCalled() await manager.releaseConversation('conversation-draining') expect(active.releaseConversation).toHaveBeenCalledWith( 'conversation-draining' ) finishDrain() await vi.waitFor(() => expect(active.dispose).toHaveBeenCalledOnce() ) expect(forceShutdown).not.toHaveBeenCalled() await manager.dispose() }) it('answers owned questions after settings reset while rejecting new work', async () => { const active = runtime() active.value.respondToQuestion = vi.fn(async () => undefined) active.value.run = async function* (request) { yield { requestId: request.requestId, type: 'question', questionId: 'retained-question', questions: [{ header: 'Choice', question: 'Continue?', options: [], multiple: false, custom: true }] } yield { requestId: request.requestId, type: 'done' } } const manager = new SelectedRuntimeManager(async () => active.value) const controller = await manager.getRuntime({ provider: 'opencode' }, executionSpace('project-one')) const stream = controller.run({ requestId: 'retained-request', conversationId: 'retained-conversation', prompt: 'Ask a question', }, new AbortController().signal) try { expect((await stream.next()).value?.type).toBe('question') await manager.reset() expect(active.dispose).not.toHaveBeenCalled() await expect(controller.respondToQuestion('unknown-question', [['Yes']])) .rejects.toThrow('正在关闭') await expect(controller.respondToQuestion('retained-question', [['Yes']])) .resolves.toBeUndefined() expect(active.value.respondToQuestion).toHaveBeenCalledWith('retained-question', [['Yes']]) await expect(controller.run({ requestId: 'new-request', conversationId: 'new-conversation', prompt: 'New work', }, new AbortController().signal).next()).rejects.toThrow('正在关闭') expect((await stream.next()).value?.type).toBe('done') await stream.next() await vi.waitFor(() => expect(active.dispose).toHaveBeenCalledOnce()) } finally { await stream.return() await manager.dispose() } }) it('routes application exit through cached runtime controllers without ordinary disposal', async () => { const active = runtime() const detachForApplicationExit = vi.fn(() => undefined) const beginDrain = vi.fn(async () => undefined) const forceShutdown = vi.fn(async () => undefined) Object.assign(active.value, { detachForApplicationExit, beginDrain, waitForDrain: vi.fn(async () => undefined), forceShutdown }) const manager = new SelectedRuntimeManager( vi.fn(async () => active.value) ) await manager.getRuntime( { provider: 'opencode' }, remoteExecutionSpace('host-one', 'ssh:host-one') ) await manager.detachForApplicationExit() await manager.dispose() expect(detachForApplicationExit).toHaveBeenCalledOnce() expect(beginDrain).not.toHaveBeenCalled() expect(forceShutdown).not.toHaveBeenCalled() expect(active.dispose).not.toHaveBeenCalled() }) it('evicts only the least-recently-used idle Runtime at the cache bound', async () => { const first = runtime() const second = runtime() const third = runtime() const create = vi .fn() .mockResolvedValueOnce(first.value) .mockResolvedValueOnce(second.value) .mockResolvedValueOnce(third.value) const manager = new SelectedRuntimeManager(create, 2) const selection = (suffix: string) => ({ provider: 'model' as const, profileId: `00000000-0000-4000-8000-0000000000${suffix}` }) const firstController = await manager.getRuntime(selection('31')) await manager.getRuntime(selection('32')) await expect( manager.getRuntime(selection('31')) ).resolves.toBe(firstController) await manager.getRuntime(selection('33')) await vi.waitFor(() => expect(second.dispose).toHaveBeenCalledOnce() ) expect(first.dispose).not.toHaveBeenCalled() expect(third.dispose).not.toHaveBeenCalled() await manager.dispose() }) it('bounds current and drain-retained Runtime controllers across resets', async () => { const created = [runtime(), runtime(), runtime()] for (const item of created) { Object.assign(item.value, { beginDrain: vi.fn(async () => undefined), waitForDrain: vi.fn(async () => await new Promise(() => undefined)), forceShutdown: vi.fn(async () => undefined) }) } const create = vi .fn() .mockResolvedValueOnce(created[0]!.value) .mockResolvedValueOnce(created[1]!.value) .mockResolvedValueOnce(created[2]!.value) const manager = new SelectedRuntimeManager(create, 1, 2) const selection = (suffix: string) => ({ provider: 'model' as const, profileId: `00000000-0000-4000-8000-0000000000${suffix}` }) await manager.getRuntime(selection('41')) await manager.reset() await manager.getRuntime(selection('42')) await manager.reset() await expect( manager.getRuntime(selection('43')) ).rejects.toThrow(/退役容量/iu) expect(create).toHaveBeenCalledTimes(3) expect(created[2]!.dispose).toHaveBeenCalledOnce() await manager.dispose() expect(created[0]!.dispose).toHaveBeenCalledOnce() expect(created[1]!.dispose).toHaveBeenCalledOnce() }) it('reserves retained capacity while an invalidated Runtime is still being created', async () => { let finishCreate!: (runtime: AgentRuntime) => void const pendingCreate = new Promise((resolve) => { finishCreate = resolve }) const created = runtime() const rejected = runtime() const create = vi.fn() .mockReturnValueOnce(pendingCreate) .mockResolvedValueOnce(rejected.value) const manager = new SelectedRuntimeManager(create, 1, 1) const selection = (suffix: string) => ({ provider: 'model' as const, profileId: `00000000-0000-4000-8000-0000000000${suffix}` }) const firstRuntime = manager .getRuntime(selection('51')) .catch((error: unknown) => error) const reset = manager.reset() await expect( manager.getRuntime(selection('52')) ).rejects.toThrow(/退役容量/iu) expect(create).toHaveBeenCalledTimes(2) expect(rejected.dispose).toHaveBeenCalledOnce() finishCreate(created.value) await expect(firstRuntime).resolves.toEqual( expect.objectContaining({ message: expect.stringMatching(/设置已更改/iu) }) ) await reset expect(created.dispose).toHaveBeenCalledOnce() await manager.dispose() }) it('releases a conversation from every selected runtime', async () => { const first = runtime() const second = runtime() const create = vi .fn() .mockResolvedValueOnce(first.value) .mockResolvedValueOnce(second.value) const manager = new SelectedRuntimeManager(create) await manager.getRuntime({ provider: 'model', profileId: '00000000-0000-4000-8000-000000000001' }) await manager.getRuntime({ provider: 'opencode' }) await manager.releaseConversation('conversation-one') expect(first.releaseConversation).toHaveBeenCalledWith( 'conversation-one' ) expect(second.releaseConversation).toHaveBeenCalledWith( 'conversation-one' ) await manager.dispose() }) it('uses only the resolver-owned execution-space cache identity', async () => { const create = vi.fn(async () => runtime().value) const manager = new SelectedRuntimeManager(create) const selection = { provider: 'opencode' as const } const remote = ( cacheIdentity: string ): ExecutionSpaceDescriptor => ({ kind: 'ssh', hostId: 'host-one', remoteRootPath: '/srv/project', cacheIdentity, routeIdentity: 'remote-route', workspaceAccess: {} as ExecutionSpaceDescriptor['workspaceAccess'] }) const baseline = await manager.getRuntime( selection, remote('resolver-identity-one') ) await expect( manager.getRuntime( selection, remote('resolver-identity-one') ) ).resolves.toBe(baseline) await expect( manager.getRuntime( selection, remote('resolver-identity-two') ) ).resolves.not.toBe(baseline) expect(create).toHaveBeenCalledTimes(2) await manager.dispose() }) })