Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content. Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation. Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit. Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
739 lines
23 KiB
TypeScript
739 lines
23 KiB
TypeScript
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<RuntimeEvent, void, void> {
|
|
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<AgentRuntime>((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<void>((resolve) => {
|
|
markStarted = resolve
|
|
})
|
|
const finish = new Promise<void>((resolve) => {
|
|
finishRun = resolve
|
|
})
|
|
const active = runtime()
|
|
active.value.run = async function* (
|
|
request: AgentExecutionRequest
|
|
): AsyncGenerator<RuntimeEvent, void, void> {
|
|
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<void>((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<AgentRuntime>((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()
|
|
})
|
|
})
|