Merge remote-tracking branch 'origin/master' into codex/ask-user-question

# Conflicts:
#	docs/architecture.md
#	docs/cordis-catalog/events-and-services.md
#	docs/core-data-structures/core.md
#	docs/module-graph.md
#	docs/tool-catalog/tools.md
#	packages/README.md
#	packages/core/README.md
#	packages/core/tools/tests/gen-tool-catalog.spec.ts
#	packages/support/README.md
#	packages/support/ui-stdio/README.md
#	packages/ui/acp-agent/tests/built-bin.e2e.ts
#	packages/ui/acp/README.md
#	packages/ui/stdio-agent/README.md
#	packages/ui/stdio-agent/package.json
#	packages/ui/stdio-agent/src/index.ts
#	packages/ui/stdio-agent/src/stdio-chat.ts
#	packages/ui/stdio-agent/tests/built-bin.e2e.ts
#	packages/ui/stdio-agent/tests/readline.spec.ts
#	packages/ui/stdio-agent/tests/stdio-chat.spec.ts
#	packages/web/web/package.json
#	packages/web/web/tsconfig.json
#	pnpm-lock.yaml
#	scripts/gen-tool-catalog.ts
This commit is contained in:
Yichen Jiang
2026-07-05 17:05:33 +08:00
455 changed files with 29519 additions and 4645 deletions

View File

@@ -8,8 +8,9 @@ Integrations that expose the agent to an external editor or client. These are **
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
| `app-boot/` | Shared boot glue for the two 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 readline `ui-stdio` plugin is the unstructured analogue but lives in `support/` because it exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product.
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 readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
`tool-ask-user` lives here because it is a model-facing product affordance that depends on a UI/provider seam; it is not part of the providerless core spine.

View File

@@ -32,6 +32,7 @@
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
@@ -43,6 +44,7 @@
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-acp": "workspace:^",
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",

View File

@@ -2,167 +2,45 @@
/**
* The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that
* loads the {@link @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter
* and a bash executor), speaking ACP JSON-RPC on stdio.
* and a bash executor), speaking ACP JSON-RPC on stdio. The shared boot glue —
* `.env` loading, the fail-loud Loader guards, snapshot-aware config
* resolution, the settle-the-tree boot sequence — lives in
* {@link @deepseek-ai/dsh-app-boot}; this bin owns only the ACP-specific
* lifecycle:
*
* Owns the ACP-specific boot glue the example's `start.ts` once held:
* - `.env` loading (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`) — SKIPPED in
* snapshot REPLAY so a stray key can never trigger a live model call.
* - snapshot-mode config selection: `DSH_SNAPSHOT=replay` swaps the given
* `cordis.yml` for its sibling `cordis.snapshot.yml` (the keyless replay
* tree: `llm-replay` in place of `llm-deepseek`).
* - the stdin-dispose lifecycle: in a snapshot run the harness closes stdin
* when done, so dispose the context (flushing persistence) and exit cleanly.
* - `.env` loading is SKIPPED in snapshot REPLAY so a stray key can never
* trigger a live model call.
* - `DSH_SNAPSHOT=replay` swaps the given `cordis.yml` for its sibling
* `cordis.snapshot.yml` (the keyless replay tree: `llm-replay` in place of
* `llm-deepseek`).
* - In a snapshot run the harness closes stdin when done, so dispose the
* context (flushing persistence) and exit cleanly. In a normal editor
* session stdin stays open for the connection's lifetime (the editor kills
* the process), so the EOF handler never fires.
*
* IMPORTANT: stdout is the ACP JSON-RPC channel. This bin writes diagnostics to
* STDERR only; the app plugin loads no stdout logger. A stray stdout write
* corrupts the protocol frames.
* STDERR only (the app plugin loads no stdout logger, and the shared guards
* write to stderr); a stray stdout write corrupts the protocol frames.
*
* Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`).
*
* @module @deepseek-ai/dsh-acp-agent/bin
*/
import { pathToFileURL } from 'node:url'
import { basename, dirname, resolve } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
/**
* Resolve the config to boot, honoring snapshot REPLAY. Given the requested
* path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in
* the SAME directory (the keyless replay tree). Other modes use the path as-is.
* Returns an absolute path resolved from the cwd.
*/
export function resolveConfigPath(configPath: string, snapshotMode: string | undefined): string {
const absolute = resolve(process.cwd(), configPath)
if (snapshotMode !== 'replay') return absolute
const dir = dirname(absolute)
const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
return resolve(dir, replayName)
}
const NAME = 'dsh-acp-agent'
/**
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the
* cwd (Node native). Diagnostics go to STDERR (stdout is the protocol). In
* REPLAY mode the caller skips this entirely — replay must never reach the
* network, so a present `.env` must not enable a live call.
*/
function loadEnv(): void {
try {
process.loadEnvFile(resolve(process.cwd(), '.env'))
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
process.stderr.write(`dsh-acp-agent: failed to load .env: ${String(error)}\n`)
}
// ENOENT (no .env) is fine — rely on the ambient environment.
}
}
/**
* Make a load failure fail loud with a clear message on stderr. Covers the
* failure path the entry-tree check below cannot: when the include's
* `[Service.init]` throws (e.g. a config FILE missing in a real directory), the
* cordis Loader surfaces it as an unhandled promise rejection AFTER `boot()`
* resolves — `loader.await()` does NOT rethrow it (`EntryTree.await()` uses
* `Promise.allSettled`, which swallows rejections). Node's default handler
* already exits non-zero on an unhandled rejection, so this does not change the
* exit code; it replaces the noisy stack dump with a single labelled line (on
* STDERR — stdout is the ACP JSON-RPC channel) and guarantees `process.exit(1)`.
* Install before `boot()`.
*/
export function installFailLoud(): void {
process.on('unhandledRejection', (err: unknown) => {
process.stderr.write(`dsh-acp-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
process.exit(1)
/* v8 ignore start -- thin self-executing composition over the unit-tested
dsh-app-boot helpers; exercised end-to-end by the snapshot suite and the
built-bin smoke */
installFailLoud(NAME)
const snapshotMode = process.env['DSH_SNAPSHOT']
if (snapshotMode !== 'replay') loadEnv(NAME)
const ctx = await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', snapshotMode))
if (snapshotMode !== undefined) {
process.stdin.on('end', () => {
void ctx.fiber.dispose().then(() => { process.exit(0) })
})
}
/**
* After the tree settles, assert every loader entry actually started. This is
* the load-bearing guard against the SILENT-exit-0 bug: a plugin module that
* fails to IMPORT (e.g. a config path in a non-existent directory) is caught and
* only LOGGED by the cordis Loader (`entry._init`), leaving the entry with no
* `fiber` and producing no rejection — so the process would otherwise exit 0. A
* started entry has a `fiber`; throw on any entry still missing one so `boot()`
* rejects.
*
* A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()`
* deliberately skips `init()` for it, so it settles without a fiber by design —
* a valid "plugin turned off" config, not a failed import. Exclude it.
*/
function assertEntriesLoaded(ctx: Context): void {
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
if (failed.length > 0) {
const names = failed.map(entry => entry.options.name).join(', ')
throw new Error(`dsh-acp-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
}
}
/**
* Boot the Loader against `absoluteConfigPath`. The include is handed the
* config's ABSOLUTE `file://` URL as its `path`, so resolution never depends on
* `ctx.baseUrl` (an absolute URL ignores the base) and can never fall back to
* the cwd. `baseUrl` is still pinned to the config's directory so the config's
* OWN relative plugin/include paths resolve against it. Returns the root context
* once the whole tree has settled.
*
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
* the include ENTRY is registered, but the include then loads its child plugins
* asynchronously. Without awaiting the tree, `boot()` would resolve while the ACP
* bridge is still mounting — the process would have no stdin handle attached yet
* and could exit 0 silently. Awaiting keeps the process alive until the bridge
* is up.
*
* `loader.await()` does NOT rethrow load errors (`EntryTree.await()` uses
* `Promise.allSettled`), so failures are surfaced two ways: a plugin that fails
* to IMPORT leaves an entry with no fiber, caught here by
* {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init THROWS
* surfaces as an unhandled rejection caught by {@link installFailLoud} (installed
* by `main()` before this runs). Together any load failure exits non-zero.
*
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
* resolved by the cordis Loader's internal module loader, which is only active
* under `node --expose-internals`. The `demo:acp` script runs under tsx (whose
* tsconfig `paths` map resolves the workspace plugins instead), but a consumer
* running the built bin under plain node must pass `--expose-internals` so the
* Loader resolves the config's plugins from the config directory rather than
* relative to its own module.
*/
export async function boot(absoluteConfigPath: string): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
await ctx.plugin(Loader)
await ctx.loader.create({
name: '@cordisjs/plugin-include',
config: { path: pathToFileURL(absoluteConfigPath).href },
})
await ctx.loader.await()
assertEntriesLoaded(ctx)
return ctx
}
/**
* Entry point. Installs the fail-loud guard, selects the config (snapshot-aware),
* loads `.env` outside replay, boots, and — in a snapshot run — disposes the
* context on stdin EOF so the session log is fully flushed before exit and the
* harness's `waitForExit` resolves. In a normal editor session stdin stays open
* for the connection's lifetime (the editor kills the process), so the EOF
* handler never fires.
*/
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
installFailLoud()
const snapshotMode = process.env.DSH_SNAPSHOT
const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode)
if (snapshotMode !== 'replay') loadEnv()
const ctx = await boot(configPath)
if (snapshotMode !== undefined) {
process.stdin.on('end', () => {
void ctx.fiber.dispose().then(() => { process.exit(0) })
})
}
}
/* v8 ignore start -- top-level CLI invocation; the testable core is
resolveConfigPath()/boot()/main(), driven by the keyless snapshot + Loader-path tests */
await main()
/* v8 ignore stop */

View File

@@ -39,12 +39,10 @@ const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js')
const dshPackages = [
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/user-interaction', 'core/agent-loop', 'llm/llm',
'llm/llm-deepseek', 'bash/bash', 'bash/bash-local', 'bash/tool-bash',
'support/invariants',
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent',
'ui/tool-ask-user',
]
const vendorPackages = [
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',

View File

@@ -17,6 +17,9 @@
{
"path": "../../../vendor/loader"
},
{
"path": "../app-boot"
},
{
"path": "../acp"
},

View File

@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets the bridge provide ACP-backed answers for tools such as `ask_user_question`.
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session.
### Config
@@ -16,8 +16,8 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
|---|---|---|
| `model` | — | Model name for created agents (must have a registered adapter). |
| `systemPrompt` | — | Per-agent system prompt. |
| `agentName` | `deepseek-harness-acp` | Server name reported in `initialize`. |
| `agentVersion` | `0.0.1` | Server version reported in `initialize`. |
The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config.
## ACP method mapping
@@ -28,12 +28,12 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message``user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
| `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/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content/locations owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) |
| `elicitation/create` | `ctx.userInteraction` provider | `ask_user_question` pauses the tool call and asks the ACP client for a session-scoped form; choices use a `choice` single-select field, free-form answers use `answer`/`custom_answer`, and cancel/decline returns a structured `UserInteractionError` |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` requests to ACP form elicitations; recommended options become defaults, option descriptions are shown in enum titles, optionless requests remain free-form even when `allowCustom` is false |
## Multi-session
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — and `ctx.userInteraction` requests, which carry only the calling `Agent` — demux in O(1). Every `session/event`, `agent/status`, and ask-user elicitation is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload.
@@ -43,22 +43,28 @@ Each session runs in its own workspace, recorded as the session's `SessionHeader
## Tool-call presentation
How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, optional `content` blocks shown alongside, and optional `locations``{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block; the `dsh-tool-fs` `read`/`write`/`edit` tools set a `Read/Write/Edit <path>` title, a `read`/`edit` kind, and a `locations` entry for the file. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.)
How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state) and `presentResult(args, result)` (completed state) on its `dsh-tools` definition, each returning a **`card`-tagged render intent** — a discriminated union the bridge switches on. `presentCall` returns a `ToolCallView`, one of three cards:
The `tool/result` session event carries only `{ callId, content, isError }` — not the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, a `kind` for the icon, the salient `rawInput` for a detail view, optional `content` blocks shown alongside, and optional `locations` (`FileLocation[]` = `{ path, line? }[]` files the call reads/modifies, forwarded as `tool_call.locations` so an editor can follow along).
- `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card).
- `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview.
`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind `other` — the bridge never sniffs a kind from the tool name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath``Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path.
The `tool/result` session event does not carry the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
## Terminal card (capability-gated)
A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the neutral `terminal` field on its presentation (`dsh-tools`: a `{ cwd?, output?, exitCode?, signal? }` shape on `ToolCallPresentation`/`ToolResultPresentation`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`:
A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the `terminal` card variant of its render intent (`dsh-tools`: `{ card: 'terminal', title, description?, cwd? }` from `presentCall`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` from `presentResult`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`:
- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the tool's explicit absolute `terminal.cwd`, else a relative `terminal.cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). Any pending `content` the tool supplied (e.g. bash's `description`) renders BEFORE the terminal block, so the description sits above the card.
- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the captured output) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the tool reported a structured exit. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call.
- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the card's explicit absolute `cwd`, else a relative `cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). The card's `description` renders as a content block BEFORE the terminal block, so the description sits above the card.
- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the terminal card's `output`) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the card reported a structured `exitCode`/`signal`. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call.
When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries a ` ```console ` text block the bridge DERIVES by fencing the terminal result's `output` (the tool no longer double-encodes the fences) — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../../docs/rfc/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [the render-intent-union RFC](../../../docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
## Settle-exactly-once
A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream), NOT the `agent/turn-start`/`agent/turn-end` events. One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the one signal that always fires (`closeTurn` appends it unconditionally, even when a boundary emit throws and the `agent/turn-end` EVENT is skipped). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
A `session/prompt` resolves (or rejects) exactly once, keyed off the canonical session log (the `session/event` stream). One listener captures the prompt's owning turn from the log's `turn/start` and settles on the matching `turn/end` — the durable boundary event (`closeTurn` appends it unconditionally; there is no `agent/*` turn mirror). A prompt settles only on ITS OWN turn (`inflight.turn === turn/end.turn`), so a stale `turn/end` for a previously-cancelled turn whose end arrives late can never settle the wrong prompt. A turn that ends `error` REJECTS the RPC with an internal error carrying the failure message (ACP has no error stop reason); every other reason resolves via the codec. As a fallback, when the agent settles to `idle`/`disposed` with a prompt still pending — e.g. a peer `session/event` listener registered before the bridge threw and starved the bridge's listener — an `agent/status` handler reconciles the prompt from the log (the owning turn's `turn/end`, or `cancelled` if the turn was torn down without one). An empty/whitespace prompt is rejected up front — it would queue no work, so no turn would start and the RPC would hang.
## Disposal & disconnect
@@ -66,7 +72,7 @@ Teardown reaches quiescence: for EVERY live session settle any pending prompt as
## Known limitations (tracked TODOs)
- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land.
- **`TODO(rfc010-permission-gate)`** — the `tools/pre-execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../../docs/rfc/proposed/feature/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../../docs/rfc/proposed/feature/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land.
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
## stdout is the protocol

View File

@@ -63,7 +63,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `sessionCapabilities.*` | S | ❌ | ✅ | ✅ | None advertised (list/delete/resume/close/additionalDirectories/fork all off). |
| `auth.logout` | S | ❌ | ✅ | ✅ | Not advertised. |
| `authMethods[]` | S | ⚠️ | ✅ | ✅ | Advertised as empty (no auth required to reach the model). |
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | From `agentName` / `agentVersion` config. |
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | Fixed literals: `deepseek-harness-acp` / `0.0.1` (not config). |
| `_meta` custom caps | S | ❌ | ✅ | — | E.g. Claude's `claudeCode.promptQueueing`. The bridge advertises no custom `_meta`. |
### 3b. `clientCapabilities` (consumed by the bridge)
@@ -96,10 +96,10 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
| Feature | Stable | Bridge | Claude | Codex | Notes |
|---|---|---|---|---|---|
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; richer mapping possible. |
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit` declared by each tool's `presentCall`; presenter-less tools render `other` (no name sniffing); richer mapping possible. |
| `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress``completed`/`failed`. |
| `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. |
| `diff` content | S | | ✅ | ✅ | No structured diff rendering for edits (would need a diffing edit tool + presenter). |
| `diff` content | S | | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }`. For an edit or an overwrite it carries the applied hunk(s) with surrounding context (one per `replace_all` site), computed from the before/after text and persisted on the `tool/result` event as `meta`; for a create (no before-image) it is an args-derived whole-file diff. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; a successful mutation ALWAYS returns the result diff (an ACP `tool_call_update.content` replaces the call's content, so the result diff — not the model-facing text — is what survives). |
| `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. |
| `locations` (follow-along) | S | ✅ | ✅ | ✅ | The `read`/`write`/`edit` tools emit `{ path, line? }` file-location hints via `presentCall`. |
| `rawInput` | S | ✅ | ⚠️ | ✅ | Parsed tool args surfaced as `rawInput`. |
@@ -147,7 +147,6 @@ Ranked by how commonly the reference adapters ship them and how much UX they unl
5. **Slash commands** (`available_commands_update`).
6. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
7. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
8. **Diff tool rendering** — structured `diff` content for edit tools (the `locations` follow-along hint already ships on `read`/`write`/`edit`).
9. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
10. **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.

View File

@@ -17,7 +17,8 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
*
* The mapping is total over the kinds the loop actually produces today
* (`completed`/`aborted`/`error`/`disposed`/`max-tokens`). `TurnEndReason` is
* (`completed`/`aborted`/`error`/`disposed`/`max-tokens`/`rejected`).
* `TurnEndReason` is
* merge-extensible, so an unknown future kind falls through to `end_turn` —
* the safest default (the turn DID end; we just lack a more specific wire
* reason) — rather than throwing into the SDK, which would reject an unknown
@@ -34,6 +35,10 @@ import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientpr
* for any non-bridge caller / property test.)
* - `disposed` → `cancelled` (the agent was torn down mid-turn — closest to a
* cancellation from the client's perspective)
* - `rejected` → `cancelled` (the prompt was blocked by an `agent/prompt-submit`
* hook before any step ran — ACP has no "rejected" reason, and a
* blocked prompt is, from the client's view, the prompt not being
* carried out; `cancelled` is the closest legal wire reason)
*/
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
switch (reason.kind) {
@@ -45,6 +50,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
return 'cancelled'
case 'disposed':
return 'cancelled'
case 'rejected':
return 'cancelled'
case 'error':
return 'end_turn'
// Merge-extensible: an unknown future TurnEndReason kind still has to
@@ -62,8 +69,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
* client as message content. Today only `text` maps; `resource_link` is an
* ACP prompt-only input rendered into text by {@link acpPromptToText};
* `reasoning` is surfaced via `agent_thought_chunk`
* streaming rather than as a message block, and `tool-call`/`tool-result`/
* `image` are handled by the tool-call update path or not advertised.
* streaming rather than as a message block, and `tool-call`/`tool-result`
* are handled by the tool-call update path.
*/
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
switch (block.type) {
@@ -71,7 +78,7 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock |
return { type: 'text', text: block.text }
// reasoning → streamed as agent_thought_chunk, not a message block
// tool-call / tool-result → the tool_call / tool_call_update path
// image → not advertised
// plugin-added block types → not surfaced
default:
return undefined
}

View File

@@ -22,7 +22,7 @@
* `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
* `session/event` and `agent/*` event is routed strictly to its owning session
* record, so two sessions streaming at once never interleave their
* `session/update` notifications. The `tools/execute` permission gate is
* `session/update` notifications. The `tools/pre-execute` permission gate is
* deferred — see the TODO(rfc010-permission-gate) note below.
*
* stdout is the protocol: this plugin must run in an example that loads NO
@@ -36,7 +36,7 @@
import type { Context } from 'cordis'
import { Readable, Writable } from 'node:stream'
import { randomUUID } from 'node:crypto'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { isAbsolute, relative as relativePath, resolve as resolvePath, sep as pathSep } from 'node:path'
import Schema from 'schemastery'
import {
AgentSideConnection,
@@ -65,12 +65,12 @@ import {
type StopReason,
} from '@agentclientprotocol/sdk'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
import { assertNever, CallId } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools'
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
@@ -239,10 +239,6 @@ export interface AcpConfig {
model?: string
/** Per-agent system prompt. */
systemPrompt?: string
/** Agent/server name reported to the client in `initialize`. */
agentName?: string
/** Agent/server version reported to the client in `initialize`. */
agentVersion?: string
/**
* Transport stream override. Production omits this (the plugin wires
* `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
@@ -256,8 +252,6 @@ export interface AcpConfig {
export const Config: Schema<AcpConfig> = Schema.object({
model: Schema.string(),
systemPrompt: Schema.string(),
agentName: Schema.string().default('deepseek-harness-acp'),
agentVersion: Schema.string().default('0.0.1'),
})
/**
@@ -321,22 +315,16 @@ interface SessionRecord {
}
/**
* Drive the in-flight prompt's settle from the harness event stream. A turn
* can end three ways the bridge must all handle (AGENTS.md "honor cross-seam
* contracts on BOTH sides"): the normal `agent/turn-end` event; a `turn/end`
* session event WITHOUT the agent event (a boundary emit threw inside the loop,
* which still appends `turn/end`); or the agent erroring/settling to idle. The
* first of these to fire settles the prompt; `settle` is then cleared so the
* others are no-ops (settle-exactly-once).
* Drive the in-flight prompt's settle from the harness event stream. The bridge
* settles off the durable log: the `turn/end` session event on the
* `session/event` feed for the prompt's own turn, with the agent
* erroring/settling to idle as a fallback (docs/defensive-patterns.md "honor
* cross-seam contracts on BOTH sides") for the case where a throwing peer `session/event` listener
* starved the bridge's listener before it saw the boundary. The first of these
* to fire settles the prompt; `settle` is then cleared so the others are no-ops
* (settle-exactly-once).
*/
export function apply(ctx: Context, config: AcpConfig): void {
// TODO(double-default): these literals duplicate the Config schema defaults
// (`agentName`/`agentVersion` `.default(...)` above). The Loader applies the
// schema before apply() runs, so the `??` only fires for direct-apply unit
// tests. Pick one home for the default to avoid drift.
const agentName = config.agentName ?? 'deepseek-harness-acp'
const agentVersion = config.agentVersion ?? '0.0.1'
// Capture the injected services NOW, during apply(), while we are inside this
// plugin's fiber (where `inject` grants access). The ACP method handlers run
// LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is
@@ -437,7 +425,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// sessionUpdate returns a promise; a closed connection rejects it. The
// update is best-effort UI feed, never load-bearing for correctness, so a
// throwing/rejecting send must not break the turn (the chunk is emitted
// inside the model step — see AGENTS.md "contain callback exceptions").
// inside the model step — see docs/defensive-patterns.md "contain callback exceptions").
/* v8 ignore next 3 -- the rejection only fires on a stdout/connection write
failure (closed pipe), which the in-memory test transport never induces;
the swallow is a defensive best-effort guard like the loop's emit traps */
@@ -472,15 +460,14 @@ export function apply(ctx: Context, config: AcpConfig): void {
// the canonical log: every assistant/chunk and tool/call/result is logged, so
// translating from the log makes live streaming and `session/load` replay
// share the identical path (streamSessionEventUpdate). Both the owning-turn
// capture and the settle key off the log's own `turn/start`/`turn/end` — NOT
// the `agent/turn-start`/`agent/turn-end` EVENTS, which a throwing PEER
// listener (cordis `emit` stops at the first throw) or a boundary-emit failure
// can skip. `closeTurn` appends `turn/end` to the log unconditionally, and
// `turn/start` is appended before any step runs, so within this one listener
// we always see the prompt's turn-start (tag `inflight.turn`) then its
// turn-end (settle). A `turn/end` settles the prompt ONLY when it is the
// prompt's OWN turn (`inflight.turn === event.data.turn`) — a previous,
// already-cancelled turn whose end arrives late is ignored (see
// capture and the settle key off the log's own `turn/start`/`turn/end` — the
// durable boundary events (there is no agent/* turn mirror). `closeTurn`
// appends `turn/end` to the log unconditionally, and `turn/start` is appended
// before any step runs, so within this one listener we always see the
// prompt's turn-start (tag `inflight.turn`) then its turn-end (settle). A
// `turn/end` settles the prompt ONLY when it is the prompt's OWN turn
// (`inflight.turn === event.data.turn`) — a previous, already-cancelled turn
// whose end arrives late is ignored (see
// SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP
// has no error stop reason); other reasons resolve via the codec. Demux
// strictly by session id: a `session/event` is routed to its own record, so
@@ -584,7 +571,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
return Promise.resolve({
protocolVersion,
agentInfo: { name: agentName, version: agentVersion },
// Fixed server identity: this bridge IS the harness ACP server, so the
// branding is a literal, not config (no shipped surface sets it).
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
agentCapabilities: {
loadSession: true,
// Baseline prompt blocks only: text plus resource_link rendered as
@@ -793,7 +782,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
conn = new AgentSideConnection(makeAgent, stream)
/**
* Tear ALL live sessions down to quiescence (AGENTS.md "dispose must reach
* Tear ALL live sessions down to quiescence (docs/defensive-patterns.md "dispose must reach
* quiescence"): for each session settle any pending prompt `cancelled`, then
* run that session's {@link AgentHandle} `dispose()` — which stops the loop
* (sets `disposed`, aborts the in-flight step), AWAITS the loop's exit (the
@@ -967,67 +956,13 @@ export function streamSessionEventUpdate(
return
}
case 'tool/call': {
const present = presenter.call(event.data.callId, event.data.name, event.data.arguments)
// A terminal-rendered call (a shell command) gets a terminal CARD when the
// client supports it: a `terminal` content block plus `_meta.terminal_info`
// (the cwd header). Otherwise it is an ordinary tool_call and the output
// arrives as text on the result. See the terminal-rendering RFC.
const asTerminal = present.terminal !== undefined && terminal.enabled
// The tool's pending content (e.g. bash's `description`) renders ABOVE the
// card; when the card is shown, append the terminal block AFTER it so the
// description sits over the command (Zed renders content blocks in order).
// Without the capability the description still renders as the card's body.
const callContent: ({ type: 'content'; content: AcpContentBlock } | { type: 'terminal'; terminalId: string })[] = [
...present.content !== undefined ? toolResultContent(present.content) : [],
...asTerminal ? [{ type: 'terminal' as const, terminalId: event.data.callId }] : [],
]
notify({
sessionId,
update: {
sessionUpdate: 'tool_call',
toolCallId: event.data.callId,
title: present.title,
kind: present.kind,
status: 'in_progress',
...present.rawInput !== undefined ? { rawInput: present.rawInput } : {},
...present.locations !== undefined ? { locations: present.locations } : {},
...callContent.length > 0 ? { content: callContent } : {},
...asTerminal
? { _meta: { terminal_info: { terminal_id: event.data.callId, cwd: terminalCwd(present.terminal, terminal.cwd) } } }
: {},
},
})
const view = presenter.call(event.data.callId, event.data.name, event.data.arguments)
notify({ sessionId, update: toolCallUpdate(event.data.callId, view, terminal) })
return
}
case 'tool/result': {
const present = presenter.result(event.data.callId, event.data.content, event.data.isError)
const term = present.terminal
// When the call rendered as a terminal AND the client is capable, the output
// and exit status ride on `_meta` (the terminal card consumes them) and the
// text `content` is OMITTED: a `tool_call_update.content` REPLACES the call's
// content collection in Zed, so sending the fenced ```console block here
// would clobber the terminal content block the call installed. The incapable
// path keeps sending `content` (the fenced fallback is the only rendering).
const asTerminal = term?.output !== undefined && terminal.enabled
const terminalResultMeta = asTerminal
? {
_meta: {
terminal_output: { terminal_id: event.data.callId, data: term.output },
...terminalExitMeta(event.data.callId, term),
},
}
: {}
notify({
sessionId,
update: {
sessionUpdate: 'tool_call_update',
toolCallId: event.data.callId,
status: event.data.isError ? 'failed' : 'completed',
...asTerminal ? {} : { content: toolResultContent(present.content) },
...present.title !== undefined ? { title: present.title } : {},
...terminalResultMeta,
},
})
const view = presenter.result(event.data.callId, event.data.content, event.data.isError, event.data.meta)
notify({ sessionId, update: toolResultUpdate(event.data.callId, view, event.data.isError, terminal) })
return
}
case 'todo/write': {
@@ -1070,46 +1005,20 @@ export interface TerminalRendering {
/** Default: terminal rendering off (the ` ```console ` text fallback path). */
const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
/**
* Resolved pending-state presentation the bridge feeds into a `tool_call`
* update: a title is always present (tool name when the tool gives none), `kind`
* and `rawInput` are optional.
*/
interface ResolvedCallPresentation {
title: string
kind: ToolCallKind
rawInput?: unknown
/** UI content shown on the pending call (e.g. a bash description text block above the card). */
content?: ContentBlock[]
/** Files this call reads/modifies (mapped to ACP `tool_call.locations`), for editor follow-along. */
locations?: { path: string; line?: number }[]
/** Tool's request to render as a terminal (the pending side carries the cwd). */
terminal?: ToolTerminal
}
/** Resolved completed-state presentation fed into a `tool_call_update`. */
interface ResolvedResultPresentation {
/** UI content for the result (harness blocks; the tool may reformat, else the raw result). */
content: ContentBlock[]
/** Optional replacement title for the completed call. */
title?: string
/** Tool's terminal output/exit for a terminal-rendered call (the result side). */
terminal?: ToolTerminal
}
/**
* Resolves tool-owned presentation for a session's tool-call events. A tool
* declares `presentCall`/`presentResult` (see `dsh-tools`); this looks them up
* by name in the registry and applies the generic fallback when a tool defines
* neither.
* declares `presentCall`/`presentResult` (see `dsh-tools`) returning a
* `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up
* by name in the registry and applies a generic fallback when a tool defines
* neither. The returned view is what {@link streamSessionEventUpdate} switches on.
*
* The `tool/result` session event carries only `{ callId, content, isError }` —
* NOT the tool name or args — so to call a tool's `presentResult` (which needs
* both), the presenter remembers each `tool/call`'s `{ name, args }` keyed by
* callId and looks it up on the matching result. The map is bridge-LOCAL (not a
* change to the event schema or a core service): one presenter per live session
* (and a throwaway per `session/load` replay), and each entry is removed when
* its result arrives. In the normal loop a `tool/call` is always followed by a
* The `tool/result` session event does NOT carry the tool name or args — so to
* call a tool's `presentResult` (which needs both), the presenter remembers each
* `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the
* matching result. The map is bridge-LOCAL (not a change to the event schema or a
* core service): one presenter per live session
* (and a throwaway per `session/load` replay), and each entry is removed when its
* result arrives. In the normal loop a `tool/call` is always followed by a
* `tool/result` (the registry turns even a thrown tool into an isError result),
* so the map holds only currently-in-flight calls. The one exception is a step
* torn down mid-tool (an abort between `tool/call` and `tool/result`), which can
@@ -1119,14 +1028,14 @@ interface ResolvedResultPresentation {
* stale entry's only cost is one map slot until the session ends.
*/
export class ToolPresenter {
private readonly pending = new Map<CallId, { name: string; args: unknown; isTerminal: boolean }>()
private readonly pending = new Map<CallId, { name: string; args: unknown; card: ToolCallView['card'] }>()
/**
* @param tools the registry to resolve tool definitions by name.
* @param onError invoked when a tool's `presentCall`/`presentResult` THROWS;
* the presenter swallows the error and falls back to the generic
* presentation so a buggy display callback can never fail a live turn or a
* `session/load` replay (AGENTS.md "contain callback exceptions at the
* `session/load` replay (docs/defensive-patterns.md "contain callback exceptions at the
* boundary"). Defaults to a no-op for callers that don't supply a logger.
*/
constructor(
@@ -1134,10 +1043,10 @@ export class ToolPresenter {
private readonly onError: (message: string) => void = () => {},
) {}
/** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */
call(callId: CallId, name: string, argsJson: string): ResolvedCallPresentation {
/** Pending-state render intent for a `tool/call`; remembers `(name, args, card)` for the matching result. */
call(callId: CallId, name: string, argsJson: string): ToolCallView {
const args = parseToolArguments(argsJson)
let present: ToolCallPresentation | undefined
let present: ToolCallView | undefined
try {
present = this.tools.get(name)?.presentCall?.(args)
} catch (error: unknown) {
@@ -1145,51 +1054,39 @@ export class ToolPresenter {
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
present = undefined
}
if (present === undefined) {
// No tool-owned presentation: fall back to the tool name as the title and
// the full parsed args as the raw input (the pre-seam behavior). A generic
// call is never a terminal, so a later result can't emit terminal output.
this.pending.set(callId, { name, args, isTerminal: false })
return { title: name, kind: toolKindFor(name), rawInput: args }
}
// Remember whether THIS call rendered as a terminal, so `result()` only emits
// terminal output/exit for a call that actually registered a terminal — a
// `presentResult().terminal` without a matching `presentCall().terminal`
// would otherwise orphan `_meta.terminal_output` to a terminal Zed never made.
this.pending.set(callId, { name, args, isTerminal: present.terminal !== undefined })
return {
title: present.title,
kind: present.kind ?? 'other',
rawInput: present.rawInput,
...present.content !== undefined ? { content: present.content } : {},
...present.locations !== undefined ? { locations: present.locations } : {},
...present.terminal !== undefined ? { terminal: present.terminal } : {},
}
// No tool-owned presentation: fall back to the tool name as the title, the
// full parsed args as the raw input, and kind `other` (the generic card).
// The kind is never sniffed from the name — the bridge does not special-case
// tool names; a tool that wants a richer kind declares `presentCall`.
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
this.pending.set(callId, { name, args, card: view.card })
return view
}
/** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */
result(callId: CallId, content: ContentBlock[], isError: boolean): ResolvedResultPresentation {
/** Completed-state render intent for a `tool/result`; consumes the remembered `(name, args, card)`. */
result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView {
const call = this.pending.get(callId)
this.pending.delete(callId)
// No remembered call (unknown/late callId) → nothing to present from; raw content.
if (call === undefined) return { content }
let present: ToolResultPresentation | undefined
if (call === undefined) return { card: 'generic', content }
let present: ToolResultView | undefined
try {
present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError })
present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta !== undefined ? { meta } : {} })
} catch (error: unknown) {
// A throwing presentResult must not break streaming/replay: log + fall back.
this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`)
present = undefined
}
if (present === undefined) return { content }
return {
content: present.content ?? content,
...present.title !== undefined ? { title: present.title } : {},
// Only propagate terminal output/exit when the PENDING call registered a
// terminal (finding: orphan terminal output otherwise). A result-only
// terminal with no matching call-side terminal is dropped.
...present.terminal !== undefined && call.isTerminal ? { terminal: present.terminal } : {},
}
if (present === undefined) return { card: 'generic', content }
// Orphan guard: only honor a `terminal` result when the PENDING call was a
// terminal. A result-only terminal with no matching call-side terminal would
// orphan `_meta.terminal_output` to a terminal Zed never made — drop it back
// to the raw content.
if (present.card === 'terminal' && call.card !== 'terminal') return { card: 'generic', content }
// A generic result that reformats no content keeps the RAW result content
// (the tool replaced only the title); fill it so the card is never blanked.
if (present.card === 'generic' && present.content === undefined) return { ...present, content }
return present
}
}
@@ -1199,16 +1096,8 @@ export class ToolPresenter {
* results pass their raw content through unchanged.
*/
export const nullToolPresenter: Pick<ToolPresenter, 'call' | 'result'> = {
call: (_callId, name, argsJson) => ({ title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }),
result: (_callId, content) => ({ content }),
}
/** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */
function toolKindFor(name: string): ToolCallKind {
if (name === 'bash' || name === 'bash_output' || name === 'bash_kill') return 'execute'
if (name === 'read' || name.startsWith('read')) return 'read'
if (name === 'write' || name === 'edit' || name.startsWith('edit')) return 'edit'
return 'other'
call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: 'other', rawInput: parseToolArguments(argsJson) }),
result: (_callId, content) => ({ card: 'generic', content }),
}
/** Parse a tool-call arguments JSON string for `rawInput`; raw string on failure. */
@@ -1233,20 +1122,121 @@ function toolResultContent(blocks: ContentBlock[]): { type: 'content'; content:
return out
}
/** The `session/update` payload for a `tool_call` / `tool_call_update`. */
type ToolCallSessionUpdate = SessionNotification['update']
/** An ACP tool-call content block (a text/image `content`, a `diff`, or a `terminal`). */
type AcpToolCallContent =
| { type: 'content'; content: AcpContentBlock }
| { type: 'diff'; path: string; oldText: string | null; newText: string }
| { type: 'terminal'; terminalId: string }
/**
* Resolve the terminal card's header cwd. The tool's `terminal.cwd` (a model
* `workdir`) wins when ABSOLUTE; a RELATIVE one resolves against the session
* cwd (matching how `dsh-tool-bash` resolves a relative workdir for execution,
* so the header matches where the command actually ran); when the tool gives no
* cwd, the session workspace cwd is the default. Returns `undefined` only when
* neither the tool nor the session supplies one (Zed then shows "current
* directory").
* Relativize a file card's TITLE path against the session workspace cwd, so a
* card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the
* reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the
* card's `locations`/`diff` paths stay RAW (the editor opens the real path). The
* pure tool presenter can't see the session cwd, so this happens here where the
* bridge knows it. The rewrite is an exact substring replace of the known raw
* path (a card carries the same path in `locations[0]`/`diffs[0]`), never a
* heuristic. A path outside the workspace, or an absent/relative session cwd, is
* left unchanged.
*/
function terminalCwd(term: ToolTerminal | undefined, sessionCwd: string | undefined): string | undefined {
const toolCwd = term?.cwd
if (toolCwd === undefined) return sessionCwd
if (isAbsolute(toolCwd)) return toolCwd
return sessionCwd !== undefined ? resolvePath(sessionCwd, toolCwd) : toolCwd
function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string {
if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title
const rel = relativePath(sessionCwd, rawPath)
// Only relativize a target that stays INSIDE the workspace. `relative` prefixes
// a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone
// or `..<sep>…`), NOT a bare `..` char prefix, so a sibling like `..cache/x`
// (a real in-workspace name) still relativizes. Never relativize to the empty
// string (rawPath === cwd — a non-file target).
if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title
return title.split(rawPath).join(rel)
}
/**
* Resolve the terminal card's header cwd. A `TerminalCallView.cwd` (a model
* `workdir`) wins when ABSOLUTE; a RELATIVE one resolves against the session cwd
* (matching how `dsh-tool-bash` resolves a relative workdir for execution, so the
* header matches where the command actually ran); when the view gives no cwd, the
* session workspace cwd is the default. Returns `undefined` only when neither the
* view nor the session supplies one (Zed then shows "current directory").
*/
function terminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
if (viewCwd === undefined) return sessionCwd
if (isAbsolute(viewCwd)) return viewCwd
return sessionCwd !== undefined ? resolvePath(sessionCwd, viewCwd) : viewCwd
}
/**
* Build the `tool_call` (pending) `session/update` from a tool's render intent.
* Switches on `view.card`: a `generic` card maps title/kind/rawInput/content/
* locations; a `diff` card emits `{ type: 'diff' }` content blocks (the editor's
* inline diff) plus follow-along locations; a `terminal` card renders as a
* terminal when the client is capable (a `terminal` content block + the
* `_meta.terminal_info` cwd header) and otherwise falls back to a generic execute
* card whose body is the description. File-card titles are relativized against the
* session cwd (see {@link displayTitle}).
*/
function toolCallUpdate(callId: CallId, view: ToolCallView, terminal: TerminalRendering): ToolCallSessionUpdate {
switch (view.card) {
case 'generic':
return {
sessionUpdate: 'tool_call',
toolCallId: callId,
// Relativize the title against the session cwd when the card carries a
// file location (a read/file card); a location-less card (bash, todo)
// has no path to relativize, so the title is used as-is.
title: displayTitle(view.title, view.locations?.[0]?.path, terminal.cwd),
kind: view.kind ?? 'other',
status: 'in_progress',
...view.rawInput !== undefined ? { rawInput: view.rawInput } : {},
...view.locations !== undefined ? { locations: view.locations } : {},
...view.content !== undefined && view.content.length > 0 ? { content: toolResultContent(view.content) } : {},
}
case 'diff': {
const rawPath = view.locations?.[0]?.path ?? view.diffs[0]?.path
const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText }))
return {
sessionUpdate: 'tool_call',
toolCallId: callId,
title: displayTitle(view.title, rawPath, terminal.cwd),
kind: 'edit',
status: 'in_progress',
...view.locations !== undefined ? { locations: view.locations } : {},
...content.length > 0 ? { content } : {},
}
}
case 'terminal': {
// A terminal-rendered call gets a terminal CARD when the client supports it:
// the description renders ABOVE the card, then the terminal block, plus
// `_meta.terminal_info` (the cwd header). Without the capability it is an
// ordinary execute card whose body is the description and whose rawInput is
// the command; the output arrives as text on the result.
const asTerminal = terminal.enabled
const description: AcpToolCallContent[] = view.description !== undefined
? [{ type: 'content', content: { type: 'text', text: view.description } }]
: []
const content: AcpToolCallContent[] = [
...description,
...asTerminal ? [{ type: 'terminal' as const, terminalId: callId }] : [],
]
return {
sessionUpdate: 'tool_call',
toolCallId: callId,
title: view.title,
kind: 'execute',
status: 'in_progress',
rawInput: view.title,
...content.length > 0 ? { content } : {},
...asTerminal
? { _meta: { terminal_info: { terminal_id: callId, cwd: terminalCwd(view.cwd, terminal.cwd) } } }
: {},
}
}
default:
return assertNever(view, 'ToolCallView.card')
}
}
/** The `terminal_exit` `_meta` entry for a completed terminal call. */
@@ -1256,12 +1246,89 @@ interface TerminalExitMeta {
/**
* Build the optional `terminal_exit` portion of a `tool_call_update`'s `_meta`
* from the tool's terminal result: a `signal` death yields `{signal}`, an
* `exitCode` yields `{exit_code}`, and neither yields nothing (the card simply
* shows no exit pill). Spread into the `_meta` object alongside `terminal_output`.
* from a terminal result: a `signal` death yields `{signal}`, an `exitCode`
* yields `{exit_code}`, and neither yields nothing (the card simply shows no exit
* pill). Spread into the `_meta` object alongside `terminal_output`.
*/
function terminalExitMeta(callId: string, term: ToolTerminal): TerminalExitMeta {
if (term.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: term.signal } }
if (term.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: term.exitCode } }
function terminalExitMeta(callId: string, view: TerminalResultView): TerminalExitMeta {
if (view.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: view.signal } }
if (view.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: view.exitCode } }
return {}
}
/**
* Build the `tool_call_update` (completed) `session/update` from a result render
* intent. A `generic` result sends its reformatted content (or the raw result);
* a `terminal` result rides its output/exit on `_meta` when the client is capable
* (the terminal card consumes them and `content` is OMITTED — a
* `tool_call_update.content` REPLACES the call's content collection in Zed, so
* re-sending would clobber the terminal block the call installed) and otherwise
* derives the fenced ```console fallback from `output`. A `diff` result emits its
* `{ type: 'diff' }` content blocks (an applied hunk, or a whole-file diff for a
* create), which replace the diff the call installed — so the model-facing result
* text can never clobber it.
*/
function toolResultUpdate(callId: CallId, view: ToolResultView, isError: boolean, terminal: TerminalRendering): ToolCallSessionUpdate {
const status = isError ? 'failed' as const : 'completed' as const
switch (view.card) {
case 'terminal': {
const output = view.output ?? ''
if (terminal.enabled) {
return {
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status,
...view.title !== undefined ? { title: view.title } : {},
_meta: {
terminal_output: { terminal_id: callId, data: output },
...terminalExitMeta(callId, view),
},
}
}
// No terminal capability: the bridge derives the fenced ```console fallback.
const fenced = `\`\`\`console\n${output.replace(/\n+$/, '')}\n\`\`\``
return {
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status,
content: [{ type: 'content', content: { type: 'text', text: fenced } }],
...view.title !== undefined ? { title: view.title } : {},
}
}
case 'generic':
return {
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status,
// The presenter fills a generic result's content from the raw result, so
// `content` is always defined here; the guard keeps this total for a
// directly-constructed view.
/* v8 ignore next -- content always defined via the presenter (see above) */
...view.content !== undefined ? { content: toolResultContent(view.content) } : {},
...view.title !== undefined ? { title: view.title } : {},
}
case 'diff': {
// A result-time diff: emit one `{ type: 'diff' }` content block per entry
// (an applied hunk for an edit/overwrite, or a whole-file diff for a
// create), mirroring the call-side diff arm. `tool_call_update.content`
// REPLACES the call's content in an editor, so this result diff supersedes
// the diff the pending card installed (and keeps the model-facing result
// text from clobbering it).
const content: AcpToolCallContent[] = view.diffs.map(d => ({ type: 'diff', path: d.path, oldText: d.oldText, newText: d.newText }))
// Relativize the replacement title against the session cwd from the diff
// path, exactly as the call-side card does — `tool_call_update.title`
// replaces the card header, so a raw absolute path here would undo the
// pending card's relativized title.
const title = view.title !== undefined ? displayTitle(view.title, view.diffs[0]?.path, terminal.cwd) : undefined
return {
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status,
...content.length > 0 ? { content } : {},
...title !== undefined ? { title } : {},
}
}
default:
return assertNever(view, 'ToolResultView.card')
}
}

View File

@@ -20,7 +20,7 @@ describe('acp bridge', () => {
})
afterEach(async () => {
// e2e/integration tests own their resources (AGENTS.md): dispose even on
// e2e/integration tests own their resources (docs/testing.md): dispose even on
// failure so a flaky run never leaks a context or persistence dir.
if (harness) await harness.dispose()
harness = undefined
@@ -33,7 +33,7 @@ describe('acp bridge', () => {
expect(res.protocolVersion).toBe(PROTOCOL_VERSION)
expect(res.agentCapabilities?.loadSession).toBe(true)
expect(res.agentCapabilities?.promptCapabilities).toMatchObject({ image: false, audio: false })
expect(res.agentInfo?.name).toBe('deepseek-harness-acp')
expect(res.agentInfo).toEqual({ name: 'deepseek-harness-acp', version: '0.0.1' })
})
it('session/new creates a session and a full prompt turn streams text then settles end_turn', async () => {
@@ -325,14 +325,13 @@ describe('acp bridge', () => {
await expect(harness.client.authenticate({ methodId: 'whatever' })).resolves.toBeDefined()
})
it('honors agentName/agentVersion/systemPrompt config', async () => {
it('honors systemPrompt config', async () => {
harness = await makeBridgeHarness({
storageDir,
script: [textResponse('ok')],
config: { agentName: 'custom-agent', agentVersion: '9.9.9', systemPrompt: 'be terse' },
config: { systemPrompt: 'be terse' },
})
const res = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
expect(res.agentInfo).toMatchObject({ name: 'custom-agent', version: '9.9.9' })
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
// Create + prompt so the systemPrompt config flows through agentOptions and
// reaches the model request.
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
import {
@@ -16,6 +17,7 @@ describe('turnEndToStopReason', () => {
expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens')
expect(turnEndToStopReason({ kind: 'aborted', reason: 'x' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'rejected', reason: 'blocked by hook' })).toBe('cancelled')
expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn')
})
@@ -32,9 +34,9 @@ describe('harnessBlockToAcpContent', () => {
expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' })
})
it('returns undefined for non-text blocks (reasoning/tool/image)', () => {
it('returns undefined for non-text blocks (reasoning / plugin-added)', () => {
expect(harnessBlockToAcpContent({ type: 'reasoning', text: 'think' })).toBeUndefined()
expect(harnessBlockToAcpContent({ type: 'image', url: 'https://x/y.png', mimeType: 'image/png' })).toBeUndefined()
expect(harnessBlockToAcpContent({ type: 'chart', data: 'x' } as unknown as ContentBlock)).toBeUndefined()
})
})

View File

@@ -166,7 +166,7 @@ export async function makeBridgeHarness(options: {
* Plug the REAL `dsh-bash-local` executor + `dsh-tool-bash` tools (instead of
* a test's own inline tool). Lets a test drive the actual `bash` tool — its
* real `presentCall`/`presentResult` — through the bridge, so tool-call UI
* tests verify the SHIPPING tool, not a stand-in (AGENTS.md "prefer the real
* tests verify the SHIPPING tool, not a stand-in (docs/testing.md "prefer the real
* implementation over a mock in tests").
*/
withBash?: boolean

View File

@@ -62,7 +62,7 @@ describe('acp bridge — session/load replay', () => {
// bridge. The replayed tool_call/tool_call_update must carry the tool's OWN
// presentation — identical to how it streamed live — via a throwaway
// presenter that pairs call→result as the log replays in order. Uses the
// shipping tool (withBash), not a stand-in (AGENTS.md "prefer the real
// shipping tool (withBash), not a stand-in (docs/testing.md "prefer the real
// implementation over a mock in tests").
live = await makeBridgeHarness({
storageDir,

View File

@@ -50,32 +50,34 @@ describe('streamSessionEventUpdate', () => {
.toEqual([])
})
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput (generic fallback, no presenter)', () => {
it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => {
const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }))
expect(updates).toEqual([{
sessionUpdate: 'tool_call',
toolCallId: 'c1',
title: 'bash',
kind: 'execute',
// The fallback never sniffs a kind from the tool name — even a name a
// first-party tool uses (`bash`) renders `other`; kinds are tool-owned
// via presentCall.
kind: 'other',
status: 'in_progress',
rawInput: { command: 'ls' },
}])
})
it('infers tool kinds: read*/write*/edit*/other', () => {
const kind = (name: string): unknown =>
updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c'), name, arguments: '' }))[0]
expect((kind('read_file') as { kind: string }).kind).toBe('read')
expect((kind('write') as { kind: string }).kind).toBe('edit')
expect((kind('edit_file') as { kind: string }).kind).toBe('edit')
expect((kind('frobnicate') as { kind: string }).kind).toBe('other')
})
it('falls back to the raw argument string when tool arguments are not JSON', () => {
const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: 'not json' }))[0]
expect((update as { rawInput: unknown }).rawInput).toBe('not json')
})
it('parses EMPTY tool arguments to an empty-object rawInput (a zero-arg call, not the raw-string fallback)', () => {
// `JSON.parse('')` throws, so without the empty-string guard a zero-arg
// call would render `rawInput: ''` via the non-JSON fallback; the guard
// normalizes it to `{}`.
const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'noop', arguments: '' }))[0]
expect((update as { rawInput: unknown }).rawInput).toEqual({})
})
it('maps tool/result to completed/failed tool_call_update with text content', () => {
const ok = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }))
expect(ok).toEqual([{
@@ -91,7 +93,7 @@ describe('streamSessionEventUpdate', () => {
it('drops non-text tool-result content (text-only)', () => {
const update = updatesFor(evt('tool/result', {
turn: 1, step: 1, callId: CallId('c1'),
content: [{ type: 'image', url: 'https://x/y.png' }],
content: [{ type: 'reasoning', text: 'private' }],
isError: false,
}))[0]
expect((update as { content: unknown[] }).content).toEqual([])
@@ -163,7 +165,7 @@ describe('todosToPlan', () => {
})
describe('ToolPresenter (tool-owned presentation via the tool registry)', () => {
/** A tool whose presentCall/presentResult mirror what tool-bash declares. */
/** A tool whose presentCall/presentResult return generic-card views. */
const bashLike: ToolDefinition = {
name: 'bash',
description: 'run a command',
@@ -171,9 +173,10 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
execute: async () => [],
presentCall: (args: unknown) => {
const a = args as { command: string; description: string }
return { title: a.description, kind: 'execute', rawInput: a.command }
return { card: 'generic', title: a.description, kind: 'execute', rawInput: a.command }
},
presentResult: (_args: unknown, result: { content: { type: string }[] }) => ({
card: 'generic',
content: [{ type: 'text', text: `wrapped:${result.content.length}` }],
}),
}
@@ -247,8 +250,8 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
description: 'm',
parameters: {},
execute: async () => [],
presentCall: () => ({ title: 'Doing a thing' }),
presentResult: () => ({ title: 'Did the thing' }),
presentCall: () => ({ card: 'generic', title: 'Doing a thing' }),
presentResult: () => ({ card: 'generic', title: 'Did the thing' }),
}
const presenter = new ToolPresenter(registryOf(minimal))
const updates = updatesWith(
@@ -286,7 +289,7 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => {
// A buggy tool whose display callbacks throw must NOT fail a live turn or a
// session/load replay (AGENTS.md "contain callback exceptions at the
// session/load replay (docs/defensive-patterns.md "contain callback exceptions at the
// boundary"). The presenter swallows the throw, reports via onError, and
// falls back to the generic presentation.
const boom: ToolDefinition = {
@@ -336,11 +339,50 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
})
it('forwards a tool-owned `locations` onto the wire tool_call (REAL fs read/edit tools)', async () => {
it('an unknown render-intent card throws via the exhaustiveness guard (closed union)', () => {
// The bridge switches on `view.card` and ends with assertNever: a rogue card
// (only reachable by a cast — the union is closed) must throw, so adding a
// real variant later fails to compile at the switch instead of silently
// dropping the card.
const rogue: ToolDefinition = {
name: 'rogue',
description: 'r',
parameters: {},
execute: async () => [],
// A card value outside the union — forced with a cast (no valid input reaches this).
presentCall: () => ({ card: 'chart', title: 'nope' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
}
const presenter = new ToolPresenter(registryOf(rogue))
expect(() => updatesWith(presenter, evt('tool/call', {
turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}',
}))).toThrow('unreachable variant')
})
it('an unknown render-intent RESULT card throws via the exhaustiveness guard (closed union)', () => {
// The result-side renderer is also an exhaustive switch + assertNever: a rogue
// result card (only reachable by a cast) must throw, so adding a real result
// variant later fails to compile at the switch.
const rogue: ToolDefinition = {
name: 'rogue',
description: 'r',
parameters: {},
execute: async () => [],
presentCall: () => ({ card: 'generic', title: 'r' }),
presentResult: () => ({ card: 'chart' }) as unknown as ReturnType<NonNullable<ToolDefinition['presentResult']>>,
}
const presenter = new ToolPresenter(registryOf(rogue))
expect(() => updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'rogue', arguments: '{}' }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }),
)).toThrow('unreachable variant')
})
it('forwards fs-tool render intents onto the wire (REAL read → generic locations, edit → diff content)', async () => {
// Use the SHIPPING fs tools (not a stand-in), booted through their real
// plugins, so the wire tool_call carries the actual presentCall output —
// including `locations` for editor follow-along. (AGENTS.md "prefer the real
// implementation over a mock".)
// read's follow-along `locations` and edit's `diff` content block. (docs/testing.md
// "prefer the real implementation over a mock".)
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
@@ -352,44 +394,50 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
turn: 1, step: 1, callId: CallId('r1'), name: 'read',
arguments: JSON.stringify({ file_path: 'src/a.ts', offset: 12 }),
}))
// A generic card: the read window is in the title, the offset drives the
// follow-along location line. No rawInput (the window lives in the title).
expect(readCall).toMatchObject({
sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts', kind: 'read',
rawInput: 'offset 12', locations: [{ path: 'src/a.ts', line: 12 }],
sessionUpdate: 'tool_call', toolCallId: 'r1', title: 'Read src/a.ts (from line 12)', kind: 'read',
locations: [{ path: 'src/a.ts', line: 12 }],
})
expect((readCall as { rawInput?: unknown }).rawInput).toBeUndefined()
const [editCall] = updatesWith(presenter, evt('tool/call', {
turn: 1, step: 1, callId: CallId('e1'), name: 'edit',
arguments: JSON.stringify({ file_path: 'src/b.ts', old_string: 'x', new_string: 'y' }),
}))
// A diff card: `edit` kind, a `{ type: 'diff' }` content block carrying the
// literal old→new replacement, plus the follow-along location.
expect(editCall).toMatchObject({
sessionUpdate: 'tool_call', toolCallId: 'e1', title: 'Edit src/b.ts', kind: 'edit',
locations: [{ path: 'src/b.ts' }],
content: [{ type: 'diff', path: 'src/b.ts', oldText: 'x', newText: 'y' }],
})
await ctx.fiber.dispose()
})
})
describe('terminal-card mapping (capability-gated)', () => {
// A tool that asks to render as a terminal — a stand-in for tool-bash's shape,
// letting us drive the bridge's terminal mapping without the real executor.
type CallTerm = { cwd?: string } | undefined
type ResultTerm = { output?: string; exitCode?: number; signal?: string } | undefined
const termTool = (callTerminal: CallTerm, resultTerminal: ResultTerm): ToolDefinition => ({
// A tool that renders as a terminal — a stand-in for tool-bash's shape, letting
// us drive the bridge's terminal mapping without the real executor. `callCard`
// selects a terminal call view (optionally with a cwd) or a generic one (for the
// orphan-guard test); `resultTerminal` is the terminal result view's output/exit.
type CallCard = { card: 'terminal'; cwd?: string } | { card: 'generic' }
type ResultTerm = { title?: string; output?: string; exitCode?: number; signal?: string }
const termTool = (callCard: CallCard, resultTerminal: ResultTerm): ToolDefinition => ({
name: 'bash',
description: 'run a command',
parameters: {},
execute: async () => [],
presentCall: (args: unknown) => ({
title: (args as { command: string }).command,
kind: 'execute',
rawInput: (args as { command: string }).command,
content: [{ type: 'text', text: (args as { description: string }).description }],
...callTerminal !== undefined ? { terminal: callTerminal } : {},
}),
presentResult: () => ({
content: [{ type: 'text', text: 'fallback' }],
...resultTerminal !== undefined ? { terminal: resultTerminal } : {},
}),
presentCall: (args: unknown) => {
const command = (args as { command: string }).command
const description = (args as { description: string }).description
if (callCard.card === 'terminal') {
return { card: 'terminal', title: command, description, ...callCard.cwd !== undefined ? { cwd: callCard.cwd } : {} }
}
return { card: 'generic', title: command, kind: 'execute', rawInput: command, content: [{ type: 'text', text: description }] }
},
presentResult: () => ({ card: 'terminal', ...resultTerminal }),
})
const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
@@ -403,7 +451,7 @@ describe('terminal-card mapping (capability-gated)', () => {
}
it('capability ON: description content THEN terminal block; cwd from the session header when the tool gives none', () => {
const [call, update] = termUpdates(termTool({}, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent)
const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent)
expect(call).toMatchObject({
sessionUpdate: 'tool_call',
content: [
@@ -422,33 +470,33 @@ describe('terminal-card mapping (capability-gated)', () => {
})
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
const [absCall] = termUpdates(termTool({ cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
const [absCall] = termUpdates(termTool({ card: 'terminal', cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
const [relCall] = termUpdates(termTool({ cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent)
const [relCall] = termUpdates(termTool({ card: 'terminal', cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent)
// Relative workdir resolved against the session cwd — the card header matches
// where execution actually ran (tool-bash resolves the same way).
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir')
// No session cwd to resolve against → the relative tool cwd is passed through as-is.
const [noSessionCwd] = termUpdates(termTool({ cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent)
const [noSessionCwd] = termUpdates(termTool({ card: 'terminal', cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent)
expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only')
})
it('capability ON: a signal kill maps to terminal_exit.signal', () => {
const [, update] = termUpdates(termTool({}, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent)
const [, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent)
expect((update as unknown as { _meta: { terminal_exit: unknown } })._meta.terminal_exit).toEqual({ terminal_id: 'c1', signal: 'SIGKILL' })
})
it('capability ON: a terminal result with output but NO exit/signal emits terminal_output and NO exit pill', () => {
// A terminal-rendering tool that reports no structured exit (neither exitCode
// nor signal) — the card shows output but no exit pill.
const [, update] = termUpdates(termTool({}, { output: 'partial' }), true, '/w', callEvent, resultEvent)
const [, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'partial' }), true, '/w', callEvent, resultEvent)
const meta = (update as unknown as { _meta: { terminal_output?: unknown; terminal_exit?: unknown } })._meta
expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'partial' })
expect(meta.terminal_exit).toBeUndefined()
})
it('capability OFF: no terminal block or _meta; the description content and fenced result still render', () => {
const [call, update] = termUpdates(termTool({}, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent)
it('capability OFF: no terminal block or _meta; the description content and the bridge-derived fenced result render', () => {
const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent)
expect(call).toEqual({
sessionUpdate: 'tool_call',
toolCallId: 'c1',
@@ -458,24 +506,311 @@ describe('terminal-card mapping (capability-gated)', () => {
rawInput: 'echo hi',
content: [{ type: 'content', content: { type: 'text', text: 'Greet' } }],
})
// The bridge derives the fenced ```console fallback from the terminal output.
expect(update).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'fallback' } }],
content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }],
})
})
it('orphan guard: a result-side terminal with NO call-side terminal is dropped (no orphan terminal_output)', () => {
// presentCall declares NO terminal, but presentResult returns one — the
// bridge must not emit _meta.terminal_output for a terminal Zed never made.
const [call, update] = termUpdates(termTool(undefined, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent)
// The call had no terminal → ordinary tool_call (description content, no _meta).
it('orphan guard: a result-side terminal with a GENERIC call is dropped (no orphan terminal_output)', () => {
// presentCall is a generic card, but presentResult returns a terminal view —
// the bridge must not emit _meta.terminal_output for a terminal Zed never made.
const [call, update] = termUpdates(termTool({ card: 'generic' }, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent)
// The call was generic → ordinary tool_call (description content, no _meta).
expect((call as { _meta?: unknown })._meta).toBeUndefined()
expect((call as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'Greet' } }])
// The result falls back to text content; NO terminal _meta.
// The result falls back to the RAW result content (the tool/result event's text); NO terminal _meta.
expect((update as { _meta?: unknown })._meta).toBeUndefined()
expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'fallback' } }])
expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'hi\n' } }])
})
it('capability ON: a terminal result title replaces the completed-card title; missing output emits empty data', () => {
// A terminal result MAY carry a replacement title and MAY omit output (a run
// that produced nothing) — the _meta carries empty data, not a dropped key.
const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', exitCode: 0 }), true, '/w', callEvent, resultEvent)
expect(update).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
title: 'Ran echo',
_meta: { terminal_output: { terminal_id: 'c1', data: '' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } },
})
})
it('capability OFF: a terminal result title rides on the fenced fallback update', () => {
const [, update] = termUpdates(termTool({ card: 'terminal' }, { title: 'Ran echo', output: 'hi\n' }), false, '/w', callEvent, resultEvent)
expect(update).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: '```console\nhi\n```' } }],
title: 'Ran echo',
})
})
it('a terminal call with NO description and NO capability is a bare execute card (no content key)', () => {
// A terminal view whose presentCall omits `description`, with the capability
// OFF: no description block and no terminal block → the card carries no content.
const noDesc: ToolDefinition = {
name: 'bash',
description: 'run a command',
parameters: {},
execute: async () => [],
presentCall: (args: unknown) => ({ card: 'terminal', title: (args as { command: string }).command }),
}
const [call] = termUpdates(noDesc, false, undefined, callEvent)
expect(call).toEqual({
sessionUpdate: 'tool_call',
toolCallId: 'c1',
title: 'echo hi',
kind: 'execute',
status: 'in_progress',
rawInput: 'echo hi',
})
})
})
describe('diff-card mapping', () => {
// A stand-in diff tool, letting us drive the bridge's diff arm across shapes
// the shipping fs tools don't emit (no locations, empty diffs).
const diffTool = (view: unknown): ToolDefinition => ({
name: 'writer',
description: 'writes a file',
parameters: {},
execute: async () => [],
presentCall: () => view as ReturnType<NonNullable<ToolDefinition['presentCall']>>,
})
function callUpdate(tool: ToolDefinition, cwd: string | undefined): SessionNotification['update'] {
const presenter = new ToolPresenter(registryOf(tool))
const out: SessionNotification['update'][] = []
streamSessionEventUpdate(
SessionId('s1'),
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'writer', arguments: '{}' }),
n => out.push(n.update),
presenter,
{ enabled: false, cwd },
)
return out[0]!
}
it('a diff with NO locations relativizes the title off the first diff path; omits the locations key', () => {
const update = callUpdate(diffTool({ card: 'diff', title: 'Write /work/proj/a.txt', diffs: [{ path: '/work/proj/a.txt', oldText: null, newText: 'x' }] }), '/work/proj')
expect(update).toEqual({
sessionUpdate: 'tool_call',
toolCallId: 'c1',
title: 'Write a.txt',
kind: 'edit',
status: 'in_progress',
content: [{ type: 'diff', path: '/work/proj/a.txt', oldText: null, newText: 'x' }],
})
})
it('a diff with an EMPTY diffs array omits the content key (no diff blocks to send)', () => {
const update = callUpdate(diffTool({ card: 'diff', title: 'Write nothing', diffs: [] }), undefined)
expect(update).toEqual({
sessionUpdate: 'tool_call',
toolCallId: 'c1',
title: 'Write nothing',
kind: 'edit',
status: 'in_progress',
})
})
})
describe('result-time diff card (REAL fs edit tool → tool_call_update diff blocks)', () => {
// Drive the SHIPPING fs edit tool through the bridge: the pending tool/call
// installs the call-time snippet, then the tool/result carries the tool's
// computed applied-hunk `meta`, which presentResult narrows into a `diff`
// result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses
// the REAL tool (not a stand-in) per the anti-mock convention, mirroring the
// call-side diff test above.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter)
return out
}
it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => {
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' })
// The applied hunk the tool would compute and persist on the result meta.
const meta = { diffs: [{ path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
const [, resultUpdate] = updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
)
expect(resultUpdate).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'e1',
status: 'completed',
title: 'Edit src/b.ts',
content: [{ type: 'diff', path: 'src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
})
await ctx.fiber.dispose()
})
it('an error result carries NO diff card (falls back to raw content)', async () => {
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: 'src/b.ts', old_string: 'OLD', new_string: 'NEW' })
const [, resultUpdate] = updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'Error: boom' }], isError: true }),
)
expect(resultUpdate).toMatchObject({ sessionUpdate: 'tool_call_update', status: 'failed' })
expect(resultUpdate).not.toHaveProperty('content', expect.arrayContaining([expect.objectContaining({ type: 'diff' })]))
await ctx.fiber.dispose()
})
it('the completed diff TITLE relativizes against the session cwd (the result title replaces the card header)', async () => {
// A `tool_call_update.title` replaces the card header, so the result-side
// diff must relativize its title exactly as the pending card did — otherwise
// a completed absolute-path edit flips `Edit src/b.ts` back to the raw
// absolute path. The diff/location paths stay absolute (the editor opens the
// real path). Drive the REAL fs edit tool with an absolute in-workspace path.
const ctx = await fsCtx()
const presenter = new ToolPresenter(ctx.tools)
const args = JSON.stringify({ file_path: '/work/proj/src/b.ts', old_string: 'OLD', new_string: 'NEW' })
const meta = { diffs: [{ path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
const out: SessionNotification['update'][] = []
const rendering = { enabled: false, cwd: '/work/proj' }
for (const event of [
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'text', text: 'ok' }], isError: false, meta }),
]) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, rendering)
expect(out[1]).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'e1',
status: 'completed',
title: 'Edit src/b.ts',
content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }],
})
await ctx.fiber.dispose()
})
it('a diff result with an EMPTY diffs array and no title omits both keys (nothing to send)', () => {
// A synthetic tool whose presentResult yields a `diff` card with no hunks and
// no title — the shipping fs tools never emit this (edit always has a hunk;
// write always falls back to a whole-file diff), so a stand-in is the only way
// to exercise the empty-content AND absent-title branches of the result-side
// diff arm.
const emptyDiffTool: ToolDefinition = {
name: 'writer',
description: 'writes a file',
parameters: {},
execute: async () => [],
presentCall: () => ({ card: 'diff', title: 'Write x', diffs: [{ path: 'x', oldText: null, newText: 'y' }] }),
presentResult: () => ({ card: 'diff', diffs: [] }),
}
const presenter = new ToolPresenter(registryOf(emptyDiffTool))
const [, resultUpdate] = updatesWith(
presenter,
evt('tool/call', { turn: 1, step: 1, callId: CallId('w1'), name: 'writer', arguments: '{}' }),
evt('tool/result', { turn: 1, step: 1, callId: CallId('w1'), content: [{ type: 'text', text: 'ok' }], isError: false }),
)
expect(resultUpdate).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'w1',
status: 'completed',
})
expect(resultUpdate).not.toHaveProperty('content')
expect(resultUpdate).not.toHaveProperty('title')
})
})
describe('relative-path display titles (bridge relativizes the title against the session cwd)', () => {
// The bridge relativizes a file card's TITLE against the session workspace cwd
// (mirroring the reference adapter's toDisplayPath), while leaving locations/
// diff paths RAW. Drive it with the REAL fs tools so the title/locations come
// from the shipping presentCall, and pass an ABSOLUTE file path (which a real
// editor forwards). The presenter is pure/args-only; the cwd is known only here.
async function fsCtx(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FsLocal)
await ctx.plugin(ToolFs)
return ctx
}
function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] {
const presenter = new ToolPresenter(ctx.tools)
const out: SessionNotification['update'][] = []
streamSessionEventUpdate(
SessionId('s1'),
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name, arguments: JSON.stringify(args) }),
n => out.push(n.update),
presenter,
{ enabled: false, cwd: sessionCwd },
)
return out[0]!
}
it('read: an absolute path inside the workspace relativizes the TITLE; the location path stays absolute', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/src/a.ts', offset: 5 })
expect(update).toMatchObject({
title: 'Read src/a.ts (from line 5)',
locations: [{ path: '/work/proj/src/a.ts', line: 5 }],
})
await ctx.fiber.dispose()
})
it('edit: the diff TITLE relativizes; the diff/location paths stay absolute (the editor opens the real path)', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'edit', { file_path: '/work/proj/src/b.ts', old_string: 'x', new_string: 'y' })
expect(update).toMatchObject({
title: 'Edit src/b.ts',
locations: [{ path: '/work/proj/src/b.ts' }],
content: [{ type: 'diff', path: '/work/proj/src/b.ts', oldText: 'x', newText: 'y' }],
})
await ctx.fiber.dispose()
})
it('a path OUTSIDE the workspace is left as-is (no `..` title)', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/etc/passwd' })
expect((update as { title: string }).title).toBe('Read /etc/passwd')
await ctx.fiber.dispose()
})
it('an in-workspace file whose relative form starts with `..` chars (a sibling name) still relativizes', async () => {
// `/work/proj/..cache/x` is INSIDE the workspace — its relative form
// `..cache/x` begins with the chars `..` but is NOT a parent segment. The
// guard tests for a `..` SEGMENT, so this relativizes (matching the reference
// adapter, which accepts any target under `cwd + sep`).
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: '/work/proj/..cache/x.ts' })
expect((update as { title: string }).title).toBe('Read ..cache/x.ts')
await ctx.fiber.dispose()
})
it('no session cwd → the absolute title is left unchanged', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, undefined, 'read', { file_path: '/work/proj/src/a.ts' })
expect((update as { title: string }).title).toBe('Read /work/proj/src/a.ts')
await ctx.fiber.dispose()
})
it('a relative path is passed through unchanged (already display-friendly)', async () => {
const ctx = await fsCtx()
const update = callUpdate(ctx, '/work/proj', 'read', { file_path: 'src/a.ts' })
expect((update as { title: string }).title).toBe('Read src/a.ts')
await ctx.fiber.dispose()
})
})

View File

@@ -66,7 +66,10 @@ describe('acp bridge — turn outcomes', () => {
const toolCalls = harness.updates.filter(u => u.sessionUpdate === 'tool_call')
const toolUpdates = harness.updates.filter(u => u.sessionUpdate === 'tool_call_update')
expect(toolCalls).toHaveLength(1)
expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'execute', status: 'in_progress' })
// The inline stand-in declares no presentCall, so the generic fallback
// renders kind `other` (kinds are tool-owned; the bridge never sniffs the
// name — the REAL dsh-tool-bash test below covers the execute card).
expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'other', status: 'in_progress' })
expect(toolUpdates).toHaveLength(1)
expect(toolUpdates[0]).toMatchObject({ toolCallId: 'c1', status: 'completed' })
@@ -79,7 +82,7 @@ describe('acp bridge — turn outcomes', () => {
it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => {
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline
// stand-in, so this verifies the actual presentCall/presentResult the editor
// sees (AGENTS.md "prefer the real implementation over a mock in tests").
// sees (docs/testing.md "prefer the real implementation over a mock").
// The mock MODEL still scripts the tool call (no real LLM needed), but the
// tool and executor are real: a real `echo` runs and its real output flows
// back through the bridge.

View File

@@ -0,0 +1,15 @@
# `@deepseek-ai/dsh-app-boot`
Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md), [`dsh-acp-agent`](../acp-agent/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts.
| Export | Role |
|---|---|
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
| `boot(binName, absoluteConfigPath)` | Mount the Loader, include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context |
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection.
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the cordis Loader's internal module loader, active only under `node --expose-internals`; the bins' subprocess smokes exercise that path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.

View File

@@ -0,0 +1,34 @@
{
"name": "@deepseek-ai/dsh-app-boot",
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
"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": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,154 @@
/**
* Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load
* the gitignored `.env`, install the fail-loud Loader guards, resolve the
* config path (snapshot-aware), and drive the cordis Loader against a leaf
* `cordis.yml` until the whole tree has settled. Each bin stays a thin
* self-executing composition over these helpers, parameterized by its
* diagnostic prefix; the loader-failure lore lives here, once, under the
* per-file coverage gate.
*
* Two failure classes the guards handle:
*
* - `loader.await()` does NOT rethrow a load error (`EntryTree.await()` uses
* `Promise.allSettled`, which swallows rejections). A plugin whose
* `[Service.init]` throws surfaces as an unhandled rejection AFTER `boot()`
* resolves — Node's default handler already exits non-zero, and
* {@link installFailLoud} replaces the noisy dump with one labelled stderr
* line and a guaranteed `exit(1)`.
* - A plugin module that fails to IMPORT is caught and only LOGGED by the
* cordis Loader (`entry._init`), leaving the entry with no `fiber` and
* producing no rejection — the process would otherwise exit 0 with a usable
* config typo reported only as a log line; {@link assertEntriesLoaded} makes
* `boot()` reject on any such entry instead of returning a half-empty
* context.
*
* @module @deepseek-ai/dsh-app-boot
*/
import { pathToFileURL } from 'node:url'
import { basename, dirname, resolve } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
/**
* Resolve the config to boot, honoring snapshot REPLAY. Given the requested
* path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in
* the SAME directory (the keyless replay tree). Other modes — including no
* snapshot mode at all — use the path as-is. Returns an absolute path resolved
* from `cwd`.
*/
export function resolveConfigPath(
configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
): string {
const absolute = resolve(cwd, configPath)
if (snapshotMode !== 'replay') return absolute
const dir = dirname(absolute)
const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
return resolve(dir, replayName)
}
/**
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in
* `dir` (Node native `process.loadEnvFile`). An absent file is fine — the
* environment may already carry the variables; the leaf `cordis.yml` reads
* them via the `!!js` tag. A present-but-unreadable `.env` is a real
* misconfiguration: surface it via `warn` (one line, default stderr) rather
* than silently running with the wrong environment.
*/
export function loadEnv(
binName: string, dir: string = process.cwd(),
warn: (line: string) => void = line => void process.stderr.write(line),
): void {
try {
process.loadEnvFile(resolve(dir, '.env'))
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
warn(`${binName}: failed to load .env: ${String(error)}\n`)
}
// ENOENT (no .env) is fine — rely on the ambient environment.
}
}
/**
* The slice of `process` {@link installFailLoud} needs — injectable so tests
* exercise the handler without registering on (or exiting) the real process.
*/
export interface FailLoudProcess {
on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
stderr: { write(chunk: string): unknown }
exit(code: number): void
}
/**
* Make a load failure fail loud with a clear message on stderr. Covers the
* failure path {@link assertEntriesLoaded} cannot: an include whose
* `[Service.init]` throws (e.g. a config FILE that does not exist in a real
* directory) surfaces as an unhandled promise rejection AFTER `boot()`
* resolves. Node's default handler already exits non-zero on an unhandled
* rejection; this replaces the noisy stack dump with a single labelled line on
* STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and
* guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller
* (tests use it; the bins run until exit and never do).
*/
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
const handler = (err: unknown): void => {
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
proc.exit(1)
}
proc.on('unhandledRejection', handler)
return () => void proc.off('unhandledRejection', handler)
}
/**
* After the tree settles, assert every loader entry actually started. A
* started entry has a `fiber`; an entry with `fiber === undefined` after the
* tree settled never loaded (its module failed to import), so throw and let
* `boot()` reject instead of returning a half-empty context. A `disabled`
* entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately
* skips `init()` for it — a valid "plugin turned off" config, not a failed
* import — so it is excluded.
*/
export function assertEntriesLoaded(ctx: Context, binName: string): void {
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
if (failed.length > 0) {
const names = failed.map(entry => entry.options.name).join(', ')
throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
}
}
/**
* Boot the Loader against `absoluteConfigPath` and return the root context
* once the whole tree has settled. The include is handed the config's ABSOLUTE
* `file://` URL as its `path`, so resolution never depends on `ctx.baseUrl`
* (an absolute URL ignores the base) and can never fall back to the cwd;
* `baseUrl` is still pinned to the config's directory so the config's OWN
* relative plugin/include paths resolve against it.
*
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns
* once the include ENTRY is registered, but the include then loads its child
* plugins asynchronously — without awaiting the tree, `boot()` would resolve
* while the app's plugins are still mounting, and a CLI process with no
* attached handles yet exits 0 silently. Failures surface two ways: an entry
* whose module failed to import is caught here by {@link assertEntriesLoaded}
* (this `boot()` rejects); an init that THROWS surfaces as an unhandled
* rejection caught by {@link installFailLoud} (installed by the bin first).
*
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages)
* are resolved by the cordis Loader's internal module loader, which is only
* active under `node --expose-internals`; a consumer running a built bin must
* pass that flag (or install the plugins where node hoists them). Relative
* specifiers resolve against the config directory with no flag.
*/
export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
await ctx.plugin(Loader)
await ctx.loader.create({
name: '@cordisjs/plugin-include',
config: { path: pathToFileURL(absoluteConfigPath).href },
})
await ctx.loader.await()
assertEntriesLoaded(ctx, binName)
return ctx
}

View File

@@ -0,0 +1,178 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import {
assertEntriesLoaded, boot, installFailLoud, loadEnv, resolveConfigPath,
type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
describe('resolveConfigPath', () => {
it('resolves relative to the given cwd outside replay mode', () => {
expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
})
it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
})
it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
})
})
describe('loadEnv', () => {
it('loads variables from .env in the given dir', () => {
const dir = tmp()
writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
const warn = vi.fn()
loadEnv(NAME, dir, warn)
expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
expect(warn).not.toHaveBeenCalled()
delete process.env['DSH_APP_BOOT_SPEC_VAR']
})
it('stays silent when no .env exists (ambient environment wins)', () => {
const warn = vi.fn()
loadEnv(NAME, tmp(), warn)
expect(warn).not.toHaveBeenCalled()
})
it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
const dir = tmp()
mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
const warn = vi.fn()
loadEnv(NAME, dir, warn)
expect(warn).toHaveBeenCalledTimes(1)
expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
})
it('defaults dir to the process cwd and warn to a stderr write', () => {
const dir = tmp()
writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
const previous = process.cwd()
process.chdir(dir)
try {
loadEnv(NAME) // happy path: the default warn sink is never invoked
} finally {
process.chdir(previous)
}
expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
// The default warn sink itself: point it at a broken .env with stderr
// spied, so the arrow body runs without polluting the test output.
const broken = tmp()
mkdirSync(join(broken, '.env'))
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
let written: string[]
try {
loadEnv(NAME, broken)
written = write.mock.calls.map(call => String(call[0]))
} finally {
write.mockRestore()
}
expect(written).toHaveLength(1)
expect(written[0]).toContain(`${NAME}: failed to load .env: `)
})
})
describe('installFailLoud', () => {
function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
const handlers: Array<(err: unknown) => void> = []
const written: string[] = []
const exits: number[] = []
return {
handlers, written, exits,
on: (_event, handler) => { handlers.push(handler) },
off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
stderr: { write: (chunk: string) => { written.push(chunk) } },
exit: (code: number) => { exits.push(code) },
}
}
it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
const error = new Error('boom')
proc.handlers[0]!(error)
expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
expect(proc.written[0]).toContain(error.stack)
expect(proc.exits).toEqual([1])
})
it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
const proc = fakeProc()
installFailLoud(NAME, proc)
proc.handlers[0]!('plain failure')
expect(proc.written[0]).toContain('plain failure')
const stackless = new Error('no stack')
delete (stackless as { stack?: string }).stack
proc.handlers[0]!(stackless)
expect(proc.written[1]).toContain('no stack')
expect(proc.exits).toEqual([1, 1])
})
it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
const proc = fakeProc()
const uninstall = installFailLoud(NAME, proc)
expect(proc.handlers).toHaveLength(1)
uninstall()
expect(proc.handlers).toHaveLength(0)
// Default-proc arm: install on the real process, then immediately uninstall
// so the suite leaks no handler and can never exit the runner.
const before = process.listenerCount('unhandledRejection')
const uninstallReal = installFailLoud(NAME)
expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
uninstallReal()
expect(process.listenerCount('unhandledRejection')).toBe(before)
})
})
describe('assertEntriesLoaded', () => {
const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
({ loader: { entries: () => entries } }) as unknown as Context
it('passes when every enabled entry has a fiber', () => {
expect(() => { assertEntriesLoaded(ctxWith([
{ fiber: {}, options: { name: 'a' } },
{ disabled: true, options: { name: 'off' } },
]), NAME) }).not.toThrow()
})
it('throws naming every enabled fiber-less entry', () => {
expect(() => { assertEntriesLoaded(ctxWith([
{ fiber: {}, options: { name: 'ok' } },
{ options: { name: 'broken-a' } },
{ options: { name: 'broken-b' } },
]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
})
})
describe('boot', () => {
it('boots a leaf config through the real Loader and settles the tree', async () => {
const dir = tmp()
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
try {
const entries = [...ctx.loader.entries()]
expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
} finally {
await ctx.fiber.dispose()
}
})
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
const dir = tmp()
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
})
})

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../../vendor/include"
}
]
}

View File

@@ -1,7 +1,5 @@
# @deepseek-ai/dsh-stdio-agent
Terminal stdio chat app. It composes the agent-core spine with JSONL persistence, the readline stdio UI, the user-interaction seam, and the `ask_user_question` tool so the demo/coding front door can pause for human confirmation.
The **terminal stdio chat app**: a Cordis app plugin that composes the providerless agent spine ([`@deepseek-ai/dsh-agent-core`](../../core/agent-core/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`.
It is the readline counterpart to [`@deepseek-ai/dsh-acp-agent`](../acp-agent/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster.
@@ -17,9 +15,9 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
| `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent, and the user-interaction provider |
| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent |
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:coding` leaves load it and pass `--expose-internals`.
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-core`, `hmr`, and the two leaf backends.
@@ -35,12 +33,12 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte
## The bin
`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:coding` scripts invoke it that way.
`dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`: the cordis Loader resolves the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages) through its internal module loader, which is only active under that flag. The `demo:echo` / `demo:repl` scripts invoke it that way.
## Example leaf `cordis.yml`
```yaml
# A real coding agent: hmr + the DeepSeek adapter + local bash, then this app.
# A REPL agent demo: hmr + the DeepSeek adapter + local bash, then this app.
- id: hmr
name: '@cordisjs/plugin-hmr'
config:

View File

@@ -32,13 +32,14 @@
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@cordisjs/plugin-logger-console": "^1.0.0",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
"@deepseek-ai/dsh-ui-stdio": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
@@ -46,13 +47,14 @@
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@cordisjs/plugin-logger-console": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-ui-stdio": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"

View File

@@ -2,139 +2,24 @@
/**
* The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that
* loads the {@link @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM
* adapter and a bash executor). Owns the boot glue the three `examples/*` once
* duplicated in their `start.ts`: load the gitignored repo-root `.env`, then
* drive the cordis Loader against the config path (default `./cordis.yml`).
* adapter and a bash executor). The boot glue — `.env` loading, the fail-loud
* Loader guards, the settle-the-tree boot sequence — lives in
* {@link @deepseek-ai/dsh-app-boot}, shared with the ACP bin.
*
* Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:coding`
* scripts invoke it with the example's config.
* Usage: `dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`). The
* `demo:echo` / `demo:repl` scripts invoke it with the example's config.
*
* @module @deepseek-ai/dsh-stdio-agent/bin
*/
import { pathToFileURL } from 'node:url'
import { dirname, resolve } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
/**
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the
* CURRENT WORKING DIRECTORY (Node native `process.loadEnvFile`). An absent file
* is fine — the environment may already carry the variables; the leaf
* `cordis.yml` reads them via the `!!js` tag. A present-but-unreadable/malformed
* `.env` is a real misconfiguration: surface it on stderr rather than silently
* running with the wrong environment. The mock-model demo (echo) ships no key
* and simply has no `.env`.
*/
function loadEnv(): void {
try {
process.loadEnvFile(resolve(process.cwd(), '.env'))
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
process.stderr.write(`dsh-stdio-agent: failed to load .env: ${String(error)}\n`)
}
// ENOENT (no .env) is fine — rely on the ambient environment.
}
}
const NAME = 'dsh-stdio-agent'
/**
* Make a load failure fail loud with a clear message on stderr. Covers the
* failure path the entry-tree check below cannot: when the include's
* `[Service.init]` throws (e.g. a config FILE that does not exist in a real
* directory), the cordis Loader surfaces it as an unhandled promise rejection
* AFTER `boot()` has resolved — `loader.await()` does NOT rethrow it, because
* `EntryTree.await()` uses `Promise.allSettled`, which swallows rejections.
* Node's default handler already exits non-zero on an unhandled rejection, so
* this does not change the exit code; it replaces Node's noisy stack dump with a
* single labelled line and guarantees `process.exit(1)`. Install before `boot()`.
*/
export function installFailLoud(): void {
process.on('unhandledRejection', (err: unknown) => {
process.stderr.write(`dsh-stdio-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
process.exit(1)
})
}
/**
* After the tree settles, assert every loader entry actually started. This is
* the load-bearing guard against the SILENT-exit-0 bug: when a plugin module
* fails to IMPORT (e.g. a config path in a non-existent directory, so the include
* plugin itself cannot be resolved), the cordis Loader catches the import error
* and only LOGS it (`entry._init`), leaving the entry with no `fiber` and
* producing no rejection — so the process would otherwise exit 0 with a usable
* config typo reported only as a log line. A started entry has a `fiber`; an
* entry with `fiber === undefined` after the tree settled never loaded. Throw on
* any such entry so `boot()` rejects (and the top-level `await` fails the process
* non-zero) instead of returning a half-empty context.
*
* A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()`
* deliberately skips `init()` for it, so it settles without a fiber by design.
* That is a valid config (a consumer turning an optional plugin off), not a
* failed import — exclude it so the guard catches only real load failures.
*/
function assertEntriesLoaded(ctx: Context): void {
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
if (failed.length > 0) {
const names = failed.map(entry => entry.options.name).join(', ')
throw new Error(`dsh-stdio-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
}
}
/**
* Boot the Loader against `configPath` (resolved from the CWD). The include is
* handed the config's ABSOLUTE `file://` URL as its `path`, so resolution never
* depends on `ctx.baseUrl` (an absolute URL ignores the base) and can never fall
* back to the cwd. `baseUrl` is still pinned to the config's directory so the
* config's OWN relative plugin/include paths (e.g. `./src/mock-llm.ts`) resolve
* against it. Returns the root context once the whole tree has settled.
*
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
* the include ENTRY is registered, but the include then loads its child plugins
* asynchronously. Without awaiting the tree, `boot()` (and `main()`) would
* resolve while the app plugins — the stdin reader, the agent loop — are still
* mounting, and a CLI process with no attached handles yet exits 0 silently.
* Awaiting the tree keeps the process alive until the app's handles are attached.
*
* `loader.await()` does NOT, however, rethrow load errors (`EntryTree.await()`
* uses `Promise.allSettled`), so failures are surfaced two ways: a plugin that
* fails to IMPORT leaves an entry with no fiber, caught here by
* {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init
* THROWS surfaces as an unhandled rejection caught by {@link installFailLoud}
* (installed by `main()` before this runs). Together they make any load failure
* exit non-zero with a clear message.
*
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
* resolved by the cordis Loader's internal module loader, which is only active
* under `node --expose-internals` (the flag the `demo:echo`/`demo:coding` scripts
* pass). Without it the Loader falls back to resolving relative to its own module
* and cannot find the config's plugins, so a consumer running the built bin must
* pass `--expose-internals` (or install the plugins where node hoists them).
*/
export async function boot(configPath: string): Promise<Context> {
const absolute = resolve(process.cwd(), configPath)
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dirname(absolute)).href + '/'
await ctx.plugin(Loader)
await ctx.loader.create({
name: '@cordisjs/plugin-include',
config: { path: pathToFileURL(absolute).href },
})
await ctx.loader.await()
assertEntriesLoaded(ctx)
return ctx
}
/**
* Entry point: install the fail-loud guard, load `.env`, then boot the config
* named on argv (default `./cordis.yml`). Awaited at the module top level by the
* published bin (`#!/usr/bin/env node` shebang via the package's `bin` field).
*/
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
installFailLoud()
loadEnv()
await boot(argv[0] ?? './cordis.yml')
}
/* v8 ignore start -- top-level CLI invocation; the testable core is boot()/main(), driven by the keyless Loader-path smoke */
await main()
/* v8 ignore start -- thin self-executing composition over the unit-tested
dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and
built-bin smokes */
installFailLoud(NAME)
loadEnv(NAME)
await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined))
/* v8 ignore stop */

View File

@@ -1,12 +1,13 @@
/**
* The stdio chat app: the providerless agent spine ({@link
* @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal
* chat needs — a console logger, the readline `ui-stdio` UI, JSONL session
* chat needs — a console logger, the readline UI (the in-package `stdio-chat`
* module), JSONL session
* persistence, and a pre-created `main` agent the UI drives.
*
* The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the
* console (stdout is just the terminal) and always pre-creates the `main` agent
* `ui-stdio` sends to. The leaf supplies the swappable backends (the LLM
* the readline UI sends to. The leaf supplies the swappable backends (the LLM
* adapter, the bash executor), optional product tools, the optional `hmr`
* dev-reload plugin, and this app's {@link Config} (model, prompt, persistence
* root, welcome banner).
@@ -29,8 +30,10 @@
* Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
* cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
* default would collapse the module to the bare `apply` and drop the `Config`
* namespace (see docs/postmortem/0001). The keyless Loader-path smoke in the
* echo example guards this end-to-end.
* namespace (see docs/postmortem/0001). This app carries no `inject`, so a
* collapsed shape would BOOT rather than crash a smoke — the shape is pinned by
* the explicit `unwrapExports` assertion in this package's unit suite, and the
* keyless echo smoke proves the composed tree runs through the real Loader.
*
* @module @deepseek-ai/dsh-stdio-agent
*/
@@ -44,7 +47,7 @@ import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as uiStdio from '@deepseek-ai/dsh-ui-stdio'
import * as uiStdio from './stdio-chat.ts'
export const name = 'stdio-agent'
@@ -83,7 +86,7 @@ export const Config: z<Config> = z.object({
* Compose the spine with the stdio front door. The console logger comes first
* (infra), then the agent-core bundle pre-creating the `main` agent from this
* app's `model`/`systemPrompt`/`resumeSessionId`, then the JSONL backend, then
* the `ui-stdio` UI bound to `main`. The `hmr` dev-reload plugin is a leaf
* the readline UI bound to `main`. The `hmr` dev-reload plugin is a leaf
* concern (see the module doc), so it is not mounted here.
*/
export function apply(ctx: Context, config: Config): void {

View File

@@ -0,0 +1,363 @@
/**
* The stdio app's readline UI: reads lines from stdin → `agent.send()`/
* `steer()`, and renders the durable transcript to stdout. A UI is "just a
* plugin" — it consumes the `session/event` feed (the assistant token stream,
* turn/step boundaries, tool activity, todos) plus a few `agent/*` control
* events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents`
* service. Dimmed chain-of-thought rendering plus robust piped-stdin EOF→idle
* exit handling, configured via {@link Config}.
*
* An internal module of the stdio app, not a package of its own: the app's
* front-door cluster always includes this UI, and nothing else composes it.
* The export shape stays named `name`/`inject`/`Config`/`apply` — the plugin
* contract the app's `ctx.plugin(uiStdio, …)` mount consumes.
*
* @module @deepseek-ai/dsh-stdio-agent/stdio-chat
*/
import { createInterface } from 'node:readline'
import type { Readable, Writable } from 'node:stream'
import type { Context } from 'cordis'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import {
UserInteractionError,
type AskUserQuestionAnswer,
type AskUserQuestionOption,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
export const name = 'ui-stdio'
export const inject = ['agents', 'userInteraction']
/** Serializable plugin configuration (cordis-native, schemastery). */
export interface Config {
/** Banner printed once on start, before the first `> ` prompt. */
welcome?: string
/** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */
agent?: string
}
export const Config: z<Config> = z.object({
welcome: z.string().default('ready.'),
agent: z.string().default('main'),
})
/**
* Process-I/O seam — the side-effecting handles the plugin would otherwise
* reach for as globals. Defaulted to the real `process` streams in
* {@link apply}; injected by tests so the EOF, render, and disposal branches
* are exercised without hijacking globals. Deliberately NOT part of the
* serializable {@link Config} (streams/functions don't belong in YAML config).
*/
export interface StdioRuntime {
/** Line source (default `process.stdin`). */
input: Readable
/** Render sink (default `process.stdout`). */
output: Writable
/** Process-exit hook (default `process.exit`); called once on stdin EOF. */
exit: (code: number) => void
}
function isTTYPair(input: Readable, output: Writable): boolean {
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
}
function optionAnswer(option: AskUserQuestionOption): string {
return option.value ?? option.label
}
function displayOptions(options: AskUserQuestionOption[] = []): AskUserQuestionOption[] {
return options
.map((option, index) => ({ option, index }))
.sort((left, right) => {
if (left.option.recommended === right.option.recommended) return left.index - right.index
return left.option.recommended ? -1 : 1
})
.map(({ option }) => option)
}
interface PendingQuestion {
request: AskUserQuestionRequest
resolve(answer: AskUserQuestionAnswer): void
reject(error: unknown): void
onAbort: () => void
}
/**
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
* production wrapper that binds the real `process` streams; tests call this
* directly with fakes. Returns nothing — all registration is via `ctx.on`/
* `ctx.effect`, so fiber disposal tears every listener and the readline
* interface down.
*/
export function createStdioChat(ctx: Context, config: Config, runtime: StdioRuntime): void {
// Default here too (not just via schemastery's `.default()`): this helper is
// exported and called directly by tests / programmatic consumers that bypass
// Loader validation, so it must be self-contained rather than trusting the
// cast — `config.welcome as string` would otherwise be `undefined` on `{}`.
const welcome = config.welcome ?? 'ready.'
const agentId = AgentId(config.agent ?? 'main')
const { input, output, exit } = runtime
// Render label lookup: the `turn/start` session event carries only the turn
// number, so to print the short agent id (`[main turn 1]`) we map the
// session's id to its agent's id. The session id is not reliably the agent id
// (a session can be created with an explicit/client-supplied id), so build the
// map from `agent/created` rather than parsing the id string. Seed from the
// registry's current agents first: an agent registered before this plugin
// installed (e.g. the pre-created `main` agent, or any agent surviving an HMR
// reload of just this fiber) already fired its `agent/created`, so the live
// listener alone would miss it and its turns would fall back to the raw
// session id.
const labelBySession = new Map<string, string>()
for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id)
ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) })
ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) })
// Transcript rendering off the durable `session/event` feed — the assistant
// token stream, turn/step boundaries, tool activity, and todos all come from
// the one canonical stream (no agent/* mirrors). A single listener over the
// append order keeps `inReasoning` transitions deterministic across chunk and
// boundary events.
let inReasoning = false
ctx.on('session/event', (session, event) => {
if (event.type === 'assistant/chunk') {
const { chunk } = event.data
if (chunk.type === 'reasoning-delta') {
// Dim the chain-of-thought so the final answer stands out.
if (!inReasoning) output.write('\x1B[2m')
inReasoning = true
output.write(chunk.text)
} else if (chunk.type === 'text-delta') {
if (inReasoning) output.write('\x1B[0m\n')
inReasoning = false
output.write(chunk.text)
}
} else if (event.type === 'turn/start') {
const label = labelBySession.get(session.header.id) ?? session.header.id
output.write(`\n[${label} turn ${event.data.turn}] `)
} else if (event.type === 'turn/end') {
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
output.write('\n> ')
} else if (event.type === 'tool/call') {
const { name: toolName, arguments: args } = event.data
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
output.write(`\n [tool call] ${toolName}(${args})`)
} else if (event.type === 'tool/result') {
const { content } = event.data
const text = content.filter(block => block.type === 'text').map(block => block.text).join('')
output.write(`\n [tool result] ${text}\n `)
} else if (event.type === 'todo/write') {
if (inReasoning) output.write('\x1B[0m')
inReasoning = false
const glyph = (status: string): string =>
status === 'completed' ? '[x]' : status === 'in_progress' ? '[~]' : '[ ]'
const lines = event.data.todos.map(todo => ` ${glyph(todo.status)} ${todo.content}`).join('\n')
output.write(`\n [todos]\n${lines}\n `)
}
})
ctx.effect(() => {
const reader = createInterface({ input, output, terminal: isTTYPair(input, output) })
// Piped-input exit, once stdin reaches EOF:
// - If no line ever submitted work (empty stdin, blank-only lines), exit
// immediately — no turn will ever start, so there is nothing to wait
// for. (Gating on an observed 'running' here would hang forever.)
// - If work WAS submitted, exit the next time the agent settles to idle
// AFTER having run. Two subtleties this handles: the loop batches
// several queued messages into ONE turn (one idle), so we don't count
// sends; and agent.send() does NOT synchronously flip status to
// 'running', so requiring an observed 'running' first (`sawRunning`)
// avoids exiting in the gap before the turn starts and dropping work.
let stdinClosed = false
let disposed = false
let submittedWork = false
let sawRunning = false
let exitTimer: ReturnType<typeof setTimeout> | undefined
let activeQuestion: PendingQuestion | undefined
const questionQueue: PendingQuestion[] = []
const maybeExit = (): void => {
if (disposed || !stdinClosed) return
// No work submitted: nothing will ever run, exit straight away.
// Work submitted: wait until a turn has run and the agent is idle.
if (submittedWork) {
if (!sawRunning) return
const agent = ctx.agents.get(agentId)
if (agent && agent.status !== 'idle') return // a turn is still running
}
// Let any final output flush, then exit. The handle is tracked so the
// disposer can cancel it — a dispose within the flush window must not let
// the process exit out from under HMR. Re-entrant `maybeExit` calls (e.g.
// repeated idle signals) coalesce onto the one pending timer.
if (exitTimer !== undefined) {
return // exit already scheduled — coalesce re-entrant calls
}
exitTimer = setTimeout(() => { exit(0) }, 200)
}
const disposeStatusListener = ctx.on('agent/status', (subject, status) => {
if (subject.id !== agentId) return
if (status === 'running') sawRunning = true
if (status === 'idle') maybeExit()
})
const renderQuestion = (pending: PendingQuestion): void => {
const { request } = pending
output.write('\n')
output.write(request.header ? `[${request.header}] ${request.question}\n` : `[question] ${request.question}\n`)
displayOptions(request.options).forEach((option, index) => {
output.write(` ${index + 1}. ${option.label}${option.recommended ? ' (recommended)' : ''}\n`)
if (option.description) output.write(` ${option.description}\n`)
})
output.write('> ')
}
const removeAbortListener = (pending: PendingQuestion): void => {
pending.request.signal?.removeEventListener('abort', pending.onAbort)
}
const startNextQuestion = (): void => {
if (activeQuestion !== undefined) return
const pending = questionQueue.shift()
if (pending === undefined) return
if (pending.request.signal?.aborted) {
pending.reject(new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
startNextQuestion()
return
}
activeQuestion = pending
pending.request.signal?.addEventListener('abort', pending.onAbort, { once: true })
renderQuestion(pending)
}
const disposeQuestion = (pending: PendingQuestion): void => {
removeAbortListener(pending)
pending.reject(new UserInteractionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'))
}
const disposePendingQuestions = (): void => {
if (activeQuestion !== undefined) {
disposeQuestion(activeQuestion)
activeQuestion = undefined
}
for (const pending of questionQueue.splice(0)) {
disposeQuestion(pending)
}
}
const finishQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswer): void => {
removeAbortListener(pending)
activeQuestion = undefined
pending.resolve(answer)
output.write('\n')
startNextQuestion()
}
const answerQuestion = (line: string): void => {
const pending = activeQuestion as PendingQuestion
const text = line.trim()
const options = displayOptions(pending.request.options)
const selectedIndex = /^\d+$/.test(text) ? Number(text) - 1 : -1
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined
if (selected !== undefined) {
finishQuestion(pending, { answer: optionAnswer(selected), option: selected })
return
}
const recommended = options.find(option => option.recommended)
if (text === '' && recommended !== undefined) {
finishQuestion(pending, { answer: optionAnswer(recommended), option: recommended })
return
}
const allowCustom = options.length === 0 || (pending.request.allowCustom ?? true)
if (allowCustom && text !== '') {
finishQuestion(pending, { answer: text })
return
}
output.write(options.length > 0
? 'Please enter one of the option numbers'
+ (allowCustom ? ' or a custom answer' : '')
+ '.\n> '
: 'Please enter an answer.\n> ')
}
const disposeUserInteractionProvider = ctx.userInteraction.registerProvider({
ask(request) {
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
const pending: PendingQuestion = {
request,
resolve,
reject,
onAbort: () => {
activeQuestion = undefined
disposeQuestion(pending)
startNextQuestion()
},
}
questionQueue.push(pending)
startNextQuestion()
})
},
})
reader.on('line', (line) => {
if (activeQuestion !== undefined) {
answerQuestion(line)
return
}
const text = line.trim()
if (!text) return
const agent = ctx.agents.get(agentId)
if (!agent) {
ctx.logger.error('ui-stdio: agent "%s" is not running', agentId)
return
}
submittedWork = true
if (agent.status === 'running') {
agent.steer([{ type: 'text', text }])
} else {
agent.send([{ type: 'text', text }])
}
})
reader.on('close', () => {
// Fires for BOTH stdin EOF and plugin disposal (reader.close() below);
// `disposed` guards teardown so HMR/dispose never exits the process.
stdinClosed = true
if (!disposed) disposePendingQuestions()
maybeExit()
})
output.write(`${welcome}\n> `)
return () => {
disposed = true
if (exitTimer !== undefined) clearTimeout(exitTimer)
disposePendingQuestions()
disposeUserInteractionProvider()
disposeStatusListener()
reader.close()
}
}, 'ui-stdio')
}
/**
* Cordis entry point. Binds the real `process` streams and delegates to
* {@link createStdioChat}; the indirection keeps the side-effecting handles out
* of the testable core, which is why the unit suite drives `createStdioChat`
* directly. This thin wrapper is exercised end-to-end by the keyless
* Loader-path e2e smoke in `examples/echo-agent` (the real product entry).
*/
/* v8 ignore start -- production stdio wiring; testable core is createStdioChat() (covered), exercised e2e by echo-agent keyless smoke */
export function apply(ctx: Context, config: Config): void {
createStdioChat(ctx, config, {
input: process.stdin,
output: process.stdout,
exit: code => process.exit(code),
})
}
/* v8 ignore stop */

View File

@@ -34,9 +34,8 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
// to the built `lib/` (package.json `main`), exactly as an installed dep would.
const dshPackages = [
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
'core/tools', 'core/user-interaction', 'ui/tool-ask-user',
'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
'bash/tool-bash', 'support/invariants', 'support/ui-stdio',
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence',
'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
]

View File

@@ -0,0 +1,53 @@
import { EventEmitter } from 'node:events'
import type { Readable, Writable } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import type { StdioRuntime } from '../src/stdio-chat.ts'
const createInterface = vi.hoisted(() => vi.fn(() => {
const reader = new EventEmitter() as EventEmitter & { close(): void }
reader.close = vi.fn()
return reader
}))
vi.mock('node:readline', () => ({ createInterface }))
function fakeContext(): Context {
return {
on: vi.fn(() => vi.fn()),
effect: vi.fn((callback: () => () => void) => callback()),
// The UI seeds its label map from the registry at install; this suite only
// exercises readline terminal-mode selection, so an empty roster suffices.
agents: { list: vi.fn(() => []) },
} as unknown as Context
}
function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime {
return {
input: { isTTY: inputIsTTY } as Readable & { isTTY: boolean },
output: { isTTY: outputIsTTY, write: vi.fn(() => true) } as unknown as Writable & { isTTY: boolean },
exit: vi.fn(),
}
}
describe('createStdioChat readline mode', () => {
it('enables terminal editing only when both stdio streams are TTYs', async () => {
const { createStdioChat } = await import('../src/stdio-chat.ts')
const tty = fakeRuntime(true, true)
createStdioChat(fakeContext(), {}, tty)
expect(createInterface).toHaveBeenLastCalledWith({
input: tty.input,
output: tty.output,
terminal: true,
})
const piped = fakeRuntime(true, false)
createStdioChat(fakeContext(), {}, piped)
expect(createInterface).toHaveBeenLastCalledWith({
input: piped.input,
output: piped.output,
terminal: false,
})
})
})

View File

@@ -12,9 +12,11 @@ import * as stdioAgent from '../src/index.ts'
* agent; `persistenceRoot`/`welcome`/`resumeSessionId` route to their backends.
*
* `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev
* plugin the in-process tier cannot import); the REAL Loader-path guard (export
* shape, `unwrapExports`, the whole subprocess tree incl. `hmr`) is the keyless
* echo smoke in `examples/echo-agent`. Here we assert the composition + config
* plugin the in-process tier cannot import); the keyless echo smoke in
* `examples/echo-agent` proves the whole subprocess tree (incl. `hmr`) boots
* through the real Loader, while the export SHAPE is pinned by this suite's
* explicit `unwrapExports` assertion (an inject-less app would boot past a
* stray default rather than crash). Here we assert the composition + config
* forwarding the unit tier can reach.
*/
async function mount(config: stdioAgent.Config): Promise<Context> {

View File

@@ -0,0 +1,661 @@
import { Readable } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
/**
* Unit tests for the stdio UI plugin. They drive the REAL plugin body
* (`createStdioChat`) with an injected {@link StdioRuntime} so every render,
* input, EOF, and disposal branch runs without touching the real `process`
* streams — the I/O seam is what makes the per-file gate reachable. The
* `agents` service is real (`@deepseek-ai/dsh-agent`); a minimal fake `Agent`
* stands in for the loop, since the loop is the genuinely expensive collaborator
* and we only need its `status` + `send`/`steer` surface here.
*/
/** A controllable stdin: a Readable we push lines into and can end on demand. */
function makeInput(): Readable & { feed(line: string): void; finish(): void } {
const stream = new Readable({ read() {} }) as Readable & { feed(line: string): void; finish(): void }
stream.feed = (line: string) => stream.push(`${line}\n`)
stream.finish = () => stream.push(null)
return stream
}
/** A stdout sink that accumulates everything written, for assertions. */
function makeOutput(): { write: (s: string) => boolean; text: () => string } {
let buf = ''
return { write: (s: string) => { buf += s; return true }, text: () => buf }
}
function makeRuntime(over: Partial<StdioRuntime> = {}): {
runtime: StdioRuntime
input: ReturnType<typeof makeInput>
out: ReturnType<typeof makeOutput>
exit: ReturnType<typeof vi.fn>
} {
const input = makeInput()
const out = makeOutput()
const exit = vi.fn()
return { runtime: { input, output: { write: out.write } as never, exit, ...over }, input, out, exit }
}
/** A minimal Agent fake exposing the surface the UI touches. */
function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & {
status: AgentStatus
sent: ContentBlock[][]
steered: ContentBlock[][]
} {
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
return {
id: id as Agent['id'],
status,
sent,
steered,
// A minimal session stub: the UI reads only `session.header.id` (to map the
// session back to its agent id for the turn-boundary label).
session: { header: { id: `${id}-session` } },
send: (content: ContentBlock[]) => void sent.push(content),
steer: (content: ContentBlock[]) => void steered.push(content),
} as never
}
/** A session stub whose `header.id` matches an agent's, for `session/event` emits. */
function makeSession(agentId: string): Session {
return { header: { id: `${agentId}-session` } } as Session
}
/** An `assistant/chunk` session event carrying one raw stream chunk. */
function chunkEvent(chunk: StreamChunk): SessionEvent {
return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } }
}
const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const { runtime, input, out, exit } = makeRuntime(runtimeOver)
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, config, runtime)
}, { inject: ['agents', 'userInteraction'] }))
return { ctx, fiber, input, out, exit }
}
/** Drive a fake idle timer past the 200ms flush delay. */
function flushExit(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 250))
}
describe('createStdioChat rendering', () => {
it('writes the welcome banner and prompt on start', async () => {
const { out } = await setup()
expect(out.text()).toBe('hi there\n> ')
})
it('falls back to default welcome/agent when called with empty config', async () => {
// createStdioChat is exported and may be driven directly (bypassing the
// Loader's schemastery validation), so it must default welcome/agent itself.
const { out } = await setup({})
expect(out.text()).toBe('ready.\n> ')
// And it drives the default agent id 'main'.
})
it('renders text-delta chunks verbatim', async () => {
const { ctx, out } = await setup()
ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' }))
expect(out.text()).toContain('hello')
})
it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => {
const { ctx, out } = await setup()
const session = makeSession('main')
ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' }))
ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' }))
ctx.emit('session/event', session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' }))
expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer')
})
it('ignores stream-chunk types it does not render', async () => {
const { ctx, out } = await setup()
const before = out.text()
ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' }))
expect(out.text()).toBe(before)
})
it('renders turn/start and turn/end markers from the session feed', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
// agent/created populates the session-id → agent-id label map.
ctx.emit('agent/created', agent)
const session = makeSession('main')
ctx.emit('session/event', session, {
type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main turn 3] ')
ctx.emit('session/event', session, {
type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } },
} as SessionEvent)
expect(out.text()).toContain('\n> ')
})
it('falls back to the session id as the label when no agent is mapped', async () => {
const { ctx, out } = await setup()
// No agent/created emitted, so the label map is empty — the header id shows.
ctx.emit('session/event', makeSession('orphan'), {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[orphan-session turn 1] ')
})
it('seeds labels for agents already registered before the UI installs', async () => {
// The pre-created `main` agent (and any agent surviving an HMR reload of just
// this fiber) fired its `agent/created` before the UI's listener existed, so
// the live listener alone would miss it. Seeding from `ctx.agents.list()` at
// install time is what keeps its turn header showing `[main turn N]` instead
// of the raw session id.
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const agent = makeAgent('main')
ctx.agents.register(agent) // registered BEFORE the UI plugin below
const { runtime, out } = makeRuntime()
await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, CONFIG, runtime)
}, { inject: ['agents', 'userInteraction'] }))
ctx.emit('session/event', makeSession('main'), {
type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main turn 5] ')
})
it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => {
const { ctx, out } = await setup()
const session = makeSession('main')
ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' }))
ctx.emit('session/event', session, {
type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } },
} as SessionEvent)
expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
})
it('drops the label mapping on agent/disposed', async () => {
const { ctx, out } = await setup()
const agent = makeAgent('main')
ctx.emit('agent/created', agent)
ctx.emit('agent/disposed', agent)
// After disposal the map no longer resolves the agent id — fall back to the
// session header id.
ctx.emit('session/event', makeSession('main'), {
type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
} as SessionEvent)
expect(out.text()).toContain('[main-session turn 1] ')
})
it('renders tool/call and tool/result session events', async () => {
const { ctx, out } = await setup()
const session = {} as Session
const callEvent = {
type: 'tool/call', seq: 1, time: 0,
data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' },
} as SessionEvent
ctx.emit('session/event', session, callEvent)
expect(out.text()).toContain('[tool call] bash({"command":"ls"})')
const resultEvent = {
type: 'tool/result', seq: 2, time: 0,
data: { turn: 1, step: 0, callId: 'c1', content: [{ type: 'text', text: 'file.txt' }], isError: false },
} as SessionEvent
ctx.emit('session/event', session, resultEvent)
expect(out.text()).toContain('[tool result] file.txt')
})
it('renders a todo/write session event as a glyphed checklist', async () => {
const { ctx, out } = await setup()
const session = {} as Session
ctx.emit('session/event', session, {
type: 'todo/write', seq: 1, time: 0,
data: { todos: [
{ content: 'read the code', status: 'completed' },
{ content: 'write the fix', status: 'in_progress' },
{ content: 'run the tests', status: 'pending' },
] },
} as SessionEvent)
const text = out.text()
expect(text).toContain('[todos]')
expect(text).toContain('[x] read the code')
expect(text).toContain('[~] write the fix')
expect(text).toContain('[ ] run the tests')
})
it('resets dim styling when a todo/write interrupts reasoning', async () => {
const { ctx, out } = await setup()
ctx.emit('session/event', {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' }))
ctx.emit('session/event', {} as Session, {
type: 'todo/write', seq: 1, time: 0,
data: { todos: [{ content: 'a task', status: 'pending' }] },
} as SessionEvent)
expect(out.text()).toContain('\x1B[2mr\x1B[0m')
})
it('resets dim styling when a tool/call interrupts reasoning', async () => {
const { ctx, out } = await setup()
const session = {} as Session
ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' }))
ctx.emit('session/event', session, {
type: 'tool/call', seq: 1, time: 0,
data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' },
} as SessionEvent)
expect(out.text()).toContain('\x1B[2mr\x1B[0m')
})
it('ignores session events it does not render', async () => {
const { ctx, out } = await setup()
const before = out.text()
ctx.emit('session/event', {} as Session, {
type: 'user/message', seq: 1, time: 0,
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
} as SessionEvent)
expect(out.text()).toBe(before)
})
})
describe('createStdioChat input', () => {
it('answers a pending user question instead of sending the line to the agent', async () => {
const { ctx, input, out } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
const answer = ctx.userInteraction.ask({
header: 'Confirm',
question: 'Proceed with the edit?',
options: [{ label: 'Yes', value: 'Proceed', description: 'Apply the edit now.', recommended: true }],
})
await new Promise(r => setImmediate(r))
input.feed('Use a smaller change')
await expect(answer).resolves.toEqual({ answer: 'Use a smaller change' })
expect(agent.sent).toEqual([])
expect(out.text()).toContain('[Confirm] Proceed with the edit?')
expect(out.text()).toContain('1. Yes (recommended)')
expect(out.text()).toContain('Apply the edit now.')
})
it('answers a pending user question by numeric option selection', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Which mode?',
options: [
{ label: 'Safe', value: 'Use safe mode', recommended: true },
{ label: 'Fast', value: 'Use fast mode' },
],
allowCustom: false,
})
await new Promise(r => setImmediate(r))
input.feed('2')
await expect(answer).resolves.toEqual({
answer: 'Use fast mode',
option: { label: 'Fast', value: 'Use fast mode' },
})
})
it('renders recommended options first and selects by displayed number', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Which topic?',
options: [
{ label: 'Hobbies', value: 'hobbies' },
{ label: 'Work', value: 'work', description: 'Questions about current projects.' },
{ label: 'Casual', value: 'casual', recommended: true, description: 'Easy conversation.' },
],
allowCustom: false,
})
await new Promise(r => setImmediate(r))
expect(out.text()).toContain([
'[question] Which topic?',
' 1. Casual (recommended)',
' Easy conversation.',
' 2. Hobbies',
' 3. Work',
' Questions about current projects.',
].join('\n'))
input.feed('1')
await expect(answer).resolves.toEqual({
answer: 'casual',
option: { label: 'Casual', value: 'casual', recommended: true, description: 'Easy conversation.' },
})
})
it('uses the recommended option when the user submits an empty answer', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Continue?',
options: [
{ label: 'No' },
{ label: 'Yes', value: 'Continue', recommended: true },
],
allowCustom: false,
})
await new Promise(r => setImmediate(r))
input.feed('')
await expect(answer).resolves.toEqual({
answer: 'Continue',
option: { label: 'Yes', value: 'Continue', recommended: true },
})
})
it('re-prompts when options are required and the input is invalid', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Which mode?',
options: [{ label: 'Safe' }],
allowCustom: false,
})
await new Promise(r => setImmediate(r))
input.feed('custom')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers.')
input.feed('1')
await expect(answer).resolves.toEqual({
answer: 'Safe',
option: { label: 'Safe' },
})
})
it('re-prompts with custom-answer guidance when options also allow free-form input', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Which mode?',
options: [{ label: 'Safe' }],
})
await new Promise(r => setImmediate(r))
input.feed('')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
input.feed('Use custom mode')
await expect(answer).resolves.toEqual({ answer: 'Use custom mode' })
})
it('re-prompts when a free-form question receives an empty answer', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({ question: 'What should I use?' })
await new Promise(r => setImmediate(r))
input.feed('')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter an answer.')
input.feed('Use defaults')
await expect(answer).resolves.toEqual({ answer: 'Use defaults' })
})
it('accepts free-form input for an optionless question even when allowCustom is false', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
question: 'Choose?',
allowCustom: false,
})
await new Promise(r => setImmediate(r))
input.feed('Use the default path')
await expect(answer).resolves.toEqual({ answer: 'Use the default path' })
})
it('rejects an active question when its signal aborts', async () => {
const { ctx } = await setup()
const controller = new AbortController()
const answer = ctx.userInteraction.ask({ question: 'Continue?', signal: controller.signal })
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
controller.abort()
await rejected
})
it('continues to the next queued question when the active question aborts', async () => {
const { ctx, input, out } = await setup()
const controller = new AbortController()
const first = ctx.userInteraction.ask({ question: 'First?', signal: controller.signal })
const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const second = ctx.userInteraction.ask({ question: 'Second?' })
await new Promise(r => setImmediate(r))
controller.abort()
await firstRejected
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('[question] Second?')
input.feed('second answer')
await expect(second).resolves.toEqual({ answer: 'second answer' })
})
it('skips a queued question whose signal aborted before it became active', async () => {
const { ctx, input, out } = await setup()
const controller = new AbortController()
const first = ctx.userInteraction.ask({ question: 'First?' })
const second = ctx.userInteraction.ask({ question: 'Second?', signal: controller.signal })
const secondRejected = expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
controller.abort()
input.feed('first answer')
await expect(first).resolves.toEqual({ answer: 'first answer' })
await secondRejected
expect(out.text()).not.toContain('[question] Second?')
})
it('rejects active and queued questions when the UI is disposed', async () => {
const { ctx, fiber } = await setup()
const active = ctx.userInteraction.ask({ question: 'Active?' })
const queued = ctx.userInteraction.ask({ question: 'Queued?' })
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
await fiber.dispose()
await activeRejected
await queuedRejected
})
it('rejects active and queued questions when stdin closes before the user answers', async () => {
const { ctx, input, exit } = await setup()
const active = ctx.userInteraction.ask({ question: 'Active?' })
const queued = ctx.userInteraction.ask({ question: 'Queued?' })
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
input.finish()
await new Promise(r => setImmediate(r))
await activeRejected
await queuedRejected
expect(exit).not.toHaveBeenCalled()
})
it('sends a typed line to an idle agent', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
input.feed('do a thing')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]])
expect(agent.steered).toEqual([])
})
it('steers a typed line into a running agent', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main', 'running')
ctx.agents.register(agent)
input.feed('steer me')
await new Promise(r => setImmediate(r))
expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]])
expect(agent.sent).toEqual([])
})
it('ignores blank lines', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main')
ctx.agents.register(agent)
input.feed(' ')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([])
})
it('logs and drops a line when the target agent is not running', async () => {
const { ctx, input } = await setup()
const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
input.feed('nobody home')
await new Promise(r => setImmediate(r))
expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main')
})
it('drives the agent named in config, not a hardcoded id', async () => {
const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' })
const agent = makeAgent('worker')
ctx.agents.register(agent)
input.feed('hi')
await new Promise(r => setImmediate(r))
expect(agent.sent).toHaveLength(1)
})
})
describe('createStdioChat EOF exit', () => {
it('exits immediately on EOF when no work was submitted', async () => {
const { input, exit } = await setup()
input.finish()
await flushExit()
expect(exit).toHaveBeenCalledWith(0)
})
it('waits for the agent to settle idle after running before exiting', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
input.feed('work')
await new Promise(r => setImmediate(r))
input.finish()
await new Promise(r => setImmediate(r))
// Work submitted but no 'running' observed yet — must NOT exit.
expect(exit).not.toHaveBeenCalled()
// The turn starts, then settles.
ctx.emit('agent/status', agent, 'running')
;(agent as { status: AgentStatus }).status = 'idle'
ctx.emit('agent/status', agent, 'idle')
await flushExit()
expect(exit).toHaveBeenCalledWith(0)
})
it('schedules the exit only once when idle fires repeatedly', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'running')
ctx.agents.register(agent)
input.feed('work')
await new Promise(r => setImmediate(r))
ctx.emit('agent/status', agent, 'running') // sawRunning = true
input.finish()
await new Promise(r => setImmediate(r)) // let readline 'close' set stdinClosed
;(agent as { status: AgentStatus }).status = 'idle'
// Two idle signals while stdin is already closed: the first arms the timer,
// the second must hit the already-scheduled guard, not arm a second.
ctx.emit('agent/status', agent, 'idle')
ctx.emit('agent/status', agent, 'idle')
await flushExit()
expect(exit).toHaveBeenCalledTimes(1)
})
it('does not exit on an idle transition for a different agent', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
input.feed('work')
await new Promise(r => setImmediate(r))
input.finish()
const other = makeAgent('other')
ctx.emit('agent/status', other, 'running')
ctx.emit('agent/status', other, 'idle')
await flushExit()
expect(exit).not.toHaveBeenCalled()
})
it('does not exit while a turn is still running at EOF', async () => {
const { ctx, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
input.feed('work')
await new Promise(r => setImmediate(r))
ctx.emit('agent/status', agent, 'running')
;(agent as { status: AgentStatus }).status = 'running'
input.finish()
// sawRunning is true, but the agent is still running — the idle gate holds.
ctx.emit('agent/status', agent, 'idle') // a stale/duplicate signal while status stays 'running'
await flushExit()
expect(exit).not.toHaveBeenCalled()
})
})
describe('createStdioChat disposal (HMR safety)', () => {
it('never exits the process when EOF arrives after fiber dispose', async () => {
const { fiber, input, exit } = await setup()
await fiber.dispose()
// A late EOF after disposal (reader.close() also fires 'close') must not exit.
input.finish()
await flushExit()
expect(exit).not.toHaveBeenCalled()
})
it('cancels a scheduled exit if disposed within the flush window', async () => {
const { fiber, input, exit } = await setup()
// EOF with no work submitted schedules the 200ms flush-then-exit timer.
input.finish()
await new Promise(r => setImmediate(r))
expect(exit).not.toHaveBeenCalled() // not yet — still inside the window
// Dispose BEFORE the timer fires: the tracked handle must be cleared.
await fiber.dispose()
await flushExit()
expect(exit).not.toHaveBeenCalled()
})
it('stops handling input after dispose', async () => {
const { ctx, fiber, input } = await setup()
const agent = makeAgent('main')
ctx.agents.register(agent)
await fiber.dispose()
// The readline interface is closed on dispose; a late line reaches no handler.
input.feed('too late')
await new Promise(r => setImmediate(r))
expect(agent.sent).toEqual([])
})
it('removes the agent/status listener on dispose', async () => {
const { ctx, fiber, input, exit } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
input.feed('work')
await new Promise(r => setImmediate(r))
await fiber.dispose()
// After dispose, status transitions must neither throw nor schedule an exit
// (the listener and the EOF-exit path are both torn down).
expect(() => {
ctx.emit('agent/status', agent, 'running')
ctx.emit('agent/status', agent, 'idle')
}).not.toThrow()
await flushExit()
expect(exit).not.toHaveBeenCalled()
})
})

View File

@@ -17,6 +17,9 @@
{
"path": "../../../vendor/loader"
},
{
"path": "../app-boot"
},
{
"path": "../../../vendor/logger-console"
},
@@ -37,9 +40,6 @@
},
{
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../support/ui-stdio"
}
]
}