Merge remote-tracking branch 'origin/master' into codex/trim-redundant-comments

# Conflicts:
#	docs/config-catalog.md
#	examples/acp-agent/tests/acp.e2e.ts
#	packages/ui/acp/src/index.ts
#	packages/ui/acp/tests/bridge.spec.ts
#	packages/ui/acp/tests/config-options.spec.ts
#	packages/ui/acp/tests/dispose.spec.ts
#	packages/ui/acp/tests/properties.spec.ts
This commit is contained in:
Turtle
2026-07-25 13:36:21 +08:00
508 changed files with 4228 additions and 21490 deletions

View File

@@ -37,8 +37,9 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
| [`ui/`](ui/README.md) | Human/client integrations: TUI and JSON-RPC, approval/interaction seams, ask-user tool | Product — stable surface |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |

9
packages/acp/README.md Normal file
View File

@@ -0,0 +1,9 @@
# acp/ — Agent Client Protocol automation
The ACP group exposes harness agents to programmatic clients. It is an interoperability transport, not a presentation or human-interaction layer.
| Package | Role |
|---|---|
| [`acp/`](acp/README.md) | Automation-only ACP server: fresh text sessions, committed assistant output, machine permission policy, cancellation, and connection-owned teardown. |
The matching out-of-process subagent client remains in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface; arbitrary ACP clients may drive the same server contract.

View File

@@ -0,0 +1,77 @@
# @deepseek-ai/dsh-acp
Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text prompts, collect committed assistant text, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md).
This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the web and TUI modules.
## Plugin
`apply(ctx, config)` opens an `AgentSideConnection` on stdin/stdout and drives `ctx.agents`. Stdout is reserved for protocol frames.
| Config | Default | Meaning |
|---|---|---|
| `provider` | — | Initial provider route for every created agent. |
| `model` | — | Initial model for every created agent. |
Both fields are optional so another agent/request listener may supply the target. The runnable ACP composition requires both.
## Protocol contract
| Method | Behavior |
|---|---|
| `initialize` | Negotiates the supported version and advertises baseline-only prompts (no image, audio, or embedded-context capability). No session, editor, terminal, filesystem, or MCP capability is advertised. |
| `authenticate` | No-op because the server advertises no authentication methods. |
| `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. |
| `session/prompt` | Concatenates text blocks, renders baseline resource links as bracketed textual references, rejects empty or beyond-baseline input, permits one in-flight request per session, and settles from that request's owning durable `turn/end`. |
| `session/cancel` | Cancels only the addressed agent and settles its pending prompt as `cancelled`; unknown ids are no-ops. |
| `session/update` | Emits one `agent_message_chunk` per non-empty text block in a committed `assistant/message`. Raw deltas and non-message events are omitted. |
| `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. |
One connection may own several sessions. The bridge keys records by branded session id and checks exact agent identity before routing events or permission requests. Each session has an independent prompt slot, workspace, cancellation path, and disposer.
Committed-message output intentionally trades token-by-token latency for a clean automation result. Uncommitted provider chunks and retry attempts cannot leak partial text; reasoning and tool activity remain in the session log for observability through other interfaces.
## Lifecycle
Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, settles pending prompts, then disposes all owned agent handles in parallel and awaits their loop/session cleanup. An ACP-only plugin reload therefore leaves no orphan agent.
## Running
`pnpm --dir /path/to/deepseek-harness run demo:acp` boots the repository's automation server composition. A parent harness can spawn it through [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md); other ACP clients need only the core methods above.
## Model Experience
### Prompt text
#### What the model sees
`session/prompt` text blocks are concatenated verbatim into one user message; a baseline resource link appears in that message as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Protocol metadata, client capabilities, permission choices, and session ids never enter the model request.
#### Token effect
Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions retain independent contexts.
#### KV Cache effect
Append-only; the new user message follows the reusable request prefix and does not invalidate prior cache entries.
### Permission decisions
#### What the model sees
Nothing directly. The owning tool records its allowed, rejected, cancelled, or unavailable outcome through the normal tool-result path.
#### Token effect
Only the owning tool result contributes tokens.
#### KV Cache effect
Append-only through the owning tool result.
## Known Limitations and Deferred Work
- **Fresh sessions only** — load, list, resume, delete, and fork are unsupported.
- **Baseline prompts and one workspace only** — images, audio, embedded resources, non-empty additional directories, and MCP servers reject; resource links flatten to textual references rather than fetched content.
- **Committed answers only** — live progress, reasoning, tool activity, plans, titles, and usage stay off the wire.
- **Connection-owned lifetime** — one connection releases all of its sessions; per-session close is not implemented.

View File

@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-acp",
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
"schemastery": "^3.17.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,63 @@
/**
* Pure translation between the harness lifecycle and the automation-only ACP wire.
* @module @deepseek-ai/dsh-acp/codec
*/
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
/**
* Map a harness turn ending to ACP's terminal reason vocabulary.
* @param reason - harness turn outcome.
* @returns the closest legal ACP stop reason.
*/
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
switch (reason.kind) {
case 'completed':
return 'end_turn'
case 'max-tokens':
return 'max_tokens'
case 'aborted':
case 'disposed':
case 'rejected':
case 'interrupted':
return 'cancelled'
case 'error':
return 'end_turn'
// TurnEndReason is merge-extensible; future variants still need a legal wire value.
default:
return 'end_turn'
}
}
/**
* Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate
* verbatim; resource links become explicit textual references so a baseline
* client can point at files without the bridge silently dropping that context.
* @param prompt - supported ACP prompt blocks.
* @returns text in wire order, with resource links rendered as bracketed references.
*/
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
return prompt.flatMap((block): string[] => {
switch (block.type) {
case 'text':
return [block.text]
case 'resource_link':
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
default:
return []
}
}).join('')
}
/**
* Whether a prompt carries content beyond the ACP baseline. The spec requires
* every agent to accept `text` and `resource_link`; richer inline payloads
* (image, audio, embedded resource) are optional capabilities this bridge does
* not advertise, so they are rejected rather than silently dropped.
* @param prompt - ACP prompt blocks to inspect.
* @returns `true` when any block is neither `text` nor `resource_link`.
*/
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')
}

View File

@@ -0,0 +1,330 @@
/**
* Automation-only Agent Client Protocol server over JSON-RPC stdio.
*
* The bridge exposes fresh harness sessions to trusted programmatic clients. It
* carries prompt text, committed assistant text, cancellation, and one-shot
* permission decisions; presentation and human-interaction features stay with
* the harness's UI modules.
*
* @module @deepseek-ai/dsh-acp
*/
import type { Context } from 'cordis'
import { randomUUID } from 'node:crypto'
import { isAbsolute } from 'node:path'
import { Readable, Writable } from 'node:stream'
import Schema from 'schemastery'
import {
AgentSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
RequestError,
type Agent as AcpAgent,
type AuthenticateRequest,
type CancelNotification,
type InitializeRequest,
type InitializeResponse,
type NewSessionRequest,
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type SessionNotification,
type StopReason,
type Stream,
} from '@agentclientprotocol/sdk'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
// Side-effect type import: declaration-merges the approval waterfall answered below.
import type {} from '@deepseek-ai/dsh-user-approval'
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from './codec.ts'
export const name = 'acp'
/** The bridge creates and owns agents; every other concern is carried by the agent composition. */
export const inject = ['agents']
/** Preserve invalid-parameter detail in the SDK wire error message. */
function invalidParams(detail: string): RequestError {
return RequestError.invalidParams(undefined, detail)
}
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
function internalError(detail: string): RequestError {
return RequestError.internalError(undefined, detail)
}
/** Plugin config: the provider/model target used for each ACP-created agent. */
export interface AcpConfig {
/** Provider route for created agents. */
provider?: string
/** Model name for created agents. */
model?: string
/** Runtime-only transport override; production uses stdio. */
stream?: Stream
}
export const Config: Schema<AcpConfig> = Schema.object({
provider: Schema.string(),
model: Schema.string(),
})
/** Per-session protocol state. */
interface SessionRecord {
agent: Agent
/** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */
dispose: () => Promise<void>
/** In-flight prompt and its captured turn number for exact settlement. */
inflight: {
resolve: (reason: StopReason) => void
reject: (error: Error) => void
turn: number | undefined
} | undefined
}
/**
* Mount the automation-only ACP server.
* @param ctx - Cordis context carrying the agent factory and session events.
* @param config - Initial provider/model target and optional test transport.
*/
export function apply(ctx: Context, config: AcpConfig): void {
// ACP handlers execute outside this plugin's injection scope, so capture the
// injected service during apply rather than reading it lazily in a callback.
const agents = ctx.agents
const logger = ctx.logger
const sessions = new Map<SessionId, SessionRecord>()
let closed = false
let conn: AgentSideConnection
/** Return the bridge-owned record for an agent, rejecting same-id impostors. */
const ownedRecord = (agent: Agent): SessionRecord | undefined => {
const record = sessions.get(agent.session.id)
return record?.agent === agent ? record : undefined
}
const assertOpen = (): void => {
if (closed) throw internalError('the ACP bridge has been disposed')
}
const requireSession = (sessionId: SessionId): SessionRecord => {
const record = sessions.get(sessionId)
if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`)
return record
}
/** Send a protocol update without letting a disconnected client fail an agent turn. */
const notify = (notification: SessionNotification): void => {
/* v8 ignore next 3 -- only a transport write failure reaches this guard. */
void conn.sessionUpdate(notification).catch((error: unknown) => {
logger.warn(`acp: session/update failed: ${String(error)}`)
})
}
const settlePrompt = (record: SessionRecord, reason: StopReason): void => {
const inflight = record.inflight
if (inflight === undefined) return
record.inflight = undefined
inflight.resolve(reason)
}
const settleFromTurnEnd = (
inflight: NonNullable<SessionRecord['inflight']>,
reason: TurnEndReason,
): void => {
if (reason.kind === 'error') {
inflight.reject(internalError(`turn failed: ${'failure' in reason ? reason.failure.message : reason.message}`))
return
}
inflight.resolve(turnEndToStopReason(reason))
}
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
// titles, and retry markers are presentation or trace data and stay off the
// automation wire.
ctx.on('session/event', (session, event: SessionEvent) => {
const record = sessions.get(session.header.id)
if (record === undefined || record.agent.session !== session) return
try {
if (event.type === 'assistant/message') {
for (const block of event.data.content) {
if (block.type === 'text' && block.text.length > 0) {
notify({
sessionId: record.agent.session.id,
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: block.text },
},
})
}
}
}
} finally {
const inflight = record.inflight
if (inflight !== undefined && event.type === 'turn/start') {
if (inflight.turn === undefined && event.data.trigger.kind === 'message'
&& event.data.trigger.source.kind === 'user') {
inflight.turn = event.data.turn
}
} else if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) {
record.inflight = undefined
settleFromTurnEnd(inflight, event.data.reason)
}
}
})
// Permission requests are a machine policy channel for ACP clients such as
// dsh-subagent-acp. The bridge offers one-shot choices only and never infers a
// durable grant from an unknown client response.
ctx.on('approval/request', (request, next) => {
const record = ownedRecord(request.agent)
if (record === undefined || request.callId === undefined) return next()
return conn.requestPermission({
sessionId: record.agent.session.id,
toolCall: { toolCallId: request.callId },
options: [
{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject-once', name: 'Reject', kind: 'reject_once' },
],
}).then(({ outcome }) => {
if (outcome.outcome === 'cancelled') return 'cancelled'
return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected'
})
})
const makeAgent = (connection: AgentSideConnection): AcpAgent => {
conn = connection
return {
initialize(_params: InitializeRequest): Promise<InitializeResponse> {
// Single-version agent: the spec's "same version if supported, else
// the latest supported" both resolve to this server's one version.
return Promise.resolve({
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
promptCapabilities: { image: false, audio: false, embeddedContext: false },
},
authMethods: [],
})
},
authenticate(_params: AuthenticateRequest): Promise<void> {
return Promise.resolve()
},
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
assertOpen()
validateSessionParams(params)
const sessionId = SessionId(randomUUID())
const handle = await agents.create({
sessionId,
meta: { cwd: params.cwd },
agentOptions: agentOptions(config),
})
/* v8 ignore next 4 -- a real stdio close can race an in-flight create. */
if (closed) {
await handle.dispose()
throw internalError('connection closed during session/new')
}
sessions.set(sessionId, {
agent: handle.agent,
dispose: () => handle.dispose(),
inflight: undefined,
})
return { sessionId }
},
async prompt(params: PromptRequest): Promise<PromptResponse> {
assertOpen()
const record = requireSession(SessionId(params.sessionId))
if (record.inflight !== undefined) {
throw invalidParams('a prompt is already in flight for this session')
}
if (promptHasUnsupportedContent(params.prompt)) {
throw invalidParams('only text and resource_link prompt content is supported')
}
const text = acpPromptToText(params.prompt)
if (text.trim().length === 0) throw invalidParams('empty prompt')
const stopReason = await new Promise<StopReason>((resolve, reject) => {
// Arm the slot before followup() so a listener-driven synchronous
// turn cannot slip past correlation; a synchronous followup()
// failure (an agent disposed outside the bridge, e.g. an
// agent-loop-only reload) must free the slot again or the session
// would reject every later prompt as already in flight.
record.inflight = { resolve, reject, turn: undefined }
try {
record.agent.followup([{ type: 'text', text }])
} catch (error: unknown) {
record.inflight = undefined
// followup() throws only Errors (disposed agent / invalid input);
// the String arm is a defensive fallback for a non-Error throw.
/* v8 ignore next */
const detail = error instanceof Error ? error.message : String(error)
throw internalError(`prompt was not queued: ${detail}`)
}
})
return { stopReason }
},
cancel(params: CancelNotification): Promise<void> {
const record = sessions.get(SessionId(params.sessionId))
if (record === undefined) return Promise.resolve()
record.agent.cancel({ kind: 'user' })
settlePrompt(record, 'cancelled')
return Promise.resolve()
},
}
}
/* v8 ignore next 4 -- production stdio wiring; tests inject config.stream. */
const stream: Stream = config.stream ?? ndJsonStream(
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
)
conn = new AgentSideConnection(makeAgent, stream)
let quiescing: Promise<void> | undefined
const quiesce = (): Promise<void> => {
if (quiescing !== undefined) return quiescing
closed = true
const records = [...sessions.values()]
sessions.clear()
quiescing = Promise.all(records.map(async (record) => {
settlePrompt(record, 'cancelled')
await record.dispose()
})).then(() => {})
return quiescing
}
/* v8 ignore start -- production transport rejection and teardown failure. */
void conn.closed
.catch((error: unknown) => {
logger.warn(`acp: connection closed with an error: ${String(error)}`)
})
.then(quiesce)
.catch((error: unknown) => {
logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
})
/* v8 ignore stop */
ctx.effect(() => quiesce, 'acp.connection')
}
/**
* Build per-agent options from plugin config without assigning absent optional fields.
* @param config - ACP provider/model configuration.
* @returns the configured fields only.
*/
function agentOptions(config: AcpConfig): { provider?: string; model?: string } {
return {
...config.provider !== undefined ? { provider: config.provider } : {},
...config.model !== undefined ? { model: config.model } : {},
}
}
/** Reject session features outside the automation contract. */
function validateSessionParams(params: NewSessionRequest): void {
if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
throw invalidParams('additionalDirectories is not supported')
}
if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported')
}

View File

@@ -15,8 +15,8 @@ export const name = 'acp-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
* boundary and replay tests cover its protocol mapping.
* No runtime invariant: this transport owns no durable package-local event stream;
* protocol and lifecycle tests cover its mapping.
*/
const install: InvariantInstaller = () => {}

View File

@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
describe('ACP machine permission policy', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
async function ownedRequest(overrides: Partial<ApprovalRequest> = {}): Promise<ApprovalRequest> {
if (harness === undefined) throw new Error('missing harness')
await harness.ctx.plugin(ApprovalService)
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))!
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides }
}
it('maps the two advertised one-shot choices', async () => {
harness = await makeBridgeHarness()
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
const request = await ownedRequest()
await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once')
expect(harness.permissionRequests[0]).toMatchObject({
sessionId: request.agent.session.id,
toolCall: { toolCallId: 'call-9' },
options: [
{ optionId: 'allow-once', kind: 'allow_once' },
{ optionId: 'reject-once', kind: 'reject_once' },
],
})
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } })
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
})
it('maps cancellation and unknown choices without granting access', async () => {
harness = await makeBridgeHarness()
const request = await ownedRequest()
await expect(harness.ctx.approval.request(request)).resolves.toBe('cancelled')
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'unknown-grant' } })
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
})
it('fails closed when the client errors the permission request', async () => {
harness = await makeBridgeHarness()
const request = await ownedRequest()
harness.onPermission = () => { throw new Error('client gone') }
await expect(harness.ctx.approval.request(request)).resolves.toBe('unavailable')
})
it('delegates a same-id foreign agent', async () => {
harness = await makeBridgeHarness()
const request = await ownedRequest()
const foreign = {
session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) },
} as unknown as Agent
await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') }))
.resolves.toBe('unavailable')
expect(harness.permissionRequests).toHaveLength(0)
})
it('delegates requests that have no protocol tool-call identity', async () => {
harness = await makeBridgeHarness()
const request = await ownedRequest()
await expect(harness.ctx.approval.request({ agent: request.agent, toolName: request.toolName }))
.resolves.toBe('unavailable')
expect(harness.permissionRequests).toHaveLength(0)
})
})

View File

@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
describe('automation-only ACP bridge', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
it('advertises only fresh text sessions', async () => {
harness = await makeBridgeHarness()
const response = await harness.client.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: { _meta: { terminal_output: true } },
})
expect(response).toEqual({
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
promptCapabilities: { image: false, audio: false, embeddedContext: false },
},
authMethods: [],
})
})
it('negotiates an unsupported version and accepts the required no-op authentication call', async () => {
harness = await makeBridgeHarness()
const response = await harness.client.initialize({ protocolVersion: 0, clientCapabilities: {} })
expect(response.protocolVersion).toBe(PROTOCOL_VERSION)
await expect(harness.client.authenticate({ methodId: 'unused' })).resolves.toEqual({})
})
it('creates a session, emits one committed answer, and settles the prompt', async () => {
harness = await makeBridgeHarness({ script: [textResponse('hello there')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const result = await harness.client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'say hello' }],
})
expect(result.stopReason).toBe('end_turn')
await vi.waitFor(() => { expect(harness!.updates).toHaveLength(1) })
expect(harness.updates).toEqual([{
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'hello there' },
}])
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.header.cwd).toBe(process.cwd())
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'say hello' }])
})
it('leaves absent agent targets for request listeners to supply', async () => {
harness = await makeBridgeHarness({ config: { provider: undefined, model: undefined } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(harness.ctx.agents.get(SessionId(sessionId))?.options).toEqual({})
})
it('concatenates text blocks without exposing protocol framing to the model', async () => {
harness = await makeBridgeHarness({ script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: 'first' },
{ type: 'text', text: ' second' },
],
})
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'first second' }])
})
it('renders the deployment persona for an ACP-created agent', async () => {
harness = await makeBridgeHarness({ persona: 'Automation persona for {{model}} in {{cwd}}.', script: [textResponse('ok')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(harness.adapter.requests[0]?.system).toContain(`Automation persona for mock in ${process.cwd()}.`)
})
it('requires one absolute workspace and no MCP servers', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.newSession({ cwd: 'relative', mcpServers: [] })).rejects.toThrow(/absolute path/)
await expect(harness.client.newSession({
cwd: process.cwd(),
mcpServers: [],
additionalDirectories: ['/tmp/other'],
})).rejects.toThrow(/additionalDirectories/)
await expect(harness.client.newSession({
cwd: process.cwd(),
mcpServers: [{ name: 'fs', command: 'node', args: [], env: [] }],
})).rejects.toThrow(/mcpServers/)
await expect(harness.client.newSession({
cwd: process.cwd(),
mcpServers: [],
additionalDirectories: [],
})).resolves.toHaveProperty('sessionId')
})
it('rejects empty and beyond-baseline prompts before a turn starts', async () => {
harness = await makeBridgeHarness()
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/)
await expect(harness.client.prompt({
sessionId,
prompt: [{ type: 'image', data: '', mimeType: 'image/png' }],
})).rejects.toThrow(/only text and resource_link/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events.some(event => event.type === 'turn/start')).toBe(false)
})
it('renders baseline resource links as textual references in the user message', async () => {
harness = await makeBridgeHarness({ script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: 'summarize' },
{ type: 'resource_link', name: 'notes.txt', uri: 'file:///tmp/notes.txt' },
],
})
expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{
type: 'text',
text: 'summarize\n[resource_link name="notes.txt" uri="file:///tmp/notes.txt"]\n',
}])
})
it('rejects prompts for unknown sessions and ignores unknown cancellation', async () => {
harness = await makeBridgeHarness()
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.prompt({ sessionId: 'missing', prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/unknown session/)
await expect(harness.client.cancel({ sessionId: 'missing' })).resolves.toBeUndefined()
})
})

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from '../src/codec.ts'
describe('ACP automation codec', () => {
it('maps every known turn outcome to a legal stop reason', () => {
const cases: [TurnEndReason, string][] = [
[{ kind: 'completed' }, 'end_turn'],
[{ kind: 'max-tokens' }, 'max_tokens'],
[{ kind: 'aborted' }, 'cancelled'],
[{ kind: 'disposed' }, 'cancelled'],
[{ kind: 'rejected', reason: 'blocked' }, 'cancelled'],
[{ kind: 'interrupted' }, 'cancelled'],
[{ kind: 'error', step: 1, message: 'boom' }, 'end_turn'],
]
for (const [reason, expected] of cases) expect(turnEndToStopReason(reason)).toBe(expected)
})
it('uses a legal fallback for merge-extensible future outcomes', () => {
expect(turnEndToStopReason({ kind: 'future' } as unknown as TurnEndReason)).toBe('end_turn')
})
it('flattens baseline blocks and rejects everything richer', () => {
expect(acpPromptToText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
expect(acpPromptToText([
{ type: 'text', text: 'see' },
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
])).toBe('see\n[resource_link name="x" uri="file:///x"]\n')
expect(acpPromptToText([{ type: 'image', data: '', mimeType: 'image/png' }])).toBe('')
expect(promptHasUnsupportedContent([
{ type: 'text', text: 'ok' },
{ type: 'resource_link', name: 'x', uri: 'file:///x' },
])).toBe(false)
expect(promptHasUnsupportedContent([
{ type: 'image', data: '', mimeType: 'image/png' },
])).toBe(true)
})
})

View File

@@ -0,0 +1,85 @@
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('disposed')
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('disposed')
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(agent.status).toBe('disposed') })
await vi.waitFor(() => {
expect(harness!.ctx.agents.get(SessionId(sessionId)) === undefined).toBe(true)
})
})
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('disposed')
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)
})
})

View File

@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
function toolCallResponse(): StreamChunk[] {
return [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('call-1'), name: 'echo', argumentsDelta: '{}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{}' } },
{ type: 'finish', reason: { kind: 'tool-calls' } },
]
}
describe('ACP automation output boundary', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
it('does not emit tool, terminal, plan, title, or reasoning presentation updates', async () => {
harness = await makeBridgeHarness({ script: [toolCallResponse(), textResponse('done')] })
harness.ctx.tools.register(defineContentToolFixture({
name: 'echo',
description: 'Return a deterministic result.',
parameters: {},
execute: () => Promise.resolve([{ type: 'text', text: 'tool result' }]),
}))
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
await vi.waitFor(() => { expect(harness!.updates).toHaveLength(1) })
expect(harness.updates).toEqual([{
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'done' },
}])
})
it('ignores events from agents the bridge does not own', async () => {
harness = await makeBridgeHarness({ script: [textResponse('foreign')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const { agent } = await harness.ctx.agents.create({
sessionId: SessionId('foreign'),
agentOptions: { provider: 'mock', model: 'mock' },
})
agent.followup([{ type: 'text', text: 'go' }])
await agent.whenIdle()
expect(harness.updates).toHaveLength(0)
})
// `session/update` is a JSON-RPC notification, so a client-side handler
// failure never reaches the bridge; this pins that the prompt still settles
// normally with such a client. The bridge's own write-failure guard is
// transport-level and documented untestable at `notify`.
it('settles the prompt normally when the client rejects update notifications', async () => {
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
harness.onSessionUpdateError = () => {}
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
})
})

View File

@@ -0,0 +1,174 @@
/** In-memory ACP transport fixture over the real agent factory and loop. */
import { Context } from 'cordis'
import {
ClientSideConnection,
ndJsonStream,
type Agent as AcpAgent,
type Client,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type Stream,
} from '@agentclientprotocol/sdk'
import { type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as AcpPlugin from '../src/index.ts'
import type { AcpConfig } from '../src/index.ts'
/** Scripted adapter for protocol tests. */
class MockAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(private readonly script: (StreamChunk[] | 'hang')[]) {
super()
}
override providerInfo(provider: string) {
if (provider !== 'mock') throw new Error(`MockAdapter: unknown provider ${provider}`)
return { id: 'mock', name: 'Mock' }
}
override listModels(provider: string) {
return Promise.resolve(provider === 'mock' ? [{ provider: 'mock', id: 'mock', name: 'Mock' }] : [])
}
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
const entry = this.script.shift()
if (entry === undefined) 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. */
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. */
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 after publishing an uncommitted partial 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', failure: { message, code: 'PROVIDER_ERROR' } } },
]
}
export type CapturedUpdate = SessionNotification['update']
export interface BridgeHarness {
ctx: Context
client: ClientSideConnection
adapter: MockAdapter
updates: CapturedUpdate[]
sessionUpdates: { sessionId: string; update: CapturedUpdate }[]
permissionRequests: RequestPermissionRequest[]
onPermission: (request: RequestPermissionRequest) => RequestPermissionResponse
onSessionUpdateError: (() => void) | undefined
closeClientTransport: () => Promise<void>
abortClientTransport: () => Promise<void>
acpFiber: Awaited<ReturnType<Context['plugin']>>
/** The AgentLoop fiber, so a test can reload the loop out from under the bridge. */
loopFiber: Awaited<ReturnType<Context['plugin']>>
dispose: () => Promise<void>
}
type AcpConfigOverrides = { [K in keyof AcpConfig]?: AcpConfig[K] | undefined }
/** Build the bridge and a connected SDK client over cross-wired byte streams. */
export async function makeBridgeHarness(options: {
script?: (StreamChunk[] | 'hang')[]
config?: AcpConfigOverrides
persona?: string
} = {}): Promise<BridgeHarness> {
const adapter = new MockAdapter(options.script ?? [])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } })
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
const agentToClient = new TransformStream<Uint8Array, Uint8Array>()
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>()
const clientToAgentWriter = clientToAgent.writable.getWriter()
const clientOutput = new WritableStream<Uint8Array>({
write: chunk => clientToAgentWriter.write(chunk),
})
const agentStream: Stream = ndJsonStream(agentToClient.writable, clientToAgent.readable)
const clientStream: Stream = ndJsonStream(clientOutput, agentToClient.readable)
const updates: CapturedUpdate[] = []
const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = []
const permissionRequests: RequestPermissionRequest[] = []
const harness: BridgeHarness = {
ctx,
adapter,
updates,
sessionUpdates,
permissionRequests,
onPermission: () => ({ outcome: { outcome: 'cancelled' } }),
onSessionUpdateError: undefined,
client: undefined as unknown as ClientSideConnection,
acpFiber: undefined as unknown as BridgeHarness['acpFiber'],
loopFiber,
closeClientTransport: async () => { await clientToAgentWriter.close() },
abortClientTransport: async () => { await clientToAgentWriter.abort(new Error('client transport failed')) },
dispose: async () => { await ctx.fiber.dispose() },
}
const makeClient = (_agent: AcpAgent): Client => ({
sessionUpdate(params: SessionNotification): Promise<void> {
updates.push(params.update)
sessionUpdates.push({ sessionId: params.sessionId, update: params.update })
if (harness.onSessionUpdateError !== undefined) 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))
},
})
const config = { stream: agentStream, ...options.config } as AcpConfig
if (!(options.config && 'provider' in options.config)) config.provider = 'mock'
if (!(options.config && 'model' in options.config)) config.model = 'mock'
harness.acpFiber = await ctx.plugin({
name: 'acp-test',
inject: [...AcpPlugin.inject],
apply: (inner: Context) => { AcpPlugin.apply(inner, config) },
})
harness.client = new ClientSideConnection(makeClient, clientStream)
return harness
}

View File

@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
function messageTextFor(
updates: { sessionId: string; update: CapturedUpdate }[],
sessionId: string,
): string {
return updates.flatMap(({ sessionId: owner, update }) => (
owner === sessionId && update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
? [update.content.text]
: []
)).join('')
}
describe('ACP multi-session isolation', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
it('demultiplexes concurrent answers by session id', async () => {
harness = await makeBridgeHarness({ script: [textResponse('answer-A'), textResponse('answer-B')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const [resultA, resultB] = await Promise.all([
harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] }),
harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }),
])
expect(resultA.stopReason).toBe('end_turn')
expect(resultB.stopReason).toBe('end_turn')
await vi.waitFor(() => {
expect(messageTextFor(harness!.sessionUpdates, a)).toBe('answer-A')
expect(messageTextFor(harness!.sessionUpdates, b)).toBe('answer-B')
})
})
it('cancels one session without affecting another', async () => {
harness = await makeBridgeHarness({ script: ['hang', textResponse('B done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const pendingA = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'hang A' }] })
await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(a))?.status).toBe('running') })
await harness.client.cancel({ sessionId: a })
await expect(pendingA).resolves.toEqual({ stopReason: 'cancelled' })
await expect(harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await vi.waitFor(() => { expect(messageTextFor(harness!.sessionUpdates, b)).toBe('B done') })
})
it('enforces one in-flight prompt independently for each session', async () => {
harness = await makeBridgeHarness({ script: ['hang', 'hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const pendingA = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'A' }] })
const pendingB = harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'B' }] })
await vi.waitFor(() => {
expect(harness!.ctx.agents.get(SessionId(a))?.status).toBe('running')
expect(harness!.ctx.agents.get(SessionId(b))?.status).toBe('running')
})
await expect(harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'again' }] }))
.rejects.toThrow(/already in flight/)
await Promise.all([harness.client.cancel({ sessionId: a }), harness.client.cancel({ sessionId: b })])
await expect(pendingA).resolves.toEqual({ stopReason: 'cancelled' })
await expect(pendingB).resolves.toEqual({ stopReason: 'cancelled' })
})
it('drains every live session on bridge disposal', async () => {
harness = await makeBridgeHarness({ script: ['hang', 'hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const agentA = harness.ctx.agents.get(SessionId(a))!
const agentB = harness.ctx.agents.get(SessionId(b))!
void harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'A' }] }).catch(() => {})
void harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'B' }] }).catch(() => {})
await vi.waitFor(() => {
expect(agentA.status).toBe('running')
expect(agentB.status).toBe('running')
})
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(SessionId(a))).toBeUndefined()
expect(harness.ctx.agents.get(SessionId(b))).toBeUndefined()
})
})

View File

@@ -0,0 +1,169 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
errorResponse,
makeBridgeHarness,
maxTokensResponse,
textResponse,
type BridgeHarness,
} from './harness.ts'
async function newSession(harness: BridgeHarness): Promise<string> {
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
return (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
}
function messageText(harness: BridgeHarness): string {
return harness.updates.flatMap(update => (
update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
? [update.content.text]
: []
)).join('')
}
describe('ACP prompt lifecycle', () => {
let harness: BridgeHarness | undefined
afterEach(async () => {
await harness?.dispose()
harness = undefined
})
it('maps a max-token turn without losing its committed text', async () => {
harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] })
const sessionId = await newSession(harness)
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(result.stopReason).toBe('max_tokens')
await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') })
})
it('rejects a failed turn and never publishes its partial chunks', async () => {
harness = await makeBridgeHarness({ 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/)
expect(messageText(harness)).toBe('')
})
it('rejects an ordinary plugin failure through the same prompt boundary', async () => {
harness = await makeBridgeHarness({ script: [textResponse('must not run')] })
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
const sessionId = await newSession(harness)
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.rejects.toThrow(/turn failed: plugin pre-step failed/)
})
it('settles even when an earlier turn observer throws', async () => {
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
harness.ctx.on('session/event', (_session, event) => {
if (event.type === 'turn/start' || 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' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
})
it('ignores an injection turn while correlating the owning message turn', async () => {
harness = await makeBridgeHarness({ script: [textResponse('real answer')] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
let injected = false
harness.ctx.on('agent/inbox/enqueue', (subject) => {
if (subject === agent && !injected) {
injected = true
agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'test' } })
}
})
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
expect(result.stopReason).toBe('end_turn')
await vi.waitFor(() => { expect(messageText(harness!)).toBe('real answer') })
})
it('ignores an autonomous message turn while correlating the client turn', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const sessionId = await newSession(harness)
const agent = harness.ctx.agents.get(SessionId(sessionId))!
let inserted = false
harness.ctx.on('agent/inbox/enqueue', (subject, message) => {
if (subject !== agent || message.source.kind !== 'user' || inserted) return
inserted = true
const source = { kind: 'plugin', plugin: 'test' } as const
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source } })
agent.session.append('user/message', {
content: [{ type: 'text', text: 'autonomous work' }],
source,
}, { surfaceOp: 'append' })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
})
let settled = false
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
.finally(() => { settled = true })
await vi.waitFor(() => {
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
})
expect(settled).toBe(false)
await harness.client.cancel({ sessionId })
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
})
it('frees the prompt slot when the agent rejects the send synchronously', async () => {
harness = await makeBridgeHarness({ script: [] })
const sessionId = await newSession(harness)
// Reload the loop out from under the bridge: its agents dispose while the
// bridge record survives, so the next send() throws synchronously.
await harness.loopFiber.dispose()
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] }))
.rejects.toThrow(/prompt was not queued/)
// The failed prompt must not wedge the session's single prompt slot.
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
.rejects.toThrow(/prompt was not queued/)
})
it('permits only one in-flight prompt per session', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const sessionId = await newSession(harness)
const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] })
await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId))?.status).toBe('running') })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
.rejects.toThrow(/already in flight/)
await harness.client.cancel({ sessionId })
await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
})
it('cancels a running turn and records the aborted outcome', async () => {
harness = await makeBridgeHarness({ script: ['hang'] })
const sessionId = await newSession(harness)
const prompt = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
await vi.waitFor(() => { expect(agent.status).toBe('running') })
await harness.client.cancel({ sessionId })
await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' })
await agent.whenIdle()
expect(agent.session.events.findLast(event => event.type === 'turn/end')?.data.reason).toEqual({ kind: 'aborted' })
})
it('an idle cancel does not affect the following prompt', async () => {
harness = await makeBridgeHarness({ script: [textResponse('answer')] })
const sessionId = await newSession(harness)
await harness.client.cancel({ sessionId })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await vi.waitFor(() => { expect(messageText(harness!)).toBe('answer') })
})
it('a late end from a cancelled turn cannot settle the next prompt', async () => {
harness = await makeBridgeHarness({ script: ['hang', textResponse('next')] })
const sessionId = await newSession(harness)
const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] })
await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId))?.status).toBe('running') })
await harness.client.cancel({ sessionId })
await expect(first).resolves.toEqual({ stopReason: 'cancelled' })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') })
})
})

View File

@@ -0,0 +1,33 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/session"
},
{
"path": "../../core/agent"
},
{
"path": "../../ui/user-approval"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -1,6 +1,6 @@
# context/ — request-context extensions
Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI and ACP bundles compose `session-reference` explicitly.
Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI bundle composes `session-reference` explicitly.
| Package | Role | ctx key |
|---|---|---|

View File

@@ -1,6 +1,6 @@
# `@deepseek-ai/dsh-session-reference`
`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as prompt-prefix context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI and ACP demo bundles mount it, while other hosts may call the service directly.
`ctx.sessionReferences` prepares bounded, read-only snapshots of other sessions as prompt-prefix context. It consumes `ctx.sessionQuery` and the backend-independent compact checkpoint marker; SQLite FTS is not required. The standard TUI bundle mounts it, while other hosts may call the service directly.
## Public API
@@ -12,7 +12,7 @@
Preparation calls `ctx.sessionQuery.readSurface()` once per distinct source and never rereads it after enqueue. It projects only direct-user `user/message`, direct-user `steering/message`, assistant text, and `user/message` checkpoints carrying the canonical `dsh-compact` source marker from the folded current surface. For a source prompt that already contains baked prefix context, projection reads only its model-hidden display content, preventing recursive snapshot propagation. Shadowed pre-compaction events, tools, reasoning, context, plugin-generated user messages other than marked compact checkpoints, and unfinished assistant chunks are excluded. A compacted source therefore contributes its latest checkpoint plus retained later conversation, not restored shadowed text.
The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `placement: 'prompt-prefix'`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and metadata for TUI/ACP replay. Later source mutation, compaction, or deletion cannot change target replay.
The context source is `{ kind: 'plugin', plugin: 'session-reference' }` with `placement: 'prompt-prefix'`. Its metadata records version `1`, source ids and labels, capture seqs, compact presence, retained/omitted message counts, omitted UTF-8 bytes, and truncation state. AgentLoop writes the snapshot, `## My request:` delimiter, and effective prompt into one `user/message` or `steering/message`; the same event's model-hidden envelope retains the direct prompt and metadata for UI replay. Later source mutation, compaction, or deletion cannot change target replay.
## Configuration

View File

@@ -1,5 +1,5 @@
/**
* ACP render intents for the three cordis tools — all `generic` cards, decided
* UI render intents for the three cordis tools — all `generic` cards, decided
* up front as part of the tool design. Presenters are pure functions of the
* call arguments (they run on replay too): no I/O, no session state, no clock.
* No `presentResult` overrides exist — the tools' text results are their
@@ -13,7 +13,7 @@ import type { GenericCallView } from '@deepseek-ai/dsh-tools'
/**
* The `cordis_inspect` call card: a read, titled with the requested section.
* @param args - the validated call arguments.
* @returns the generic card the ACP bridge renders.
* @returns the generic call card.
*/
export function presentInspectCall(args: { what?: string; name?: string }): GenericCallView {
const target = args.name === undefined ? args.what : `${args.what}: ${args.name}`
@@ -27,7 +27,7 @@ export function presentInspectCall(args: { what?: string; name?: string }): Gene
/**
* The `cordis_mount` call card: an execute carrying the mount code as raw input.
* @param args - the validated call arguments.
* @returns the generic card the ACP bridge renders.
* @returns the generic call card.
*/
export function presentMountCall(args: { code: string }): GenericCallView {
return {
@@ -41,7 +41,7 @@ export function presentMountCall(args: { code: string }): GenericCallView {
/**
* The `cordis_unmount` call card: a delete, titled with the mount id.
* @param args - the validated call arguments.
* @returns the generic card the ACP bridge renders.
* @returns the generic call card.
*/
export function presentUnmountCall(args: { id: string }): GenericCallView {
return {

View File

@@ -139,10 +139,9 @@ export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
*
* Deliberately minimal: a human-readable `content` line and a three-state
* `status`. No id, priority, or `activeForm` — the list is replaced wholesale
* on every write (last-write-wins), so entries need no stable identity, and the
* status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a
* todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally
* requires).
* on every write (last-write-wins), so entries need no stable identity. The
* three statuses describe the complete portable lifecycle needed by model and
* UI consumers.
*/
export interface TodoItem {
/** What this task is — a short imperative line shown in the UI. */

View File

@@ -363,7 +363,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
exec.signal.removeEventListener('abort', onOuterAbort)
}
},
// ACP execute cards use the program as their visible title.
// The program is the call's always-visible UI label.
presentCall: args => ({
card: 'generic',
title: args.code,

View File

@@ -69,7 +69,7 @@ export { defineContentToolFixture, type ContentToolFixtureOptions } from './test
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
// stays the single public surface for consumers (producers + the ACP bridge).
// stays the single public surface for tool producers and UI adapters.
export type {
ToolCallKind,
FileLocation,

View File

@@ -8,19 +8,17 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
/**
* Category of a tool call, used by a UI to pick an icon / treatment. A neutral
* vocabulary owned here (NOT an ACP type) so tools describe themselves without
* depending on any client protocol; a UI bridge maps it to its own enum. The
* member set mirrors the common ACP `ToolKind` values; `other` is the default.
* Category of a tool call, used by a UI to pick an icon or treatment. The
* provider-neutral vocabulary lets tools describe themselves without depending
* on a particular client; `other` is the default.
*/
export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'
/**
* A file location a tool reads or modifies, so a capable UI can "follow along" —
* highlight or jump to the file (and line) as the tool runs. Provider-neutral;
* a UI bridge maps it to its own affordance (the ACP bridge forwards it as
* `tool_call.locations`). `path` is what the tool operated on (the model-facing
* path); `line` is an optional 1-based line to focus (e.g. a read's offset).
* highlight or jump to the file (and line) as the tool runs. `path` is what the
* tool operated on (the model-facing path); `line` is an optional 1-based line
* to focus (e.g. a read's offset).
*/
export interface FileLocation {
path: string
@@ -29,10 +27,9 @@ export interface FileLocation {
/**
* A single-file change a tool is about to make, for a UI that renders inline
* diffs (an editor's diff card). Provider-neutral; the ACP bridge forwards it as
* a `{ type: 'diff' }` tool-call content block. `oldText` is `null` for a
* new-file create (nothing to diff against); an overwrite also uses `null`,
* because a call-time presenter has no access to the file's prior content.
* diffs. `oldText` is `null` for a new-file create (nothing to diff against);
* an overwrite also uses `null`, because a call-time presenter has no access to
* the file's prior content.
*/
export interface FileDiff {
path: string

View File

@@ -696,10 +696,8 @@ describe('the run_code dispatch bridge', () => {
it('presents the program as the execute-card title', async () => {
const { ctx } = await setup({ mode: 'code' })
const tool = ctx.tools.get(RUN_CODE_NAME)!
// The program IS the title, mirroring how command tools title their cards
// with the command: an ACP client's execute-card header is the only
// always-visible slot (Zed renders no body content and no raw input for
// execute-kind cards without a real terminal).
// The program is the title, mirroring how command tools label their cards
// with the command while retaining the same value in the expanded input.
expect(tool.presentCall?.({ code: 'return 1' })).toEqual({
card: 'generic',
title: 'return 1',

View File

@@ -7,12 +7,12 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + persisted goals + `/goal` command + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP automation server app: the spine + persisted goals + JSONL persistence + the [`acp`](../acp/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), human/SDK channels and boot glue in [`ui/`](../ui/README.md), the automation transport in [`acp/`](../acp/README.md), and swappable backends in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.
Do not confuse this group with the repo-root [`examples/`](../../examples/AGENTS.md): that directory holds the runnable `cordis.yml` **leaves**; this group holds the **bundles** those leaves load.

View File

@@ -1,77 +1,56 @@
# @deepseek-ai/dsh-acp-demo
The **ACP server app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)) with the front-door cluster an [Agent Client Protocol](../../ui/acp/README.md) server needs, and a `bin` that boots a leaf `cordis.yml` speaking ACP JSON-RPC on stdio.
ACP automation server app: the default agent spine, client-created agents through [`@deepseek-ai/dsh-acp`](../../acp/acp/README.md), JSONL persistence, and semantic checkpointing behind one JSON-RPC stdio bin. Programmatic clients create fresh sessions; this package mounts no human UI.
It is the structured counterpart to [`@deepseek-ai/dsh-tui-demo`](../tui-demo/README.md): both consume the same spine, but ACP creates sessions from its client and reserves stdout for its wire protocol.
## Composition
## What it bakes in — and what it deliberately omits
stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it LEAVES OUT as what it includes:
| Plugin | Why |
| Plugin | Role |
|---|---|
| `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
| `@deepseek-ai/dsh-commands` | the human-command registry used for ACP discovery and direct slash dispatch |
| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | combined exact/FTS session queries and bounded `dsh-session:` snapshots |
| `@deepseek-ai/dsh-session-checkpoint-policy` | semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |
| ~~`@deepseek-ai/dsh-user-approval`~~ | **omitted by default** — permission policy is deployment-specific; sandbox/approval leaves opt in and the ACP bridge then supplies the answerer |
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../../ui/acp/README.md)) |
| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |
| `@deepseek-ai/dsh-agent-spine-demo` | Providerless agent spine with no pre-created agents; `session/new` creates each agent. |
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session logs used by checkpointing, observability, and snapshot replay. |
| `@deepseek-ai/dsh-session-checkpoint-policy` | Durability barriers before model calls and top-level tool effects, plus completed-step checkpoints. |
| `@deepseek-ai/dsh-acp` | Automation-only ACP transport over stdin/stdout. |
The app owns this cluster through one ordered Cordis effect. Teardown drains the ACP bridge before removing the checkpoint policy or persistence backend, so a graceful disconnect persists the real closing `step/end` and `turn/end` events rather than leaving crash recovery to synthesize them. Because the package wires no logger entry, an ACP leaf has **nothing to get wrong by default**: it only picks backends. A leaf author can still add `@cordisjs/plugin-logger-console` as a sibling entry, so the rule remains: never add a stdout logger to an ACP leaf; use a stderr exporter instead.
The app does not install commands, user interaction, session navigation, configuration pickers, or a stdout logger. It owns the four plugins through one ordered effect so ACP sessions quiesce before checkpointing and persistence detach. Leaf configurations supply LLM, executor, sandbox, approval, filesystem, and model-facing tool plugins.
## Config
| Key | Default | Routed to |
|---|---|---|
| `provider` | (required) | the initial provider route for each per-session agent the bridge creates; ACP model selection may replace it per session |
| `model` | (required) | the initial model for each per-session agent; ACP clients may switch among adapter-advertised models |
| `maxParallelToolCalls` | agent-loop default | positive-integer concurrent tool-call cap shared by the bundled loop's agents; `1` is serial |
| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `sessionTitle` | spine example limits | fallback title word/byte limits routed through `dsh-agent-spine-demo` |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `workspaceContext` | (required) | workspace-instruction byte budget/config, or `false`; routed to the providerless-safe `dsh-workspace-context` plugin |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |
| `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` |
| `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` |
| `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer |
| `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` |
| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory and the parent of the derived `session-query.db` index |
| `packChunks` | `false` | write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`) |
| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) |
| `sessionReferences` | service defaults | cross-session candidate and snapshot limits routed to `dsh-session-reference` |
| `provider` | required | Provider route for each ACP-created agent. |
| `model` | required | Model for each ACP-created agent. |
| `maxParallelToolCalls` | agent-loop default | Positive-integer tool-call concurrency cap; `1` is serial. |
| `persona` | — | Deployment persona template for `dsh-system-prompt`. |
| `toolOrder` | lexicographic | Explicit model-facing tool order for `dsh-system-prompt`. |
| `tools` | `{ mode: 'native' }` | Native, Code Mode, or combined model tool transport. |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home shared by bash and local skill discovery. |
| `sessionTitle` | spine example limits | Durable fallback-title limits; titles remain off the ACP wire. |
| `persistenceRoot` | `./.sessions` | JSONL backend root. |
| `packChunks` | `false` | Pack consecutive delta-chunk events in storage. |
| `persistenceCompression` | `zstd` | Checksummed Zstandard frames or raw `none`. |
| `workspaceContext` | required | Workspace-instruction byte budget/config, or `false`. |
| `skills` | owner defaults | Skill registry, local provider, and model-facing skill tool. |
| `toolBash` | owner defaults | Model-facing bash tool config. |
| `toolTasks` | owner defaults | Generic background-task control config, or `false`. |
| `goals` | owner defaults | Persisted same-session goal domain and model tools, or `false`. |
| `llmRetry` | owner defaults | Bounded transient model-request retry policy. |
The leaf supplies the swappable backends: an LLM adapter (`llm-deepseek` for the real model, `llm-replay` for keyless snapshot replay), a bash executor, and optionally a `ctx.fs` provider. Workspace context becomes a no-op without `ctx.fs`; the shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) selects `dsh-sandbox-policy`, `dsh-fs-sandbox`, `dsh-fs-policy`, and `dsh-tool-fs` so baseline instructions and model-facing `read`/`write`/`edit` share one provider, sandbox mode, workspace root, and observed-version policy.
The shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) adds the DeepSeek adapter, sandboxed bash and filesystem providers, one-shot approval policy, compaction, subagents, workflows, hooks, and model-facing tools. Snapshot overlays replace only nondeterministic providers or policy values.
## The bin
## Bin
`dsh-acp-demo [--config path-to-cordis.yml]` (short form `-c`; default `./cordis.yml`):
- loads a gitignored `.env` from the cwd — **skipped** in snapshot REPLAY so a stray key can never trigger a live call;
- honors `DSH_SNAPSHOT=replay` by booting the sibling `cordis.snapshot.yml` (the keyless replay tree, `llm-replay` in place of `llm-deepseek`);
- in a snapshot run, disposes the context on stdin EOF so the session log is fully flushed before exit.
The repository installs Loader's optional `node-addon-require-builtin` peer, so the built bin resolves bare plugin specifiers through the internal module loader under plain Node. (`demo:acp` runs under tsx, whose tsconfig `paths` map resolves them instead.)
All diagnostics go to **stderr** — stdout is the protocol.
`dsh-acp-demo [--config path-to-cordis.yml]` (short form `-c`; default `./cordis.yml`) loads the gitignored `.env`, except in replay mode; `DSH_SNAPSHOT=replay` selects the sibling `cordis.snapshot.yml`; stdin EOF disposes the context and flushes sessions before exit. Loader's installed optional `node-addon-require-builtin` peer resolves bare plugin specifiers for the built bin under plain Node. Diagnostics use stderr because stdout is the ACP wire.
## Model Experience
Indirectly, through `dsh-agent-spine-demo` and `dsh-acp`, which compose each ACP agent's prompt, goal tools, and message history. Direct `/goal` input and output remain outside the model, while accepted mutations append domain-owned model-visible snapshots.
Indirectly, through `dsh-agent-spine-demo` and the leaf's model-facing plugins. ACP prompt text becomes the ordinary logged user message; protocol metadata and permission choices do not enter the model request.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
Append-only per session; the app adds no request-prefix content itself.
## Known Limitations and Deferred Work
- **JSONL persistence is baked in** — config chooses its root but cannot select a different backend; that requires a sibling entry or differently composed app package.
- **User-question and approval mechanisms are omitted by default** — the bridge can answer both when their services/tools are composed, but this front door does not enable those deployment policies itself.
- **A leaf can still corrupt stdout** — the app mounts no console logger, but it cannot prevent a sibling leaf entry from writing non-protocol bytes to the ACP channel.
- **JSONL persistence is fixed** — a different backend requires another composition.
- **Sibling plugins can corrupt stdout** — the app cannot prevent another entry from writing non-protocol bytes.
- **Fresh automation sessions only** — resume and human interaction belong to other front doors.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-acp-demo",
"description": "ACP server app: agent spine + human commands + JSONL persistence + ACP bridge (no stdout logger, hmr, or pre-created agents), with a JSON-RPC stdio bin",
"description": "ACP automation server app: agent spine + JSONL persistence + ACP transport, with a JSON-RPC stdio bin",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -38,18 +38,12 @@
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-command-goal": "^0.0.1",
"@deepseek-ai/dsh-agent-spine-demo": "^0.0.1",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-query-sqlite": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"
@@ -58,20 +52,14 @@
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-command-goal": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-spine-demo": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"cordis": "^4.0.0-rc.7",
"schemastery": "^3.17.0"

View File

@@ -5,7 +5,7 @@
* loading, Loader guards, snapshot config selection, and settled-tree boot live
* in dsh-app-boot. Replay skips `.env` and selects sibling
* `cordis.snapshot.yml` so a stray key cannot trigger a model call. EOF disposes
* and flushes snapshot runs; editors normally own process lifetime. Stdout is
* and flushes snapshot runs; the calling automation owns process lifetime. Stdout is
* reserved for JSON-RPC, so diagnostics go only to stderr.
* @module @deepseek-ai/dsh-acp-demo/bin
*/

View File

@@ -1,7 +1,7 @@
/**
* The ACP server app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}),
* human-command registry, JSONL session persistence, and the
* {@link @deepseek-ai/dsh-acp} bridge. The app owns those plugins through one
* The ACP automation server app: the default agent spine
* ({@link @deepseek-ai/dsh-agent-spine-demo}), JSONL session persistence, and
* the {@link @deepseek-ai/dsh-acp} bridge. The app owns those plugins through one
* ordered lifecycle so ACP sessions quiesce before persistence detaches. It
* writes nothing to stdout.
* It pre-creates no agents and leaves adapters, executors, and optional tools to
@@ -12,11 +12,8 @@
*/
import type { Context } from 'cordis'
import { join } from 'node:path'
import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import CommandService from '@deepseek-ai/dsh-commands'
import * as commandGoal from '@deepseek-ai/dsh-command-goal'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import * as workspaceContext from '@deepseek-ai/dsh-workspace-context'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
@@ -25,17 +22,13 @@ import SessionPersistenceJsonl, {
type JsonlCompression,
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference'
export const name = 'acp-demo'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
/**
* App config: the swappable per-deployment values. `provider` and `model` configure the
* agent template the ACP bridge creates each session's agent from (NOT a
* pre-created agent — ACP creates agents at `session/new`); `persona` is the
* App config: the swappable per-deployment values. `provider` and `model` configure
* each agent the ACP bridge creates at `session/new`; `persona` is the
* deployment persona (forwarded to the system-prompt plugin); `toolOrder` is
* the explicit model-facing tool order (forwarded to the system-prompt plugin);
* `tools` is the tool registry's config (its presentation `mode`, forwarded
@@ -58,14 +51,12 @@ export interface Config {
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
/** Directory for JSONL sessions. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
packChunks?: boolean
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
/** Cross-session reference discovery and snapshot byte budgets. */
sessionReferences?: SessionReferenceConfig
/** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */
workspaceContext: agentCore.Config['workspaceContext']
/** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */
@@ -74,7 +65,7 @@ export interface Config {
toolBash?: NonNullable<agentCore.Config['toolBash']>
/** Generic background-task controls forwarded through agent-core; set false to omit their tool surface. */
toolTasks?: NonNullable<agentCore.Config['toolTasks']>
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */
/** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */
goals?: agentCore.GoalConfig | false
/** Bounded transient model-request retry policy forwarded through agent-core. */
llmRetry?: NonNullable<agentCore.Config['llmRetry']>
@@ -98,7 +89,6 @@ export const Config: z<Config> = z.object({
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
packChunks: z.boolean().default(false),
persistenceCompression: JsonlCompressionSchema,
sessionReferences: SessionReferenceService.Config,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
@@ -109,9 +99,9 @@ export const Config: z<Config> = z.object({
/* jscpd:ignore-end */
/**
* Compose the spine with the ACP front door. The agent-spine-demo bundle pre-creates
* Compose the spine with the ACP automation transport. The agent-spine-demo bundle pre-creates
* NO agents (its `agents` list defaults to `[]`) and carries the deployment
* `persona`; the JSONL backend and derived query index persist under
* `persona`; the JSONL backend persists under
* `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates one
* agent per `session/new` from the provider/model pair. The composite effect
* unloads in reverse order, keeping checkpoint and persistence listeners
@@ -122,10 +112,7 @@ export function apply(ctx: Context, config: Config): void {
const goals = config.goals ?? {}
const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT
ctx.effect(function* () {
yield ctx.plugin(CommandService).dispose
if (goals !== false) yield ctx.plugin(commandGoal).dispose
yield ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }).dispose
yield ctx.plugin(UserInteractionService).dispose
// Same rationale as the Config schema above: each front door forwards its own
// persistence passthroughs rather than sharing a facade with stdio-demo.
/* jscpd:ignore-start */
@@ -136,8 +123,6 @@ export function apply(ctx: Context, config: Config): void {
}).dispose
/* jscpd:ignore-end */
yield ctx.plugin(sessionCheckpointPolicy).dispose
yield ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') }).dispose
yield ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}).dispose
yield ctx.plugin(acp, { provider: config.provider, model: config.model }).dispose
}, 'acp-demo.composition')
}

View File

@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Message } from '@deepseek-ai/dsh-llm'
import * as acpAgent from '../src/index.ts'
@@ -84,32 +83,27 @@ describe('dsh-acp-demo composition', () => {
persona: 'hi',
persistenceRoot: '/tmp/dsh-acp-demo-test',
persistenceCompression: 'none',
sessionReferences: { candidateLimit: 1 },
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('sessionQuery')).toBeDefined()
expect(ctx.get('sessionReferences')).toBeDefined()
expect(ctx.get('sessionQuery')).toBeUndefined()
expect(ctx.get('sessionReferences')).toBeUndefined()
expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none')
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('userInteraction')).toBeUndefined()
expect(ctx.get('commands')).toBeUndefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
expect(ctx.get('goals')).toBeDefined()
expect(ctx.get('tools')?.get('get_goal')).toBeDefined()
const target = ctx.sessions.create(SessionId('candidate-target'))
ctx.sessions.create(SessionId('candidate-one'))
ctx.sessions.create(SessionId('candidate-two'))
await expect(ctx.sessionReferences.listCandidates({ id: target.id, session: target } as Agent))
.resolves.toHaveLength(1)
// No pre-created agents — ACP session/new creates them on demand.
expect(ctx.get('agents')!.list()).toHaveLength(0)
await ctx.fiber.dispose()
})
it('can explicitly omit the persisted-goal stack and its command', async () => {
it('can explicitly omit the persisted-goal stack', async () => {
const ctx = await mount({
provider: 'mock',
model: 'mock',
@@ -117,12 +111,7 @@ describe('dsh-acp-demo composition', () => {
workspaceContext: false,
})
expect(ctx.get('goals')).toBeUndefined()
const handle = await ctx.agents.create({
sessionId: 'disabled-goals' as import('@deepseek-ai/dsh-session').SessionId,
agentOptions: { provider: 'mock', model: 'mock' },
})
expect(ctx.commands.find(handle.agent, 'goal')).toBeUndefined()
await handle.dispose()
expect(ctx.get('tools')?.get('get_goal')).toBeUndefined()
await ctx.fiber.dispose()
})

View File

@@ -18,7 +18,6 @@ import { Readable, Writable } from 'node:stream'
import { promisify } from 'node:util'
import { zstdDecompress } from 'node:zlib'
import { afterEach, describe, expect, it } from 'vitest'
import { ACP_SESSION_REFERENCE_META_KEY } from '@deepseek-ai/dsh-acp'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer and
@@ -36,8 +35,7 @@ const dshPackages = [
'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl',
'session-query/session-query', 'session-query/session-query-sqlite',
'context/session-reference', 'ui/acp', 'examples/acp-demo', 'util/paths',
'acp/acp', 'examples/acp-demo', 'util/paths',
]
const vendorPackages = [
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
@@ -45,8 +43,8 @@ const vendorPackages = [
]
// Resolve ACP's declared third-party dependencies from that package, not this test: pnpm's strict
// layout need not hoist them. Symlink those exact paths into the plain-Node consumer.
const npmDeps = ['@agentclientprotocol/sdk', 'zod']
const acpPkgDir = join(repoRoot, 'packages/ui/acp')
const npmDeps = ['@agentclientprotocol/sdk']
const acpPkgDir = join(repoRoot, 'packages/acp/acp')
async function pkgName(absDir: string): Promise<string> {
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
@@ -72,7 +70,7 @@ async function makeConsumer(): Promise<string> {
await link(abs, await pkgName(abs), nm)
}
for (const dep of npmDeps) {
// Resolve from `ui/acp`'s package.json URL (the package that declares the
// Resolve from ACP's package.json URL (the package that declares the
// dep), not this test file's location — `acp-agent` does not depend on these.
const fromAcp = pathToFileURL(join(acpPkgDir, 'package.json')).href
const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp))
@@ -154,8 +152,12 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
Writable.toWeb(child.stdin!) as WritableStream<Uint8Array>,
Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
)
const updates: SessionNotification['update'][] = []
const makeClient = (_a: AcpAgent): Client => ({
sessionUpdate(_p: SessionNotification): Promise<void> { return Promise.resolve() },
sessionUpdate(params: SessionNotification): Promise<void> {
updates.push(params.update)
return Promise.resolve()
},
requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
},
@@ -163,33 +165,17 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n
const client = new ClientSideConnection(makeClient, stream)
const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
// A response at all proves the built bin booted the bridge (the settle-race
// regression would exit before answering); loadSession proves the real app
// mounted, not a collapsed export shape.
expect(init.agentCapabilities?.loadSession).toBe(true)
expect(init.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(init.agentCapabilities).toEqual({
promptCapabilities: { image: false, audio: false, embeddedContext: false },
})
const sessionCwd = consumer
const { sessionId } = await client.newSession({ cwd: sessionCwd, mcpServers: [] })
const result = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'reply' }] })
expect(result.stopReason).toBe('end_turn')
await expect.poll(async () => {
return (await client.listSessions({ cwd: sessionCwd })).sessions.find(candidate => candidate.sessionId === sessionId)
}).toMatchObject({
sessionId,
cwd: sessionCwd,
title: 'reply',
})
const listed = await client.listSessions({ cwd: sessionCwd })
const reference = listed.sessions.find(candidate => candidate.sessionId === sessionId)
?._meta?.[ACP_SESSION_REFERENCE_META_KEY]
expect(reference).toBeTypeOf('object')
expect(reference).not.toBeNull()
expect(reference).toHaveProperty('uri')
if (typeof reference !== 'object' || reference === null || !('uri' in reference)) {
throw new Error('expected session reference metadata')
}
expect(reference.uri).toBeTypeOf('string')
expect(reference.uri).toMatch(/^dsh-session:[A-Za-z0-9_-]+$/u)
await expect.poll(() => updates).toEqual([{
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'ACP BUILT OK' },
}])
const sessionsRoot = join(sessionCwd, '.sessions')
let log: string | undefined
await expect.poll(async () => {

View File

@@ -17,11 +17,10 @@ import {
} from '@agentclientprotocol/sdk'
/**
* Source-path Loader smoke through the package's own bin, covering initialize, session/new, and
* session/load across the `unwrapExports` path implicated by postmortem 0001. Session creation and
* unknown-id loading reach factories but not the model, so a dummy key is sufficient. The temp cwd
* is also the session workspace, and an explicit root tsconfig keeps unbuilt path aliases resolvable
* when the child starts outside the repository.
* Source-path Loader smoke through the package's own bin, covering the
* automation server's initialize and fresh-session path across the
* `unwrapExports` shape implicated by postmortem 0001. Session creation reaches
* the factory but not the model, so a dummy key is sufficient.
*/
const binScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url))
@@ -107,7 +106,7 @@ async function boot(): Promise<Spawned & { cwd: string }> {
}
describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => {
it('boots via its bin and answers initialize → session/new → session/load', async () => {
it('boots via its bin and exposes only fresh text sessions', async () => {
const { client, cwd, stderr } = await boot()
// initialize: a broken export shape (collapsed bridge plugin, dropped inject)
// crashes the tree on the first service read here — see postmortem 0001.
@@ -115,22 +114,14 @@ describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => {
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {},
})
expect(init.agentCapabilities?.loadSession).toBe(true)
expect(init.agentCapabilities).toEqual({
promptCapabilities: { image: false, audio: false, embeddedContext: false },
})
// session/new reaches the agent FACTORY (create) without the model.
const { sessionId } = await client.newSession({ cwd, mcpServers: [] })
expect(sessionId).toBeTruthy()
// session/load reaches the resume FACTORY + persistence without the model: load an UNKNOWN
// id (loading the live `sessionId` would correctly reject as "already loaded"). Persistence
// and resume run from the JSON-RPC loop outside bridge injection; a healthy tree reaches
// not-found, while a collapsed export would fail earlier with missing injection.
const unknownId = '00000000-0000-4000-8000-000000000000'
await client.loadSession({ sessionId: unknownId, cwd, mcpServers: [] }).then(
() => { throw new Error('expected session/load of an unknown id to reject') },
(error: unknown) => { expect(String(error)).not.toContain('without inject') },
)
expect(stderr.join('')).not.toContain('without inject')
}, 30_000)
})

View File

@@ -21,22 +21,7 @@
"path": "../../ui/app-boot"
},
{
"path": "../../ui/acp"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-query/session-query-sqlite"
},
{
"path": "../../context/session-reference"
},
{
"path": "../../ui/commands"
},
{
"path": "../../goal/command-goal"
"path": "../../acp/acp"
},
{
"path": "../../core/agent"
@@ -47,12 +32,6 @@
{
"path": "../../context/workspace-context"
},
{
"path": "../../ui/user-interaction"
},
{
"path": "../../ui/tool-ask-user"
},
{
"path": "../../session-persistence/session-checkpoint-policy"
},

View File

@@ -45,7 +45,7 @@ The spine is everything COMMON to every front door. The swappable and front-door
- **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider.
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
- **presentation + per-app infra** — the terminal TUI or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
@@ -63,7 +63,7 @@ For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/
## Why a code bundle, not a shared YAML include
A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. App packages make stdout-safe ACP wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling.
A YAML include can deduplicate config but cannot own a bin or provide front-door defaults. The ACP app package makes protocol-pure stdout wiring the default, though a leaf can still add an unsafe logger. Bundle children register services in the root isolate-keyed store, so injected leaf siblings see them without load-order coupling.
The bounded retry policy may repeat a transiently failed request in a new numbered step. Retry status and failed partial chunks stay outside model history, each provider attempt can still incur billing, front doors derive usage across every logged step, and the reconstructed request preserves the prior prefix for provider cache reuse.

View File

@@ -7,7 +7,7 @@
import { structuredPatch } from 'diff'
import type { FileDiff } from '@deepseek-ai/dsh-tools'
/** Context lines shown on each side of an applied hunk (matches claude-agent-acp). */
/** Context lines shown on each side of an applied hunk. */
export const DIFF_CONTEXT = 3
/**
@@ -15,8 +15,7 @@ export const DIFF_CONTEXT = 3
* contextual-diff hunks. Attached opaquely (as `unknown`) on the tool result and
* persisted with the session log — it must be JSON-serializable (the session
* validates this at `append`), so `presentResult` reproduces the diff card on
* replay. The producing tool owns this shape; the bridge only sees the opaque
* `meta` and the tool narrows it back via {@link diffsFromMeta}.
* replay. The producing tool owns and narrows this opaque shape.
*/
export type FsDiffMeta = { diffs: FileDiff[] }

View File

@@ -1,6 +1,6 @@
/**
* Derive the working directory a filesystem tool resolves relative paths against: the calling
* agent's per-session workspace (`exec.agent.session.header.cwd`), so each ACP session's
* agent's per-session workspace (`exec.agent.session.header.cwd`), so each session's
* `read`/`write`/`edit` act on ITS workspace, not the server's launch dir — mirroring how
* `dsh-tool-bash` defaults a bash `workdir` to the session cwd.
* Non-agent calls return `undefined`, leaving the fallback in the provider rather than reading

View File

@@ -125,9 +125,8 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
after: outcome.after,
}
},
// Pure display: a diff card (an editor renders write as a new-file / full- replace diff).
// `oldText: null` — a call-time presenter has no access to the file's prior content, so
// even an overwrite renders new-file style, matching claude-agent-acp.
// Pure display: a diff card. A call-time presenter has no access to prior
// file content, so `oldText: null` also represents an overwrite here.
presentCall(args): DiffCallView {
return {
card: 'diff',
@@ -136,10 +135,9 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxSurface): void {
locations: [{ path: args.file_path }],
}
},
// Result-time display: a `diff` card so the completed `tool_call_update` re-installs the
// diff rather than the model-facing result text (an ACP `tool_call_update.content` REPLACES
// the call's content, so a text result would clobber the pending diff card). Overwrites use
// applied metadata; creates and identical overwrites use the replay-safe args fallback.
// Result-time display repeats the diff because completed views replace the
// pending view. Overwrites use applied metadata; creates and identical
// overwrites use the replay-safe args fallback.
presentResult(args, result: ToolResult): DiffResultView | undefined {
if (result.isError) return undefined
const diffs = diffsFromMeta(result.meta)

View File

@@ -2,7 +2,7 @@
* Unit tests for the result-time contextual-diff computation (`src/diff.ts`):
* the pure before/after → {@link FileDiff}[] hunk builder and the defensive
* `meta` narrowing. These pin the exact hunk reconstruction (context lines,
* multi-hunk replaceAll, pure insertion/deletion, no-op) the ACP bridge renders.
* multi-hunk replaceAll, pure insertion/deletion, no-op) that UIs render.
*/
import { describe, expect, it } from 'vitest'

View File

@@ -284,8 +284,8 @@ describe('bare provider (no dsh-fs-policy)', () => {
})
// Per-session cwd: a relative file_path resolves against the calling session's workspace
// (`exec.agent.session.header.cwd`), not the backend's config.cwd so an ACP editor's
// per-session dir wins, matching dsh-tool-bash.
// (`exec.agent.session.header.cwd`), not the backend's config.cwd, so the
// caller-selected session workspace wins, matching dsh-tool-bash.
describe('per-session cwd', () => {
let sessionDir: string
beforeEach(async () => {

View File

@@ -425,8 +425,8 @@ describe('edit tool', () => {
})
describe('tool-owned presentation (pure presentCall)', () => {
// presentCall is a pure display function of args (no I/O); it drives the ACP
// card's title/kind and the `locations` an editor follows along to.
// presentCall is a pure display function of args (no I/O); it drives the
// card's title/kind and the `locations` a UI follows along to.
const presentCall = async (name: string, args: unknown) => {
const { ctx } = await setup()
return ctx.tools.get(name)?.presentCall?.(args)
@@ -478,7 +478,7 @@ describe('tool-owned presentation (pure presentCall)', () => {
describe('result-time contextual diff (meta + presentResult)', () => {
// An edit records the applied contextual hunk on `tool/result` meta, and the tool's
// presentResult narrows it back into a `diff` result card the bridge renders.
// presentResult narrows it back into a replayable `diff` result card.
const withContext = 'a\nb\nc\nOLD\nd\ne\nf\n'
it('edit: execute attaches the applied hunk as meta { diffs }', async () => {
@@ -519,9 +519,8 @@ describe('result-time contextual diff (meta + presentResult)', () => {
})
it('write CREATE: an empty applied-diff projection still falls back to the whole-file diff card', async () => {
// A create has no prior content, yet the completed card must be a `diff` — an
// ACP tool_call_update.content REPLACES the call's content, so a non-diff result would
// clobber the pending new-file diff.
// A create has no prior content, yet the completed replacement view must
// remain a diff instead of clobbering the pending new-file diff with text.
const { ctx } = await setup()
const session = { header: {} }
const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session })

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-command-goal
Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI and ACP execute it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions.
Human-facing `/goal` control over [`ctx.goals`](../goal/README.md). The plugin registers one global command through [`ctx.commands`](../../ui/commands/README.md), so every composed command adapter discovers it; the shipped TUI executes it without a model turn. The [human goal-command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-human-goal-command.md) owns the UX and composition decisions.
## Command contract
@@ -30,7 +30,7 @@ The producer injects `commands` and `goals`. A custom app mounts their owners pl
name: '@deepseek-ai/dsh-command-goal'
```
The TUI and ACP demo apps enable the complete persisted-goal stack and this command by default; `goals: false` removes both. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation.
The TUI app enables the complete persisted-goal stack and this command by default. The ACP automation app enables the domain and model tools without mounting the command registry; `goals: false` removes that stack. The UI-less `agent-spine-demo` requires an explicit `goals: {}` so headless one-shot callers do not silently change from one physical turn to a multi-round operation.
## Model Experience
@@ -50,7 +50,7 @@ Command discovery and direct output do not affect the cache. A mutation appends
## Known Limitations and Deferred Work
- **Plain-text interaction only** — the generic command registry has no modal edit form or replacement-confirmation callback; inline edit and explicit clear keep destructive intent deterministic on both TUI and ACP.
- **Plain-text interaction only** — the generic command registry has no modal edit form or replacement-confirmation callback; inline edit and explicit clear keep destructive intent deterministic across adapters.
- **No per-command round-cap argument** — `defaultMaxGoalRounds` remains deployment config, while a direct human request may ask the model to edit `max_goal_rounds` through the separately authorized goal tool.
- **No continuous status widget** — bare `/goal` is the portable observation surface; adapter-specific badges and reconnectable command output remain future UI work.
- **TUI and ACP only** — the headless CLI and JSON-RPC adapters do not consume `ctx.commands`. Ordinary human prompts can still authorize the model-facing goal tools when those are composed.
- **TUI only in the shipped apps** — the headless CLI, ACP automation, and JSON-RPC adapters do not consume `ctx.commands`. Ordinary prompts can still authorize model-facing goal tools when those are composed.

View File

@@ -8,7 +8,7 @@ The model-facing control surface for [`ctx.goals`](../goal/README.md): `get_goal
- `create_goal(objective, max_goal_rounds?)` creates one goal from a direct top-level human turn. The model may infer long-running goal intent without an exact command phrase; non-human turns and subagents are rejected at execution.
- `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`. Replacements belong only to `edit`; `blocked_reason` is required only for `blocked` and is persisted with the stable code `model-reported`. Strict-schema empty-string and zero fillers count as omitted, while meaningful values remain limited to their action.
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. ACP and other clients receive pure generic cards: read for `get_goal`, other for mutations. Mutation cards select the first meaningful action value and otherwise show the goal id, so accepted fillers never produce blank input.
All calls are exclusive, so a model-ordered batch observes earlier mutations and their new revisions. UI clients receive pure generic cards: read for `get_goal`, other for mutations. Mutation cards select the first meaningful action value and otherwise show the goal id, so accepted fillers never produce blank input.
All three canonical values match the compact JSON already rendered to Native callers: `{ goal: null }` or `{ goal: { id, revision, objective, phase, roundsStarted, maxGoalRounds, blockedReason? }, activation }`. Programmatic consumers therefore receive the same domain structure without parsing the rendered JSON.

View File

@@ -137,7 +137,7 @@ export function apply(ctx: Context, config: Config): void {
const groups: MatcherGroup[] = parsed[point] ?? []
const outputs: HookOutput[] = []
// Run the hook in the agent's session workspace (the `session/new` cwd on the session
// header), not the executor default (the ACP server's launch dir).
// header), not the executor or front-door process's launch dir.
const workdir = opts.agent?.session.header.cwd
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to the session
// workspace (the same dir the hook runs in).

View File

@@ -2,8 +2,7 @@
* One-step host startup seam: boot core → assemble ApiProxy → assemble the
* fetch handler. The returned RunningHost is shell-agnostic — node:http
* (dsh web), in-process injection (dsh -p, tests), an IPC bridge (future
* Electron sidecar), and front-door plugin mounting (future dsh acp) all
* consume the same shape.
* Electron sidecar), and automation transports all consume the same shape.
*/
import type { Context } from 'cordis'
@@ -32,8 +31,7 @@ export interface RunningHost {
defaults: HostDefaults
/**
* Root context — a formal seam, not an escape hatch: (1) the mount point for
* protocol front-door plugins (`dsh acp` = startHost() → ctx.plugin(uiAcp, config));
* (2) headless session-event subscription. Discipline: consuming clients must
* automation transports; (2) headless session-event subscription. Discipline: consuming clients must
* not bypass `api` through ctx; shells must not ctx.plugin to alter the
* assembly (mounting a front door is the shell's own shape, not an assembly change).
*/

View File

@@ -26,7 +26,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire
contextWindow: 64000
```
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`, each with a 128,000-token context window; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for UI selectors and deployment introspection, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id.
`contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists it returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-tool-lsp
The model-facing **`lsp` tool** over `ctx.lsp`: one read-only tool with four operations for precise code navigation. It owns the model schema, prompt guidance, coordinate conversion, result limits and formatting, and ACP presentation; it imports no provider.
The model-facing **`lsp` tool** over `ctx.lsp`: one read-only tool with four operations for precise code navigation. It owns the model schema, prompt guidance, coordinate conversion, result limits and formatting, and UI presentation; it imports no provider.
Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). Injects `tools`, `lsp`, and `systemPrompt`.
@@ -68,7 +68,7 @@ Capped per tool result by `maxResultChars`, with `maxLocations` additionally bou
Tool results append after the cached request prefix and do not directly invalidate it.
### ACP presentation
### UI presentation
#### What the model sees
@@ -80,7 +80,7 @@ Zero direct token effect because rendering is client-side only.
#### KV Cache effect
None; ACP presentation is outside the model request.
None; UI presentation is outside the model request.
## Known Limitations and Deferred Work

View File

@@ -1,7 +1,7 @@
/**
* Pure formatting and coordinate conversion for the `lsp` tool: one-based↔zero-based UTF-16 cursor
* conversion, workspace-grouped location rendering with `file:`-URI resolution, complete-result
* capping, and ACP presentation. No I/O — a UI may call the presenter on live streaming and on
* capping, and UI presentation. No I/O — a UI may call the presenter on live streaming and on
* replay, so it depends only on the tool arguments.
* @module @deepseek-ai/dsh-tool-lsp/render
*/
@@ -152,9 +152,9 @@ export function renderUri(uri: string, workspaceRoot: string): string {
}
/**
* ACP presentation for a pending `lsp` call. Uses a generic search card; the title carries the
* operation and one-based cursor, and `locations` focuses the queried line (ACP `FileLocation` has
* no character, so the title preserves the column).
* UI presentation for a pending `lsp` call. Uses a generic search card; the title carries the
* operation and one-based cursor, and `locations` focuses the queried line. The shared location
* shape has no character, so the title preserves the column.
* @param args - the raw tool arguments.
* @returns the generic call view.
*/

View File

@@ -6,4 +6,4 @@ Plan mode is one logged, per-agent collaboration state. It is a single **product
|---|---|---|
| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]` entry and `/plan off` exit, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` |
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. ACP maps this capability onto its generic `default` / `plan` picker; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. Interactive adapters use the plugin-owned `/plan` command; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).

View File

@@ -14,7 +14,7 @@ While active, `plan:policy` renders the configured `section`. The plugin always
When `ctx.commands` is composed, the package registers `/plan [message]` and reserves the exact argument `off` for direct exit. Bare `/plan` selects plan mode; any other non-empty argument selects it first and is then submitted through `agent.steer()`, so it becomes the next step's ordinary logged user message under plan guidance. `/plan off` selects inactive without sending model input; it also cancels a pending entry before plan mode reaches a request.
ACP is an adapter, not the owner of this vocabulary: it advertises the fixed wire ids `default` and `plan`, maps `session/set_mode` to the boolean service, and translates committed `plan/mode` events back to `current_mode_update`.
The TUI consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary.
## Configuration
@@ -86,3 +86,4 @@ Mode transitions do not change the tool catalog; plan arguments and review resul
- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls.
- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it.
- Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option.
- The `exit_plan_mode` review arc (submit → human review → approved flip or rejected feedback) is covered by package tests only; its assembled-application snapshot left with the retired ACP UI scenarios ([automation-only ACP](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)) and the TUI keyless scenarios exercise only `/plan` entry and `/plan off` exit.

View File

@@ -71,8 +71,8 @@ describe('plan mode through the agent loop', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('it-plan-seed'), { provider: 'mock', model: 'mock' })
// Selected while idle (the ACP picker shape): the pending intent flushes at
// the first prompt-submit, BEFORE the first assembly.
// Selected while idle: the pending intent flushes at the first
// prompt-submit, BEFORE the first assembly.
ctx.planMode.set(agent, true)
agent.followup([{ type: 'text', text: 'explore the repo' }])

View File

@@ -2,7 +2,7 @@
Six model-facing tools over `ctx.pty`: `terminal_open`, `terminal_send`, `terminal_read`, `terminal_signal`, `terminal_close`, and `terminal_list`. Every operation requires the exact initiating `Agent`, so a model cannot address another agent's terminal even if it learns the id.
`terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight and the PTY service's exclusive per-session send reservation occur before the task id is returned, completion is collected with `task_output`, and `task_kill` delivers `SIGINT` to the foreground process group. Foreground sends use terminal ACP call/result cards. Background sends use a generic execute card; open, read, signal, close, and list use generic `execute`, `read`, `execute`, `delete`, and `read` cards respectively. None declares source locations.
`terminal_send(run_in_background: true)` reuses `ctx.tasks`; task preflight and the PTY service's exclusive per-session send reservation occur before the task id is returned, completion is collected with `task_output`, and `task_kill` delivers `SIGINT` to the foreground process group. Foreground sends use terminal call/result cards. Background sends use a generic execute card; open, read, signal, close, and list use generic `execute`, `read`, `execute`, `delete`, and `read` cards respectively. None declares source locations.
## Config

View File

@@ -1,4 +1,4 @@
/** Model and ACP rendering for persistent terminal tool results. */
/** Model and UI rendering for persistent terminal tool results. */
import { TextRetainer } from '@deepseek-ai/dsh-retention'

View File

@@ -23,7 +23,7 @@ The optional `./invariant` companion rejects a forged durable `sandbox/mode` eve
## The per-session store
A runtime switch (an ACP `session/set_config_option`, a test scenario) is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event.
A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event is log-only (the `approval/*` precedent): the model learns the mode from the enforcing tools' denial markers, never from the event.
## Model Experience

View File

@@ -1,7 +1,7 @@
/**
* Per-session sandbox-mode override: the session log as the store. A runtime
* switch (an ACP `session/set_config_option`, a test scenario) is recorded as
* one `sandbox/mode` event on the session it applies to;
* switch (a UI policy control or test scenario) is recorded as one
* `sandbox/mode` event on the session it applies to;
* `effective = fold(events) ?? the deployment default`, so an override
* survives restart by replay, two sessions can never see each other's state,
* and there is no external config store. The event is log-only (the

View File

@@ -168,7 +168,7 @@ const PROJECT_QUESTION_STEPS: readonly WizardStep<ProjectAnswerState>[] = [
id: 'interface',
message: 'Run interface',
options: [
{ value: 'acp', label: 'ACP server' },
{ value: 'acp', label: 'ACP automation server' },
{ value: 'tui', label: 'Terminal TUI' },
{ value: 'embed', label: 'Embedded context' },
],

View File

@@ -183,7 +183,7 @@ describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', ()
"kind": "select",
"message": "Run interface",
"options": [
"ACP server",
"ACP automation server",
"Terminal TUI",
"Embedded context",
],
@@ -287,12 +287,6 @@ describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', ()
"label": "Tool timeout policy",
"required": false,
},
{
"choices": undefined,
"default": false,
"label": "Ask the user from the model loop",
"required": false,
},
],
},
{

View File

@@ -6,7 +6,7 @@ The package owns the builtin typed-spec catalog, provider/app behavior entities,
All business and document validation completes before commit writes any affected file. Commit detects external edits made after the session opened, but deliberately provides no cross-file rollback after writing starts.
Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes the human-command and user-interaction services before the bridge.
Builtin features are provider, bash, app, persistence, HMR, filesystem, todo, skill, web, subagent, workflow, compaction, hooks, repeat-tool guard, timeout policy, and ask-user. The catalog owns feature options, required and non-default Cordis plugin config, feature requirements, resource contribution, and round-trip markers; create and config use the same registry and configurator. The ACP app option contributes only the automation bridge; interactive services belong to TUI or Web compositions.
`SdkProject.open()` requires only readable root `package.json` and `cordis.yml`. A Cordis config entry anchors feature installation; a package present only through a linked NPM dependency closure leaves the feature absent. Once an owned Cordis config entry exists, an incomplete resource shape is `inconsistent` and cannot be modified automatically.

View File

@@ -73,14 +73,6 @@ class AppOption extends FeatureOption {
case 'acp':
return new ProjectContribution([
...appProjectResources(profile, this.id),
...npmCordisConfigEntry(ID, {
id: 'commands',
name: '@deepseek-ai/dsh-commands',
}),
...npmCordisConfigEntry(ID, {
id: 'user-interaction',
name: '@deepseek-ai/dsh-user-interaction',
}),
...npmCordisConfigEntry(ID, {
id: 'acp',
name: '@deepseek-ai/dsh-acp',
@@ -119,7 +111,7 @@ export class AppFeature extends ExclusiveOptionFeature {
override readonly required = true
override readonly requires = [featureId('spine')]
override readonly options = [
new AppOption('acp', 'ACP server'),
new AppOption('acp', 'ACP automation server'),
new AppOption('tui', 'Terminal TUI'),
new AppOption('embed', 'Embedded context'),
]

View File

@@ -347,7 +347,7 @@ config:
id: 'ask-user',
summary: 'Ask the user from the model loop',
mode: 'single',
supportedInterfaces: ['acp', 'tui'],
supportedInterfaces: ['tui'],
options: [{
id: 'default',
label: 'ask_user_question tool',

View File

@@ -5,9 +5,9 @@
Built with the DeepSeek Harness SDK using the {{model}} model.
{{#if isAcp}}
## Run as an ACP server
## Run as an ACP automation server
Run `{{packageManager}} start` and configure your ACP client to launch this project. Standard output is reserved for ACP JSON-RPC.
Run `{{packageManager}} start` and configure a programmatic ACP client to launch this project. Standard output is reserved for ACP JSON-RPC.
{{else}}
{{#if isTui}}
## Run in a terminal

View File

@@ -289,12 +289,13 @@ describe('SdkProject and ProjectEditSession', () => {
edit.configureFeature(registry.get(featureId('app')), selection('app', ['acp']))
const acp = (await edit.commit()).project
expect(acp.profile.runInterface).toBe('acp')
expect(acp.cordis.entry('commands')).toMatchObject({ name: '@deepseek-ai/dsh-commands' })
expect(acp.cordis.entry('commands')).toBeUndefined()
expect(acp.cordis.entry('user-interaction')).toBeUndefined()
expect(acp.packageManifest().scripts).toMatchObject({
dev: 'dsh-sdk dev index.ts',
start: 'dsh-sdk start index.js',
})
expect(await readFile(join(acp.root, 'README.md'), 'utf8')).toContain('Run as an ACP server')
expect(await readFile(join(acp.root, 'README.md'), 'utf8')).toContain('Run as an ACP automation server')
expect(await readFile(join(acp.root, 'index.ts'), 'utf8')).not.toContain('agents.create')
const acpRegistry = createBuiltinRegistry(acp.profile)
@@ -324,12 +325,16 @@ describe('SdkProject and ProjectEditSession', () => {
.toContain('missing package.json script dev')
})
it('rejects enabled features that do not apply to the target app interface', async () => {
it('rejects ask-user on non-interactive app interfaces', async () => {
const project = await createCommitted([selection('ask-user', ['default'])])
const registry = createBuiltinRegistry(project.profile)
const edit = project.edit(registry)
edit.configureFeature(registry.get(featureId('app')), selection('app', ['embed']))
await expect(edit.commit()).rejects.toThrow('feature ask-user is not available for embed')
const embed = project.edit(registry)
embed.configureFeature(registry.get(featureId('app')), selection('app', ['embed']))
await expect(embed.commit()).rejects.toThrow('feature ask-user is not available for embed')
const acp = project.edit(registry)
acp.configureFeature(registry.get(featureId('app')), selection('app', ['acp']))
await expect(acp.commit()).rejects.toThrow('feature ask-user is not available for acp')
})
it('supports disabled feature reconfiguration and rejects invalid state operations', async () => {

View File

@@ -85,7 +85,7 @@ Change file: package.json
"choices": [
{
"default": false,
"label": "ACP server",
"label": "ACP automation server",
"value": "acp",
},
{

View File

@@ -542,23 +542,23 @@ describe('ConfigWorkflow', () => {
expect(result.commit?.project.cordis.entry('agent-core')).toBeUndefined()
})
it('disables ask-user when switching its app interface to embed', async () => {
it('disables ask-user when switching its app interface to ACP', async () => {
const project = await committedProject([
{ id: featureId('ask-user'), options: ['default'] },
], [], 'acp')
], [], 'tui')
const registry = createBuiltinRegistry(project.profile)
const output = outputBuffer()
const workflow = new ConfigWorkflow(new QueuePort([
[
{ value: 'feature:provider', choices: ['deepseek'] },
{ value: 'feature:app', choices: ['embed'] },
{ value: 'feature:app', choices: ['acp'] },
{ value: 'feature:persistence', choices: ['jsonl'] },
{ value: 'feature:ask-user', choices: ['default'] },
],
true,
]), output.stream, async () => {})
const result = await workflow.run(project, registry)
expect(result.commit?.project.profile.runInterface).toBe('embed')
expect(result.commit?.project.profile.runInterface).toBe('acp')
expect(result.commit?.project.cordis.entry('tool-ask-user')?.disabled).toBe(true)
expect(output.read()).toContain('Disable feature: ask-user')
})

View File

@@ -96,6 +96,6 @@ Append-only; newly visible content follows the reusable request prefix and does
- **A fresh process per run** — persistent-process pooling is a future optimization ([the seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)).
- **Local workspaces only** — the resolved cwd is a local path handed to a child on the same machine; workspace mapping for a remote ACP agent would need its own backend capability and is not designed here.
- **No optional start-time capabilities** — this provider cannot apply the local harness's `outputSchema`, depth cap, tool filter, or persona inside the remote process, so it advertises none and the service rejects requests that require them.
- **Only `agent_message_chunk` text is collected** — the child's tool-call activity, thought chunks, and plan updates are not surfaced to the parent.
- **Only committed `agent_message_chunk` text is collected** — the automation server keeps reasoning, tool activity, plans, and other trace data in the child session log rather than emitting them on ACP.
- **Permission prompts are auto-answered** (`permission: allow | reject`) — no human is surfaced a child's `session/request_permission` in this cut.
- **No snapshot-tier replay coverage** (`TODO(acp-subagent-replay)`) — an ACP child is its own process with its own replay shape, deferred.

View File

@@ -6,7 +6,7 @@ Four layers, importable separately:
- **`launchAcpTestAgent` (launcher)** — boots a source agent under tsx or a built `lib` agent under plain Node from a supplied cwd, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through startup, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. When Windows accepts forced termination but publishes its exit marker asynchronously, shutdown gives that marker a bounded grace before treating fallback refusal as a second failure. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy.
- **`runScenario` (harness)** — drives ACP JSON-RPC stdio from a deterministic `input.json` script through the launcher, tees raw stdout for the expected-output and purity checks, and harvests every persisted raw JSONL session log (parent and subagent children, primary-first) after graceful stdin EOF. `AgentUnderTest` supplies absolute `binScript`, optional `libBinScript`, `configPath`, and `tsconfigPath` paths because the subprocess cwd is outside the repo; `workspaceParent` may move the generated child cwd from the platform temp directory when that grant is itself under test. Startup failures preserve captured agent stderr in the rejected diagnostic.
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; `session_info_update.updatedAt``{{updatedAt}}`; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs and every native/JavaScript filesystem spelling of the generated cwd → tokens, longest-first; cwd-rooted separators selected as canonical `/` or host-native; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept, the same cwd-path policy), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, structure kept — [pinned-header Agent Note](../../../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario expected-output and re-persisted-log comparisons, record/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.expected.md` plus `tool-schemas.expected.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Refresh expands packed timing envelopes before aligning existing volatile event times, so switching between packed and unpacked layouts cannot shift later records; fresh chunk-fragment arrays remain authoritative. A newly inserted `session/title` receives its preceding event's time so feature-driven insertions do not churn the remainder of a fixture. Each scenario directory's `session.jsonl` plus contiguous `session.<n>.jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time.
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
@@ -14,7 +14,22 @@ A consuming `*.snapshot.ts` is the scenario table plus one factory call:
```ts
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineAcpSnapshotSuite, type Scenario } from '@deepseek-ai/dsh-acp-snapshot'
import {
defineAcpSnapshotSuite,
type Scenario,
type SnapshotSuiteOptions,
} from '@deepseek-ai/dsh-acp-snapshot'
function snapshotMode(value: string | undefined): SnapshotSuiteOptions['mode'] {
switch (value) {
case undefined:
case '':
case 'replay': return 'replay'
case 'record': return 'record'
case 'refresh': return 'refresh'
default: throw new Error(`unknown DSH_SNAPSHOT mode: ${value}`)
}
}
const SCENARIOS: Scenario[] = [
{ name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
@@ -28,11 +43,7 @@ defineAcpSnapshotSuite({
},
snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'),
scenarios: SCENARIOS, // exactly one entry per header class sets pinsHeader
mode: process.env.DSH_SNAPSHOT === 'record'
? 'record'
: process.env.DSH_SNAPSHOT === 'refresh'
? 'refresh'
: 'replay',
mode: snapshotMode(process.env.DSH_SNAPSHOT),
})
```
@@ -42,7 +53,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript).
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
## Model Experience
@@ -56,3 +67,4 @@ None; this package neither assembles nor sends a provider request.
- **Session harvest requires raw JSONL mode** — `runScenario` collects persisted `.jsonl` logs, so snapshot configs set `persistenceCompression: 'none'`; compressed JSONL and SQLite compositions have no snapshot-harvest path.
- **Built mode requires current artifacts** — run `pnpm run build` before selecting `DSH_EXAMPLE_MODE=lib`; source mode remains the zero-build path.
- **Backend coverage still rides an ACP driver** — see the [automation-only ACP decision](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md#snapshot-boundary) for why retained scenarios use this transport.

View File

@@ -25,8 +25,6 @@ import { setTimeout as delay } from 'node:timers/promises'
import {
ClientSideConnection,
PROTOCOL_VERSION,
type CreateElicitationRequest,
type CreateElicitationResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
@@ -44,18 +42,19 @@ const WAIT_POLL_INTERVAL_MS = 10
* (random) session id into a `{{sessionId}}` variable that later steps
* reference, since a committed file cannot know the id in advance.
*
* `promptAndCancel` starts a prompt without awaiting completion, waits until
* the client observes the selected update (`agent_message_chunk` by default),
* then cancels and awaits completion. An optional `waitForFile` first observes
* a cwd-relative readiness marker, and a named `waitForToolCallUpdate` keeps
* the step open for a terminal tool update that may follow the prompt response.
* `promptAndCancel` starts a prompt without awaiting completion, waits for a
* readiness condition, then cancels and awaits completion. `waitForFile`
* observes a cwd-relative marker; the default observes the durable turn start.
* `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending
* the prompt, then keeps the application live until that later update arrives.
* `waitForTurnEnd` holds the subprocess open until the selected session's latest
* complete raw-JSONL turn boundary is `turn/end`; its timeout defaults to 10s.
* `waitForTurnStart` waits for an open durable turn, optionally at or beyond a
* specified turn number. `waitForTurnEnd` holds the subprocess open until the
* selected session's latest complete raw-JSONL turn boundary is `turn/end`.
* A standalone `cancel` may also wait for a cwd-relative readiness marker.
* All wait timeouts default to 10s.
*/
export type InputStep =
| { op: 'initialize'; terminalOutput?: boolean }
| { op: 'initialize' }
| { op: 'newSession' }
| { op: 'newSessionExpectError'; additionalDirectories?: string[] }
| { op: 'prompt'; text: string }
@@ -64,16 +63,11 @@ export type InputStep =
| {
op: 'promptAndCancel'
text: string
afterUpdate?: 'agent_message_chunk' | 'tool_call'
waitForFile?: { path: string; timeoutMs?: number }
waitForToolCallUpdate?: string
}
| { op: 'waitForTurnStart'; minimumTurn?: number; timeoutMs?: number }
| { op: 'waitForTurnEnd'; timeoutMs?: number }
| { op: 'cancel' }
| { op: 'setMode'; modeId: string }
| { op: 'setModeExpectError'; modeId: string }
| { op: 'setConfigOption'; configId: string; value: string }
| { op: 'setConfigOptionExpectError'; configId: string; value: string }
| { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } }
/** A scenario's `input.json`: an ordered list of input steps. */
export interface InputScript {
@@ -86,21 +80,11 @@ export interface InputScript {
* kind → the offered `optionId` at answer time. A request beyond the queue
* (or with no queue at all) is answered `cancelled` — the stub behavior a
* scenario without approvals relies on. A scripted kind the request does
* not offer REJECTS the run: the scenario scripted an impossible click,
* not offer REJECTS the run: the scenario scripted an impossible selection,
* and {@link runScenario} throws once the in-flight step settles (the
* agent itself just sees `cancelled`, so it cannot absorb the bug).
*/
permissionAnswers?: PermissionAnswer[]
/**
* Ordered answers for the agent's `elicitation/create` round-trips (the
* ask_user_question / plan-review forms), consumed FIFO — the Nth request
* gets the Nth answer. Exhaustion (or no queue) answers `cancel`, the same
* fail-closed stub an elicitation-free scenario relies on. Unlike permission
* kinds, the scripted strings are not validated against the offered form —
* a stray `choice` reaches the agent verbatim, which reads it as a custom
* (non-consenting) answer, so a scenario bug fails safe in the transcript.
*/
elicitationAnswers?: ElicitationAnswer[]
}
/** One scripted answer to a permission request: which offered option kind to select. */
@@ -109,16 +93,6 @@ export interface PermissionAnswer {
kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'
}
/** One scripted answer to an elicitation form (accept with choice/custom content, or cancel). */
export interface ElicitationAnswer {
/** Accept the form with the content below, or cancel it. */
action: 'accept' | 'cancel'
/** The selected option label (the form's `choice` field). */
choice?: string
/** Free-form text (the form's `custom` field). */
custom?: string
}
/** One harvested session log plus the identifying facts off its header line. */
export interface HarvestedLog {
/** The recorded session id (header `id`). */
@@ -158,6 +132,8 @@ export interface RunOptions {
agent: AgentUnderTest
/** `replay` (default, keyless) or `record` (real API, harvests the log). */
mode: 'replay' | 'record'
/** Scenario-specific deployment environment layered into the subprocess. */
env?: NodeJS.ProcessEnv
/** The recorded session JSONL fixture path (replay reads it; record writes near it). */
fixtureFile: string
/** Optional sidecar override path (replay). */
@@ -244,6 +220,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
await cp(opts.workspaceDir, cwd, { recursive: true })
}
const env: NodeJS.ProcessEnv = {
...opts.env,
DSH_SNAPSHOT: opts.mode,
DSH_SNAPSHOT_FILE: opts.fixtureFile,
DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
@@ -259,13 +236,11 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
// Permission answers are consumed FIFO across the whole run; exhaustion
// falls back to `cancelled` so approval-free scenarios keep the plain stub.
const permissionQueue = [...input.permissionAnswers ?? []]
// Elicitation answers mirror the permission queue: FIFO, cancel on exhaustion.
const elicitationQueue = [...input.elicitationAnswers ?? []]
// A scenario bug detected inside a client callback (a scripted permission
// kind the agent never offered). It cannot fail the run from in there: a
// callback throw only becomes a JSON-RPC error RESPONSE to the agent, and
// a tolerant agent treats that as a denial and carries on — the run (or
// worse, a record) would absorb the impossible click silently. So the
// worse, a record) would absorb the impossible selection silently. So the
// callback answers `cancelled` (a well-defined path for the agent),
// captures the error here, and the step loop fails the run on it.
let scriptError: Error | undefined
@@ -279,7 +254,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
if (answer === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } })
const option = params.options.find(o => o.kind === answer.kind)
if (option === undefined) {
// The scenario scripted a click the agent never offered — a scenario
// The scenario scripted a selection the agent never offered — a scenario
// bug. Captured (last one wins; same bug class either way) and
// answered `cancelled`; the step loop rejects the run on it.
scriptError = new Error(
@@ -290,17 +265,6 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
}
return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } })
},
createElicitation(_params: CreateElicitationRequest): Promise<CreateElicitationResponse> {
const answer = elicitationQueue.shift()
if (answer === undefined || answer.action !== 'accept') return Promise.resolve({ action: 'cancel' })
return Promise.resolve({
action: 'accept',
content: {
...answer.choice !== undefined ? { choice: answer.choice } : {},
...answer.custom !== undefined ? { custom: answer.custom } : {},
},
})
},
})
const active = launched
await active.spawned
@@ -314,6 +278,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
match => active.waitForUpdate(match),
() => sessionId,
(id) => { sessionId = id },
(id, timeoutMs, minimumTurn) => waitForPersistedTurnStart(sessionsRoot, id, timeoutMs, minimumTurn),
(id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs),
)
// A permission exchange happens while a step's request is in flight, so
@@ -386,13 +351,14 @@ async function runStep(
waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise<SessionNotification['update']>,
getSessionId: () => string | undefined,
setSessionId: (id: string) => void,
waitForTurnStart: (sessionId: string, timeoutMs?: number, minimumTurn?: number) => Promise<void>,
waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
): Promise<void> {
switch (step.op) {
case 'initialize':
await client.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: step.terminalOutput === true ? { _meta: { terminal_output: true } } : {},
clientCapabilities: {},
})
return
case 'newSession': {
@@ -435,7 +401,7 @@ async function runStep(
if (sessionId === undefined) throw new Error('snapshot-harness: promptExpectError before newSession')
// The model fails this turn (a recorded provider error), so the bridge
// answers the prompt with a JSON-RPC error and the SDK rejects. That
// rejection IS the expected editor experience — swallow it so the run
// rejection IS the expected protocol result — swallow it so the run
// completes and the stdout transcript (the error frame) is captured.
await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
.then(() => { throw new Error('snapshot-harness: expected the prompt to fail but it succeeded') },
@@ -446,21 +412,16 @@ async function runStep(
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: promptAndCancel before newSession')
// Dispatch without awaiting because the fixture does not settle on its
// own. Waiting for the selected update pins it before cancellation and
// the cancelled prompt response in the transcript.
// own. Wait for an external readiness marker or the durable turn start
// before sending cancellation.
const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
const afterUpdate = step.afterUpdate ?? 'agent_message_chunk'
await waitForUpdate(u => u.sessionUpdate === afterUpdate)
if (step.waitForFile !== undefined) {
await waitForWorkspaceFile(cwd, step.waitForFile.path, step.waitForFile.timeoutMs)
} else {
await waitForTurnStart(sessionId)
}
// Arm this before cancellation so a fast tool drain cannot outrun the waiter.
const toolCallUpdateDone = step.waitForToolCallUpdate === undefined
? undefined
: waitForUpdate(u => u.sessionUpdate === 'tool_call_update' && u.toolCallId === step.waitForToolCallUpdate)
await client.cancel({ sessionId })
await promptDone
if (toolCallUpdateDone !== undefined) await toolCallUpdateDone
return
}
case 'waitForTurnEnd': {
@@ -469,53 +430,46 @@ async function runStep(
await waitForTurnEnd(sessionId, step.timeoutMs)
return
}
case 'waitForTurnStart': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForTurnStart before newSession')
await waitForTurnStart(sessionId, step.timeoutMs, step.minimumTurn)
return
}
case 'cancel': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: cancel before newSession')
if (step.waitForFile !== undefined) {
await waitForWorkspaceFile(cwd, step.waitForFile.path, step.waitForFile.timeoutMs)
}
await client.cancel({ sessionId })
return
}
case 'setMode': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: setMode before newSession')
await client.setSessionMode({ sessionId, modeId: step.modeId })
return
}
case 'setModeExpectError': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: setModeExpectError before newSession')
// The bridge rejects an unknown/uncomposed mode id with invalidParams;
// that rejection IS the expected wire behavior — swallow it so the run
// completes and the error frame is captured in the transcript.
await client.setSessionMode({ sessionId, modeId: step.modeId }).then(
() => { throw new Error('snapshot-harness: expected session/set_mode to be rejected but it succeeded') },
() => { /* expected: the bridge rejected the mode id */ },
)
return
}
case 'setConfigOption': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: setConfigOption before newSession')
await client.setSessionConfigOption({ sessionId, configId: step.configId, value: step.value })
return
}
case 'setConfigOptionExpectError': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: setConfigOptionExpectError before newSession')
// The bridge rejects an unknown id / out-of-vocabulary value; the SDK
// surfaces that as a rejected RPC — swallow it so the run completes and
// the error frame is captured in the transcript.
await client.setSessionConfigOption({ sessionId, configId: step.configId, value: step.value }).then(
() => { throw new Error('snapshot-harness: expected set_config_option to be rejected but it succeeded') },
() => { /* expected: the bridge rejected the id or value */ },
)
return
}
default:
throw new Error(`snapshot-harness: unknown input op ${JSON.stringify(step)}`)
}
}
/** Wait until persistence exposes an open turn for the selected session. */
async function waitForPersistedTurnStart(
root: string,
sessionId: string,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
minimumTurn?: number,
): Promise<void> {
const deadline = Date.now() + timeoutMs
while (true) {
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
const openTurn = log === undefined ? undefined : latestOpenTurn(log.content)
if (openTurn !== undefined && (minimumTurn === undefined || openTurn >= minimumTurn)) return
if (Date.now() >= deadline) {
const detail = minimumTurn === undefined ? 'turn/start' : `turn/start at or beyond turn ${minimumTurn}`
throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${detail} within ${timeoutMs}ms`)
}
await delay(WAIT_POLL_INTERVAL_MS)
}
}
/**
* Wait until the raw JSONL backend exposes one complete closing turn boundary.
* The ACP cancel notification settles its prompt before the agent necessarily
@@ -561,6 +515,20 @@ function latestTurnIsClosed(content: string): boolean {
> complete.lastIndexOf('\n{"type":"turn/start",')
}
/** Return the latest open turn number, validating the persisted boundary record. */
function latestOpenTurn(content: string): number | undefined {
const complete = content.slice(0, content.lastIndexOf('\n') + 1)
const start = complete.lastIndexOf('\n{"type":"turn/start",')
if (start <= complete.lastIndexOf('\n{"type":"turn/end",')) return undefined
const end = complete.indexOf('\n', start + 1)
const record = JSON.parse(complete.slice(start + 1, end)) as { data?: { turn?: unknown } | null }
const turn = record.data?.turn
if (!Number.isSafeInteger(turn) || (turn as number) < 1) {
throw new Error('snapshot-harness: invalid persisted turn/start record')
}
return turn as number
}
/**
* Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each
* header line, and return them ordered primary-first: the top-level session (no

View File

@@ -18,7 +18,6 @@
export {
runScenario,
type ElicitationAnswer,
type HarvestedLog,
type InputScript,
type InputStep,

View File

@@ -15,8 +15,6 @@ import {
ndJsonStream,
type Agent as AcpAgent,
type Client,
type CreateElicitationRequest,
type CreateElicitationResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
@@ -49,8 +47,6 @@ export interface AcpTestLaunchOptions {
env?: NodeJS.ProcessEnv
/** Permission handler; omitted requests fail closed as `cancelled`. */
requestPermission?: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse>
/** Elicitation handler; omitted requests fail closed as `cancel`. */
createElicitation?: (params: CreateElicitationRequest) => Promise<CreateElicitationResponse>
}
/** A running ACP test process and its captured client-side surfaces. */
@@ -156,8 +152,6 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
}
const requestPermission = options.requestPermission
?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' as const } }))
const createElicitation = options.createElicitation
?? (() => Promise.resolve({ action: 'cancel' as const }))
const makeClient = (_agent: AcpAgent): Client => ({
sessionUpdate(params: SessionNotification): Promise<void> {
return trackClientCallback(() => {
@@ -181,7 +175,6 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe
})
},
requestPermission: params => trackClientCallback(() => requestPermission(params)),
unstable_createElicitation: params => trackClientCallback(() => createElicitation(params)),
})
const client = new ClientSideConnection(makeClient, stream)
// `exit` only reports the parent process's status. Descendants may retain

View File

@@ -11,7 +11,6 @@ const CWD = '{{cwd}}'
const SYSTEM = '{{system}}'
const TOOLS = '{{tools}}'
const MESSAGE_PREFIX = '{{messagePrefix}}'
const UPDATED_AT = '{{updatedAt}}'
/** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */
const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g
@@ -130,8 +129,6 @@ export function normalizeStdout(
if ('id' in frame && frame.id !== undefined && frame.id !== null) {
frame.id = stableId(frame.id)
}
const update = (frame.params as { update?: Record<string, unknown> } | undefined)?.update
if (update?.sessionUpdate === 'session_info_update') update.updatedAt = UPDATED_AT
return scrubValue(frame, ctx, cwdPathMode) as Record<string, unknown>
})
return frames.map(f => JSON.stringify(f)).join('\n') + '\n'

View File

@@ -47,6 +47,8 @@ const PACKED_CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool
/** A snapshot scenario and how its fixtures are produced. */
export interface Scenario {
name: string
/** Deployment environment for this scenario's subprocess. */
env?: NodeJS.ProcessEnv
/** Whether the scenario drives at least one model turn (so a JSONL expected output applies). */
hasModelTurn: boolean
/**
@@ -604,6 +606,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
agent,
mode: childMode,
fixtureFile: join(dir, 'session.jsonl'),
...scenario.env !== undefined ? { env: scenario.env } : {},
...existsSync(overrideFile) ? { overrideFile } : {},
// In REPLAY, forward the recorded child fixtures so each subagent session
// replays from its own script. In RECORD they are harvested, not read.

View File

@@ -45,18 +45,10 @@ interface Behavior {
rejectExtraDirs?: boolean
/** How `session/prompt` settles: a clean response, a JSON-RPC error, or a hang until `session/cancel`. */
prompt?: 'respond' | 'error' | 'hang-until-cancel'
/** Emit a tool call instead of a message chunk before parking a cancellable prompt. */
cancelAtToolCall?: boolean
/** Emit the parked tool call's terminal update after answering cancellation. */
cancelToolCallUpdate?: boolean
/** Persist the scripted logs while handling cancellation, before stdin EOF. */
persistLogsOnCancel?: boolean
/** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */
permissionProbe?: boolean
/** Before responding to a prompt, send an `elicitation/create` request and echo its response as a chunk. */
elicitationProbe?: boolean
/** How `session/set_mode` settles: an empty response (echoing the modeId as a chunk) or a JSON-RPC error. */
setMode?: 'respond' | 'error'
/** Echo the `DSH_SNAPSHOT_*` env the harness set as a chunk (spec-side env-plumbing assertions). */
echoEnv?: boolean
/** Echo the sorted cwd listing as a chunk (spec-side workspace-seeding assertions). */
@@ -73,13 +65,6 @@ interface Behavior {
strayBucketFile?: boolean
/** Delete the sessions root entirely (harvest must yield no logs). */
deleteSessionsRoot?: boolean
/**
* Vocabulary for `session/set_config_option`: allowed values per config id.
* A set naming an unknown id or an out-of-vocabulary value rejects (the
* real bridge's rule); a valid set answers with the complete refreshed
* option state, `currentValue` updated. Absent: every set rejects.
*/
configOptions?: Record<string, string[]>
}
const sessionsRoot = process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? ''
@@ -102,10 +87,10 @@ let sessionId = ''
let sessionCwd = ''
/** The parked prompt request id while `hang-until-cancel` waits for the cancel notification. */
let parkedPromptId: number | string | null = null
/** Resolvers for outbound probe responses (permission/elicitation), keyed by request id. */
/** The transient raw JSONL log that proves the parked turn started durably. */
let parkedTurnLog: string | undefined
/** Resolvers for outbound permission responses, keyed by request id. */
const pendingOutbound = new Map<number, (result: unknown) => void>()
/** Per-run `session/set_config_option` state: config id → current value (first vocabulary entry until set). */
const currentConfig: Record<string, string> = {}
function send(frame: Record<string, unknown>): void {
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`)
@@ -138,39 +123,34 @@ function instantiate(value: unknown): unknown {
return value
}
/** Persist an open turn so cancellation tests wait on agent state, not presentation output. */
function persistParkedTurnStart(): void {
parkedTurnLog = join(sessionsRoot, 'ready', 'open.jsonl')
mkdirSync(dirname(parkedTurnLog), { recursive: true })
writeFileSync(parkedTurnLog, [
JSON.stringify({ type: 'session', version: 0, id: sessionId, createdAt: 1, cwd: sessionCwd, delegationDepth: 0 }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }),
'',
].join('\n'))
}
/** Remove the transient open-turn log before publishing any scripted final logs. */
function clearParkedTurnStart(): void {
if (parkedTurnLog === undefined) return
rmSync(parkedTurnLog, { force: true })
parkedTurnLog = undefined
}
async function handlePrompt(id: number | string): Promise<void> {
if ((behavior.prompt ?? 'respond') === 'hang-until-cancel') {
// A thought chunk BEFORE any message chunk: a promptAndCancel waiter
// watches for agent_message_chunk, so this exercises its non-matching
// update path while the waiter is armed.
send({
method: 'session/update',
params: { sessionId, update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'mulling' } } },
})
}
if (behavior.cancelAtToolCall === true) {
send({
method: 'session/update',
params: {
sessionId,
update: {
sessionUpdate: 'tool_call',
toolCallId: 'call_fake_1',
title: 'fake tool',
kind: 'execute',
status: 'in_progress',
},
},
})
} else {
chunk('thinking about it')
}
chunk('thinking about it')
if (behavior.echoEnv === true) {
chunk(`env:${JSON.stringify({
mode: process.env.DSH_SNAPSHOT,
override: process.env.DSH_SNAPSHOT_OVERRIDE ?? null,
childFiles: process.env.DSH_SNAPSHOT_CHILD_FILES ?? null,
spillRoot: process.env.DSH_SNAPSHOT_SPILL_ROOT ?? null,
// Scenario-supplied deployment env (the `Scenario.env` layering seam).
permissionMode: process.env.DSH_PERMISSION_MODE ?? null,
})}`)
}
if (behavior.echoWorkspace === true) {
@@ -185,7 +165,7 @@ async function handlePrompt(id: number | string): Promise<void> {
method: 'session/request_permission',
params: {
sessionId,
toolCall: { toolCallId: 'call_fake_1', title: 'fake tool', kind: 'execute', status: 'pending' },
toolCall: { toolCallId: 'call_fake_1' },
options: [
{ optionId: 'opt-allow', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'opt-reject', name: 'Reject once', kind: 'reject_once' },
@@ -195,23 +175,6 @@ async function handlePrompt(id: number | string): Promise<void> {
})
chunk(`permission:${JSON.stringify((result as { outcome?: unknown } | undefined)?.outcome ?? null)}`)
}
if (behavior.elicitationProbe === true) {
const requestId = nextOutboundId++
const result = await new Promise<unknown>((resolve) => {
pendingOutbound.set(requestId, resolve)
send({
id: requestId,
method: 'elicitation/create',
params: {
sessionId,
mode: 'form',
message: 'Approve this plan and leave plan mode?',
requestedSchema: { type: 'object', title: 'Plan review', properties: { choice: { type: 'string' }, custom: { type: 'string' } }, required: [] },
},
})
})
chunk(`elicitation:${JSON.stringify(result ?? null)}`)
}
switch (behavior.prompt ?? 'respond') {
case 'respond':
respond(id, { stopReason: 'end_turn' })
@@ -220,6 +183,7 @@ async function handlePrompt(id: number | string): Promise<void> {
respondError(id, 'model exploded')
return
case 'hang-until-cancel':
persistParkedTurnStart()
parkedPromptId = id
return
}
@@ -254,59 +218,13 @@ function handleFrame(frame: Record<string, unknown>): void {
case 'session/prompt':
void handlePrompt(id as number | string)
return
case 'session/set_mode':
if ((behavior.setMode ?? 'respond') === 'error') {
respondError(id as number | string, 'unknown mode')
return
}
chunk(`setMode:${String(params.modeId)}`)
respond(id as number | string, {})
return
case 'session/set_config_option': {
const vocabulary = behavior.configOptions
const configId = params.configId as string
const value = params.value as string
const values = vocabulary?.[configId]
if (values === undefined) {
respondError(id as number | string, `unknown config option ${configId}`)
return
}
if (!values.includes(value)) {
respondError(id as number | string, `unknown ${configId} value ${value}`)
return
}
currentConfig[configId] = value
// The real bridge's contract: every set answers with the COMPLETE
// refreshed option state, not just the changed entry.
respond(id as number | string, {
configOptions: Object.entries(vocabulary as Record<string, string[]>).map(([cid, vs]) => ({
id: cid,
type: 'select',
currentValue: currentConfig[cid] ?? vs[0],
options: vs.map(v => ({ value: v, name: v })),
})),
})
return
}
case 'session/cancel':
if (parkedPromptId !== null) {
const parked = parkedPromptId
parkedPromptId = null
respond(parked, { stopReason: 'cancelled' })
if (behavior.cancelToolCallUpdate === true) {
send({
method: 'session/update',
params: {
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: 'call_fake_1',
status: 'failed',
},
},
})
}
clearParkedTurnStart()
if (behavior.persistLogsOnCancel === true) writeLogs()
respond(parked, { stopReason: 'cancelled' })
}
return
default:
@@ -325,6 +243,7 @@ function writeLogs(): void {
}
function flushLogsAndExit(): void {
clearParkedTurnStart()
writeLogs()
if (behavior.strayRootFile === true) writeFileSync(join(sessionsRoot, 'stray.txt'), 'not a bucket\n')
if (behavior.strayBucketFile === true) {

View File

@@ -95,8 +95,8 @@ describe('runScenario', () => {
expect(clientClosed).toBe(true)
})
it('centralizes ACP boot, captures, updates, fail-closed interactions, and shutdown', { timeout: 20_000 }, async () => {
const { dir, fixtureFile } = await scenario({ permissionProbe: true, elicitationProbe: true, echoEnv: true, stderrNote: 'launcher stderr' })
it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => {
const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' })
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-'))
tempDirs.push(sessionsRoot)
const launched = launchAcpTestAgent({
@@ -112,6 +112,8 @@ describe('runScenario', () => {
await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] })
const nextChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk')
const laterChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk'
&& update.content.type === 'text' && update.content.text === 'never this one')
const predicateFailure = new Error('predicate failed')
const failedPredicate = launched.waitForUpdate(() => { throw predicateFailure })
.catch((error: unknown): unknown => error)
@@ -120,8 +122,8 @@ describe('runScenario', () => {
expect((await nextChunk).sessionUpdate).toBe('agent_message_chunk')
expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true)
expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}')
expect(launched.rawStdout()).toContain('elicitation:{\\"action\\":\\"cancel\\"}')
expect(launched.stderr()).toContain('launcher stderr')
void laterChunk.catch(() => undefined)
const unmatched = expect(launched.waitForUpdate(() => false)).rejects.toThrow(/update stream closed/)
await launched.close()
await unmatched
@@ -374,7 +376,7 @@ describe('runScenario', () => {
}
})
it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => {
it('drives a full turn: initialize, session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
permissionProbe: true,
logs: [{
@@ -386,7 +388,7 @@ describe('runScenario', () => {
}],
})
const result = await runScenario(
{ steps: [{ op: 'initialize', terminalOutput: true }, { op: 'newSession' }, { op: 'prompt', text: 'go' }] },
{ steps: [{ op: 'initialize' }, { op: 'newSession' }, { op: 'prompt', text: 'go' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.sessionId).toBeDefined()
@@ -482,7 +484,7 @@ describe('runScenario', () => {
expect(child.startsWith(`..${sep}`)).toBe(false)
})
it('promptAndCancel waits for the streamed chunk, cancels, and settles the prompt', { timeout: 20_000 }, async () => {
it('promptAndCancel waits for the durable turn start, cancels, and settles the prompt', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel' })
const result = await runScenario(
{ steps: [...boot, { op: 'promptAndCancel', text: 'hang' }] },
@@ -539,28 +541,6 @@ describe('runScenario', () => {
expect(result.rawStdout).toContain('thinking about it')
})
it('promptAndCancel can bracket cancellation with tool-call updates', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
prompt: 'hang-until-cancel',
cancelAtToolCall: true,
cancelToolCallUpdate: true,
})
const result = await runScenario(
{
steps: [...boot, {
op: 'promptAndCancel',
text: 'hang',
afterUpdate: 'tool_call',
waitForToolCallUpdate: 'call_fake_1',
}],
},
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.rawStdout).toContain('"sessionUpdate":"tool_call"')
expect(result.rawStdout.indexOf('"sessionUpdate":"tool_call"')).toBeLessThan(result.rawStdout.indexOf('cancelled'))
expect(result.rawStdout.indexOf('cancelled')).toBeLessThan(result.rawStdout.indexOf('"sessionUpdate":"tool_call_update"'))
})
it('waitForTurnEnd holds cancellation open through the persisted closing boundary', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
prompt: 'hang-until-cancel',
@@ -580,6 +560,108 @@ describe('runScenario', () => {
expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"')
})
it('waitForTurnStart can require a later durable turn before continuing', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
prompt: 'hang-until-cancel',
persistLogsOnCancel: true,
logs: [{
file: 'bucket/session.jsonl',
lines: [
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 3 } },
],
}],
})
const result = await runScenario(
{
steps: [
...boot,
{ op: 'promptAndCancel', text: 'hang' },
{ op: 'waitForTurnStart', minimumTurn: 3 },
],
},
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.sessionLogs[0]?.content).toContain('"turn":3')
})
it('waitForTurnStart rejects missing, earlier, and malformed durable turns', { timeout: 20_000 }, async () => {
const missing = await scenario({})
await expect(runScenario(
{ steps: [...boot, { op: 'waitForTurnStart', timeoutMs: 20 }] },
{ agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
)).rejects.toThrow(/did not persist turn\/start within 20ms/)
const earlier = await scenario({
prompt: 'hang-until-cancel',
persistLogsOnCancel: true,
logs: [{
file: 'bucket/session.jsonl',
lines: [
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
],
}],
})
await expect(runScenario(
{
steps: [
...boot,
{ op: 'promptAndCancel', text: 'hang' },
{ op: 'waitForTurnStart', minimumTurn: 3, timeoutMs: 20 },
],
},
{ agent: AGENT, mode: 'replay', fixtureFile: earlier.fixtureFile },
)).rejects.toThrow(/turn\/start at or beyond turn 3 within 20ms/)
const closed = await scenario({
prompt: 'hang-until-cancel',
persistLogsOnCancel: true,
logs: [{
file: 'bucket/session.jsonl',
lines: [
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'stop' } } },
],
}],
})
await expect(runScenario(
{
steps: [
...boot,
{ op: 'promptAndCancel', text: 'hang' },
{ op: 'waitForTurnStart', timeoutMs: 20 },
],
},
{ agent: AGENT, mode: 'replay', fixtureFile: closed.fixtureFile },
)).rejects.toThrow(/did not persist turn\/start within 20ms/)
for (const turn of [undefined, 0]) {
const malformed = await scenario({
prompt: 'hang-until-cancel',
persistLogsOnCancel: true,
logs: [{
file: 'bucket/session.jsonl',
lines: [
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
{ type: 'turn/start', seq: 0, time: 1, data: turn === undefined ? {} : { turn } },
],
}],
})
await expect(runScenario(
{
steps: [
...boot,
{ op: 'promptAndCancel', text: 'hang' },
{ op: 'waitForTurnStart' },
],
},
{ agent: AGENT, mode: 'replay', fixtureFile: malformed.fixtureFile },
)).rejects.toThrow('invalid persisted turn/start record')
}
})
it('waitForTurnEnd times out for a missing log and an open logged turn', { timeout: 20_000 }, async () => {
const missing = await scenario({})
await expect(runScenario(
@@ -695,15 +777,27 @@ describe('runScenario', () => {
expect(result.sessionId).toBeDefined()
})
it('a standalone cancel can wait for cwd-relative readiness', { timeout: 20_000 }, async () => {
const { dir, fixtureFile } = await scenario({})
const workspaceDir = join(dir, 'workspace')
const { mkdir } = await import('node:fs/promises')
await mkdir(workspaceDir, { recursive: true })
await writeFile(join(workspaceDir, 'ready'), '')
const result = await runScenario(
{ steps: [...boot, { op: 'cancel', waitForFile: { path: 'ready' } }] },
{ agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
)
expect(result.sessionId).toBeDefined()
})
it.each([
[{ op: 'prompt', text: 'x' }, /prompt before newSession/],
[{ op: 'promptAndWaitForAgentMessage', text: 'x', waitForText: 'later' }, /promptAndWaitForAgentMessage before newSession/],
[{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/],
[{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
[{ op: 'waitForTurnStart' }, /waitForTurnStart before newSession/],
[{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/],
[{ op: 'cancel' }, /cancel before newSession/],
[{ op: 'setConfigOption', configId: 'sandbox-mode', value: 'read-only' }, /setConfigOption before newSession/],
[{ op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' }, /setConfigOptionExpectError before newSession/],
] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => {
const { fixtureFile } = await scenario({})
await expect(runScenario(
@@ -712,53 +806,6 @@ describe('runScenario', () => {
)).rejects.toThrow(message)
})
it('setConfigOption switches a value and receives the complete refreshed option state', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
configOptions: { 'sandbox-mode': ['read-only', 'workspace-write'], 'approval-policy': ['ask', 'never'] },
})
const result = await runScenario(
{
steps: [...boot,
{ op: 'setConfigOption', configId: 'sandbox-mode', value: 'workspace-write' },
{ op: 'setConfigOption', configId: 'approval-policy', value: 'never' }],
},
{ agent: AGENT, mode: 'replay', fixtureFile },
)
// Every set answers with the FULL state: the second response carries the
// first switch's value too — the complete-refreshed-state contract.
const frames = result.rawStdout.trim().split('\n').map(line => JSON.parse(line) as { result?: { configOptions?: { id: string; currentValue: string }[] } })
const states = frames
.map(f => f.result?.configOptions)
.filter(options => options !== undefined)
.map(options => Object.fromEntries((options as { id: string; currentValue: string }[]).map(o => [o.id, o.currentValue])))
expect(states).toEqual([
{ 'sandbox-mode': 'workspace-write', 'approval-policy': 'ask' },
{ 'sandbox-mode': 'workspace-write', 'approval-policy': 'never' },
])
})
it('setConfigOptionExpectError swallows the rejection for unknown ids and out-of-vocabulary values', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ configOptions: { 'sandbox-mode': ['read-only'] } })
const result = await runScenario(
{
steps: [...boot,
{ op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'yolo' },
{ op: 'setConfigOptionExpectError', configId: 'reasoning-effort', value: 'max' }],
},
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.rawStdout).toContain('unknown sandbox-mode value yolo')
expect(result.rawStdout).toContain('unknown config option reasoning-effort')
})
it('setConfigOptionExpectError throws when the set unexpectedly succeeds', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ configOptions: { 'sandbox-mode': ['read-only'] } })
await expect(runScenario(
{ steps: [...boot, { op: 'setConfigOptionExpectError', configId: 'sandbox-mode', value: 'read-only' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)).rejects.toThrow(/expected set_config_option to be rejected/)
})
it('rejects an unknown input op', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({})
const bogus = { op: 'reticulate' } as unknown as InputStep
@@ -814,69 +861,6 @@ describe('runScenario', () => {
expect(result.sessionLogs).toHaveLength(0)
})
it('drives session/set_mode and swallows the expected rejection of setModeExpectError', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({})
const result = await runScenario(
{ steps: [...boot, { op: 'setMode', modeId: 'plan' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.rawStdout).toContain('setMode:plan')
const rejecting = await scenario({ setMode: 'error' })
const rejected = await runScenario(
{ steps: [...boot, { op: 'setModeExpectError', modeId: 'yolo' }] },
{ agent: AGENT, mode: 'replay', fixtureFile: rejecting.fixtureFile },
)
expect(rejected.rawStdout).toContain('unknown mode')
})
it('fails the run when setModeExpectError unexpectedly succeeds, and both mode ops require a session', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({})
await expect(runScenario(
{ steps: [...boot, { op: 'setModeExpectError', modeId: 'plan' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)).rejects.toThrow(/expected session\/set_mode to be rejected/)
await expect(runScenario(
{ steps: [{ op: 'initialize' }, { op: 'setMode', modeId: 'plan' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)).rejects.toThrow(/setMode before newSession/)
await expect(runScenario(
{ steps: [{ op: 'initialize' }, { op: 'setModeExpectError', modeId: 'plan' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)).rejects.toThrow(/setModeExpectError before newSession/)
})
it('answers elicitations from the scripted queue, falling back to cancel on exhaustion', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ elicitationProbe: true })
// Three prompts → three elicitations: an accept-with-choice, an
// accept-with-custom (feedback), then the exhausted-queue cancel.
const result = await runScenario(
{
steps: [...boot, { op: 'prompt', text: 'one' }, { op: 'prompt', text: 'two' }, { op: 'prompt', text: 'three' }],
elicitationAnswers: [
{ action: 'accept', choice: 'Approve' },
{ action: 'accept', custom: 'add tests first' },
],
},
{ agent: AGENT, mode: 'replay', fixtureFile },
)
const first = result.rawStdout.indexOf('elicitation:{\\"action\\":\\"accept\\",\\"content\\":{\\"choice\\":\\"Approve\\"}}')
const second = result.rawStdout.indexOf('elicitation:{\\"action\\":\\"accept\\",\\"content\\":{\\"custom\\":\\"add tests first\\"}}')
const third = result.rawStdout.indexOf('elicitation:{\\"action\\":\\"cancel\\"}')
expect(first).toBeGreaterThanOrEqual(0)
expect(second).toBeGreaterThan(first)
expect(third).toBeGreaterThan(second)
})
it('a scripted elicitation cancel answers cancel', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ elicitationProbe: true })
const result = await runScenario(
{ steps: [...boot, { op: 'prompt', text: 'one' }], elicitationAnswers: [{ action: 'cancel' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.rawStdout).toContain('elicitation:{\\"action\\":\\"cancel\\"}')
})
it('answers permission requests from the scripted queue by option kind, falling back to cancelled', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ permissionProbe: true })
// Two prompts → two permission round-trips; one scripted answer, so the

View File

@@ -123,24 +123,6 @@ Additional instructions from: nested\AGENTS.md`,
expect(out).not.toContain('"id"')
})
it('stabilizes the timestamp carried by session title updates', () => {
const raw = JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: ctx.sessionIds[0],
update: {
sessionUpdate: 'session_info_update',
title: 'Stable title',
updatedAt: '2026-07-20T17:03:13.689Z',
},
},
})
const out = normalizeStdout(raw, ctx)
expect(out).toContain('"updatedAt":"{{updatedAt}}"')
expect(out).not.toContain('2026-07-20T17:03:13.689Z')
})
it('throws on a non-JSON stdout line (the purity check)', () => {
const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
expect(() => normalizeStdout(raw, ctx)).toThrow()

View File

@@ -53,6 +53,7 @@ const REPLAY_SCENARIOS: Scenario[] = [
hasModelTurn: true,
recorded: true,
headerClass: 'main',
env: { DSH_PERMISSION_MODE: 'never' },
configPath: AGENT.configPath,
workspaceParent: tmpdir(),
},
@@ -130,6 +131,8 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => {
expect(stdout).not.toContain('stale stdout')
expect(stdout).toContain('env:{\\"mode\\":\\"replay\\"')
expect(stdout).not.toContain('\\"mode\\":\\"refresh\\"')
// The scenario's own env layer reached the subprocess.
expect(stdout).toContain('\\"permissionMode\\":\\"never\\"')
const blocked = readFileSync(join(refreshDir, 'blocked-log', 'session.jsonl'), 'utf8')
expect(blocked).toContain('"decision":"block"')

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-llm-replay
A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is visible to clients such as ACP editors; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery.
A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is available to scenarios that exercise model discovery; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery.
Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stream-json` snapshot in `examples/headless-agent`; each loads this plugin in place of a real LLM adapter. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`.
@@ -8,7 +8,7 @@ Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stre
The fixture IS the persisted session log (`<scenario>/session.jsonl`). Its `assistant/chunk` events carry every `StreamChunk`, so grouping them by `(turn, step)` reconstructs each `stream()` call's chunk sequence (one model call per loop step). Recording is therefore "run the real agent once and harvest the `.jsonl`", done by the snapshot harness — this plugin does not record. A fixture may carry its `request/header` content tokenized to `{{system}}`/`{{tools}}` (the harness pins that content in one scenario and scrubs the rest); replay is indifferent — derivation reads only `assistant/chunk` events and the line-0 session header.
Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`<scenario>/replay.override.json`: a `ReplayEntry[]`) that REPLACES the derived script.
Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`<scenario>/replay.override.json`: a `ReplayEntry[]`) that REPLACES the derived script. A `hang` entry may name `readyFile`; replay writes that empty marker after its prefix chunks reach the loop and before it waits for cancellation, so an external driver can cancel deterministically without observing a presentation update.
## Nested agents: per-session keying

View File

@@ -6,7 +6,7 @@
* @module @deepseek-ai/dsh-llm-replay
*/
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { delimiter as pathDelimiter } from 'node:path'
import type { Context } from 'cordis'
import { decodeStorageRecord } from '@deepseek-ai/dsh-session'
@@ -22,7 +22,11 @@ import { LlmAdapter, LlmError, assertNever } from '@deepseek-ai/dsh-llm'
export type ReplayEntry =
| { kind: 'chunks'; chunks: StreamChunk[] }
| { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string }
| { kind: 'hang' }
| {
kind: 'hang'
/** Optional marker written after the prefix chunks are consumed and before the stream waits for cancellation. */
readyFile?: string
}
/** One model exposed by a replay-only provider catalog. */
export interface ReplayModelConfig {
@@ -42,7 +46,7 @@ export interface ReplayProviderConfig {
id: string
/** Selector label; defaults to {@link id}. */
name?: string
/** Advisory models exposed to clients such as ACP editors. */
/** Advisory models exposed to replay scenarios that exercise discovery. */
models?: ReplayModelConfig[]
}
@@ -301,6 +305,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
// chunk, then wait for abort and surface it as the consumer expects.
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: 'partial' }
if (entry.readyFile !== undefined) writeFileSync(entry.readyFile, '')
await new Promise<void>((_resolve, reject) => {
if (signal?.aborted) { reject(new Error('aborted')); return }
signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
@@ -379,7 +379,8 @@ describe('installLlmReplay (through the real LlmService)', () => {
it('rejects a hang entry when the signal fires DURING the wait (abort listener path)', async () => {
writeFileSync(file, sessionJsonl([]), 'utf8')
const overrideFile = join(dir, 'replay.override.json')
writeFileSync(overrideFile, JSON.stringify([{ kind: 'hang' }]), 'utf8')
const readyFile = join(dir, 'stream-ready')
writeFileSync(overrideFile, JSON.stringify([{ kind: 'hang', readyFile }]), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
installLlmReplay(ctx, { file, overrideFile })
@@ -392,6 +393,7 @@ describe('installLlmReplay (through the real LlmService)', () => {
expect((await iterator.next()).value).toMatchObject({ type: 'text-delta' })
const pending = iterator.next()
await new Promise(r => setImmediate(r))
expect(existsSync(readyFile)).toBe(true)
controller.abort()
await expect(pending).rejects.toThrow('aborted')
})

View File

@@ -8,7 +8,7 @@ The model-facing control surface for `ctx.tasks`: three kind-independent tools,
- `task_list()` returns caller-visible tasks as `<id> [<kind>] <status> — <label>`.
- `task_kill(task_id, reason?)` requests cancellation immediately and forwards the logged reason. Terminal tasks return a non-consuming snapshot.
All three use generic ACP cards: `read` for output and list, `execute` for kill.
All three use generic UI cards: `read` for output and list, `execute` for kill.
Their canonical values are `{ text, task }`, `PublicTaskSnapshot[]`, and `{ outcome: 'cancellation-requested' | 'already-finished', task }`. A public snapshot carries id, kind, label, status/detail, and start/finish times; it deliberately omits `ownerSession` and the internal `reported` notice bit. Native renderers preserve the status and acknowledgement text above.

View File

@@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa
|---|---|---|
| `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) |
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [TUI app](../examples/tui-demo) shows a persistent plan, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate.
The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs such as the [TUI app](../examples/tui-demo) and the host/client runtime render the durable list from session events.

View File

@@ -6,7 +6,7 @@ The model-facing `todo_write` tool: the agent's whole task list, replaced wholes
Registers one tool, `todo_write(todos: [{ content, status }])`, on `ctx.tools`. The model sends the ENTIRE list every call — there are no partial updates or per-item edits. Each call appends a `todo/write` event (the full list snapshot) to the calling agent's session log via `agent.session.append('todo/write', { todos })`; the current list is the most recent such event (last-write-wins on replay).
`status` is one of `pending`, `in_progress`, `completed` — exactly the ACP `PlanEntryStatus` triple.
`status` is one of `pending`, `in_progress`, or `completed`.
## Single owner
@@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup
## Rendering
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to `session/event` and render that durable list themselves: the [TUI app](../../examples/tui-demo) shows a persistent plan, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires).
The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves; the [TUI app](../../examples/tui-demo) shows it as a persistent plan.
## Export shape
@@ -57,5 +57,5 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Single-owner scope only** — the list belongs to the one calling agent session; subagent/shared/swarm scopes are a deliberate cut (see § Single owner), and a non-agent caller is rejected.
- **The item shape is deliberately minimal** — `content` plus three-state `status`; no id, priority, or active-form fields, and the ACP bridge synthesizes the `priority` ACP requires.
- **The item shape is deliberately minimal** — `content` plus three-state `status`; whole-list replacement needs no stable id, priority, or active-form fields.
- **Whole-list replacement is the only operation** — no partial updates, no read-back tool; the model must resend the entire list each call.

View File

@@ -1,10 +1,9 @@
# ui/ — editor/client integration surfaces
# ui/ — human and SDK-client integration surfaces
Integrations that expose the agent to an external editor or client. These are **product** packages: a real surface a user drives the harness through.
Human-facing channels and the out-of-process SDK server. These are **product** packages: real interfaces that a person or SDK client drives.
| Package | Role | ctx key |
|---|---|---|
| `acp/` | Agent Client Protocol bridge: serves agents, commands, and live/replayed title updates to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
| `commands/` | Human-command registry: shared discovery metadata, scoped shadowing, cancellation, and direct UI dispatch | `ctx.commands` |
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
@@ -14,8 +13,8 @@ Integrations that expose the agent to an external editor or client. These are **
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door and supplies the terminal-local `ctx.tui` extension service; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. [`tui`](tui/README.md) is the interactive terminal front door and supplies the terminal-local `ctx.tui` extension service; [`jsonrpc`](jsonrpc/README.md) serves out-of-process SDK clients, while non-interactive one-shot tasks use `cli-demo`. [`commands`](commands/README.md) is the human-only discovery and dispatch plane consumed by TUI; command input and output do not become model messages.
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers.
The runnable app bundles that bake these bridges into boot bins — the TUI app, ACP server app, and JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
The runnable app bundles that bake these interfaces into boot bins live in [`examples/`](../examples/README.md), composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md). `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.

View File

@@ -1,199 +0,0 @@
# @deepseek-ai/dsh-acp
Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target.
It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui` channel — NOT a loop change and NOT a [capability seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
## Service / plugin
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
The plugin injects `agents`, [`commands`](../commands/README.md), `sessionPersistence`, `sessionQuery`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; live-preferred session queries back `session/list`; the command registry backs slash discovery and direct dispatch; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms.
### Config
| Key | Default | Meaning |
|---|---|---|
| `provider` | — | Initial provider route for created agents (must have a registered adapter). |
| `model` | — | Initial model id for created agents. |
(No persona key: `dsh-system-prompt`'s own `persona` config supplies the global default section, so ACP-created agents render it without the bridge carrying prompt text. An agent-scoped same-name section may still shadow that default.)
The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config.
## ACP method mapping
| ACP method | Harness seam | Notes |
|---|---|---|
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text), `loadSession: true`, and `sessionCapabilities.list` |
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands |
| `session/list` | `ctx.sessionQuery` | returns live-preferred newest-first sessions with absolute cwd and optional folded title; supports exact normalized cwd filtering, returns no cursor, and rejects supplied cursors |
| `session/prompt` | `ctx.commands.execute()` or `agent.followup()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" |
| `session/set_config_option` | agent-scoped request target / `ctx.permission.set()` | per-session provider+model and permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
## Multi-session
One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt or reference-preparation operation; `session/cancel` aborts preparation before it can enqueue. Teardown drains all sessions in parallel. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md).
## Human commands
After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A new session's server-generated id is introduced by the RPC response before its snapshot enters the connection write queue. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`.
ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model; prefix whitespace when literal slash-leading text must reach the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands).
## Session config options
The bridge advertises a `model`-category select in `session/new` and `session/load` when the session has a complete target whose provider is registered. Values encode the complete provider/model pair, are grouped by provider when more than one group is available, and come from `ctx.llm.listProviders()` / `listModels()`. The configured or last-requested model is added when absent because catalogs are advisory and private adapters may accept unlisted ids. A selection changes only that ACP session. Agent-scoped prompt assembly snapshots the selected pair for one step, supplies matching `{{provider}}` / `{{model}}` variables, and the `agent/request` waterfall applies the same pair; a concurrent selection therefore takes effect on the next step instead of splitting prompt text from routing. The resulting request header is the durable record restored by `session/load`; a selection never used by a request remains in-memory only.
When `ctx.permission` is composed, the bridge also advertises a `permission` select. Options come from the deployment's preset table; the current value comes from the session fold, with switch-away-only `custom` for unmatched knobs. `session/set_config_option` accepts advertised presets and writes both sandbox-mode and approval-policy events through `PermissionService.set()`. Open-turn switches append immediately; idle switches overlay responses and anchor at the next `agent/prompt-submit`, before request assembly. A crash before anchoring restores the durable fold. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md), [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md), [`dsh-permission`](../permission/README.md), and [protocol matrix](acp-feature-support.md#6-session-modes--config-options--models).
The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictable task ids by the owning session; ACP sessions therefore cannot read or stop one another's background work.
ACP updates are append-only, so `llm/retry` emits a visible separator that marks preceding partial model output discarded before the next attempt streams. A terminal model-request failure emits the same discarded-output warning; replay derives both markers from the durable events.
A log-only `session/title` event maps to ACP `session_info_update` with `title` and the event timestamp as `updatedAt`. The same mapping runs for live events and `session/load` replay, so an asynchronously generated late title and a restored persisted title have one wire representation without entering model history.
`session/list` returns the same latest folded title in standard `SessionInfo.title`. When `ctx.sessionReferences` is mounted, each listed item also carries `_meta["deepseek-harness/sessionReference"].uri`; a title-aware client can render `title ?? sessionId` in its `@` picker and submit that URI as a `resource_link` with the same display name. Sessions without cwd are omitted because ACP requires an absolute `SessionInfo.cwd` and the bridge cannot load them.
## Per-session cwd
`session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported.
## Tool-call presentation
Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. File-card titles are relative to the session cwd and use the host separator, while location and diff paths remain raw so the editor opens the real file. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation).
## Terminal card (capability-gated)
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session and preserves the host filesystem separator, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md).
## Settle-exactly-once
A prompt captures its owning turn and settles exactly once from the matching durable `turn/end`, even if presentation failed. Turn correlation excludes stale endings. Error turns reject with an ACP internal error; empty prompts reject before enqueue.
## Permission prompts
For a bridge-owned call, the [approval seam](../user-approval/README.md) maps `ask` to an editor prompt with one-shot allow/reject options. Foreign or call-less requests delegate; unknown choices never grant, cancellation stays cancellation, and transport failure becomes fail-closed unavailability. Whether a tool asks remains policy outside the bridge.
## Disposal & disconnect
Disposal and client disconnect share one memoized teardown. It cancels pending prompts and disposes all owned agent handles in parallel, waiting for loop exit and final flush before registry removal. Mid-turn teardown records `disposed`; `session/cancel` records `aborted`.
## stdout is the protocol
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
## Running
`pnpm --dir /path/to/deepseek-harness run demo:acp` boots `examples/acp-agent` (needs `DEEPSEEK_API_KEY`). Point an ACP client at it; for Zed, add to `agent_servers`:
```json
{
"agent_servers": {
"DeepSeek Harness": {
"command": "pnpm",
"args": ["--dir", "/path/to/deepseek-harness", "run", "demo:acp"]
}
}
}
```
## Model Experience
### User messages
#### What the model sees
Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each ordinary `resource_link` becomes exactly a leading newline, `[resource_link name=<JSON-string> uri=<JSON-string>]`, and a trailing newline. When `ctx.sessionReferences` is mounted, a `resource_link` whose URI uses `dsh-session:` or an inline canonical mention becomes readable `@label` text plus one durable untrusted snapshot context; without the capability it is rejected. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted.
#### Token effect
Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Human commands
#### What the model sees
Nothing from command discovery, slash input, or command output. A command handler may separately mutate a durable domain whose later state affects model requests.
#### Token effect
Direct dispatch adds no model tokens and no session message. The mutated domain owns any later prompt or history cost.
#### KV Cache effect
Command discovery, dispatch, and direct output never enter a model request and do not affect its cache. A mutated domain owns any later cache effect.
### Human answers and permission decisions
#### What the model sees
When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, title updates, and other streamed session updates are UI-only.
#### Token effect
Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. A replacement `tool/result` still changes the model-facing session surface, but live and replayed ACP feeds ignore it as an execution update so the original terminal or diff completion is not overwritten.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Permission preset switches
#### What the model sees
`session/set_config_option` emits no model message itself. When `dsh-permission` is composed, the bridge writes the selected preset through that service; the resulting model-visible policy prompt and change notice belong to [`dsh-user-approval`](../user-approval/README.md), while sandbox-mode effects belong to [`dsh-tool-bash`](../../bash/tool-bash/README.md). The ACP `Permissions` select, its option descriptions, pending idle value, and refreshed config response remain client-only.
#### Token effect
Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome.
#### KV Cache effect
The ACP option and log event cause no direct invalidation. The downstream policy-prompt change may invalidate reuse from that system section, while its change notice appends to history.
### Model switches
#### What the model sees
The ACP selector itself emits no message. The selected provider/model pair supplies the next step's `{{provider}}` / `{{model}}` prompt variables and request routing together; all other call-config fields continue through the `agent/request` waterfall unchanged.
#### Token effect
The selector adds no direct tokens. A changed model may tokenize the same retained prompt/history differently, and any persona text that interpolates provider or model changes accordingly.
#### KV Cache effect
Switching provider or model selects a different cache domain. If the persona interpolates either value, the rendered system prompt also changes and prevents reuse from its first changed token.
### Loaded sessions
#### What the model sees
`session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message.
#### Token effect
Restored context has the persistence and session packages' normal retained cost; ACP replay to the client adds none.
#### KV Cache effect
Loading does not rewrite the stored log, but the next request is reconstructed under the current envelope and route. Reuse requires that reconstruction to match; ACP replay to the client has no cache effect.
## Known Limitations and Deferred Work
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
- **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`.
- **Session picker UI is client-owned** — `session/list` supplies standard title metadata and, when references are available, a canonical URI extension; an ACP client must consume those fields to add an `@` picker. Title/body search remains future metadata or FTS work.
- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
- **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam.
- **Command output is live-only** — discovery is refreshed after load, but direct command results are not persisted or replayed into a reconnected editor.

View File

@@ -1,158 +0,0 @@
# ACP feature support checklist
A structured inventory of [Agent Client Protocol](https://agentclientprotocol.com) (ACP) features and where the harness's ACP bridge ([`@deepseek-ai/dsh-acp`](README.md)) stands on each. The bridge exposes the harness agent as an ACP **server** (the agent side of an editor↔agent connection), so "supported" below means *the bridge implements the agent's half* — answering an agent method, advertising a capability, or calling a client method.
## Scope
This tracks the **stable** ACP v1 surface (schema `1.14.0`, `schema/v1/schema.json`) PLUS the **unstable/draft** features that the two reference adapters — [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) (Claude Code) and [`codex-acp`](https://github.com/zed-industries/codex-acp) (OpenAI Codex) — actually ship. A purely-unstable feature that neither reference adapter uses is omitted (see [Out of scope](#out-of-scope)).
Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. The **Stable** column marks whether the feature is in the released v1 schema (S) or only the unstable schema (U). The **Claude** / **Codex** columns record whether each reference adapter ships it, as a maturity signal.
## At a glance
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load/list, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection and permission presets, and **session modes** (the picker, via `@deepseek-ai/dsh-plan-mode`). The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
## 1. Agent methods (client → agent)
| Method | Stable | Bridge | Claude | Codex | Notes |
|---|---|---|---|---|---|
| `initialize` | S | ✅ | ✅ | ✅ | Negotiates `PROTOCOL_VERSION`; advertises `loadSession`, `sessionCapabilities.list`, and baseline prompt caps. Snapshots the Zed `_meta.terminal_output` client cap. |
| `authenticate` | S | ⚠️ | ✅ | ✅ | No-op stub; the bridge advertises no `authMethods`, so there is nothing to authenticate. |
| `logout` | S | ❌ | ✅ | ✅ | Gated by `agentCapabilities.auth.logout`; not advertised. |
| `session/new` | S | ✅ | ✅ | ✅ | Maps to `agents.create`; requires an absolute `cwd` (becomes the session workspace); rejects non-empty `additionalDirectories` / `mcpServers`. |
| `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. |
| `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. |
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.followup`. One request is in flight per session. |
| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
| `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
| `session/list` | S | ✅ | ✅ | ✅ | Uses live-preferred `ctx.sessionQuery`; returns absolute-cwd sessions newest-first with optional folded title and exact cwd filtering. Pagination is not emitted; supplied cursors are rejected. |
| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. |
| `session/fork` | U | ❌ | ✅ | ❌ | Claude ships `unstable_forkSession`; Codex does not. |
## 2. Client methods the agent CALLS (agent → client)
These are capabilities the bridge would *drive* on the editor. The harness runs tools in-process (its own `dsh-bash` executor, direct file I/O), so it does not yet delegate to the editor for any of these.
| Method | Stable | Bridge | Claude | Codex | Notes |
|---|---|---|---|---|---|
| `session/update` | S | ✅ | ✅ | ✅ | The bridge's primary output channel (see [§4](#4-sessionupdate-variants)). |
| `session/request_permission` | S | ✅ | ✅ | ✅ | The bridge answers the [`ctx.approval`](../user-approval/README.md) seam for the agents it owns: an `ask` from a hook/plugin becomes an editor prompt attached to the streamed tool call, one-shot `allow_once`/`reject_once` options only. Whether a call asks is policy (nothing asks by default); `allow_always` is deferred (grant storage). |
| `fs/read_text_file` | S | ❌ | ✅ | ❌ | The harness reads files directly (it does not see the editor's unsaved buffer state). Claude delegates; Codex does not. |
| `fs/write_text_file` | S | ❌ | ✅ | ❌ | Same — direct writes, no editor delegation. |
| `terminal/create` | S | ❌ | ❌ | ❌ | Neither reference adapter drives the client terminal API either — both, like the bridge, render shell output as tool-call content + a `_meta` channel (see [§5 Terminal](#terminal-rendering)). |
| `terminal/output` | S | ❌ | ❌ | ❌ | As above. |
| `terminal/wait_for_exit` | S | ❌ | ❌ | ❌ | As above. |
| `terminal/kill` | S | ❌ | ❌ | ❌ | As above. |
| `terminal/release` | S | ❌ | ❌ | ❌ | As above. |
| `elicitation/create` · `elicitation/complete` | U | ⚠️ | ✅ | ⚠️ | The bridge drives `unstable_createElicitation` for `ask_user_question` form prompts (session-scoped, no URL-mode flow yet). Claude calls the `unstable_*` elicitation methods for MCP server elicitations; Codex maps elicitations onto `session/request_permission`. |
## 3. Capabilities
### 3a. `agentCapabilities` (advertised by the bridge)
| Capability | Stable | Bridge | Claude | Codex | Notes |
|---|---|---|---|---|---|
| `loadSession` | S | ✅ | ✅ | ✅ | Advertised `true`; backs `session/load`. |
| `promptCapabilities.image` | S | ❌ | ✅ | ✅ | Bridge advertises `image: false`; image prompt blocks are rejected. |
| `promptCapabilities.audio` | S | ❌ | ❌ | ❌ | `audio: false`; neither adapter accepts audio either. |
| `promptCapabilities.embeddedContext` | S | ❌ | ✅ | ✅ | `embeddedContext: false`; embedded `resource` blocks rejected. |
| `mcpCapabilities.{http,sse}` | S | ❌ | ✅ | ⚠️ | No MCP passthrough; `mcpServers` is rejected. Claude advertises http+sse, Codex http only. |
| `sessionCapabilities.*` | S | ⚠️ | ✅ | ✅ | `list` is advertised; delete/resume/close/additionalDirectories/fork remain off. |
| `auth.logout` | S | ❌ | ✅ | ✅ | Not advertised. |
| `authMethods[]` | S | ⚠️ | ✅ | ✅ | Advertised as empty (no auth required to reach the model). |
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | Fixed literals: `deepseek-harness-acp` / `0.0.1` (not config). |
| `_meta` custom caps | S | ❌ | ✅ | — | E.g. Claude's `claudeCode.promptQueueing`. The bridge advertises no custom `_meta`. |
### 3b. `clientCapabilities` (consumed by the bridge)
| Capability | Stable | Bridge | Notes |
|---|---|---|---|
| `fs.{readTextFile,writeTextFile}` | S | ❌ | Not consulted (the bridge never calls `fs/*`). |
| `terminal` | S | ❌ | Not consulted; the bridge keys terminal rendering off the Zed `_meta.terminal_output` cap instead. |
| `_meta.terminal_output` (Zed) | S (`_meta`) | ✅ | Snapshotted per session at create/load; gates terminal-card rendering. |
## 4. `session/update` variants
| `sessionUpdate` | Stable | Bridge | Claude | Codex | Notes |
|---|---|---|---|---|---|
| `agent_message_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` text-delta. |
| `agent_thought_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` reasoning-delta. |
| `user_message_chunk` | S | ✅ | ✅ | ✅ | Emitted during `session/load` replay to reconstruct the user side. |
| `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). |
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated``{ sessionUpdate: 'plan', entries }`). |
| `available_commands_update` | S | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. |
| `current_mode_update` | S | ✅ | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified when a logged `plan/mode` maps to a different wire id (covers the `exit_plan_mode` tool flipping the session back). |
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
| `session_info_update` | S | ✅ | ⚠️ | ⚠️ | Log-backed title events push title and event time; load replay uses the same mapping. |
## 5. Tool-call rendering
Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
| Feature | Stable | Bridge | Claude | Codex | Notes |
|---|---|---|---|---|---|
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit` declared by each tool's `presentCall`; presenter-less tools render `other` (no name sniffing); richer mapping possible. |
| `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress``completed`/`failed`. |
| `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. |
| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }`. For an edit or an overwrite it carries the applied hunk(s) with surrounding context (one per `replace_all` site), computed from the before/after text and persisted on the `tool/result` event as `meta`; for a create (no before-image) it is an args-derived whole-file diff. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; a successful mutation ALWAYS returns the result diff (an ACP `tool_call_update.content` replaces the call's content, so the result diff — not the model-facing text — is what survives). |
| `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. |
| `locations` (follow-along) | S | ✅ | ✅ | ✅ | The `read`/`write`/`edit` tools emit `{ path, line? }` file-location hints via `presentCall`. |
| `rawInput` | S | ✅ | ⚠️ | ✅ | Parsed tool args surfaced as `rawInput`. |
| `rawOutput` | S | ❌ | ⚠️ | ✅ | Not emitted. |
### Terminal rendering
⚠️ Implemented via the **Zed `_meta` convention** (`terminal_info` / `terminal_output` / `terminal_exit`), gated on the client advertising `_meta.terminal_output` — NOT the spec's `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox / env-scrub / ownership / cwd). Both reference adapters take the same `_meta` approach. Live incremental streaming (`terminal_output_delta`, which Codex negotiates) is a follow-up — the bridge currently sends the full captured output once on the result.
## 6. Session modes / config options / models
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): ACP owns the fixed `default` / `plan` wire vocabulary and projects it onto `ctx.planMode`'s boolean `{ active, pending? }` state; `session/set_mode` calls `set()` and `current_mode_update` tracks the optimistic selection plus each distinct committed `plan/mode` flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-collaboration-state / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
## 7. Content blocks
| Block | Stable | In prompts | In updates | Notes |
|---|---|---|---|---|
| `text` | S | ✅ | ✅ | Baseline. |
| `resource_link` | S | ✅ | ⚠️ | Accepted in prompts and rendered into text (`acpPromptToText`); not emitted as a structured update block. |
| `image` | S | ❌ | ❌ | Rejected in prompts (`promptCapabilities.image: false`). |
| `audio` | S | ❌ | ❌ | Rejected. |
| `resource` (embedded) | S | ❌ | ❌ | Rejected (`embeddedContext: false`). |
The bridge rejects unsupported prompt blocks rather than silently dropping them (`promptHasUnsupportedContent`), per the "explicit over implicit" convention.
## 8. Cross-cutting
| Feature | Stable | Bridge | Notes |
|---|---|---|---|
| `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. |
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md). |
| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. |
| `_meta` extensibility | S | ⚠️ | Consumed for the Zed terminal cap and emitted for terminal cards. Listed sessions add `deepseek-harness/sessionReference` with a canonical URI when cross-session references are mounted. |
| Background-task ownership isolation | — | ✅ | Generic `task_output`/`task_kill` reject tasks whose branded owner `SessionId` belongs to another session. |
| stdout-is-the-protocol guarantee | S | ✅ | The bridge runs in an example with no stdout logger. |
## Gap summary
Ranked by how commonly the reference adapters ship them and how much UX they unlock:
1. **Session lifecycle**`session/delete`, then `session/resume` / `session/close`.
2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
3. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
4. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
5. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
6. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
## Out of scope
Unstable/draft ACP features that **neither** reference adapter ships are not tracked above: `providers/*` (LLM provider selection), `mcp/connect`·`mcp/message`·`mcp/disconnect` (client-side MCP passthrough), `nes/*` (Next Edit Suggestion), `document/did*` (LSP-style document sync), the v2 plan model (`plan_update` / `plan_removed`), boolean config options, `$/cancel_request`, and the draft Streamable-HTTP transport. They can be added if a target editor adopts them.
## Sources
- Stable spec: `schema/v1/schema.json` (schema `1.14.0`) and `docs/protocol/v1/*.mdx` in the [agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) repo.
- Reference adapters: [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) and [`codex-acp`](https://github.com/zed-industries/codex-acp).
- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP Agent Notes under [`.agents/notes/`](../../../.agents/notes/README.md).

View File

@@ -1,86 +0,0 @@
{
"name": "@deepseek-ai/dsh-acp",
"description": "Agent Client Protocol (ACP) bridge: drive DeepSeek Harness SDK agents from an ACP editor over JSON-RPC stdio",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
"schemastery": "^3.17.0",
"zod": "^4.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-plan-mode": "^0.0.1",
"@deepseek-ai/dsh-permission": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-approval": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-permission": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,27 +0,0 @@
<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.
Run `pnpm run gen-doc-graphs` to regenerate. -->
# ACP Snapshot Replay
This graph explains what a snapshot scenario proves: recorded real-model session logs are replayed keylessly, ACP stdout is normalized and diffed, and scenario workspaces preserve tool side effects that the UI stream alone cannot prove.
```mermaid
sequenceDiagram
participant Recorder as Real API recording
participant Fixture as snapshot fixture
participant Workspace
participant Replay as llm-replay adapter
participant ACP as acp-agent subprocess
participant Expected as stdout expected output
Recorder->>Fixture: session.jsonl + workspace inputs
Fixture->>Workspace: seed files and hook configs
Fixture->>Replay: recorded StreamChunk script
Replay->>ACP: deterministic <code>llm/stream</code> chunks
ACP->>Workspace: bash, fs, and hook side effects
ACP->>Expected: normalized sessionUpdate stream
Expected-->>ACP: diff must be empty
```
The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.
Maintenance mode: curated Mermaid sequence based on the snapshot test harness.

View File

@@ -1,138 +0,0 @@
/**
* Pure, total translation between harness vocabulary and ACP wire types.
* @module @deepseek-ai/dsh-acp/codec
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import {
SESSION_REFERENCE_SCHEME,
decodeSessionReferenceUri,
parseSessionReferenceText,
type SessionReferenceInput,
} from '@deepseek-ai/dsh-session-reference'
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
/**
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
*
* `completed` and the defensive `error` case map to `end_turn`;
* `max-tokens` maps to `max_tokens`; `aborted`, `disposed`, and `rejected` map
* to `cancelled`. The bridge rejects error turns before this mapping. Unknown
* merge-extensible kinds use legal fallback `end_turn` rather than breaking
* the prompt RPC.
* @param reason - the harness turn-end reason to translate.
* @returns the legal ACP wire value per the mapping above.
*/
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
switch (reason.kind) {
case 'completed':
return 'end_turn'
case 'max-tokens':
return 'max_tokens'
case 'aborted':
return 'cancelled'
case 'disposed':
return 'cancelled'
case 'rejected':
return 'cancelled'
case 'error':
return 'end_turn'
// Merge-extensible: an unknown future TurnEndReason kind still has to produce a legal wire
// value (the SDK rejects unknown stopReason), so default to end_turn rather than
// assertNever.
default:
return 'end_turn'
}
}
/**
* Map replayable text to ACP message content. Other block kinds use their
* prompt, thought-stream, or tool-update paths.
* @param block - the harness content block to translate.
* @returns the ACP block, or `undefined` for a kind with no message-content mapping.
*/
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
switch (block.type) {
case 'text':
return { type: 'text', text: block.text }
// reasoning → streamed as agent_thought_chunk, not a message block
// tool-call / tool-result → the tool_call / tool_call_update path
// plugin-added block types → not surfaced
default:
return undefined
}
}
/**
* Extract plain text from an ACP prompt's content blocks. Text blocks are
* concatenated verbatim; resource links become explicit textual references so
* baseline ACP clients can point at files without the bridge silently dropping
* that context.
* @param prompt - the ACP prompt blocks to flatten.
* @returns the concatenated text, with resource links rendered as bracketed references.
*/
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
return prompt
.flatMap((block): string[] => {
switch (block.type) {
case 'text':
return [block.text]
case 'resource_link':
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
default:
return []
}
})
.join('')
}
/** ACP prompt text plus structured session references extracted from text and resource links. */
export interface AcpReferencedPrompt {
/** Readable prompt text with opaque session URIs removed. */
text: string
/** Structured session references in ACP block and inline appearance order. */
references: SessionReferenceInput[]
}
/**
* Extract canonical session references while preserving ordinary ACP resource links.
* @param prompt - already-supported ACP prompt blocks.
* @returns readable text and structured references.
* @throws when any observed `dsh-session:` URI is malformed.
*/
export function acpPromptToReferencedPrompt(prompt: readonly AcpContentBlock[]): AcpReferencedPrompt {
const references: SessionReferenceInput[] = []
const text = prompt.flatMap((block): string[] => {
switch (block.type) {
case 'text': {
const parsed = parseSessionReferenceText(block.text)
references.push(...parsed.references)
return [parsed.text]
}
case 'resource_link': {
if (!block.uri.startsWith(SESSION_REFERENCE_SCHEME)) {
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
}
const sessionId = decodeSessionReferenceUri(block.uri)
const label = block.name === '' ? sessionId : block.name
references.push({ sessionId, label })
return [`@${label}`]
}
default:
return []
}
}).join('')
return { text, references }
}
/**
* Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP
* requires `text` and `resource_link`; richer inline payloads (`resource`,
* image, audio, …) are rejected rather than silently dropped.
* @param prompt - the ACP prompt blocks to inspect.
* @returns `true` when any block is neither `text` nor `resource_link`.
*/
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,113 +0,0 @@
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 { CallId } from '@deepseek-ai/dsh-llm'
import { type Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/**
* The bridge's `approval/request` answerer: an ask for an agent the bridge
* owns becomes a `session/request_permission` prompt attached to the tool
* call; foreign or call-less requests delegate down to the fail-closed
* default. Driven through `ctx.approval` — the same path dsh-tools' ask
* routing takes — against the harness's scriptable client.
*/
describe('acp bridge — approval answerer', () => {
let storageDir: string
let harness: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-approval-')) })
afterEach(async () => {
await harness?.dispose()
harness = undefined
await rm(storageDir, { recursive: true, force: true })
})
async function ownedAgentRequest(
h: BridgeHarness, overrides: Partial<ApprovalRequest> = {},
): Promise<{ agent: Agent; request: ApprovalRequest }> {
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = h.ctx.agents.get(SessionId(sessionId))
if (agent === undefined) throw new Error('newSession created no agent')
// In production an ask always fires mid-turn (tool execution); open one so
// request()'s turn-enclosure precondition holds for the direct drive below.
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
return { agent, request: { agent, toolName: 'echo', callId: CallId('call-9'), ...overrides } }
}
it('prompts the editor for an owned agent and maps allow-once → allowed-once', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
const { request } = await ownedAgentRequest(harness)
await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once')
expect(harness.permissionRequests).toHaveLength(1)
const wire = harness.permissionRequests[0]
expect(wire?.toolCall).toEqual({ toolCallId: 'call-9' })
expect(wire?.options.map(o => ({ optionId: o.optionId, kind: o.kind }))).toEqual([
{ optionId: 'allow-once', kind: 'allow_once' },
{ optionId: 'reject-once', kind: 'reject_once' },
])
})
it('maps reject-once → rejected', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } })
const { request } = await ownedAgentRequest(harness)
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
})
it('maps a client cancellation → cancelled', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'cancelled' } })
const { request } = await ownedAgentRequest(harness)
await expect(harness.ctx.approval.request(request)).resolves.toBe('cancelled')
})
it('treats an unknown optionId from a non-conforming client as a rejection, never a grant', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-always-i-insist' } })
const { request } = await ownedAgentRequest(harness)
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
})
it('delegates a foreign agent down to the fail-closed default', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
const { agent } = await ownedAgentRequest(harness)
// Even an impostor that claims the bridge-owned session id must delegate:
// ownership requires the exact Agent object stored in the session record.
const foreign = {
session: { id: agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) },
} as unknown as Agent
await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') }))
.resolves.toBe('unavailable')
expect(harness.permissionRequests).toHaveLength(0)
})
it('delegates a call-less request — the protocol prompt must attach to a tool call', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.ctx.plugin(ApprovalService)
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
const { agent } = await ownedAgentRequest(harness)
await expect(harness.ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable')
expect(harness.permissionRequests).toHaveLength(0)
})
})

View File

@@ -1,463 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } 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, toolCallResponse, type BridgeHarness } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri, formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
/**
* 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 (docs/testing.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).toEqual({ name: 'deepseek-harness-acp', version: '0.0.1' })
})
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('routes ask_user_question through ACP form elicitation and continues with the selected option', async () => {
harness = await makeBridgeHarness({
storageDir,
withAskUser: true,
script: [
toolCallResponse('ask-1', 'ask_user_question', {
questions: [{
id: 'language',
header: 'Project config',
question: 'Which language should I use?',
options: [
{ label: 'TypeScript', description: 'Good for UI apps' },
{ label: 'Python', description: 'Good for scripts' },
],
}],
}),
textResponse('Python it is.'),
],
})
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'Python' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'ask me' }] })
expect(result.stopReason).toBe('end_turn')
expect(harness.elicitationRequests).toHaveLength(1)
expect(harness.elicitationRequests[0]).toMatchObject({
sessionId,
mode: 'form',
message: 'Which language should I use?',
requestedSchema: {
title: 'Project config',
properties: {
choice: {
oneOf: [
{ const: 'TypeScript', title: 'TypeScript: Good for UI apps' },
{ const: 'Python', title: 'Python: Good for scripts' },
],
},
custom: { type: 'string' },
},
required: [],
},
})
const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result')
const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined
const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined
expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}')
})
it('routes optionless ask_user_question through an ACP free-form answer field', async () => {
harness = await makeBridgeHarness({
storageDir,
withAskUser: true,
script: [
toolCallResponse('ask-1', 'ask_user_question', {
questions: [{ id: 'name', question: 'What should I name it?' }],
}),
textResponse('Name recorded.'),
],
})
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'apollo' } })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'ask me' }] })
expect(harness.elicitationRequests[0]).toMatchObject({
requestedSchema: {
properties: { custom: { type: 'string', title: 'What should I name it?' } },
required: ['custom'],
},
})
const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result')
expect(JSON.stringify(toolResult)).toContain('apollo')
})
it('supports ACP custom answers alongside choices', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } })
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 result = await harness.ctx.userInteraction.ask({
agent,
questions: [{
id: 'language',
question: 'Which language?',
detail: 'Choose the implementation language for this project.',
options: [{ label: 'TypeScript' }],
}],
})
expect(result).toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
expect(harness.elicitationRequests[0]).toMatchObject({
message: 'Which language?\n\nChoose the implementation language for this project.',
requestedSchema: {
properties: {
choice: {
title: 'Which language?',
description: 'Choose one option, or fill a custom answer below.',
oneOf: [{ const: 'TypeScript', title: 'TypeScript' }],
},
custom: { type: 'string' },
},
required: [],
},
})
})
it('treats ACP custom answers as overriding selected choices', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } })
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))!
await expect(harness.ctx.userInteraction.ask({
agent,
questions: [{
id: 'language',
question: 'Which language?',
options: [{ label: 'TypeScript' }],
}],
})).resolves.toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
})
it('supports ACP multi-select answers', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } })
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))!
await expect(harness.ctx.userInteraction.ask({
agent,
questions: [{
id: 'targets',
question: 'Pick',
options: [{ label: 'Tests' }, { label: 'Docs' }],
multiSelect: true,
}],
})).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Tests', 'Docs'] }] })
})
it('reports ACP ask-user routing and answer failures as structured errors', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
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))!
await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
const impostor = { session: { id: agent.session.id } } as typeof agent
await expect(harness.ctx.userInteraction.ask({ agent: impostor, questions: [{ id: 'x', question: 'No session?' }] }))
.rejects.toMatchObject({ code: 'NO_SESSION' })
harness.onElicitation = () => ({ action: 'cancel' })
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Cancel?' }] }))
.rejects.toMatchObject({ code: 'ASK_CANCELLED' })
harness.onElicitation = () => ({ action: 'accept', content: {} })
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Empty?' }] }))
.rejects.toMatchObject({ code: 'NO_ANSWER' })
harness.onElicitation = () => { throw new Error('client boom') }
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Client fails?' }], signal: new AbortController().signal }))
.rejects.toMatchObject({ code: 'ASK_FAILED' })
})
it('aborts ACP ask-user requests before and while waiting for elicitation', async () => {
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
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 alreadyAborted = new AbortController()
alreadyAborted.abort()
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Already?' }], signal: alreadyAborted.signal }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
let abortedReads = 0
const racingAbort = {
get aborted() { return abortedReads++ > 0 },
addEventListener() {},
removeEventListener() {},
dispatchEvent() { return false },
onabort: null,
reason: undefined,
throwIfAborted() {},
} as AbortSignal
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Raced?' }], signal: racingAbort }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
let release: ((value: { action: 'accept'; content: { custom: string } }) => void) | undefined
harness.onElicitation = () => new Promise((resolve) => { release = resolve })
const pendingAbort = new AbortController()
const ask = harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Pending?' }], signal: pendingAbort.signal })
await new Promise(resolve => setImmediate(resolve))
pendingAbort.abort()
await expect(ask).rejects.toMatchObject({ code: 'ASK_ABORTED' })
release?.({ action: 'accept', content: { custom: 'too late' } })
})
it('allows multiple concurrent sessions, each with a distinct id', async () => {
harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(a.sessionId).toBeTruthy()
expect(b.sessionId).toBeTruthy()
expect(a.sessionId).not.toBe(b.sessionId)
// Both agents are live and independently registered.
expect(harness.ctx.agents.get(SessionId(a.sessionId))).toBeDefined()
expect(harness.ctx.agents.get(SessionId(b.sessionId))).toBeDefined()
})
it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', 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/)
const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] })
expect(res.sessionId).toBeTruthy()
expect(harness.ctx.agents.get(SessionId(res.sessionId))!.session.header.cwd).toBe('/tmp')
})
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('accepts a resource_link prompt by rendering the link into the text sent to the agent', 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: [] })
const result = await harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: 'fix the bug in' },
{ type: 'resource_link', uri: 'file:///x.ts', name: 'x.ts' },
],
})
expect(result.stopReason).toBe('end_turn')
const user = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'user/message')
expect(JSON.stringify(user)).toContain('resource_link')
})
it('rejects canonical session references when the optional capability is not mounted', 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: 'resource_link', uri: encodeSessionReferenceUri(SessionId('source')), name: 'source' }],
})).rejects.toThrow(/session reference capability unavailable/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('reports malformed inline session references at the ACP request boundary', 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: 'use dsh-session:IiJ' }],
})).rejects.toThrow(/invalid session reference/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('prepares ACP session resource links and inline mentions before one atomic send', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [textResponse('ok')] })
const source = harness.ctx.sessions.create(SessionId('source'), { meta: { cwd: '/source' } })
source.append('user/message', {
content: [{ type: 'text', text: 'source background' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const mention = formatSessionReferenceMention({ sessionId: source.id, label: 'source-inline' })
const result = await harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: `use ${mention} and ` },
{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source-link' },
],
})
expect(result.stopReason).toBe('end_turn')
const target = harness.ctx.agents.get(SessionId(sessionId))!.session
const user = target.events.find(event => event.type === 'user/message')
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({
displayContent: [{ type: 'text', text: 'use @source-inline and @source-link' }],
prefixContexts: [{
source: { kind: 'plugin', plugin: 'session-reference' },
meta: {
kind: 'session-reference',
references: [{ sessionId: 'source', label: 'source-inline' }],
},
}],
})
expect(target.events.some(event => event.type === 'user/message' && event.data.source.kind !== 'user')).toBe(false)
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
expect(request).toContain('untrusted, read-only snapshot')
expect(request).toContain('source background')
expect(request.indexOf('source background')).toBeLessThan(request.indexOf('## My request:'))
expect(request.indexOf('## My request:')).toBeLessThan(request.indexOf('use @source-inline and @source-link'))
})
it('rejects a failed referenced-session read before starting a turn', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, 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: 'resource_link', uri: encodeSessionReferenceUri(SessionId('missing')), name: 'missing' }],
})).rejects.toThrow(/preparation failed/)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('cancels reference preparation before a turn is created', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [] })
const source = harness.ctx.sessions.create(SessionId('source'))
const snapshot = await harness.ctx.sessionQuery.readSurface(source.id)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
let releaseRead: (() => void) | undefined
const readSurface = vi.spyOn(harness.ctx.sessionQuery, 'readSurface').mockImplementationOnce(async () => {
await new Promise<void>((resolve) => { releaseRead = resolve })
return snapshot
})
const pending = harness.client.prompt({
sessionId,
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source' }],
})
await vi.waitFor(() => { expect(releaseRead).toBeTypeOf('function') })
await harness.client.cancel({ sessionId })
await expect(pending).resolves.toEqual({ stopReason: 'cancelled' })
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
releaseRead?.()
await Promise.resolve()
readSurface.mockRestore()
})
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('renders the deployment persona into ACP-created agents\' requests', async () => {
harness = await makeBridgeHarness({
storageDir,
script: [textResponse('ok')],
persona: 'be terse',
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
// Create + prompt so the system-prompt plugin's persona section reaches
// the model request of an agent the BRIDGE created (session/new).
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')
})
})

View File

@@ -1,101 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import { encodeSessionReferenceUri, formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
import {
acpPromptToReferencedPrompt,
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' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'rejected', reason: 'blocked by hook' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'error', step: 1, 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 / plugin-added)', () => {
expect(harnessBlockToAcpContent({ type: 'reasoning', text: 'think' })).toBeUndefined()
expect(harnessBlockToAcpContent({ type: 'chart', data: 'x' } as unknown as ContentBlock)).toBeUndefined()
})
})
describe('acpPromptToText', () => {
it('concatenates text blocks and renders resource links explicitly', () => {
const prompt: AcpContentBlock[] = [
{ type: 'text', text: 'hello ' },
{ type: 'resource_link', uri: 'file:///x', name: 'x' },
{ type: 'text', text: 'world' },
]
expect(acpPromptToText(prompt)).toBe('hello \n[resource_link name="x" uri="file:///x"]\nworld')
})
it('returns empty string for a prompt with no text blocks', () => {
expect(acpPromptToText([{ type: 'image', mimeType: 'image/png', data: 'AA==' }])).toBe('')
})
})
describe('acpPromptToReferencedPrompt', () => {
it('extracts resource links and inline mentions while preserving ordinary links', () => {
const sessionId = SessionId('source/会话')
const prompt: AcpContentBlock[] = [
{ type: 'text', text: `compare ${formatSessionReferenceMention({ sessionId, label: 'inline' })} with ` },
{ type: 'resource_link', uri: encodeSessionReferenceUri(sessionId), name: 'linked' },
{ type: 'resource_link', uri: 'file:///x', name: 'x' },
]
expect(acpPromptToReferencedPrompt(prompt)).toEqual({
text: 'compare @inline with @linked\n[resource_link name="x" uri="file:///x"]\n',
references: [{ sessionId, label: 'inline' }, { sessionId, label: 'linked' }],
})
})
it('rejects malformed session resource links', () => {
expect(() => acpPromptToReferencedPrompt([
{ type: 'resource_link', uri: 'dsh-session:%%%', name: 'bad' },
])).toThrow(/invalid session reference URI/)
})
it('uses the decoded id for an empty resource name and ignores unsupported direct inputs', () => {
const sessionId = SessionId('source')
expect(acpPromptToReferencedPrompt([
{ type: 'resource_link', uri: encodeSessionReferenceUri(sessionId), name: '' },
{ type: 'image', mimeType: 'image/png', data: 'AA==' },
])).toEqual({ text: '@source', references: [{ sessionId, label: 'source' }] })
})
})
describe('promptHasUnsupportedContent', () => {
it('detects image, audio, and embedded resource blocks', () => {
expect(promptHasUnsupportedContent([{ type: 'image', mimeType: 'image/png', data: 'AA==' }])).toBe(true)
expect(promptHasUnsupportedContent([{ type: 'audio', mimeType: 'audio/wav', data: 'AA==' }])).toBe(true)
expect(promptHasUnsupportedContent([{ type: 'resource', resource: { uri: 'file:///x', text: 'x' } }])).toBe(true)
})
it('passes baseline text and resource_link prompt blocks', () => {
expect(promptHasUnsupportedContent([{ type: 'text', text: 'hi' }])).toBe(false)
expect(promptHasUnsupportedContent([{ type: 'resource_link', uri: 'file:///x', name: 'x' }])).toBe(false)
})
})

View File

@@ -1,299 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } 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 { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
function commandUpdates(harness: BridgeHarness, sessionId: string) {
return harness.sessionUpdates.filter(update => update.sessionId === sessionId
&& update.update.sessionUpdate === 'available_commands_update')
}
function messageText(harness: BridgeHarness, sessionId: string): string {
return harness.sessionUpdates
.filter(update => update.sessionId === sessionId && update.update.sessionUpdate === 'agent_message_chunk')
.map(({ update }) => update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
? update.content.text : '')
.join('')
}
describe('ACP plugin commands', () => {
let storageDir: string
let harness: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-command-')) })
afterEach(async () => {
if (harness !== undefined) await harness.dispose()
harness = undefined
await rm(storageDir, { recursive: true, force: true })
})
it('publishes a full command snapshot after session creation and refreshes it dynamically', async () => {
harness = await makeBridgeHarness({ storageDir })
harness.ctx.commands.register({
name: 'inspect',
description: 'Inspect the session',
input: { hint: '<target>' },
handler: () => ({ kind: 'success' }),
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await vi.waitFor(() => {
expect(commandUpdates(harness!, sessionId).at(-1)?.update).toEqual({
sessionUpdate: 'available_commands_update',
availableCommands: [{
name: 'inspect',
description: 'Inspect the session',
input: { hint: '<target>' },
}],
})
})
const dispose = harness.ctx.commands.register({
name: 'alpha',
description: 'Alpha command',
handler: () => ({ kind: 'success' }),
})
await vi.waitFor(() => {
expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({
availableCommands: [{ name: 'alpha' }, { name: 'inspect' }],
})
})
dispose()
await vi.waitFor(() => {
expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({
availableCommands: [{ name: 'inspect' }],
})
})
})
it('re-advertises commands after loading a persisted session', async () => {
const live = await makeBridgeHarness({ storageDir, script: [textResponse('persisted')] })
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: 'persist this session' }] })
await live.dispose()
harness = await makeBridgeHarness({ storageDir })
harness.ctx.commands.register({
name: 'loaded', description: 'Loaded command', handler: () => ({ kind: 'success' }),
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await harness.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(commandUpdates(harness, sessionId).at(-1)?.update).toMatchObject({
availableCommands: [{ name: 'loaded', description: 'Loaded command' }],
})
})
it('coalesces registry changes before a new session command snapshot is announced', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
harness.ctx.commands.register({
name: 'raced', description: 'Registered after the response', handler: () => ({ kind: 'success' }),
})
await vi.waitFor(() => {
expect(commandUpdates(harness!, sessionId)).toHaveLength(1)
expect(commandUpdates(harness!, sessionId)[0]?.update).toMatchObject({
availableCommands: [{ name: 'raced' }],
})
})
})
it('executes a known single-text command directly and never sends it to the model', async () => {
harness = await makeBridgeHarness({ storageDir })
const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' }))
harness.ctx.commands.register({ name: 'direct', description: 'Run directly', handler: seen })
harness.ctx.commands.register({
name: 'silent', description: 'Return no text', handler: () => ({ kind: 'success' }),
})
harness.ctx.commands.register({
name: 'empty', description: 'Return empty text', handler: () => ({ kind: 'success', text: '' }),
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const response = await harness.client.prompt({
sessionId,
prompt: [{ type: 'text', text: '/direct raw args ' }],
})
expect(response.stopReason).toBe('end_turn')
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' }))
expect(messageText(harness, sessionId)).toContain('DIRECT RESULT')
const updatesAfterText = harness.sessionUpdates.length
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/empty' }] })
expect(harness.sessionUpdates).toHaveLength(updatesAfterText)
expect(harness.adapter.requests).toHaveLength(0)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('renders expected command errors and rejects unknown slash commands without model fallback', async () => {
harness = await makeBridgeHarness({ storageDir })
harness.ctx.commands.register({
name: 'denied',
description: 'Deny directly',
handler: () => ({ kind: 'error', text: 'not allowed now' }),
})
harness.ctx.commands.register({
name: 'throws',
description: 'Throw an ordinary error',
handler: () => { throw new Error('handler exploded') },
})
harness.ctx.commands.register({
name: 'hostile',
description: 'Throw a hostile value',
handler: () => {
throw { toString(): string { throw new Error('coercion exploded') } }
},
})
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: '/denied' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/missing input' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/throws' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/hostile' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
expect(messageText(harness, sessionId)).toContain('Error: not allowed now')
expect(messageText(harness, sessionId)).toContain('Error: unknown command: /missing input')
expect(messageText(harness, sessionId)).toContain('Error: command failed: Error: handler exploded')
expect(messageText(harness, sessionId)).toContain('Error: command failed: <unrenderable thrown value>')
expect(harness.adapter.requests).toHaveLength(0)
})
it('flattens supported command prompt blocks without invoking the model', async () => {
harness = await makeBridgeHarness({ storageDir })
const command = vi.fn(() => ({ kind: 'success' as const, text: 'combined' }))
harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command })
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: '/direct' },
{ type: 'text', text: ' extra' },
{ type: 'resource_link', name: 'input', uri: 'file:///workspace/input.txt' },
],
})).resolves.toEqual({ stopReason: 'end_turn' })
expect(command).toHaveBeenCalledWith(expect.objectContaining({
rawInput: ' extra\n[resource_link name="input" uri="file:///workspace/input.txt"]\n',
}))
expect(messageText(harness, sessionId)).toContain('combined')
expect(harness.adapter.requests).toHaveLength(0)
})
it('keeps session-reference syntax opaque in direct command arguments', async () => {
harness = await makeBridgeHarness({ storageDir })
const command = vi.fn(() => ({ kind: 'success' as const }))
harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const sourceUri = encodeSessionReferenceUri(SessionId('source'))
await expect(harness.client.prompt({
sessionId,
prompt: [
{ type: 'text', text: `/direct valid=${sourceUri} malformed=dsh-session:IiJ` },
{ type: 'resource_link', name: 'source', uri: sourceUri },
],
})).resolves.toEqual({ stopReason: 'end_turn' })
expect(command).toHaveBeenCalledWith(expect.objectContaining({
rawInput: ` valid=${sourceUri} malformed=dsh-session:IiJ\n[resource_link name="source" uri=${JSON.stringify(sourceUri)}]\n`,
}))
expect(harness.adapter.requests).toHaveLength(0)
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
})
it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => {
harness = await makeBridgeHarness({ storageDir })
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
harness.ctx.commands.register({
name: 'wait',
description: 'Wait for cancellation',
handler: ({ signal }) => {
started()
return new Promise((resolve) => {
signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late abort result' }) }, { once: true })
})
},
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const waiting = harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] })
await ready
await expect(harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] }))
.rejects.toThrow(/already in flight/)
await harness.client.cancel({ sessionId: a.sessionId })
await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' })
await expect(harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/missing' }] }))
.resolves.toEqual({ stopReason: 'end_turn' })
expect(messageText(harness, a.sessionId)).not.toContain('late abort result')
})
it('aborts an in-flight command when the ACP bridge is disposed', async () => {
harness = await makeBridgeHarness({ storageDir })
let started!: () => void
const ready = new Promise<void>((resolve) => { started = resolve })
let commandSignal: AbortSignal | undefined
harness.ctx.commands.register({
name: 'wait-dispose',
description: 'Wait for bridge disposal',
handler: ({ signal }) => {
commandSignal = signal
started()
return new Promise<never>(() => {})
},
})
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const waiting = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/wait-dispose' }] })
await ready
await harness.acpFiber.dispose()
expect(commandSignal?.aborted).toBe(true)
await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' })
})
it('resolves scoped command catalogs and execution independently per session', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agentA = harness.ctx.agents.get(SessionId(a.sessionId))
if (agentA === undefined) throw new Error('session A has no agent')
await agentA.ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({
name: 'private', description: 'Only session A',
handler: () => ({ kind: 'success', text: 'A ONLY' }),
})
})
await vi.waitFor(() => {
expect(commandUpdates(harness!, a.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [{ name: 'private' }] })
})
expect(commandUpdates(harness, b.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [] })
await harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/private' }] })
await harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/private' }] })
expect(messageText(harness, a.sessionId)).toContain('A ONLY')
expect(messageText(harness, b.sessionId)).toContain('unknown command')
})
})

View File

@@ -1,420 +0,0 @@
/**
* Exercises the bridge's per-session Permissions option: validation, idle
* turn anchoring, isolation, and persistence through `session/load`.
*/
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 InvariantService from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import PermissionService from '@deepseek-ai/dsh-permission'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
/**
* Advertises the real executor through the `sandboxMode` capability without
* loading a kernel sandbox, which these bridge tests do not exercise.
*/
class SandboxedLocalExecutor extends LocalBashExecutor {
override get sandboxMode(): SandboxMode {
return 'workspace-write'
}
}
async function mountInvariants(ctx: BridgeHarness['ctx']): Promise<void> {
await ctx.plugin(InvariantService)
await ctx.plugin(SessionInvariant)
await ctx.plugin(AgentInvariant)
await ctx.plugin(AgentLoopInvariant)
}
function permissionOption(currentValue: string): object {
return {
id: 'permission',
name: 'Permissions',
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
category: 'mode',
type: 'select',
currentValue,
options: [
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
],
}
}
function modelValue(provider = 'mock', model = 'mock'): string {
return JSON.stringify([provider, model])
}
function modelOption(currentValue = modelValue()): object {
return {
id: 'model',
name: 'Model',
description: 'Sets this session\'s provider and model.',
category: 'model',
type: 'select',
currentValue,
options: [{ value: modelValue(), name: 'Mock' }],
}
}
function optionsWithPermission(currentValue: string): object[] {
return [modelOption(), permissionOption(currentValue)]
}
describe('acp bridge — session config options', () => {
let storageDir: string
let h: BridgeHarness | undefined
let loader: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-config-')) })
afterEach(async () => {
if (h) await h.dispose()
if (loader) await loader.dispose()
h = loader = undefined
await rm(storageDir, { recursive: true, force: true })
})
async function presetStack(options: { script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
// Make an out-of-turn switch fail in this suite.
await mountInvariants(harness.ctx)
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
await harness.ctx.plugin(ApprovalService)
await harness.ctx.plugin(PermissionService)
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
return harness
}
it('advertises the model selector without requiring the permission service', async () => {
h = await makeBridgeHarness({ storageDir })
await h.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
await h.ctx.plugin(ApprovalService)
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual([modelOption()])
})
it('groups models by provider and switches routing plus prompt variables as one session target', async () => {
h = await makeBridgeHarness({
storageDir,
script: [textResponse('ok')],
config: { provider: 'alpha', model: 'a1' },
persona: 'Route {{provider}} / {{model}}',
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
models: [
{ provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' },
{ provider: 'beta', id: 'b1', name: 'Beta One' },
],
},
})
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const created = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(created.configOptions).toEqual([{
id: 'model',
name: 'Model',
description: 'Sets this session\'s provider and model.',
category: 'model',
type: 'select',
currentValue: modelValue('alpha', 'a1'),
options: [
{ group: 'alpha', name: 'Alpha', options: [{ value: modelValue('alpha', 'a1'), name: 'Alpha One', description: 'Fast' }] },
{ group: 'beta', name: 'Beta', options: [{ value: modelValue('beta', 'b1'), name: 'Beta One' }] },
],
}])
const switched = await h.client.setSessionConfigOption({
sessionId: created.sessionId,
configId: 'model',
value: modelValue('beta', 'b1'),
})
expect(switched.configOptions?.[0]).toMatchObject({ currentValue: modelValue('beta', 'b1') })
await h.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use beta' }] })
expect(h.adapter.requests[0]).toMatchObject({
provider: 'beta',
model: 'b1',
})
expect(h.adapter.requests[0]?.system).toContain('Route beta / b1')
expect(h.ctx.agents.list()[0]?.session.requestHeader()?.config).toMatchObject({ provider: 'beta', model: 'b1' })
})
it('adds the configured private model to an advisory catalog and ignores empty non-current groups', async () => {
h = await makeBridgeHarness({
storageDir,
config: { provider: 'alpha', model: 'private-model' },
catalog: {
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'empty', name: 'Empty' }],
models: [{ provider: 'alpha', id: 'public-model', name: 'Public Model' }],
},
})
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions?.[0]).toMatchObject({
currentValue: modelValue('alpha', 'private-model'),
options: [
{ value: modelValue('alpha', 'public-model'), name: 'Public Model' },
{ value: modelValue('alpha', 'private-model'), name: 'private-model' },
],
})
})
it('omits model selection without a complete or registered current target', async () => {
h = await makeBridgeHarness({ storageDir, config: { model: undefined } })
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const missing = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(missing.configOptions).toBeUndefined()
await h.dispose()
h = await makeBridgeHarness({ storageDir, config: { provider: 'unregistered', model: 'm' } })
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const unknown = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(unknown.configOptions).toBeUndefined()
})
it('leaves model-less agents available to another agent/request supplier', async () => {
h = await makeBridgeHarness({ storageDir, config: { model: undefined }, script: [textResponse('ok')] })
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent')
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _signal, _next) => ({
...callConfig,
provider: 'mock',
model: 'mock',
}))
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'supplied elsewhere' }] })
expect(h.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
})
it('advertises the Permissions select with the default preset current', async () => {
h = await presetStack()
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual(optionsWithPermission('workspace-write'))
})
it('an idle switch is pending (overlaid, not yet logged), then anchors inside the next prompt\'s turn', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
expect(after.configOptions).toEqual(optionsWithPermission('danger-full-access'))
const session = h.ctx.agents.list()[0]?.session
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false)
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = session?.events ?? []
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
expect(events.filter(e => e.type === 'sandbox/mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
const turnStart = events.findIndex(e => e.type === 'turn/start')
const anchored = events.findIndex(e => e.type === 'permission/preset')
expect(turnStart).toBeGreaterThanOrEqual(0)
expect(anchored).toBeGreaterThan(turnStart)
})
it('an idle flip-flop anchors as one switch (last write wins)', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
expect(again.configOptions).toEqual(optionsWithPermission('danger-full-access'))
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
// A closed turn does not make a later idle switch appendable.
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
expect(h.ctx.agents.list()[0]?.session.events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
})
it('a net-zero idle flip-flop anchors nothing (switches are recorded, select clicks are not)', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
expect(back.configOptions).toEqual(optionsWithPermission('workspace-write'))
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false)
})
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
expect(echo.configOptions).toEqual(optionsWithPermission('workspace-write'))
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
expect(repeat.configOptions).toEqual(optionsWithPermission('danger-full-access'))
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
const events = h.ctx.agents.list()[0]?.session.events ?? []
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
})
it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => {
h = await presetStack({ script: ['hang'] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
// Give the loop a tick to open the turn (the turns.spec hang idiom).
await new Promise(resolve => setTimeout(resolve, 30))
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const events = h.ctx.agents.list()[0]?.session.events ?? []
const turnStart = events.findIndex(e => e.type === 'turn/start')
const anchored = events.findIndex(e => e.type === 'permission/preset')
expect(turnStart).toBeGreaterThanOrEqual(0)
expect(anchored).toBeGreaterThan(turnStart)
expect(events.some(e => e.type === 'sandbox/mode')).toBe(true)
expect(events.some(e => e.type === 'approval/policy')).toBe(true)
await h.client.cancel({ sessionId })
await hung
})
it('rejects unknown ids, unadvertised ids, boolean values, and out-of-vocabulary values', async () => {
h = await makeBridgeHarness({ storageDir })
await h.ctx.plugin(ApprovalService)
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' }))
.rejects.toThrow(/unknown config option/)
// This composition never advertised `permission`.
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }))
.rejects.toThrow(/unknown permission value/)
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'model', value: modelValue('mock', 'missing') }))
.rejects.toThrow(/unknown model value/)
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true }))
.rejects.toThrow(/select; boolean values are not accepted/)
})
it('rejects an out-of-vocabulary preset on an advertising composition', async () => {
h = await presetStack()
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'plan' }))
.rejects.toThrow(/unknown permission value/)
})
it('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => {
h = await presetStack()
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' })
expect(bAfter.configOptions).toEqual(optionsWithPermission('workspace-write'))
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
expect(aAfter.configOptions).toEqual(optionsWithPermission('danger-full-access'))
})
it('keeps model targets isolated across concurrent sessions', async () => {
h = await makeBridgeHarness({
storageDir,
script: [textResponse('a'), textResponse('b')],
config: { provider: 'mock', model: 'one' },
catalog: {
providers: [{ id: 'mock', name: 'Mock' }],
models: [
{ provider: 'mock', id: 'one', name: 'One' },
{ provider: 'mock', id: 'two', name: 'Two' },
],
},
})
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'model', value: modelValue('mock', 'two') })
await h.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: 'a' }] })
await h.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: 'b' }] })
expect(h.adapter.requests.map(request => request.model)).toEqual(['two', 'one'])
})
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
h = await presetStack()
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
// A plugin may write a valid state not represented by a named preset.
const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent')
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
agent.session.append('sandbox/mode', { mode: 'read-only' })
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
const option = echo.configOptions?.find(entry => entry.id === 'permission')
expect(option).toMatchObject({ currentValue: 'custom' })
if (option === undefined || !('options' in option)) throw new Error('expected a select option')
expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access', 'custom'])
const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
const afterOption = away.configOptions?.find(entry => entry.id === 'permission')
expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' })
if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option')
expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access'])
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' }))
.rejects.toThrow(/unknown permission value/)
})
it('session/load reports a resumed session\'s preset from its own log', async () => {
h = await presetStack({ script: [textResponse('ok')] })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
// One turn checkpoints the log (the switch events flush with it).
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] })
await h.dispose()
h = undefined
loader = await presetStack()
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(res.configOptions).toEqual(optionsWithPermission('danger-full-access'))
})
it('session/load restores the last requested provider/model from the request header', async () => {
const catalog = {
providers: [{ id: 'mock', name: 'Mock' }],
models: [
{ provider: 'mock', id: 'one', name: 'One' },
{ provider: 'mock', id: 'two', name: 'Two' },
],
}
h = await makeBridgeHarness({
storageDir,
script: [textResponse('ok')],
config: { provider: 'mock', model: 'one' },
catalog,
})
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await h.client.setSessionConfigOption({ sessionId, configId: 'model', value: modelValue('mock', 'two') })
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist target' }] })
await h.dispose()
h = undefined
loader = await makeBridgeHarness({ storageDir, config: { provider: 'mock', model: 'one' }, catalog })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const loaded = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(loaded.configOptions?.find(option => option.id === 'model')).toMatchObject({
currentValue: modelValue('mock', 'two'),
})
})
it('session/load omits config options when the persisted session has no target or permission service', async () => {
h = await makeBridgeHarness({ storageDir, config: { model: undefined } })
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const agent = h.ctx.agents.list()[0]
if (agent === undefined) throw new Error('expected an agent')
agent.inject([{ type: 'text', text: 'checkpoint' }], { source: { kind: 'plugin', plugin: 'test' } })
await agent.whenIdle()
await h.dispose()
h = undefined
loader = await makeBridgeHarness({ storageDir, config: { model: undefined } })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const loaded = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(loaded.configOptions).toBeUndefined()
})
})

View File

@@ -1,299 +0,0 @@
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 } 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(sessionId))!
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
// Fiber disposal must not resolve until the running agent is quiescent.
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: [] })
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('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
// The factory (`ctx.agents.create`) is reached through the bridge's
// traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
// registration binds to the CALLER context — the bridge fiber — not the
// AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
// must therefore reclaim the agent's registry entry, even though agents/
// agent-loop stay up. This pins the fiber-ownership the bridge's teardown
// doc comment relies on; if a refactor rebinds the registration to the
// AgentLoop fiber, the agent would survive bridge dispose and this fails.
const harness = await makeBridgeHarness({ storageDir, script: [] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeDefined()
await harness.acpFiber.dispose() // tear down ONLY the bridge
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
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 disposes the session (no registered agent left)', async () => {
// Transport closure owns the agent handle; idle-but-registered is also a leak.
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(sessionId))!
// The transport will sever this RPC, 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')
await harness.closeClientTransport()
await agent.whenIdle()
expect(agent.status).toBe('disposed')
// Fiber disposal joins the disconnect teardown while root services remain queryable.
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
await harness.dispose()
})
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(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(sessionId))!.session
await harness.ctx.fiber.dispose()
const before = harness.updates.length
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)
})
it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
// AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
// through the still-attached store observer → `session/event`), and only
// THEN remove its publication hooks and session entry. If the order were inverted
// (detach first), the closing events would never reach persistence. Drive a
// CLEAN turn to completion, dispose JUST the bridge, then re-load the
// persisted log from disk and assert the closing turn/end is on disk — the
// world, not the agent's self-report.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
const liveEvents = harness.ctx.agents.get(SessionId(sessionId))!.session.events.length
expect(liveEvents).toBeGreaterThan(0)
// Tear down JUST the bridge (the AgentHandle dispose runs to quiescence).
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
// Re-load the session from disk: every live event (incl. the closing
// turn/end) was flushed before the session was detached.
const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId))
expect(reloaded.events.length).toBe(liveEvents)
const last = reloaded.events.at(-1)!
expect(last.type).toBe('turn/end')
await harness.dispose()
})
it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => {
// The teardown-order contract only earns its keep when the closing events are
// produced BY the dispose itself. Here the model stream HANGS, so the turn is
// still open when teardown runs: the composite agent effect stops the loop,
// the loop unwinds and appends `turn/end {disposed}` + runs its final
// `session/flush` — all while the store-owned publication hooks are still attached (the session
// detach is the LAST disposer in the same effect's LIFO chain) — and only
// THEN is the session detached. If the order were inverted (or the session
// were a racing SIBLING effect), the abort-produced `turn/end` would never
// reach disk and a re-load would instead show crash-recovery's synthetic
// `interrupted` closer. Re-load from disk and assert the REAL `disposed`
// reason landed — proving the loop's own closing event was captured, not a
// recovered substitute.
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(sessionId))!
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
// The turn is OPEN in the log (turn/start appended, no turn/end yet).
const openTurnEnds = agent.session.events.filter(e => e.type === 'turn/end').length
// Dispose JUST the bridge: a fiber unload that must STILL honor the ordered
// teardown (the composite effect runs its disposer chain as a unit).
await harness.acpFiber.dispose()
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
// The loop's own `turn/end {disposed}` is on disk (re-load: the world, not
// self-report) — NOT a crash-recovery `interrupted` substitute.
const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId))
const persistedTurnEnds = reloaded.events.filter(e => e.type === 'turn/end')
expect(persistedTurnEnds.length).toBe(openTurnEnds + 1)
expect(persistedTurnEnds.at(-1)!.data.reason).toMatchObject({ kind: 'disposed' })
await harness.dispose()
})
it('per-session AgentHandle dispose leaves sibling agents untouched', async () => {
// The factory returns a per-agent AgentHandle whose dispose() tears down
// EXACTLY that agent + its session — the registry's per-handle isolation
// contract. Create two agents
// directly through the registry factory (the same path the ACP bridge uses),
// dispose one handle, and assert the other survives, registered and
// queryable, with its session still in the store.
const harness = await makeBridgeHarness({ storageDir, script: [] })
const handleA = await harness.ctx.agents.create({
sessionId: SessionId('sib-a'), agentOptions: { provider: 'mock', model: 'mock' },
})
const handleB = await harness.ctx.agents.create({
sessionId: SessionId('sib-b'), agentOptions: { provider: 'mock', model: 'mock' },
})
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBe(handleA.agent)
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
await handleA.dispose()
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
expect(handleA.agent.status).toBe('disposed')
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
expect(handleB.agent.status).not.toBe('disposed')
await harness.dispose()
})
it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => {
// The AgentHandle teardown folds session-detach, register, and loop-stop
// into ONE composite effect whose disposers run as a `.then()` chain. The
// register disposer emits `agent/disposed`; if a listener throws and the
// emit is UNCONTAINED, the rejected chain skips the LATER session-detach
// disposer — stranding the session in the store with its publication hooks attached (a
// leak AND a durability hole, since the new design relies on detach
// running). The emit must be contained. Register a throwing listener, drive
// a clean turn, dispose, and assert the session was STILL removed.
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
const handle = await harness.ctx.agents.create({
sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' },
})
handle.agent.followup([{ type: 'text', text: 'go' }])
await handle.agent.whenIdle()
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined()
// Dispose: the throwing listener must NOT break the chain before detach.
await handle.dispose()
expect(harness.ctx.agents.get(SessionId('guard-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran
await harness.dispose()
})
it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => {
// The handle's dispose() must memoize: the underlying cordis effect disposer
// is single-shot, so a second dispose() while the first is mid-teardown would
// otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the
// first call's await agent.done + final flush finished. Every caller must
// observe the same quiescence boundary.
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
const handle = await harness.ctx.agents.create({
sessionId: SessionId('conc-a'), agentOptions: { provider: 'mock', model: 'mock' },
})
// Drive a turn that hangs in the model stream, so the loop is mid-turn when
// disposed — its exit runs a final session/flush we can gate to hold the
// teardown observably in-flight.
handle.agent.followup([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
expect(handle.agent.status).toBe('running')
let releaseFlush!: () => void
const flushGate = new Promise<void>((resolve) => { releaseFlush = resolve })
harness.ctx.on('session/flush', () => flushGate)
// First dispose enters teardown (aborts the hanging step) and blocks in the
// gated final flush.
const first = handle.dispose()
let firstSettled = false
void first.then(() => { firstSettled = true })
await new Promise(r => setTimeout(r, 20))
expect(firstSettled).toBe(false)
// Second dispose MUST await the same in-flight teardown, not resolve early.
const second = handle.dispose()
let secondSettled = false
void second.then(() => { secondSettled = true })
await new Promise(r => setTimeout(r, 20))
expect(secondSettled).toBe(false) // memoized: still pending with the first
// Release the flush; both resolve together and the session is gone.
releaseFlush()
await Promise.all([first, second])
expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined()
expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined()
await harness.dispose()
})
})

View File

@@ -1,68 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } 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 } 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 events 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: [] })
await vi.waitFor(() => {
expect(harness!.updates.some(update => update.sessionUpdate === 'available_commands_update')).toBe(true)
})
const before = harness.updates.length
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
foreign.followup([{ 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()
})
it('rejects non-empty mcpServers until MCP wiring is implemented', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.newSession({
cwd: process.cwd(),
mcpServers: [{ name: 'fs', command: 'npx', args: ['server'], env: [] }],
})).rejects.toThrow(/mcpServers/)
})
})

View File

@@ -1,340 +0,0 @@
/**
* Shared non-spec fixture that mounts the full in-memory agent/persistence stack and connects the
* ACP bridge to a real SDK client over memory streams. Tests exercise the same protocol path as an
* editor without a subprocess or stdio.
*/
import { Context } from 'cordis'
import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
import {
ClientSideConnection,
ndJsonStream,
type Agent as AcpAgent,
type Client,
type CreateElicitationRequest,
type CreateElicitationResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type Stream,
} from '@agentclientprotocol/sdk'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import SessionReferenceService from '@deepseek-ai/dsh-session-reference'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as AcpPlugin from '../src/index.ts'
import { type AcpConfig } from '../src/index.ts'
class TestSessionQueryService extends SessionQueryService {
override searchSessions(
..._args: Parameters<SessionQueryService['searchSessions']>
): ReturnType<SessionQueryService['searchSessions']> {
return Promise.resolve({ items: [] })
}
override searchEvents(
..._args: Parameters<SessionQueryService['searchEvents']>
): ReturnType<SessionQueryService['searchEvents']> {
return Promise.resolve({ items: [] })
}
}
/** A scripted mock adapter (mirrors the agent-loop test adapter). */
class MockAdapter extends LlmAdapter {
requests: GenerateOptions[] = []
constructor(
private script: (StreamChunk[] | 'hang')[],
private readonly providers: readonly LlmProviderInfo[],
private readonly models: readonly LlmModelInfo[],
) {
super()
}
override providerInfo(provider: string): LlmProviderInfo {
const info = this.providers.find(entry => entry.id === provider)
if (info === undefined) throw new Error(`MockAdapter: unknown provider ${provider}`)
return info
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
return Promise.resolve(this.models.filter(model => model.provider === provider))
}
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', failure: { 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 (payload only). */
updates: CapturedUpdate[]
/** Same, but tagged with each update's `sessionId` (for multi-session demux assertions). */
sessionUpdates: { sessionId: string; update: 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
/** Elicitation requests the bridge issued for ask_user_question. */
elicitationRequests: CreateElicitationRequest[]
/** Decide each elicitation response (default: cancel). */
onElicitation: (req: CreateElicitationRequest) => CreateElicitationResponse | Promise<CreateElicitationResponse>
/** 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
}
/** Test-only overrides preserve explicit undefined to suppress harness defaults. */
type AcpConfigOverrides = { [K in keyof AcpConfig]?: AcpConfig[K] | undefined }
/**
* 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 an explicit undefined route field to suppress its mock default.
*/
export async function makeBridgeHarness(options: {
script?: (StreamChunk[] | 'hang')[]
config?: AcpConfigOverrides
/** Provider-neutral directory exposed to ACP model-selection tests. */
catalog?: { providers: LlmProviderInfo[]; models: LlmModelInfo[] }
/** Deployment persona for the tree (the system-prompt plugin's config). */
persona?: string
storageDir: string
/**
* Plug the REAL `dsh-bash-local` executor + `dsh-tool-bash` tools (instead of
* a test's own inline tool). Lets a test drive the actual `bash` tool — its
* real `presentCall`/`presentResult` — through the bridge, so tool-call UI
* tests verify the SHIPPING tool, not a stand-in (docs/testing.md "prefer the real
* implementation over a mock in tests").
*/
withBash?: boolean
/** Plug the REAL `ask_user_question` tool and ACP user-interaction provider. */
withAskUser?: boolean
/**
* Plug the REAL `dsh-tool-todo` tool so a test can drive `todo_write` through
* the bridge and assert the resulting `plan` sessionUpdate — the shipping
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
*/
withTodo?: boolean
/** Mount exact session reads and cross-session snapshot preparation before ACP. */
withSessionReferences?: boolean
/** Plug the REAL `dsh-plan-mode` plugin so a test can drive the session-mode picker. */
withModes?: boolean
/**
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
* and assert their tool-owned presentation (title/kind/`locations`) on the
* wire — the shipping tools, not a stand-in. `fsCwd` sets the local backend's
* base directory (default: `storageDir`).
*/
withFs?: boolean
fsCwd?: string
} = { storageDir: '' }): Promise<BridgeHarness> {
const catalog = options.catalog ?? {
providers: [{ id: 'mock', name: 'Mock' }],
models: [{ provider: 'mock', id: 'mock', name: 'Mock' }],
}
const adapter = new MockAdapter(options.script ?? [], catalog.providers, catalog.models)
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx, {
systemPrompt: { persona: options.persona ?? '' },
})
await ctx.plugin(CommandService)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
await ctx.plugin(TestSessionQueryService)
if (options.withSessionReferences) {
await ctx.plugin(SessionReferenceService)
}
await ctx.plugin(UserInteractionService)
if (options.withAskUser) {
await ctx.plugin(ToolAskUser)
}
if (options.withBash) {
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(ToolBash)
}
if (options.withTodo) {
await ctx.plugin(ToolTodo)
}
if (options.withModes) {
await ctx.plugin(PlanModeService, { section: 'Test plan mode instructions.' })
}
if (options.withFs) {
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs)
}
ctx.llm.registerAdapter(catalog.providers.map(provider => provider.id), 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.) Holding the c2a
// writer lets tests EOF the agent reader and simulate editor disconnect.
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 sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = []
const permissionRequests: RequestPermissionRequest[] = []
const elicitationRequests: CreateElicitationRequest[] = []
const harness: BridgeHarness = {
ctx,
adapter,
updates,
sessionUpdates,
permissionRequests,
onPermission: () => ({ outcome: { outcome: 'cancelled' } }),
elicitationRequests,
onElicitation: () => ({ action: 'cancel' }),
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.
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)
sessionUpdates.push({ sessionId: params.sessionId, update: 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))
},
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse> {
elicitationRequests.push(params)
return Promise.resolve(harness.onElicitation(params))
},
})
// Default route fields only when the caller omitted them; explicit undefined values must survive.
const cfg = { stream: agentStream, ...options.config } as AcpConfig
if (!(options.config && 'provider' in options.config)) cfg.provider = 'mock'
if (!(options.config && 'model' in options.config)) cfg.model = 'mock'
// Mount the bridge the way production does: as a cordis plugin (via `ctx.plugin` with the
// real `inject`), not `AcpPlugin.apply(ctx, cfg)` on the ungated root. Later JSON-RPC callbacks run
// outside apply's injection scope, matching production and exposing missing-inject failures.
harness.acpFiber = await ctx.plugin({
name: 'acp-test',
// Use the bridge's real exported `inject` so this never drifts from the plugin's actual
// dependency list (adding a service to the bridge must not require editing the harness — a
// hardcoded list silently broke when `tools` was added). The returned fiber permits ACP-only
// disposal while root services remain live for HMR assertions.
inject: [...AcpPlugin.inject],
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
})
harness.client = new ClientSideConnection(makeClient, clientStream)
return harness
}

View File

@@ -1,337 +0,0 @@
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 { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import { makeBridgeHarness, textResponse, toolCallResponse, 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('streams and replays the same persisted session_info_update for a title event', async () => {
live = await makeBridgeHarness({ storageDir, script: [] })
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
const session = live.ctx.agents.get(SessionId(sessionId))!.session
const event = await live.ctx.sessions.appendOutOfBand(session, 'session/title', {
title: 'Durable ACP title',
messageSeqs: [1],
source: { kind: 'fallback' },
}, { kind: 'session-title' })
const expected = {
sessionUpdate: 'session_info_update' as const,
title: 'Durable ACP title',
updatedAt: new Date(event.time).toISOString(),
}
expect(live.updates).toContainEqual(expected)
await live.dispose()
live = undefined
loader = await makeBridgeHarness({ storageDir, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(loader.updates).toContainEqual(expected)
})
it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => {
// Persist a real bash call, then replay it through a fresh bridge. A throwaway presenter pairs
// call and result in log order so replay uses the shipping tool's same cards as live streaming.
live = await makeBridgeHarness({
storageDir,
withBash: true,
script: [toolCallResponse('c1', 'bash', { command: 'echo hello', description: 'Print a greeting' }), textResponse('done')],
})
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: 'greet' }] })
await live.dispose()
live = undefined
// A fresh bridge — also with the real bash tool, since the presentation is
// resolved from the live registry at replay time — loads the session.
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
expect(call).toMatchObject({ toolCallId: 'c1', title: 'echo hello', kind: 'execute', rawInput: 'echo hello' })
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// Capability OFF on this loader: the description renders as a content block, no terminal block.
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
expect(update?.sessionUpdate).toBe('tool_call_update')
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
const content = update.content as { content: { text: string } }[]
expect(content[0]?.content.text).toBe('```console\nhello\n```')
})
it('replays a persisted todo/write as a plan sessionUpdate on load', async () => {
// A persisted `todo/write` must replay as an ACP plan update so a reopened editor sees the
// current plan, not just the tool transcript.
live = await makeBridgeHarness({
storageDir,
withTodo: true,
script: [
toolCallResponse('c1', 'todo_write', {
todos: [
{ content: 'first step', status: 'in_progress' },
{ content: 'second step', status: 'pending' },
],
}),
textResponse('planned'),
],
})
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: 'plan it' }] })
await live.dispose()
live = undefined
loader = await makeBridgeHarness({ storageDir, withTodo: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const plan = loader.updates.find(u => u.sessionUpdate === 'plan')
expect(plan).toEqual({
sessionUpdate: 'plan',
entries: [
{ content: 'first step', priority: 'medium', status: 'in_progress' },
{ content: 'second step', priority: 'medium', status: 'pending' },
],
})
})
it('replays a persisted bash call as a TERMINAL card when the loader advertises the capability', async () => {
// The presentation is resolved at replay time, so a loader that advertised
// _meta.terminal_output must reconstruct the terminal card (content + _meta)
// from the persisted log — identical to how it would have streamed live.
live = await makeBridgeHarness({
storageDir,
withBash: true,
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
})
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: 'greet' }] })
await live.dispose()
live = undefined
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// Replay reconstructs the terminal card: description block, then terminal block.
expect(call.content).toEqual([
{ type: 'content', content: { type: 'text', text: 'Greet' } },
{ type: 'terminal', terminalId: 'c1' },
])
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
// Terminal mode: content omitted, output + exit on _meta — matching live.
expect(update.content).toBeUndefined()
const meta = update._meta as { terminal_output?: { data: string }; terminal_exit?: { exit_code?: number } }
expect(meta.terminal_output?.data).toBe('hi\n')
expect(meta.terminal_exit?.exit_code).toBe(0)
})
it('keeps one terminal completion live and on replay when a pruning replacement is logged', async () => {
live = await makeBridgeHarness({
storageDir,
withBash: true,
script: [toolCallResponse('c1', 'bash', { command: 'echo full', description: 'Print full output' }), textResponse('done')],
})
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] })
const session = live.ctx.agents.get(SessionId(sessionId))!.session
const original = session.events.find(event => event.type === 'tool/result')
if (original?.type !== 'tool/result') throw new Error('expected original tool/result')
const liveCompletions = () => live!.updates.filter(update =>
update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
expect(liveCompletions()).toHaveLength(1)
expect((liveCompletions()[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
.toBe('full\n')
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
})
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
// The replacement is durable but is not another live completion.
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
expect(JSON.stringify(session.deriveMessages())).toContain('tool result middle pruned')
expect(liveCompletions()).toHaveLength(1)
await live.dispose()
live = undefined
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const replayed = loader.updates.filter(update =>
update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
expect(replayed).toHaveLength(1)
expect((replayed[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
.toBe('full\n')
})
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
// Stall persistence so transport closes while resume is pending. Whether the SDK rejects first
// or the bridge's post-await guard fires, no agent may survive for the dead connection.
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(sessionId))).toBeUndefined()
})
it('rejects load when the requested cwd does not match the persisted session cwd', async () => {
// Seed a session on disk whose header.cwd is a DIFFERENT absolute path than the server's
// launch dir. Resume must retain the header cwd and route bash there rather than reject the
// mismatch or substitute the server cwd.
loader = await makeBridgeHarness({ storageDir, script: [] })
const otherCwd = '/some/other/workspace'
await loader.ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd,
})
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(/cwd mismatch/)
expect(loader.ctx.agents.get(SessionId('elsewhere'))).toBeUndefined()
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] })
expect(res).toBeDefined()
expect(loader.ctx.agents.get(SessionId('elsewhere'))!.session.header.cwd).toBe(otherCwd)
})
it('rejects load for a non-absolute cwd (still required to be absolute)', 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/)
})
it('lets persistence reject a load for an unknown id after metadata lookup misses', async () => {
loader = await makeBridgeHarness({ storageDir, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(loader.client.loadSession({ sessionId: 'missing', cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/Internal error/)
})
it('rejects loading a persisted session that has NO cwd (would silently run in the launch dir)', async () => {
// A legacy/external log without `header.cwd` must be rejected; the request cwd does not override
// it, and accepting would let bash silently fall back to the server launch directory.
loader = await makeBridgeHarness({ storageDir, script: [] })
await loader.ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION, id: SessionId('legacy'), createdAt: 1, // no cwd
})
await loader.ctx.sessionPersistence.append(SessionId('legacy'), [
{ 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: 'legacy', cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/no absolute persisted cwd/)
// Rejected BEFORE resume (metadata-only check) — no agent was registered, so
// the id is not wedged: a later attempt hits the same clean rejection, not a
// duplicate-registration error.
expect(loader.ctx.agents.get(SessionId('legacy'))).toBeUndefined()
await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/no absolute persisted cwd/)
})
it('allows loading alongside an existing session but rejects re-loading the SAME id', async () => {
// Multi-session: a load can coexist with a live session, but loading an id
// that is already live is rejected (it is already loaded).
live = await makeBridgeHarness({ storageDir, script: [textResponse('one')] })
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' }] })
// A different new session coexists.
const other = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(other.sessionId).not.toBe(sessionId)
// Re-loading the already-live id is rejected.
await expect(live.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }))
.rejects.toThrow(/already loaded/)
})
})

View File

@@ -1,116 +0,0 @@
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'
/** The `current_mode_update` notifications, in order. */
function modeUpdates(updates: CapturedUpdate[]): string[] {
return updates
.filter(update => update.sessionUpdate === 'current_mode_update')
.map(update => update.currentModeId)
}
describe('acp bridge — plan mode projection', () => {
let storageDir: string
let harness: BridgeHarness | undefined
let loader: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-modes-')) })
afterEach(async () => {
if (harness) await harness.dispose()
if (loader) await loader.dispose()
harness = loader = undefined
await rm(storageDir, { recursive: true, force: true })
})
it('advertises no mode surface and rejects session/set_mode when plan mode is not composed', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.modes).toBeUndefined()
await expect(harness.client.setSessionMode({ sessionId: res.sessionId, modeId: 'plan' }))
.rejects.toMatchObject({ message: expect.stringContaining('session modes are not composed') as string })
})
it('advertises availableModes/currentModeId on session/new', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(res.modes).toEqual({
availableModes: [
{ id: 'default', name: 'default' },
{ id: 'plan', name: 'plan' },
],
currentModeId: 'default',
})
})
it('session/set_mode records the pending intent and echoes one optimistic current_mode_update', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
expect(modeUpdates(harness.updates)).toEqual(['plan'])
const agent = harness.ctx.agents.get(SessionId(sessionId))!
expect(harness.ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
})
it('rejects an unknown ACP mode id at the adapter boundary', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await expect(harness.client.setSessionMode({ sessionId, modeId: 'nope' }))
.rejects.toMatchObject({ message: expect.stringContaining('unknown session mode "nope"') as string })
expect(modeUpdates(harness.updates)).toEqual([])
})
it('does not re-notify when the boundary flush logs the mode the picker already showed', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
const agent = harness.ctx.agents.get(SessionId(sessionId))!
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(true)
expect(modeUpdates(harness.updates)).toEqual(['plan'])
})
it('re-notifies on a logged flip the picker has not seen (the tool-driven exit shape)', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
// A writer other than the picker (exit_plan_mode's execute) appends the
// flip back; the bridge must re-notify the client off the logged event.
const agent = harness.ctx.agents.get(SessionId(sessionId))!
agent.session.append('plan/mode', { active: false })
// The notification crosses the in-memory JSON-RPC transport asynchronously.
await new Promise(resolve => setTimeout(resolve, 20))
expect(modeUpdates(harness.updates)).toEqual(['plan', 'default'])
})
it('advertises the folded mode on session/load', async () => {
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
await harness.dispose()
harness = undefined
loader = await makeBridgeHarness({ storageDir, withModes: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
expect(res.modes).toEqual({
availableModes: [
{ id: 'default', name: 'default' },
{ id: 'plan', name: 'plan' },
],
currentModeId: 'plan',
})
})
})

View File

@@ -1,129 +0,0 @@
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, type CapturedUpdate } from './harness.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
/** Text of the agent_message_chunk updates scoped to one session id. */
function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string {
return updates
.filter(u => u.sessionId === sessionId && u.update.sessionUpdate === 'agent_message_chunk')
.map(u => (u.update.sessionUpdate === 'agent_message_chunk' && u.update.content.type === 'text' ? u.update.content.text : ''))
.join('')
}
describe('acp bridge — multi-session isolation', () => {
let storageDir: string
let harness: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-multi-')) })
afterEach(async () => {
if (harness) await harness.dispose()
harness = undefined
await rm(storageDir, { recursive: true, force: true })
})
it('two sessions stream concurrently without interleaving their updates', async () => {
// Each session's prompt answer must arrive only on its own sessionId. The
// scripted adapter answers in send order; both prompts run, and the bridge
// demuxes every chunk by session id.
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer-A'), textResponse('answer-B')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const [ra, rb] = await Promise.all([
harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] }),
harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }),
])
expect(ra.stopReason).toBe('end_turn')
expect(rb.stopReason).toBe('end_turn')
// A's text landed only on A; B's only on B (strict id demux, no interleave).
expect(messageTextFor(harness.sessionUpdates, a)).toContain('answer-A')
expect(messageTextFor(harness.sessionUpdates, a)).not.toContain('answer-B')
expect(messageTextFor(harness.sessionUpdates, b)).toContain('answer-B')
expect(messageTextFor(harness.sessionUpdates, b)).not.toContain('answer-A')
})
it('cancel in one session leaves the other session untouched', async () => {
// Session A hangs; session B completes normally. Cancelling A settles ONLY
// A as cancelled and never disturbs B's stream or result.
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const aPromise = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'hang A' }] })
await new Promise(r => setTimeout(r, 30))
await harness.client.cancel({ sessionId: a })
expect((await aPromise).stopReason).toBe('cancelled')
// B runs to completion, unaffected by A's cancel.
const rb = await harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] })
expect(rb.stopReason).toBe('end_turn')
expect(messageTextFor(harness.sessionUpdates, b)).toContain('B done')
})
it('enforces one in-flight prompt PER session independently', async () => {
harness = await makeBridgeHarness({ storageDir, script: ['hang', 'hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
// One in-flight prompt in EACH session is allowed (independent limits).
const aPromise = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'one A' }] })
const bPromise = harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'one B' }] })
await new Promise(r => setTimeout(r, 30))
// A second prompt in A is rejected, but B's in-flight prompt is unaffected.
await expect(harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'two A' }] }))
.rejects.toThrow(/already in flight/)
await harness.client.cancel({ sessionId: a })
await harness.client.cancel({ sessionId: b })
expect((await aPromise).stopReason).toBe('cancelled')
expect((await bPromise).stopReason).toBe('cancelled')
})
it('a cancel for a non-existent session id is a silent no-op (does not touch others)', async () => {
harness = await makeBridgeHarness({ storageDir, script: [textResponse('A done')] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
await expect(harness.client.cancel({ sessionId: 'ghost' })).resolves.toBeUndefined()
// A still works after a cancel for an unknown id.
const ra = await harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] })
expect(ra.stopReason).toBe('end_turn')
})
it('disposing the whole bridge drains all live sessions to quiescence', async () => {
harness = await makeBridgeHarness({ storageDir, script: ['hang', 'hang'] })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
const agentA = harness.ctx.agents.get(SessionId(a))!
const agentB = harness.ctx.agents.get(SessionId(b))!
// Wait deterministically for BOTH agents to enter `running` (not a fixed
// sleep — agent startup latency is unbounded on a loaded worker).
const running = (agent: typeof agentA) => agent.status === 'running'
? Promise.resolve()
: new Promise<void>((resolve) => {
const dispose = harness!.ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'running') { dispose(); resolve() }
})
})
void harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] }).catch(() => {})
void harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }).catch(() => {})
await Promise.all([running(agentA), running(agentB)])
expect(agentA.status).toBe('running')
expect(agentB.status).toBe('running')
await harness.ctx.fiber.dispose()
// BOTH agents drained (not still running) — teardown reached quiescence
// across all sessions, not just one.
expect(agentA.status).not.toBe('running')
expect(agentB.status).not.toBe('running')
})
})

View File

@@ -1,109 +0,0 @@
/**
* Property tests exercise the pure event translator so live/replay equivalence
* and per-call ordering remain deterministic rather than timing-dependent.
*/
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, 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(SessionId('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))
}))
})
})

View File

@@ -1,92 +0,0 @@
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 type {} from '@deepseek-ai/dsh-session-title'
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
import { ACP_SESSION_REFERENCE_META_KEY } from '../src/index.ts'
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
describe('acp bridge — session/list', () => {
let storageDir: string
let harness: BridgeHarness | undefined
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-list-')) })
afterEach(async () => {
await harness?.dispose()
harness = undefined
await rm(storageDir, { recursive: true, force: true })
})
it('advertises title-aware listing and reference metadata for loadable sessions', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true })
const initialized = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
const cwd = process.cwd()
const { sessionId } = await harness.client.newSession({ cwd, mcpServers: [] })
const session = harness.ctx.agents.get(SessionId(sessionId))!.session
await harness.ctx.sessions.appendOutOfBand(session, 'session/title', {
title: 'Reference source title',
messageSeqs: [],
source: { kind: 'fallback' },
}, { kind: 'session-title' })
harness.ctx.sessions.create(SessionId('untitled'), { meta: { cwd: join(storageDir, 'other') } })
harness.ctx.sessions.create(SessionId('missing-cwd'))
const listed = await harness.client.listSessions({})
expect(listed.nextCursor).toBeUndefined()
expect(listed.sessions.map(item => item.sessionId)).toEqual(expect.arrayContaining([sessionId, 'untitled']))
expect(listed.sessions.map(item => item.sessionId)).not.toContain('missing-cwd')
const source = listed.sessions.find(item => item.sessionId === sessionId)
expect(source).toMatchObject({ cwd, title: 'Reference source title' })
expect(source?._meta?.[ACP_SESSION_REFERENCE_META_KEY]).toEqual({
uri: encodeSessionReferenceUri(SessionId(sessionId)),
})
expect(listed.sessions.find(item => item.sessionId === 'untitled')).not.toHaveProperty('title')
})
it('filters by normalized cwd and omits reference metadata without the optional capability', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const firstCwd = join(storageDir, 'first')
const secondCwd = join(storageDir, 'second')
const first = await harness.client.newSession({ cwd: firstCwd, mcpServers: [] })
await harness.client.newSession({ cwd: secondCwd, mcpServers: [] })
const listed = await harness.client.listSessions({ cursor: null, cwd: firstCwd })
expect(listed.sessions).toHaveLength(1)
expect(listed.sessions[0]).toMatchObject({ sessionId: first.sessionId, cwd: firstCwd })
expect(listed.sessions[0]?._meta).toBeUndefined()
await expect(harness.client.listSessions({ cwd: null })).resolves.toHaveProperty('sessions')
})
it('rejects unsupported cursors and relative cwd filters', async () => {
harness = await makeBridgeHarness({ storageDir })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.listSessions({ cursor: 'next' })).rejects.toThrow('session/list does not paginate')
await expect(harness.client.listSessions({ cwd: 'relative' })).rejects.toThrow('session/list cwd must be absolute')
})
it('folds titles from persisted sessions in a fresh bridge', async () => {
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const cwd = process.cwd()
const { sessionId } = await harness.client.newSession({ cwd, mcpServers: [] })
const session = harness.ctx.agents.get(SessionId(sessionId))!.session
await harness.ctx.sessions.appendOutOfBand(session, 'session/title', {
title: 'Persisted reference title',
messageSeqs: [],
source: { kind: 'fallback' },
}, { kind: 'session-title' })
await harness.dispose()
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
await expect(harness.client.listSessions({ cwd })).resolves.toMatchObject({
sessions: [{ sessionId, cwd, title: 'Persisted reference title' }],
})
})
})

Some files were not shown because too many files have changed in this diff Show More