feat(acp): ACP bridge — drive the coding agent from an editor over JSON-RPC stdio
Implements the RFC 010 MVP: a new `@deepseek-ai/dsh-acp` package bridges the harness agent to the Agent Client Protocol (JSON-RPC 2.0 over newline-delimited stdio), so Zed and other ACP editors can drive the coding agent — streaming render, tool-call display, and resumable sessions via `session/load`. - packages/acp: AgentSideConnection wiring; initialize/newSession/loadSession/ prompt/cancel; a total TurnEndReason→StopReason codec; settle-once with a fallback chain (agent/turn-end → logged turn/end → idle); single-session guard; cwd-must-equal-launch-dir validation; load replays from the persisted event log (assistant/chunk→agent_message_chunk, tool/call/result→tool_call*). - agent: add Agent.whenIdle() quiescence signal to the interface; LoopAgent implements it (resolves on the first running→idle/disposed transition). The bridge awaits it on disposal so teardown reaches quiescence, not just abort. - examples: extract the shared provider/tool core into examples/base.yml; coding-agent nest-includes it; new examples/acp-agent serves the agent over ACP with JSONL persistence and no stdout logger (stdout is the protocol). - Permission gate deferred (TODO(rfc010-permission-gate)): tools run with the executor's full authority; only the Agent→sessionId ownership seam is laid down. Cancel is best-effort for a not-yet-started queued turn (TODO(rfc010-cancel-prestep)). RFC 010 stays `proposed`. - Docs: package README + Zed snippet; client-driver cookbook section; root and packages layout/commands; RFC 010 implementation-status note. 48 bridge tests + whenIdle coverage; 100% per-file coverage; e2e boots the example as a subprocess and verifies a written file on disk (key-gated, with a no-key stdout-purity check).
This commit is contained in:
152
packages/acp/tests/bridge.spec.ts
Normal file
152
packages/acp/tests/bridge.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
/**
|
||||
* End-to-end bridge specs over an in-memory transport: a real
|
||||
* ClientSideConnection drives the bridge's AgentSideConnection, so every
|
||||
* assertion exercises actual JSON-RPC framing and the harness event taxonomy.
|
||||
*/
|
||||
describe('acp bridge', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
storageDir = await mkdtemp(join(tmpdir(), 'acp-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// e2e/integration tests own their resources (AGENTS.md): dispose even on
|
||||
// failure so a flaky run never leaks a context or persistence dir.
|
||||
if (harness) await harness.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('initialize negotiates the protocol version and advertises capabilities', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
const res = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
expect(res.protocolVersion).toBe(PROTOCOL_VERSION)
|
||||
expect(res.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(res.agentCapabilities?.promptCapabilities).toMatchObject({ image: false, audio: false })
|
||||
expect(res.agentInfo?.name).toBe('deepseek-harness-acp')
|
||||
})
|
||||
|
||||
it('session/new creates a session and a full prompt turn streams text then settles end_turn', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('hello there')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(sessionId).toBeTruthy()
|
||||
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] })
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
|
||||
// The streamed text arrived as agent_message_chunk updates.
|
||||
const text = harness.updates
|
||||
.filter(u => u.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
expect(text).toBe('hello there')
|
||||
})
|
||||
|
||||
it('rejects a second session/new (single-session MVP)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/single session/)
|
||||
})
|
||||
|
||||
it('rejects a non-absolute cwd and a cwd that differs from the launch dir', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.newSession({ cwd: 'relative/path', mcpServers: [] }))
|
||||
.rejects.toThrow(/absolute/)
|
||||
await expect(harness.client.newSession({ cwd: '/some/other/dir', mcpServers: [] }))
|
||||
.rejects.toThrow(/launch directory/)
|
||||
})
|
||||
|
||||
it('rejects non-empty additionalDirectories', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [], additionalDirectories: ['/x'] }))
|
||||
.rejects.toThrow(/additionalDirectories/)
|
||||
})
|
||||
|
||||
it('rejects an empty prompt without hanging', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: ' ' }] }))
|
||||
.rejects.toThrow(/empty prompt/)
|
||||
})
|
||||
|
||||
it('rejects image content in a prompt (text-only capabilities)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', mimeType: 'image/png', data: 'AA==' }],
|
||||
})).rejects.toThrow(/text/)
|
||||
})
|
||||
|
||||
it('rejects a prompt carrying a non-text block alongside text (no silent context loss)', async () => {
|
||||
// A text + resource_link prompt must be rejected, not run text-only with the
|
||||
// resource silently dropped — that would feed the model an incomplete prompt.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'text', text: 'fix the bug in' },
|
||||
{ type: 'resource_link', uri: 'file:///x.ts', name: 'x.ts' },
|
||||
],
|
||||
})).rejects.toThrow(/text/)
|
||||
})
|
||||
|
||||
it('rejects a prompt for an unknown session', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.prompt({ sessionId: 'nope', prompt: [{ type: 'text', text: 'hi' }] }))
|
||||
.rejects.toThrow(/unknown session/)
|
||||
})
|
||||
|
||||
it('negotiates an unsupported protocol version down to the supported one', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
const res = await harness.client.initialize({ protocolVersion: 999, clientCapabilities: {} })
|
||||
expect(res.protocolVersion).toBe(PROTOCOL_VERSION)
|
||||
})
|
||||
|
||||
it('a cancel for an unknown/absent session is a silent no-op', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// No session created yet — cancel must not throw.
|
||||
await expect(harness.client.cancel({ sessionId: 'nope' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('authenticate is a no-op (no auth methods advertised)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.authenticate({ methodId: 'whatever' })).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('honors agentName/agentVersion/systemPrompt config', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { agentName: 'custom-agent', agentVersion: '9.9.9', systemPrompt: 'be terse' },
|
||||
})
|
||||
const res = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
expect(res.agentInfo).toMatchObject({ name: 'custom-agent', version: '9.9.9' })
|
||||
// Create + prompt so the systemPrompt config flows through agentOptions and
|
||||
// reaches the model request.
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] })
|
||||
expect(harness.adapter.requests[0]?.system).toContain('be terse')
|
||||
})
|
||||
})
|
||||
65
packages/acp/tests/codec.spec.ts
Normal file
65
packages/acp/tests/codec.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
acpPromptToText,
|
||||
harnessBlockToAcpContent,
|
||||
promptHasUnsupportedContent,
|
||||
turnEndToStopReason,
|
||||
} from '../src/codec.ts'
|
||||
|
||||
describe('turnEndToStopReason', () => {
|
||||
// The SDK rejects an unknown stopReason, so this must be total over every
|
||||
// TurnEndReason kind and always produce a legal wire value.
|
||||
it('maps every known TurnEndReason kind to a legal StopReason', () => {
|
||||
expect(turnEndToStopReason({ kind: 'completed' })).toBe('end_turn')
|
||||
expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens')
|
||||
expect(turnEndToStopReason({ kind: 'aborted', reason: 'x' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'error', message: 'boom' })).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('falls back to end_turn for an unknown (merge-extensible) future kind', () => {
|
||||
// A plugin-added TurnEndReason variant the bridge does not yet know about
|
||||
// must still produce a legal wire value, not throw into the SDK.
|
||||
const future = { kind: 'refusal' } as unknown as TurnEndReason
|
||||
expect(turnEndToStopReason(future)).toBe('end_turn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('harnessBlockToAcpContent', () => {
|
||||
it('maps a text block to ACP text content', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' })
|
||||
})
|
||||
|
||||
it('returns undefined for non-text blocks (reasoning/tool/image)', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'reasoning', text: 'think' })).toBeUndefined()
|
||||
expect(harnessBlockToAcpContent({ type: 'image', url: 'https://x/y.png', mimeType: 'image/png' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('acpPromptToText', () => {
|
||||
it('concatenates text blocks and ignores non-text', () => {
|
||||
const prompt: AcpContentBlock[] = [
|
||||
{ type: 'text', text: 'hello ' },
|
||||
{ type: 'resource_link', uri: 'file:///x', name: 'x' },
|
||||
{ type: 'text', text: 'world' },
|
||||
]
|
||||
expect(acpPromptToText(prompt)).toBe('hello world')
|
||||
})
|
||||
|
||||
it('returns empty string for a prompt with no text blocks', () => {
|
||||
expect(acpPromptToText([{ type: 'resource_link', uri: 'file:///x', name: 'x' }])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptHasUnsupportedContent', () => {
|
||||
it('detects image and audio blocks', () => {
|
||||
expect(promptHasUnsupportedContent([{ type: 'image', mimeType: 'image/png', data: 'AA==' }])).toBe(true)
|
||||
expect(promptHasUnsupportedContent([{ type: 'audio', mimeType: 'audio/wav', data: 'AA==' }])).toBe(true)
|
||||
})
|
||||
|
||||
it('passes a text-only prompt', () => {
|
||||
expect(promptHasUnsupportedContent([{ type: 'text', text: 'hi' }])).toBe(false)
|
||||
})
|
||||
})
|
||||
124
packages/acp/tests/dispose.spec.ts
Normal file
124
packages/acp/tests/dispose.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { makeBridgeHarness } from './harness.ts'
|
||||
|
||||
describe('acp bridge — disposal & HMR safety', () => {
|
||||
let storageDir: string
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-dispose-')) })
|
||||
afterEach(async () => { await rm(storageDir, { recursive: true, force: true }) })
|
||||
|
||||
it('disposal reaches quiescence: a running turn is aborted and awaited before dispose returns', async () => {
|
||||
const harness = await makeBridgeHarness({ storageDir, 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)!
|
||||
|
||||
// Start a prompt that hangs in the model stream.
|
||||
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
// Dispose the whole context. The bridge's teardown must abort the agent and
|
||||
// AWAIT whenIdle() — so right after dispose resolves, the agent is settled
|
||||
// (not still running). Proves disposal waited, not just requested.
|
||||
await harness.ctx.fiber.dispose()
|
||||
expect(agent.status).not.toBe('running')
|
||||
|
||||
// The in-flight prompt settled (cancelled) rather than hanging forever.
|
||||
const res = await promptDone
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => {
|
||||
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop
|
||||
// stay up and the transport is still live. A late session/new must hit the
|
||||
// `closed` guard and reject — NOT create an agent the disposed bridge can no
|
||||
// longer stream or settle. Verify the world: no agent appeared.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [], childFiber: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const before = harness.ctx.agents.list().length
|
||||
await harness.acpFiber.dispose() // tear down ONLY the bridge
|
||||
await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/disposed/)
|
||||
expect(harness.ctx.agents.list().length).toBe(before)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
|
||||
// After teardown (here a client disconnect sets `closed`), a late
|
||||
// `session/new` must NOT create an orphan agent the bridge can no longer
|
||||
// drive/settle. The transport is gone so the RPC rejects; assert the world:
|
||||
// no new agent appeared in the registry.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const before = harness.ctx.agents.list().length
|
||||
await harness.closeClientTransport() // teardown → closed = true
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }).catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(harness.ctx.agents.list().length).toBe(before)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('a client disconnect mid-prompt tears the session down to quiescence', async () => {
|
||||
// The ACP transport closes (editor quits) while a turn runs. The bridge must
|
||||
// settle the in-flight prompt cancelled and abort+drain the agent rather
|
||||
// than leaving an orphaned running agent whose updates are swallowed.
|
||||
const harness = await makeBridgeHarness({ storageDir, 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)!
|
||||
// Start a prompt that hangs in the model stream. The prompt RPC will never
|
||||
// return (its transport is severed), so do not await it.
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
// Sever the transport — the bridge's conn.closed teardown runs and drives
|
||||
// the agent to quiescence on its OWN (assert before any dispose() runs).
|
||||
await harness.closeClientTransport()
|
||||
await agent.whenIdle()
|
||||
expect(agent.status).toBe('idle')
|
||||
|
||||
await harness.dispose() // idempotent with the close teardown
|
||||
})
|
||||
|
||||
it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => {
|
||||
// conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously.
|
||||
// They must share one teardown promise: dispose() must NOT return before the
|
||||
// disconnect teardown's whenIdle() has settled (a `record === undefined`-only
|
||||
// guard would let the second caller return early mid-teardown).
|
||||
const harness = await makeBridgeHarness({ storageDir, 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)!
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
// Fire both teardown paths without awaiting the first, then await both.
|
||||
const close = harness.closeClientTransport()
|
||||
const dispose = harness.ctx.fiber.dispose()
|
||||
await Promise.all([close, dispose])
|
||||
// After BOTH settle, the agent has fully drained (not still running).
|
||||
expect(agent.status).not.toBe('running')
|
||||
})
|
||||
|
||||
it('after dispose, session/update listeners are gone (no further updates emitted)', async () => {
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const session = harness.ctx.agents.get(sessionId)!.session
|
||||
|
||||
await harness.ctx.fiber.dispose()
|
||||
const before = harness.updates.length
|
||||
// Append an event directly to the (now-detached) session: the bridge's
|
||||
// session/event listener should have been disposed, so no update fires.
|
||||
session.append('turn/start', { turn: 99, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(harness.updates.length).toBe(before)
|
||||
})
|
||||
})
|
||||
55
packages/acp/tests/edges.spec.ts
Normal file
55
packages/acp/tests/edges.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
describe('acp bridge — demux & config edges', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-edge-')) })
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('ignores events from an agent the bridge does not own (strict id demux)', async () => {
|
||||
// A second agent created directly on the registry (NOT via the bridge) runs
|
||||
// a turn. Its session/event + agent/status must NOT produce ACP updates and
|
||||
// must not settle anything — the bridge demuxes strictly by its own id.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const before = harness.updates.length
|
||||
|
||||
const foreign = harness.ctx.agents.create({ agentId: 'foreign', sessionId: 'foreign-session', agentOptions: { model: 'mock' } })
|
||||
foreign.send([{ type: 'text', text: 'hi' }])
|
||||
await foreign.whenIdle()
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
// No update was emitted for the foreign agent's stream.
|
||||
expect(harness.updates.length).toBe(before)
|
||||
})
|
||||
|
||||
it('survives a session/update that the client rejects (best-effort notify)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// Make the client reject every update — the bridge's notify() must swallow
|
||||
// the rejection and the prompt must still settle normally.
|
||||
harness.onSessionUpdateError = () => { throw new Error('client update rejected') }
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('accepts session/new with additionalDirectories empty', async () => {
|
||||
// Exercises the defined-but-empty additionalDirectories branch (length 0 → allowed).
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [], additionalDirectories: [] })
|
||||
expect(a.sessionId).toBeTruthy()
|
||||
})
|
||||
})
|
||||
241
packages/acp/tests/harness.ts
Normal file
241
packages/acp/tests/harness.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Shared test fixtures for the ACP bridge specs. A plain module (NOT a
|
||||
* *.spec.ts) so importing it does not re-register a describe block.
|
||||
*
|
||||
* `makeBridgeHarness` builds a full in-memory cordis context (llm + session +
|
||||
* system-prompt + tools + agents + agent-loop + persistence) with the ACP
|
||||
* bridge wired to an in-memory transport, plus a `ClientSideConnection` on the
|
||||
* other end — so a test drives the bridge exactly as an editor would, with no
|
||||
* subprocess and no real stdio.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type Stream,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import * as AcpPlugin from '../src/index.ts'
|
||||
import { type AcpConfig } from '../src/index.ts'
|
||||
|
||||
/** A scripted mock adapter (mirrors the agent-loop test adapter). */
|
||||
class MockAdapter extends LlmAdapter {
|
||||
requests: GenerateOptions[] = []
|
||||
constructor(private script: (StreamChunk[] | 'hang')[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('MockAdapter: script exhausted')
|
||||
if (entry === 'hang') {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'partial' }
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (options.signal?.aborted) { reject(new Error('aborted')); return }
|
||||
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
for (const chunk of entry) {
|
||||
if (options.signal?.aborted) throw new Error('aborted')
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Scripted text response ending in a clean `stop` finish. */
|
||||
export function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 5, outputTokens: text.length } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** Scripted response ending at the output-token ceiling (max-tokens finish). */
|
||||
export function maxTokensResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** Scripted response that fails mid-turn with a finish-error chunk. */
|
||||
export function errorResponse(message: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'partial' },
|
||||
{ type: 'finish', reason: { kind: 'error', message, code: 'PROVIDER_ERROR' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** Scripted single tool call (no follow-up step scripted by default). */
|
||||
export function toolCallResponse(rawCallId: string, name: string, args: object): StreamChunk[] {
|
||||
const argumentsJson = JSON.stringify(args)
|
||||
const id = CallId(rawCallId)
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id, name, argumentsDelta: argumentsJson },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: argumentsJson } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** A captured `session/update` notification (the update payload only). */
|
||||
export type CapturedUpdate = SessionNotification['update']
|
||||
|
||||
export interface BridgeHarness {
|
||||
ctx: Context
|
||||
client: ClientSideConnection
|
||||
adapter: MockAdapter
|
||||
/** Every `session/update` the bridge pushed, in order. */
|
||||
updates: CapturedUpdate[]
|
||||
/** Permission requests the bridge issued (none until the gate lands). */
|
||||
permissionRequests: RequestPermissionRequest[]
|
||||
/** Decide each permission request's outcome (default: cancelled). */
|
||||
onPermission: (req: RequestPermissionRequest) => RequestPermissionResponse
|
||||
/** If set, the client's sessionUpdate throws this (tests notify error path). */
|
||||
onSessionUpdateError: (() => void) | undefined
|
||||
/**
|
||||
* Sever the client→agent transport (close the writable the agent reads),
|
||||
* which ends the agent-side stream and resolves the bridge's `conn.closed` —
|
||||
* simulating an editor disconnecting. Returns once the close is requested.
|
||||
*/
|
||||
closeClientTransport: () => Promise<void>
|
||||
/**
|
||||
* The child fiber the ACP bridge is mounted in. Disposing it tears down JUST
|
||||
* the bridge (its `ctx.on` listeners + effect) while the rest of the harness
|
||||
* stays up — an ACP-only HMR reload.
|
||||
*/
|
||||
acpFiber: Awaited<ReturnType<Context['plugin']>>
|
||||
dispose: () => Promise<void>
|
||||
storageDir: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the bridge + a connected client over an in-memory transport pair.
|
||||
*
|
||||
* Two identity `TransformStream`s cross-wired (agent writes → client reads,
|
||||
* client writes → agent reads) give a faithful bidirectional JSON-RPC channel.
|
||||
* The bridge's `apply` receives the agent-side `Stream` via `config.stream`;
|
||||
* the test holds the `ClientSideConnection`.
|
||||
*
|
||||
* Pass `config: { model: undefined }` to override the default `model: 'mock'`
|
||||
* (the model key is dropped entirely when explicitly undefined).
|
||||
*/
|
||||
export async function makeBridgeHarness(options: {
|
||||
script?: (StreamChunk[] | 'hang')[]
|
||||
config?: Partial<AcpConfig>
|
||||
storageDir: string
|
||||
/** Mount the bridge in a disposable child fiber (for the ACP-only-HMR test). */
|
||||
childFiber?: boolean
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the
|
||||
// agent writes flow to the client's reader and vice versa. (ndJsonStream
|
||||
// takes (output, input): the agent writes to a2c and reads from c2a; the
|
||||
// client writes to c2a and reads from a2c.) The client→agent path (c2a) runs
|
||||
// through a hand-held writer so a test can close it (`closeClientTransport`)
|
||||
// to simulate the editor disconnecting — closing it EOFs the agent's reader
|
||||
// and resolves the bridge's `conn.closed`.
|
||||
const a2c = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const c2a = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const c2aWriter = c2a.writable.getWriter()
|
||||
// A WritableStream the client writes into; each chunk is forwarded to the
|
||||
// held c2a writer. `closeClientTransport` closes that writer directly.
|
||||
const clientOutput = new WritableStream<Uint8Array>({
|
||||
write: chunk => c2aWriter.write(chunk),
|
||||
})
|
||||
|
||||
const agentStream: Stream = ndJsonStream(a2c.writable, c2a.readable)
|
||||
const clientStream: Stream = ndJsonStream(clientOutput, a2c.readable)
|
||||
|
||||
const updates: CapturedUpdate[] = []
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const harness: BridgeHarness = {
|
||||
ctx,
|
||||
adapter,
|
||||
updates,
|
||||
permissionRequests,
|
||||
onPermission: () => ({ outcome: { outcome: 'cancelled' } }),
|
||||
onSessionUpdateError: undefined,
|
||||
client: undefined as unknown as ClientSideConnection,
|
||||
acpFiber: undefined as unknown as BridgeHarness['acpFiber'],
|
||||
// Close the writable the CLIENT writes to (c2a) — its readable, which the
|
||||
// agent's ndJsonStream consumes, then EOFs cleanly, so the bridge's
|
||||
// `conn.closed` resolves and it sees the client disconnect. If the client
|
||||
// connection holds a writer lock on it, abort the connection's signal path
|
||||
// instead by closing through the underlying stream.
|
||||
closeClientTransport: async () => { await c2aWriter.close() },
|
||||
dispose: async () => { await ctx.fiber.dispose() },
|
||||
storageDir: options.storageDir,
|
||||
}
|
||||
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
updates.push(params.update)
|
||||
// Let a test force the bridge's notify() error path.
|
||||
if (harness.onSessionUpdateError) return Promise.reject(new Error('client update rejected'))
|
||||
return Promise.resolve()
|
||||
},
|
||||
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
permissionRequests.push(params)
|
||||
return Promise.resolve(harness.onPermission(params))
|
||||
},
|
||||
})
|
||||
|
||||
// Wire the bridge (agent side) and the client (test side). The test config
|
||||
// can override `model` (including to undefined): default to 'mock' unless the
|
||||
// caller explicitly set the key (even to undefined), so a `{ model: undefined }`
|
||||
// override means "no model at all".
|
||||
const cfg: AcpConfig = { stream: agentStream, ...options.config }
|
||||
if (!(options.config && 'model' in options.config)) cfg.model = 'mock'
|
||||
// By default apply the bridge directly on the root ctx (services ungated). For
|
||||
// the ACP-only-HMR test, `childFiber: true` mounts it in a CHILD fiber instead
|
||||
// so the test can dispose JUST the bridge while the rest of the harness stays
|
||||
// up — its disposer (`harness.acpFiber.dispose()`) tears down only the
|
||||
// bridge's listeners/effect. (Child-fiber service tracing gates the async
|
||||
// persistence path, so the load-replay tests use the default direct mount.)
|
||||
if (options.childFiber) {
|
||||
harness.acpFiber = await ctx.plugin({
|
||||
name: 'acp-test',
|
||||
inject: ['agents', 'sessions', 'sessionPersistence'],
|
||||
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
|
||||
})
|
||||
} else {
|
||||
AcpPlugin.apply(ctx, cfg)
|
||||
}
|
||||
harness.client = new ClientSideConnection(makeClient, clientStream)
|
||||
|
||||
return harness
|
||||
}
|
||||
130
packages/acp/tests/load.spec.ts
Normal file
130
packages/acp/tests/load.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
/** Concatenate the text of all agent_message_chunk updates. */
|
||||
function messageText(updates: CapturedUpdate[]): string {
|
||||
return updates
|
||||
.filter(u => u.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('acp bridge — session/load replay', () => {
|
||||
let storageDir: string
|
||||
let live: BridgeHarness | undefined
|
||||
let loader: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-load-')) })
|
||||
afterEach(async () => {
|
||||
if (live) await live.dispose()
|
||||
if (loader) await loader.dispose()
|
||||
live = loader = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('replays a persisted turn from the event log as session/update on load', async () => {
|
||||
// 1. Create a session and run one turn — persistence writes the event log.
|
||||
live = await makeBridgeHarness({ storageDir, script: [textResponse('remembered answer')] })
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'remember this' }] })
|
||||
// Dispose to flush + release; the on-disk log persists.
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
// 2. A fresh bridge loads the same session id and must replay the turn.
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res).toBeDefined()
|
||||
|
||||
// The replayed updates reconstruct the assistant text from the event log
|
||||
// (assistant/chunk → agent_message_chunk), NOT from deriveMessages.
|
||||
expect(messageText(loader.updates)).toBe('remembered answer')
|
||||
|
||||
// And the USER side of the turn replays too (user/message →
|
||||
// user_message_chunk), so the editor transcript shows both sides.
|
||||
const userText = loader.updates
|
||||
.filter(u => u.sessionUpdate === 'user_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
expect(userText).toBe('remember this')
|
||||
})
|
||||
|
||||
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
|
||||
// A session/load is mid-resume() when the client transport closes. The load
|
||||
// must NOT end up with a live registered agent for the connection that is
|
||||
// already gone. (The bridge's post-await `closed` guard backs this on real
|
||||
// stdio; here the SDK rejects the in-flight request on close — either way no
|
||||
// agent survives.) Stall persistence so resume() is pending across the close.
|
||||
live = await makeBridgeHarness({ storageDir, script: [textResponse('x')] })
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] })
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const realLoad = loader.ctx.sessionPersistence.load.bind(loader.ctx.sessionPersistence)
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((r) => { release = r })
|
||||
loader.ctx.sessionPersistence.load = async (id) => { await gate; return realLoad(id) }
|
||||
|
||||
const loadResult = loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
.then(() => 'resolved' as const, () => 'rejected' as const)
|
||||
await loader.closeClientTransport() // teardown sets `closed` while load is gated
|
||||
release() // resume() finishes AFTER teardown
|
||||
expect(await loadResult).toBe('rejected')
|
||||
// No live agent was installed for the closed connection.
|
||||
expect(loader.ctx.agents.get(sessionId)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects load when the persisted session cwd differs from the launch dir', async () => {
|
||||
// Seed a session on disk whose header.cwd is a DIFFERENT absolute path than
|
||||
// the server's launch dir, then load it requesting the launch cwd (so the
|
||||
// request-cwd check passes). The bridge must still reject on the persisted
|
||||
// header cwd — else it would replay that session while tools run here.
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
const otherCwd = '/some/other/workspace'
|
||||
await loader.ctx.sessionPersistence.create({
|
||||
version: 1, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd, updatedAt: 1,
|
||||
})
|
||||
await loader.ctx.sessionPersistence.append(SessionId('elsewhere'), [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
])
|
||||
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/created in \/some\/other\/workspace/)
|
||||
// The rejected load must NOT have constructed/registered a live agent (the
|
||||
// cwd is validated from persisted metadata BEFORE resume) — no leak.
|
||||
expect(loader.ctx.agents.get('elsewhere')).toBeUndefined()
|
||||
// And a fresh newSession still works (the connection is not wedged).
|
||||
const ok = await loader.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(ok.sessionId).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects load for a non-absolute or mismatched cwd', async () => {
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 's', cwd: 'rel', mcpServers: [] }))
|
||||
.rejects.toThrow(/absolute/)
|
||||
await expect(loader.client.loadSession({ sessionId: 's', cwd: '/other', mcpServers: [] }))
|
||||
.rejects.toThrow(/launch directory/)
|
||||
})
|
||||
|
||||
it('rejects load when a session already exists (single-session MVP)', async () => {
|
||||
live = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(live.client.loadSession({ sessionId: 'other', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/single session/)
|
||||
})
|
||||
})
|
||||
120
packages/acp/tests/properties.spec.ts
Normal file
120
packages/acp/tests/properties.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Property-based protocol-shape tests for the ACP update stream (RFC 001 →
|
||||
* ADR 0013 precedent). Fuzz arbitrary harness `SessionEvent` sequences through
|
||||
* the pure `streamSessionEventUpdate` translator and assert the invariants an
|
||||
* ACP client relies on:
|
||||
*
|
||||
* - every emitted update is a legal `SessionUpdate` variant;
|
||||
* - a `tool_call_update` for a given id is never emitted before a `tool_call`
|
||||
* for that id (the client must see the pending call before its completion);
|
||||
* - the translator is a pure function of the event (same event → same updates),
|
||||
* so live streaming and `session/load` replay produce identical streams.
|
||||
*
|
||||
* Pure-function fuzzing (no live loop) keeps these deterministic — a failure is
|
||||
* a real finding, not timing noise.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import { streamSessionEventUpdate } from '../src/index.ts'
|
||||
|
||||
const LEGAL_UPDATE_KINDS = new Set([
|
||||
'agent_message_chunk',
|
||||
'agent_thought_chunk',
|
||||
'tool_call',
|
||||
'tool_call_update',
|
||||
])
|
||||
|
||||
/**
|
||||
* Build a WELL-FORMED harness event sequence: a list of "actions" where a tool
|
||||
* result can only reference a call already opened earlier. This mirrors what
|
||||
* the loop actually appends (tool/call always precedes its tool/result), so the
|
||||
* ordering invariant is asserted over realistic logs, not arbitrary noise.
|
||||
*/
|
||||
type Action =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'reasoning'; text: string }
|
||||
| { kind: 'call'; id: string; name: string }
|
||||
| { kind: 'result'; idx: number; isError: boolean }
|
||||
| { kind: 'ignored' }
|
||||
|
||||
function actionsArb(): fc.Arbitrary<Action[]> {
|
||||
const action: fc.Arbitrary<Action> = fc.oneof(
|
||||
fc.string().map((text): Action => ({ kind: 'text', text })),
|
||||
fc.string().map((text): Action => ({ kind: 'reasoning', text })),
|
||||
fc.record({ id: fc.string({ minLength: 1 }), name: fc.string() }).map(({ id, name }): Action => ({ kind: 'call', id, name })),
|
||||
fc.record({ idx: fc.nat(), isError: fc.boolean() }).map(({ idx, isError }): Action => ({ kind: 'result', idx, isError })),
|
||||
fc.constant<Action>({ kind: 'ignored' }),
|
||||
)
|
||||
return fc.array(action, { maxLength: 30 })
|
||||
}
|
||||
|
||||
/** Lower well-formed actions into a harness event sequence. */
|
||||
function actionsToEvents(actions: Action[]): SessionEvent[] {
|
||||
const events: SessionEvent[] = []
|
||||
const openCalls: string[] = []
|
||||
for (const a of actions) {
|
||||
switch (a.kind) {
|
||||
case 'text':
|
||||
events.push({ type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: a.text } } })
|
||||
break
|
||||
case 'reasoning':
|
||||
events.push({ type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: a.text } } })
|
||||
break
|
||||
case 'call':
|
||||
openCalls.push(a.id)
|
||||
events.push({ type: 'tool/call', seq: 0, time: 0, data: { turn: 1, step: 1, callId: CallId(a.id), name: a.name, arguments: '{}' } })
|
||||
break
|
||||
case 'result': {
|
||||
// Only emit a result for an already-opened call (well-formedness).
|
||||
if (openCalls.length === 0) break
|
||||
const id = openCalls[a.idx % openCalls.length]!
|
||||
events.push({ type: 'tool/result', seq: 0, time: 0, data: { turn: 1, step: 1, callId: CallId(id), content: [], isError: a.isError } })
|
||||
break
|
||||
}
|
||||
case 'ignored':
|
||||
events.push({ type: 'turn/end', seq: 0, time: 0, data: { turn: 1, reason: { kind: 'completed' } } })
|
||||
break
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function runStream(events: SessionEvent[]): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update))
|
||||
return out
|
||||
}
|
||||
|
||||
describe('ACP update-stream invariants (property-based)', () => {
|
||||
it('every emitted update is a legal SessionUpdate variant', () => {
|
||||
fc.assert(fc.property(actionsArb(), (actions) => {
|
||||
for (const update of runStream(actionsToEvents(actions))) {
|
||||
expect(LEGAL_UPDATE_KINDS.has(update.sessionUpdate)).toBe(true)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
it('never emits a tool_call_update for an id before that id\'s tool_call', () => {
|
||||
fc.assert(fc.property(actionsArb(), (actions) => {
|
||||
const seenCall = new Set<string>()
|
||||
for (const update of runStream(actionsToEvents(actions))) {
|
||||
if (update.sessionUpdate === 'tool_call') {
|
||||
seenCall.add(update.toolCallId)
|
||||
} else if (update.sessionUpdate === 'tool_call_update') {
|
||||
expect(seenCall.has(update.toolCallId)).toBe(true)
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
it('is a pure function of the event (replay equals live)', () => {
|
||||
fc.assert(fc.property(actionsArb(), (actions) => {
|
||||
const events = actionsToEvents(actions)
|
||||
expect(runStream(events)).toEqual(runStream(events))
|
||||
}))
|
||||
})
|
||||
})
|
||||
109
packages/acp/tests/stream-update.spec.ts
Normal file
109
packages/acp/tests/stream-update.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import { streamSessionEventUpdate, agentOptions } from '../src/index.ts'
|
||||
|
||||
/** Collect the updates a single event produces. */
|
||||
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
streamSessionEventUpdate('s1', event, n => out.push(n.update))
|
||||
return out
|
||||
}
|
||||
|
||||
function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
|
||||
return { type, seq: 0, time: 0, data } as SessionEvent
|
||||
}
|
||||
|
||||
describe('streamSessionEventUpdate', () => {
|
||||
it('maps assistant/chunk text-delta to agent_message_chunk', () => {
|
||||
expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })))
|
||||
.toEqual([{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }])
|
||||
})
|
||||
|
||||
it('maps assistant/chunk reasoning-delta to agent_thought_chunk', () => {
|
||||
expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'mm' } })))
|
||||
.toEqual([{ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'mm' } }])
|
||||
})
|
||||
|
||||
it('produces no update for a non-text/reasoning chunk (e.g. block-start)', () => {
|
||||
expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } })))
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput', () => {
|
||||
const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }))
|
||||
expect(updates).toEqual([{
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'bash',
|
||||
kind: 'execute',
|
||||
status: 'in_progress',
|
||||
rawInput: { command: 'ls' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('infers tool kinds: read*/write*/edit*/other', () => {
|
||||
const kind = (name: string): unknown =>
|
||||
updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c'), name, arguments: '' }))[0]
|
||||
expect((kind('read_file') as { kind: string }).kind).toBe('read')
|
||||
expect((kind('write') as { kind: string }).kind).toBe('edit')
|
||||
expect((kind('edit_file') as { kind: string }).kind).toBe('edit')
|
||||
expect((kind('frobnicate') as { kind: string }).kind).toBe('other')
|
||||
})
|
||||
|
||||
it('falls back to the raw argument string when tool arguments are not JSON', () => {
|
||||
const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: 'not json' }))[0]
|
||||
expect((update as { rawInput: unknown }).rawInput).toBe('not json')
|
||||
})
|
||||
|
||||
it('maps tool/result to completed/failed tool_call_update with text content', () => {
|
||||
const ok = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }))
|
||||
expect(ok).toEqual([{
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'out' } }],
|
||||
}])
|
||||
const failed = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [], isError: true }))
|
||||
expect((failed[0] as { status: string }).status).toBe('failed')
|
||||
})
|
||||
|
||||
it('drops non-text tool-result content (text-only)', () => {
|
||||
const update = updatesFor(evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'),
|
||||
content: [{ type: 'image', url: 'https://x/y.png' }],
|
||||
isError: false,
|
||||
}))[0]
|
||||
expect((update as { content: unknown[] }).content).toEqual([])
|
||||
})
|
||||
|
||||
it('maps user/message text blocks to user_message_chunk (load replays the user side)', () => {
|
||||
// A text block surfaces; a non-text block (here a tool-call) is skipped, so
|
||||
// only the text chunk is emitted.
|
||||
expect(updatesFor(evt('user/message', {
|
||||
content: [
|
||||
{ type: 'text', text: 'hi' },
|
||||
{ type: 'tool-call', id: CallId('c'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
source: { kind: 'user' },
|
||||
}))).toEqual([{ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hi' } }])
|
||||
// A user/message with no text-bearing blocks produces no chunk.
|
||||
expect(updatesFor(evt('user/message', { content: [], source: { kind: 'user' } }))).toEqual([])
|
||||
})
|
||||
|
||||
it('produces no update for boundary/other event types', () => {
|
||||
expect(updatesFor(evt('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))).toEqual([])
|
||||
expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'completed' } }))).toEqual([])
|
||||
expect(updatesFor(evt('usage', { turn: 1, step: 1, usage: { inputTokens: 1, outputTokens: 1 } }))).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentOptions', () => {
|
||||
it('includes only the fields present in config', () => {
|
||||
expect(agentOptions({})).toEqual({})
|
||||
expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' })
|
||||
expect(agentOptions({ systemPrompt: 'sp' })).toEqual({ systemPrompt: 'sp' })
|
||||
expect(agentOptions({ model: 'm', systemPrompt: 'sp' })).toEqual({ model: 'm', systemPrompt: 'sp' })
|
||||
})
|
||||
})
|
||||
231
packages/acp/tests/turns.spec.ts
Normal file
231
packages/acp/tests/turns.spec.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
errorResponse,
|
||||
makeBridgeHarness,
|
||||
maxTokensResponse,
|
||||
textResponse,
|
||||
toolCallResponse,
|
||||
type BridgeHarness,
|
||||
} from './harness.ts'
|
||||
|
||||
/** Boilerplate: initialize + create one session, returning its id. */
|
||||
async function newSession(h: BridgeHarness): Promise<string> {
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
return sessionId
|
||||
}
|
||||
|
||||
describe('acp bridge — turn outcomes', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-test-')) })
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('maps a max-tokens turn to stopReason max_tokens', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [maxTokensResponse('cut off')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('max_tokens')
|
||||
})
|
||||
|
||||
it('rejects the prompt RPC when a turn fails (no misleading end_turn)', async () => {
|
||||
// ACP has no "error" stop reason; a failed turn must surface as a rejected
|
||||
// session/prompt, not a normal end_turn that hides the failure from the
|
||||
// client. The bridge rejects via the turn/end{error} log record.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('provider boom')] })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: provider boom/)
|
||||
})
|
||||
|
||||
it('streams a tool call as tool_call then tool_call_update', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi' }), textResponse('done')],
|
||||
})
|
||||
harness.ctx.tools.register(defineTool({
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: { command: { type: 'string' } },
|
||||
async execute() { return [{ type: 'text', text: 'hi\n' }] },
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] })
|
||||
|
||||
const toolCalls = harness.updates.filter(u => u.sessionUpdate === 'tool_call')
|
||||
const toolUpdates = harness.updates.filter(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(toolCalls).toHaveLength(1)
|
||||
expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'execute', status: 'in_progress' })
|
||||
expect(toolUpdates).toHaveLength(1)
|
||||
expect(toolUpdates[0]).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
|
||||
// Ordering invariant: the tool_call precedes its tool_call_update.
|
||||
const callIdx = harness.updates.findIndex(u => u.sessionUpdate === 'tool_call')
|
||||
const updIdx = harness.updates.findIndex(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(callIdx).toBeLessThan(updIdx)
|
||||
})
|
||||
|
||||
it('a failing tool yields a failed tool_call_update', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'boom' }), textResponse('ok')],
|
||||
})
|
||||
harness.ctx.tools.register(defineTool({
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: { command: { type: 'string' } },
|
||||
async execute() { throw new Error('command failed') },
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] })
|
||||
const failed = harness.updates.filter(u => u.sessionUpdate === 'tool_call_update' && u.status === 'failed')
|
||||
expect(failed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('settles via the log fallback when a prior session/event listener throws (starvation)', async () => {
|
||||
// A peer session/event listener that runs BEFORE the bridge's listener
|
||||
// throws on turn/end (prepend: true puts it first). cordis emit stops at the
|
||||
// throw, so the bridge's session/event listener never sees turn/end and
|
||||
// cannot settle there. The agent/status idle-fallback must reconcile the
|
||||
// prompt from the log so the RPC settles instead of hanging.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/end') throw new Error('peer listener boom')
|
||||
}, { prepend: true })
|
||||
const sessionId = await newSession(harness)
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('log fallback REJECTS when the starved turn ended in error', async () => {
|
||||
// Same starvation as above, but the turn fails: the idle-fallback must
|
||||
// reject the RPC from the logged turn/end{error}, not resolve.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('starved boom')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/end') throw new Error('peer listener boom')
|
||||
}, { prepend: true })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: starved boom/)
|
||||
})
|
||||
|
||||
it('log fallback infers the owning turn when turn/START capture is starved', async () => {
|
||||
// A peer listener throws on turn/START (not turn/end): the bridge never
|
||||
// captures inflight.turn via the live stream. A throwing turn/start listener
|
||||
// also FAILS the turn (the throw is recorded as the turn's error). Without
|
||||
// the watermark inference the fallback would resolve `cancelled` (the bug);
|
||||
// with it, it infers the owning turn from the log and REJECTS from that
|
||||
// turn's error turn/end. (The model's own error is never reached — the turn
|
||||
// failed at start — so the rejection carries the listener's failure.)
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('never runs')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/start') throw new Error('peer listener boom on start')
|
||||
}, { prepend: true })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed:/)
|
||||
})
|
||||
|
||||
it('a between-turn injection does not settle the prompt early (message-trigger correlation)', async () => {
|
||||
// A plugin injects context (a one-shot injection-triggered turn) right after
|
||||
// the prompt is queued but before the prompt's own message turn runs. The
|
||||
// bridge must NOT mistake the injection turn's turn/end for the prompt's —
|
||||
// it correlates only to message-triggered turns. The prompt settles on its
|
||||
// OWN turn with the real model answer.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(sessionId)!
|
||||
// On the queued prompt, synchronously inject a one-shot context turn (idle
|
||||
// inject writes turn/start{injection} → context/message → turn/end). Fire
|
||||
// once so it lands between install and the prompt turn.
|
||||
let injected = false
|
||||
harness.ctx.on('agent/queued', (subject) => {
|
||||
if (subject === agent && !injected) {
|
||||
injected = true
|
||||
agent.inject([{ type: 'text', text: 'ctx note' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
}
|
||||
})
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
const text = harness.updates
|
||||
.filter(u => u.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
expect(text).toContain('real answer')
|
||||
})
|
||||
|
||||
it('rejects a second prompt while one is in flight', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
// Start the first prompt but do NOT await — it hangs in the model stream.
|
||||
const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] })
|
||||
// Give the loop a tick to install the settle + start running.
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
|
||||
.rejects.toThrow(/already in flight/)
|
||||
// Cancel to settle the first so the harness disposes cleanly.
|
||||
await harness.client.cancel({ sessionId })
|
||||
await first
|
||||
})
|
||||
|
||||
it('session/cancel aborts a running turn and settles the prompt as cancelled', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await harness.client.cancel({ sessionId })
|
||||
const res = await promptDone
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('cancel in the pre-step window still settles the prompt cancelled exactly once', async () => {
|
||||
// No script entry is consumed before cancel: cancel immediately after the
|
||||
// prompt is sent, before the model step starts. The prompt must still
|
||||
// settle cancelled (best-effort abort + settle), not hang.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('late')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
await harness.client.cancel({ sessionId })
|
||||
const res = await promptDone
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
// The queued turn may still start after the cancel cleared the in-flight
|
||||
// slot (the documented TODO(rfc010-cancel-prestep) best-effort window): its
|
||||
// turn-start then fires with no prompt to tag, and the bridge does nothing.
|
||||
// Let it run to completion and assert nothing re-settles (no throw, no hang).
|
||||
await harness.ctx.agents.get(sessionId)!.whenIdle()
|
||||
})
|
||||
|
||||
it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => {
|
||||
// Regression: prompt A runs; cancel settles A and frees the slot; A's
|
||||
// aborted turn/end is still pending in the loop. Prompt B is sent before
|
||||
// A's turn/end arrives. A's late turn/end (an EARLIER turn number) must NOT
|
||||
// settle B — B owns a later turn. B then completes on its OWN turn/end.
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] })
|
||||
await new Promise(r => setTimeout(r, 30)) // let A start running (turn 1)
|
||||
await harness.client.cancel({ sessionId })
|
||||
expect((await a).stopReason).toBe('cancelled')
|
||||
|
||||
// Immediately send B; its turn (2) is distinct from A's (1). If A's late
|
||||
// turn/end leaked onto B, B would settle 'cancelled' instead of 'end_turn'.
|
||||
const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] })
|
||||
expect(b.stopReason).toBe('end_turn')
|
||||
const text = harness.updates
|
||||
.filter(u => u.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
expect(text).toContain('B answer')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user