Files
goodbuddy/src/main/agent/acp-remote-runtime.test.ts
T
mesalogo 561de29f00 feat: keep managed SSH prompts running on Hosts
Managed SSH prompts previously depended on Desktop-owned provider and transcript state, so accepted work could stop or become uncertain when the Desktop, network, or power disappeared. The Agent now owns accepted OpenCode prompt execution, model and tool rounds, cancellation, and the durable semantic transcript, while Desktop remains authoritative for permanent projects, conversations, tasks, and messages.

Recovered work attaches to the original conversation without replay. The project switcher also groups managed SSH projects by saved Host and shows live Agent connectivity beside each group without opening a connection merely to read status. Remote project subtitles now show only the path, and the Agent lock advances to 0.11.14 with Runtime ACP v4.

Release note: 托管 SSH 任务在桌面端、网络或电源中断后可继续在主机上运行,并能回到原会话精确恢复;项目菜单现按主机分组并显示实时连接状态。
2026-08-31 11:04:41 +08:00

3030 lines
84 KiB
TypeScript

import { createHash } from 'node:crypto'
import {
AgentSideConnection,
PROTOCOL_VERSION,
ndJsonStream,
type Agent,
type AgentCapabilities,
type PromptRequest,
type PromptResponse
} from '@agentclientprotocol/sdk'
import { describe, expect, it, vi } from 'vitest'
import { canonicalJson } from '../../shared/agent-protocol/canonical'
import type {
RemotePromptOperationAcceptance,
RemotePromptOperationPreparation
} from '../../shared/remote-agent-contracts'
import {
REMOTE_RUNTIME_LAUNCH_LIMITS
} from '../../shared/remote-runtime-launch-contracts'
import type { RuntimeEvent } from './runtime'
import {
AcpRemoteRuntime,
type AcpRemoteRuntimeOptions
} from './acp-remote-runtime'
import type {
RemoteModelBridgeOpenRequest,
RemoteModelBridgeSession,
RemoteCancellationEscalation,
RemotePromptOperationCompletion,
RemotePromptOperationCompletionResult,
RemotePromptOperationReconciliation,
RemotePromptOperationReconciliationResult,
RemoteRuntimeChannel
} from './remote-runtime-channel'
import { MemoryRuntimeSessionBindingStore } from './runtime-session-binding-store'
type FakeServer = {
channel: RemoteRuntimeChannel
client: AgentSideConnection
generation: { current: boolean }
close: ReturnType<typeof vi.fn>
setInboundPaused: ReturnType<typeof vi.fn>
prepare: ReturnType<
typeof vi.fn<
(
value: RemotePromptOperationPreparation
) => Promise<RemotePromptOperationAcceptance>
>
>
complete: ReturnType<
typeof vi.fn<
(
value: RemotePromptOperationCompletion
) => Promise<RemotePromptOperationCompletionResult>
>
>
reconcile: ReturnType<
typeof vi.fn<
(
value: RemotePromptOperationReconciliation
) => Promise<RemotePromptOperationReconciliationResult>
>
>
escalate: ReturnType<
typeof vi.fn<(value: RemoteCancellationEscalation) => Promise<void>>
>
}
function capabilityDigest(capabilities: AgentCapabilities): string {
return `sha256:${createHash('sha256')
.update(
canonicalJson({
protocolVersion: PROTOCOL_VERSION,
capabilities
})
)
.digest('hex')}`
}
const identity = {
controllerId: 'controller-1',
controllerGeneration: 1,
hostId: 'host-1',
hostRevision: 1,
hostKeyGeneration: 1,
workspaceIdentity: 'workspace-1',
agentInstallationId: 'installation-1',
daemonBootIdAtOpen: 'boot-1',
runtimeBundleDigest: `sha256:${'a'.repeat(64)}`,
runtimeAdapterDigest: `sha256:${'b'.repeat(64)}`
}
function acceptPrompt(
value: RemotePromptOperationPreparation
): RemotePromptOperationAcceptance {
return {
bindingId: value.bindingId,
operationId: value.operationId,
requestId: value.requestId,
workMode: value.workMode,
deadlineAt: value.deadlineAt,
acceptedAt: new Date().toISOString()
}
}
function fakeServer(
handlers: {
capabilities?: AgentCapabilities
advertisedAcpCapabilitiesDigest?: string
channelEpoch?: string
generation?: number
initialize?: Agent['initialize']
newSession?: Agent['newSession']
prompt?: (request: PromptRequest) => Promise<PromptResponse>
loadSession?: Agent['loadSession']
resumeSession?: Agent['resumeSession']
closeSession?: Agent['closeSession']
cancel?: Agent['cancel']
preparePrompt?: (
value: RemotePromptOperationPreparation
) => Promise<RemotePromptOperationAcceptance>
completePromptOperation?: (
value: RemotePromptOperationCompletion
) => Promise<RemotePromptOperationCompletionResult>
reconcilePromptOperation?: (
value: RemotePromptOperationReconciliation
) => Promise<RemotePromptOperationReconciliationResult>
openModelBridge?: (
value: RemoteModelBridgeOpenRequest
) => Promise<RemoteModelBridgeSession>
} = {}
): FakeServer {
const mainToAgent = new TransformStream<Uint8Array, Uint8Array>()
const agentToMain = new TransformStream<Uint8Array, Uint8Array>()
const generation = { current: true }
const close = vi.fn(async () => {})
const setInboundPaused = vi.fn(async () => {})
const prepare = vi.fn<
(
value: RemotePromptOperationPreparation
) => Promise<RemotePromptOperationAcceptance>
>(
handlers.preparePrompt ??
(async (value) => acceptPrompt(value))
)
const reconcile = vi.fn<
(
value: RemotePromptOperationReconciliation
) => Promise<RemotePromptOperationReconciliationResult>
>(
handlers.reconcilePromptOperation ??
(async () => ({
status: 'terminal',
terminalState: 'completed',
processTree: 'empty'
}))
)
const complete = vi.fn<
(
value: RemotePromptOperationCompletion
) => Promise<RemotePromptOperationCompletionResult>
>(
handlers.completePromptOperation ??
(async (value) => ({
...value,
status: 'completed',
processTree: 'running'
}))
)
const escalate = vi.fn<
(value: RemoteCancellationEscalation) => Promise<void>
>(async () => {})
const agentCapabilities = handlers.capabilities ?? {
loadSession: true,
sessionCapabilities: {
close: {},
resume: {}
}
}
const connection = new AgentSideConnection(
() =>
({
initialize:
handlers.initialize ??
(async () => ({
protocolVersion: PROTOCOL_VERSION,
agentCapabilities
})),
newSession:
handlers.newSession ??
(async () => ({ sessionId: 'session-1' })),
loadSession: handlers.loadSession ?? (async () => ({})),
resumeSession: handlers.resumeSession ?? (async () => ({})),
closeSession: handlers.closeSession ?? (async () => ({})),
prompt:
handlers.prompt ??
(async () => ({ stopReason: 'end_turn' })),
cancel: handlers.cancel ?? (async () => {})
}) as unknown as Agent,
ndJsonStream(agentToMain.writable, mainToAgent.readable)
)
const channel: RemoteRuntimeChannel = {
input: agentToMain.readable,
output: mainToAgent.writable,
generation: handlers.generation ?? 1,
channelEpoch: handlers.channelEpoch ?? '1',
advertisedAcpCapabilitiesDigest:
handlers.advertisedAcpCapabilitiesDigest ??
capabilityDigest(agentCapabilities),
capabilities: {
cancellationEscalation: true,
promptOperationReconciliation: true,
modelBridge: handlers.openModelBridge !== undefined
},
closed: new Promise(() => {}),
isCurrentGeneration: () => generation.current,
getBindingCursors: async () => ({
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
}),
preparePrompt: prepare,
...(handlers.openModelBridge === undefined
? {}
: { openModelBridge: handlers.openModelBridge }),
completePromptOperation: complete,
setInboundPaused,
escalateCancellation: escalate,
reconcilePromptOperation: reconcile,
close
}
return {
channel,
client: connection,
generation,
close,
setInboundPaused,
prepare,
complete,
reconcile,
escalate
}
}
function runtime(
server: FakeServer,
store = new MemoryRuntimeSessionBindingStore(),
cancellationGraceMs = 5,
overrides: Partial<AcpRemoteRuntimeOptions> = {}
): AcpRemoteRuntime {
return new AcpRemoteRuntime({
runtimeId: 'opencode',
label: 'Remote ACP',
workspacePath: '/workspace',
identity,
channel: server.channel,
bindingStore: store,
assertHostCurrent: () => {},
cancellationGraceMs,
operationTimeoutMs: overrides.operationTimeoutMs ?? 100,
...(overrides.promptTimeoutMs === undefined
? {}
: { promptTimeoutMs: overrides.promptTimeoutMs }),
usage: {
runtime: 'opencode',
provider: 'test',
model: 'fake'
},
...overrides
})
}
function factoryRuntime(
factory: (bindingId: string) => Promise<RemoteRuntimeChannel>,
store = new MemoryRuntimeSessionBindingStore(),
overrides: Partial<AcpRemoteRuntimeOptions> = {}
): AcpRemoteRuntime {
return new AcpRemoteRuntime({
runtimeId: 'opencode',
label: 'Remote ACP',
workspacePath: '/workspace',
identity,
channelFactory: factory,
bindingStore: store,
assertHostCurrent: () => {},
cancellationGraceMs: 5,
operationTimeoutMs: overrides.operationTimeoutMs ?? 100,
...(overrides.promptTimeoutMs === undefined
? {}
: { promptTimeoutMs: overrides.promptTimeoutMs }),
usage: {
runtime: 'opencode',
provider: 'test',
model: 'fake'
},
...overrides,
channel: undefined
})
}
async function collect(
stream: AsyncGenerator<RuntimeEvent, void, void>
): Promise<RuntimeEvent[]> {
const events: RuntimeEvent[] = []
for await (const event of stream) {
events.push(event)
}
return events
}
const request = {
requestId: '1c608898-ecb7-4081-8174-2b6a52f53c01',
conversationId: 'conversation-1',
prompt: 'hello',
workMode: 'execute' as const
}
const modelBridgePolicy = {
protocol: 'openai-responses' as const,
model: 'gpt-test',
modelProfileDigest: `sha256:${'d'.repeat(64)}`,
supportsImageInput: false
}
function fakeModelBridge(
value: RemoteModelBridgeOpenRequest,
index: number,
result = { clean: true, poisoned: false }
): RemoteModelBridgeSession & {
close: ReturnType<typeof vi.fn>
} {
const close = vi.fn(async () => result)
return {
version: 'goodbuddy-model-bridge-v1',
channelId: `model-channel-${index}`,
channelEpoch: String(100 + index),
policy: value.policy,
closed: new Promise(() => {}),
close
}
}
describe('AcpRemoteRuntime', () => {
it('budgets the complete ACP input stream, not only raw prompt bytes', async () => {
const server = fakeServer()
const instance = runtime(
server,
new MemoryRuntimeSessionBindingStore()
)
await collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
expect(server.prepare).toHaveBeenCalledWith(
expect.objectContaining({
budget: expect.objectContaining({
maximumInputBytes:
REMOTE_RUNTIME_LAUNCH_LIMITS.maximumPromptInputBytes
})
})
)
await instance.dispose()
})
it('closes a binding after a definitive prepare rejection so retry is safe', async () => {
const store = new MemoryRuntimeSessionBindingStore()
let rejectedBindingId = ''
const newSession = vi.fn(async () => ({ sessionId: 'session-1' }))
const rejected = fakeServer({
newSession,
preparePrompt: async (preparation) => {
rejectedBindingId = preparation.bindingId
throw Object.assign(
new Error(
'Remote Runtime runtime/preparePrompt was rejected (RPC -32000, process)'
),
{
remoteMethod: 'runtime/preparePrompt',
remoteRequestOutcome: 'rejected'
}
)
}
})
const first = runtime(rejected, store)
await expect(
collect(
first.run(
request,
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow('runtime/preparePrompt')
expect(newSession).not.toHaveBeenCalled()
await expect(store.getById(rejectedBindingId)).resolves.toMatchObject({
state: 'closed',
activePromptOperationId: undefined
})
await expect(
store.getByConversation(request.conversationId)
).resolves.toBeUndefined()
const retry = runtime(fakeServer(), store)
await expect(
collect(
retry.run(
{ ...request, requestId: 'prompt-retry' },
new AbortController().signal,
async () => 'once'
)
)
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'done' })
])
)
await retry.dispose()
})
it('allocates and cleans a fresh model bridge for each prompt on one ACP channel', async () => {
const bridges: ReturnType<typeof fakeModelBridge>[] = []
const openModelBridge = vi.fn(
async (value: RemoteModelBridgeOpenRequest) => {
const bridge = fakeModelBridge(value, bridges.length + 1)
bridges.push(bridge)
return bridge
}
)
const server = fakeServer({
openModelBridge,
preparePrompt: async (preparation) => {
expect(preparation.modelBridge).toMatchObject({
version: 'goodbuddy-model-bridge-v1',
policy: modelBridgePolicy
})
return acceptPrompt(preparation)
}
})
const store = new MemoryRuntimeSessionBindingStore()
const instance = runtime(server, store, 5, {
modelBridgePolicy
})
await collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
await collect(
instance.run(
{ ...request, requestId: 'prompt-2' },
new AbortController().signal,
async () => 'once'
)
)
expect(openModelBridge).toHaveBeenCalledTimes(2)
expect(bridges.map((bridge) => bridge.channelId)).toEqual([
'model-channel-1',
'model-channel-2'
])
expect(bridges.every((bridge) => bridge.close.mock.calls.length === 1))
.toBe(true)
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state: 'ready',
promptSequence: 1,
modelBridgeVersion: 'goodbuddy-model-bridge-v1',
modelBridgePolicy
})
await instance.dispose()
})
it('closes and replaces an idle binding under a different model policy', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const firstServer = fakeServer({
openModelBridge: async (value) => fakeModelBridge(value, 1)
})
const first = runtime(firstServer, store, 5, {
modelBridgePolicy
})
await collect(
first.run(
request,
new AbortController().signal,
async () => 'once'
)
)
const oldBinding = await store.getByConversation(
request.conversationId
)
const newSession = vi.fn(async () => ({ sessionId: 'session-2' }))
const loadSession = vi.fn(async () => ({}))
const secondServer = fakeServer({
openModelBridge: async (value) => fakeModelBridge(value, 2),
newSession,
loadSession
})
const second = runtime(secondServer, store, 5, {
modelBridgePolicy: {
...modelBridgePolicy,
model: 'different-model'
}
})
await collect(
second.run(
{ ...request, requestId: 'prompt-2' },
new AbortController().signal,
async () => 'once'
)
)
expect(newSession).toHaveBeenCalledOnce()
expect(loadSession).not.toHaveBeenCalled()
await expect(store.getById(oldBinding!.bindingId)).resolves.toMatchObject({
state: 'closed'
})
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
bindingId: expect.not.stringMatching(oldBinding!.bindingId),
acpSessionId: 'session-2',
state: 'ready',
modelBridgePolicy: expect.objectContaining({
model: 'different-model'
})
})
await Promise.all([first.dispose(), second.dispose()])
})
it('replaces an idle binding after Agent and Runtime identity changes', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const first = runtime(fakeServer(), store)
await collect(
first.run(
request,
new AbortController().signal,
async () => 'once'
)
)
const oldBinding = await store.getByConversation(
request.conversationId
)
await first.dispose()
const server = fakeServer({
newSession: async () => ({ sessionId: 'session-2' })
})
const bindingIds: string[] = []
const replacement = factoryRuntime(
async (bindingId) => {
bindingIds.push(bindingId)
return server.channel
},
store,
{
identity: {
...identity,
agentInstallationId: 'installation-2',
runtimeBundleDigest: `sha256:${'c'.repeat(64)}`
}
}
)
await collect(
replacement.run(
{ ...request, requestId: 'prompt-2' },
new AbortController().signal
)
)
expect(bindingIds).toHaveLength(1)
expect(bindingIds[0]).not.toBe(oldBinding?.bindingId)
await expect(store.getById(oldBinding!.bindingId)).resolves.toMatchObject({
state: 'closed'
})
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
bindingId: bindingIds[0],
acpSessionId: 'session-2',
state: 'ready',
agentInstallationId: 'installation-2',
runtimeBundleDigest: `sha256:${'c'.repeat(64)}`
})
await replacement.dispose()
})
it.each(['prompt-running', 'outcome-unknown'] as const)(
'retires a %s binding before starting an explicit later prompt',
async (state) => {
const store = new MemoryRuntimeSessionBindingStore()
const active = {
bindingId: 'binding-active',
...identity,
conversationId: request.conversationId,
runtimeId: 'opencode' as const,
acpSessionId: 'session-active',
acpCapabilitiesDigest: capabilityDigest({
loadSession: true,
sessionCapabilities: {
close: {},
resume: {}
}
}),
state,
activePromptOperationId: request.requestId,
promptSequence: 0,
channelEpoch: '1',
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
}
await store.put(active)
const replacement = fakeServer()
const factory = vi.fn(
async (bindingId: string) => {
expect(bindingId).not.toBe(active.bindingId)
return replacement.channel
}
)
const instance = factoryRuntime(factory, store, {
identity: {
...identity,
agentInstallationId: 'installation-2'
}
})
await collect(
instance.run(
{ ...request, requestId: 'prompt-2' },
new AbortController().signal
)
)
expect(factory).toHaveBeenCalledOnce()
expect(factory).not.toHaveBeenCalledWith(active.bindingId)
await expect(
store.getById(active.bindingId)
).resolves.toMatchObject({
bindingId: active.bindingId,
state: 'closed',
activePromptOperationId: undefined
})
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
bindingId: factory.mock.calls[0]![0],
state: 'ready',
activePromptOperationId: undefined
})
await instance.dispose()
}
)
it('replaces a cancelled outcome-unknown session before an immediate resend', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const first = fakeServer({
prompt: () => new Promise(() => {}),
reconcilePromptOperation: async () => ({
status: 'outcome-unknown',
processTree: 'unknown'
})
})
const replacementPrompt = vi.fn(
async () => ({ stopReason: 'end_turn' as const })
)
const second = fakeServer({
newSession: async () => ({ sessionId: 'session-2' }),
prompt: replacementPrompt
})
const servers = [first, second]
const factory = vi.fn(async () => servers.shift()!.channel)
const instance = factoryRuntime(factory, store)
const controller = new AbortController()
const cancelled = collect(
instance.run(request, controller.signal, async () => 'once')
)
await vi.waitFor(() => {
expect(first.prepare).toHaveBeenCalledOnce()
})
controller.abort(new Error('cancelled'))
await expect(cancelled).rejects.toThrow('结果未知')
const resentRequest = {
...request,
requestId: 'f10873fc-c528-48c1-818d-204a75588a48',
history: [
{ role: 'user' as const, content: 'remember marker' },
{ role: 'assistant' as const, content: 'GB_CANCEL_MARKER' }
],
prompt: 'what was the marker?'
}
await expect(
collect(
instance.run(
resentRequest,
new AbortController().signal,
async () => 'once'
)
)
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'done' })
])
)
expect(factory).toHaveBeenCalledTimes(2)
expect(first.close).toHaveBeenCalledOnce()
expect(replacementPrompt).toHaveBeenCalledWith(
expect.objectContaining({
prompt: [
expect.objectContaining({
type: 'text',
text: expect.stringContaining('GB_CANCEL_MARKER')
})
]
})
)
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
acpSessionId: 'session-2',
state: 'ready',
activePromptOperationId: undefined
})
await instance.dispose()
})
it('keeps another conversation running while a cancelled one is resent', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const first = fakeServer({
prompt: () => new Promise(() => {}),
reconcilePromptOperation: async () => ({
status: 'outcome-unknown',
processTree: 'unknown'
})
})
let finishOtherPrompt:
| ((response: PromptResponse) => void)
| undefined
const otherPrompt = vi.fn(
() =>
new Promise<PromptResponse>((resolve) => {
finishOtherPrompt = resolve
})
)
const other = fakeServer({
newSession: async () => ({ sessionId: 'session-other' }),
prompt: otherPrompt
})
const replacement = fakeServer({
newSession: async () => ({ sessionId: 'session-replacement' })
})
const servers = [first, other, replacement]
const factory = vi.fn(async () => servers.shift()!.channel)
const instance = factoryRuntime(factory, store)
const controller = new AbortController()
const cancelled = collect(
instance.run(request, controller.signal, async () => 'once')
)
await vi.waitFor(() => {
expect(first.prepare).toHaveBeenCalledOnce()
})
const otherRequest = {
...request,
requestId: '8c7dcf59-ce69-4900-9f50-6312a2a19ca8',
conversationId: 'conversation-2'
}
const otherRun = collect(
instance.run(
otherRequest,
new AbortController().signal,
async () => 'once'
)
)
await vi.waitFor(() => {
expect(otherPrompt).toHaveBeenCalledOnce()
})
controller.abort(new Error('cancelled'))
await expect(cancelled).rejects.toThrow('结果未知')
await expect(
collect(
instance.run(
{
...request,
requestId: 'f10873fc-c528-48c1-818d-204a75588a48'
},
new AbortController().signal,
async () => 'once'
)
)
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'done' })
])
)
expect(other.close).not.toHaveBeenCalled()
finishOtherPrompt?.({ stopReason: 'end_turn' })
await expect(otherRun).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'done' })
])
)
await expect(
store.getByConversation(otherRequest.conversationId)
).resolves.toMatchObject({
acpSessionId: 'session-other',
state: 'ready'
})
expect(factory).toHaveBeenCalledTimes(3)
await instance.dispose()
})
it('injects Desktop history only when creating a new ACP session', async () => {
const prompt = vi.fn<
(request: PromptRequest) => Promise<PromptResponse>
>(
async () => ({
stopReason: 'end_turn' as const
})
)
const instance = runtime(fakeServer({ prompt }))
const history = [
{ role: 'user' as const, content: 'remember marker' },
{ role: 'assistant' as const, content: 'GB_HISTORY_MARKER' }
]
await collect(
instance.run(
{ ...request, history },
new AbortController().signal,
async () => 'once'
)
)
await collect(
instance.run(
{
...request,
requestId: 'f10873fc-c528-48c1-818d-204a75588a48',
prompt: 'continue'
},
new AbortController().signal,
async () => 'once'
)
)
const firstText = prompt.mock.calls[0]![0].prompt[0]
const secondText = prompt.mock.calls[1]![0].prompt[0]
expect(firstText).toMatchObject({
type: 'text',
text: expect.stringContaining('GB_HISTORY_MARKER')
})
expect(secondText).toEqual({ type: 'text', text: 'continue' })
await instance.dispose()
})
it('persists outcome-unknown and blocks reuse when model delivery is poisoned', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const openModelBridge = vi.fn(
async (value: RemoteModelBridgeOpenRequest) =>
fakeModelBridge(value, 1, {
clean: false,
poisoned: true
})
)
const instance = runtime(
fakeServer({ openModelBridge }),
store,
5,
{ modelBridgePolicy }
)
await expect(
collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow('结果未知')
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state: 'outcome-unknown',
activePromptOperationId: request.requestId
})
await expect(
collect(
instance.run(
{ ...request, requestId: 'prompt-2' },
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow()
expect(openModelBridge).toHaveBeenCalledOnce()
await instance.forceShutdown()
})
it('persists and prepares the binding before ACP initialization', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const order: string[] = []
const server = fakeServer({
initialize: async () => {
order.push('initialize')
return {
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: {
loadSession: true,
sessionCapabilities: {
close: {},
resume: {}
}
}
}
},
preparePrompt: async (preparation) => {
order.push('prepare')
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
bindingId: preparation.bindingId,
state: 'prompt-running',
activePromptOperationId: request.requestId,
acpCapabilitiesDigest:
capabilityDigest({
loadSession: true,
sessionCapabilities: {
close: {},
resume: {}
}
})
})
return acceptPrompt(preparation)
}
})
const instance = runtime(server, store)
await collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
expect(order).toEqual(['prepare', 'initialize'])
await instance.dispose()
})
it('rejects actual ACP capabilities that differ from signed evidence', async () => {
const server = fakeServer({
advertisedAcpCapabilitiesDigest:
`sha256:${'f'.repeat(64)}`
})
const instance = runtime(server)
await expect(
collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow('实际能力摘要')
expect(server.close).toHaveBeenCalledOnce()
})
it('does not send ACP initialization during a status check', async () => {
const initialize = vi.fn(async () => ({
protocolVersion: PROTOCOL_VERSION,
agentCapabilities: {
loadSession: true,
sessionCapabilities: {
close: {},
resume: {}
}
}
}))
const server = fakeServer({ initialize })
const instance = runtime(server)
await expect(instance.getStatus()).resolves.toMatchObject({
available: true
})
expect(initialize).not.toHaveBeenCalled()
await instance.dispose()
})
it('uses one fixed binding and channel per conversation', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const first = fakeServer()
const second = fakeServer({
newSession: async () => ({ sessionId: 'session-2' })
})
const servers = [first, second]
const bindingIds: string[] = []
const factory = vi.fn(async (bindingId: string) => {
bindingIds.push(bindingId)
return servers.shift()!.channel
})
const instance = factoryRuntime(factory, store)
const secondRequest = {
...request,
requestId: 'f10873fc-c528-48c1-818d-204a75588a48',
conversationId: 'conversation-2'
}
await Promise.all([
collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
),
collect(
instance.run(
secondRequest,
new AbortController().signal,
async () => 'once'
)
)
])
const firstBinding = await store.getByConversation(
request.conversationId
)
const secondBinding = await store.getByConversation(
secondRequest.conversationId
)
expect(new Set(bindingIds).size).toBe(2)
expect(bindingIds).toContain(firstBinding?.bindingId)
expect(bindingIds).toContain(secondBinding?.bindingId)
expect(first.prepare).toHaveBeenCalledWith(
expect.objectContaining({ bindingId: bindingIds[0] })
)
expect(second.prepare).toHaveBeenCalledWith(
expect.objectContaining({ bindingId: bindingIds[1] })
)
await instance.releaseConversation(request.conversationId)
expect(first.close).toHaveBeenCalledOnce()
expect(second.close).not.toHaveBeenCalled()
await instance.dispose()
expect(second.close).toHaveBeenCalledOnce()
})
it('bounds concurrent conversation channels until release', async () => {
const first = fakeServer()
const second = fakeServer()
const servers = [first, second]
const factory = vi.fn(async () => servers.shift()!.channel)
const instance = factoryRuntime(factory, undefined, {
maxConcurrentChannels: 1
})
await collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
const pending = collect(
instance.run(
{
...request,
requestId: '8c7dcf59-ce69-4900-9f50-6312a2a19ca8',
conversationId: 'conversation-2'
},
new AbortController().signal,
async () => 'once'
)
)
await new Promise((resolve) => setTimeout(resolve, 10))
expect(factory).toHaveBeenCalledOnce()
await instance.releaseConversation(request.conversationId)
await expect(pending).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'done' })
])
)
expect(factory).toHaveBeenCalledTimes(2)
await instance.dispose()
})
it('passes a durable binding identity back to the channel factory', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const first = fakeServer()
const firstIds: string[] = []
const initial = factoryRuntime(async (bindingId) => {
firstIds.push(bindingId)
return first.channel
}, store)
await collect(
initial.run(
request,
new AbortController().signal,
async () => 'once'
)
)
const durable = await store.getByConversation(request.conversationId)
await initial.dispose()
const loadSession = vi.fn(async () => ({}))
const second = fakeServer({
channelEpoch: '2',
generation: 2,
loadSession
})
const recoveredIds: string[] = []
const recovered = factoryRuntime(async (bindingId) => {
recoveredIds.push(bindingId)
return second.channel
}, store, {
identity: {
...identity,
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2'
}
})
await collect(
recovered.run(
request,
new AbortController().signal,
async () => 'once'
)
)
expect(firstIds).toEqual([durable?.bindingId])
expect(recoveredIds).toEqual([durable?.bindingId])
expect(loadSession).toHaveBeenCalledOnce()
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
bindingId: durable?.bindingId,
controllerGeneration: 2,
daemonBootIdAtOpen: 'boot-2',
channelEpoch: '2'
})
expect(second.prepare).toHaveBeenCalledWith(
expect.objectContaining({
controllerGeneration: 2,
connectionGeneration: 2,
channelEpoch: '2'
})
)
await recovered.dispose()
})
it('closes a channel when session opening fails and releases capacity', async () => {
const failed = fakeServer({
newSession: async () => {
throw new Error('open failed')
}
})
const succeeding = fakeServer()
const servers = [failed, succeeding]
const factory = vi.fn(async () => servers.shift()!.channel)
const instance = factoryRuntime(factory, undefined, {
maxConcurrentChannels: 1
})
await expect(
collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow('Internal error')
expect(failed.close).toHaveBeenCalledOnce()
await expect(
collect(
instance.run(
{
...request,
requestId: '07aac893-73cd-4ddd-85d5-19ee635ebf21',
conversationId: 'conversation-2'
},
new AbortController().signal,
async () => 'once'
)
)
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'done' })
])
)
expect(factory).toHaveBeenCalledTimes(2)
await instance.dispose()
})
it('starts the requested work mode before session and prompt', async () => {
const order: string[] = []
const server = fakeServer({
preparePrompt: async (value) => {
order.push(`prepare:${value.workMode}`)
return acceptPrompt(value)
},
newSession: async () => {
order.push('session')
return { sessionId: 'session-1' }
},
prompt: async () => {
order.push('prompt')
return { stopReason: 'end_turn' }
}
})
await collect(
runtime(server).run(
{ ...request, workMode: 'ask' },
new AbortController().signal
)
)
expect(order).toEqual(['prepare:ask', 'session', 'prompt'])
expect(server.prepare).toHaveBeenCalledWith(
expect.objectContaining({
operationId: request.requestId,
requestId: request.requestId,
workMode: 'ask',
controllerGeneration: 1,
connectionGeneration: 1,
channelEpoch: '1'
})
)
})
it('passes Execute directly as the authorization contract', async () => {
const server = fakeServer()
await collect(
runtime(server).run(
request,
new AbortController().signal,
async () => 'once'
)
)
expect(server.prepare).toHaveBeenCalledWith(
expect.objectContaining({
workMode: 'execute'
})
)
expect(server.prepare.mock.calls[0]?.[0]).not.toHaveProperty(
'trustTier'
)
expect(server.prepare.mock.calls[0]?.[0]).not.toHaveProperty(
'capabilities'
)
})
it('supports Ask and Execute without trust-tier translation', async () => {
const askServer = fakeServer()
const executeServer = fakeServer()
await collect(runtime(askServer).run(
{ ...request, workMode: 'ask' },
new AbortController().signal
))
await collect(runtime(executeServer).run(
request,
new AbortController().signal,
async () => 'once'
))
expect(askServer.prepare).toHaveBeenCalledWith(
expect.objectContaining({ workMode: 'ask' })
)
expect(executeServer.prepare).toHaveBeenCalledWith(
expect.objectContaining({ workMode: 'execute' })
)
})
it('rejects a stale Host binding before a new prompt', async () => {
const server = fakeServer()
const assertion = vi.fn(() => {
throw new Error('Host binding changed')
})
await expect(
collect(
runtime(server, undefined, undefined, {
assertHostCurrent: assertion
}).run(
request,
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow('Host binding changed')
expect(assertion).toHaveBeenCalledWith(
expect.objectContaining({ hostId: 'host-1' })
)
expect(server.prepare).not.toHaveBeenCalled()
expect(server.close).toHaveBeenCalledOnce()
})
it('rechecks the Host binding before resuming a prompt', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const firstServer = fakeServer()
const firstRuntime = runtime(firstServer, store)
await collect(
firstRuntime.run(
request,
new AbortController().signal,
async () => 'once'
)
)
await firstRuntime.dispose()
const loadSession = vi.fn(async () => ({}))
const secondServer = fakeServer({ loadSession })
const assertion = vi.fn(() => {
throw new Error('Host binding stale')
})
await expect(
collect(
runtime(secondServer, store, undefined, {
assertHostCurrent: assertion
}).run(
request,
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow('Host binding stale')
expect(assertion).toHaveBeenCalledWith(
expect.objectContaining({ hostId: 'host-1' })
)
expect(secondServer.prepare).not.toHaveBeenCalled()
expect(loadSession).not.toHaveBeenCalled()
expect(secondServer.close).toHaveBeenCalledOnce()
})
it('fails closed on a mismatched prompt acceptance before ACP session', async () => {
const newSession = vi.fn(async () => ({ sessionId: 'session-1' }))
const prompt = vi.fn(async () => ({ stopReason: 'end_turn' as const }))
const server = fakeServer({
preparePrompt: async (value) => ({
...acceptPrompt(value),
bindingId: 'different-binding',
}),
newSession,
prompt
})
await expect(
collect(
runtime(server).run(
request,
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow('稳定操作身份不匹配')
expect(newSession).not.toHaveBeenCalled()
expect(prompt).not.toHaveBeenCalled()
})
it('maps message, thought, tool, plan and usage events', async () => {
const client: { value?: AgentSideConnection } = {}
const server = fakeServer({
prompt: async ({ sessionId }) => {
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'answer' }
}
})
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'thinking' }
}
})
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call',
toolCallId: 'tool-1',
title: 'Read',
status: 'in_progress'
}
})
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'plan',
entries: [
{
content: 'Inspect',
priority: 'high',
status: 'in_progress'
}
]
}
})
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'usage_update',
used: 20,
size: 100
}
})
return {
stopReason: 'end_turn',
usage: {
inputTokens: 10,
outputTokens: 5,
totalTokens: 15
}
}
}
})
client.value = server.client
const events = await collect(
runtime(server).run(
request,
new AbortController().signal,
async () => 'once'
)
)
expect(events.map((event) => event.type)).toEqual([
'status',
'text',
'reasoning',
'tool',
'status',
'context-metrics',
'model-usage',
'done'
])
})
it('merges incremental OpenCode Task input into native subagent events', async () => {
const client: { value?: AgentSideConnection } = {}
const server = fakeServer({
prompt: async ({ sessionId }) => {
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call',
toolCallId: 'task-1',
title: 'Review application architecture',
kind: 'other',
status: 'in_progress',
rawInput: {}
}
})
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'task-1',
status: 'in_progress',
rawInput: {
subagent_type: 'explorer',
description: 'Review application architecture',
prompt: 'Inspect the complete source tree.'
}
}
})
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'task-1',
status: 'in_progress',
rawInput: {
subagent_type: 'explorer',
description: 'Review application architecture',
prompt: 'Inspect the complete source tree.'
}
}
})
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'task-1',
status: 'completed',
rawOutput: 'Architecture review complete.'
}
})
return { stopReason: 'end_turn' }
}
})
client.value = server.client
const events = await collect(
runtime(server).run(
request,
new AbortController().signal,
async () => 'once'
)
)
expect(events.map((event) => event.type)).toEqual([
'status',
'tool',
'subagent',
'subagent',
'done'
])
expect(events[2]).toMatchObject({
expertName: 'explorer',
routingMode: 'native',
runtimeCallId: 'task-1',
state: 'running'
})
expect(events[3]).toMatchObject({
state: 'completed',
output: 'Architecture review complete.'
})
})
it('bounds native subagent output per event and per request', async () => {
const client: { value?: AgentSideConnection } = {}
const server = fakeServer({
prompt: async ({ sessionId }) => {
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call',
toolCallId: 'task-bounded',
title: 'Bounded task',
kind: 'other',
status: 'completed',
rawInput: {
subagent_type: 'explorer',
prompt: 'Inspect the complete source tree.'
},
rawOutput: 'x'.repeat(100)
}
})
return { stopReason: 'end_turn' }
}
})
client.value = server.client
const events = await collect(
runtime(server, undefined, 5, {
maxEventCharacters: 10,
maxRequestOutputBytes: 1_000
}).run(
request,
new AbortController().signal,
async () => 'once'
)
)
expect(
events.find((event) => event.type === 'subagent')
).toMatchObject({
state: 'completed',
output: 'xxxxx…'
})
const overBudgetServer = fakeServer({
prompt: async ({ sessionId }) => {
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call',
toolCallId: 'task-over-budget',
title: 'Over-budget task',
kind: 'other',
status: 'completed',
rawInput: {
subagent_type: 'explorer',
prompt: 'Inspect the complete source tree.'
},
rawOutput: 'x'.repeat(100)
}
})
return { stopReason: 'end_turn' }
}
})
client.value = overBudgetServer.client
await expect(
collect(
runtime(overBudgetServer, undefined, 5, {
maxEventCharacters: 1_000,
maxRequestOutputBytes: 80
}).run(
{
...request,
requestId: 'b5b07fa5-f722-4bbc-ad19-33a6435817f1'
},
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow('远端 Runtime 请求输出超过安全上限')
})
it('rejects more than 100 distinct remote tool calls', async () => {
const client: { value?: AgentSideConnection } = {}
const server = fakeServer({
prompt: async ({ sessionId }) => {
for (let index = 0; index <= 100; index += 1) {
await client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'tool_call',
toolCallId: `call-${index}`,
title: 'tool',
status: 'in_progress'
}
})
}
return { stopReason: 'end_turn' }
}
})
client.value = server.client
await expect(
collect(
runtime(server).run(
request,
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow(
'远端 Runtime 单次运行的工具调用超过 100 个'
)
})
it('does not impose a default wall-clock deadline on remote prompts', async () => {
vi.useFakeTimers()
try {
let release!: () => void
const gate = new Promise<void>((resolve) => {
release = resolve
})
const server = fakeServer({
prompt: async () => {
await gate
return { stopReason: 'end_turn' }
}
})
const running = collect(
runtime(server).run(
request,
new AbortController().signal,
async () => 'once'
)
)
await vi.waitFor(() => expect(server.prepare).toHaveBeenCalledOnce())
await vi.advanceTimersByTimeAsync(25 * 60 * 60_000)
expect(server.escalate).not.toHaveBeenCalled()
release()
await expect(running).resolves.toEqual([
expect.objectContaining({ type: 'status' }),
expect.objectContaining({ type: 'done' })
])
} finally {
vi.useRealTimers()
}
})
it('routes Execute permission through the authorizer and denies mutating Ask permission', async () => {
const client: { value?: AgentSideConnection } = {}
const outcomes: string[] = []
const server = fakeServer({
prompt: async ({ sessionId }) => {
const response = await client.value!.requestPermission({
sessionId,
options: [
{
optionId: 'allow',
name: 'Allow',
kind: 'allow_once'
},
{
optionId: 'reject',
name: 'Reject',
kind: 'reject_once'
}
],
toolCall: {
toolCallId: 'tool-1',
title: 'Write',
kind: 'edit',
rawInput: { path: 'a.txt' }
}
})
outcomes.push(
response.outcome.outcome === 'selected'
? response.outcome.optionId
: 'cancelled'
)
return { stopReason: 'end_turn' }
}
})
client.value = server.client
const authorize = vi.fn(async () => 'once' as const)
await collect(
runtime(server).run(
request,
new AbortController().signal,
authorize
)
)
const askClient: { value?: AgentSideConnection } = {}
const askServer = fakeServer({
prompt: async ({ sessionId }) => {
const response = await askClient.value!.requestPermission({
sessionId,
options: [
{
optionId: 'ask-allow',
name: 'Allow',
kind: 'allow_always'
},
{
optionId: 'reject',
name: 'Reject',
kind: 'reject_once'
}
],
toolCall: {
toolCallId: 'tool-2',
title: 'Write',
kind: 'edit'
}
})
outcomes.push(
response.outcome.outcome === 'selected'
? response.outcome.optionId
: 'cancelled'
)
return { stopReason: 'end_turn' }
}
})
askClient.value = askServer.client
await collect(
runtime(askServer).run(
{ ...request, conversationId: 'conversation-2', workMode: 'ask' },
new AbortController().signal,
authorize
)
)
expect(authorize).toHaveBeenCalledOnce()
expect(outcomes).toEqual(['allow', 'reject'])
})
it('allows only one-shot read permission in Ask mode', async () => {
const client: { value?: AgentSideConnection } = {}
const outcomes: string[] = []
const server = fakeServer({
prompt: async ({ sessionId }) => {
for (const toolCall of [
{
toolCallId: 'tool-read',
title: 'Read',
kind: 'read' as const
},
{
toolCallId: 'tool-search',
title: 'Search',
kind: 'search' as const
}
]) {
const response = await client.value!.requestPermission({
sessionId,
options: [
{
optionId: `${toolCall.toolCallId}-once`,
name: 'Allow once',
kind: 'allow_once'
},
{
optionId: `${toolCall.toolCallId}-always`,
name: 'Allow always',
kind: 'allow_always'
},
{
optionId: `${toolCall.toolCallId}-reject`,
name: 'Reject',
kind: 'reject_once'
}
],
toolCall
})
outcomes.push(
response.outcome.outcome === 'selected'
? response.outcome.optionId
: 'cancelled'
)
}
const persistentRead =
await client.value!.requestPermission({
sessionId,
options: [
{
optionId: 'read-always',
name: 'Allow always',
kind: 'allow_always'
},
{
optionId: 'read-reject',
name: 'Reject',
kind: 'reject_once'
}
],
toolCall: {
toolCallId: 'tool-persistent-read',
title: 'Read',
kind: 'read'
}
})
outcomes.push(
persistentRead.outcome.outcome === 'selected'
? persistentRead.outcome.optionId
: 'cancelled'
)
return { stopReason: 'end_turn' }
}
})
client.value = server.client
const authorize = vi.fn(async () => 'once' as const)
await collect(
runtime(server).run(
{ ...request, workMode: 'ask' },
new AbortController().signal,
authorize
)
)
expect(authorize).not.toHaveBeenCalled()
expect(outcomes).toEqual([
'tool-read-once',
'tool-search-reject',
'read-reject'
])
})
it('auto-allows Execute permissions without an external authorizer', async () => {
const client: { value?: AgentSideConnection } = {}
const outcomes: string[] = []
const server = fakeServer({
prompt: async ({ sessionId }) => {
for (const options of [
[
{
optionId: 'once',
name: 'Allow once',
kind: 'allow_once' as const
},
{
optionId: 'always',
name: 'Allow always',
kind: 'allow_always' as const
},
{
optionId: 'reject',
name: 'Reject',
kind: 'reject_once' as const
}
],
[
{
optionId: 'once-only',
name: 'Allow once',
kind: 'allow_once' as const
}
]
]) {
const response = await client.value!.requestPermission({
sessionId,
options,
toolCall: {
toolCallId: `tool-${outcomes.length + 1}`,
title: 'Write'
}
})
outcomes.push(
response.outcome.outcome === 'selected'
? response.outcome.optionId
: 'cancelled'
)
}
return { stopReason: 'end_turn' }
}
})
client.value = server.client
await collect(
runtime(server).run(
request,
new AbortController().signal
)
)
expect(outcomes).toEqual(['always', 'once-only'])
})
it('preserves an explicit Execute authorizer denial', async () => {
const client: { value?: AgentSideConnection } = {}
let outcome = ''
const server = fakeServer({
prompt: async ({ sessionId }) => {
const response = await client.value!.requestPermission({
sessionId,
options: [
{
optionId: 'always',
name: 'Allow always',
kind: 'allow_always'
},
{
optionId: 'reject',
name: 'Reject',
kind: 'reject_once'
}
],
toolCall: {
toolCallId: 'tool-denied',
title: 'Write'
}
})
outcome =
response.outcome.outcome === 'selected'
? response.outcome.optionId
: 'cancelled'
return { stopReason: 'end_turn' }
}
})
client.value = server.client
const authorize = vi.fn(async () => 'deny' as const)
await collect(
runtime(server).run(
request,
new AbortController().signal,
authorize
)
)
expect(authorize).toHaveBeenCalledOnce()
expect(outcome).toBe('reject')
})
it('loads a durable ready binding on a new channel', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const claim = vi.spyOn(store, 'claimAcpSession')
const first = fakeServer()
await collect(
runtime(first, store).run(
request,
new AbortController().signal,
async () => 'once'
)
)
await runtime(first, store).dispose()
claim.mockClear()
const loadSession = vi.fn(async () => ({}))
const prompt = vi.fn<
(request: PromptRequest) => Promise<PromptResponse>
>(
async () => ({
stopReason: 'end_turn' as const
})
)
const second = fakeServer({ loadSession, prompt })
await collect(
runtime(second, store).run(
{
...request,
requestId: 'f10873fc-c528-48c1-818d-204a75588a48',
history: [
{
role: 'assistant',
content: 'GB_RESUMED_HISTORY_MARKER'
}
],
prompt: 'continue'
},
new AbortController().signal,
async () => 'once'
)
)
expect(loadSession).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'session-1' })
)
expect(claim).toHaveBeenCalledWith(
expect.any(String),
'session-1'
)
expect(prompt).toHaveBeenCalledWith(
expect.objectContaining({
prompt: [{ type: 'text', text: 'continue' }]
})
)
})
it('reconciles an accepted completion when its response is lost without replaying it', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const server = fakeServer({
completePromptOperation: async () => {
throw new Error('completion response lost')
},
reconcilePromptOperation: async () => ({
status: 'terminal',
terminalState: 'completed',
processTree: 'running'
})
})
await expect(
collect(
runtime(server, store).run(
request,
new AbortController().signal,
async () => 'once'
)
)
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'done' })
])
)
expect(server.complete).toHaveBeenCalledOnce()
expect(server.reconcile).toHaveBeenCalledOnce()
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state: 'ready',
activePromptOperationId: undefined
})
})
it('uses resume when loading history is unavailable', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const claim = vi.spyOn(store, 'claimAcpSession')
const capabilities = {
sessionCapabilities: { resume: {} }
}
const first = fakeServer({ capabilities })
const firstRuntime = runtime(first, store)
await collect(
firstRuntime.run(
request,
new AbortController().signal,
async () => 'once'
)
)
await firstRuntime.dispose()
claim.mockClear()
const resumeSession = vi.fn(async () => ({}))
const second = fakeServer({ capabilities, resumeSession })
await collect(
runtime(second, store).run(
request,
new AbortController().signal,
async () => 'once'
)
)
expect(resumeSession).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'session-1' })
)
expect(claim).toHaveBeenCalledWith(
expect.any(String),
'session-1'
)
})
it('rejects stale generations and escalates cancellation', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const server = fakeServer({
prompt: () => new Promise(() => {}),
reconcilePromptOperation: async () => ({
status: 'outcome-unknown',
processTree: 'unknown'
})
})
const instance = runtime(server, store)
const controller = new AbortController()
const pending = collect(
instance.run(request, controller.signal, async () => 'once')
)
await new Promise((resolve) => setTimeout(resolve, 10))
controller.abort(new Error('cancelled'))
await expect(pending).rejects.toThrow('结果未知')
expect(server.escalate).toHaveBeenCalledOnce()
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state: 'outcome-unknown',
activePromptOperationId: request.requestId
})
const stale = fakeServer()
stale.generation.current = false
await expect(stale.channel.isCurrentGeneration()).toBe(false)
await expect(
collect(
runtime(stale).run(
request,
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow('代际')
})
it('honors cancellation while opening a session before prompt', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const prompt = vi.fn(async () => ({ stopReason: 'end_turn' as const }))
const server = fakeServer({
newSession: () => new Promise(() => {}),
prompt,
reconcilePromptOperation: async () => ({
status: 'outcome-unknown',
processTree: 'unknown'
})
})
const instance = runtime(server, store)
const controller = new AbortController()
const pending = collect(
instance.run(request, controller.signal, async () => 'once')
)
await new Promise((resolve) => setTimeout(resolve, 10))
controller.abort(new Error('cancelled before prompt'))
await expect(pending).rejects.toThrow('cancelled before prompt')
expect(prompt).not.toHaveBeenCalled()
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state: 'outcome-unknown',
activePromptOperationId: request.requestId
})
expect(server.close).toHaveBeenCalledOnce()
})
it('bounds a prompt that never reaches a terminal response', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const cancel = vi.fn(async () => {})
const server = fakeServer({
prompt: () => new Promise(() => {}),
cancel,
reconcilePromptOperation: async () => ({
status: 'outcome-unknown',
processTree: 'unknown'
})
})
const instance = runtime(server, store, 5, {
operationTimeoutMs: 20,
promptTimeoutMs: 20
})
await expect(
collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
).rejects.toThrow('执行请求超时')
expect(cancel).toHaveBeenCalledOnce()
expect(server.escalate).toHaveBeenCalledOnce()
expect(server.close).toHaveBeenCalledOnce()
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state: 'outcome-unknown',
activePromptOperationId: request.requestId
})
})
it('allows prompts to outlive short control-operation timeouts', async () => {
const server = fakeServer({
prompt: async () => {
await new Promise((resolve) => setTimeout(resolve, 30))
return { stopReason: 'end_turn' as const }
}
})
const instance = runtime(server, undefined, 5, {
operationTimeoutMs: 10,
promptTimeoutMs: 100
})
await expect(
collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
).resolves.toEqual([
expect.objectContaining({
type: 'status',
message: '远端 ACP Runtime 正在处理请求'
}),
expect.objectContaining({
type: 'done'
})
])
})
it('cancels and truncates a flood when the consumer is slow', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const client: { value?: AgentSideConnection } = {}
const cancel = vi.fn(async () => {})
const server = fakeServer({
prompt: async ({ sessionId }) => {
await Promise.all(
Array.from({ length: 20 }, (_value, index) =>
client.value!.sessionUpdate({
sessionId,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: `chunk-${index}` }
}
})
)
)
return { stopReason: 'end_turn' }
},
cancel,
reconcilePromptOperation: async () => ({
status: 'outcome-unknown',
processTree: 'unknown'
})
})
client.value = server.client
const instance = runtime(server, store, 5, {
maxPendingUpdates: 2,
maxPendingUpdateBytes: 1_024
})
const stream = instance.run(
request,
new AbortController().signal,
async () => 'once'
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' },
done: false
})
await new Promise((resolve) => setTimeout(resolve, 10))
await expect(stream.next()).rejects.toThrow('输出已截断')
expect(server.setInboundPaused).toHaveBeenCalledWith(true)
expect(server.setInboundPaused).not.toHaveBeenCalledWith(false)
expect(cancel).toHaveBeenCalledOnce()
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state: 'outcome-unknown',
activePromptOperationId: request.requestId
})
})
it('keeps capability digests stable across object key order', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const first = fakeServer({
capabilities: {
sessionCapabilities: { resume: {}, close: {} },
loadSession: true
}
})
const firstRuntime = runtime(first, store)
await collect(
firstRuntime.run(
request,
new AbortController().signal,
async () => 'once'
)
)
await firstRuntime.dispose()
const loadSession = vi.fn(async () => ({}))
const second = fakeServer({
capabilities: {
loadSession: true,
sessionCapabilities: { close: {}, resume: {} }
},
loadSession
})
await collect(
runtime(second, store).run(
request,
new AbortController().signal,
async () => 'once'
)
)
expect(loadSession).toHaveBeenCalledOnce()
})
it('rejects duplicate Runtime-provided sessions across live conversations', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const first = fakeServer({
newSession: async () => ({ sessionId: 'shared-session' })
})
const second = fakeServer({
newSession: async () => ({ sessionId: 'shared-session' })
})
const outcomes = await Promise.allSettled([
collect(
runtime(first, store).run(
request,
new AbortController().signal,
async () => 'once'
)
),
collect(
runtime(second, store).run(
{ ...request, conversationId: 'conversation-2' },
new AbortController().signal,
async () => 'once'
)
)
])
expect(
outcomes.filter((outcome) => outcome.status === 'fulfilled')
).toHaveLength(1)
const rejected = outcomes.find(
(outcome): outcome is PromiseRejectedResult =>
outcome.status === 'rejected'
)
expect(String(rejected?.reason)).toContain(
'already owned by another live binding'
)
})
it('bounds release cleanup and persists interruption', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const closeSession = vi.fn(() => new Promise<Record<string, never>>(
() => {}
))
const server = fakeServer({ closeSession })
const instance = runtime(server, store, 5, {
operationTimeoutMs: 20
})
await collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
await expect(
instance.releaseConversation(request.conversationId)
).rejects.toThrow('关闭会话超时')
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({ state: 'interrupted' })
expect(server.close).toHaveBeenCalledOnce()
})
it('cancels and cleans up when event consumption ends early', async () => {
const store = new MemoryRuntimeSessionBindingStore()
let finishPrompt:
| ((response: PromptResponse) => void)
| undefined
const cancel = vi.fn(async () => {
finishPrompt?.({ stopReason: 'cancelled' })
})
const server = fakeServer({
prompt: () =>
new Promise<PromptResponse>((resolve) => {
finishPrompt = resolve
}),
cancel
})
const instance = runtime(server, store)
const stream = instance.run(
request,
new AbortController().signal,
async () => 'once'
)
await stream.next()
await stream.return()
expect(cancel).toHaveBeenCalledOnce()
expect(server.escalate).not.toHaveBeenCalled()
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state: 'ready',
activePromptOperationId: undefined
})
await instance.releaseConversation(request.conversationId)
await instance.beginDrain()
await instance.waitForDrain()
})
it('drains until session release and closes session resources', async () => {
const closeSession = vi.fn(async () => ({}))
const server = fakeServer({ closeSession })
const instance = runtime(server)
await collect(
instance.run(
request,
new AbortController().signal,
async () => 'once'
)
)
await instance.beginDrain()
let drained = false
void instance.waitForDrain().then(() => {
drained = true
})
await Promise.resolve()
expect(drained).toBe(false)
await instance.releaseConversation(request.conversationId)
await instance.waitForDrain()
expect(closeSession).toHaveBeenCalledOnce()
await instance.dispose()
expect(server.close).toHaveBeenCalledOnce()
})
it('force shutdown cancels by stable operation and preserves unknown identity', async () => {
const store = new MemoryRuntimeSessionBindingStore()
const server = fakeServer({
prompt: () => new Promise(() => {}),
reconcilePromptOperation: async () => ({
status: 'outcome-unknown',
processTree: 'unknown'
})
})
const instance = runtime(server, store, 5, {
operationTimeoutMs: 1_000
})
const stream = instance.run(
request,
new AbortController().signal,
async () => 'once'
)
await stream.next()
await instance.forceShutdown()
await expect(stream.next()).rejects.toThrow()
expect(server.escalate).toHaveBeenCalledWith(
expect.objectContaining({
operationId: request.requestId,
requestId: request.requestId
})
)
expect(server.reconcile).toHaveBeenCalledWith({
bindingId: expect.any(String),
operationId: request.requestId,
requestId: request.requestId
})
await expect(
store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state: 'outcome-unknown',
activePromptOperationId: request.requestId
})
})
})
describe('AcpRemoteRuntime Agent-owned prompts', () => {
const ownedModelProfile = {
profileId: 'profile-owned-1',
modelProfileDigest: `sha256:${'e'.repeat(64)}`,
provider: 'openai' as const,
baseUrl: 'https://provider.example/v1',
model: 'owned-model',
protocol: 'openai-responses' as const,
authentication: 'api-key' as const,
apiKey: 'agent-prompt-secret',
capabilities: { imageInput: false },
limits: {
maximumOutputTokens: 4_096,
maximumModelCalls: 100,
maximumTotalOutputTokens: 409_600,
requestTimeoutMilliseconds: 60_000
}
}
function ownedChannel(options?: {
state?: 'completed' | 'failed' | 'cancelled' | 'outcome-unknown'
terminalPayload?: unknown
includePermissionCheckpoint?: boolean
partialRecoveredTool?: boolean
waitForCancellation?: boolean
escalateCancellation?: () => Promise<void>
}) {
const terminalState = options?.state ?? 'completed'
const startOwnedPrompt = vi.fn(async (input: {
bindingId: string
operationId: string
requestId: string
}) => ({
bindingId: input.bindingId,
operationId: input.operationId,
requestId: input.requestId,
sessionId: 'owned-session',
state: 'running' as const,
latestSemanticSequence: '0'
}))
const attachOwnedPrompt = vi.fn(async (input: {
bindingId: string
operationId: string
requestId: string
}) => ({
...input,
sessionId: 'owned-session',
state: 'running' as const,
latestSemanticSequence: '0'
}))
const events = [
{
sequence: '1',
kind: 'session-update' as const,
payload: {
sessionId: 'owned-session',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'first model round' }
}
},
createdAt: 1
},
...(options?.includePermissionCheckpoint
? [
{
sequence: '2',
kind: 'permission-decision' as const,
payload: {
sessionId: 'owned-session',
toolCallId: 'approval-1',
outcome: { outcome: 'cancelled' }
},
createdAt: 2
}
]
: []),
{
sequence: options?.includePermissionCheckpoint ? '3' : '2',
kind: 'session-update' as const,
payload: {
sessionId: 'owned-session',
update: {
sessionUpdate: options?.partialRecoveredTool
? 'tool_call_update'
: 'tool_call',
toolCallId: 'tool-1',
...(options?.partialRecoveredTool
? {}
: { title: 'Read' }),
status: 'completed',
...(options?.partialRecoveredTool
? {}
: { rawOutput: { result: 'one' } })
}
},
createdAt: 3
},
{
sequence: options?.includePermissionCheckpoint ? '4' : '3',
kind: 'session-update' as const,
payload: {
sessionId: 'owned-session',
update: {
sessionUpdate: 'tool_call',
toolCallId: 'tool-2',
title: 'Write',
status: 'completed',
rawOutput: { result: 'two' }
}
},
createdAt: 4
},
{
sequence: options?.includePermissionCheckpoint ? '5' : '4',
kind: 'prompt-terminal' as const,
payload:
options?.terminalPayload ??
(terminalState === 'completed'
? {
status: 'completed',
response: {
stopReason: 'end_turn',
usage: {
inputTokens: 10,
outputTokens: 20,
totalTokens: 30
}
}
}
: terminalState === 'failed'
? {
status: 'failed',
error: { name: 'Error', message: 'owned failure' }
}
: { status: terminalState }),
createdAt: 5
}
]
const pageOwnedPromptTranscript = vi.fn(async ({
bindingId,
operationId,
afterSequence
}: {
bindingId: string
operationId: string
afterSequence: string
}) => {
if (options?.waitForCancellation === true) {
return await new Promise<never>(() => undefined)
}
return {
bindingId,
operationId,
events: events.filter(
(event) => BigInt(event.sequence) > BigInt(afterSequence)
),
latestSequence: events.at(-1)!.sequence,
acknowledgedSequence: '0',
state: terminalState,
sessionId: 'owned-session',
hasMore: false
}
})
const ackOwnedPromptTranscript = vi.fn(async ({
bindingId,
operationId,
acknowledgedSequence
}: {
bindingId: string
operationId: string
acknowledgedSequence: string
}) => ({
bindingId,
operationId,
acknowledgedSequence
}))
const preparePrompt = vi.fn(async (
preparation: RemotePromptOperationPreparation
) => acceptPrompt(preparation))
const channel: RemoteRuntimeChannel = {
input: new ReadableStream<Uint8Array>(),
output: new WritableStream<Uint8Array>(),
generation: 1,
channelEpoch: '1',
advertisedAcpCapabilitiesDigest: `sha256:${'f'.repeat(64)}`,
capabilities: {
cancellationEscalation: true,
promptOperationReconciliation: true,
ownedPrompt: true,
modelBridge: false
},
closed: new Promise(() => {}),
isCurrentGeneration: () => true,
getBindingCursors: async () => ({
lastOutboundJournaledSequence: '0',
lastOutboundDeliveredSequence: '0',
lastInboundJournaledSequence: '0',
lastMainAckSequence: '0'
}),
preparePrompt,
startOwnedPrompt,
attachOwnedPrompt,
pageOwnedPromptTranscript:
pageOwnedPromptTranscript as unknown as NonNullable<
RemoteRuntimeChannel['pageOwnedPromptTranscript']
>,
ackOwnedPromptTranscript,
completePromptOperation: vi.fn(async (value) => ({
...value,
status: 'completed' as const,
processTree: 'empty' as const
})),
setInboundPaused: vi.fn(async () => {}),
escalateCancellation: vi.fn(
options?.escalateCancellation ?? (async () => {})
),
reconcilePromptOperation: vi.fn(async () => ({
status: 'terminal' as const,
terminalState: 'completed' as const,
processTree: 'empty' as const
})),
close: vi.fn(async () => {})
}
const store = new MemoryRuntimeSessionBindingStore()
const instance = factoryRuntime(
async () => channel,
store,
{
modelBridgePolicy,
modelProfile: ownedModelProfile
}
)
return {
instance,
store,
channel,
preparePrompt,
startOwnedPrompt,
attachOwnedPrompt,
pageOwnedPromptTranscript,
ackOwnedPromptTranscript,
escalateCancellation: channel.escalateCancellation
}
}
it('starts once, maps multiple rounds, and ACKs only after consumption', async () => {
const fixture = ownedChannel({
includePermissionCheckpoint: true
})
const stream = fixture.instance.run(
request,
new AbortController().signal
)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
const first = await stream.next()
expect(first.value).toMatchObject({
type: 'text',
delta: 'first model round',
remoteProvenance: {
semanticSequence: '1',
eventIndex: 0
}
})
expect(fixture.ackOwnedPromptTranscript).not.toHaveBeenCalled()
const checkpoint = await stream.next()
expect(checkpoint.value).toMatchObject({
type: 'remote-semantic-checkpoint',
remoteProvenance: { semanticSequence: '1', eventIndex: 1 }
})
expect(fixture.ackOwnedPromptTranscript).not.toHaveBeenCalled()
const rest = await collect(stream)
expect(rest.filter((event) => event.type === 'tool')).toHaveLength(2)
expect(rest).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'model-usage' }),
expect.objectContaining({
type: 'done',
sessionId: 'owned-session'
})
])
)
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
expect(fixture.ackOwnedPromptTranscript.mock.calls.map(
([input]) => input.acknowledgedSequence
)).toEqual(['5'])
expect(fixture.preparePrompt).toHaveBeenCalledWith(
expect.objectContaining({
promptSequence: 0,
modelProfile: expect.objectContaining({
apiKey: 'agent-prompt-secret'
})
})
)
expect(
fixture.preparePrompt.mock.calls[0]![0]
).not.toHaveProperty('modelBridge')
})
it('freshly attaches an accepted prompt without resending it', async () => {
const fixture = ownedChannel()
const first = fixture.instance.run(
request,
new AbortController().signal
)
await first.next()
await first.return()
const attached = factoryRuntime(
async () => fixture.channel,
fixture.store,
{
modelBridgePolicy,
modelProfile: ownedModelProfile
}
)
const events = await collect(
attached.run(request, new AbortController().signal)
)
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'done' })
])
)
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
expect(fixture.attachOwnedPrompt).toHaveBeenCalledOnce()
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
})
it('does not abandon an accepted Agent prompt for a later request', async () => {
const fixture = ownedChannel({ waitForCancellation: true })
const first = fixture.instance.run(
request,
new AbortController().signal
)
await first.next()
await first.return()
const restarted = factoryRuntime(
async () => fixture.channel,
fixture.store,
{
modelBridgePolicy,
modelProfile: ownedModelProfile
}
)
await expect(
collect(
restarted.run(
{
...request,
requestId:
'1c608898-ecb7-4081-8174-2b6a52f53c02'
},
new AbortController().signal
)
)
).rejects.toThrow(/必须先恢复或取消原请求/u)
expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce()
expect(fixture.preparePrompt).toHaveBeenCalledOnce()
await restarted.dispose()
})
it('replaces an unrecoverable prompt binding after Agent identity changes', async () => {
const fixture = ownedChannel({ waitForCancellation: true })
const first = fixture.instance.run(
request,
new AbortController().signal
)
await first.next()
await first.return()
const stale = await fixture.store.getByConversation(
request.conversationId
)
const fresh = ownedChannel()
const replacement = factoryRuntime(
async () => fresh.channel,
fixture.store,
{
identity: {
...identity,
agentInstallationId: 'installation-2'
},
modelBridgePolicy,
modelProfile: ownedModelProfile
}
)
await expect(
collect(
replacement.run(
{
...request,
requestId:
'1c608898-ecb7-4081-8174-2b6a52f53c03'
},
new AbortController().signal
)
)
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'done' })
])
)
await expect(
fixture.store.getById(stale!.bindingId)
).resolves.toMatchObject({ state: 'closed' })
await Promise.all([
replacement.dispose(),
fresh.instance.dispose()
])
})
it('merges partial recovery tool updates with durable tool identity', async () => {
const fixture = ownedChannel({ partialRecoveredTool: true })
const events = await collect(
fixture.instance.run(
{
...request,
remoteRecoveredTools: [
{
callId: 'tool-1',
name: 'Original Read',
state: 'running',
summary: 'Read the original file',
input: '{"path":"/workspace/file.txt"}'
}
]
},
new AbortController().signal
)
)
expect(events).toContainEqual(
expect.objectContaining({
type: 'tool',
callId: 'tool-1',
name: 'Original Read',
state: 'completed',
input: '{"path":"/workspace/file.txt"}'
})
)
})
it('never starts a replacement prompt for attach-only recovery', async () => {
const fixture = ownedChannel()
const recoveryRequest = {
...request,
remoteRecoveryOnly: true,
remoteSemanticAfterSequence: '7'
}
await expect(
collect(
fixture.instance.run(
recoveryRequest,
new AbortController().signal
)
)
).rejects.toThrow(/不会重放任务/iu)
expect(fixture.preparePrompt).not.toHaveBeenCalled()
expect(fixture.startOwnedPrompt).not.toHaveBeenCalled()
expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled()
})
it('waits for the in-flight Agent cancellation before releasing its lease', async () => {
let releaseEscalation!: () => void
const escalation = new Promise<void>((resolve) => {
releaseEscalation = resolve
})
const fixture = ownedChannel({
waitForCancellation: true,
escalateCancellation: async () => await escalation
})
const controller = new AbortController()
const stream = fixture.instance.run(request, controller.signal)
await expect(stream.next()).resolves.toMatchObject({
value: { type: 'status' }
})
const pending = stream.next()
await vi.waitFor(() =>
expect(fixture.pageOwnedPromptTranscript).toHaveBeenCalledOnce()
)
controller.abort(new Error('cancel owned prompt'))
await vi.waitFor(() =>
expect(fixture.escalateCancellation).toHaveBeenCalledOnce()
)
let settled = false
void pending.then(
() => {
settled = true
},
() => {
settled = true
}
)
await new Promise<void>((resolve) => setImmediate(resolve))
expect(settled).toBe(false)
releaseEscalation()
await expect(pending).rejects.toThrow('cancel owned prompt')
})
it.each([
['cancelled', /请求已取消/iu],
['failed', /owned failure/iu],
['outcome-unknown', /终态未知/iu]
] as const)('maps the %s terminal exactly', async (state, message) => {
const fixture = ownedChannel({ state })
await expect(
collect(
fixture.instance.run(
request,
new AbortController().signal
)
)
).rejects.toThrow(message)
await expect(
fixture.store.getByConversation(request.conversationId)
).resolves.toMatchObject({
state:
state === 'outcome-unknown'
? 'outcome-unknown'
: 'ready'
})
})
})