Merge origin/master into worktree-windows-runtime
# Conflicts: # .agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md # packages/support/acp-snapshot/src/harness.ts # packages/support/acp-snapshot/src/normalize.ts # packages/support/acp-snapshot/tests/harness.spec.ts # packages/support/acp-snapshot/tests/normalize.spec.ts
This commit is contained in:
@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the terminal `
|
||||
|
||||
`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`, `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; 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.
|
||||
The plugin injects `agents`, [`commands`](../commands/README.md), `sessionPersistence`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; 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
|
||||
|
||||
@@ -26,10 +26,10 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
| ACP method | Harness seam | Notes |
|
||||
|---|---|---|
|
||||
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
|
||||
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `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, and replays user, assistant, and tool events |
|
||||
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
|
||||
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
|
||||
| `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, and tool events, and re-advertises commands |
|
||||
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; unsupported content 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, and tool render intents |
|
||||
| `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" |
|
||||
@@ -39,6 +39,12 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
|
||||
|
||||
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; 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.
|
||||
@@ -108,6 +114,20 @@ Prompt tokens are data-dependent and remain in that session's history until comp
|
||||
|
||||
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
|
||||
@@ -170,3 +190,4 @@ Loading does not rewrite the stored log, but the next request is reconstructed u
|
||||
- **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`.
|
||||
- **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.
|
||||
|
||||
@@ -10,7 +10,7 @@ Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. Th
|
||||
|
||||
## At a glance
|
||||
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, one-shot permission prompts, per-session model selection, and permission presets. The largest **unbuilt** areas are **MCP passthrough**, **slash commands**, 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).
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load, 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. 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)
|
||||
|
||||
@@ -23,8 +23,8 @@ The bridge implements the **core prompt-turn loop** for N concurrent sessions: i
|
||||
| `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 | ✅ | ✅ | ✅ | Maps to `agent.send`; one in-flight prompt per session; settles on the owning turn's end. |
|
||||
| `session/cancel` | S | ✅ | ✅ | ✅ | Queue-aware `agent.cancel`; settles the in-flight prompt `cancelled`, scoped to the one session. |
|
||||
| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. 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 | ❌ | ✅ | ✅ | Session modes deliberately skipped: config options are the spec's replacement and modes are slated for removal in ACP v2 (see [§6](#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. |
|
||||
@@ -84,7 +84,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `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 | ❌ | ✅ | ✅ | No slash commands advertised. |
|
||||
| `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 | ❌ | ✅ | ✅ | No session modes. |
|
||||
| `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`). |
|
||||
@@ -142,11 +142,10 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl
|
||||
|
||||
1. **Session lifecycle** — `session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
|
||||
2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
|
||||
3. **Slash commands** (`available_commands_update`).
|
||||
4. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
|
||||
5. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
|
||||
6. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
|
||||
7. **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.
|
||||
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
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"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-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-permission": "^0.0.1",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@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:^",
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
PROTOCOL_VERSION,
|
||||
RequestError,
|
||||
type Agent as AcpAgent,
|
||||
type AnyMessage,
|
||||
type AuthenticateRequest,
|
||||
type AvailableCommand,
|
||||
type CancelNotification,
|
||||
type ContentBlock as AcpContentBlock,
|
||||
type CreateElicitationRequest,
|
||||
@@ -46,6 +48,7 @@ import type { ContentBlock, LlmCallConfig, LlmModelInfo, LlmProviderInfo } from
|
||||
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Side-effect type import: resolves `ctx.get('permission')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-permission'
|
||||
@@ -77,13 +80,50 @@ import {
|
||||
|
||||
export const name = 'acp'
|
||||
// Interface services back loading, presentation, interaction, and prompt assembly.
|
||||
export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
|
||||
export const inject = ['agents', 'commands', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt']
|
||||
|
||||
/** Preserve invalid-parameter detail in the SDK wire error message. */
|
||||
function invalidParams(detail: string): RequestError {
|
||||
return RequestError.invalidParams(undefined, detail)
|
||||
}
|
||||
|
||||
/** Render arbitrary thrown values without trusting their string coercion. */
|
||||
function renderThrown(value: unknown): string {
|
||||
try {
|
||||
return String(value)
|
||||
} catch {
|
||||
return '<unrenderable thrown value>'
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a server-created session id carried by an outbound success response. */
|
||||
function responseSessionId(message: AnyMessage): SessionId | undefined {
|
||||
if (!('result' in message) || typeof message.result !== 'object' || message.result === null
|
||||
|| !('sessionId' in message.result) || typeof message.result.sessionId !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
return SessionId(message.result.sessionId)
|
||||
}
|
||||
|
||||
/** Observe messages only after the wrapped ACP transport has written them. */
|
||||
function observeOutbound(stream: Stream, onWritten: (message: AnyMessage) => void): Stream {
|
||||
const writer = stream.writable.getWriter()
|
||||
return {
|
||||
readable: stream.readable,
|
||||
writable: new WritableStream<AnyMessage>({
|
||||
async write(message) {
|
||||
await writer.write(message)
|
||||
onWritten(message)
|
||||
},
|
||||
/* v8 ignore start -- the ACP SDK never closes or aborts its outbound stream;
|
||||
preserve the wrapped Stream contract for other consumers nonetheless */
|
||||
close: () => writer.close(),
|
||||
abort: (reason: unknown) => writer.abort(reason),
|
||||
/* v8 ignore stop */
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */
|
||||
function internalError(detail: string): RequestError {
|
||||
return RequestError.internalError(undefined, detail)
|
||||
@@ -260,6 +300,8 @@ interface SessionRecord {
|
||||
reject: (error: Error) => void
|
||||
turn: number | undefined
|
||||
} | undefined
|
||||
/** Abort owner for a direct slash-command request, mutually exclusive with `inflight`. */
|
||||
commandAbort: AbortController | undefined
|
||||
/** Last idle switch per knob, anchored before the next prompt assembles. */
|
||||
pendingSwitches: { preset?: string }
|
||||
}
|
||||
@@ -274,6 +316,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// ACP handlers execute outside this plugin's injection scope, so capture
|
||||
// injected services during apply(); lazy service reads in a handler fail.
|
||||
const agents = ctx.agents
|
||||
const commands = ctx.commands
|
||||
const llm = ctx.llm
|
||||
const sessionPersistence = ctx.sessionPersistence
|
||||
const logger = ctx.logger
|
||||
@@ -380,6 +423,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const sessions = new Map<SessionId, SessionRecord>()
|
||||
// Reserve an id before resume so pipelined load/new requests cannot duplicate it.
|
||||
const loadingIds = new Set<SessionId>()
|
||||
// A new-session response introduces its server-generated id to the client;
|
||||
// keep its initial command snapshot pending until that response is written.
|
||||
const pendingCommandSnapshots = new Map<SessionId, SessionRecord>()
|
||||
// Async creation checks this after awaits to avoid publishing after teardown.
|
||||
let closed = false
|
||||
// Each new or loaded session snapshots the latest connection capability.
|
||||
@@ -468,6 +514,43 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
})
|
||||
}
|
||||
|
||||
/** Project the effective registry view onto ACP discovery metadata. */
|
||||
const availableCommands = (agent: Agent): AvailableCommand[] => commands.list(agent).map(command => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
...command.input === undefined ? {} : { input: { hint: command.input.hint } },
|
||||
}))
|
||||
|
||||
/** Push the protocol's full-snapshot command catalog for one live session. */
|
||||
const notifyCommands = (rec: SessionRecord): void => {
|
||||
notify({
|
||||
sessionId: rec.agent.session.id,
|
||||
update: {
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: availableCommands(rec.agent),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Enqueue a new session's first command snapshot behind its written RPC response. */
|
||||
const announceInitialCommands = (message: AnyMessage): void => {
|
||||
const sessionId = responseSessionId(message)
|
||||
if (sessionId === undefined) return
|
||||
const rec = pendingCommandSnapshots.get(sessionId)
|
||||
if (rec === undefined) return
|
||||
pendingCommandSnapshots.delete(sessionId)
|
||||
notifyCommands(rec)
|
||||
}
|
||||
|
||||
// Registration and HMR removal can affect global or one scoped view; refresh
|
||||
// every announced bridge-owned session and let the registry resolve each
|
||||
// exact agent. A pending new-session snapshot will read the latest registry.
|
||||
ctx.on('commands/change', () => {
|
||||
for (const rec of sessions.values()) {
|
||||
if (!pendingCommandSnapshots.has(rec.agent.session.id)) notifyCommands(rec)
|
||||
}
|
||||
})
|
||||
|
||||
/** Settle the in-flight prompt with a stop reason, exactly once (no-op if none pending). */
|
||||
const settlePrompt = (rec: SessionRecord, reason: StopReason): void => {
|
||||
const inflight = rec.inflight
|
||||
@@ -674,15 +757,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
await handle.dispose()
|
||||
throw internalError('connection closed during session/new')
|
||||
}
|
||||
sessions.set(sessionId, {
|
||||
const record: SessionRecord = {
|
||||
agent: handle.agent,
|
||||
dispose: () => handle.dispose(),
|
||||
presenter: makePresenter(handle.agent),
|
||||
terminalEnabled: terminalOutputCap,
|
||||
target,
|
||||
inflight: undefined,
|
||||
commandAbort: undefined,
|
||||
pendingSwitches: {},
|
||||
})
|
||||
}
|
||||
sessions.set(sessionId, record)
|
||||
pendingCommandSnapshots.set(sessionId, record)
|
||||
const configOptions = configOptionsFor(handle.agent, directory)
|
||||
return { sessionId, ...configOptions.length > 0 ? { configOptions } : {} }
|
||||
},
|
||||
@@ -763,6 +849,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
terminalEnabled,
|
||||
target,
|
||||
inflight: undefined,
|
||||
commandAbort: undefined,
|
||||
pendingSwitches: {},
|
||||
}
|
||||
sessions.set(sessionId, record)
|
||||
@@ -787,6 +874,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
for (const event of agent.session.events) {
|
||||
streamSessionEventUpdate(sessionId, event, notify, replayPresenter, replayTerminal)
|
||||
}
|
||||
notifyCommands(record)
|
||||
const configOptions = configOptionsFor(agent, directory)
|
||||
return configOptions.length > 0 ? { configOptions } : {}
|
||||
} finally {
|
||||
@@ -797,7 +885,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
assertOpen()
|
||||
const rec = requireSession(SessionId(params.sessionId))
|
||||
if (rec.inflight !== undefined) {
|
||||
if (rec.inflight !== undefined || rec.commandAbort !== undefined) {
|
||||
throw invalidParams('a prompt is already in flight for this session')
|
||||
}
|
||||
if (promptHasUnsupportedContent(params.prompt)) {
|
||||
@@ -810,6 +898,52 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// waiting for a settle that never comes.
|
||||
throw invalidParams('empty prompt')
|
||||
}
|
||||
// ACP command prompts may carry additional supported content blocks.
|
||||
// The same lossless flattening used for model prompts supplies their
|
||||
// unstructured command input; unsupported kinds were rejected above.
|
||||
const commandLine = text.startsWith('/') ? text : undefined
|
||||
if (commandLine !== undefined) {
|
||||
const controller = new AbortController()
|
||||
rec.commandAbort = controller
|
||||
try {
|
||||
const result = await commands.execute(rec.agent, commandLine, controller.signal)
|
||||
if (result !== undefined && result.text !== undefined && result.text !== '') {
|
||||
notify({
|
||||
sessionId: rec.agent.session.id,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: result.kind === 'error' ? `Error: ${result.text}` : result.text,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else if (result === undefined) {
|
||||
notify({
|
||||
sessionId: rec.agent.session.id,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: `Error: unknown command: ${commandLine}` },
|
||||
},
|
||||
})
|
||||
}
|
||||
return { stopReason: 'end_turn' }
|
||||
} catch (error: unknown) {
|
||||
if (controller.signal.aborted) return { stopReason: 'cancelled' }
|
||||
const rendered = renderThrown(error)
|
||||
logger.warn(`acp: command failed: ${rendered}`)
|
||||
notify({
|
||||
sessionId: rec.agent.session.id,
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: { type: 'text', text: `Error: command failed: ${rendered}` },
|
||||
},
|
||||
})
|
||||
return { stopReason: 'end_turn' }
|
||||
} finally {
|
||||
rec.commandAbort = undefined
|
||||
}
|
||||
}
|
||||
// Install the in-flight slot BEFORE send() (send does not synchronously
|
||||
// flip status to running; the session/event listener records the turn
|
||||
// number and settle/rejects it). Capture the log length now as the
|
||||
@@ -837,8 +971,12 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// settle it, because cancel() may drop the turn before any turn/end is
|
||||
// emitted, and removing this direct settle would move the RPC's
|
||||
// resolution onto a later observer path, changing its timing.
|
||||
rec.agent.cancel('session/cancel')
|
||||
settlePrompt(rec, 'cancelled')
|
||||
if (rec.commandAbort !== undefined) {
|
||||
rec.commandAbort.abort(new Error('session/cancel'))
|
||||
} else {
|
||||
rec.agent.cancel('session/cancel')
|
||||
settlePrompt(rec, 'cancelled')
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
|
||||
@@ -908,7 +1046,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
Writable.toWeb(process.stdout) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>,
|
||||
)
|
||||
conn = new AgentSideConnection(makeAgent, stream)
|
||||
conn = new AgentSideConnection(makeAgent, observeOutbound(stream, announceInitialCommands))
|
||||
|
||||
/**
|
||||
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
|
||||
@@ -946,12 +1084,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// installed yet) must observe this after its await and refuse to install a
|
||||
// post-teardown record. Set even when there are no live sessions.
|
||||
closed = true
|
||||
pendingCommandSnapshots.clear()
|
||||
const recs = [...sessions.values()]
|
||||
sessions.clear()
|
||||
if (recs.length === 0) return Promise.resolve()
|
||||
quiescing = (async () => {
|
||||
await Promise.all(recs.map(async (rec) => {
|
||||
settlePrompt(rec, 'cancelled')
|
||||
rec.commandAbort?.abort(new Error('ACP connection closed'))
|
||||
// Per-agent dispose (the AgentHandle disposer): unregister this agent,
|
||||
// stop its loop (sets disposed + aborts the in-flight step), await
|
||||
// quiescence (the loop exit + final flush), and remove its session — so
|
||||
|
||||
276
packages/ui/acp/tests/commands.spec.ts
Normal file
276
packages/ui/acp/tests/commands.spec.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
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'
|
||||
|
||||
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('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')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
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'
|
||||
@@ -24,6 +24,9 @@ describe('acp bridge — demux & config edges', () => {
|
||||
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' } })
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo,
|
||||
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'
|
||||
@@ -210,6 +211,7 @@ export async function makeBridgeHarness(options: {
|
||||
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(UserInteractionService)
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../commands"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user