Merge origin/master into skill system branch
Resolve documentation split, tool presentation, and generated catalog changes from master while preserving the skill system integration.
This commit is contained in:
@@ -7,7 +7,8 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `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.
|
||||
|
||||
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.
|
||||
|
||||
@@ -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",
|
||||
@@ -41,6 +42,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:^",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -40,7 +40,7 @@ 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/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'support/invariants',
|
||||
'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',
|
||||
]
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../acp"
|
||||
},
|
||||
|
||||
@@ -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,7 +28,7 @@ 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) |
|
||||
| `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) |
|
||||
|
||||
## Multi-session
|
||||
|
||||
@@ -42,22 +42,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
|
||||
|
||||
@@ -65,7 +71,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -62,12 +62,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'
|
||||
@@ -117,10 +117,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
|
||||
@@ -134,8 +130,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'),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -199,22 +193,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
|
||||
@@ -283,7 +271,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 */
|
||||
@@ -318,15 +306,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
|
||||
@@ -430,7 +417,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
|
||||
@@ -639,7 +628,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
|
||||
@@ -813,67 +802,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': {
|
||||
@@ -916,46 +851,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
|
||||
@@ -965,14 +874,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(
|
||||
@@ -980,10 +889,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) {
|
||||
@@ -991,51 +900,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,16 +942,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. */
|
||||
@@ -1079,20 +968,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. */
|
||||
@@ -1102,12 +1092,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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -148,14 +148,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: [] })
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -158,7 +158,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
15
packages/ui/app-boot/README.md
Normal file
15
packages/ui/app-boot/README.md
Normal 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.
|
||||
34
packages/ui/app-boot/package.json
Normal file
34
packages/ui/app-boot/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
154
packages/ui/app-boot/src/index.ts
Normal file
154
packages/ui/app-boot/src/index.ts
Normal 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
|
||||
}
|
||||
178
packages/ui/app-boot/tests/app-boot.spec.ts
Normal file
178
packages/ui/app-boot/tests/app-boot.spec.ts
Normal 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`)
|
||||
})
|
||||
})
|
||||
21
packages/ui/app-boot/tsconfig.json
Normal file
21
packages/ui/app-boot/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,9 +13,9 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
|
||||
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
|
||||
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` with `process.cwd()` as the fresh session cwd |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
|
||||
| `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent |
|
||||
| `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.
|
||||
|
||||
@@ -33,12 +33,12 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -32,24 +32,26 @@
|
||||
"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-ui-stdio": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6",
|
||||
"schemastery": "^3.17.0"
|
||||
},
|
||||
"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-ui-stdio": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6",
|
||||
"schemastery": "^3.17.0"
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -42,7 +45,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-core'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
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 {
|
||||
|
||||
225
packages/ui/stdio-agent/src/stdio-chat.ts
Normal file
225
packages/ui/stdio-agent/src/stdio-chat.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
export const name = 'ui-stdio'
|
||||
export const inject = ['agents']
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
reader.on('line', (line) => {
|
||||
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
|
||||
maybeExit()
|
||||
})
|
||||
output.write(`${welcome}\n> `)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (exitTimer !== undefined) clearTimeout(exitTimer)
|
||||
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 */
|
||||
@@ -35,7 +35,7 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
|
||||
'bash/tool-bash', 'support/invariants', 'support/ui-stdio',
|
||||
'bash/tool-bash', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
|
||||
]
|
||||
|
||||
53
packages/ui/stdio-agent/tests/readline.spec.ts
Normal file
53
packages/ui/stdio-agent/tests/readline.spec.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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> {
|
||||
|
||||
437
packages/ui/stdio-agent/tests/stdio-chat.spec.ts
Normal file
437
packages/ui/stdio-agent/tests/stdio-chat.spec.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
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 { 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)
|
||||
const { runtime, input, out, exit } = makeRuntime(runtimeOver)
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
createStdioChat(inner, config, runtime)
|
||||
}, { inject: ['agents'] }))
|
||||
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)
|
||||
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'] }))
|
||||
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('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()
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/logger-console"
|
||||
},
|
||||
@@ -31,9 +34,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
{
|
||||
"path": "../../support/ui-stdio"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user