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:
263 files changed
+12997
-276
No files matched your search
@@ -5,6 +5,7 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) 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` |
|
||||
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
|
||||
@@ -13,7 +14,7 @@ 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 and not a capability seam: 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; non-interactive tasks use the headless `cli-demo` app instead of a UI channel.
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: 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; 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.
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-commands
|
||||
|
||||
Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plugin command registration Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and protocol mapping.
|
||||
|
||||
## Service contract
|
||||
|
||||
`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
|
||||
|
||||
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names.
|
||||
|
||||
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
|
||||
|
||||
Handlers return `success` or `error` plus optional UI text. Results are rendered directly by the adapter and never enter model history. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it.
|
||||
|
||||
## Composition
|
||||
|
||||
The terminal and ACP app bundles mount this service with their consuming front door; the UI-less agent spine does not. Custom compositions that use `dsh-tui`, `dsh-acp`, or a command producer mount `@deepseek-ai/dsh-commands` explicitly.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Direct human commands
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing. Known slash commands execute in the UI command plane, and their `CommandResult` text is not submitted as a user message. Unknown slash-command input is rejected by shipped adapters instead of becoming a model prompt.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Command discovery, execution, and UI output add no model tokens. A command plugin may separately mutate a model-visible domain through that domain's durable APIs.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Registry metadata, command input, and direct output never enter a model request and do not affect its cache. A mutated domain owns any later cache effect.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only unstructured text input** — the descriptor intentionally matches ACP's current unstructured command input; forms, completion schemas, and typed arguments remain command-owned parsing concerns.
|
||||
- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect.
|
||||
- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-commands",
|
||||
"description": "Plugin-owned human command registry for DeepSeek Harness UI surfaces",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Plugin-owned human-command registry shared by interactive UI adapters.
|
||||
* @module @deepseek-ai/dsh-commands
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
|
||||
export const name = 'commands'
|
||||
|
||||
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
|
||||
|
||||
/** Immutable command input metadata compatible with ACP unstructured input. */
|
||||
export interface CommandInputDescriptor {
|
||||
/** Placeholder shown before the user supplies free-form input. */
|
||||
readonly hint: string
|
||||
}
|
||||
|
||||
/** Invocation passed to one registered command handler. */
|
||||
export interface CommandInvocation {
|
||||
/** Exact agent whose human-facing surface received the command. */
|
||||
readonly agent: Agent
|
||||
/** Exact text following the registered command name, including separator whitespace. */
|
||||
readonly rawInput: string
|
||||
/** Cancellation signal owned by the dispatching UI request. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
/** Expected command outcome rendered directly by the dispatching UI. */
|
||||
export type CommandResult =
|
||||
| { readonly kind: 'success'; readonly text?: string }
|
||||
| { readonly kind: 'error'; readonly text: string }
|
||||
|
||||
/** Plugin-owned command registration. */
|
||||
export interface CommandDefinition {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
readonly name: string
|
||||
/** Human-readable summary used in discovery UI. */
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
/** Execute against the receiving agent without sending the command to the model. */
|
||||
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
|
||||
}
|
||||
|
||||
/** Handler-free immutable command view returned to UI adapters. */
|
||||
export interface CommandDescriptor {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
readonly name: string
|
||||
/** Human-readable summary used in discovery UI. */
|
||||
readonly description: string
|
||||
/** Optional free-form input hint advertised to capable clients. */
|
||||
readonly input?: CommandInputDescriptor
|
||||
}
|
||||
|
||||
/** Syntactically valid slash command before registry resolution. */
|
||||
export interface ParsedCommand {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
readonly name: string
|
||||
/** Exact text following the command name. */
|
||||
readonly rawInput: string
|
||||
}
|
||||
|
||||
interface RegisteredCommand {
|
||||
readonly definition: CommandDefinition
|
||||
readonly descriptor: CommandDescriptor
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
commands: CommandService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A command was registered or unregistered. This is an unfiltered registry
|
||||
* notification because a global or scoped change may affect any UI view.
|
||||
* Observer failures are contained and cannot veto the registry mutation.
|
||||
* @mode emit
|
||||
*/
|
||||
'commands/change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an exact slash command without normalizing its trailing input.
|
||||
*
|
||||
* @param line - Complete candidate command line.
|
||||
* @returns The parsed command, or `undefined` when the line is not a command.
|
||||
*/
|
||||
export function parseCommand(line: string): ParsedCommand | undefined {
|
||||
const match = /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u.exec(line)
|
||||
if (match === null) return undefined
|
||||
const name = match[1]
|
||||
/* v8 ignore next -- the first capture is required whenever the regular expression matches */
|
||||
if (name === undefined) return undefined
|
||||
return Object.freeze({ name, rawInput: line.slice(match[0].length) })
|
||||
}
|
||||
|
||||
/** Convert arbitrary abort reasons to one stable rejected Error. */
|
||||
function abortError(signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason
|
||||
return new Error(typeof signal.reason === 'string' ? signal.reason : 'command aborted')
|
||||
}
|
||||
|
||||
/** Render arbitrary thrown values without trusting their string coercion. */
|
||||
function renderThrown(value: unknown): string {
|
||||
try {
|
||||
return String(value)
|
||||
} catch {
|
||||
return '<unrenderable thrown value>'
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop awaiting an uncooperative handler once its owning UI request aborts. */
|
||||
function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(abortError(signal))
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(abortError(signal))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(error instanceof Error
|
||||
? error
|
||||
: new Error(`command handler rejected with a non-Error value: ${renderThrown(error)}`, { cause: error }))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Reject invalid command metadata before it can reach a UI protocol. */
|
||||
function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
|
||||
if (!COMMAND_NAME.test(definition.name)) {
|
||||
throw new TypeError(`command name "${definition.name}" must match ${String(COMMAND_NAME)}`)
|
||||
}
|
||||
if (typeof definition.description !== 'string') {
|
||||
throw new TypeError(`command "${definition.name}" description must be a string`)
|
||||
}
|
||||
if (definition.description.trim().length === 0) {
|
||||
throw new TypeError(`command "${definition.name}" description must not be empty`)
|
||||
}
|
||||
if (typeof definition.handler !== 'function') {
|
||||
throw new TypeError(`command "${definition.name}" handler must be a function`)
|
||||
}
|
||||
const rawInput: unknown = definition.input
|
||||
let input: CommandInputDescriptor | undefined
|
||||
if (rawInput !== undefined) {
|
||||
if (typeof rawInput !== 'object' || rawInput === null || !('hint' in rawInput)
|
||||
|| typeof rawInput.hint !== 'string') {
|
||||
throw new TypeError(`command "${definition.name}" input hint must be a string`)
|
||||
}
|
||||
if (rawInput.hint.trim().length === 0) {
|
||||
throw new TypeError(`command "${definition.name}" input hint must not be empty`)
|
||||
}
|
||||
input = Object.freeze({ hint: rawInput.hint })
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
...input === undefined ? {} : { input },
|
||||
handler: definition.handler,
|
||||
})
|
||||
const descriptor = Object.freeze({
|
||||
name: normalized.name,
|
||||
description: normalized.description,
|
||||
...normalized.input === undefined ? {} : { input: normalized.input },
|
||||
})
|
||||
return { definition: normalized, descriptor }
|
||||
}
|
||||
|
||||
/** Validate and detach an untrusted handler result at the registry boundary. */
|
||||
function normalizeResult(command: string, value: unknown): CommandResult {
|
||||
if (typeof value !== 'object' || value === null || !('kind' in value)) {
|
||||
throw new TypeError(`command "${command}" handler must return a CommandResult`)
|
||||
}
|
||||
const result = value as { kind?: unknown; text?: unknown }
|
||||
if (result.kind === 'success') {
|
||||
if (result.text !== undefined && typeof result.text !== 'string') {
|
||||
throw new TypeError(`command "${command}" success text must be a string when supplied`)
|
||||
}
|
||||
return Object.freeze(result.text === undefined ? { kind: 'success' } : { kind: 'success', text: result.text })
|
||||
}
|
||||
if (result.kind === 'error') {
|
||||
if (typeof result.text !== 'string' || result.text.trim().length === 0) {
|
||||
throw new TypeError(`command "${command}" error text must be a non-empty string`)
|
||||
}
|
||||
return Object.freeze({ kind: 'error', text: result.text })
|
||||
}
|
||||
throw new TypeError(`command "${command}" returned unknown result kind "${String(result.kind)}"`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-command registry. Plain-context definitions are global; definitions
|
||||
* registered through a command-injected child of an agent context shadow
|
||||
* globals for that agent.
|
||||
*/
|
||||
export class CommandService extends Service {
|
||||
private readonly global = new Map<string, RegisteredCommand>()
|
||||
private readonly scoped = new Map<ScopeKey, Map<string, RegisteredCommand>>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'commands')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a global or calling-agent-scoped command.
|
||||
* @param definition - discovery metadata and direct UI handler.
|
||||
* @returns the exact effect disposer that unregisters this definition.
|
||||
*/
|
||||
register(definition: CommandDefinition): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const registered = normalizeDefinition(definition)
|
||||
const dispose = this.ctx.effect(function* (this: CommandService) {
|
||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||
if (layer.has(registered.definition.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `command "${registered.definition.name}" is already registered (for a per-agent variant, mount a command-injected plugin under that agent's \`agent.ctx\`)`
|
||||
: `command "${registered.definition.name}" is already registered in this scope`)
|
||||
}
|
||||
layer.set(registered.definition.name, registered)
|
||||
yield () => {
|
||||
layer.delete(registered.definition.name)
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.notifyChange()
|
||||
}
|
||||
this.notifyChange()
|
||||
}.bind(this), 'commands.register()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exact synchronous disposer preserves composite teardown order
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* List the effective immutable command descriptors for one agent.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @returns name-sorted descriptors after scoped shadowing.
|
||||
*/
|
||||
list(agent: Agent): readonly CommandDescriptor[] {
|
||||
return Object.freeze([...this.view(agent).values()]
|
||||
.map(command => command.descriptor)
|
||||
// Names are unique in the effective view, so equality is impossible.
|
||||
.sort((left, right) => left.name < right.name ? -1 : 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one effective command definition.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
* @param name - command name without a slash.
|
||||
* @returns the scoped shadow or global definition.
|
||||
*/
|
||||
find(agent: Agent, name: string): CommandDefinition | undefined {
|
||||
return this.view(agent).get(name)?.definition
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and execute a known command without sending it to the model.
|
||||
* @param agent - exact receiving agent.
|
||||
* @param line - complete slash-command line.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns a detached result, or `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
async execute(
|
||||
agent: Agent,
|
||||
line: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandResult | undefined> {
|
||||
const parsed = parseCommand(line)
|
||||
if (parsed === undefined) return undefined
|
||||
const command = this.view(agent).get(parsed.name)
|
||||
if (command === undefined) return undefined
|
||||
if (signal.aborted) throw abortError(signal)
|
||||
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
|
||||
const output = command.definition.handler(invocation)
|
||||
return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
|
||||
}
|
||||
|
||||
/** Resolve global definitions followed by exact scoped shadows. */
|
||||
private view(agent: Agent): Map<string, RegisteredCommand> {
|
||||
const visible = new Map(this.global)
|
||||
for (const [name, command] of this.scoped.get(agent) ?? []) visible.set(name, command)
|
||||
return visible
|
||||
}
|
||||
|
||||
/** Create the registration layer for one agent scope on demand. */
|
||||
private layerFor(scope: ScopeKey): Map<string, RegisteredCommand> {
|
||||
let layer = this.scoped.get(scope)
|
||||
if (layer === undefined) {
|
||||
layer = new Map()
|
||||
this.scoped.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
}
|
||||
|
||||
/** Notify every registry observer without making UI refresh load-bearing. */
|
||||
private notifyChange(): void {
|
||||
// Cordis emit uses Array.map: one synchronous throw starves later listeners,
|
||||
// and returned promises are discarded. Registry notifications are
|
||||
// non-vetoing, so contain each callback independently.
|
||||
for (const callback of this.ctx.events.dispatch('emit', ['commands/change'])) {
|
||||
try {
|
||||
const returned: unknown = callback()
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`commands/change listener rejected: ${renderThrown(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`commands/change listener threw: ${renderThrown(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CommandService
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
|
||||
|
||||
function command(name: string, text = `ran:${name}`): CommandDefinition {
|
||||
return {
|
||||
name,
|
||||
description: `command ${name}`,
|
||||
handler: () => ({ kind: 'success', text }),
|
||||
}
|
||||
}
|
||||
|
||||
async function mount(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(CommandService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Mint a scope whose key is sufficient for registry lookup and invocation. */
|
||||
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
|
||||
const agent = { id: name as SessionId } as Agent
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
|
||||
return { scope, agent }
|
||||
}
|
||||
|
||||
describe('parseCommand()', () => {
|
||||
it.each([
|
||||
['/goal', { name: 'goal', rawInput: '' }],
|
||||
['/goal create the thing', { name: 'goal', rawInput: ' create the thing' }],
|
||||
['/goal\ncreate the thing', { name: 'goal', rawInput: '\ncreate the thing' }],
|
||||
['/goal_name-2\t x ', { name: 'goal_name-2', rawInput: '\t x ' }],
|
||||
] as const)('parses %j without normalizing trailing input', (line, expected) => {
|
||||
expect(parseCommand(line)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each(['goal', ' /goal', '/', '/Goal', '/goal/path', '/goal🔥'])('rejects non-command boundary %j', (line) => {
|
||||
expect(parseCommand(line)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('CommandService', () => {
|
||||
it('lists immutable global descriptors with input metadata', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const definition: CommandDefinition = {
|
||||
name: 'inspect',
|
||||
description: 'Inspect state',
|
||||
input: { hint: '<target>' },
|
||||
handler: () => ({ kind: 'success' }),
|
||||
}
|
||||
ctx.commands.register(definition)
|
||||
|
||||
const listed = ctx.commands.list(agent)
|
||||
expect(listed).toEqual([{
|
||||
name: 'inspect',
|
||||
description: 'Inspect state',
|
||||
input: { hint: '<target>' },
|
||||
}])
|
||||
expect(Object.isFrozen(listed)).toBe(true)
|
||||
expect(Object.isFrozen(listed[0])).toBe(true)
|
||||
expect(Object.isFrozen(listed[0]?.input)).toBe(true)
|
||||
expect(ctx.commands.find(agent, 'inspect')).toMatchObject({ name: 'inspect' })
|
||||
expect(ctx.commands.find(agent, 'missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('sorts distinct effective command names', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('zeta'))
|
||||
ctx.commands.register(command('alpha'))
|
||||
ctx.commands.register(command('middle'))
|
||||
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['alpha', 'middle', 'zeta'])
|
||||
})
|
||||
|
||||
it('uses agent-scoped shadows and removes them with their scope', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, agent } = await mintAgentScope(ctx, 'a')
|
||||
const other = { id: 'other' as SessionId } as Agent
|
||||
ctx.commands.register(command('shared', 'global'))
|
||||
scope.ctx.commands.register(command('shared', 'scoped'))
|
||||
|
||||
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
|
||||
expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
|
||||
expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
|
||||
expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
|
||||
.toEqual({ kind: 'success', text: 'scoped' })
|
||||
|
||||
await scope.dispose()
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
})
|
||||
|
||||
it('rejects duplicates within one layer while allowing a scoped shadow', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('same'))
|
||||
expect(() => ctx.commands.register(command('same'))).toThrow(/agent\.ctx/)
|
||||
scope.ctx.commands.register(command('same'))
|
||||
expect(() => scope.ctx.commands.register(command('same'))).toThrow(/already registered in this scope/)
|
||||
})
|
||||
|
||||
it('notifies on registration and disposal while containing broken observers', async () => {
|
||||
const ctx = await mount()
|
||||
const changed = vi.fn()
|
||||
ctx.on('commands/change', changed)
|
||||
const dispose = ctx.commands.register(command('live'))
|
||||
dispose()
|
||||
dispose()
|
||||
expect(changed).toHaveBeenCalledTimes(2)
|
||||
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
ctx.on('commands/change', () => { throw new Error('observer threw') })
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- exercises rejected-listener containment
|
||||
ctx.on('commands/change', () => Promise.reject(new Error('observer rejected')))
|
||||
const afterFailures = vi.fn()
|
||||
ctx.on('commands/change', afterFailures)
|
||||
const removeContained = ctx.commands.register(command('contained'))
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
expect(ctx.commands.find(agent, 'contained')).toBeDefined()
|
||||
expect(afterFailures).toHaveBeenCalledTimes(1)
|
||||
await vi.waitFor(() => {
|
||||
expect(warn).toHaveBeenCalledWith('commands/change listener threw: Error: observer threw')
|
||||
expect(warn).toHaveBeenCalledWith('commands/change listener rejected: Error: observer rejected')
|
||||
})
|
||||
removeContained()
|
||||
expect(ctx.commands.find(agent, 'contained')).toBeUndefined()
|
||||
expect(afterFailures).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects non-string descriptions and input hints with boundary diagnostics', async () => {
|
||||
const ctx = await mount()
|
||||
expect(() => ctx.commands.register({
|
||||
...command('description-type'),
|
||||
description: undefined,
|
||||
} as unknown as CommandDefinition)).toThrow('command "description-type" description must be a string')
|
||||
expect(() => ctx.commands.register({
|
||||
...command('hint-type'),
|
||||
input: { hint: 42 },
|
||||
} as unknown as CommandDefinition)).toThrow('command "hint-type" input hint must be a string')
|
||||
expect(() => ctx.commands.register({
|
||||
...command('input-type'),
|
||||
input: null,
|
||||
} as unknown as CommandDefinition)).toThrow('command "input-type" input hint must be a string')
|
||||
})
|
||||
|
||||
it('passes exact invocation context and detaches valid handler results', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const seen = vi.fn(() => ({ kind: 'success' as const, text: 'ok' }))
|
||||
ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
|
||||
|
||||
expect(result).toEqual({ kind: 'success', text: 'ok' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agent,
|
||||
rawInput: ' untouched ',
|
||||
signal: controller.signal,
|
||||
}))
|
||||
await expect(ctx.commands.execute(agent, 'run', controller.signal)).resolves.toBeUndefined()
|
||||
await expect(ctx.commands.execute(agent, '/missing', controller.signal)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops awaiting an aborted handler and handles an already-aborted signal', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
let release!: (result: { kind: 'success'; text: string }) => void
|
||||
ctx.commands.register({
|
||||
name: 'wait',
|
||||
description: 'Wait',
|
||||
handler: () => new Promise((resolve) => { release = resolve }),
|
||||
})
|
||||
const running = new AbortController()
|
||||
const promise = ctx.commands.execute(agent, '/wait', running.signal)
|
||||
running.abort('operator cancelled command')
|
||||
await expect(promise).rejects.toThrow('operator cancelled command')
|
||||
release({ kind: 'success', text: 'late' })
|
||||
|
||||
const already = new AbortController()
|
||||
already.abort(new Error('already gone'))
|
||||
await expect(ctx.commands.execute(agent, '/wait', already.signal)).rejects.toThrow('already gone')
|
||||
|
||||
const defaultReason = new AbortController()
|
||||
defaultReason.abort({ source: 'test' })
|
||||
await expect(ctx.commands.execute(agent, '/wait', defaultReason.signal)).rejects.toThrow('command aborted')
|
||||
})
|
||||
|
||||
it('propagates an asynchronously rejected handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'reject',
|
||||
description: 'Reject',
|
||||
handler: () => Promise.reject(new Error('handler rejected')),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/reject', new AbortController().signal))
|
||||
.rejects.toThrow('handler rejected')
|
||||
|
||||
ctx.commands.register({
|
||||
name: 'reject-value',
|
||||
description: 'Reject a non-Error value',
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise untyped plugin normalization
|
||||
handler: () => Promise.reject('not an Error'),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/reject-value', new AbortController().signal))
|
||||
.rejects.toThrow('command handler rejected with a non-Error value: not an Error')
|
||||
|
||||
const hostile = { toString(): string { throw new Error('cannot render') } }
|
||||
ctx.commands.register({
|
||||
name: 'reject-hostile',
|
||||
description: 'Reject an unrenderable value',
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise hostile plugin normalization
|
||||
handler: () => Promise.reject(hostile),
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/reject-hostile', new AbortController().signal))
|
||||
.rejects.toMatchObject({
|
||||
message: 'command handler rejected with a non-Error value: <unrenderable thrown value>',
|
||||
cause: hostile,
|
||||
})
|
||||
})
|
||||
|
||||
it('observes an abort triggered synchronously inside the handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
const controller = new AbortController()
|
||||
ctx.commands.register({
|
||||
name: 'self-abort',
|
||||
description: 'Abort before returning',
|
||||
handler: () => {
|
||||
controller.abort('aborted in handler')
|
||||
return { kind: 'success' }
|
||||
},
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/self-abort', controller.signal))
|
||||
.rejects.toThrow('aborted in handler')
|
||||
})
|
||||
|
||||
it('returns a detached expected-error result', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'denied',
|
||||
description: 'Denied',
|
||||
handler: () => ({ kind: 'error', text: 'not now' }),
|
||||
})
|
||||
const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
|
||||
ctx.commands.register({
|
||||
name: 'silent',
|
||||
description: 'No output',
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
|
||||
expect(silent).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ ...command('Bad') }, /command name/],
|
||||
[{ ...command('empty-description'), description: ' ' }, /description/],
|
||||
[{ ...command('empty-hint'), input: { hint: '' } }, /input hint/],
|
||||
[{ ...command('bad-handler'), handler: undefined }, /handler/],
|
||||
] as const)('rejects invalid definition %#', async (definition, expected) => {
|
||||
const ctx = await mount()
|
||||
expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, /CommandResult/],
|
||||
[null, /CommandResult/],
|
||||
[{}, /CommandResult/],
|
||||
[{ kind: 'success', text: 1 }, /success text/],
|
||||
[{ kind: 'error', text: '' }, /error text/],
|
||||
[{ kind: 'error', text: 1 }, /error text/],
|
||||
[{ kind: 'future', text: 'x' }, /unknown result kind/],
|
||||
] as const)('rejects malformed handler result %j', async (output, expected) => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'broken',
|
||||
description: 'Broken',
|
||||
handler: () => output as never,
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/broken', new AbortController().signal)).rejects.toThrow(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,7 +6,7 @@ The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feat
|
||||
|
||||
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
|
||||
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
|
||||
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
|
||||
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
|
||||
@@ -14,7 +14,7 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
|
||||
|
||||
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
|
||||
|
||||
While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords.
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path reaches the model. The TUI registers `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -41,7 +41,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t
|
||||
maxToolOutputLines: 12
|
||||
```
|
||||
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
|
||||
Startup fails before mounting when either process stream is not a TTY. The composing app must mount the TUI before its config-created agent so the front door can observe `agent-loop/config-start-failed`; a matching exact-session failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal aborts running commands, removes the TUI definitions, stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR.
|
||||
|
||||
## Color
|
||||
|
||||
@@ -53,7 +53,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only.
|
||||
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only; command results remain terminal notices.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-loop": "^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-session": "^0.0.1",
|
||||
@@ -39,6 +40,7 @@
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-loop'
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-llm-retry'
|
||||
@@ -55,7 +56,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'ui-tui'
|
||||
export const inject = ['agents', 'userInteraction', 'tools']
|
||||
export const inject = ['agents', 'commands', 'userInteraction', 'tools']
|
||||
|
||||
/** Presentation settings for the pi-tui terminal mode. */
|
||||
export interface TuiConfig {
|
||||
@@ -865,6 +866,7 @@ export function createTuiChat(
|
||||
const allToolCards = new Set<ToolCardComponent>()
|
||||
const liveErrors = new Set<string>()
|
||||
const questionQueue: PendingQuestion[] = []
|
||||
const commandControllers = new Set<AbortController>()
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
|
||||
const welcome = config.welcome ?? 'ready.'
|
||||
@@ -1146,6 +1148,8 @@ export function createTuiChat(
|
||||
shuttingDown ??= (async () => {
|
||||
disposed = true
|
||||
clearStatus()
|
||||
for (const controller of commandControllers) controller.abort(new Error('TUI disposed'))
|
||||
commandControllers.clear()
|
||||
if (activeQuestion !== undefined) {
|
||||
const pending = activeQuestion
|
||||
activeQuestion = undefined
|
||||
@@ -1170,16 +1174,6 @@ export function createTuiChat(
|
||||
void shutdown(true)
|
||||
}
|
||||
|
||||
editor.setAutocompleteProvider(new CombinedAutocompleteProvider([
|
||||
{ name: 'help', description: 'Show keyboard shortcuts and commands' },
|
||||
{ name: 'clear', description: 'Clear the transcript view (session history is unchanged)' },
|
||||
{ name: 'cancel', description: 'Cancel the active turn' },
|
||||
{ name: 'reasoning', description: 'Toggle reasoning blocks' },
|
||||
{ name: 'tools', description: 'Expand or collapse all tool cards' },
|
||||
{ name: 'redraw', description: 'Invalidate components and redraw the terminal' },
|
||||
{ name: 'exit', description: 'Exit after the active turn reaches idle' },
|
||||
], agent.session.header.cwd ?? process.cwd()))
|
||||
|
||||
const toggleTools = (): void => {
|
||||
toolsExpanded = !toolsExpanded
|
||||
for (const card of allToolCards) card.setExpanded(toolsExpanded)
|
||||
@@ -1199,52 +1193,107 @@ export function createTuiChat(
|
||||
}
|
||||
|
||||
const showHelp = (): void => {
|
||||
const commandLines = ctx.commands.list(agent).map((command) => {
|
||||
const input = command.input === undefined ? '' : ` ${command.input.hint}`
|
||||
return `/${command.name}${input} — ${command.description}`
|
||||
})
|
||||
chat.addChild(new Spacer(1))
|
||||
chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0))
|
||||
chat.addChild(new Text([
|
||||
'Enter send • Shift/Alt+Enter newline • Up/Down prompt history',
|
||||
'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning',
|
||||
'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit',
|
||||
'/help /clear /cancel /reasoning /tools /redraw /exit',
|
||||
'',
|
||||
...commandLines,
|
||||
].map(line => palette.muted(line)).join('\n'), 1, 0))
|
||||
requestRender()
|
||||
}
|
||||
|
||||
const refreshCommandAutocomplete = (): void => {
|
||||
editor.setAutocompleteProvider(new CombinedAutocompleteProvider(
|
||||
ctx.commands.list(agent).map(command => ({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
})),
|
||||
agent.session.header.cwd ?? process.cwd(),
|
||||
))
|
||||
}
|
||||
const disposeCommandChanges = ctx.on('commands/change', refreshCommandAutocomplete)
|
||||
refreshCommandAutocomplete()
|
||||
|
||||
// The agent scope is minted by agent-loop and intentionally inherits only
|
||||
// that core plugin's dependencies. A child command producer declares its own
|
||||
// UI-service dependency while retaining the parent agent scope and lifetime.
|
||||
const commandFiber = agent.ctx.inject(['commands'], (commandCtx) => {
|
||||
commandCtx.commands.register({
|
||||
name: 'help',
|
||||
description: 'Show keyboard shortcuts and commands',
|
||||
handler: () => { showHelp(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'clear',
|
||||
description: 'Clear the transcript view (session history is unchanged)',
|
||||
handler: () => { chat.clear(); requestRender(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'cancel',
|
||||
description: 'Cancel the active turn',
|
||||
handler: () => {
|
||||
if (agent.status !== 'running') return { kind: 'error', text: 'The agent is already idle.' }
|
||||
agent.cancel('cancelled from terminal')
|
||||
return { kind: 'success', text: 'Cancellation requested.' }
|
||||
},
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'reasoning',
|
||||
description: 'Toggle reasoning blocks',
|
||||
handler: () => { toggleReasoning(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'tools',
|
||||
description: 'Expand or collapse all tool cards',
|
||||
handler: () => { toggleTools(); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'redraw',
|
||||
description: 'Invalidate components and redraw the terminal',
|
||||
handler: () => { ui.invalidate(); ui.requestRender(true); return { kind: 'success' } },
|
||||
})
|
||||
commandCtx.commands.register({
|
||||
name: 'exit',
|
||||
description: 'Exit after the active turn reaches idle',
|
||||
handler: () => { requestExit(); return { kind: 'success' } },
|
||||
})
|
||||
})
|
||||
|
||||
const runCommand = (text: string): void => {
|
||||
const controller = new AbortController()
|
||||
commandControllers.add(controller)
|
||||
void ctx.commands.execute(agent, text, controller.signal).then(
|
||||
(result) => {
|
||||
if (disposed) return
|
||||
if (result === undefined) {
|
||||
appendNotice(`Unknown command: ${text}`, 'warning')
|
||||
} else if (result.text !== undefined && result.text !== '') {
|
||||
appendNotice(result.text, result.kind === 'error' ? 'error' : 'info')
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!disposed) {
|
||||
appendNotice(`Command failed: ${errorChain(error)}`, 'error')
|
||||
}
|
||||
},
|
||||
).finally(() => { commandControllers.delete(controller) })
|
||||
}
|
||||
|
||||
editor.onSubmit = (value: string) => {
|
||||
const text = value.trim()
|
||||
if (text === '') return
|
||||
editor.addToHistory(text)
|
||||
editor.setText('')
|
||||
switch (text) {
|
||||
case '/help':
|
||||
showHelp()
|
||||
return
|
||||
case '/clear':
|
||||
chat.clear()
|
||||
requestRender()
|
||||
return
|
||||
case '/cancel':
|
||||
if (agent.status === 'running') agent.cancel('cancelled from terminal')
|
||||
else appendNotice('The agent is already idle.')
|
||||
return
|
||||
case '/reasoning':
|
||||
toggleReasoning()
|
||||
return
|
||||
case '/tools':
|
||||
toggleTools()
|
||||
return
|
||||
case '/redraw':
|
||||
ui.invalidate()
|
||||
ui.requestRender(true)
|
||||
return
|
||||
case '/exit':
|
||||
requestExit()
|
||||
return
|
||||
default:
|
||||
if (text.startsWith('/')) {
|
||||
appendNotice(`Unknown command: ${text}`, 'warning')
|
||||
return
|
||||
}
|
||||
if (value.startsWith('/')) {
|
||||
runCommand(value)
|
||||
return
|
||||
}
|
||||
if (agent.status === 'disposed') {
|
||||
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
|
||||
@@ -1321,6 +1370,7 @@ export function createTuiChat(
|
||||
|
||||
const detachListeners = (): void => {
|
||||
removeInputListener()
|
||||
disposeCommandChanges()
|
||||
disposeSessionEvents()
|
||||
disposeStatus()
|
||||
disposeError()
|
||||
@@ -1334,6 +1384,12 @@ export function createTuiChat(
|
||||
} catch (error: unknown) {
|
||||
disposed = true
|
||||
detachListeners()
|
||||
void commandFiber.dispose().catch(
|
||||
/* v8 ignore next 2 -- command registration cleanup is non-throwing; this guards a future disposer regression */
|
||||
(cleanupError: unknown) => {
|
||||
ctx.logger.warn(`ui-tui: command cleanup after startup failure failed: ${errorChain(cleanupError)}`)
|
||||
},
|
||||
)
|
||||
clearStatus()
|
||||
disposeUserInteraction()
|
||||
ui.stop()
|
||||
@@ -1344,6 +1400,7 @@ export function createTuiChat(
|
||||
async dispose(): Promise<void> {
|
||||
detachListeners()
|
||||
await shutdown(false)
|
||||
await commandFiber.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
@@ -48,6 +49,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
if (options.configureContext === undefined) {
|
||||
const tools = options.tools ?? {}
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('dsh-tui plugin export shape', () => {
|
||||
const unwrapped = loader.unwrapExports(tui) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(tui)
|
||||
expect(unwrapped.name).toBe('ui-tui')
|
||||
expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools'])
|
||||
expect(unwrapped.inject).toEqual(['agents', 'commands', 'userInteraction', 'tools'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
lifecycle started=1 stopped=1 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor visible column=0 viewportRow=22 bufferRow=22
|
||||
cursor visible column=0 viewportRow=29 bufferRow=29
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-91 fg=bright-blue
|
||||
@@ -29,24 +29,37 @@ buffer
|
||||
style 1-75 fg=bright-black
|
||||
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 1-73 fg=bright-black
|
||||
10| " /help /clear /cancel /reasoning /tools /redraw /exit "
|
||||
style 1-52 fg=bright-black
|
||||
11| <blank>
|
||||
12| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
13| <blank>
|
||||
14| " provider stream failed after partial output "
|
||||
10| " "
|
||||
11| " /cancel — Cancel the active turn "
|
||||
style 1-32 fg=bright-black
|
||||
12| " /clear — Clear the transcript view (session history is unchanged) "
|
||||
style 1-65 fg=bright-black
|
||||
13| " /exit — Exit after the active turn reaches idle "
|
||||
style 1-47 fg=bright-black
|
||||
14| " /help — Show keyboard shortcuts and commands "
|
||||
style 1-44 fg=bright-black
|
||||
15| " /reasoning — Toggle reasoning blocks "
|
||||
style 1-36 fg=bright-black
|
||||
16| " /redraw — Invalidate components and redraw the terminal "
|
||||
style 1-55 fg=bright-black
|
||||
17| " /tools — Expand or collapse all tool cards "
|
||||
style 1-42 fg=bright-black
|
||||
18| <blank>
|
||||
19| " provider stream failed after partial output "
|
||||
style 1-43 fg=red
|
||||
15| <blank>
|
||||
16| " The previous process ended during this turn. "
|
||||
20| <blank>
|
||||
21| " The previous process ended during this turn. "
|
||||
style 1-44 fg=yellow
|
||||
17| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
22| <blank>
|
||||
23| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
24| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
18| " "
|
||||
25| " "
|
||||
style 1-1 inverse
|
||||
19| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
26| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 59-91 dim
|
||||
21-31| <blank>
|
||||
28-31| <blank>
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=18 bufferRow=18
|
||||
cursor hidden column=1 viewportRow=25 bufferRow=25
|
||||
buffer
|
||||
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
style 0-91 fg=bright-blue
|
||||
@@ -29,24 +29,37 @@ buffer
|
||||
style 1-75 fg=bright-black
|
||||
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 1-73 fg=bright-black
|
||||
10| " /help /clear /cancel /reasoning /tools /redraw /exit "
|
||||
style 1-52 fg=bright-black
|
||||
11| <blank>
|
||||
12| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
13| <blank>
|
||||
14| " provider stream failed after partial output "
|
||||
10| " "
|
||||
11| " /cancel — Cancel the active turn "
|
||||
style 1-32 fg=bright-black
|
||||
12| " /clear — Clear the transcript view (session history is unchanged) "
|
||||
style 1-65 fg=bright-black
|
||||
13| " /exit — Exit after the active turn reaches idle "
|
||||
style 1-47 fg=bright-black
|
||||
14| " /help — Show keyboard shortcuts and commands "
|
||||
style 1-44 fg=bright-black
|
||||
15| " /reasoning — Toggle reasoning blocks "
|
||||
style 1-36 fg=bright-black
|
||||
16| " /redraw — Invalidate components and redraw the terminal "
|
||||
style 1-55 fg=bright-black
|
||||
17| " /tools — Expand or collapse all tool cards "
|
||||
style 1-42 fg=bright-black
|
||||
18| <blank>
|
||||
19| " provider stream failed after partial output "
|
||||
style 1-43 fg=red
|
||||
15| <blank>
|
||||
16| " The previous process ended during this turn. "
|
||||
20| <blank>
|
||||
21| " The previous process ended during this turn. "
|
||||
style 1-44 fg=yellow
|
||||
17| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
22| <blank>
|
||||
23| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
24| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
18| " "
|
||||
25| " "
|
||||
style 1-1 inverse
|
||||
19| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
26| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact"
|
||||
style 0-24 dim
|
||||
style 59-91 dim
|
||||
21-31| <blank>
|
||||
28-31| <blank>
|
||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
@@ -516,6 +517,108 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
||||
await dispose(disposedAgent)
|
||||
})
|
||||
|
||||
it('discovers and executes plugin commands, then removes TUI-local commands on disposal', async () => {
|
||||
const result = await setup()
|
||||
const handler = vi.fn(({ rawInput }: CommandInvocation) => ({
|
||||
kind: 'success' as const,
|
||||
text: `PLUGIN:${rawInput}`,
|
||||
}))
|
||||
result.ctx.commands.register({
|
||||
name: 'plugin-check',
|
||||
description: 'Run a plugin command',
|
||||
input: { hint: '<value>' },
|
||||
handler,
|
||||
})
|
||||
result.ctx.commands.register({
|
||||
name: 'plugin-fail',
|
||||
description: 'Fail a plugin command',
|
||||
handler: () => { throw new Error('plugin command exploded') },
|
||||
})
|
||||
|
||||
result.terminal.send('/plugin-check value ')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
const invocation = handler.mock.calls[0]?.[0]
|
||||
expect(invocation?.agent).toBe(result.agent)
|
||||
// pi-tui's Editor owns terminal-line normalization and removes trailing
|
||||
// spaces before onSubmit; the registry preserves the adapter-delivered line.
|
||||
expect(invocation?.rawInput).toBe(' value')
|
||||
expect(result.terminal.output).toContain('PLUGIN: value')
|
||||
result.terminal.send('/plugin-fail')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('Command failed: plugin command exploded')
|
||||
result.terminal.send('/help')
|
||||
result.terminal.send('\r')
|
||||
await tick()
|
||||
expect(result.terminal.output).toContain('/plugin-check <value> — Run a plugin command')
|
||||
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toContain('help')
|
||||
|
||||
await result.controller.dispose()
|
||||
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([
|
||||
'plugin-check',
|
||||
'plugin-fail',
|
||||
])
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('aborts an in-flight plugin command during TUI disposal', async () => {
|
||||
const result = await setup()
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => { started = resolve })
|
||||
let commandSignal: AbortSignal | undefined
|
||||
result.ctx.commands.register({
|
||||
name: 'wait-plugin',
|
||||
description: 'Wait until disposal',
|
||||
handler: ({ signal }) => {
|
||||
commandSignal = signal
|
||||
started()
|
||||
return new Promise((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late result' }) }, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
result.terminal.send('/wait-plugin')
|
||||
result.terminal.send('\r')
|
||||
await ready
|
||||
await result.controller.dispose()
|
||||
|
||||
expect(commandSignal?.aborted).toBe(true)
|
||||
expect(result.terminal.output).not.toContain('late result')
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('suppresses a successful plugin result that settles as TUI disposal starts', async () => {
|
||||
const result = await setup()
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => { started = resolve })
|
||||
let resolveCommand!: (result: { kind: 'success'; text: string }) => void
|
||||
result.ctx.commands.register({
|
||||
name: 'late-success',
|
||||
description: 'Resolve while the TUI closes',
|
||||
handler: () => new Promise((resolve) => {
|
||||
resolveCommand = resolve
|
||||
started()
|
||||
}),
|
||||
})
|
||||
|
||||
result.terminal.send('/late-success')
|
||||
result.terminal.send('\r')
|
||||
await ready
|
||||
resolveCommand({ kind: 'success', text: 'must not render after disposal' })
|
||||
// Let the command boundary accept the result before disposal, but leave the
|
||||
// TUI continuation queued so the success-side disposal guard owns the race.
|
||||
await Promise.resolve()
|
||||
await result.controller.dispose()
|
||||
await tick()
|
||||
|
||||
expect(result.terminal.output).not.toContain('must not render after disposal')
|
||||
await result.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('cancels before /exit while running and handles agent errors/disposal', async () => {
|
||||
const result = await setup({ status: 'running' })
|
||||
result.terminal.send('/exit')
|
||||
@@ -899,6 +1002,7 @@ describe('terminal mounting', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('main'))
|
||||
@@ -917,6 +1021,7 @@ describe('terminal mounting', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -945,6 +1050,7 @@ describe('terminal mounting', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -972,6 +1078,7 @@ describe('terminal mounting', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const terminal = new FakeTerminal()
|
||||
@@ -992,6 +1099,7 @@ describe('terminal mounting', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const session = ctx.sessions.create(SessionId('failed-start-session'))
|
||||
@@ -1004,6 +1112,8 @@ describe('terminal mounting', () => {
|
||||
|
||||
expect(() => createTuiChat(ctx, { sessionId: 'failed-start-session', color: false }, { terminal, exit: vi.fn() }))
|
||||
.toThrow('terminal startup failed')
|
||||
await tick()
|
||||
expect(ctx.commands.list(ctx.agents.get(SessionId('failed-start-session'))!)).toEqual([])
|
||||
expect(terminal.stopped).toBe(1)
|
||||
expect(terminal.progress).toEqual([false, true, false])
|
||||
await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] }))
|
||||
@@ -1021,6 +1131,7 @@ describe('terminal mounting', () => {
|
||||
it('throws when createTuiChat is called without the configured agent', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.provide('tools', { get: () => undefined } as never)
|
||||
const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() }
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../commands"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user