# Conflicts: # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md # .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md # .agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml # .agents/notes/implemented/feature/2026-06-30-interception-seams.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.i18n.yaml # docs/architecture.i18n.yaml # docs/cookbook/adding-a-tool.i18n.yaml # docs/cookbook/extension-cookbook.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/session.i18n.yaml # docs/core-data-structures/tools.i18n.yaml # docs/event-producer-consumer.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/permission-switching/session.jsonl # examples/acp-agent/tests/snapshots/plan-mode-reject/session.jsonl # examples/acp-agent/tests/snapshots/plan-mode/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # packages/context/session-reference/README.md # packages/core/agent-loop/tests/agent.spec.ts # packages/hooks/hooks-claude/tests/coverage-cases.ts # packages/host/runtime/tests/host-runtime.spec.ts # packages/llm/llm-retry/tests/retry.spec.ts # packages/session-persistence/session-persistence/src/coordinator.ts # packages/support/acp-snapshot/README.md # packages/support/acp-snapshot/src/normalize.ts # packages/ui/acp/acp-feature-support.md # packages/ui/acp/src/codec.ts # packages/ui/acp/src/index.ts # packages/ui/acp/tests/bridge.spec.ts # packages/ui/acp/tests/codec.spec.ts # packages/ui/acp/tests/config-options.spec.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/acp/tests/edges.spec.ts # packages/ui/acp/tests/stream-update.spec.ts # packages/ui/acp/tests/turns.spec.ts
86 lines
4.2 KiB
TypeScript
86 lines
4.2 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
|
|
|
describe('ACP connection ownership', () => {
|
|
let harness: BridgeHarness | undefined
|
|
|
|
afterEach(async () => {
|
|
await harness?.dispose()
|
|
harness = undefined
|
|
})
|
|
|
|
it('disposal cancels a running prompt and awaits agent teardown', async () => {
|
|
harness = await makeBridgeHarness({ script: ['hang'] })
|
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
|
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
|
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
|
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
|
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
|
|
|
await harness.acpFiber.dispose()
|
|
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
|
|
expect(agent.status).toBe('idle')
|
|
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
|
})
|
|
|
|
it('an ACP-only reload rejects new sessions before creating an orphan', async () => {
|
|
harness = await makeBridgeHarness()
|
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
|
await harness.acpFiber.dispose()
|
|
await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }))
|
|
.rejects.toThrow(/disposed/)
|
|
expect(harness.ctx.agents.list()).toHaveLength(0)
|
|
})
|
|
|
|
it('a client disconnect disposes every owned session without root-context disposal', async () => {
|
|
harness = await makeBridgeHarness({ script: ['hang'] })
|
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
|
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
|
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
|
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
|
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
|
|
|
await harness.closeClientTransport()
|
|
await harness.acpFiber.dispose()
|
|
expect(agent.status).toBe('idle')
|
|
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
|
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
|
|
})
|
|
|
|
it('a failed client transport still disposes every owned session', async () => {
|
|
harness = await makeBridgeHarness({ script: ['hang'] })
|
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
|
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
|
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
|
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
|
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
|
|
|
await harness.abortClientTransport()
|
|
await vi.waitFor(() => {
|
|
expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true)
|
|
})
|
|
expect(agent.status).toBe('idle')
|
|
})
|
|
|
|
it('disconnect and plugin disposal share one quiescence boundary', async () => {
|
|
harness = await makeBridgeHarness({ script: ['hang'] })
|
|
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
|
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
|
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
|
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
|
await vi.waitFor(() => { expect(agent.status).toBe('running') })
|
|
|
|
await Promise.all([harness.closeClientTransport(), harness.acpFiber.dispose()])
|
|
expect(agent.status).toBe('idle')
|
|
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
|
})
|
|
|
|
it('disposing a session-less bridge is idempotent', async () => {
|
|
harness = await makeBridgeHarness()
|
|
await Promise.all([harness.acpFiber.dispose(), harness.acpFiber.dispose()])
|
|
expect(harness.ctx.agents.list()).toHaveLength(0)
|
|
})
|
|
})
|