Merge origin/master into web-permission-sandbox
39 conflicts resolved per the reattachment plan (missions worktree-projbiz 0728-1859): baseline wins for deleted packages (host/runtime, old ui/acp, ui-sidebar Rows/tree) and retired specs; unions for wire-layer exports and client summary fields; the approval takeover, waitingApprovals tracking, and PendingApproval domain face carry over onto the master structure. The two new host specs follow the runtime->apiproxy rename. Dead PR-side wiring (ConversationInjected permissions/setPermission spread, InputBar controls prop, boot.ts sandbox composition) resolves to master and its replacement lands in follow-up commits.
This commit is contained in:
6
packages/ui/README.i18n.yaml
Normal file
6
packages/ui/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: f08157d411a018141cdc21c487f81ae198f4de56
|
||||
README.zh.md: d321ee9a181eb27ccecb143d3733ee46e417b27f
|
||||
@@ -1,10 +1,11 @@
|
||||
# ui/ — editor/client integration surfaces
|
||||
# ui/ — human and SDK-client integration surfaces
|
||||
|
||||
Integrations that expose the agent to an external editor or client. These are **product** packages: a real surface a user drives the harness through.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Human-facing channels and the out-of-process SDK server. These are **product** packages: real interfaces that a person or SDK client drives.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `acp/` | Agent Client Protocol bridge: serves agents, commands, and live/replayed title updates to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `commands/` | Human-command registry: shared discovery metadata, scoped shadowing, cancellation, and direct UI dispatch | `ctx.commands` |
|
||||
| `user-approval/` | One-shot user-approval mechanism, closed outcome vocabulary, audit events, and per-session approval policy | `ctx.approval` |
|
||||
| `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` |
|
||||
@@ -14,8 +15,8 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) |
|
||||
| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). [`tui`](tui/README.md) is the interactive terminal front door and supplies the terminal-local `ctx.tui` extension service; non-interactive tasks use the headless `cli-demo` app instead of a UI channel. [`commands`](commands/README.md) is the human-only discovery and dispatch plane shared by TUI and ACP; command input and output do not become model messages.
|
||||
A UI integration is a client-driver plugin, not a loop change: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. [`tui`](tui/README.md) is the interactive terminal front door and supplies the terminal-local `ctx.tui` extension service; [`jsonrpc`](jsonrpc/README.md) serves out-of-process SDK clients, while non-interactive one-shot tasks use `cli-demo`. [`commands`](commands/README.md) is the human-only discovery and dispatch plane consumed by TUI; command input and output do not become model messages.
|
||||
|
||||
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers.
|
||||
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers.
|
||||
|
||||
The runnable app bundles that bake these bridges into boot bins — the TUI app, ACP server app, and JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
|
||||
|
||||
22
packages/ui/README.zh.md
Normal file
22
packages/ui/README.zh.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# ui/:面向用户和 SDK 客户端的集成接口
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
面向用户的交互通道和进程外 SDK 服务器。这些是**产品** 包(package):由用户或 SDK 客户端直接操作的真实接口。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `commands/` | 用户命令注册表:共享发现元数据、作用域遮蔽、取消以及 UI 直接分派 | `ctx.commands` |
|
||||
| `user-approval/` | 一次性用户审批机制、封闭的结果词汇、审计事件和逐会话审批策略 | `ctx.approval` |
|
||||
| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):用一个产品级选择器组合沙箱模式与审批策略两个调节项,并写入各自的会话事件 | `ctx.permission` |
|
||||
| `user-interaction/` | UI 支持的确认工具所使用的抽象用户问答 seam | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | 模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现 | (注册到 `ctx.tools`) |
|
||||
| `tui/` | 交互式 pi-tui 终端通道:渲染会话标题、事件和工具意图,响应 `ctx.userInteraction`,并托管由 effect 持有的插件浮层 | `ctx.tui`(驱动 `ctx.agents`) |
|
||||
| `jsonrpc/` | 面向进程外 SDK 客户端的 stdio JSON-RPC 服务器 | (驱动 `ctx.agents`) |
|
||||
| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、Loader 快速失败保护、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) |
|
||||
|
||||
UI 集成属于客户端驱动插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,而非交互式单次任务使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的纯用户发现与分派通道;命令输入和输出不会成为模型消息。
|
||||
|
||||
`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于提供方无关的核心主干。`user-approval` 持有一次性的 `ctx.approval` 决策机制及其策略层级;应答方仍归拥有 agent(智能体)的通道或自动化传输层所有。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体实现。
|
||||
|
||||
基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`tui-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 持有启动 bin;`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI 启动。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。
|
||||
@@ -1,199 +0,0 @@
|
||||
# @deepseek-ai/dsh-acp
|
||||
|
||||
Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target.
|
||||
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui` channel — NOT a loop change and NOT a [capability seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
|
||||
## Service / plugin
|
||||
|
||||
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
|
||||
|
||||
The plugin injects `agents`, [`commands`](../commands/README.md), `sessionPersistence`, `sessionQuery`, `tools`, `userInteraction`, `llm`, and `systemPrompt`, never the concrete loop. Persistence backs `session/load`; live-preferred session queries back `session/list`; the command registry backs slash discovery and direct dispatch; the LLM catalog backs model selection; prompt assembly keeps model variables aligned with routing; tool definitions own presentation; user interaction maps agent questions to ACP forms.
|
||||
|
||||
### Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `provider` | — | Initial provider route for created agents (must have a registered adapter). |
|
||||
| `model` | — | Initial model id for created agents. |
|
||||
|
||||
(No persona key: `dsh-system-prompt`'s own `persona` config supplies the global default section, so ACP-created agents render it without the bridge carrying prompt text. An agent-scoped same-name section may still shadow that default.)
|
||||
|
||||
The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config.
|
||||
|
||||
## ACP method mapping
|
||||
|
||||
| ACP method | Harness seam | Notes |
|
||||
|---|---|---|
|
||||
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text), `loadSession: true`, and `sessionCapabilities.list` |
|
||||
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; advertises the effective command snapshot; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
|
||||
| `session/load` | `ctx.agents.resume(...)` | reserves the id, verifies the persisted cwd, resumes, replays user, assistant, tool, and title events, and re-advertises commands |
|
||||
| `session/list` | `ctx.sessionQuery` | returns live-preferred newest-first sessions with absolute cwd and optional folded title; supports exact normalized cwd filtering, returns no cursor, and rejects supplied cursors |
|
||||
| `session/prompt` | `ctx.commands.execute()` or `agent.send()` | a flattened prompt beginning with `/` stays in the direct command plane; ordinary prompts support ACP `text` and `resource_link`; `dsh-session:` links and inline mentions are snapshotted through optional `ctx.sessionReferences` before enqueue; unsupported content, unavailable reference capability, failed snapshots, and empty prompts are rejected; one request is in flight per session and settles on the owning turn's end, with an error turn rejecting the RPC |
|
||||
| `session/cancel` | command `AbortSignal` or `agent.cancel()` | aborts the exact direct command, or applies the queue-aware agent cancel and settles its prompt `cancelled`; one session never cancels another |
|
||||
| `session/update` | `session/event` | streams user replay, assistant text/reasoning, retry/failure attempt markers, tool render intents, and `session_info_update` title revisions |
|
||||
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
|
||||
| `session/request_permission` | `approval/request` listener | answers one-shot allow/reject requests for bridge-owned calls; foreign or call-less requests delegate and fail closed if unanswered — see "Permission prompts" |
|
||||
| `session/set_config_option` | agent-scoped request target / `ctx.permission.set()` | per-session provider+model and permission-preset switching over [session config options](https://agentclientprotocol.com/protocol/session-config-options) — see "Session config options" |
|
||||
|
||||
## Multi-session
|
||||
|
||||
One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt or reference-preparation operation; `session/cancel` aborts preparation before it can enqueue. Teardown drains all sessions in parallel. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md).
|
||||
|
||||
## Human commands
|
||||
|
||||
After `session/new` and `session/load`, the bridge emits ACP's full `available_commands_update` snapshot for that exact agent. A new session's server-generated id is introduced by the RPC response before its snapshot enters the connection write queue. A global or scoped registry change refreshes every live session from its independently resolved view, so clients replace rather than merge cached catalogs. Names omit the slash; descriptions and optional unstructured-input hints map directly to ACP `AvailableCommand`.
|
||||
|
||||
ACP v1 permits a command prompt to carry additional content blocks. The bridge applies its ordinary lossless flattening for supported `text` and `resource_link` blocks, then dispatches when the result begins with `/`. Known commands execute without a model request. Unknown or malformed slash input returns a direct error instead of falling back to the model; prefix whitespace when literal slash-leading text must reach the model. Expected handler errors, thrown failures, and successful text stream as UI-only `agent_message_chunk` output and end the request; cancellation returns `cancelled`. See the [command Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) and the [ACP v1 slash-command contract](https://agentclientprotocol.com/protocol/v1/slash-commands).
|
||||
|
||||
## Session config options
|
||||
|
||||
The bridge advertises a `model`-category select in `session/new` and `session/load` when the session has a complete target whose provider is registered. Values encode the complete provider/model pair, are grouped by provider when more than one group is available, and come from `ctx.llm.listProviders()` / `listModels()`. The configured or last-requested model is added when absent because catalogs are advisory and private adapters may accept unlisted ids. A selection changes only that ACP session. Agent-scoped prompt assembly snapshots the selected pair for one step, supplies matching `{{provider}}` / `{{model}}` variables, and the `agent/request` waterfall applies the same pair; a concurrent selection therefore takes effect on the next step instead of splitting prompt text from routing. The resulting request header is the durable record restored by `session/load`; a selection never used by a request remains in-memory only.
|
||||
|
||||
When `ctx.permission` is composed, the bridge also advertises a `permission` select. Options come from the deployment's preset table; the current value comes from the session fold, with switch-away-only `custom` for unmatched knobs. `session/set_config_option` accepts advertised presets and writes both sandbox-mode and approval-policy events through `PermissionService.set()`. Open-turn switches append immediately; idle switches overlay responses and anchor at the next `agent/prompt-submit`, before request assembly. A crash before anchoring restores the durable fold. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md), [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md), [`dsh-permission`](../permission/README.md), and [protocol matrix](acp-feature-support.md#6-session-modes--config-options--models).
|
||||
|
||||
The shared [`ctx.tasks` runtime](../../tasks/tasks/) fences access to predictable task ids by the owning session; ACP sessions therefore cannot read or stop one another's background work.
|
||||
|
||||
ACP updates are append-only, so `llm/retry` emits a visible separator that marks preceding partial model output discarded before the next attempt streams. A terminal model-request failure emits the same discarded-output warning; replay derives both markers from the durable events.
|
||||
|
||||
A log-only `session/title` event maps to ACP `session_info_update` with `title` and the event timestamp as `updatedAt`. The same mapping runs for live events and `session/load` replay, so an asynchronously generated late title and a restored persisted title have one wire representation without entering model history.
|
||||
|
||||
`session/list` returns the same latest folded title in standard `SessionInfo.title`. When `ctx.sessionReferences` is mounted, each listed item also carries `_meta["deepseek-harness/sessionReference"].uri`; a title-aware client can render `title ?? sessionId` in its `@` picker and submit that URI as a `resource_link` with the same display name. Sessions without cwd are omitted because ACP requires an absolute `SessionInfo.cwd` and the bridge cannot load them.
|
||||
|
||||
## Per-session cwd
|
||||
|
||||
`session/new` records the request's absolute cwd in the session header. Before constructing an agent, `session/load` uses persisted metadata to require an absolute request cwd that matches the stored one. Bash defaults to that workspace; an explicit relative workdir resolves against it, and multiple sessions may use different workspaces. `additionalDirectories` remains unsupported.
|
||||
|
||||
## Tool-call presentation
|
||||
|
||||
Tools return provider-neutral `generic`, `terminal`, or `diff` render intents from `presentCall()` and `presentResult()`. The bridge maps the discriminator to ACP without special-casing tool names and falls back to a generic card. Per-session call-id state supplies result events with their omitted name and arguments during live streaming and replay. File-card titles are relative to the session cwd and use the host separator, while location and diff paths remain raw so the editor opens the real file. See [`dsh-tools`](../../core/tools/README.md#tool-owned-ui-presentation).
|
||||
|
||||
## Terminal card (capability-gated)
|
||||
|
||||
When the client advertises `_meta.terminal_output`, terminal intents map to Zed's terminal info, output, and exit metadata. The bridge resolves relative cwd against the session and preserves the host filesystem separator, places the description before the terminal block, and omits result content because ACP updates replace call content. Other clients receive a generic card and bridge-derived fenced console fallback. Session creation snapshots the capability so call and result agree. The command still executes through the harness, not ACP terminal creation. See the [terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) and [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md).
|
||||
|
||||
## Settle-exactly-once
|
||||
|
||||
A prompt captures its owning turn and settles exactly once from the matching durable `turn/end`, even if presentation failed. Turn correlation excludes stale endings. Error turns reject with an ACP internal error; empty prompts reject before enqueue.
|
||||
|
||||
## Permission prompts
|
||||
|
||||
For a bridge-owned call, the [approval seam](../user-approval/README.md) maps `ask` to an editor prompt with one-shot allow/reject options. Foreign or call-less requests delegate; unknown choices never grant, cancellation stays cancellation, and transport failure becomes fail-closed unavailability. Whether a tool asks remains policy outside the bridge.
|
||||
|
||||
## Disposal & disconnect
|
||||
|
||||
Disposal and client disconnect share one memoized teardown. It cancels pending prompts and disposes all owned agent handles in parallel, waiting for loop exit and final flush before registry removal. Mid-turn teardown records `disposed`; `session/cancel` records `aborted`.
|
||||
|
||||
## stdout is the protocol
|
||||
|
||||
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../../.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
|
||||
|
||||
## Running
|
||||
|
||||
`pnpm --dir /path/to/deepseek-harness run demo:acp` boots `examples/acp-agent` (needs `DEEPSEEK_API_KEY`). Point an ACP client at it; for Zed, add to `agent_servers`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agent_servers": {
|
||||
"DeepSeek Harness": {
|
||||
"command": "pnpm",
|
||||
"args": ["--dir", "/path/to/deepseek-harness", "run", "demo:acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
### User messages
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each ACP `session/prompt` becomes an agent user message: text passes through verbatim and each ordinary `resource_link` becomes exactly a leading newline, `[resource_link name=<JSON-string> uri=<JSON-string>]`, and a trailing newline. When `ctx.sessionReferences` is mounted, a `resource_link` whose URI uses `dsh-session:` or an inline canonical mention becomes readable `@label` text plus one durable untrusted snapshot context; without the capability it is rejected. Unsupported image, audio, and embedded-resource blocks are rejected rather than silently omitted.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Prompt tokens are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions keep separate contexts.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Human commands
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing from command discovery, slash input, or command output. A command handler may separately mutate a durable domain whose later state affects model requests.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Direct dispatch adds no model tokens and no session message. The mutated domain owns any later prompt or history cost.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Command discovery, dispatch, and direct output never enter a model request and do not affect its cache. A mutated domain owns any later cache effect.
|
||||
|
||||
### Human answers and permission decisions
|
||||
|
||||
#### What the model sees
|
||||
|
||||
When optional consumers are loaded, ACP form answers become the exact JSON shape documented by `dsh-tool-ask-user`. Failures become `Error: ACP user questions must come from an agent-owned request`, `Error: ACP user question has no matching session`, `Error: ACP elicitation request failed`, `Error: ask_user_question was cancelled by the user`, `Error: ask_user_question returned no answer`, or `Error: ask_user_question was aborted before the user answered`. Permission decisions control whether another tool yields success or denial. ACP tool cards, terminal output, diffs, title updates, and other streamed session updates are UI-only.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Answer, error, and denial text enters context only through the owning tool result; presentation metadata adds zero model tokens. A replacement `tool/result` still changes the model-facing session surface, but live and replayed ACP feeds ignore it as an execution update so the original terminal or diff completion is not overwritten.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
### Permission preset switches
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`session/set_config_option` emits no model message itself. When `dsh-permission` is composed, the bridge writes the selected preset through that service; the resulting model-visible policy prompt and change notice belong to [`dsh-user-approval`](../user-approval/README.md), while sandbox-mode effects belong to [`dsh-tool-bash`](../../bash/tool-bash/README.md). The ACP `Permissions` select, its option descriptions, pending idle value, and refreshed config response remain client-only.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero direct tokens from the ACP option or the log-only `permission/preset` event. Downstream cost is limited to the owning plugins' policy prompt, conditional retained change notice, and any changed tool outcome.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
The ACP option and log event cause no direct invalidation. The downstream policy-prompt change may invalidate reuse from that system section, while its change notice appends to history.
|
||||
|
||||
### Model switches
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The ACP selector itself emits no message. The selected provider/model pair supplies the next step's `{{provider}}` / `{{model}}` prompt variables and request routing together; all other call-config fields continue through the `agent/request` waterfall unchanged.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The selector adds no direct tokens. A changed model may tokenize the same retained prompt/history differently, and any persona text that interpolates provider or model changes accordingly.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Switching provider or model selects a different cache domain. If the persona interpolates either value, the rendered system prompt also changes and prevents reuse from its first changed token.
|
||||
|
||||
### Loaded sessions
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`session/load` resumes the persisted log, after which the loop sends its reconstructed history and request header. Replaying that log to the editor is not an extra model message.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Restored context has the persistence and session packages' normal retained cost; ACP replay to the client adds none.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Loading does not rewrite the stored log, but the next request is reconstructed under the current envelope and route. Reuse requires that reconstruction to match; ACP replay to the client has no cache effect.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
|
||||
- **Prompt content is `text` + `resource_link` only** — image, audio, and embedded-resource blocks are rejected, as is a non-empty `mcpServers` list at `session/new`.
|
||||
- **Session picker UI is client-owned** — `session/list` supplies standard title metadata and, when references are available, a canonical URI extension; an ACP client must consume those fields to add an `@` picker. Title/body search remains future metadata or FTS work.
|
||||
- **Terminal cards render completed output** — live incremental streaming and command classification are named follow-ups of [the terminal-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
|
||||
- **Permission answers are one-shot only** — the bridge offers `allow_once` / `reject_once`; durable `allow_always` grants and their storage/revocation policy remain deferred to the approval seam.
|
||||
- **Command output is live-only** — discovery is refreshed after load, but direct command results are not persisted or replayed into a reconnected editor.
|
||||
@@ -1,158 +0,0 @@
|
||||
# ACP feature support checklist
|
||||
|
||||
A structured inventory of [Agent Client Protocol](https://agentclientprotocol.com) (ACP) features and where the harness's ACP bridge ([`@deepseek-ai/dsh-acp`](README.md)) stands on each. The bridge exposes the harness agent as an ACP **server** (the agent side of an editor↔agent connection), so "supported" below means *the bridge implements the agent's half* — answering an agent method, advertising a capability, or calling a client method.
|
||||
|
||||
## Scope
|
||||
|
||||
This tracks the **stable** ACP v1 surface (schema `1.14.0`, `schema/v1/schema.json`) PLUS the **unstable/draft** features that the two reference adapters — [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) (Claude Code) and [`codex-acp`](https://github.com/zed-industries/codex-acp) (OpenAI Codex) — actually ship. A purely-unstable feature that neither reference adapter uses is omitted (see [Out of scope](#out-of-scope)).
|
||||
|
||||
Legend: ✅ supported · ⚠️ partial / fallback · ❌ not yet · — n/a. The **Stable** column marks whether the feature is in the released v1 schema (S) or only the unstable schema (U). The **Claude** / **Codex** columns record whether each reference adapter ships it, as a maturity signal.
|
||||
|
||||
## At a glance
|
||||
|
||||
The bridge implements the **core prompt-turn loop** for N concurrent sessions: initialize, session new/load/list, prompt, cancel, streamed assistant/thought chunks, tool-call rendering (including Zed terminal cards), resumable session replay, slash commands, one-shot permission prompts, per-session model selection and permission presets, and **session modes** (the picker, via `@deepseek-ai/dsh-plan-mode`). The largest **unbuilt** areas are **MCP passthrough** and **agent plans**, plus the client **filesystem** and **terminal** method families (which the adapters mostly do NOT drive either — see rows 43-49). See [Gap summary](#gap-summary).
|
||||
|
||||
## 1. Agent methods (client → agent)
|
||||
|
||||
| Method | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `initialize` | S | ✅ | ✅ | ✅ | Negotiates `PROTOCOL_VERSION`; advertises `loadSession`, `sessionCapabilities.list`, and baseline prompt caps. Snapshots the Zed `_meta.terminal_output` client cap. |
|
||||
| `authenticate` | S | ⚠️ | ✅ | ✅ | No-op stub; the bridge advertises no `authMethods`, so there is nothing to authenticate. |
|
||||
| `logout` | S | ❌ | ✅ | ✅ | Gated by `agentCapabilities.auth.logout`; not advertised. |
|
||||
| `session/new` | S | ✅ | ✅ | ✅ | Maps to `agents.create`; requires an absolute `cwd` (becomes the session workspace); rejects non-empty `additionalDirectories` / `mcpServers`. |
|
||||
| `session/load` | S | ✅ | ✅ | ✅ | Maps to `agents.resume` + full event-log replay; validates persisted `cwd` before constructing the agent. |
|
||||
| `session/resume` | S | ❌ | ✅ | ✅ | Reconnect WITHOUT replay; gated by `sessionCapabilities.resume`. Not advertised. |
|
||||
| `session/close` | S | ❌ | ✅ | ✅ | No `session/close` handler — the SDK dispatch returns `method_not_found`. The bridge tears sessions down on client disconnect / Cordis disposal (cross-cutting, see [§8](#8-cross-cutting)), but that is not the on-demand per-session method. |
|
||||
| `session/prompt` | S | ✅ | ✅ | ✅ | A flattened prompt beginning with `/` dispatches through `ctx.commands` without a model request; ordinary input maps to `agent.send`. One request is in flight per session. |
|
||||
| `session/cancel` | S | ✅ | ✅ | ✅ | Aborts the exact direct command, or applies queue-aware `agent.cancel` and settles its prompt `cancelled`, scoped to one session. |
|
||||
| `session/set_mode` | S | ✅ | ✅ | ✅ | Composed opportunistically: with `@deepseek-ai/dsh-plan-mode` mounted, `session/new`/`session/load` advertise the fixed `default` / `plan` projection and `session/set_mode` records the boolean pending intent (optimistic `current_mode_update`; logged `plan/mode` lands at the turn boundary). Without the plugin: no `modes` advertised, `set_mode` rejected (see [§6 Modes](#6-session-modes--config-options--models)). |
|
||||
| `session/set_config_option` | S | ✅ | ✅ | ✅ | A provider/model select is present for a complete registered target; one `permission` select is added when `ctx.permission` is composed. Every response carries the complete refreshed state. |
|
||||
| model selection | S | ✅ | ✅ | ✅ | No distinct stable `session/set_model` — model is the `model`-category `session/set_config_option`. Values preserve the provider/model pair, catalogs come from `ctx.llm`, selection is per session, and `session/load` restores the last requested pair. Codex also supports the legacy `unstable_setSessionModel` ext method. |
|
||||
| `session/list` | S | ✅ | ✅ | ✅ | Uses live-preferred `ctx.sessionQuery`; returns absolute-cwd sessions newest-first with optional folded title and exact cwd filtering. Pagination is not emitted; supplied cursors are rejected. |
|
||||
| `session/delete` | S | ❌ | ✅ | ✅ | Gated by `sessionCapabilities.delete`. |
|
||||
| `session/fork` | U | ❌ | ✅ | ❌ | Claude ships `unstable_forkSession`; Codex does not. |
|
||||
|
||||
## 2. Client methods the agent CALLS (agent → client)
|
||||
|
||||
These are capabilities the bridge would *drive* on the editor. The harness runs tools in-process (its own `dsh-bash` executor, direct file I/O), so it does not yet delegate to the editor for any of these.
|
||||
|
||||
| Method | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `session/update` | S | ✅ | ✅ | ✅ | The bridge's primary output channel (see [§4](#4-sessionupdate-variants)). |
|
||||
| `session/request_permission` | S | ✅ | ✅ | ✅ | The bridge answers the [`ctx.approval`](../user-approval/README.md) seam for the agents it owns: an `ask` from a hook/plugin becomes an editor prompt attached to the streamed tool call, one-shot `allow_once`/`reject_once` options only. Whether a call asks is policy (nothing asks by default); `allow_always` is deferred (grant storage). |
|
||||
| `fs/read_text_file` | S | ❌ | ✅ | ❌ | The harness reads files directly (it does not see the editor's unsaved buffer state). Claude delegates; Codex does not. |
|
||||
| `fs/write_text_file` | S | ❌ | ✅ | ❌ | Same — direct writes, no editor delegation. |
|
||||
| `terminal/create` | S | ❌ | ❌ | ❌ | Neither reference adapter drives the client terminal API either — both, like the bridge, render shell output as tool-call content + a `_meta` channel (see [§5 Terminal](#terminal-rendering)). |
|
||||
| `terminal/output` | S | ❌ | ❌ | ❌ | As above. |
|
||||
| `terminal/wait_for_exit` | S | ❌ | ❌ | ❌ | As above. |
|
||||
| `terminal/kill` | S | ❌ | ❌ | ❌ | As above. |
|
||||
| `terminal/release` | S | ❌ | ❌ | ❌ | As above. |
|
||||
| `elicitation/create` · `elicitation/complete` | U | ⚠️ | ✅ | ⚠️ | The bridge drives `unstable_createElicitation` for `ask_user_question` form prompts (session-scoped, no URL-mode flow yet). Claude calls the `unstable_*` elicitation methods for MCP server elicitations; Codex maps elicitations onto `session/request_permission`. |
|
||||
|
||||
## 3. Capabilities
|
||||
|
||||
### 3a. `agentCapabilities` (advertised by the bridge)
|
||||
|
||||
| Capability | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `loadSession` | S | ✅ | ✅ | ✅ | Advertised `true`; backs `session/load`. |
|
||||
| `promptCapabilities.image` | S | ❌ | ✅ | ✅ | Bridge advertises `image: false`; image prompt blocks are rejected. |
|
||||
| `promptCapabilities.audio` | S | ❌ | ❌ | ❌ | `audio: false`; neither adapter accepts audio either. |
|
||||
| `promptCapabilities.embeddedContext` | S | ❌ | ✅ | ✅ | `embeddedContext: false`; embedded `resource` blocks rejected. |
|
||||
| `mcpCapabilities.{http,sse}` | S | ❌ | ✅ | ⚠️ | No MCP passthrough; `mcpServers` is rejected. Claude advertises http+sse, Codex http only. |
|
||||
| `sessionCapabilities.*` | S | ⚠️ | ✅ | ✅ | `list` is advertised; delete/resume/close/additionalDirectories/fork remain off. |
|
||||
| `auth.logout` | S | ❌ | ✅ | ✅ | Not advertised. |
|
||||
| `authMethods[]` | S | ⚠️ | ✅ | ✅ | Advertised as empty (no auth required to reach the model). |
|
||||
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | Fixed literals: `deepseek-harness-acp` / `0.0.1` (not config). |
|
||||
| `_meta` custom caps | S | ❌ | ✅ | — | E.g. Claude's `claudeCode.promptQueueing`. The bridge advertises no custom `_meta`. |
|
||||
|
||||
### 3b. `clientCapabilities` (consumed by the bridge)
|
||||
|
||||
| Capability | Stable | Bridge | Notes |
|
||||
|---|---|---|---|
|
||||
| `fs.{readTextFile,writeTextFile}` | S | ❌ | Not consulted (the bridge never calls `fs/*`). |
|
||||
| `terminal` | S | ❌ | Not consulted; the bridge keys terminal rendering off the Zed `_meta.terminal_output` cap instead. |
|
||||
| `_meta.terminal_output` (Zed) | S (`_meta`) | ✅ | Snapshotted per session at create/load; gates terminal-card rendering. |
|
||||
|
||||
## 4. `session/update` variants
|
||||
|
||||
| `sessionUpdate` | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `agent_message_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` text-delta. |
|
||||
| `agent_thought_chunk` | S | ✅ | ✅ | ✅ | From `assistant/chunk` reasoning-delta. |
|
||||
| `user_message_chunk` | S | ✅ | ✅ | ✅ | Emitted during `session/load` replay to reconstruct the user side. |
|
||||
| `tool_call` | S | ✅ | ✅ | ✅ | Tool-owned presentation (`presentCall`); see [§5](#5-tool-call-rendering). |
|
||||
| `tool_call_update` | S | ✅ | ✅ | ✅ | From appended `tool/result` via `presentResult`; replacement results rewrite model context and do not duplicate or overwrite execution presentation. |
|
||||
| `plan` | S | ❌ | ✅ | ✅ | No agent plan emitted. Both adapters emit real plan entries (Codex's `CodexEventHandler.updatePlan` maps `turn/plan/updated` → `{ sessionUpdate: 'plan', entries }`). |
|
||||
| `available_commands_update` | S | ✅ | ✅ | ✅ | Full effective snapshot after create/load and registry changes; names, descriptions, and unstructured-input hints come from `ctx.commands`. |
|
||||
| `current_mode_update` | S | ✅ | ✅ | ✅ | Echoed optimistically on `session/set_mode` and re-notified when a logged `plan/mode` maps to a different wire id (covers the `exit_plan_mode` tool flipping the session back). |
|
||||
| `config_option_update` | S | ❌ | ✅ | ✅ | Config options exist (advertised in `session/new`/`session/load`, switched via `session/set_config_option`), but the bridge never pushes agent-initiated changes — an operator default drift is narrated to the MODEL, not echoed to the editor. Future work in the [sandbox Agent Note § Per-session mode switching](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). |
|
||||
| `usage_update` | S | ❌ | ✅ | ✅ | Token/cost reporting not surfaced (the harness records token usage internally on `assistant/message`). |
|
||||
| `session_info_update` | S | ✅ | ⚠️ | ⚠️ | Log-backed title events push title and event time; load replay uses the same mapping. |
|
||||
|
||||
## 5. Tool-call rendering
|
||||
|
||||
Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult` on the `dsh-tools` definition), not special-cased in the bridge — see the [terminal-and-tool-rendering Agent Note](../../../.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md).
|
||||
|
||||
| Feature | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit` declared by each tool's `presentCall`; presenter-less tools render `other` (no name sniffing); richer mapping possible. |
|
||||
| `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress` → `completed`/`failed`. |
|
||||
| `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. |
|
||||
| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }`. For an edit or an overwrite it carries the applied hunk(s) with surrounding context (one per `replace_all` site), computed from the before/after text and persisted on the `tool/result` event as `meta`; for a create (no before-image) it is an args-derived whole-file diff. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; a successful mutation ALWAYS returns the result diff (an ACP `tool_call_update.content` replaces the call's content, so the result diff — not the model-facing text — is what survives). |
|
||||
| `terminal` content | S | ✅ | ✅ | ✅ | Via the Zed `_meta` terminal convention (see below), not the spec `terminal/*` sub-protocol. |
|
||||
| `locations` (follow-along) | S | ✅ | ✅ | ✅ | The `read`/`write`/`edit` tools emit `{ path, line? }` file-location hints via `presentCall`. |
|
||||
| `rawInput` | S | ✅ | ⚠️ | ✅ | Parsed tool args surfaced as `rawInput`. |
|
||||
| `rawOutput` | S | ❌ | ⚠️ | ✅ | Not emitted. |
|
||||
|
||||
### Terminal rendering
|
||||
|
||||
⚠️ Implemented via the **Zed `_meta` convention** (`terminal_info` / `terminal_output` / `terminal_exit`), gated on the client advertising `_meta.terminal_output` — NOT the spec's `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox / env-scrub / ownership / cwd). Both reference adapters take the same `_meta` approach. Live incremental streaming (`terminal_output_delta`, which Codex negotiates) is a follow-up — the bridge currently sends the full captured output once on the result.
|
||||
|
||||
## 6. Session modes / config options / models
|
||||
|
||||
Session modes ✅ (the [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md)): ACP owns the fixed `default` / `plan` wire vocabulary and projects it onto `ctx.planMode`'s boolean `{ active, pending? }` state; `session/set_mode` calls `set()` and `current_mode_update` tracks the optimistic selection plus each distinct committed `plan/mode` flip. Config options ✅: the bridge advertises a `model` select from the advisory LLM provider/model catalog, preserving each provider/model pair in an opaque value and grouping multiple providers. A selected pair is isolated to one session, snapshotted with the prompt for each step, applied through `agent/request`, and restored from the logged request header on load. When `ctx.permission` is composed, the bridge also advertises one `permission` select whose values come from the deployment preset table and whose current value derives from the session log; idle permission switches anchor at the next `agent/prompt-submit` inside its open turn. The division is picker-to-collaboration-state / knobs-to-config-options: individual environment knobs and the provider/model selector are not modes. See the [model-catalog Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## 7. Content blocks
|
||||
|
||||
| Block | Stable | In prompts | In updates | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `text` | S | ✅ | ✅ | Baseline. |
|
||||
| `resource_link` | S | ✅ | ⚠️ | Accepted in prompts and rendered into text (`acpPromptToText`); not emitted as a structured update block. |
|
||||
| `image` | S | ❌ | ❌ | Rejected in prompts (`promptCapabilities.image: false`). |
|
||||
| `audio` | S | ❌ | ❌ | Rejected. |
|
||||
| `resource` (embedded) | S | ❌ | ❌ | Rejected (`embeddedContext: false`). |
|
||||
|
||||
The bridge rejects unsupported prompt blocks rather than silently dropping them (`promptHasUnsupportedContent`), per the "explicit over implicit" convention.
|
||||
|
||||
## 8. Cross-cutting
|
||||
|
||||
| Feature | Stable | Bridge | Notes |
|
||||
|---|---|---|---|
|
||||
| `StopReason` mapping | S | ✅ | `turnEndToStopReason` is total over harness turn-end reasons → `end_turn`/`max_tokens`/`cancelled`. |
|
||||
| Multi-session (N per connection) | S | ✅ | Strict per-session demux; concurrent streams never interleave. See the [multi-session Agent Note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md). |
|
||||
| Disconnect / disposal teardown | S | ✅ | Quiesces every live session on client disconnect or Cordis disposal. |
|
||||
| `_meta` extensibility | S | ⚠️ | Consumed for the Zed terminal cap and emitted for terminal cards. Listed sessions add `deepseek-harness/sessionReference` with a canonical URI when cross-session references are mounted. |
|
||||
| Background-task ownership isolation | — | ✅ | Generic `task_output`/`task_kill` reject tasks whose branded owner `SessionId` belongs to another session. |
|
||||
| stdout-is-the-protocol guarantee | S | ✅ | The bridge runs in an example with no stdout logger. |
|
||||
|
||||
## Gap summary
|
||||
|
||||
Ranked by how commonly the reference adapters ship them and how much UX they unlock:
|
||||
|
||||
1. **Session lifecycle** — `session/delete`, then `session/resume` / `session/close`.
|
||||
2. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
|
||||
3. **MCP passthrough** (`mcpServers` on `session/new` + `mcpCapabilities`).
|
||||
4. **Richer prompt content** — image / embedded `resource` blocks (needs a multimodal model path).
|
||||
5. **Usage reporting** (`usage_update`) — the harness already records token usage internally (on `assistant/message`).
|
||||
6. **Editor filesystem delegation** (`fs/read_text_file` / `fs/write_text_file`) — lets the agent see unsaved buffers; lower priority since the harness has direct disk access.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Unstable/draft ACP features that **neither** reference adapter ships are not tracked above: `providers/*` (LLM provider selection), `mcp/connect`·`mcp/message`·`mcp/disconnect` (client-side MCP passthrough), `nes/*` (Next Edit Suggestion), `document/did*` (LSP-style document sync), the v2 plan model (`plan_update` / `plan_removed`), boolean config options, `$/cancel_request`, and the draft Streamable-HTTP transport. They can be added if a target editor adopts them.
|
||||
|
||||
## Sources
|
||||
|
||||
- Stable spec: `schema/v1/schema.json` (schema `1.14.0`) and `docs/protocol/v1/*.mdx` in the [agent-client-protocol](https://github.com/agentclientprotocol/agent-client-protocol) repo.
|
||||
- Reference adapters: [`claude-agent-acp`](https://github.com/zed-industries/claude-code-acp) and [`codex-acp`](https://github.com/zed-industries/codex-acp).
|
||||
- Bridge: [`README.md`](README.md), [`src/index.ts`](src/index.ts), and the ACP Agent Notes under [`.agents/notes/`](../../../.agents/notes/README.md).
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp",
|
||||
"description": "Agent Client Protocol (ACP) bridge: drive DeepSeek Harness SDK agents from an ACP editor over JSON-RPC stdio",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
"schemastery": "^3.17.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-bash": "^0.0.1",
|
||||
"@deepseek-ai/dsh-commands": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-plan-mode": "^0.0.1",
|
||||
"@deepseek-ai/dsh-permission": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sandbox": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-reference": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-plan-mode": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission": "workspace:^",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<!-- Generated by scripts/gen-doc-graphs.ts - do not edit by hand.
|
||||
Run `pnpm run gen-doc-graphs` to regenerate. -->
|
||||
|
||||
# ACP Snapshot Replay
|
||||
|
||||
This graph explains what a snapshot scenario proves: recorded real-model session logs are replayed keylessly, ACP stdout is normalized and diffed, and scenario workspaces preserve tool side effects that the UI stream alone cannot prove.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Recorder as Real API recording
|
||||
participant Fixture as snapshot fixture
|
||||
participant Workspace
|
||||
participant Replay as llm-replay adapter
|
||||
participant ACP as acp-agent subprocess
|
||||
participant Expected as stdout expected output
|
||||
Recorder->>Fixture: session.jsonl + workspace inputs
|
||||
Fixture->>Workspace: seed files and hook configs
|
||||
Fixture->>Replay: recorded StreamChunk script
|
||||
Replay->>ACP: deterministic <code>llm/stream</code> chunks
|
||||
ACP->>Workspace: bash, fs, and hook side effects
|
||||
ACP->>Expected: normalized sessionUpdate stream
|
||||
Expected-->>ACP: diff must be empty
|
||||
```
|
||||
|
||||
The fs and hook snapshot matrix is valuable because it proves world state, hook decisions, and failed tool-card rendering, not just that replay returns text.
|
||||
|
||||
Maintenance mode: curated Mermaid sequence based on the snapshot test harness.
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* Pure, total translation between harness vocabulary and ACP wire types.
|
||||
* @module @deepseek-ai/dsh-acp/codec
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
SESSION_REFERENCE_SCHEME,
|
||||
decodeSessionReferenceUri,
|
||||
parseSessionReferenceText,
|
||||
type SessionReferenceInput,
|
||||
} from '@deepseek-ai/dsh-session-reference'
|
||||
import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk'
|
||||
|
||||
/**
|
||||
* Map a harness {@link TurnEndReason} to the ACP `StopReason` wire enum.
|
||||
*
|
||||
* `completed` and the defensive `error` case map to `end_turn`;
|
||||
* `max-tokens` maps to `max_tokens`; `aborted`, `disposed`, and `rejected` map
|
||||
* to `cancelled`. The bridge rejects error turns before this mapping. Unknown
|
||||
* merge-extensible kinds use legal fallback `end_turn` rather than breaking
|
||||
* the prompt RPC.
|
||||
* @param reason - the harness turn-end reason to translate.
|
||||
* @returns the legal ACP wire value per the mapping above.
|
||||
*/
|
||||
export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
switch (reason.kind) {
|
||||
case 'completed':
|
||||
return 'end_turn'
|
||||
case 'max-tokens':
|
||||
return 'max_tokens'
|
||||
case 'aborted':
|
||||
return 'cancelled'
|
||||
case 'disposed':
|
||||
return 'cancelled'
|
||||
case 'rejected':
|
||||
return 'cancelled'
|
||||
case 'error':
|
||||
return 'end_turn'
|
||||
// Merge-extensible: an unknown future TurnEndReason kind still has to produce a legal wire
|
||||
// value (the SDK rejects unknown stopReason), so default to end_turn rather than
|
||||
// assertNever.
|
||||
default:
|
||||
return 'end_turn'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map replayable text to ACP message content. Other block kinds use their
|
||||
* prompt, thought-stream, or tool-update paths.
|
||||
* @param block - the harness content block to translate.
|
||||
* @returns the ACP block, or `undefined` for a kind with no message-content mapping.
|
||||
*/
|
||||
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return { type: 'text', text: block.text }
|
||||
// reasoning → streamed as agent_thought_chunk, not a message block
|
||||
// tool-call / tool-result → the tool_call / tool_call_update path
|
||||
// plugin-added block types → not surfaced
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract plain text from an ACP prompt's content blocks. Text blocks are
|
||||
* concatenated verbatim; resource links become explicit textual references so
|
||||
* baseline ACP clients can point at files without the bridge silently dropping
|
||||
* that context.
|
||||
* @param prompt - the ACP prompt blocks to flatten.
|
||||
* @returns the concatenated text, with resource links rendered as bracketed references.
|
||||
*/
|
||||
export function acpPromptToText(prompt: readonly AcpContentBlock[]): string {
|
||||
return prompt
|
||||
.flatMap((block): string[] => {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
return [block.text]
|
||||
case 'resource_link':
|
||||
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** ACP prompt text plus structured session references extracted from text and resource links. */
|
||||
export interface AcpReferencedPrompt {
|
||||
/** Readable prompt text with opaque session URIs removed. */
|
||||
text: string
|
||||
/** Structured session references in ACP block and inline appearance order. */
|
||||
references: SessionReferenceInput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract canonical session references while preserving ordinary ACP resource links.
|
||||
* @param prompt - already-supported ACP prompt blocks.
|
||||
* @returns readable text and structured references.
|
||||
* @throws when any observed `dsh-session:` URI is malformed.
|
||||
*/
|
||||
export function acpPromptToReferencedPrompt(prompt: readonly AcpContentBlock[]): AcpReferencedPrompt {
|
||||
const references: SessionReferenceInput[] = []
|
||||
const text = prompt.flatMap((block): string[] => {
|
||||
switch (block.type) {
|
||||
case 'text': {
|
||||
const parsed = parseSessionReferenceText(block.text)
|
||||
references.push(...parsed.references)
|
||||
return [parsed.text]
|
||||
}
|
||||
case 'resource_link': {
|
||||
if (!block.uri.startsWith(SESSION_REFERENCE_SCHEME)) {
|
||||
return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`]
|
||||
}
|
||||
const sessionId = decodeSessionReferenceUri(block.uri)
|
||||
const label = block.name === '' ? sessionId : block.name
|
||||
references.push({ sessionId, label })
|
||||
return [`@${label}`]
|
||||
}
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}).join('')
|
||||
return { text, references }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an ACP prompt contains content the bridge cannot accept. Baseline ACP
|
||||
* requires `text` and `resource_link`; richer inline payloads (`resource`,
|
||||
* image, audio, …) are rejected rather than silently dropped.
|
||||
* @param prompt - the ACP prompt blocks to inspect.
|
||||
* @returns `true` when any block is neither `text` nor `resource_link`.
|
||||
*/
|
||||
export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean {
|
||||
return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link')
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-acp`.
|
||||
* @module @deepseek-ai/dsh-acp/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-acp'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'acp-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this presentation adapter owns no durable package-local event stream;
|
||||
* boundary and replay tests cover its protocol mapping.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
@@ -1,113 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
|
||||
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* The bridge's `approval/request` answerer: an ask for an agent the bridge
|
||||
* owns becomes a `session/request_permission` prompt attached to the tool
|
||||
* call; foreign or call-less requests delegate down to the fail-closed
|
||||
* default. Driven through `ctx.approval` — the same path dsh-tools' ask
|
||||
* routing takes — against the harness's scriptable client.
|
||||
*/
|
||||
describe('acp bridge — approval answerer', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-approval-')) })
|
||||
afterEach(async () => {
|
||||
await harness?.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function ownedAgentRequest(
|
||||
h: BridgeHarness, overrides: Partial<ApprovalRequest> = {},
|
||||
): Promise<{ agent: Agent; request: ApprovalRequest }> {
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.get(SessionId(sessionId))
|
||||
if (agent === undefined) throw new Error('newSession created no agent')
|
||||
// In production an ask always fires mid-turn (tool execution); open one so
|
||||
// request()'s turn-enclosure precondition holds for the direct drive below.
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
return { agent, request: { agent, toolName: 'echo', callId: CallId('call-9'), ...overrides } }
|
||||
}
|
||||
|
||||
it('prompts the editor for an owned agent and maps allow-once → allowed-once', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once')
|
||||
|
||||
expect(harness.permissionRequests).toHaveLength(1)
|
||||
const wire = harness.permissionRequests[0]
|
||||
expect(wire?.toolCall).toEqual({ toolCallId: 'call-9' })
|
||||
expect(wire?.options.map(o => ({ optionId: o.optionId, kind: o.kind }))).toEqual([
|
||||
{ optionId: 'allow-once', kind: 'allow_once' },
|
||||
{ optionId: 'reject-once', kind: 'reject_once' },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps reject-once → rejected', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('maps a client cancellation → cancelled', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'cancelled' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('cancelled')
|
||||
})
|
||||
|
||||
it('treats an unknown optionId from a non-conforming client as a rejection, never a grant', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-always-i-insist' } })
|
||||
|
||||
const { request } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request(request)).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('delegates a foreign agent down to the fail-closed default', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
|
||||
const { agent } = await ownedAgentRequest(harness)
|
||||
// Even an impostor that claims the bridge-owned session id must delegate:
|
||||
// ownership requires the exact Agent object stored in the session record.
|
||||
const foreign = {
|
||||
session: { id: agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) },
|
||||
} as unknown as Agent
|
||||
await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') }))
|
||||
.resolves.toBe('unavailable')
|
||||
expect(harness.permissionRequests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('delegates a call-less request — the protocol prompt must attach to a tool call', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } })
|
||||
|
||||
const { agent } = await ownedAgentRequest(harness)
|
||||
await expect(harness.ctx.approval.request({ agent, toolName: 'echo' })).resolves.toBe('unavailable')
|
||||
expect(harness.permissionRequests).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,468 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { encodeSessionReferenceUri, formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||||
|
||||
/**
|
||||
* End-to-end bridge specs over an in-memory transport: a real
|
||||
* ClientSideConnection drives the bridge's AgentSideConnection, so every
|
||||
* assertion exercises actual JSON-RPC framing and the harness event taxonomy.
|
||||
*/
|
||||
describe('acp bridge', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
storageDir = await mkdtemp(join(tmpdir(), 'acp-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// e2e/integration tests own their resources (docs/testing.md): dispose even on
|
||||
// failure so a flaky run never leaks a context or persistence dir.
|
||||
if (harness) await harness.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('initialize negotiates the protocol version and advertises capabilities', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
const res = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
expect(res.protocolVersion).toBe(PROTOCOL_VERSION)
|
||||
expect(res.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(res.agentCapabilities?.promptCapabilities).toMatchObject({ image: false, audio: false })
|
||||
expect(res.agentInfo).toEqual({ name: 'deepseek-harness-acp', version: '0.0.1' })
|
||||
})
|
||||
|
||||
it('session/new creates a session and a full prompt turn streams text then settles end_turn', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('hello there')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(sessionId).toBeTruthy()
|
||||
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] })
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
|
||||
// The streamed text arrived as agent_message_chunk updates.
|
||||
const text = harness.updates
|
||||
.filter(u => u.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
expect(text).toBe('hello there')
|
||||
})
|
||||
|
||||
it('routes ask_user_question through ACP form elicitation and continues with the selected option', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withAskUser: true,
|
||||
script: [
|
||||
toolCallResponse('ask-1', 'ask_user_question', {
|
||||
questions: [{
|
||||
id: 'language',
|
||||
header: 'Project config',
|
||||
question: 'Which language should I use?',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: 'Good for UI apps' },
|
||||
{ label: 'Python', description: 'Good for scripts' },
|
||||
],
|
||||
}],
|
||||
}),
|
||||
textResponse('Python it is.'),
|
||||
],
|
||||
})
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'Python' } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'ask me' }] })
|
||||
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
expect(harness.elicitationRequests).toHaveLength(1)
|
||||
expect(harness.elicitationRequests[0]).toMatchObject({
|
||||
sessionId,
|
||||
mode: 'form',
|
||||
message: 'Which language should I use?',
|
||||
requestedSchema: {
|
||||
title: 'Project config',
|
||||
properties: {
|
||||
choice: {
|
||||
oneOf: [
|
||||
{ const: 'TypeScript', title: 'TypeScript: Good for UI apps' },
|
||||
{ const: 'Python', title: 'Python: Good for scripts' },
|
||||
],
|
||||
},
|
||||
custom: { type: 'string' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
})
|
||||
const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result')
|
||||
const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined
|
||||
const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined
|
||||
expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}')
|
||||
})
|
||||
|
||||
it('routes optionless ask_user_question through an ACP free-form answer field', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withAskUser: true,
|
||||
script: [
|
||||
toolCallResponse('ask-1', 'ask_user_question', {
|
||||
questions: [{ id: 'name', question: 'What should I name it?' }],
|
||||
}),
|
||||
textResponse('Name recorded.'),
|
||||
],
|
||||
})
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'apollo' } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'ask me' }] })
|
||||
|
||||
expect(harness.elicitationRequests[0]).toMatchObject({
|
||||
requestedSchema: {
|
||||
properties: { custom: { type: 'string', title: 'What should I name it?' } },
|
||||
required: ['custom'],
|
||||
},
|
||||
})
|
||||
const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result')
|
||||
expect(JSON.stringify(toolResult)).toContain('apollo')
|
||||
})
|
||||
|
||||
it('supports ACP custom answers alongside choices', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
const result = await harness.ctx.userInteraction.ask({
|
||||
agent,
|
||||
questions: [{
|
||||
id: 'language',
|
||||
question: 'Which language?',
|
||||
detail: 'Choose the implementation language for this project.',
|
||||
options: [{ label: 'TypeScript' }],
|
||||
}],
|
||||
})
|
||||
|
||||
expect(result).toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
|
||||
expect(harness.elicitationRequests[0]).toMatchObject({
|
||||
message: 'Which language?\n\nChoose the implementation language for this project.',
|
||||
requestedSchema: {
|
||||
properties: {
|
||||
choice: {
|
||||
title: 'Which language?',
|
||||
description: 'Choose one option, or fill a custom answer below.',
|
||||
oneOf: [{ const: 'TypeScript', title: 'TypeScript' }],
|
||||
},
|
||||
custom: { type: 'string' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('treats ACP custom answers as overriding selected choices', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
await expect(harness.ctx.userInteraction.ask({
|
||||
agent,
|
||||
questions: [{
|
||||
id: 'language',
|
||||
question: 'Which language?',
|
||||
options: [{ label: 'TypeScript' }],
|
||||
}],
|
||||
})).resolves.toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
|
||||
})
|
||||
|
||||
it('supports ACP multi-select answers', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
await expect(harness.ctx.userInteraction.ask({
|
||||
agent,
|
||||
questions: [{
|
||||
id: 'targets',
|
||||
question: 'Pick',
|
||||
options: [{ label: 'Tests' }, { label: 'Docs' }],
|
||||
multiSelect: true,
|
||||
}],
|
||||
})).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Tests', 'Docs'] }] })
|
||||
})
|
||||
|
||||
it('reports ACP ask-user routing and answer failures as structured errors', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] }))
|
||||
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
|
||||
const impostor = { session: { id: agent.session.id } } as typeof agent
|
||||
await expect(harness.ctx.userInteraction.ask({ agent: impostor, questions: [{ id: 'x', question: 'No session?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_SESSION' })
|
||||
|
||||
harness.onElicitation = () => ({ action: 'cancel' })
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Cancel?' }] }))
|
||||
.rejects.toMatchObject({ code: 'ASK_CANCELLED' })
|
||||
|
||||
harness.onElicitation = () => ({ action: 'accept', content: {} })
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Empty?' }] }))
|
||||
.rejects.toMatchObject({ code: 'NO_ANSWER' })
|
||||
|
||||
harness.onElicitation = () => { throw new Error('client boom') }
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Client fails?' }], signal: new AbortController().signal }))
|
||||
.rejects.toMatchObject({ code: 'ASK_FAILED' })
|
||||
})
|
||||
|
||||
it('aborts ACP ask-user requests before and while waiting for elicitation', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
const alreadyAborted = new AbortController()
|
||||
alreadyAborted.abort()
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Already?' }], signal: alreadyAborted.signal }))
|
||||
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
let abortedReads = 0
|
||||
const racingAbort = {
|
||||
get aborted() { return abortedReads++ > 0 },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent() { return false },
|
||||
onabort: null,
|
||||
reason: undefined,
|
||||
throwIfAborted() {},
|
||||
} as AbortSignal
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Raced?' }], signal: racingAbort }))
|
||||
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
let release: ((value: { action: 'accept'; content: { custom: string } }) => void) | undefined
|
||||
harness.onElicitation = () => new Promise((resolve) => { release = resolve })
|
||||
const pendingAbort = new AbortController()
|
||||
const ask = harness.ctx.userInteraction.ask({ agent, questions: [{ id: 'x', question: 'Pending?' }], signal: pendingAbort.signal })
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
pendingAbort.abort()
|
||||
|
||||
await expect(ask).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
release?.({ action: 'accept', content: { custom: 'too late' } })
|
||||
})
|
||||
|
||||
it('allows multiple concurrent sessions, each with a distinct id', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(a.sessionId).toBeTruthy()
|
||||
expect(b.sessionId).toBeTruthy()
|
||||
expect(a.sessionId).not.toBe(b.sessionId)
|
||||
// Both agents are live and independently registered.
|
||||
expect(harness.ctx.agents.get(SessionId(a.sessionId))).toBeDefined()
|
||||
expect(harness.ctx.agents.get(SessionId(b.sessionId))).toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// Relative cwd is still rejected (it becomes the session header / bash workdir).
|
||||
await expect(harness.client.newSession({ cwd: 'relative/path', mcpServers: [] }))
|
||||
.rejects.toThrow(/absolute/)
|
||||
// An absolute cwd that differs from the server launch dir is now ACCEPTED —
|
||||
// the per-session cwd is honored (routed to the bash workdir), so the server
|
||||
// no longer has to launch in the workspace.
|
||||
const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] })
|
||||
expect(res.sessionId).toBeTruthy()
|
||||
// The session header records that cwd, so its bash tools run there.
|
||||
expect(harness.ctx.agents.get(SessionId(res.sessionId))!.session.header.cwd).toBe('/tmp')
|
||||
})
|
||||
|
||||
it('rejects non-empty additionalDirectories', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [], additionalDirectories: ['/x'] }))
|
||||
.rejects.toThrow(/additionalDirectories/)
|
||||
})
|
||||
|
||||
it('rejects an empty prompt without hanging', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: ' ' }] }))
|
||||
.rejects.toThrow(/empty prompt/)
|
||||
})
|
||||
|
||||
it('rejects image content in a prompt (text-only capabilities)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'image', mimeType: 'image/png', data: 'AA==' }],
|
||||
})).rejects.toThrow(/text/)
|
||||
})
|
||||
|
||||
it('accepts a resource_link prompt by rendering the link into the text sent to the agent', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const result = await harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'text', text: 'fix the bug in' },
|
||||
{ type: 'resource_link', uri: 'file:///x.ts', name: 'x.ts' },
|
||||
],
|
||||
})
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
const user = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'user/message')
|
||||
expect(JSON.stringify(user)).toContain('resource_link')
|
||||
})
|
||||
|
||||
it('rejects canonical session references when the optional capability is not mounted', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(SessionId('source')), name: 'source' }],
|
||||
})).rejects.toThrow(/session reference capability unavailable/)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reports malformed inline session references at the ACP request boundary', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: 'use dsh-session:IiJ' }],
|
||||
})).rejects.toThrow(/invalid session reference/)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('prepares ACP session resource links and inline mentions before one atomic send', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [textResponse('ok')] })
|
||||
const source = harness.ctx.sessions.create(SessionId('source'), { meta: { cwd: '/source' } })
|
||||
source.append('user/message', {
|
||||
content: [{ type: 'text', text: 'source background' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const mention = formatSessionReferenceMention({ sessionId: source.id, label: 'source-inline' })
|
||||
const result = await harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'text', text: `use ${mention} and ` },
|
||||
{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source-link' },
|
||||
],
|
||||
})
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
|
||||
const target = harness.ctx.agents.get(SessionId(sessionId))!.session
|
||||
const user = target.events.find(event => event.type === 'user/message')
|
||||
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({
|
||||
displayContent: [{ type: 'text', text: 'use @source-inline and @source-link' }],
|
||||
prefixContexts: [{
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'source', label: 'source-inline' }],
|
||||
},
|
||||
}],
|
||||
})
|
||||
expect(target.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
const request = JSON.stringify(harness.adapter.requests[0]?.messages)
|
||||
expect(request).toContain('untrusted, read-only snapshot')
|
||||
expect(request).toContain('source background')
|
||||
expect(request.indexOf('source background')).toBeLessThan(request.indexOf('## My request:'))
|
||||
expect(request.indexOf('## My request:')).toBeLessThan(request.indexOf('use @source-inline and @source-link'))
|
||||
})
|
||||
|
||||
it('rejects a failed referenced-session read before starting a turn', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(SessionId('missing')), name: 'missing' }],
|
||||
})).rejects.toThrow(/preparation failed/)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('cancels reference preparation before a turn is created', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true, script: [] })
|
||||
const source = harness.ctx.sessions.create(SessionId('source'))
|
||||
const snapshot = await harness.ctx.sessionQuery.readSurface(source.id)
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
let releaseRead: (() => void) | undefined
|
||||
const readSurface = vi.spyOn(harness.ctx.sessionQuery, 'readSurface').mockImplementationOnce(async () => {
|
||||
await new Promise<void>((resolve) => { releaseRead = resolve })
|
||||
return snapshot
|
||||
})
|
||||
const pending = harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'resource_link', uri: encodeSessionReferenceUri(source.id), name: 'source' }],
|
||||
})
|
||||
await vi.waitFor(() => { expect(releaseRead).toBeTypeOf('function') })
|
||||
await harness.client.cancel({ sessionId })
|
||||
await expect(pending).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
|
||||
releaseRead?.()
|
||||
await Promise.resolve()
|
||||
readSurface.mockRestore()
|
||||
})
|
||||
|
||||
it('rejects a prompt for an unknown session', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.prompt({ sessionId: 'nope', prompt: [{ type: 'text', text: 'hi' }] }))
|
||||
.rejects.toThrow(/unknown session/)
|
||||
})
|
||||
|
||||
it('negotiates an unsupported protocol version down to the supported one', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
const res = await harness.client.initialize({ protocolVersion: 999, clientCapabilities: {} })
|
||||
expect(res.protocolVersion).toBe(PROTOCOL_VERSION)
|
||||
})
|
||||
|
||||
it('a cancel for an unknown/absent session is a silent no-op', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// No session created yet — cancel must not throw.
|
||||
await expect(harness.client.cancel({ sessionId: 'nope' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('authenticate is a no-op (no auth methods advertised)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.authenticate({ methodId: 'whatever' })).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('renders the deployment persona into ACP-created agents\' requests', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
persona: 'be terse',
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// Create + prompt so the system-prompt plugin's persona section reaches
|
||||
// the model request of an agent the BRIDGE created (session/new).
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] })
|
||||
expect(harness.adapter.requests[0]?.system).toContain('be terse')
|
||||
})
|
||||
})
|
||||
@@ -1,101 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { encodeSessionReferenceUri, formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||||
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
acpPromptToReferencedPrompt,
|
||||
acpPromptToText,
|
||||
harnessBlockToAcpContent,
|
||||
promptHasUnsupportedContent,
|
||||
turnEndToStopReason,
|
||||
} from '../src/codec.ts'
|
||||
|
||||
describe('turnEndToStopReason', () => {
|
||||
// The SDK rejects an unknown stopReason, so this must be total over every
|
||||
// TurnEndReason kind and always produce a legal wire value.
|
||||
it('maps every known TurnEndReason kind to a legal StopReason', () => {
|
||||
expect(turnEndToStopReason({ kind: 'completed' })).toBe('end_turn')
|
||||
expect(turnEndToStopReason({ kind: 'max-tokens' })).toBe('max_tokens')
|
||||
expect(turnEndToStopReason({ kind: 'aborted' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'disposed' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'rejected', reason: 'blocked by hook' })).toBe('cancelled')
|
||||
expect(turnEndToStopReason({ kind: 'error', step: 1, message: 'boom' })).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('falls back to end_turn for an unknown (merge-extensible) future kind', () => {
|
||||
// A plugin-added TurnEndReason variant the bridge does not yet know about
|
||||
// must still produce a legal wire value, not throw into the SDK.
|
||||
const future = { kind: 'refusal' } as unknown as TurnEndReason
|
||||
expect(turnEndToStopReason(future)).toBe('end_turn')
|
||||
})
|
||||
})
|
||||
|
||||
describe('harnessBlockToAcpContent', () => {
|
||||
it('maps a text block to ACP text content', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' })
|
||||
})
|
||||
|
||||
it('returns undefined for non-text blocks (reasoning / plugin-added)', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'reasoning', text: 'think' })).toBeUndefined()
|
||||
expect(harnessBlockToAcpContent({ type: 'chart', data: 'x' } as unknown as ContentBlock)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('acpPromptToText', () => {
|
||||
it('concatenates text blocks and renders resource links explicitly', () => {
|
||||
const prompt: AcpContentBlock[] = [
|
||||
{ type: 'text', text: 'hello ' },
|
||||
{ type: 'resource_link', uri: 'file:///x', name: 'x' },
|
||||
{ type: 'text', text: 'world' },
|
||||
]
|
||||
expect(acpPromptToText(prompt)).toBe('hello \n[resource_link name="x" uri="file:///x"]\nworld')
|
||||
})
|
||||
|
||||
it('returns empty string for a prompt with no text blocks', () => {
|
||||
expect(acpPromptToText([{ type: 'image', mimeType: 'image/png', data: 'AA==' }])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('acpPromptToReferencedPrompt', () => {
|
||||
it('extracts resource links and inline mentions while preserving ordinary links', () => {
|
||||
const sessionId = SessionId('source/会话')
|
||||
const prompt: AcpContentBlock[] = [
|
||||
{ type: 'text', text: `compare ${formatSessionReferenceMention({ sessionId, label: 'inline' })} with ` },
|
||||
{ type: 'resource_link', uri: encodeSessionReferenceUri(sessionId), name: 'linked' },
|
||||
{ type: 'resource_link', uri: 'file:///x', name: 'x' },
|
||||
]
|
||||
expect(acpPromptToReferencedPrompt(prompt)).toEqual({
|
||||
text: 'compare @inline with @linked\n[resource_link name="x" uri="file:///x"]\n',
|
||||
references: [{ sessionId, label: 'inline' }, { sessionId, label: 'linked' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed session resource links', () => {
|
||||
expect(() => acpPromptToReferencedPrompt([
|
||||
{ type: 'resource_link', uri: 'dsh-session:%%%', name: 'bad' },
|
||||
])).toThrow(/invalid session reference URI/)
|
||||
})
|
||||
|
||||
it('uses the decoded id for an empty resource name and ignores unsupported direct inputs', () => {
|
||||
const sessionId = SessionId('source')
|
||||
expect(acpPromptToReferencedPrompt([
|
||||
{ type: 'resource_link', uri: encodeSessionReferenceUri(sessionId), name: '' },
|
||||
{ type: 'image', mimeType: 'image/png', data: 'AA==' },
|
||||
])).toEqual({ text: '@source', references: [{ sessionId, label: 'source' }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('promptHasUnsupportedContent', () => {
|
||||
it('detects image, audio, and embedded resource blocks', () => {
|
||||
expect(promptHasUnsupportedContent([{ type: 'image', mimeType: 'image/png', data: 'AA==' }])).toBe(true)
|
||||
expect(promptHasUnsupportedContent([{ type: 'audio', mimeType: 'audio/wav', data: 'AA==' }])).toBe(true)
|
||||
expect(promptHasUnsupportedContent([{ type: 'resource', resource: { uri: 'file:///x', text: 'x' } }])).toBe(true)
|
||||
})
|
||||
|
||||
it('passes baseline text and resource_link prompt blocks', () => {
|
||||
expect(promptHasUnsupportedContent([{ type: 'text', text: 'hi' }])).toBe(false)
|
||||
expect(promptHasUnsupportedContent([{ type: 'resource_link', uri: 'file:///x', name: 'x' }])).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,299 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
function commandUpdates(harness: BridgeHarness, sessionId: string) {
|
||||
return harness.sessionUpdates.filter(update => update.sessionId === sessionId
|
||||
&& update.update.sessionUpdate === 'available_commands_update')
|
||||
}
|
||||
|
||||
function messageText(harness: BridgeHarness, sessionId: string): string {
|
||||
return harness.sessionUpdates
|
||||
.filter(update => update.sessionId === sessionId && update.update.sessionUpdate === 'agent_message_chunk')
|
||||
.map(({ update }) => update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text'
|
||||
? update.content.text : '')
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('ACP plugin commands', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-command-')) })
|
||||
afterEach(async () => {
|
||||
if (harness !== undefined) await harness.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('publishes a full command snapshot after session creation and refreshes it dynamically', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
harness.ctx.commands.register({
|
||||
name: 'inspect',
|
||||
description: 'Inspect the session',
|
||||
input: { hint: '<target>' },
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(commandUpdates(harness!, sessionId).at(-1)?.update).toEqual({
|
||||
sessionUpdate: 'available_commands_update',
|
||||
availableCommands: [{
|
||||
name: 'inspect',
|
||||
description: 'Inspect the session',
|
||||
input: { hint: '<target>' },
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
const dispose = harness.ctx.commands.register({
|
||||
name: 'alpha',
|
||||
description: 'Alpha command',
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({
|
||||
availableCommands: [{ name: 'alpha' }, { name: 'inspect' }],
|
||||
})
|
||||
})
|
||||
dispose()
|
||||
await vi.waitFor(() => {
|
||||
expect(commandUpdates(harness!, sessionId).at(-1)?.update).toMatchObject({
|
||||
availableCommands: [{ name: 'inspect' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('re-advertises commands after loading a persisted session', async () => {
|
||||
const live = await makeBridgeHarness({ storageDir, script: [textResponse('persisted')] })
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist this session' }] })
|
||||
await live.dispose()
|
||||
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
harness.ctx.commands.register({
|
||||
name: 'loaded', description: 'Loaded command', handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await harness.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
expect(commandUpdates(harness, sessionId).at(-1)?.update).toMatchObject({
|
||||
availableCommands: [{ name: 'loaded', description: 'Loaded command' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('coalesces registry changes before a new session command snapshot is announced', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
harness.ctx.commands.register({
|
||||
name: 'raced', description: 'Registered after the response', handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(commandUpdates(harness!, sessionId)).toHaveLength(1)
|
||||
expect(commandUpdates(harness!, sessionId)[0]?.update).toMatchObject({
|
||||
availableCommands: [{ name: 'raced' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('executes a known single-text command directly and never sends it to the model', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
const seen = vi.fn(() => ({ kind: 'success' as const, text: 'DIRECT RESULT' }))
|
||||
harness.ctx.commands.register({ name: 'direct', description: 'Run directly', handler: seen })
|
||||
harness.ctx.commands.register({
|
||||
name: 'silent', description: 'Return no text', handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
harness.ctx.commands.register({
|
||||
name: 'empty', description: 'Return empty text', handler: () => ({ kind: 'success', text: '' }),
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const response = await harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [{ type: 'text', text: '/direct raw args ' }],
|
||||
})
|
||||
|
||||
expect(response.stopReason).toBe('end_turn')
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({ rawInput: ' raw args ' }))
|
||||
expect(messageText(harness, sessionId)).toContain('DIRECT RESULT')
|
||||
const updatesAfterText = harness.sessionUpdates.length
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/silent' }] })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/empty' }] })
|
||||
expect(harness.sessionUpdates).toHaveLength(updatesAfterText)
|
||||
expect(harness.adapter.requests).toHaveLength(0)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders expected command errors and rejects unknown slash commands without model fallback', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
harness.ctx.commands.register({
|
||||
name: 'denied',
|
||||
description: 'Deny directly',
|
||||
handler: () => ({ kind: 'error', text: 'not allowed now' }),
|
||||
})
|
||||
harness.ctx.commands.register({
|
||||
name: 'throws',
|
||||
description: 'Throw an ordinary error',
|
||||
handler: () => { throw new Error('handler exploded') },
|
||||
})
|
||||
harness.ctx.commands.register({
|
||||
name: 'hostile',
|
||||
description: 'Throw a hostile value',
|
||||
handler: () => {
|
||||
throw { toString(): string { throw new Error('coercion exploded') } }
|
||||
},
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/denied' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/missing input' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/throws' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/hostile' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
|
||||
expect(messageText(harness, sessionId)).toContain('Error: not allowed now')
|
||||
expect(messageText(harness, sessionId)).toContain('Error: unknown command: /missing input')
|
||||
expect(messageText(harness, sessionId)).toContain('Error: command failed: Error: handler exploded')
|
||||
expect(messageText(harness, sessionId)).toContain('Error: command failed: <unrenderable thrown value>')
|
||||
expect(harness.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('flattens supported command prompt blocks without invoking the model', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
const command = vi.fn(() => ({ kind: 'success' as const, text: 'combined' }))
|
||||
harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'text', text: '/direct' },
|
||||
{ type: 'text', text: ' extra' },
|
||||
{ type: 'resource_link', name: 'input', uri: 'file:///workspace/input.txt' },
|
||||
],
|
||||
})).resolves.toEqual({ stopReason: 'end_turn' })
|
||||
expect(command).toHaveBeenCalledWith(expect.objectContaining({
|
||||
rawInput: ' extra\n[resource_link name="input" uri="file:///workspace/input.txt"]\n',
|
||||
}))
|
||||
expect(messageText(harness, sessionId)).toContain('combined')
|
||||
expect(harness.adapter.requests).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps session-reference syntax opaque in direct command arguments', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
const command = vi.fn(() => ({ kind: 'success' as const }))
|
||||
harness.ctx.commands.register({ name: 'direct', description: 'Direct', handler: command })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const sourceUri = encodeSessionReferenceUri(SessionId('source'))
|
||||
|
||||
await expect(harness.client.prompt({
|
||||
sessionId,
|
||||
prompt: [
|
||||
{ type: 'text', text: `/direct valid=${sourceUri} malformed=dsh-session:IiJ` },
|
||||
{ type: 'resource_link', name: 'source', uri: sourceUri },
|
||||
],
|
||||
})).resolves.toEqual({ stopReason: 'end_turn' })
|
||||
expect(command).toHaveBeenCalledWith(expect.objectContaining({
|
||||
rawInput: ` valid=${sourceUri} malformed=dsh-session:IiJ\n[resource_link name="source" uri=${JSON.stringify(sourceUri)}]\n`,
|
||||
}))
|
||||
expect(harness.adapter.requests).toHaveLength(0)
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))?.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('maps session cancellation to the in-flight command signal and isolates other sessions', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => { started = resolve })
|
||||
harness.ctx.commands.register({
|
||||
name: 'wait',
|
||||
description: 'Wait for cancellation',
|
||||
handler: ({ signal }) => {
|
||||
started()
|
||||
return new Promise((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve({ kind: 'error', text: 'late abort result' }) }, { once: true })
|
||||
})
|
||||
},
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const waiting = harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] })
|
||||
await ready
|
||||
await expect(harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/wait' }] }))
|
||||
.rejects.toThrow(/already in flight/)
|
||||
await harness.client.cancel({ sessionId: a.sessionId })
|
||||
|
||||
await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
await expect(harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/missing' }] }))
|
||||
.resolves.toEqual({ stopReason: 'end_turn' })
|
||||
expect(messageText(harness, a.sessionId)).not.toContain('late abort result')
|
||||
})
|
||||
|
||||
it('aborts an in-flight command when the ACP bridge is disposed', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>((resolve) => { started = resolve })
|
||||
let commandSignal: AbortSignal | undefined
|
||||
harness.ctx.commands.register({
|
||||
name: 'wait-dispose',
|
||||
description: 'Wait for bridge disposal',
|
||||
handler: ({ signal }) => {
|
||||
commandSignal = signal
|
||||
started()
|
||||
return new Promise<never>(() => {})
|
||||
},
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const waiting = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: '/wait-dispose' }] })
|
||||
await ready
|
||||
await harness.acpFiber.dispose()
|
||||
|
||||
expect(commandSignal?.aborted).toBe(true)
|
||||
await expect(waiting).resolves.toEqual({ stopReason: 'cancelled' })
|
||||
})
|
||||
|
||||
it('resolves scoped command catalogs and execution independently per session', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agentA = harness.ctx.agents.get(SessionId(a.sessionId))
|
||||
if (agentA === undefined) throw new Error('session A has no agent')
|
||||
await agentA.ctx.inject(['commands'], (commandCtx) => {
|
||||
commandCtx.commands.register({
|
||||
name: 'private', description: 'Only session A',
|
||||
handler: () => ({ kind: 'success', text: 'A ONLY' }),
|
||||
})
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(commandUpdates(harness!, a.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [{ name: 'private' }] })
|
||||
})
|
||||
expect(commandUpdates(harness, b.sessionId).at(-1)?.update).toMatchObject({ availableCommands: [] })
|
||||
await harness.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: '/private' }] })
|
||||
await harness.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: '/private' }] })
|
||||
expect(messageText(harness, a.sessionId)).toContain('A ONLY')
|
||||
expect(messageText(harness, b.sessionId)).toContain('unknown command')
|
||||
})
|
||||
})
|
||||
@@ -1,420 +0,0 @@
|
||||
/**
|
||||
* Exercises the bridge's per-session Permissions option: validation, idle
|
||||
* turn anchoring, isolation, and persistence through `session/load`.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
|
||||
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
|
||||
import PermissionService from '@deepseek-ai/dsh-permission'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
/**
|
||||
* Advertises the real executor through the `sandboxMode` capability without
|
||||
* loading a kernel sandbox, which these bridge tests do not exercise.
|
||||
*/
|
||||
class SandboxedLocalExecutor extends LocalBashExecutor {
|
||||
override get sandboxMode(): SandboxMode {
|
||||
return 'workspace-write'
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInvariants(ctx: BridgeHarness['ctx']): Promise<void> {
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionInvariant)
|
||||
await ctx.plugin(AgentInvariant)
|
||||
await ctx.plugin(AgentLoopInvariant)
|
||||
}
|
||||
|
||||
function permissionOption(currentValue: string): object {
|
||||
return {
|
||||
id: 'permission',
|
||||
name: 'Permissions',
|
||||
description: 'The session permission preset: each choice bundles a sandbox mode and an approval policy.',
|
||||
category: 'mode',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' },
|
||||
{ value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function modelValue(provider = 'mock', model = 'mock'): string {
|
||||
return JSON.stringify([provider, model])
|
||||
}
|
||||
|
||||
function modelOption(currentValue = modelValue()): object {
|
||||
return {
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'Sets this session\'s provider and model.',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue,
|
||||
options: [{ value: modelValue(), name: 'Mock' }],
|
||||
}
|
||||
}
|
||||
|
||||
function optionsWithPermission(currentValue: string): object[] {
|
||||
return [modelOption(), permissionOption(currentValue)]
|
||||
}
|
||||
|
||||
describe('acp bridge — session config options', () => {
|
||||
let storageDir: string
|
||||
let h: BridgeHarness | undefined
|
||||
let loader: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-config-')) })
|
||||
afterEach(async () => {
|
||||
if (h) await h.dispose()
|
||||
if (loader) await loader.dispose()
|
||||
h = loader = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function presetStack(options: { script?: NonNullable<Parameters<typeof makeBridgeHarness>[0]>['script'] } = {}): Promise<BridgeHarness> {
|
||||
const harness = await makeBridgeHarness({ storageDir, ...options.script !== undefined ? { script: options.script } : {} })
|
||||
// Make an out-of-turn switch fail in this suite.
|
||||
await mountInvariants(harness.ctx)
|
||||
await harness.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await harness.ctx.plugin(ApprovalService)
|
||||
await harness.ctx.plugin(PermissionService)
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
return harness
|
||||
}
|
||||
|
||||
it('advertises the model selector without requiring the permission service', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(SandboxedLocalExecutor, { timeoutMs: 10_000 })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual([modelOption()])
|
||||
})
|
||||
|
||||
it('groups models by provider and switches routing plus prompt variables as one session target', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { provider: 'alpha', model: 'a1' },
|
||||
persona: 'Route {{provider}} / {{model}}',
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'beta', name: 'Beta' }],
|
||||
models: [
|
||||
{ provider: 'alpha', id: 'a1', name: 'Alpha One', description: 'Fast' },
|
||||
{ provider: 'beta', id: 'b1', name: 'Beta One' },
|
||||
],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const created = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(created.configOptions).toEqual([{
|
||||
id: 'model',
|
||||
name: 'Model',
|
||||
description: 'Sets this session\'s provider and model.',
|
||||
category: 'model',
|
||||
type: 'select',
|
||||
currentValue: modelValue('alpha', 'a1'),
|
||||
options: [
|
||||
{ group: 'alpha', name: 'Alpha', options: [{ value: modelValue('alpha', 'a1'), name: 'Alpha One', description: 'Fast' }] },
|
||||
{ group: 'beta', name: 'Beta', options: [{ value: modelValue('beta', 'b1'), name: 'Beta One' }] },
|
||||
],
|
||||
}])
|
||||
|
||||
const switched = await h.client.setSessionConfigOption({
|
||||
sessionId: created.sessionId,
|
||||
configId: 'model',
|
||||
value: modelValue('beta', 'b1'),
|
||||
})
|
||||
expect(switched.configOptions?.[0]).toMatchObject({ currentValue: modelValue('beta', 'b1') })
|
||||
await h.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use beta' }] })
|
||||
expect(h.adapter.requests[0]).toMatchObject({
|
||||
provider: 'beta',
|
||||
model: 'b1',
|
||||
})
|
||||
expect(h.adapter.requests[0]?.system).toContain('Route beta / b1')
|
||||
expect(h.ctx.agents.list()[0]?.session.requestHeader()?.config).toMatchObject({ provider: 'beta', model: 'b1' })
|
||||
})
|
||||
|
||||
it('adds the configured private model to an advisory catalog and ignores empty non-current groups', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
config: { provider: 'alpha', model: 'private-model' },
|
||||
catalog: {
|
||||
providers: [{ id: 'alpha', name: 'Alpha' }, { id: 'empty', name: 'Empty' }],
|
||||
models: [{ provider: 'alpha', id: 'public-model', name: 'Public Model' }],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions?.[0]).toMatchObject({
|
||||
currentValue: modelValue('alpha', 'private-model'),
|
||||
options: [
|
||||
{ value: modelValue('alpha', 'public-model'), name: 'Public Model' },
|
||||
{ value: modelValue('alpha', 'private-model'), name: 'private-model' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('omits model selection without a complete or registered current target', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const missing = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(missing.configOptions).toBeUndefined()
|
||||
await h.dispose()
|
||||
|
||||
h = await makeBridgeHarness({ storageDir, config: { provider: 'unregistered', model: 'm' } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const unknown = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(unknown.configOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('leaves model-less agents available to another agent/request supplier', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined }, script: [textResponse('ok')] })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.ctx.on('agent/request', async (_agent, _turn, _step, callConfig, _signal, _next) => ({
|
||||
...callConfig,
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
}))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'supplied elsewhere' }] })
|
||||
expect(h.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' })
|
||||
})
|
||||
|
||||
it('advertises the Permissions select with the default preset current', async () => {
|
||||
h = await presetStack()
|
||||
const res = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
})
|
||||
|
||||
it('an idle switch is pending (overlaid, not yet logged), then anchors inside the next prompt\'s turn', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const after = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(after.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
|
||||
const session = h.ctx.agents.list()[0]?.session
|
||||
expect(session?.events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false)
|
||||
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = session?.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
|
||||
expect(events.filter(e => e.type === 'sandbox/mode').map(e => e.data)).toEqual([{ mode: 'danger-full-access' }])
|
||||
expect(events.filter(e => e.type === 'approval/policy').map(e => e.data)).toEqual([{ policy: 'never' }])
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'permission/preset')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
})
|
||||
|
||||
it('an idle flip-flop anchors as one switch (last write wins)', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const again = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(again.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
|
||||
// A closed turn does not make a later idle switch appendable.
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(h.ctx.agents.list()[0]?.session.events.filter(e => e.type === 'permission/preset')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a net-zero idle flip-flop anchors nothing (switches are recorded, select clicks are not)', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const back = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(back.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.some(e => e.type === 'permission/preset' || e.type === 'sandbox/mode' || e.type === 'approval/policy')).toBe(false)
|
||||
})
|
||||
|
||||
it('a no-op switch (the value already shown) records nothing and keeps a live pending', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(echo.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const repeat = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(repeat.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'anchor' }] })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
expect(events.filter(e => e.type === 'permission/preset').map(e => e.data)).toEqual([{ preset: 'danger-full-access' }])
|
||||
})
|
||||
|
||||
it('a mid-turn switch anchors immediately (the open turn encloses it)', async () => {
|
||||
h = await presetStack({ script: ['hang'] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const hung = h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
// Give the loop a tick to open the turn (the turns.spec hang idiom).
|
||||
await new Promise(resolve => setTimeout(resolve, 30))
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const events = h.ctx.agents.list()[0]?.session.events ?? []
|
||||
const turnStart = events.findIndex(e => e.type === 'turn/start')
|
||||
const anchored = events.findIndex(e => e.type === 'permission/preset')
|
||||
expect(turnStart).toBeGreaterThanOrEqual(0)
|
||||
expect(anchored).toBeGreaterThan(turnStart)
|
||||
expect(events.some(e => e.type === 'sandbox/mode')).toBe(true)
|
||||
expect(events.some(e => e.type === 'approval/policy')).toBe(true)
|
||||
await h.client.cancel({ sessionId })
|
||||
await hung
|
||||
})
|
||||
|
||||
it('rejects unknown ids, unadvertised ids, boolean values, and out-of-vocabulary values', async () => {
|
||||
h = await makeBridgeHarness({ storageDir })
|
||||
await h.ctx.plugin(ApprovalService)
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'reasoning-effort', value: 'max' }))
|
||||
.rejects.toThrow(/unknown config option/)
|
||||
// This composition never advertised `permission`.
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'model', value: modelValue('mock', 'missing') }))
|
||||
.rejects.toThrow(/unknown model value/)
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', type: 'boolean', value: true }))
|
||||
.rejects.toThrow(/select; boolean values are not accepted/)
|
||||
})
|
||||
|
||||
it('rejects an out-of-vocabulary preset on an advertising composition', async () => {
|
||||
h = await presetStack()
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'plan' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
})
|
||||
|
||||
it('a switch in one session never leaks into a concurrent one (state and pending both per-session)', async () => {
|
||||
h = await presetStack()
|
||||
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const bAfter = await h.client.setSessionConfigOption({ sessionId: b.sessionId, configId: 'permission', value: 'workspace-write' })
|
||||
expect(bAfter.configOptions).toEqual(optionsWithPermission('workspace-write'))
|
||||
const aAfter = await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
expect(aAfter.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
})
|
||||
|
||||
it('keeps model targets isolated across concurrent sessions', async () => {
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('a'), textResponse('b')],
|
||||
config: { provider: 'mock', model: 'one' },
|
||||
catalog: {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [
|
||||
{ provider: 'mock', id: 'one', name: 'One' },
|
||||
{ provider: 'mock', id: 'two', name: 'Two' },
|
||||
],
|
||||
},
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const b = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId: a.sessionId, configId: 'model', value: modelValue('mock', 'two') })
|
||||
await h.client.prompt({ sessionId: a.sessionId, prompt: [{ type: 'text', text: 'a' }] })
|
||||
await h.client.prompt({ sessionId: b.sessionId, prompt: [{ type: 'text', text: 'b' }] })
|
||||
expect(h.adapter.requests.map(request => request.model)).toEqual(['two', 'one'])
|
||||
})
|
||||
|
||||
it('a knob drifted outside the table derives a visible-but-untargetable custom current', async () => {
|
||||
h = await presetStack()
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// Simulate a plugin calling the public knob setter inside a valid turn.
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.session.append('sandbox/mode', { mode: 'read-only' })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const echo = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' })
|
||||
const option = echo.configOptions?.find(entry => entry.id === 'permission')
|
||||
expect(option).toMatchObject({ currentValue: 'custom' })
|
||||
if (option === undefined || !('options' in option)) throw new Error('expected a select option')
|
||||
expect(option.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access', 'custom'])
|
||||
const away = await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
const afterOption = away.configOptions?.find(entry => entry.id === 'permission')
|
||||
expect(afterOption).toMatchObject({ currentValue: 'danger-full-access' })
|
||||
if (afterOption === undefined || !('options' in afterOption)) throw new Error('expected a select option')
|
||||
expect(afterOption.options.map(o => 'value' in o ? o.value : o)).toEqual(['workspace-write', 'danger-full-access'])
|
||||
await expect(h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'custom' }))
|
||||
.rejects.toThrow(/unknown permission value/)
|
||||
})
|
||||
|
||||
it('session/load reports a resumed session\'s preset from its own log', async () => {
|
||||
h = await presetStack({ script: [textResponse('ok')] })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'permission', value: 'danger-full-access' })
|
||||
// One turn checkpoints the log (the switch events flush with it).
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist me' }] })
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await presetStack()
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.configOptions).toEqual(optionsWithPermission('danger-full-access'))
|
||||
})
|
||||
|
||||
it('session/load restores the last requested provider/model from the request header', async () => {
|
||||
const catalog = {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [
|
||||
{ provider: 'mock', id: 'one', name: 'One' },
|
||||
{ provider: 'mock', id: 'two', name: 'Two' },
|
||||
],
|
||||
}
|
||||
h = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { provider: 'mock', model: 'one' },
|
||||
catalog,
|
||||
})
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await h.client.setSessionConfigOption({ sessionId, configId: 'model', value: modelValue('mock', 'two') })
|
||||
await h.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'persist target' }] })
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, config: { provider: 'mock', model: 'one' }, catalog })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const loaded = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loaded.configOptions?.find(option => option.id === 'model')).toMatchObject({
|
||||
currentValue: modelValue('mock', 'two'),
|
||||
})
|
||||
})
|
||||
|
||||
it('session/load omits config options when the persisted session has no target or permission service', async () => {
|
||||
h = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = h.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('expected an agent')
|
||||
agent.inject([{ type: 'text', text: 'checkpoint' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
await agent.whenIdle()
|
||||
await h.dispose()
|
||||
h = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, config: { model: undefined } })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const loaded = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loaded.configOptions).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,320 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse } from './harness.ts'
|
||||
|
||||
describe('acp bridge — disposal & HMR safety', () => {
|
||||
let storageDir: string
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-dispose-')) })
|
||||
afterEach(async () => { await rm(storageDir, { recursive: true, force: true }) })
|
||||
|
||||
it('disposal reaches quiescence: a running turn is aborted and awaited before dispose returns', async () => {
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
|
||||
// Start a prompt that hangs in the model stream.
|
||||
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
// Dispose the whole context. The bridge's teardown must abort the agent and
|
||||
// AWAIT whenIdle() — so right after dispose resolves, the agent is settled
|
||||
// (not still running). Proves disposal waited, not just requested.
|
||||
await harness.ctx.fiber.dispose()
|
||||
expect(agent.status).not.toBe('running')
|
||||
|
||||
// The in-flight prompt settled (cancelled) rather than hanging forever.
|
||||
const res = await promptDone
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('after an ACP-only HMR dispose, a late session/new creates no orphan agent (closed guard)', async () => {
|
||||
// Dispose JUST the bridge's fiber (an HMR reload) while agents/agent-loop
|
||||
// stay up and the transport is still live. A late session/new must hit the
|
||||
// `closed` guard and reject — NOT create an agent the disposed bridge can no
|
||||
// longer stream or settle. Verify the world: no agent appeared.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const before = harness.ctx.agents.list().length
|
||||
await harness.acpFiber.dispose() // tear down ONLY the bridge
|
||||
await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/disposed/)
|
||||
expect(harness.ctx.agents.list().length).toBe(before)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
|
||||
// The factory (`ctx.agents.create`) is reached through the bridge's
|
||||
// traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
|
||||
// registration binds to the CALLER context — the bridge fiber — not the
|
||||
// AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
|
||||
// must therefore reclaim the agent's registry entry, even though agents/
|
||||
// agent-loop stay up. This pins the fiber-ownership the bridge's teardown
|
||||
// doc comment relies on; if a refactor rebinds the registration to the
|
||||
// AgentLoop fiber, the agent would survive bridge dispose and this fails.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeDefined()
|
||||
|
||||
await harness.acpFiber.dispose() // tear down ONLY the bridge
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
|
||||
// After teardown (here a client disconnect sets `closed`), a late
|
||||
// `session/new` must NOT create an orphan agent the bridge can no longer
|
||||
// drive/settle. The transport is gone so the RPC rejects; assert the world:
|
||||
// no new agent appeared in the registry.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const before = harness.ctx.agents.list().length
|
||||
await harness.closeClientTransport() // teardown → closed = true
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }).catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(harness.ctx.agents.list().length).toBe(before)
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('a client disconnect mid-prompt disposes the session (no registered agent left)', async () => {
|
||||
// The ACP transport closes (editor quits) while a turn runs. The bridge must
|
||||
// settle the in-flight prompt cancelled and DISPOSE the agent (the session's
|
||||
// per-agent AgentHandle teardown) rather than leaving an orphaned running —
|
||||
// or even idled-but-still-registered — agent whose updates are swallowed.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
// Start a prompt that hangs in the model stream. The prompt RPC will never
|
||||
// return (its transport is severed), so do not await it.
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
// Sever the transport — the bridge's conn.closed teardown runs and drives the
|
||||
// agent's AgentHandle dispose to quiescence on its OWN (before any dispose()).
|
||||
await harness.closeClientTransport()
|
||||
await agent.whenIdle()
|
||||
// The agent's loop has stopped: status `disposed`.
|
||||
expect(agent.status).toBe('disposed')
|
||||
|
||||
// Await the bridge teardown to completion WITHOUT tearing down the root
|
||||
// agents/sessions services (so we can still query them). acpFiber.dispose()
|
||||
// invokes the SAME memoized quiesce() the disconnect started and awaits its
|
||||
// promise — which resolves only after every rec.dispose() (loop exit +
|
||||
// session removal) has finished, closing the whenIdle()/owned.dispose()
|
||||
// microtask race. The AgentHandle dispose has run: the agent is unregistered
|
||||
// and its session removed from the store, not merely idled (the old
|
||||
// behavior). The services live on the root ctx, so they survive this.
|
||||
await harness.acpFiber.dispose()
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('a client disconnect racing fiber dispose both reach quiescence (shared teardown)', async () => {
|
||||
// conn.closed teardown and ctx.fiber.dispose() can fire near-simultaneously.
|
||||
// They must share one teardown promise: dispose() must NOT return before the
|
||||
// disconnect teardown's whenIdle() has settled (a `record === undefined`-only
|
||||
// guard would let the second caller return early mid-teardown).
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
// Fire both teardown paths without awaiting the first, then await both.
|
||||
const close = harness.closeClientTransport()
|
||||
const dispose = harness.ctx.fiber.dispose()
|
||||
await Promise.all([close, dispose])
|
||||
// After BOTH settle, the agent has fully drained (not still running).
|
||||
expect(agent.status).not.toBe('running')
|
||||
})
|
||||
|
||||
it('after dispose, session/update listeners are gone (no further updates emitted)', async () => {
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const session = harness.ctx.agents.get(SessionId(sessionId))!.session
|
||||
|
||||
await harness.ctx.fiber.dispose()
|
||||
const before = harness.updates.length
|
||||
// Append an event directly to the (now-detached) session: the bridge's
|
||||
// session/event listener should have been disposed, so no update fires.
|
||||
session.append('turn/start', { turn: 99, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(harness.updates.length).toBe(before)
|
||||
})
|
||||
|
||||
it('the final turn closing events are persisted across an AgentHandle dispose (durability)', async () => {
|
||||
// The teardown-ORDER guarantee: a per-agent dispose must stop the loop,
|
||||
// AWAIT its exit (so the loop's final `turn/end` + `session/flush` fire
|
||||
// through the still-attached store observer → `session/event`), and only
|
||||
// THEN remove its publication hooks and session entry. If the order were inverted
|
||||
// (detach first), the closing events would never reach persistence. Drive a
|
||||
// CLEAN turn to completion, dispose JUST the bridge, then re-load the
|
||||
// persisted log from disk and assert the closing turn/end is on disk — the
|
||||
// world, not the agent's self-report.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('done')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
const liveEvents = harness.ctx.agents.get(SessionId(sessionId))!.session.events.length
|
||||
expect(liveEvents).toBeGreaterThan(0)
|
||||
|
||||
// Tear down JUST the bridge (the AgentHandle dispose runs to quiescence).
|
||||
await harness.acpFiber.dispose()
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
|
||||
// Re-load the session from disk: every live event (incl. the closing
|
||||
// turn/end) was flushed before the session was detached.
|
||||
const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId))
|
||||
expect(reloaded.events.length).toBe(liveEvents)
|
||||
const last = reloaded.events.at(-1)!
|
||||
expect(last.type).toBe('turn/end')
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('a turn aborted BY the dispose still flushes its closing turn/end to disk (durability, mid-turn)', async () => {
|
||||
// The teardown-order contract only earns its keep when the closing events are
|
||||
// produced BY the dispose itself. Here the model stream HANGS, so the turn is
|
||||
// still open when teardown runs: the composite agent effect stops the loop,
|
||||
// the loop unwinds and appends `turn/end {disposed}` + runs its final
|
||||
// `session/flush` — all while the store-owned publication hooks are still attached (the session
|
||||
// detach is the LAST disposer in the same effect's LIFO chain) — and only
|
||||
// THEN is the session detached. If the order were inverted (or the session
|
||||
// were a racing SIBLING effect), the abort-produced `turn/end` would never
|
||||
// reach disk and a re-load would instead show crash-recovery's synthetic
|
||||
// `interrupted` closer. Re-load from disk and assert the REAL `disposed`
|
||||
// reason landed — proving the loop's own closing event was captured, not a
|
||||
// recovered substitute.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {})
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
// The turn is OPEN in the log (turn/start appended, no turn/end yet).
|
||||
const openTurnEnds = agent.session.events.filter(e => e.type === 'turn/end').length
|
||||
|
||||
// Dispose JUST the bridge: a fiber unload that must STILL honor the ordered
|
||||
// teardown (the composite effect runs its disposer chain as a unit).
|
||||
await harness.acpFiber.dispose()
|
||||
expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
|
||||
// The loop's own `turn/end {disposed}` is on disk (re-load: the world, not
|
||||
// self-report) — NOT a crash-recovery `interrupted` substitute.
|
||||
const reloaded = await harness.ctx.sessionPersistence.load(SessionId(sessionId))
|
||||
const persistedTurnEnds = reloaded.events.filter(e => e.type === 'turn/end')
|
||||
expect(persistedTurnEnds.length).toBe(openTurnEnds + 1)
|
||||
expect(persistedTurnEnds.at(-1)!.data.reason).toMatchObject({ kind: 'disposed' })
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('per-session AgentHandle dispose leaves sibling agents untouched', async () => {
|
||||
// The factory returns a per-agent AgentHandle whose dispose() tears down
|
||||
// EXACTLY that agent + its session — the registry's per-handle isolation
|
||||
// contract. Create two agents
|
||||
// directly through the registry factory (the same path the ACP bridge uses),
|
||||
// dispose one handle, and assert the other survives, registered and
|
||||
// queryable, with its session still in the store.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
const handleA = await harness.ctx.agents.create({
|
||||
sessionId: SessionId('sib-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const handleB = await harness.ctx.agents.create({
|
||||
sessionId: SessionId('sib-b'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBe(handleA.agent)
|
||||
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
|
||||
|
||||
await handleA.dispose()
|
||||
// A is gone — unregistered AND its session removed from the store.
|
||||
expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined()
|
||||
expect(handleA.agent.status).toBe('disposed')
|
||||
// B is wholly unaffected.
|
||||
expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent)
|
||||
expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined()
|
||||
expect(handleB.agent.status).not.toBe('disposed')
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('a throwing agent/disposed listener does not prevent session removal (composite-effect containment)', async () => {
|
||||
// The AgentHandle teardown folds session-detach, register, and loop-stop
|
||||
// into ONE composite effect whose disposers run as a `.then()` chain. The
|
||||
// register disposer emits `agent/disposed`; if a listener throws and the
|
||||
// emit is UNCONTAINED, the rejected chain skips the LATER session-detach
|
||||
// disposer — stranding the session in the store with its publication hooks attached (a
|
||||
// leak AND a durability hole, since the new design relies on detach
|
||||
// running). The emit must be contained. Register a throwing listener, drive
|
||||
// a clean turn, dispose, and assert the session was STILL removed.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') })
|
||||
const handle = await harness.ctx.agents.create({
|
||||
sessionId: SessionId('guard-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await handle.agent.whenIdle()
|
||||
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeDefined()
|
||||
|
||||
// Dispose: the throwing listener must NOT break the chain before detach.
|
||||
await handle.dispose()
|
||||
expect(harness.ctx.agents.get(SessionId('guard-a'))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('concurrent AgentHandle dispose() calls all await the SAME teardown (memoized)', async () => {
|
||||
// The handle's dispose() must memoize: the underlying cordis effect disposer
|
||||
// is single-shot, so a second dispose() while the first is mid-teardown would
|
||||
// otherwise resolve IMMEDIATELY (effect epoch already cleared) — before the
|
||||
// first call's await agent.done + final flush finished. Every caller must
|
||||
// observe the same quiescence boundary.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
const handle = await harness.ctx.agents.create({
|
||||
sessionId: SessionId('conc-a'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
// Drive a turn that hangs in the model stream, so the loop is mid-turn when
|
||||
// disposed — its exit runs a final session/flush we can gate to hold the
|
||||
// teardown observably in-flight.
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(handle.agent.status).toBe('running')
|
||||
let releaseFlush!: () => void
|
||||
const flushGate = new Promise<void>((resolve) => { releaseFlush = resolve })
|
||||
harness.ctx.on('session/flush', () => flushGate)
|
||||
|
||||
// First dispose enters teardown (aborts the hanging step) and blocks in the
|
||||
// gated final flush.
|
||||
const first = handle.dispose()
|
||||
let firstSettled = false
|
||||
void first.then(() => { firstSettled = true })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(firstSettled).toBe(false)
|
||||
|
||||
// Second dispose MUST await the same in-flight teardown, not resolve early.
|
||||
const second = handle.dispose()
|
||||
let secondSettled = false
|
||||
void second.then(() => { secondSettled = true })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(secondSettled).toBe(false) // memoized: still pending with the first
|
||||
|
||||
// Release the flush; both resolve together and the session is gone.
|
||||
releaseFlush()
|
||||
await Promise.all([first, second])
|
||||
expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined()
|
||||
expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined()
|
||||
await harness.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,68 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
|
||||
|
||||
describe('acp bridge — demux & config edges', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-edge-')) })
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('ignores events from an agent the bridge does not own (strict id demux)', async () => {
|
||||
// A second agent created directly on the registry (NOT via the bridge) runs
|
||||
// a turn. Its session events must NOT produce ACP updates and
|
||||
// must not settle anything — the bridge demuxes strictly by its own id.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('foreign')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await vi.waitFor(() => {
|
||||
expect(harness!.updates.some(update => update.sessionUpdate === 'available_commands_update')).toBe(true)
|
||||
})
|
||||
const before = harness.updates.length
|
||||
|
||||
const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
foreign.send([{ type: 'text', text: 'hi' }])
|
||||
await foreign.whenIdle()
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
|
||||
// No update was emitted for the foreign agent's stream.
|
||||
expect(harness.updates.length).toBe(before)
|
||||
})
|
||||
|
||||
it('survives a session/update that the client rejects (best-effort notify)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// Make the client reject every update — the bridge's notify() must swallow
|
||||
// the rejection and the prompt must still settle normally.
|
||||
harness.onSessionUpdateError = () => { throw new Error('client update rejected') }
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('accepts session/new with additionalDirectories empty', async () => {
|
||||
// Exercises the defined-but-empty additionalDirectories branch (length 0 → allowed).
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [], additionalDirectories: [] })
|
||||
expect(a.sessionId).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects non-empty mcpServers until MCP wiring is implemented', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [{ name: 'fs', command: 'npx', args: ['server'], env: [] }],
|
||||
})).rejects.toThrow(/mcpServers/)
|
||||
})
|
||||
})
|
||||
@@ -1,340 +0,0 @@
|
||||
/**
|
||||
* Shared non-spec fixture that mounts the full in-memory agent/persistence stack and connects the
|
||||
* ACP bridge to a real SDK client over memory streams. Tests exercise the same protocol path as an
|
||||
* editor without a subprocess or stdio.
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
type Agent as AcpAgent,
|
||||
type Client,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type Stream,
|
||||
} from '@agentclientprotocol/sdk'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
||||
import SessionReferenceService from '@deepseek-ai/dsh-session-reference'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
import * as AcpPlugin from '../src/index.ts'
|
||||
import { type AcpConfig } from '../src/index.ts'
|
||||
|
||||
class TestSessionQueryService extends SessionQueryService {
|
||||
override searchSessions(
|
||||
..._args: Parameters<SessionQueryService['searchSessions']>
|
||||
): ReturnType<SessionQueryService['searchSessions']> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
|
||||
override searchEvents(
|
||||
..._args: Parameters<SessionQueryService['searchEvents']>
|
||||
): ReturnType<SessionQueryService['searchEvents']> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
}
|
||||
|
||||
/** A scripted mock adapter (mirrors the agent-loop test adapter). */
|
||||
class MockAdapter extends LlmAdapter {
|
||||
requests: GenerateOptions[] = []
|
||||
constructor(
|
||||
private script: (StreamChunk[] | 'hang')[],
|
||||
private readonly providers: readonly LlmProviderInfo[],
|
||||
private readonly models: readonly LlmModelInfo[],
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
const info = this.providers.find(entry => entry.id === provider)
|
||||
if (info === undefined) throw new Error(`MockAdapter: unknown provider ${provider}`)
|
||||
return info
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.models.filter(model => model.provider === provider))
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('MockAdapter: script exhausted')
|
||||
if (entry === 'hang') {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'partial' }
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
if (options.signal?.aborted) { reject(new Error('aborted')); return }
|
||||
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
for (const chunk of entry) {
|
||||
if (options.signal?.aborted) throw new Error('aborted')
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Scripted text response ending in a clean `stop` finish. */
|
||||
export function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 5, outputTokens: text.length } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** Scripted response ending at the output-token ceiling (max-tokens finish). */
|
||||
export function maxTokensResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** Scripted response that fails mid-turn with a finish-error chunk. */
|
||||
export function errorResponse(message: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'partial' },
|
||||
{ type: 'finish', reason: { kind: 'error', failure: { message, code: 'PROVIDER_ERROR' } } },
|
||||
]
|
||||
}
|
||||
|
||||
/** Scripted single tool call (no follow-up step scripted by default). */
|
||||
export function toolCallResponse(rawCallId: string, name: string, args: object): StreamChunk[] {
|
||||
const argumentsJson = JSON.stringify(args)
|
||||
const id = CallId(rawCallId)
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id, name, argumentsDelta: argumentsJson },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id, name, arguments: argumentsJson } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
}
|
||||
|
||||
/** A captured `session/update` notification (the update payload only). */
|
||||
export type CapturedUpdate = SessionNotification['update']
|
||||
|
||||
export interface BridgeHarness {
|
||||
ctx: Context
|
||||
client: ClientSideConnection
|
||||
adapter: MockAdapter
|
||||
/** Every `session/update` the bridge pushed, in order (payload only). */
|
||||
updates: CapturedUpdate[]
|
||||
/** Same, but tagged with each update's `sessionId` (for multi-session demux assertions). */
|
||||
sessionUpdates: { sessionId: string; update: CapturedUpdate }[]
|
||||
/** Permission requests the bridge issued (none until the gate lands). */
|
||||
permissionRequests: RequestPermissionRequest[]
|
||||
/** Decide each permission request's outcome (default: cancelled). */
|
||||
onPermission: (req: RequestPermissionRequest) => RequestPermissionResponse
|
||||
/** Elicitation requests the bridge issued for ask_user_question. */
|
||||
elicitationRequests: CreateElicitationRequest[]
|
||||
/** Decide each elicitation response (default: cancel). */
|
||||
onElicitation: (req: CreateElicitationRequest) => CreateElicitationResponse | Promise<CreateElicitationResponse>
|
||||
/** If set, the client's sessionUpdate throws this (tests notify error path). */
|
||||
onSessionUpdateError: (() => void) | undefined
|
||||
/**
|
||||
* Sever the client→agent transport (close the writable the agent reads),
|
||||
* which ends the agent-side stream and resolves the bridge's `conn.closed` —
|
||||
* simulating an editor disconnecting. Returns once the close is requested.
|
||||
*/
|
||||
closeClientTransport: () => Promise<void>
|
||||
/**
|
||||
* The child fiber the ACP bridge is mounted in. Disposing it tears down JUST
|
||||
* the bridge (its `ctx.on` listeners + effect) while the rest of the harness
|
||||
* stays up — an ACP-only HMR reload.
|
||||
*/
|
||||
acpFiber: Awaited<ReturnType<Context['plugin']>>
|
||||
dispose: () => Promise<void>
|
||||
storageDir: string
|
||||
}
|
||||
|
||||
/** Test-only overrides preserve explicit undefined to suppress harness defaults. */
|
||||
type AcpConfigOverrides = { [K in keyof AcpConfig]?: AcpConfig[K] | undefined }
|
||||
|
||||
/**
|
||||
* Build the bridge + a connected client over an in-memory transport pair.
|
||||
*
|
||||
* Two identity `TransformStream`s cross-wired (agent writes → client reads,
|
||||
* client writes → agent reads) give a faithful bidirectional JSON-RPC channel.
|
||||
* The bridge's `apply` receives the agent-side `Stream` via `config.stream`;
|
||||
* the test holds the `ClientSideConnection`.
|
||||
*
|
||||
* Pass an explicit undefined route field to suppress its mock default.
|
||||
*/
|
||||
export async function makeBridgeHarness(options: {
|
||||
script?: (StreamChunk[] | 'hang')[]
|
||||
config?: AcpConfigOverrides
|
||||
/** Provider-neutral directory exposed to ACP model-selection tests. */
|
||||
catalog?: { providers: LlmProviderInfo[]; models: LlmModelInfo[] }
|
||||
/** Deployment persona for the tree (the system-prompt plugin's config). */
|
||||
persona?: string
|
||||
storageDir: string
|
||||
/**
|
||||
* Plug the REAL `dsh-bash-local` executor + `dsh-tool-bash` tools (instead of
|
||||
* a test's own inline tool). Lets a test drive the actual `bash` tool — its
|
||||
* real `presentCall`/`presentResult` — through the bridge, so tool-call UI
|
||||
* tests verify the SHIPPING tool, not a stand-in (docs/testing.md "prefer the real
|
||||
* implementation over a mock in tests").
|
||||
*/
|
||||
withBash?: boolean
|
||||
/** Plug the REAL `ask_user_question` tool and ACP user-interaction provider. */
|
||||
withAskUser?: boolean
|
||||
/**
|
||||
* Plug the REAL `dsh-tool-todo` tool so a test can drive `todo_write` through
|
||||
* the bridge and assert the resulting `plan` sessionUpdate — the shipping
|
||||
* tool + the bridge's own todo/write→plan mapping, not a stand-in.
|
||||
*/
|
||||
withTodo?: boolean
|
||||
/** Mount exact session reads and cross-session snapshot preparation before ACP. */
|
||||
withSessionReferences?: boolean
|
||||
/** Plug the REAL `dsh-plan-mode` plugin so a test can drive the session-mode picker. */
|
||||
withModes?: boolean
|
||||
/**
|
||||
* Plug the REAL filesystem stack (`dsh-fs-local` + `dsh-fs-policy` +
|
||||
* `dsh-tool-fs`) so a test can drive `read`/`write`/`edit` through the bridge
|
||||
* and assert their tool-owned presentation (title/kind/`locations`) on the
|
||||
* wire — the shipping tools, not a stand-in. `fsCwd` sets the local backend's
|
||||
* base directory (default: `storageDir`).
|
||||
*/
|
||||
withFs?: boolean
|
||||
fsCwd?: string
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const catalog = options.catalog ?? {
|
||||
providers: [{ id: 'mock', name: 'Mock' }],
|
||||
models: [{ provider: 'mock', id: 'mock', name: 'Mock' }],
|
||||
}
|
||||
const adapter = new MockAdapter(options.script ?? [], catalog.providers, catalog.models)
|
||||
|
||||
const ctx = new Context()
|
||||
await mountAgentLoopTestDependencies(ctx, {
|
||||
systemPrompt: { persona: options.persona ?? '' },
|
||||
})
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
if (options.withSessionReferences) {
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
}
|
||||
await ctx.plugin(UserInteractionService)
|
||||
if (options.withAskUser) {
|
||||
await ctx.plugin(ToolAskUser)
|
||||
}
|
||||
if (options.withBash) {
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
}
|
||||
if (options.withTodo) {
|
||||
await ctx.plugin(ToolTodo)
|
||||
}
|
||||
if (options.withModes) {
|
||||
await ctx.plugin(PlanModeService, { section: 'Test plan mode instructions.' })
|
||||
}
|
||||
if (options.withFs) {
|
||||
await ctx.plugin(LocalFileSystem, { cwd: options.fsCwd ?? options.storageDir })
|
||||
await ctx.plugin(FsPolicy)
|
||||
await ctx.plugin(ToolFs)
|
||||
}
|
||||
ctx.llm.registerAdapter(catalog.providers.map(provider => provider.id), adapter)
|
||||
|
||||
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the agent writes flow
|
||||
// to the client's reader and vice versa. (ndJsonStream takes (output, input): the agent
|
||||
// writes to a2c and reads from c2a; the client writes to c2a and reads from a2c.) Holding the c2a
|
||||
// writer lets tests EOF the agent reader and simulate editor disconnect.
|
||||
const a2c = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const c2a = new TransformStream<Uint8Array, Uint8Array>()
|
||||
const c2aWriter = c2a.writable.getWriter()
|
||||
// A WritableStream the client writes into; each chunk is forwarded to the
|
||||
// held c2a writer. `closeClientTransport` closes that writer directly.
|
||||
const clientOutput = new WritableStream<Uint8Array>({
|
||||
write: chunk => c2aWriter.write(chunk),
|
||||
})
|
||||
|
||||
const agentStream: Stream = ndJsonStream(a2c.writable, c2a.readable)
|
||||
const clientStream: Stream = ndJsonStream(clientOutput, a2c.readable)
|
||||
|
||||
const updates: CapturedUpdate[] = []
|
||||
const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = []
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const elicitationRequests: CreateElicitationRequest[] = []
|
||||
const harness: BridgeHarness = {
|
||||
ctx,
|
||||
adapter,
|
||||
updates,
|
||||
sessionUpdates,
|
||||
permissionRequests,
|
||||
onPermission: () => ({ outcome: { outcome: 'cancelled' } }),
|
||||
elicitationRequests,
|
||||
onElicitation: () => ({ action: 'cancel' }),
|
||||
onSessionUpdateError: undefined,
|
||||
client: undefined as unknown as ClientSideConnection,
|
||||
acpFiber: undefined as unknown as BridgeHarness['acpFiber'],
|
||||
// Close the writable the CLIENT writes to (c2a) — its readable, which the agent's
|
||||
// ndJsonStream consumes, then EOFs cleanly, so the bridge's `conn.closed` resolves and it
|
||||
// sees the client disconnect.
|
||||
closeClientTransport: async () => { await c2aWriter.close() },
|
||||
dispose: async () => { await ctx.fiber.dispose() },
|
||||
storageDir: options.storageDir,
|
||||
}
|
||||
|
||||
const makeClient = (_agent: AcpAgent): Client => ({
|
||||
sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
updates.push(params.update)
|
||||
sessionUpdates.push({ sessionId: params.sessionId, update: params.update })
|
||||
// Let a test force the bridge's notify() error path.
|
||||
if (harness.onSessionUpdateError) return Promise.reject(new Error('client update rejected'))
|
||||
return Promise.resolve()
|
||||
},
|
||||
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
permissionRequests.push(params)
|
||||
return Promise.resolve(harness.onPermission(params))
|
||||
},
|
||||
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse> {
|
||||
elicitationRequests.push(params)
|
||||
return Promise.resolve(harness.onElicitation(params))
|
||||
},
|
||||
})
|
||||
|
||||
// Default route fields only when the caller omitted them; explicit undefined values must survive.
|
||||
const cfg = { stream: agentStream, ...options.config } as AcpConfig
|
||||
if (!(options.config && 'provider' in options.config)) cfg.provider = 'mock'
|
||||
if (!(options.config && 'model' in options.config)) cfg.model = 'mock'
|
||||
// Mount the bridge the way production does: as a cordis plugin (via `ctx.plugin` with the
|
||||
// real `inject`), not `AcpPlugin.apply(ctx, cfg)` on the ungated root. Later JSON-RPC callbacks run
|
||||
// outside apply's injection scope, matching production and exposing missing-inject failures.
|
||||
harness.acpFiber = await ctx.plugin({
|
||||
name: 'acp-test',
|
||||
// Use the bridge's real exported `inject` so this never drifts from the plugin's actual
|
||||
// dependency list (adding a service to the bridge must not require editing the harness — a
|
||||
// hardcoded list silently broke when `tools` was added). The returned fiber permits ACP-only
|
||||
// disposal while root services remain live for HMR assertions.
|
||||
inject: [...AcpPlugin.inject],
|
||||
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
|
||||
})
|
||||
harness.client = new ClientSideConnection(makeClient, clientStream)
|
||||
|
||||
return harness
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
/** Concatenate the text of all agent_message_chunk updates. */
|
||||
function messageText(updates: CapturedUpdate[]): string {
|
||||
return updates
|
||||
.filter(u => u.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('acp bridge — session/load replay', () => {
|
||||
let storageDir: string
|
||||
let live: BridgeHarness | undefined
|
||||
let loader: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-load-')) })
|
||||
afterEach(async () => {
|
||||
if (live) await live.dispose()
|
||||
if (loader) await loader.dispose()
|
||||
live = loader = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('replays a persisted turn from the event log as session/update on load', async () => {
|
||||
// 1. Create a session and run one turn — persistence writes the event log.
|
||||
live = await makeBridgeHarness({ storageDir, script: [textResponse('remembered answer')] })
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'remember this' }] })
|
||||
// Dispose to flush + release; the on-disk log persists.
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
// 2. A fresh bridge loads the same session id and must replay the turn.
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res).toBeDefined()
|
||||
|
||||
// The replayed updates reconstruct the assistant text from the event log
|
||||
// (assistant/chunk → agent_message_chunk), NOT from deriveMessages.
|
||||
expect(messageText(loader.updates)).toBe('remembered answer')
|
||||
|
||||
// And the USER side of the turn replays too (user/message →
|
||||
// user_message_chunk), so the editor transcript shows both sides.
|
||||
const userText = loader.updates
|
||||
.filter(u => u.sessionUpdate === 'user_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
expect(userText).toBe('remember this')
|
||||
})
|
||||
|
||||
it('streams and replays the same persisted session_info_update for a title event', async () => {
|
||||
live = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
const session = live.ctx.agents.get(SessionId(sessionId))!.session
|
||||
const event = await live.ctx.sessions.appendOutOfBand(session, 'session/title', {
|
||||
title: 'Durable ACP title',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
}, { kind: 'session-title' })
|
||||
const expected = {
|
||||
sessionUpdate: 'session_info_update' as const,
|
||||
title: 'Durable ACP title',
|
||||
updatedAt: new Date(event.time).toISOString(),
|
||||
}
|
||||
expect(live.updates).toContainEqual(expected)
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(loader.updates).toContainEqual(expected)
|
||||
})
|
||||
|
||||
it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => {
|
||||
// Persist a real bash call, then replay it through a fresh bridge. A throwaway presenter pairs
|
||||
// call and result in log order so replay uses the shipping tool's same cards as live streaming.
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hello', description: 'Print a greeting' }), textResponse('done')],
|
||||
})
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
// A fresh bridge — also with the real bash tool, since the presentation is
|
||||
// resolved from the live registry at replay time — loads the session.
|
||||
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
expect(call).toMatchObject({ toolCallId: 'c1', title: 'echo hello', kind: 'execute', rawInput: 'echo hello' })
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Capability OFF on this loader: the description renders as a content block, no terminal block.
|
||||
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
|
||||
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(update?.sessionUpdate).toBe('tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
const content = update.content as { content: { text: string } }[]
|
||||
expect(content[0]?.content.text).toBe('```console\nhello\n```')
|
||||
})
|
||||
|
||||
it('replays a persisted todo/write as a plan sessionUpdate on load', async () => {
|
||||
// A persisted `todo/write` must replay as an ACP plan update so a reopened editor sees the
|
||||
// current plan, not just the tool transcript.
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withTodo: true,
|
||||
script: [
|
||||
toolCallResponse('c1', 'todo_write', {
|
||||
todos: [
|
||||
{ content: 'first step', status: 'in_progress' },
|
||||
{ content: 'second step', status: 'pending' },
|
||||
],
|
||||
}),
|
||||
textResponse('planned'),
|
||||
],
|
||||
})
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'plan it' }] })
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, withTodo: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const plan = loader.updates.find(u => u.sessionUpdate === 'plan')
|
||||
expect(plan).toEqual({
|
||||
sessionUpdate: 'plan',
|
||||
entries: [
|
||||
{ content: 'first step', priority: 'medium', status: 'in_progress' },
|
||||
{ content: 'second step', priority: 'medium', status: 'pending' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('replays a persisted bash call as a TERMINAL card when the loader advertises the capability', async () => {
|
||||
// The presentation is resolved at replay time, so a loader that advertised
|
||||
// _meta.terminal_output must reconstruct the terminal card (content + _meta)
|
||||
// from the persisted log — identical to how it would have streamed live.
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
|
||||
})
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Replay reconstructs the terminal card: description block, then terminal block.
|
||||
expect(call.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Greet' } },
|
||||
{ type: 'terminal', terminalId: 'c1' },
|
||||
])
|
||||
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
|
||||
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
// Terminal mode: content omitted, output + exit on _meta — matching live.
|
||||
expect(update.content).toBeUndefined()
|
||||
const meta = update._meta as { terminal_output?: { data: string }; terminal_exit?: { exit_code?: number } }
|
||||
expect(meta.terminal_output?.data).toBe('hi\n')
|
||||
expect(meta.terminal_exit?.exit_code).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps one terminal completion live and on replay when a pruning replacement is logged', async () => {
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo full', description: 'Print full output' }), textResponse('done')],
|
||||
})
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] })
|
||||
|
||||
const session = live.ctx.agents.get(SessionId(sessionId))!.session
|
||||
const original = session.events.find(event => event.type === 'tool/result')
|
||||
if (original?.type !== 'tool/result') throw new Error('expected original tool/result')
|
||||
const liveCompletions = () => live!.updates.filter(update =>
|
||||
update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
|
||||
expect(liveCompletions()).toHaveLength(1)
|
||||
expect((liveCompletions()[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
|
||||
.toBe('full\n')
|
||||
|
||||
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('tool/result', {
|
||||
...original.data,
|
||||
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
|
||||
sourceEventSeqs: [original.seq],
|
||||
})
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
|
||||
// The replacement is durable but is not another live completion.
|
||||
expect(session.events.filter(event => event.type === 'tool/result')).toHaveLength(2)
|
||||
expect(JSON.stringify(session.deriveMessages())).toContain('tool result middle pruned')
|
||||
expect(liveCompletions()).toHaveLength(1)
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const replayed = loader.updates.filter(update =>
|
||||
update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'c1')
|
||||
expect(replayed).toHaveLength(1)
|
||||
expect((replayed[0] as { _meta?: { terminal_output?: { data: string } } })._meta?.terminal_output?.data)
|
||||
.toBe('full\n')
|
||||
})
|
||||
|
||||
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
|
||||
// Stall persistence so transport closes while resume is pending. Whether the SDK rejects first
|
||||
// or the bridge's post-await guard fires, no agent may survive for the dead connection.
|
||||
live = await makeBridgeHarness({ storageDir, script: [textResponse('x')] })
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] })
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const realLoad = loader.ctx.sessionPersistence.load.bind(loader.ctx.sessionPersistence)
|
||||
let release!: () => void
|
||||
const gate = new Promise<void>((r) => { release = r })
|
||||
loader.ctx.sessionPersistence.load = async (id) => { await gate; return realLoad(id) }
|
||||
|
||||
const loadResult = loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
.then(() => 'resolved' as const, () => 'rejected' as const)
|
||||
await loader.closeClientTransport() // teardown sets `closed` while load is gated
|
||||
release() // resume() finishes AFTER teardown
|
||||
expect(await loadResult).toBe('rejected')
|
||||
// No live agent was installed for the closed connection.
|
||||
expect(loader.ctx.agents.get(SessionId(sessionId))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects load when the requested cwd does not match the persisted session cwd', async () => {
|
||||
// Seed a session on disk whose header.cwd is a DIFFERENT absolute path than the server's
|
||||
// launch dir. Resume must retain the header cwd and route bash there rather than reject the
|
||||
// mismatch or substitute the server cwd.
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
const otherCwd = '/some/other/workspace'
|
||||
await loader.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION, id: SessionId('elsewhere'), createdAt: 1, cwd: otherCwd,
|
||||
})
|
||||
await loader.ctx.sessionPersistence.append(SessionId('elsewhere'), [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
])
|
||||
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/cwd mismatch/)
|
||||
expect(loader.ctx.agents.get(SessionId('elsewhere'))).toBeUndefined()
|
||||
|
||||
const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] })
|
||||
expect(res).toBeDefined()
|
||||
expect(loader.ctx.agents.get(SessionId('elsewhere'))!.session.header.cwd).toBe(otherCwd)
|
||||
})
|
||||
|
||||
it('rejects load for a non-absolute cwd (still required to be absolute)', async () => {
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 's', cwd: 'rel', mcpServers: [] }))
|
||||
.rejects.toThrow(/absolute/)
|
||||
})
|
||||
|
||||
it('lets persistence reject a load for an unknown id after metadata lookup misses', async () => {
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 'missing', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/Internal error/)
|
||||
})
|
||||
|
||||
it('rejects loading a persisted session that has NO cwd (would silently run in the launch dir)', async () => {
|
||||
// A legacy/external log without `header.cwd` must be rejected; the request cwd does not override
|
||||
// it, and accepting would let bash silently fall back to the server launch directory.
|
||||
loader = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await loader.ctx.sessionPersistence.create({
|
||||
version: SESSION_FORMAT_VERSION, id: SessionId('legacy'), createdAt: 1, // no cwd
|
||||
})
|
||||
await loader.ctx.sessionPersistence.append(SessionId('legacy'), [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
])
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/no absolute persisted cwd/)
|
||||
// Rejected BEFORE resume (metadata-only check) — no agent was registered, so
|
||||
// the id is not wedged: a later attempt hits the same clean rejection, not a
|
||||
// duplicate-registration error.
|
||||
expect(loader.ctx.agents.get(SessionId('legacy'))).toBeUndefined()
|
||||
await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/no absolute persisted cwd/)
|
||||
})
|
||||
|
||||
it('allows loading alongside an existing session but rejects re-loading the SAME id', async () => {
|
||||
// Multi-session: a load can coexist with a live session, but loading an id
|
||||
// that is already live is rejected (it is already loaded).
|
||||
live = await makeBridgeHarness({ storageDir, script: [textResponse('one')] })
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hi' }] })
|
||||
// A different new session coexists.
|
||||
const other = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(other.sessionId).not.toBe(sessionId)
|
||||
// Re-loading the already-live id is rejected.
|
||||
await expect(live.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] }))
|
||||
.rejects.toThrow(/already loaded/)
|
||||
})
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
/** The `current_mode_update` notifications, in order. */
|
||||
function modeUpdates(updates: CapturedUpdate[]): string[] {
|
||||
return updates
|
||||
.filter(update => update.sessionUpdate === 'current_mode_update')
|
||||
.map(update => update.currentModeId)
|
||||
}
|
||||
|
||||
describe('acp bridge — plan mode projection', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
let loader: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-modes-')) })
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.dispose()
|
||||
if (loader) await loader.dispose()
|
||||
harness = loader = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('advertises no mode surface and rejects session/set_mode when plan mode is not composed', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.modes).toBeUndefined()
|
||||
await expect(harness.client.setSessionMode({ sessionId: res.sessionId, modeId: 'plan' }))
|
||||
.rejects.toMatchObject({ message: expect.stringContaining('session modes are not composed') as string })
|
||||
})
|
||||
|
||||
it('advertises availableModes/currentModeId on session/new', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'default' },
|
||||
{ id: 'plan', name: 'plan' },
|
||||
],
|
||||
currentModeId: 'default',
|
||||
})
|
||||
})
|
||||
|
||||
it('session/set_mode records the pending intent and echoes one optimistic current_mode_update', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
|
||||
expect(modeUpdates(harness.updates)).toEqual(['plan'])
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
expect(harness.ctx.planMode.get(agent)).toEqual({ active: false, pending: true })
|
||||
})
|
||||
|
||||
it('rejects an unknown ACP mode id at the adapter boundary', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await expect(harness.client.setSessionMode({ sessionId, modeId: 'nope' }))
|
||||
.rejects.toMatchObject({ message: expect.stringContaining('unknown session mode "nope"') as string })
|
||||
expect(modeUpdates(harness.updates)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not re-notify when the boundary flush logs the mode the picker already showed', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
expect(agent.session.events.some(event => event.type === 'plan/mode')).toBe(true)
|
||||
expect(modeUpdates(harness.updates)).toEqual(['plan'])
|
||||
})
|
||||
|
||||
it('re-notifies on a logged flip the picker has not seen (the tool-driven exit shape)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
|
||||
// A writer other than the picker (exit_plan_mode's execute) appends the
|
||||
// flip back; the bridge must re-notify the client off the logged event.
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
agent.session.append('plan/mode', { active: false })
|
||||
// The notification crosses the in-memory JSON-RPC transport asynchronously.
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(modeUpdates(harness.updates)).toEqual(['plan', 'default'])
|
||||
})
|
||||
|
||||
it('advertises the folded mode on session/load', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withModes: true, script: [textResponse('planning')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await harness.client.setSessionMode({ sessionId, modeId: 'plan' })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go plan' }] })
|
||||
await harness.dispose()
|
||||
harness = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, withModes: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const res = await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
expect(res.modes).toEqual({
|
||||
availableModes: [
|
||||
{ id: 'default', name: 'default' },
|
||||
{ id: 'plan', name: 'plan' },
|
||||
],
|
||||
currentModeId: 'plan',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,129 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Text of the agent_message_chunk updates scoped to one session id. */
|
||||
function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string {
|
||||
return updates
|
||||
.filter(u => u.sessionId === sessionId && u.update.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.update.sessionUpdate === 'agent_message_chunk' && u.update.content.type === 'text' ? u.update.content.text : ''))
|
||||
.join('')
|
||||
}
|
||||
|
||||
describe('acp bridge — multi-session isolation', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-multi-')) })
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('two sessions stream concurrently without interleaving their updates', async () => {
|
||||
// Each session's prompt answer must arrive only on its own sessionId. The
|
||||
// scripted adapter answers in send order; both prompts run, and the bridge
|
||||
// demuxes every chunk by session id.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer-A'), textResponse('answer-B')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
|
||||
const [ra, rb] = await Promise.all([
|
||||
harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] }),
|
||||
harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }),
|
||||
])
|
||||
expect(ra.stopReason).toBe('end_turn')
|
||||
expect(rb.stopReason).toBe('end_turn')
|
||||
|
||||
// A's text landed only on A; B's only on B (strict id demux, no interleave).
|
||||
expect(messageTextFor(harness.sessionUpdates, a)).toContain('answer-A')
|
||||
expect(messageTextFor(harness.sessionUpdates, a)).not.toContain('answer-B')
|
||||
expect(messageTextFor(harness.sessionUpdates, b)).toContain('answer-B')
|
||||
expect(messageTextFor(harness.sessionUpdates, b)).not.toContain('answer-A')
|
||||
})
|
||||
|
||||
it('cancel in one session leaves the other session untouched', async () => {
|
||||
// Session A hangs; session B completes normally. Cancelling A settles ONLY
|
||||
// A as cancelled and never disturbs B's stream or result.
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B done')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
|
||||
const aPromise = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'hang A' }] })
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await harness.client.cancel({ sessionId: a })
|
||||
expect((await aPromise).stopReason).toBe('cancelled')
|
||||
|
||||
// B runs to completion, unaffected by A's cancel.
|
||||
const rb = await harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] })
|
||||
expect(rb.stopReason).toBe('end_turn')
|
||||
expect(messageTextFor(harness.sessionUpdates, b)).toContain('B done')
|
||||
})
|
||||
|
||||
it('enforces one in-flight prompt PER session independently', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang', 'hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
|
||||
// One in-flight prompt in EACH session is allowed (independent limits).
|
||||
const aPromise = harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'one A' }] })
|
||||
const bPromise = harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'one B' }] })
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
// A second prompt in A is rejected, but B's in-flight prompt is unaffected.
|
||||
await expect(harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'two A' }] }))
|
||||
.rejects.toThrow(/already in flight/)
|
||||
|
||||
await harness.client.cancel({ sessionId: a })
|
||||
await harness.client.cancel({ sessionId: b })
|
||||
expect((await aPromise).stopReason).toBe('cancelled')
|
||||
expect((await bPromise).stopReason).toBe('cancelled')
|
||||
})
|
||||
|
||||
it('a cancel for a non-existent session id is a silent no-op (does not touch others)', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('A done')] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
await expect(harness.client.cancel({ sessionId: 'ghost' })).resolves.toBeUndefined()
|
||||
// A still works after a cancel for an unknown id.
|
||||
const ra = await harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] })
|
||||
expect(ra.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('disposing the whole bridge drains all live sessions to quiescence', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang', 'hang'] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId
|
||||
const agentA = harness.ctx.agents.get(SessionId(a))!
|
||||
const agentB = harness.ctx.agents.get(SessionId(b))!
|
||||
|
||||
// Wait deterministically for BOTH agents to enter `running` (not a fixed
|
||||
// sleep — agent startup latency is unbounded on a loaded worker).
|
||||
const running = (agent: typeof agentA) => agent.status === 'running'
|
||||
? Promise.resolve()
|
||||
: new Promise<void>((resolve) => {
|
||||
const dispose = harness!.ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'running') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
void harness.client.prompt({ sessionId: a, prompt: [{ type: 'text', text: 'go A' }] }).catch(() => {})
|
||||
void harness.client.prompt({ sessionId: b, prompt: [{ type: 'text', text: 'go B' }] }).catch(() => {})
|
||||
await Promise.all([running(agentA), running(agentB)])
|
||||
expect(agentA.status).toBe('running')
|
||||
expect(agentB.status).toBe('running')
|
||||
|
||||
await harness.ctx.fiber.dispose()
|
||||
// BOTH agents drained (not still running) — teardown reached quiescence
|
||||
// across all sessions, not just one.
|
||||
expect(agentA.status).not.toBe('running')
|
||||
expect(agentB.status).not.toBe('running')
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Property-based protocol-shape tests for the ACP update stream (RFC 001 → ADR 0013
|
||||
* precedent). Fuzz arbitrary harness `SessionEvent` sequences through the pure
|
||||
* `streamSessionEventUpdate` translator and assert legal update variants, call-before-result order
|
||||
* per tool id, and deterministic event-to-update translation. Keeping this pure makes live and
|
||||
* replay equivalence deterministic rather than a timing property.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import { streamSessionEventUpdate } from '../src/index.ts'
|
||||
|
||||
const LEGAL_UPDATE_KINDS = new Set([
|
||||
'agent_message_chunk',
|
||||
'agent_thought_chunk',
|
||||
'tool_call',
|
||||
'tool_call_update',
|
||||
])
|
||||
|
||||
/**
|
||||
* Build a WELL-FORMED harness event sequence: a list of "actions" where a tool
|
||||
* result can only reference a call already opened earlier. This mirrors what
|
||||
* the loop actually appends (tool/call always precedes its tool/result), so the
|
||||
* ordering invariant is asserted over realistic logs, not arbitrary noise.
|
||||
*/
|
||||
type Action =
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'reasoning'; text: string }
|
||||
| { kind: 'call'; id: string; name: string }
|
||||
| { kind: 'result'; idx: number; isError: boolean }
|
||||
| { kind: 'ignored' }
|
||||
|
||||
function actionsArb(): fc.Arbitrary<Action[]> {
|
||||
const action: fc.Arbitrary<Action> = fc.oneof(
|
||||
fc.string().map((text): Action => ({ kind: 'text', text })),
|
||||
fc.string().map((text): Action => ({ kind: 'reasoning', text })),
|
||||
fc.record({ id: fc.string({ minLength: 1 }), name: fc.string() }).map(({ id, name }): Action => ({ kind: 'call', id, name })),
|
||||
fc.record({ idx: fc.nat(), isError: fc.boolean() }).map(({ idx, isError }): Action => ({ kind: 'result', idx, isError })),
|
||||
fc.constant<Action>({ kind: 'ignored' }),
|
||||
)
|
||||
return fc.array(action, { maxLength: 30 })
|
||||
}
|
||||
|
||||
/** Lower well-formed actions into a harness event sequence. */
|
||||
function actionsToEvents(actions: Action[]): SessionEvent[] {
|
||||
const events: SessionEvent[] = []
|
||||
const openCalls: string[] = []
|
||||
for (const a of actions) {
|
||||
switch (a.kind) {
|
||||
case 'text':
|
||||
events.push({ type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: a.text } } })
|
||||
break
|
||||
case 'reasoning':
|
||||
events.push({ type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: a.text } } })
|
||||
break
|
||||
case 'call':
|
||||
openCalls.push(a.id)
|
||||
events.push({ type: 'tool/call', seq: 0, time: 0, data: { turn: 1, step: 1, callId: CallId(a.id), name: a.name, arguments: '{}' } })
|
||||
break
|
||||
case 'result': {
|
||||
// Only emit a result for an already-opened call (well-formedness).
|
||||
if (openCalls.length === 0) break
|
||||
const id = openCalls[a.idx % openCalls.length]!
|
||||
events.push({ type: 'tool/result', seq: 0, time: 0, data: { turn: 1, step: 1, callId: CallId(id), content: [], isError: a.isError } })
|
||||
break
|
||||
}
|
||||
case 'ignored':
|
||||
events.push({ type: 'turn/end', seq: 0, time: 0, data: { turn: 1, reason: { kind: 'completed' } } })
|
||||
break
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function runStream(events: SessionEvent[]): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update))
|
||||
return out
|
||||
}
|
||||
|
||||
describe('ACP update-stream invariants (property-based)', () => {
|
||||
it('every emitted update is a legal SessionUpdate variant', () => {
|
||||
fc.assert(fc.property(actionsArb(), (actions) => {
|
||||
for (const update of runStream(actionsToEvents(actions))) {
|
||||
expect(LEGAL_UPDATE_KINDS.has(update.sessionUpdate)).toBe(true)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
it('never emits a tool_call_update for an id before that id\'s tool_call', () => {
|
||||
fc.assert(fc.property(actionsArb(), (actions) => {
|
||||
const seenCall = new Set<string>()
|
||||
for (const update of runStream(actionsToEvents(actions))) {
|
||||
if (update.sessionUpdate === 'tool_call') {
|
||||
seenCall.add(update.toolCallId)
|
||||
} else if (update.sessionUpdate === 'tool_call_update') {
|
||||
expect(seenCall.has(update.toolCallId)).toBe(true)
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
it('is a pure function of the event (replay equals live)', () => {
|
||||
fc.assert(fc.property(actionsArb(), (actions) => {
|
||||
const events = actionsToEvents(actions)
|
||||
expect(runStream(events)).toEqual(runStream(events))
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import { encodeSessionReferenceUri } from '@deepseek-ai/dsh-session-reference'
|
||||
import { ACP_SESSION_REFERENCE_META_KEY } from '../src/index.ts'
|
||||
import { makeBridgeHarness, type BridgeHarness } from './harness.ts'
|
||||
|
||||
describe('acp bridge — session/list', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-list-')) })
|
||||
afterEach(async () => {
|
||||
await harness?.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('advertises title-aware listing and reference metadata for loadable sessions', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true })
|
||||
const initialized = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
||||
|
||||
const cwd = process.cwd()
|
||||
const { sessionId } = await harness.client.newSession({ cwd, mcpServers: [] })
|
||||
const session = harness.ctx.agents.get(SessionId(sessionId))!.session
|
||||
await harness.ctx.sessions.appendOutOfBand(session, 'session/title', {
|
||||
title: 'Reference source title',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' },
|
||||
}, { kind: 'session-title' })
|
||||
harness.ctx.sessions.create(SessionId('untitled'), { meta: { cwd: join(storageDir, 'other') } })
|
||||
harness.ctx.sessions.create(SessionId('missing-cwd'))
|
||||
|
||||
const listed = await harness.client.listSessions({})
|
||||
expect(listed.nextCursor).toBeUndefined()
|
||||
expect(listed.sessions.map(item => item.sessionId)).toEqual(expect.arrayContaining([sessionId, 'untitled']))
|
||||
expect(listed.sessions.map(item => item.sessionId)).not.toContain('missing-cwd')
|
||||
const source = listed.sessions.find(item => item.sessionId === sessionId)
|
||||
expect(source).toMatchObject({ cwd, title: 'Reference source title' })
|
||||
expect(source?._meta?.[ACP_SESSION_REFERENCE_META_KEY]).toEqual({
|
||||
uri: encodeSessionReferenceUri(SessionId(sessionId)),
|
||||
})
|
||||
expect(listed.sessions.find(item => item.sessionId === 'untitled')).not.toHaveProperty('title')
|
||||
})
|
||||
|
||||
it('filters by normalized cwd and omits reference metadata without the optional capability', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const firstCwd = join(storageDir, 'first')
|
||||
const secondCwd = join(storageDir, 'second')
|
||||
const first = await harness.client.newSession({ cwd: firstCwd, mcpServers: [] })
|
||||
await harness.client.newSession({ cwd: secondCwd, mcpServers: [] })
|
||||
|
||||
const listed = await harness.client.listSessions({ cursor: null, cwd: firstCwd })
|
||||
expect(listed.sessions).toHaveLength(1)
|
||||
expect(listed.sessions[0]).toMatchObject({ sessionId: first.sessionId, cwd: firstCwd })
|
||||
expect(listed.sessions[0]?._meta).toBeUndefined()
|
||||
await expect(harness.client.listSessions({ cwd: null })).resolves.toHaveProperty('sessions')
|
||||
})
|
||||
|
||||
it('rejects unsupported cursors and relative cwd filters', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.listSessions({ cursor: 'next' })).rejects.toThrow('session/list does not paginate')
|
||||
await expect(harness.client.listSessions({ cwd: 'relative' })).rejects.toThrow('session/list cwd must be absolute')
|
||||
})
|
||||
|
||||
it('folds titles from persisted sessions in a fresh bridge', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const cwd = process.cwd()
|
||||
const { sessionId } = await harness.client.newSession({ cwd, mcpServers: [] })
|
||||
const session = harness.ctx.agents.get(SessionId(sessionId))!.session
|
||||
await harness.ctx.sessions.appendOutOfBand(session, 'session/title', {
|
||||
title: 'Persisted reference title',
|
||||
messageSeqs: [],
|
||||
source: { kind: 'fallback' },
|
||||
}, { kind: 'session-title' })
|
||||
await harness.dispose()
|
||||
|
||||
harness = await makeBridgeHarness({ storageDir, withSessionReferences: true })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await expect(harness.client.listSessions({ cwd })).resolves.toMatchObject({
|
||||
sessions: [{ sessionId, cwd, title: 'Persisted reference title' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,966 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { join as pathJoin, resolve as pathResolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import type { ToolDefinition, ToolRegistry as ToolRegistryType } from '@deepseek-ai/dsh-tools'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import FsLocal from '@deepseek-ai/dsh-fs-local'
|
||||
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import { streamSessionEventUpdate, agentOptions, todosToPlan, ToolPresenter } from '../src/index.ts'
|
||||
|
||||
const UNUSED_TOOL_OUTPUT: ToolDefinition['output'] = {
|
||||
schema: { type: 'null' },
|
||||
render: () => [],
|
||||
}
|
||||
|
||||
/** Collect the updates a single event produces (no presenter → generic fallback). */
|
||||
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Collect the updates emitted by the live prompt stream (user echo suppressed). */
|
||||
function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false })
|
||||
return out
|
||||
}
|
||||
|
||||
/** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */
|
||||
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistryType, 'get'> {
|
||||
const map = new Map(tools.map(t => [t.name, t]))
|
||||
return { get: name => map.get(name) }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
|
||||
return { type, seq: 0, time: 0, data } as SessionEvent
|
||||
}
|
||||
|
||||
/** ACP path fields are filesystem paths; expectations use the host separator. */
|
||||
function nativePath(...segments: string[]): string {
|
||||
return pathJoin(...segments)
|
||||
}
|
||||
|
||||
/** Resolve root-relative fixtures the same way the bridge does on this host. */
|
||||
function nativeAbsolute(...segments: string[]): string {
|
||||
return pathResolve(...segments)
|
||||
}
|
||||
|
||||
describe('streamSessionEventUpdate', () => {
|
||||
it('maps a title event to session_info_update with the event timestamp', () => {
|
||||
expect(updatesFor({
|
||||
type: 'session/title',
|
||||
seq: 3,
|
||||
time: 1_725_000_000_000,
|
||||
data: {
|
||||
title: 'Log-backed titles',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
})).toEqual([{
|
||||
sessionUpdate: 'session_info_update',
|
||||
title: 'Log-backed titles',
|
||||
updatedAt: new Date(1_725_000_000_000).toISOString(),
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps assistant/chunk text-delta to agent_message_chunk', () => {
|
||||
expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })))
|
||||
.toEqual([{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }])
|
||||
})
|
||||
|
||||
it('maps assistant/chunk reasoning-delta to agent_thought_chunk', () => {
|
||||
expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'mm' } })))
|
||||
.toEqual([{ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'mm' } }])
|
||||
})
|
||||
|
||||
it('produces no update for a non-text/reasoning chunk (e.g. block-start)', () => {
|
||||
expect(updatesFor(evt('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } })))
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('marks retry and terminal model failure boundaries but not ordinary turn errors', () => {
|
||||
expect(updatesFor(evt('llm/retry', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
retry: 1,
|
||||
maxRetries: 2,
|
||||
delayMs: 500,
|
||||
failure: { message: 'backend busy', code: 'SERVER' },
|
||||
}))).toEqual([{
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: '\n\n[Previous model attempt discarded; retrying 1/2 in 500ms: backend busy]\n\n',
|
||||
},
|
||||
}])
|
||||
expect(updatesFor(evt('turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', step: 2, failure: { message: 'still busy', code: 'SERVER' } },
|
||||
}))).toEqual([{
|
||||
sessionUpdate: 'agent_message_chunk',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: '\n\n[Model attempt failed; any partial output above is discarded: still busy]\n\n',
|
||||
},
|
||||
}])
|
||||
expect(updatesFor(evt('turn/end', {
|
||||
turn: 1,
|
||||
reason: { kind: 'error', step: 2, message: 'post-step failed' },
|
||||
}))).toEqual([])
|
||||
})
|
||||
|
||||
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',
|
||||
// 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('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([{
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'out' } }],
|
||||
}])
|
||||
const failed = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [], isError: true }))
|
||||
expect((failed[0] as { status: string }).status).toBe('failed')
|
||||
})
|
||||
|
||||
it('emits no execution update for a tool-result surface replacement', () => {
|
||||
const replacement = {
|
||||
...evt('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
|
||||
isError: false,
|
||||
}),
|
||||
seq: 2,
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
sourceEventSeqs: [1],
|
||||
} as SessionEvent
|
||||
expect(updatesFor(replacement)).toEqual([])
|
||||
})
|
||||
|
||||
it('drops non-text tool-result content (text-only)', () => {
|
||||
const update = updatesFor(evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'),
|
||||
content: [{ type: 'reasoning', text: 'private' }],
|
||||
isError: false,
|
||||
}))[0]
|
||||
expect((update as { content: unknown[] }).content).toEqual([])
|
||||
})
|
||||
|
||||
it('maps user/message text blocks to user_message_chunk (load replays the user side)', () => {
|
||||
// A text block surfaces; a non-text block (here a tool-call) is skipped, so
|
||||
// only the text chunk is emitted.
|
||||
expect(updatesFor(evt('user/message', {
|
||||
content: [
|
||||
{ type: 'text', text: 'hi' },
|
||||
{ type: 'tool-call', id: CallId('c'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
source: { kind: 'user' },
|
||||
}))).toEqual([{ sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'hi' } }])
|
||||
// A user/message with no text-bearing blocks produces no chunk.
|
||||
expect(updatesFor(evt('user/message', { content: [], source: { kind: 'user' } }))).toEqual([])
|
||||
})
|
||||
|
||||
it('replays only the direct prompt from a prefixed user message', () => {
|
||||
expect(updatesFor(evt('user/message', {
|
||||
content: [
|
||||
{ type: 'text', text: 'internal prefix' },
|
||||
{ type: 'text', text: '\n\n## My request:\n' },
|
||||
{ type: 'text', text: 'visible request' },
|
||||
],
|
||||
source: { kind: 'user' },
|
||||
envelope: {
|
||||
displayContent: [{ type: 'text', text: 'visible request' }],
|
||||
prefixContexts: [{ source: { kind: 'plugin', plugin: 'reference' } }],
|
||||
},
|
||||
}))).toEqual([{
|
||||
sessionUpdate: 'user_message_chunk',
|
||||
content: { type: 'text', text: 'visible request' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('can suppress user/message chunks for live prompt turns', () => {
|
||||
expect(liveUpdatesFor(evt('user/message', {
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
source: { kind: 'user' },
|
||||
}))).toEqual([])
|
||||
})
|
||||
|
||||
it('produces no update for boundary/other event types', () => {
|
||||
expect(updatesFor(evt('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))).toEqual([])
|
||||
expect(updatesFor(evt('turn/end', { turn: 1, reason: { kind: 'completed' } }))).toEqual([])
|
||||
expect(updatesFor(evt('step/start', { turn: 1, step: 1 }))).toEqual([])
|
||||
})
|
||||
|
||||
it('maps todo/write to a plan sessionUpdate with priority synthesized as medium', () => {
|
||||
expect(updatesFor(evt('todo/write', {
|
||||
todos: [
|
||||
{ content: 'plan the work', status: 'in_progress' },
|
||||
{ content: 'write the code', status: 'pending' },
|
||||
{ content: 'run the tests', status: 'completed' },
|
||||
],
|
||||
}))).toEqual([{
|
||||
sessionUpdate: 'plan',
|
||||
entries: [
|
||||
{ content: 'plan the work', priority: 'medium', status: 'in_progress' },
|
||||
{ content: 'write the code', priority: 'medium', status: 'pending' },
|
||||
{ content: 'run the tests', priority: 'medium', status: 'completed' },
|
||||
],
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps an empty todo list to a plan with no entries', () => {
|
||||
expect(updatesFor(evt('todo/write', { todos: [] }))).toEqual([{ sessionUpdate: 'plan', entries: [] }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('todosToPlan', () => {
|
||||
it('maps status 1:1 and stamps every entry priority medium', () => {
|
||||
expect(todosToPlan([
|
||||
{ content: 'a', status: 'pending' },
|
||||
{ content: 'b', status: 'in_progress' },
|
||||
{ content: 'c', status: 'completed' },
|
||||
])).toEqual({
|
||||
entries: [
|
||||
{ content: 'a', priority: 'medium', status: 'pending' },
|
||||
{ content: 'b', priority: 'medium', status: 'in_progress' },
|
||||
{ content: 'c', priority: 'medium', status: 'completed' },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolPresenter (tool-owned presentation via the tool registry)', () => {
|
||||
/** A tool whose presentCall/presentResult return generic-card views. */
|
||||
const bashLike: ToolDefinition = {
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
execute: async () => [],
|
||||
presentCall: (args: unknown) => {
|
||||
const a = args as { command: string; description: string }
|
||||
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}` }],
|
||||
}),
|
||||
}
|
||||
|
||||
it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
const [update] = updatesWith(presenter, evt('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), name: 'bash',
|
||||
arguments: JSON.stringify({ command: 'ls -la', description: 'List files' }),
|
||||
}))
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'List files',
|
||||
kind: 'execute',
|
||||
status: 'in_progress',
|
||||
rawInput: 'ls -la',
|
||||
})
|
||||
})
|
||||
|
||||
it('tool/result uses the tool to reformat content (resolved by the remembered tool/call)', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }),
|
||||
)
|
||||
expect(updates[1]).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'wrapped:1' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a result with NO preceding call (unknown callId) falls back to the raw content', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
// No tool/call for c9 → presenter has nothing remembered → generic fallback.
|
||||
const [update] = updatesWith(presenter, evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c9'), content: [{ type: 'text', text: 'raw' }], isError: false,
|
||||
}))
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c9',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'raw' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a tool with no presentCall/presentResult gets the generic fallback (title = name)', () => {
|
||||
const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [] }
|
||||
const presenter = new ToolPresenter(registryOf(plain))
|
||||
const [update] = updatesWith(presenter, evt('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), name: 'plain', arguments: '{"a":1}',
|
||||
}))
|
||||
expect(update).toMatchObject({ title: 'plain', kind: 'other', rawInput: { a: 1 } })
|
||||
})
|
||||
|
||||
it('a presentation that omits kind/content/rawInput uses the defaults (kind other, raw result content kept)', () => {
|
||||
// A minimal tool-owned presentation: presentCall returns only a title (no
|
||||
// kind → defaults to `other`, no rawInput → omitted); presentResult returns
|
||||
// only a title (no content → the raw result content is kept).
|
||||
const minimal: ToolDefinition = {
|
||||
name: 'mini',
|
||||
description: 'm',
|
||||
parameters: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
execute: async () => [],
|
||||
presentCall: () => ({ card: 'generic', title: 'Doing a thing' }),
|
||||
presentResult: () => ({ card: 'generic', title: 'Did the thing' }),
|
||||
}
|
||||
const presenter = new ToolPresenter(registryOf(minimal))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'mini', arguments: '{}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'kept' }], isError: false }),
|
||||
)
|
||||
// No kind → 'other'; no rawInput key at all.
|
||||
expect(updates[0]).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'Doing a thing', kind: 'other', status: 'in_progress' })
|
||||
// Title replaced; content falls back to the raw result content.
|
||||
expect(updates[1]).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'kept' } }],
|
||||
title: 'Did the thing',
|
||||
})
|
||||
})
|
||||
|
||||
it('holds ONLY in-flight calls: the callId entry is removed once its result is presented', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'o' }], isError: false }),
|
||||
)
|
||||
// A SECOND result for the same callId now finds nothing remembered, so it
|
||||
// falls back to raw content (proving the first result consumed the entry —
|
||||
// the map does not retain finished calls).
|
||||
const [late] = updatesWith(presenter, evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'late' }], isError: false,
|
||||
}))
|
||||
expect(late).toMatchObject({ content: [{ type: 'content', content: { type: 'text', text: 'late' } }] })
|
||||
})
|
||||
|
||||
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 (docs/defensive-patterns.md "contain callback exceptions at the boundary"). The
|
||||
// presenter reports the error and falls back to generic rendering.
|
||||
const boom: ToolDefinition = {
|
||||
name: 'boom',
|
||||
description: 'b',
|
||||
parameters: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
execute: async () => [],
|
||||
presentCall: () => { throw new Error('call boom') },
|
||||
presentResult: () => { throw new Error('result boom') },
|
||||
}
|
||||
const errors: string[] = []
|
||||
const presenter = new ToolPresenter(registryOf(boom), msg => errors.push(msg))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{"a":1}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }),
|
||||
)
|
||||
// tool/call fell back to title=name, raw args as rawInput.
|
||||
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom', kind: 'other', rawInput: { a: 1 } })
|
||||
// tool/result fell back to the raw content.
|
||||
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
|
||||
// Both throws were reported, not propagated.
|
||||
expect(errors).toHaveLength(2)
|
||||
expect(errors[0]).toContain('presentCall threw')
|
||||
expect(errors[1]).toContain('presentResult threw')
|
||||
})
|
||||
|
||||
it('contains a throwing presenter even with the DEFAULT (no-op) onError sink', () => {
|
||||
// Constructed without an onError sink (the default `() => {}`): a throwing
|
||||
// presenter is still swallowed and falls back generically — the absence of a
|
||||
// logger must not turn a display bug into a propagated exception.
|
||||
const boom: ToolDefinition = {
|
||||
name: 'boom',
|
||||
description: 'b',
|
||||
parameters: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
execute: async () => [],
|
||||
presentCall: () => { throw new Error('call boom') },
|
||||
presentResult: () => { throw new Error('result boom') },
|
||||
}
|
||||
const presenter = new ToolPresenter(registryOf(boom))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }),
|
||||
)
|
||||
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' })
|
||||
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
|
||||
})
|
||||
|
||||
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: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
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: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
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 —
|
||||
// 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)
|
||||
await ctx.plugin(FsLocal)
|
||||
await ctx.plugin(ToolFs)
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
|
||||
const [readCall] = updatesWith(presenter, evt('tool/call', {
|
||||
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 (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 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: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
execute: async () => [],
|
||||
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' }) })
|
||||
const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false })
|
||||
const prunedResultEvent = {
|
||||
...resultEvent,
|
||||
seq: 2,
|
||||
data: {
|
||||
...resultEvent.data,
|
||||
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: 1, end: 1 },
|
||||
sourceEventSeqs: [1],
|
||||
} as SessionEvent
|
||||
|
||||
function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
|
||||
const presenter = new ToolPresenter(registryOf(tool))
|
||||
const out: SessionNotification['update'][] = []
|
||||
for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter, { enabled, cwd })
|
||||
return out
|
||||
}
|
||||
|
||||
it('capability ON: description content THEN terminal block; cwd from the session header when the tool gives none', () => {
|
||||
const [call, update] = termUpdates(termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent)
|
||||
expect(call).toMatchObject({
|
||||
sessionUpdate: 'tool_call',
|
||||
content: [
|
||||
{ type: 'content', content: { type: 'text', text: 'Greet' } },
|
||||
{ type: 'terminal', terminalId: 'c1' },
|
||||
],
|
||||
_meta: { terminal_info: { terminal_id: 'c1', cwd: '/work/proj' } },
|
||||
})
|
||||
// The update OMITS content (it would clobber the terminal block) and carries output + exit.
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
_meta: { terminal_output: { terminal_id: 'c1', data: 'hi\n' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('live/replay translation preserves the original terminal completion across a pruning rewrite', () => {
|
||||
const updates = termUpdates(
|
||||
termTool({ card: 'terminal' }, { output: 'hi\n', exitCode: 0 }),
|
||||
true,
|
||||
'/work/proj',
|
||||
callEvent,
|
||||
resultEvent,
|
||||
prunedResultEvent,
|
||||
)
|
||||
expect(updates).toHaveLength(2)
|
||||
expect(updates[1]).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
_meta: {
|
||||
terminal_output: { terminal_id: 'c1', data: 'hi\n' },
|
||||
terminal_exit: { terminal_id: 'c1', exit_code: 0 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
|
||||
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({ card: 'terminal', cwd: nativePath('sub', 'dir') }, { output: 'x' }), true, nativeAbsolute('/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(nativeAbsolute('/work/proj', 'sub', 'dir'))
|
||||
// No session cwd to resolve against → the relative tool cwd is passed through as-is.
|
||||
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({ 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({ 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 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',
|
||||
title: 'echo hi',
|
||||
kind: 'execute',
|
||||
status: 'in_progress',
|
||||
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: '```console\nhi\n```' } }],
|
||||
})
|
||||
})
|
||||
|
||||
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 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: '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: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
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: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
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. The real tool is required because its result metadata is the contract.
|
||||
it('live/replay translation keeps the applied diff when a pruning rewrite follows', 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 originalResult = evt('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('e1'),
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
isError: false,
|
||||
meta,
|
||||
})
|
||||
const replacement = {
|
||||
...originalResult,
|
||||
seq: 3,
|
||||
data: {
|
||||
...originalResult.data,
|
||||
content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
|
||||
},
|
||||
surfaceOp: { op: 'replace', start: 2, end: 2 },
|
||||
sourceEventSeqs: [2],
|
||||
} as SessionEvent
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'edit', arguments: args }),
|
||||
originalResult,
|
||||
replacement,
|
||||
)
|
||||
expect(updates).toHaveLength(2)
|
||||
const resultUpdate = updates[1]
|
||||
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. Diff and location
|
||||
// paths remain absolute so the editor can open the real file.
|
||||
const ctx = await fsCtx()
|
||||
const presenter = new ToolPresenter(ctx.tools)
|
||||
const workspace = nativeAbsolute('/work/proj')
|
||||
const file = nativeAbsolute('/work/proj', 'src', 'b.ts')
|
||||
const args = JSON.stringify({ file_path: file, old_string: 'OLD', new_string: 'NEW' })
|
||||
const meta = { diffs: [{ path: file, oldText: 'a\nOLD\nb', newText: 'a\nNEW\nb' }] }
|
||||
const out: SessionNotification['update'][] = []
|
||||
const rendering = { enabled: false, cwd: workspace }
|
||||
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 ${nativePath('src', 'b.ts')}`,
|
||||
content: [{ type: 'diff', path: file, 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)', () => {
|
||||
// Shipping edit always has a hunk and write falls back to a whole-file diff, so a synthetic
|
||||
// tool is required to cover both absent-title and empty-content result branches.
|
||||
const emptyDiffTool: ToolDefinition = {
|
||||
name: 'writer',
|
||||
description: 'writes a file',
|
||||
parameters: {},
|
||||
output: UNUSED_TOOL_OUTPUT,
|
||||
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 location/diff paths raw. Use real fs tools
|
||||
// and the absolute paths an editor supplies; presentation itself is args-only and lacks cwd.
|
||||
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 workspace = nativeAbsolute('/work/proj')
|
||||
const file = nativeAbsolute('/work/proj', 'src', 'a.ts')
|
||||
const update = callUpdate(ctx, workspace, 'read', { file_path: file, offset: 5 })
|
||||
expect(update).toMatchObject({
|
||||
title: `Read ${nativePath('src', 'a.ts')} (from line 5)`,
|
||||
locations: [{ path: file, 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 workspace = nativeAbsolute('/work/proj')
|
||||
const file = nativeAbsolute('/work/proj', 'src', 'b.ts')
|
||||
const update = callUpdate(ctx, workspace, 'edit', { file_path: file, old_string: 'x', new_string: 'y' })
|
||||
expect(update).toMatchObject({
|
||||
title: `Edit ${nativePath('src', 'b.ts')}`,
|
||||
locations: [{ path: file }],
|
||||
content: [{ type: 'diff', path: file, 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. Segment-aware guarding must relativize it,
|
||||
// matching targets under `cwd + sep` in the reference adapter.
|
||||
const ctx = await fsCtx()
|
||||
const update = callUpdate(ctx, nativeAbsolute('/work/proj'), 'read', { file_path: nativeAbsolute('/work/proj', '..cache', 'x.ts') })
|
||||
expect((update as { title: string }).title).toBe(`Read ${nativePath('..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, nativeAbsolute('/work/proj'), 'read', { file_path: nativePath('src', 'a.ts') })
|
||||
expect((update as { title: string }).title).toBe(`Read ${nativePath('src', 'a.ts')}`)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentOptions', () => {
|
||||
it('includes only the fields present in config', () => {
|
||||
expect(agentOptions({})).toEqual({})
|
||||
expect(agentOptions({ model: 'm' })).toEqual({ model: 'm' })
|
||||
expect(agentOptions({ provider: 'p', model: 'm' })).toEqual({ provider: 'p', model: 'm' })
|
||||
})
|
||||
})
|
||||
@@ -1,406 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
errorResponse,
|
||||
makeBridgeHarness,
|
||||
maxTokensResponse,
|
||||
textResponse,
|
||||
toolCallResponse,
|
||||
type BridgeHarness,
|
||||
} from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Boilerplate: initialize + create one session, returning its id. */
|
||||
async function newSession(h: BridgeHarness, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
return sessionId
|
||||
}
|
||||
|
||||
describe('acp bridge — turn outcomes', () => {
|
||||
let storageDir: string
|
||||
let harness: BridgeHarness | undefined
|
||||
|
||||
beforeEach(async () => { storageDir = await mkdtemp(join(tmpdir(), 'acp-test-')) })
|
||||
afterEach(async () => {
|
||||
if (harness) await harness.dispose()
|
||||
harness = undefined
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('maps a max-tokens turn to stopReason max_tokens', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [maxTokensResponse('cut off')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('max_tokens')
|
||||
})
|
||||
|
||||
it('rejects the prompt RPC when a turn fails (no misleading end_turn)', async () => {
|
||||
// ACP has no "error" stop reason; a failed turn must surface as a rejected
|
||||
// session/prompt, not a normal end_turn that hides the failure from the
|
||||
// client. The bridge rejects via the turn/end{error} log record.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('provider boom')] })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: provider boom/)
|
||||
})
|
||||
|
||||
it('rejects an ordinary plugin turn failure through the same ACP boundary', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('must not run')] })
|
||||
harness.ctx.on('agent/pre-step', () => { throw new Error('plugin pre-step failed') })
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: plugin pre-step failed/)
|
||||
})
|
||||
|
||||
it('streams a tool call as tool_call then tool_call_update', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi' }), textResponse('done')],
|
||||
})
|
||||
harness.ctx.tools.register(defineContentToolFixture({
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: { command: { type: 'string' } },
|
||||
async execute() { return [{ type: 'text', text: 'hi\n' }] },
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] })
|
||||
|
||||
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)
|
||||
// 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' })
|
||||
|
||||
// Ordering invariant: the tool_call precedes its tool_call_update.
|
||||
const callIdx = harness.updates.findIndex(u => u.sessionUpdate === 'tool_call')
|
||||
const updIdx = harness.updates.findIndex(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(callIdx).toBeLessThan(updIdx)
|
||||
})
|
||||
|
||||
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 (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.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [
|
||||
toolCallResponse('c1', 'bash', { command: 'echo hello', description: 'Print a greeting' }),
|
||||
textResponse('done'),
|
||||
],
|
||||
})
|
||||
const sessionId = await newSession(harness)
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
|
||||
// presentCall: execute kind, title IS the command (an execute card hides
|
||||
// rawInput, so the command is the title), the description rides as a content
|
||||
// text block, the command is also rawInput for non-terminal UIs.
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
expect(call).toMatchObject({
|
||||
toolCallId: 'c1',
|
||||
title: 'echo hello',
|
||||
kind: 'execute',
|
||||
rawInput: 'echo hello',
|
||||
status: 'in_progress',
|
||||
})
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Capability OFF: the description renders as the only content block (no terminal block).
|
||||
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
|
||||
// presentResult: the REAL command output, wrapped in a fenced console block.
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(update?.sessionUpdate).toBe('tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
const content = update.content as { content: { type: string; text: string } }[]
|
||||
expect(content[0]?.content.text).toBe('```console\nhello\n```')
|
||||
// Capability OFF (the default newSession): NO terminal _meta on either update.
|
||||
expect((call as { _meta?: unknown })._meta).toBeUndefined()
|
||||
expect((update as { _meta?: unknown })._meta).toBeUndefined()
|
||||
})
|
||||
|
||||
it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => {
|
||||
// With terminal output advertised, a real bash call emits description then terminal content
|
||||
// plus cwd metadata; its result uses terminal output/exit metadata and omits text that would
|
||||
// clobber the card.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
|
||||
})
|
||||
// Capability lives under clientCapabilities._meta.terminal_output.
|
||||
const sessionId = await newSession(harness, { _meta: { terminal_output: true } })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// The description content block FIRST (renders above the card), then a
|
||||
// terminal content block keyed by the callId; terminal_info carries the
|
||||
// session cwd (the bridge fills it from the session header).
|
||||
expect(call.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Greet' } },
|
||||
{ type: 'terminal', terminalId: 'c1' },
|
||||
])
|
||||
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
|
||||
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
// In terminal mode the text content is OMITTED (a tool_call_update.content
|
||||
// REPLACES the call's content — it would clobber the terminal block).
|
||||
expect(update.content).toBeUndefined()
|
||||
// Output rides on _meta.terminal_output; the parsed exit on _meta.terminal_exit.
|
||||
const meta = update._meta as {
|
||||
terminal_output?: { terminal_id: string; data: string }
|
||||
terminal_exit?: { terminal_id: string; exit_code?: number; signal?: string }
|
||||
}
|
||||
expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'hi\n' })
|
||||
expect(meta.terminal_exit).toEqual({ terminal_id: 'c1', exit_code: 0 })
|
||||
})
|
||||
|
||||
it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => {
|
||||
// Create the session with terminal support, then disable it connection-wide. The session's
|
||||
// snapshot must keep call and result rendering consistent instead of re-reading changed state.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// A re-initialize that DROPS the capability after the session exists.
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Still a terminal card (the session's snapshot, not the mutated connection cap).
|
||||
expect((call._meta as { terminal_info?: unknown }).terminal_info).toBeDefined()
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
// The result AGREES with the call: terminal output present, content omitted.
|
||||
expect(update.content).toBeUndefined()
|
||||
expect((update._meta as { terminal_output?: unknown }).terminal_output).toBeDefined()
|
||||
})
|
||||
|
||||
it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {
|
||||
// A buggy tool whose presentCall throws must not fail the live turn — the
|
||||
// bridge's presenter contains the throw (logging via its onError sink) and
|
||||
// falls back to the generic title=name presentation. Exercises the real
|
||||
// bridge wiring of the per-session presenter's error sink.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')],
|
||||
})
|
||||
harness.ctx.tools.register(defineContentToolFixture({
|
||||
name: 'kaboom',
|
||||
description: 'explodes when presented',
|
||||
parameters: { x: { type: 'number' } },
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
presentCall: () => { throw new Error('present boom') },
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('end_turn') // the turn completed despite the throw
|
||||
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
// Generic fallback: title is the tool name, raw args as rawInput.
|
||||
expect(call).toMatchObject({ toolCallId: 'c1', title: 'kaboom', kind: 'other', rawInput: { x: 1 } })
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
})
|
||||
|
||||
it('a failing tool yields a failed tool_call_update', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'boom' }), textResponse('ok')],
|
||||
})
|
||||
harness.ctx.tools.register(defineContentToolFixture({
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: { command: { type: 'string' } },
|
||||
async execute() { throw new Error('command failed') },
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'run it' }] })
|
||||
const failed = harness.updates.filter(u => u.sessionUpdate === 'tool_call_update' && u.status === 'failed')
|
||||
expect(failed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('settles successfully when an earlier turn/end observer throws', async () => {
|
||||
// Session contains each post-commit observer failure, so a prepended peer
|
||||
// cannot starve the bridge's live turn/end delivery.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/end') throw new Error('peer listener boom')
|
||||
}, { prepend: true })
|
||||
const sessionId = await newSession(harness)
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('still rejects a failed turn when an earlier turn/end observer throws', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: [errorResponse('starved boom')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/end') throw new Error('peer listener boom')
|
||||
}, { prepend: true })
|
||||
const sessionId = await newSession(harness)
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }))
|
||||
.rejects.toThrow(/turn failed: starved boom/)
|
||||
})
|
||||
|
||||
it('captures and settles the owning turn when an earlier turn-start observer throws', async () => {
|
||||
// Turn correlation still reaches the bridge after the throwing peer and
|
||||
// captures inflight.turn via the live stream. A throwing turn/start listener
|
||||
// Session contains post-commit callbacks independently.
|
||||
// The model request and normal turn outcome therefore still occur.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer')] })
|
||||
harness.ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'turn/start') throw new Error('peer listener boom on start')
|
||||
}, { prepend: true })
|
||||
const sessionId = await newSession(harness)
|
||||
const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(result.stopReason).toBe('end_turn')
|
||||
})
|
||||
|
||||
it('a between-turn injection does not settle the prompt early (message-trigger correlation)', async () => {
|
||||
// A plugin injects context (a one-shot injection-triggered turn) right after
|
||||
// the prompt is queued but before the prompt's own message turn runs. The
|
||||
// bridge must NOT mistake the injection turn's turn/end for the prompt's —
|
||||
// it correlates only to message-triggered turns. The prompt settles on its
|
||||
// OWN turn with the real model answer.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
// On the queued prompt, synchronously inject a one-shot context turn (idle
|
||||
// inject writes turn/start{injection} → context/message → turn/end). Fire
|
||||
// once so it lands between install and the prompt turn.
|
||||
let injected = false
|
||||
harness.ctx.on('agent/queued', (subject) => {
|
||||
if (subject === agent && !injected) {
|
||||
injected = true
|
||||
agent.inject([{ type: 'text', text: 'ctx note' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
}
|
||||
})
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
const text = harness.updates
|
||||
.filter(u => u.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
expect(text).toContain('real answer')
|
||||
})
|
||||
|
||||
it('rejects a second prompt while one is in flight', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
// Start the first prompt but do NOT await — it hangs in the model stream.
|
||||
const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] })
|
||||
// Give the loop a tick to install the settle + start running.
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] }))
|
||||
.rejects.toThrow(/already in flight/)
|
||||
// Cancel to settle the first so the harness disposes cleanly.
|
||||
await harness.client.cancel({ sessionId })
|
||||
await first
|
||||
})
|
||||
|
||||
it('session/cancel aborts a running turn and settles the prompt as cancelled', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang'] })
|
||||
const sessionId = await newSession(harness)
|
||||
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await harness.client.cancel({ sessionId })
|
||||
const res = await promptDone
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
await agent.whenIdle()
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
|
||||
})
|
||||
|
||||
it('cancel right after prompt settles cancelled and leaves the agent idle, no leaked turn', async () => {
|
||||
// JSON-RPC timing normally makes this a running mid-step cancellation; pre-step dropping is
|
||||
// covered in agent-loop. Here the prompt must settle cancelled, return idle, and clear queued
|
||||
// work so the scripted second response cannot leak into another turn.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('answer'), textResponse('leaked')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
await harness.client.cancel({ sessionId })
|
||||
const res = await promptDone
|
||||
expect(res.stopReason).toBe('cancelled')
|
||||
const agent = harness.ctx.agents.get(SessionId(sessionId))!
|
||||
await agent.whenIdle()
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start').length
|
||||
expect(turnStarts).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('idle session/cancel then session/prompt runs the prompt (no intervening whenIdle)', async () => {
|
||||
// The bridge settles cancel synchronously, so exercise the production cancel→prompt race with
|
||||
// no `whenIdle()`. An idle cancel must not mark or drop the following prompt.
|
||||
harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
// Cancel while idle (no prompt in flight) — a no-op.
|
||||
await harness.client.cancel({ sessionId })
|
||||
// Immediately prompt, no whenIdle() between.
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('end_turn')
|
||||
const text = harness.updates
|
||||
.filter(u => u.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
expect(text).toContain('real answer')
|
||||
})
|
||||
|
||||
it('mid-stream cancel then an IMMEDIATE next prompt runs (no intervening whenIdle)', async () => {
|
||||
// Cancel a running turn and immediately send another prompt without awaiting quiescence. The
|
||||
// cancellation marker belongs only to the first turn and must not drop the next request.
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('next answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] })
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await harness.client.cancel({ sessionId })
|
||||
expect((await a).stopReason).toBe('cancelled')
|
||||
// Immediately — no whenIdle() — send the next prompt.
|
||||
const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] })
|
||||
expect(b.stopReason).toBe('end_turn')
|
||||
const text = harness.updates
|
||||
.filter(u => u.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
expect(text).toContain('next answer')
|
||||
})
|
||||
|
||||
it('a cancelled turn\'s late turn/end does not settle the NEXT prompt', async () => {
|
||||
// Cancellation frees A's slot before its aborted turn/end is appended. Send B in that window;
|
||||
// correlation by turn number must prevent A's late closer from settling B as cancelled.
|
||||
harness = await makeBridgeHarness({ storageDir, script: ['hang', textResponse('B answer')] })
|
||||
const sessionId = await newSession(harness)
|
||||
|
||||
const a = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'A' }] })
|
||||
await new Promise(r => setTimeout(r, 30)) // let A start running (turn 1)
|
||||
await harness.client.cancel({ sessionId })
|
||||
expect((await a).stopReason).toBe('cancelled')
|
||||
|
||||
// B owns the later turn and must complete on its own turn/end.
|
||||
const b = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'B' }] })
|
||||
expect(b.stopReason).toBe('end_turn')
|
||||
const text = harness.updates
|
||||
.filter(u => u.sessionUpdate === 'agent_message_chunk')
|
||||
.map(u => (u.content.type === 'text' ? u.content.text : ''))
|
||||
.join('')
|
||||
expect(text).toContain('B answer')
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm-retry"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../context/session-reference"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../commands"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../plan/plan-mode"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../permission"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
packages/ui/app-boot/README.i18n.yaml
Normal file
6
packages/ui/app-boot/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 59fc4ed46f047ee8f72aa237ec3a47b21091358f
|
||||
README.zh.md: 596392a8eb11469a9f3afb34046c2c37521a4ed6
|
||||
@@ -1,17 +1,18 @@
|
||||
# `@deepseek-ai/dsh-app-boot`
|
||||
|
||||
Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/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 published artifacts.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/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 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` |
|
||||
| `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh |
|
||||
| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume <sessionId>` pair while preserving positional arguments |
|
||||
| `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) |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
|
||||
| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
|
||||
48
packages/ui/app-boot/README.zh.md
Normal file
48
packages/ui/app-boot/README.zh.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# `@deepseek-ai/dsh-app-boot`
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
供 app bin([`dsh`](../../../apps/cli/README.md)、[`dsh-cli-demo`](../../examples/cli-demo/README.md)、[`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些 helper 上构建的精简自执行组合,并以自身诊断前缀参数化。这样,Loader 故障处理知识只需维护一处并接受逐文件覆盖率门禁,不会在已发布产物之间逐渐分化。
|
||||
|
||||
| 导出 | 职责 |
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` |
|
||||
| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) |
|
||||
| `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目(即导入失败的插件模块),则抛出异常 |
|
||||
| `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 |
|
||||
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 |
|
||||
| `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 |
|
||||
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
|
||||
|
||||
这些保护处理两类故障。`loader.await()` 会吞掉初始化 rejection(`Promise.allSettled`);Node 仍会因随后产生的未处理 rejection 以非零状态退出,而 `installFailLoud` 会把冗长转储替换为一行带标签的消息,并确保执行 `exit(1)`。插件导入失败则只会由 Loader 记录日志(否则,即使配置存在拼写错误,进程也会以代码 0 退出),并留下没有 fiber 的条目;`assertEntriesLoaded` 会将其转换为 `boot()` rejection。
|
||||
|
||||
配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包(package))通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。bin 的子进程冒烟测试覆盖内部 loader 路径,而本包的单元测试套件会在进程内使用相对 specifier 配置驱动 `boot()`。
|
||||
|
||||
此包不包含 loader 钩子,也不提供开发模式接口:`dsh-scripts` launcher([`sdk/scripts`](../../sdk/scripts/README.md),共享项目模型见 [`sdk/helper`](../../sdk/helper/README.md))持有进程启动、tsx 注册和本地插件源代码解析,并在自身的启动序列中使用这些 helper。
|
||||
|
||||
## 个人配置
|
||||
|
||||
开发者的机器本地偏好位于所有仓库之外的 Harness home 中(默认 `~/.dsh`,可由 `$DSH_HOME` 覆盖;统一由根级 [`resolveDshHome`](../../util/paths/README.md) 解析),并由 `dsh` CLI(命令行界面)的 TUI 界面([`apps/cli`](../../../apps/cli/README.md))使用;demo bin 会原样启动仓库中提交的树。这里有两个可选文件:
|
||||
|
||||
- **`.env`**:在调用目录的 `.env` 之后加载;`process.loadEnvFile` 从不覆盖已有值,因此优先级为环境中的值 > 项目 `.env` > 个人 `.env`。
|
||||
- **`config.yaml`**:在发布的默认配置上应用 Loader overlay patch,语义与 include 条目的 `patches` 相同(以仓库提交的 Code Mode overlay 为模板):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值,因此个人 `apiKey` 可以引用个人 `.env`。如果 patch 指定的条目 id 不在已启动树中,Loader 会发出警告并跳过。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用 overlay,请使用 `[]` 或删除该文件。
|
||||
|
||||
子进程测试 launcher 会把 `DSH_HOME` 指向逐测试隔离的目录,确保开发者的个人 overlay 不会泄漏到 fixture(测试前置数据)中。
|
||||
|
||||
## 模型体验
|
||||
|
||||
模型通过此包加载的插件树间接受到影响;该树决定最终应用中的提示词、schema、消息和模型适配器。唯一贡献模型可见文本的导出 `addHarnessSourceSection`,也只有在消费方启动后调用它时才会产生影响。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
`boot()` 不会直接使缓存失效;消费方调用 `addHarnessSourceSection` 时,会在系统提示词靠前位置、逐请求内容之前添加一行短文本,因此不会使跨轮次缓存失效。请求前缀的其他任何变化均由相应的具名消费方持有。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生 helper;没有该 helper 的进程内调用方必须使用可解析的相对/file specifier,或使用 tsx 路径映射。
|
||||
- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。
|
||||
- **环境加载局限于 cwd 且为可选操作**:helper 只加载一个 `.env` 文件,并在失败时发出警告;它不会搜索父目录、合并 profile 或验证必需变量。
|
||||
- **个人配置采用 patch 形式**:按 id 定位的 patch 会替换条目的整个 `config`,而不是深度合并,因此个人覆盖必须重述需要保留的基础字段。
|
||||
- **个人 patch 只能看到已启动文件自身的条目**:如果 overlay 叶子通过嵌套 include 条目访问其基础配置(例如 Code Mode 配置),个人 patch id 只会在 overlay 的顶层条目中解析,不会进入被 include 的子树。
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
|
||||
* optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
|
||||
* against a leaf `cordis.yml` until the whole tree has settled.
|
||||
@@ -36,62 +36,6 @@ export function resolveConfigPath(
|
||||
return resolve(dir, replayName)
|
||||
}
|
||||
|
||||
/** CLI flag the interactive surface accepts to resume a persisted session by id. */
|
||||
const RESUME_FLAG = '--resume'
|
||||
|
||||
/**
|
||||
* Split a leading `--resume <id>` / `--resume=<id>` flag out of a CLI argument
|
||||
* vector, returning the resumed session id (when the flag is present) and the
|
||||
* remaining arguments with the flag and its value removed — so a positional
|
||||
* config path stays readable regardless of the flag's position. A `--resume`
|
||||
* with no following id, an empty id (`--resume=`), or a repeated `--resume`
|
||||
* throws: a mistyped resume must fail loud, never silently start a fresh
|
||||
* session. The id is not validated here; an unknown id fails loud downstream
|
||||
* when the session cannot load.
|
||||
* @param argv - the CLI arguments after subcommand dispatch.
|
||||
* @returns the parsed resume id (or `undefined`) and the flag-stripped arguments.
|
||||
*/
|
||||
export function parseResumeArg(
|
||||
argv: readonly string[],
|
||||
): { resumeSessionId: string | undefined; rest: string[] } {
|
||||
const rest: string[] = []
|
||||
let resumeSessionId: string | undefined
|
||||
let skipNext = false
|
||||
for (const [i, arg] of argv.entries()) {
|
||||
if (skipNext) {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
const inlineValue = arg.startsWith(`${RESUME_FLAG}=`)
|
||||
if (arg === RESUME_FLAG || inlineValue) {
|
||||
if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`)
|
||||
const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1]
|
||||
// A following token that is itself resume syntax (`--resume --resume x`)
|
||||
// is a missing id, not a session literally named `--resume…`.
|
||||
if (value === undefined || value === '' || value === RESUME_FLAG || value.startsWith(`${RESUME_FLAG}=`)) {
|
||||
throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} <session-id>)`)
|
||||
}
|
||||
resumeSessionId = value
|
||||
skipNext = !inlineValue // the space form consumed the following token as its value
|
||||
continue
|
||||
}
|
||||
rest.push(arg)
|
||||
}
|
||||
return { resumeSessionId, rest }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace any existing resume flag with one canonical trailing `--resume <id>` pair.
|
||||
* @param argv - current arguments after command dispatch.
|
||||
* @param sessionId - selected session id.
|
||||
* @returns flag-normalized arguments for a process replacement.
|
||||
*/
|
||||
export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] {
|
||||
if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`)
|
||||
const { rest } = parseResumeArg(argv)
|
||||
return [...rest, RESUME_FLAG, sessionId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
|
||||
* ambient environment; other read failures are reported through `warn`.
|
||||
@@ -212,6 +156,17 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
|
||||
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
|
||||
* makes `id` readable as the bare identifier `resumeSessionId` in a config
|
||||
* `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
|
||||
* so resuming a session needs no environment variable. A bin that never
|
||||
* provides it leaves the identifier undeclared, so configs read it defensively
|
||||
* (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
|
||||
*/
|
||||
export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
|
||||
|
||||
/**
|
||||
* Boot the Loader against `absoluteConfigPath` and return only after the whole
|
||||
* tree settles. Entry names load through the Loader's internal module loader
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Context } from 'cordis'
|
||||
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import {
|
||||
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
|
||||
installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess,
|
||||
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
@@ -30,40 +30,6 @@ describe('resolveConfigPath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseResumeArg', () => {
|
||||
it('returns no resume id and passes arguments through when the flag is absent', () => {
|
||||
expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] })
|
||||
expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] })
|
||||
})
|
||||
|
||||
it('parses the space form, the inline form, and leaves a positional config path in any position', () => {
|
||||
expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] })
|
||||
expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] })
|
||||
expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] })
|
||||
expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] })
|
||||
})
|
||||
|
||||
it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => {
|
||||
expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id')
|
||||
expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id')
|
||||
expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once')
|
||||
})
|
||||
|
||||
it('rejects resume syntax used as the flag value instead of resuming a session named like the flag', () => {
|
||||
expect(() => parseResumeArg(['--resume', '--resume', 'sess'])).toThrow('--resume requires a session id')
|
||||
expect(() => parseResumeArg(['--resume', '--resume=sess'])).toThrow('--resume requires a session id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceResumeArg', () => {
|
||||
it('keeps positional arguments and replaces either existing flag form', () => {
|
||||
expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next'])
|
||||
expect(() => replaceResumeArg([], '')).toThrow('non-empty session id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadEnv', () => {
|
||||
it('loads variables from .env in the given dir', () => {
|
||||
const dir = tmp()
|
||||
|
||||
6
packages/ui/commands/README.i18n.yaml
Normal file
6
packages/ui/commands/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/commands/README.md
|
||||
README.md: 4ad72cf9e232c8d41e525f42eecde5637032a391
|
||||
README.zh.md: bace8f6346ac737a838d802dfc5c6ffe52c56edd
|
||||
@@ -1,12 +1,14 @@
|
||||
# @deepseek-ai/dsh-commands
|
||||
|
||||
Plugin-owned human-command registry shared by the TUI and ACP adapters. The [plugin command registration Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and protocol mapping.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Plugin-owned human-command registry consumed by interactive UI adapters. The [plugin command registration Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and dispatch contract.
|
||||
|
||||
## Service contract
|
||||
|
||||
`ctx.commands.register(definition)` registers one lowercase command name, description, optional ACP-compatible unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
|
||||
`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input hint, and abortable handler. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers.
|
||||
|
||||
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning `undefined` for invalid syntax or unknown names.
|
||||
`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing. `find(agent, name)` returns the corresponding definition. `execute(agent, line, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured `name`/`args` split, and the issuing `CommandSource`) and `command/done` (at settlement, with the outcome kind and verbatim text; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown.
|
||||
|
||||
`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits.
|
||||
|
||||
@@ -14,7 +16,7 @@ Handlers return `success` or `error` plus optional UI text. Results are rendered
|
||||
|
||||
## Composition
|
||||
|
||||
The terminal and ACP app bundles mount this service with their consuming front door; the UI-less agent spine does not. Custom compositions that use `dsh-tui`, `dsh-acp`, or a command producer mount `@deepseek-ai/dsh-commands` explicitly.
|
||||
The terminal app bundle mounts this service with `dsh-tui`; the UI-less agent spine and ACP automation app do not. Custom interactive compositions and command producers mount `@deepseek-ai/dsh-commands` explicitly.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -34,6 +36,5 @@ Registry metadata, command input, and direct output never enter a model request
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only unstructured text input** — the descriptor intentionally matches ACP's current unstructured command input; forms, completion schemas, and typed arguments remain command-owned parsing concerns.
|
||||
- **No persisted command output** — adapters display results live, but the generic registry does not add them to the session log or reconstruct them after reconnect.
|
||||
- **Only unstructured text input** — forms, completion schemas, and typed arguments remain command-owned parsing concerns.
|
||||
- **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems.
|
||||
|
||||
40
packages/ui/commands/README.zh.md
Normal file
40
packages/ui/commands/README.zh.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# @deepseek-ai/dsh-commands
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
由插件拥有、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发契约。
|
||||
|
||||
## 服务契约
|
||||
|
||||
`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入提示,以及可中止的处理器。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent 的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使实时适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。
|
||||
|
||||
`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符。`find(agent, name)` 返回相应定义。`execute(agent, line, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带铸造的 `commandId`、解析器的结构化 `name`/`args` 切分和发起方 `CommandSource`)与 `command/done`(结算时记录,携带结局种类与原样文本;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都是直接独立追加:没有轮次包裹它们,持久化在常规检查点与 teardown 时排空它们。
|
||||
|
||||
`parseCommand()` 识别位于字节零位置的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方拥有各命令专用的语法,只能执行该语法允许的规范化。
|
||||
|
||||
处理器返回 `success` 或 `error`,并可附带 UI 文本。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方拥有由此产生的消息契约。注册表会让处理器完成与所提供的中止信号竞速,但不协作的处理器可能在调用方停止等待后继续产生自身的外部副作用。
|
||||
|
||||
## 组合
|
||||
|
||||
终端应用组合包会将此服务与 `dsh-tui` 一起挂载;无 UI 的 agent 主干和 ACP(Agent Client Protocol)自动化应用不会挂载它。自定义交互式组合与命令生产方会显式挂载 `@deepseek-ai/dsh-commands`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 直接面向用户的命令
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
命令发现、执行和 UI 输出不会增加模型 token。命令生产方显式安排的 agent 工作与相应 agent 输入具有相同的 token 影响。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
注册表元数据、命令输入和直接输出绝不会进入模型请求,也不会影响其缓存。被命令变更的领域拥有之后产生的所有缓存影响。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **仅支持非结构化文本输入**:表单、补全 schema 和类型化参数仍由各命令自行解析。
|
||||
- **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./brand": {
|
||||
"types": "./lib/types/brand.d.ts",
|
||||
"default": "./lib/types/brand.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -28,12 +33,15 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
29
packages/ui/commands/src/brand.ts
Normal file
29
packages/ui/commands/src/brand.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* dsh-commands' owned branded id: command lifecycle pairing across the
|
||||
* session log, the wire admission response, and client-side flow pairing.
|
||||
*
|
||||
* The `Branded<B>` primitive lives in `@deepseek-ai/dsh-brand`; this module
|
||||
* is a pure type/constructor outlet (no cordis imports, no module
|
||||
* augmentation) so wire and client programs can name the brand without
|
||||
* loading the host plugin's Context merges — the `dsh-llm/brand` shape.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-commands/brand
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/**
|
||||
* Pairs one command execution's `command/run`/`command/done` lifecycle
|
||||
* records with each other and with the `command.execute` admission response.
|
||||
* Minted by the executor, monotonic per service instance.
|
||||
*/
|
||||
export type CommandId = Branded<'CommandId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link CommandId}.
|
||||
* @param id - the executor-minted pairing id.
|
||||
* @returns the same string, branded; no validation is performed.
|
||||
*/
|
||||
export function CommandId(id: string): CommandId {
|
||||
return id as CommandId
|
||||
}
|
||||
@@ -7,12 +7,29 @@ import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
|
||||
import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
import { CommandId } from './brand.ts'
|
||||
|
||||
export { CommandId } from './brand.ts'
|
||||
|
||||
export const name = 'commands'
|
||||
|
||||
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
|
||||
|
||||
/** Immutable command input metadata compatible with ACP unstructured input. */
|
||||
/**
|
||||
* Producer record for one command invocation (the `command/run` event's
|
||||
* provenance slot). Merge-extensible sum type mirroring `MessageSourceMap`'s
|
||||
* shape; minimal today because every executor caller is a human-facing UI
|
||||
* surface dispatching a human-typed line, so the sole variant is `user`.
|
||||
*/
|
||||
export interface CommandSourceMap {
|
||||
user: { kind: 'user' }
|
||||
}
|
||||
|
||||
/** The union over {@link CommandSourceMap} — who issued a command line. */
|
||||
export type CommandSource = CommandSourceMap[keyof CommandSourceMap]
|
||||
|
||||
/** Immutable metadata for a command's optional unstructured input. */
|
||||
export interface CommandInputDescriptor {
|
||||
/** Placeholder shown before the user supplies free-form input. */
|
||||
readonly hint: string
|
||||
@@ -33,6 +50,19 @@ export type CommandResult =
|
||||
| { readonly kind: 'success'; readonly text?: string }
|
||||
| { readonly kind: 'error'; readonly text: string }
|
||||
|
||||
/**
|
||||
* One settled command execution: the handler's normalized result plus the
|
||||
* lifecycle pairing id minted for its `command/run`/`command/done` records,
|
||||
* so a dispatching surface can correlate the RPC-level acknowledgment with
|
||||
* the flow node those events produce.
|
||||
*/
|
||||
export interface CommandExecution {
|
||||
/** Pairing id carried by this execution's lifecycle events. */
|
||||
readonly commandId: CommandId
|
||||
/** The handler's normalized outcome. */
|
||||
readonly result: CommandResult
|
||||
}
|
||||
|
||||
/** Plugin-owned command registration. */
|
||||
export interface CommandDefinition {
|
||||
/** Lowercase command name without the leading slash. */
|
||||
@@ -88,6 +118,27 @@ class CommandLayer implements ScopeLayer {
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* A resolved slash command entered its handler. Log-only (never model
|
||||
* surface); paired with `command/done` by `commandId`, mirroring the
|
||||
* `tool/call`↔`tool/result` pairing. The payload is structured — `name`
|
||||
* and `args` are `parseCommand`'s own split (name and verbatim rawInput,
|
||||
* separator whitespace included), so a consumer (a projection unit
|
||||
* folding its own command records, a rich command card) never re-parses
|
||||
* a line.
|
||||
*/
|
||||
'command/run': { commandId: CommandId; name: string; args: string; source: CommandSource }
|
||||
/**
|
||||
* The paired command settled. `kind`/`text` carry the handler's verbatim
|
||||
* outcome (a thrown/aborted handler settles as `kind: 'error'` with the
|
||||
* rendered failure); presentation stays client-computed at render time.
|
||||
*/
|
||||
'command/done': { commandId: CommandId; kind: 'success' | 'error'; text?: string }
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
commands: CommandService
|
||||
@@ -230,6 +281,11 @@ export class CommandService extends Service {
|
||||
() => { this.notifyChange() },
|
||||
)
|
||||
|
||||
/** Monotonic per-instance counter behind {@link mintCommandId}. */
|
||||
private commandSeq = 0
|
||||
/** Instance token keeping minted ids unique across process restarts over one resumed log. */
|
||||
private readonly instanceToken = crypto.randomUUID().slice(0, 8)
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'commands')
|
||||
}
|
||||
@@ -272,24 +328,82 @@ export class CommandService extends Service {
|
||||
|
||||
/**
|
||||
* Parse and execute a known command without sending it to the model.
|
||||
*
|
||||
* A resolved command's lifecycle is logged: `command/run` is appended
|
||||
* before the handler is invoked and `command/done` after settlement (a
|
||||
* thrown or aborted handler settles as `kind: 'error'`). Both are direct
|
||||
* log-only appends — no turn wraps them, and persistence drains them at
|
||||
* ordinary checkpoints. Admission misses (syntax or unknown name) log
|
||||
* nothing — they never entered a handler. A `command/run` append failure
|
||||
* fails the execution loud; a `command/done` append failure on the
|
||||
* handler-failure path is contained so the handler's own error stays the
|
||||
* reported failure.
|
||||
*
|
||||
* @param agent - exact receiving agent.
|
||||
* @param line - complete slash-command line.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns a detached result, or `undefined` when syntax or name does not resolve.
|
||||
* @returns the settled execution (result + lifecycle pairing id), or
|
||||
* `undefined` when syntax or name does not resolve.
|
||||
*/
|
||||
async execute(
|
||||
agent: Agent,
|
||||
line: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandResult | undefined> {
|
||||
): Promise<CommandExecution | undefined> {
|
||||
const parsed = parseCommand(line)
|
||||
if (parsed === undefined) return undefined
|
||||
const command = this.view(agent).get(parsed.name)
|
||||
if (command === undefined) return undefined
|
||||
if (signal.aborted) throw abortError(signal)
|
||||
const commandId = this.mintCommandId()
|
||||
this.appendLifecycle(agent.session, 'command/run', {
|
||||
commandId, name: parsed.name, args: parsed.rawInput, source: { kind: 'user' },
|
||||
})
|
||||
const invocation = Object.freeze({ agent, rawInput: parsed.rawInput, signal })
|
||||
const output = command.definition.handler(invocation)
|
||||
return normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
|
||||
let result: CommandResult
|
||||
try {
|
||||
const output = command.definition.handler(invocation)
|
||||
result = normalizeResult(parsed.name, await withAbort(Promise.resolve(output), signal))
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
this.appendLifecycle(agent.session, 'command/done', {
|
||||
commandId, kind: 'error',
|
||||
text: error instanceof Error ? error.message : renderThrown(error),
|
||||
})
|
||||
} catch (appendError: unknown) {
|
||||
this.ctx.logger.warn(`command "${parsed.name}": command/done append failed: ${renderThrown(appendError)}`)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
this.appendLifecycle(agent.session, 'command/done', {
|
||||
commandId, kind: result.kind,
|
||||
...result.text === undefined ? {} : { text: result.text },
|
||||
})
|
||||
return Object.freeze({ commandId, result })
|
||||
}
|
||||
|
||||
/** Mint the next pairing id (monotonic; instance-token-prefixed so a resumed log never repeats one). */
|
||||
private mintCommandId(): CommandId {
|
||||
this.commandSeq += 1
|
||||
return CommandId(`cmd-${this.instanceToken}-${this.commandSeq}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one log-only lifecycle event directly: no turn is opened for it and
|
||||
* no flush is forced — persistence observes the eager `session/event` path
|
||||
* and drains at ordinary checkpoints and teardown, like every other
|
||||
* standalone plugin event.
|
||||
*/
|
||||
private appendLifecycle<T extends 'command/run' | 'command/done'>(
|
||||
session: Session,
|
||||
type: T,
|
||||
data: SessionEventMap[T],
|
||||
): SessionEvent<T> {
|
||||
// Both admitted types are log-only (non-surface), but TypeScript does not
|
||||
// reduce Session.append's conditional rest parameter through a generic
|
||||
// type parameter. Preserve the proven two-argument call shape.
|
||||
const appendLogOnly = session.append.bind(session) as (eventType: T, eventData: SessionEventMap[T]) => SessionEvent<T>
|
||||
return appendLogOnly(type, data)
|
||||
}
|
||||
|
||||
/** Resolve global definitions followed by exact scoped shadows. */
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-commands`.
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-commands`:
|
||||
* command lifecycle events pair by commandId within one session log.
|
||||
* @module @deepseek-ai/dsh-commands/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-commands'
|
||||
|
||||
@@ -14,11 +15,36 @@ export const name = 'commands-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: registry notifications intentionally hide mutation details and contain
|
||||
* observers, so list/find self-comparisons would duplicate implementation rather than detect drift.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Install pairing validation over loaded logs and newly appended lifecycle events. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
// Install-scoped so a dispose/re-register cycle re-sweeps from a clean slate.
|
||||
const runIds = new WeakMap<Session, Set<string>>()
|
||||
const validateEvent = (session: Session, event: SessionEvent): void => {
|
||||
if (event.type === 'command/run') {
|
||||
const ids = runIds.get(session) ?? new Set<string>()
|
||||
if (ids.has(event.data.commandId)) {
|
||||
fail(`command/run repeats commandId ${JSON.stringify(event.data.commandId)}`)
|
||||
}
|
||||
ids.add(event.data.commandId)
|
||||
runIds.set(session, ids)
|
||||
return
|
||||
}
|
||||
if (event.type !== 'command/done') return
|
||||
if (runIds.get(session)?.has(event.data.commandId) !== true) {
|
||||
fail(`command/done ${JSON.stringify(event.data.commandId)} pairs no prior command/run in this log`)
|
||||
}
|
||||
}
|
||||
for (const session of ctx.sessions.list()) {
|
||||
for (const event of session.events) validateEvent(session, event)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
validateEvent(session, event)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
@@ -27,4 +53,3 @@ const install: InvariantInstaller = () => {}
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import CommandService, { parseCommand, type CommandDefinition } from '@deepseek-ai/dsh-commands'
|
||||
|
||||
function command(name: string, text = `ran:${name}`): CommandDefinition {
|
||||
@@ -16,18 +16,27 @@ function command(name: string, text = `ran:${name}`): CommandDefinition {
|
||||
|
||||
async function mount(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(CommandService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Mint a scope whose key is sufficient for registry lookup and invocation. */
|
||||
/** Mint a scope whose key is a live agent (real session: the executor logs lifecycle events on it). */
|
||||
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; agent: Agent }> {
|
||||
const agent = { id: name as SessionId } as Agent
|
||||
const session = ctx.sessions.create(SessionId(name))
|
||||
const agent = { id: session.id, session } as Agent
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['commands'] }))
|
||||
return { scope, agent }
|
||||
}
|
||||
|
||||
/** The lifecycle slice of one agent's log (boundary markers stripped). */
|
||||
function lifecycleOf(agent: Agent): Array<{ type: string; data: unknown }> {
|
||||
return agent.session.events
|
||||
.filter(event => event.type === 'command/run' || event.type === 'command/done')
|
||||
.map(event => ({ type: event.type, data: event.data }))
|
||||
}
|
||||
|
||||
describe('parseCommand()', () => {
|
||||
it.each([
|
||||
['/goal', { name: 'goal', rawInput: '' }],
|
||||
@@ -87,11 +96,11 @@ describe('CommandService', () => {
|
||||
expect(ctx.commands.list(agent).map(item => item.name)).toEqual(['shared'])
|
||||
expect(ctx.commands.find(agent, 'shared')?.handler).toBeDefined()
|
||||
expect(ctx.commands.list(other).map(item => item.name)).toEqual(['shared'])
|
||||
expect(await ctx.commands.execute(agent, '/shared', new AbortController().signal))
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result)
|
||||
.toEqual({ kind: 'success', text: 'scoped' })
|
||||
|
||||
await scope.dispose()
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.text).toBe('global')
|
||||
expect((await ctx.commands.execute(agent, '/shared', new AbortController().signal))?.result.text).toBe('global')
|
||||
})
|
||||
|
||||
it('removes a registration when its contributing plugin fiber is disposed', async () => {
|
||||
@@ -167,10 +176,12 @@ describe('CommandService', () => {
|
||||
ctx.commands.register({ name: 'run', description: 'Run it', handler: seen })
|
||||
const controller = new AbortController()
|
||||
|
||||
const result = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
|
||||
const execution = await ctx.commands.execute(agent, '/run untouched ', controller.signal)
|
||||
|
||||
expect(result).toEqual({ kind: 'success', text: 'ok' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
expect(execution?.result).toEqual({ kind: 'success', text: 'ok' })
|
||||
expect(execution?.commandId).toBeTruthy()
|
||||
expect(Object.isFrozen(execution)).toBe(true)
|
||||
expect(Object.isFrozen(execution?.result)).toBe(true)
|
||||
expect(seen).toHaveBeenCalledWith(expect.objectContaining({
|
||||
agent,
|
||||
rawInput: ' untouched ',
|
||||
@@ -262,9 +273,9 @@ describe('CommandService', () => {
|
||||
description: 'Denied',
|
||||
handler: () => ({ kind: 'error', text: 'not now' }),
|
||||
})
|
||||
const result = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
const execution = await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(execution?.result).toEqual({ kind: 'error', text: 'not now' })
|
||||
expect(Object.isFrozen(execution?.result)).toBe(true)
|
||||
|
||||
ctx.commands.register({
|
||||
name: 'silent',
|
||||
@@ -272,8 +283,8 @@ describe('CommandService', () => {
|
||||
handler: () => ({ kind: 'success' }),
|
||||
})
|
||||
const silent = await ctx.commands.execute(agent, '/silent', new AbortController().signal)
|
||||
expect(silent).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent)).toBe(true)
|
||||
expect(silent?.result).toEqual({ kind: 'success' })
|
||||
expect(Object.isFrozen(silent?.result)).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -286,6 +297,112 @@ describe('CommandService', () => {
|
||||
expect(() => ctx.commands.register(definition as unknown as CommandDefinition)).toThrow(expected)
|
||||
})
|
||||
|
||||
it('logs a paired command/run + command/done around a successful handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('deploy', 'deployed'))
|
||||
|
||||
const execution = await ctx.commands.execute(agent, '/deploy now', new AbortController().signal)
|
||||
|
||||
const lifecycle = lifecycleOf(agent)
|
||||
expect(lifecycle).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'deploy', args: ' now', source: { kind: 'user' } } },
|
||||
{ type: 'command/done', data: { kind: 'success', text: 'deployed' } },
|
||||
])
|
||||
const ids = lifecycle.map(event => (event.data as { commandId: string }).commandId)
|
||||
expect(ids[0]).toBeTruthy()
|
||||
expect(ids[0]).toBe(ids[1])
|
||||
// The execution's pairing id is the logged one (RPC-level correlation).
|
||||
expect(execution?.commandId).toBe(ids[0])
|
||||
// Direct log-only appends: no turn is opened for the pair on an idle log.
|
||||
expect(agent.session.events.map(event => event.type)).toEqual([
|
||||
'command/run', 'command/done',
|
||||
])
|
||||
})
|
||||
|
||||
it('mints distinct monotonic commandIds across executions', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('first'))
|
||||
ctx.commands.register(command('second'))
|
||||
await ctx.commands.execute(agent, '/first', new AbortController().signal)
|
||||
await ctx.commands.execute(agent, '/second', new AbortController().signal)
|
||||
const ids = lifecycleOf(agent)
|
||||
.filter(event => event.type === 'command/run')
|
||||
.map(event => (event.data as { commandId: string }).commandId)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
})
|
||||
|
||||
it('logs command/done kind error for an expected error result', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({ name: 'denied', description: 'Denied', handler: () => ({ kind: 'error', text: 'not now' }) })
|
||||
await ctx.commands.execute(agent, '/denied', new AbortController().signal)
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'denied' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'not now' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('logs command/done kind error when the handler throws, and preserves the throw', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'boom',
|
||||
description: 'Throw',
|
||||
handler: () => { throw new Error('handler exploded') },
|
||||
})
|
||||
await expect(ctx.commands.execute(agent, '/boom', new AbortController().signal))
|
||||
.rejects.toThrow('handler exploded')
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'boom' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'handler exploded' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('logs command/done kind error when the signal aborts a hanging handler', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register({
|
||||
name: 'hang',
|
||||
description: 'Hang',
|
||||
handler: () => new Promise(() => undefined),
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.commands.execute(agent, '/hang', controller.signal)
|
||||
// The run append must land before the abort so the pair stays complete.
|
||||
await vi.waitFor(() => { expect(lifecycleOf(agent)).toHaveLength(1) })
|
||||
controller.abort('operator cancelled command')
|
||||
await expect(pending).rejects.toThrow('operator cancelled command')
|
||||
await vi.waitFor(() => {
|
||||
expect(lifecycleOf(agent)).toMatchObject([
|
||||
{ type: 'command/run', data: { name: 'hang' } },
|
||||
{ type: 'command/done', data: { kind: 'error', text: 'operator cancelled command' } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('logs nothing for admission misses (syntax or unknown name)', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('real'))
|
||||
const signal = new AbortController().signal
|
||||
await ctx.commands.execute(agent, 'not a command', signal)
|
||||
await ctx.commands.execute(agent, '/missing', signal)
|
||||
expect(agent.session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('joins an open turn without wrapping the lifecycle pair in synthetic turns', async () => {
|
||||
const ctx = await mount()
|
||||
const { agent } = await mintAgentScope(ctx, 'a')
|
||||
ctx.commands.register(command('mid'))
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
await ctx.commands.execute(agent, '/mid', new AbortController().signal)
|
||||
expect(agent.session.events.map(event => event.type)).toEqual([
|
||||
'turn/start', 'command/run', 'command/done',
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, /CommandResult/],
|
||||
[null, /CommandResult/],
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
6
packages/ui/jsonrpc/README.i18n.yaml
Normal file
6
packages/ui/jsonrpc/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/jsonrpc/README.md
|
||||
README.md: b1219ba10269fc7d046da22c280ff1b91424a5ae
|
||||
README.zh.md: 63615654769bf4ed7a69c09dc818af034c3a3c3c
|
||||
@@ -1,6 +1,8 @@
|
||||
# @deepseek-ai/dsh-jsonrpc
|
||||
|
||||
The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; the transport and the named wire types live in [`dsh-sdk-protocol`](../../sdk/sdk-protocol/README.md), shared with the client SDKs; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application.
|
||||
|
||||
## Wiring
|
||||
|
||||
@@ -20,7 +22,7 @@ The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to qu
|
||||
|
||||
## Wire notes
|
||||
|
||||
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later injection or plugin-owned zero-step turns still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
|
||||
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. An optional positive `initialize.maxTokens` becomes the request output cap of each SDK-created agent and its in-process descendants; invalid values reject initialization, while omission sends no cap and preserves provider defaults. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. `session.finished` reports that prompt's message-triggered turn outcome; later between-turn records still stream as `session.event` notifications but cannot replace the prompt status. Persistence roots and persona come from `cordis.yml`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
47
packages/ui/jsonrpc/README.zh.md
Normal file
47
packages/ui/jsonrpc/README.zh.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# @deepseek-ai/dsh-jsonrpc
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`jsonrpc` 插件通过 stdio 提供以换行符分隔的 JSON-RPC,使进程外 SDK 客户端能够驱动 harness agent(智能体)。[`HarnessSdkServer`](src/server.ts) 持有协议方法和通知;传输与具名线类型位于 [`dsh-sdk-protocol`](../../sdk/sdk-protocol/README.md),与客户端 SDK 共享;[`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) 提供外围的 `cordis.yml` 应用。
|
||||
|
||||
## 组装
|
||||
|
||||
`inject: ['agents']`。服务器按 `sessionId` 获取或创建一个 agent。只有服务建立快照时的生命周期 `local` 标志为 true,服务器才会转发 subagent 完成事件;提供方名称、子级 id 和持久化谱系均不能证明本地性。已注册的适配器优先;未被持有的 `deepseek` 路由会挂载 `dsh-llm-deepseek`,任何其他未被持有的提供方都会导致初始化失败。其他功能由外围 `cordis.yml` 提供。
|
||||
|
||||
## 配置
|
||||
|
||||
`maxTokensAsSuccess` 默认为 `false`。对于需要区分「因 token 上限而结束但可接受的 agent 结果」与「基础设施故障」的评测宿主,请将其设为 `true`。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输 seam;生产环境使用进程 stdio 和 `process.exit`。
|
||||
|
||||
## stdout 即协议
|
||||
|
||||
Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写入 stderr。
|
||||
|
||||
## 关闭与退出语义
|
||||
|
||||
插件响应 `shutdown`,将 SDK 持有的 agent 和订阅 dispose(资源释放)至完全停稳,关闭传输层,然后以代码 0 退出。EOF 和信号退出由 app bin 处理,后者会 dispose 根上下文。仅卸载此插件会停止服务,但不会退出进程。
|
||||
|
||||
## 协议说明
|
||||
|
||||
`initialize.serverInfo.name` 的协议稳定值为 `deepseek-harness-sdk-runtime`。可选的正整数 `initialize.maxTokens` 会成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值会使初始化失败,省略时则不发送上限并保留提供方默认值。一个会话只接受一个进行中的提示词;重叠请求会立即失败,其他会话保持独立,当前请求结算后该会话可再次使用。`session.finished` 报告由该提示词消息触发的轮次结果;后续轮次间记录仍会作为 `session.event` 通知流式发出,但不能替换该提示词的状态。持久化根目录和 persona 由 `cordis.yml` 提供。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### SDK 用户消息
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
对于每个已接受的 `session/prompt`,对话模型会将调用方提供的 `contentBlocks` 原样接收为该 SDK 会话中的一条用户消息。此包(package)不会添加系统提示词文本或工具 schema;这些内容来自外围 `cordis.yml` 中的插件。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
依数据而定的用户消息 token 会进入保留的会话历史,并在后续轮次中重复发送,直至另一个包将其压缩(compaction)。JSON-RPC 帧、会话通知和服务器内部记录不会增加模型上下文 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **协议没有逐会话关闭或提示词取消方法**:SDK 创建的 agent 会一直存活到进程关闭;一条已接受的提示词必须运行到 agent 空闲,该会话才能接受下一条。
|
||||
- **stdout 纯净性由部署保证**:外围配置仍可能加载 stdout logger 并破坏 JSON-RPC 通道;此插件不会检查或否决同级 logger。
|
||||
- **自动挂载适配器仅支持 DeepSeek**:`initialize` 可以复用任何预先注册的模型适配器,但唯一的回退行为是挂载 `dsh-llm-deepseek`。
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
@@ -47,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-sdk-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
|
||||
@@ -12,11 +12,10 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import Schema from 'schemastery'
|
||||
import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
import { HarnessSdkServer } from './server.ts'
|
||||
import { JsonRpcLineTransport } from './transport.ts'
|
||||
|
||||
export * from './server.ts'
|
||||
export * from './transport.ts'
|
||||
|
||||
export const name = 'jsonrpc'
|
||||
// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
|
||||
|
||||
@@ -7,44 +7,24 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { resolve } from 'node:path'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import type { JsonRpcTransportPeer } from './transport.ts'
|
||||
|
||||
/** Parameters for the process-wide SDK handshake. */
|
||||
export interface InitializeParams {
|
||||
/** Working directory recorded on every SDK-created session's header. */
|
||||
cwd: string
|
||||
/** Provider route every SDK-created agent runs on. */
|
||||
provider: string
|
||||
/** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */
|
||||
model: string
|
||||
}
|
||||
|
||||
/** Wire-stable server identity returned by initialization. */
|
||||
export interface InitializeResult {
|
||||
/** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */
|
||||
serverInfo: { name: string; version: string }
|
||||
}
|
||||
|
||||
/** One user turn on one SDK session. */
|
||||
export interface SessionPromptParams {
|
||||
/** The SDK-side session id; an unknown id lazily creates the agent+session pair. */
|
||||
sessionId: string
|
||||
/** The prompt content blocks, sent verbatim as the user message. */
|
||||
contentBlocks: ContentBlock[]
|
||||
}
|
||||
|
||||
/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */
|
||||
export interface SessionPromptResult {
|
||||
/** Always `true`; the turn outcome is the paired `session.finished` notification. */
|
||||
accepted: true
|
||||
}
|
||||
import type {
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
JsonRpcTransportPeer,
|
||||
SessionEventNotification,
|
||||
SessionFinishedNotification,
|
||||
SessionPromptParams,
|
||||
SessionPromptResult,
|
||||
SubagentFinishedNotification,
|
||||
SubagentStartedNotification,
|
||||
} from '@deepseek-ai/dsh-sdk-protocol'
|
||||
|
||||
interface SessionRecord {
|
||||
handle: AgentHandle
|
||||
@@ -77,6 +57,7 @@ export class HarnessSdkServer {
|
||||
private cwd = process.cwd()
|
||||
private provider = 'deepseek'
|
||||
private model = 'deepseek'
|
||||
private maxTokens: number | undefined
|
||||
private llmFiber: { dispose(): Promise<void> } | undefined
|
||||
private readonly sessions = new Map<string, SessionRecord>()
|
||||
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
|
||||
@@ -97,15 +78,17 @@ export class HarnessSdkServer {
|
||||
rec.lastTurnEnd = event.data.reason
|
||||
}
|
||||
}
|
||||
this.transport.notify('session.event', { sessionId: String(session.id), event })
|
||||
const payload: SessionEventNotification = { sessionId: String(session.id), event }
|
||||
this.transport.notify('session.event', payload)
|
||||
}))
|
||||
this.disposers.push(ctx.on('session/created', (session) => {
|
||||
const parentSession = session.header.parentSession
|
||||
if (parentSession === undefined) return
|
||||
this.transport.notify('subagent.started', {
|
||||
const payload: SubagentStartedNotification = {
|
||||
parentSessionId: String(parentSession),
|
||||
childSessionId: String(session.id),
|
||||
})
|
||||
}
|
||||
this.transport.notify('subagent.started', payload)
|
||||
}))
|
||||
this.disposers.push(ctx.on('subagent/end', function (this: Scoped<SubagentService>, info: SubagentRunEndInfo) {
|
||||
const parent = subagentParentOf(this)
|
||||
@@ -113,7 +96,7 @@ export class HarnessSdkServer {
|
||||
// snapshots the provider's exact run provenance through child disposal;
|
||||
// matching ids or parent lineage alone never establishes locality.
|
||||
if (!info.local) return
|
||||
transport.notify('subagent.finished', {
|
||||
const payload: SubagentFinishedNotification = {
|
||||
provider: info.provider,
|
||||
agentId: String(info.id),
|
||||
parentSessionId: String(parent.session.id),
|
||||
@@ -121,7 +104,8 @@ export class HarnessSdkServer {
|
||||
status: successStatus(info.stopReason, serverOptions),
|
||||
stopReason: info.stopReason,
|
||||
...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
|
||||
})
|
||||
}
|
||||
transport.notify('subagent.finished', payload)
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -131,9 +115,14 @@ export class HarnessSdkServer {
|
||||
* @returns server identity for the handshake.
|
||||
*/
|
||||
async initialize(params: InitializeParams): Promise<InitializeResult> {
|
||||
if (params.maxTokens !== undefined
|
||||
&& (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) {
|
||||
throw new TypeError('initialize maxTokens must be a positive safe integer')
|
||||
}
|
||||
this.cwd = resolve(params.cwd)
|
||||
this.provider = params.provider
|
||||
this.model = params.model
|
||||
this.maxTokens = params.maxTokens
|
||||
if (!this.hasAdapterFor(this.provider)) {
|
||||
if (this.provider !== 'deepseek') throw new Error(`no adapter registered for provider "${this.provider}"`)
|
||||
this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
|
||||
@@ -149,17 +138,23 @@ export class HarnessSdkServer {
|
||||
async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
|
||||
const rec = await this.getOrCreateSession(params.sessionId)
|
||||
if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`)
|
||||
// An agent-loop-only reload disposes the loop's agents while this record
|
||||
// survives; a retained agent accepts followup() silently, so validate the
|
||||
// record against the live registry before delivery (as the ACP bridge does).
|
||||
if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
|
||||
throw new Error(`session agent was disposed outside the server: ${params.sessionId}`)
|
||||
}
|
||||
rec.activePrompt = true
|
||||
try {
|
||||
rec.lastTurnEnd = undefined
|
||||
rec.handle.agent.send(params.contentBlocks)
|
||||
rec.handle.agent.followup(createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } }))
|
||||
await rec.handle.agent.whenIdle()
|
||||
const status = this.finishedStatus(rec.lastTurnEnd)
|
||||
this.transport.notify('session.finished', {
|
||||
const payload: SessionFinishedNotification = {
|
||||
sessionId: params.sessionId,
|
||||
status,
|
||||
status: this.finishedStatus(rec.lastTurnEnd),
|
||||
reason: rec.lastTurnEnd,
|
||||
})
|
||||
}
|
||||
this.transport.notify('session.finished', payload)
|
||||
return { accepted: true }
|
||||
} finally {
|
||||
rec.activePrompt = false
|
||||
@@ -243,7 +238,11 @@ export class HarnessSdkServer {
|
||||
const handle = await this.ctx.agents.create({
|
||||
sessionId: SessionId(sessionId),
|
||||
meta: { cwd: this.cwd },
|
||||
agentOptions: { provider: this.provider, model: this.model },
|
||||
agentOptions: {
|
||||
provider: this.provider,
|
||||
model: this.model,
|
||||
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
|
||||
},
|
||||
})
|
||||
const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false }
|
||||
this.sessions.set(sessionId, rec)
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* Newline-delimited JSON-RPC 2.0 over byte streams. Frames with `id` and
|
||||
* `method` are requests, `id` alone is a response, and `method` alone is a
|
||||
* notification. Malformed lines are ignored; handler failures become error frames.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc/transport
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
|
||||
type JsonRpcId = string | number
|
||||
type RequestHandler = (method: string, params: Record<string, unknown>) => Promise<unknown>
|
||||
type NotificationHandler = (method: string, params: Record<string, unknown>) => void
|
||||
|
||||
/**
|
||||
* Outbound request and notification surface used by {@link HarnessSdkServer}.
|
||||
*/
|
||||
export interface JsonRpcTransportPeer {
|
||||
/**
|
||||
* Send a request and await its response.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the request parameters object.
|
||||
* @returns the result; rejects on an error response, write failure, or closure.
|
||||
*/
|
||||
request(method: string, params: Record<string, unknown>): Promise<unknown>
|
||||
/**
|
||||
* Send a notification; omitted params produce no `params` member.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the optional notification parameters object.
|
||||
*/
|
||||
notify(method: string, params?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-delimited endpoint over caller-owned streams. {@link start} attaches
|
||||
* listeners; {@link close} detaches them and rejects pending requests without
|
||||
* destroying the streams. Missing request handlers return `-32601`; handler
|
||||
* failures return `-32603`. Notifications without a handler are dropped.
|
||||
*/
|
||||
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
private buffer = ''
|
||||
private readonly decoder = new StringDecoder('utf8')
|
||||
private started = false
|
||||
private requestHandler: RequestHandler | undefined
|
||||
private notificationHandler: NotificationHandler | undefined
|
||||
private readonly pending = new Map<JsonRpcId, PendingRequest>()
|
||||
|
||||
constructor(
|
||||
private readonly input: Readable,
|
||||
private readonly output: Writable,
|
||||
) {}
|
||||
|
||||
/** Attach the input listeners and begin reading frames. Idempotent. */
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
this.input.on('data', this.onData)
|
||||
this.input.on('error', this.onInputError)
|
||||
this.input.on('end', this.onInputEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach listeners and reject pending requests. Safe before {@link start}.
|
||||
*/
|
||||
close(): void {
|
||||
this.input.off('data', this.onData)
|
||||
this.input.off('error', this.onInputError)
|
||||
this.input.off('end', this.onInputEnd)
|
||||
this.failPending(new Error('JSON-RPC transport closed'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the request handler, replacing any prior handler.
|
||||
* @param handler - resolves to the response `result`; a rejection becomes a
|
||||
* `-32603` error response carrying the message.
|
||||
*/
|
||||
onRequest(handler: RequestHandler): void {
|
||||
this.requestHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the notification handler, replacing any prior handler.
|
||||
* @param handler - invoked per notification with the method and normalized
|
||||
* params object.
|
||||
*/
|
||||
onNotification(handler: NotificationHandler): void {
|
||||
this.notificationHandler = handler
|
||||
}
|
||||
|
||||
request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
const id = `req_${randomUUID().replaceAll('-', '')}`
|
||||
const message = { jsonrpc: '2.0', id, method, params }
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
try {
|
||||
this.write(message)
|
||||
} catch (error) {
|
||||
this.pending.delete(id)
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
notify(method: string, params?: Record<string, unknown>): void {
|
||||
this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for prior frame write callbacks. The empty barrier emits no bytes.
|
||||
* @returns a promise that settles with the output write callback.
|
||||
*/
|
||||
flush(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.output.write('', (error) => {
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private readonly onData = (chunk: Buffer | string): void => {
|
||||
this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk)
|
||||
this.drainLines()
|
||||
}
|
||||
|
||||
private drainLines(): void {
|
||||
for (;;) {
|
||||
const newline = this.buffer.indexOf('\n')
|
||||
if (newline < 0) break
|
||||
const line = this.buffer.slice(0, newline).trim()
|
||||
this.buffer = this.buffer.slice(newline + 1)
|
||||
if (!line) continue
|
||||
void this.handleLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onInputError = (error: Error): void => {
|
||||
this.failPending(error)
|
||||
}
|
||||
|
||||
private readonly onInputEnd = (): void => {
|
||||
this.buffer += this.decoder.end()
|
||||
this.drainLines()
|
||||
this.failPending(new Error('JSON-RPC input closed'))
|
||||
}
|
||||
|
||||
private async handleLine(line: string): Promise<void> {
|
||||
let message: unknown
|
||||
try {
|
||||
message = JSON.parse(line)
|
||||
} catch {
|
||||
// Only JSON syntax errors reach this catch; malformed peer lines are ignored.
|
||||
return
|
||||
}
|
||||
if (!message || typeof message !== 'object') return
|
||||
const frame = message as Record<string, unknown>
|
||||
const id = frame.id
|
||||
const method = frame.method
|
||||
if ((typeof id === 'string' || typeof id === 'number') && typeof method === 'string') {
|
||||
await this.handleIncomingRequest(id, method, objectParams(frame.params))
|
||||
return
|
||||
}
|
||||
if (typeof id === 'string' || typeof id === 'number') {
|
||||
this.handleIncomingResponse(id, frame)
|
||||
return
|
||||
}
|
||||
if (typeof method === 'string') {
|
||||
this.notificationHandler?.(method, objectParams(frame.params))
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingRequest(id: JsonRpcId, method: string, params: Record<string, unknown>): Promise<void> {
|
||||
const handler = this.requestHandler
|
||||
if (!handler) {
|
||||
this.writeError(id, -32601, `method not found: ${method}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await handler(method, params)
|
||||
this.write({ jsonrpc: '2.0', id, result })
|
||||
} catch (error) {
|
||||
this.writeError(id, -32603, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
private handleIncomingResponse(id: JsonRpcId, frame: Record<string, unknown>): void {
|
||||
const pending = this.pending.get(id)
|
||||
if (!pending) return
|
||||
this.pending.delete(id)
|
||||
if (frame.error && typeof frame.error === 'object') {
|
||||
const error = frame.error as Record<string, unknown>
|
||||
pending.reject(new Error(typeof error.message === 'string' ? error.message : 'JSON-RPC error'))
|
||||
return
|
||||
}
|
||||
pending.resolve(frame.result)
|
||||
}
|
||||
|
||||
private writeError(id: JsonRpcId, code: number, message: string): void {
|
||||
this.write({ jsonrpc: '2.0', id, error: { code, message } })
|
||||
}
|
||||
|
||||
private write(message: Record<string, unknown>): void {
|
||||
this.output.write(`${JSON.stringify(message)}\n`)
|
||||
}
|
||||
|
||||
private failPending(error: Error): void {
|
||||
const pending = [...this.pending.values()]
|
||||
this.pending.clear()
|
||||
for (const waiter of pending) waiter.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize JSON-RPC `params` to a plain object (arrays and scalars collapse to `{}`). */
|
||||
function objectParams(params: unknown): Record<string, unknown> {
|
||||
return params && typeof params === 'object' && !Array.isArray(params) ? params as Record<string, unknown> : {}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
@@ -5,24 +6,25 @@ import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import SubagentService, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
|
||||
import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts'
|
||||
import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol'
|
||||
import { HarnessSdkServer } from '../src/index.ts'
|
||||
|
||||
class FakeTransport implements JsonRpcTransportPeer {
|
||||
notifications: { method: string; params?: Record<string, unknown> }[] = []
|
||||
|
||||
async request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
async request(method: string, params: object): Promise<unknown> {
|
||||
throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`)
|
||||
}
|
||||
|
||||
notify(method: string, params?: Record<string, unknown>): void {
|
||||
this.notifications.push(params === undefined ? { method } : { method, params })
|
||||
notify(method: string, params?: object): void {
|
||||
this.notifications.push(params === undefined ? { method } : { method, params: params as Record<string, unknown> })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +123,7 @@ describe('HarnessSdkServer', () => {
|
||||
cwd: storageDir,
|
||||
provider: 'deepseek',
|
||||
model: 'dsagent-model',
|
||||
maxTokens: 321,
|
||||
}) as { serverInfo: { name: string } }
|
||||
expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
|
||||
|
||||
@@ -130,8 +133,9 @@ describe('HarnessSdkServer', () => {
|
||||
})
|
||||
|
||||
expect(llmServer.requests).toHaveLength(1)
|
||||
const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
|
||||
const body = llmServer.requests[0] as { model: string; messages: { role: string }[]; max_tokens?: number }
|
||||
expect(body.model).toBe('dsagent-model')
|
||||
expect(body.max_tokens).toBe(321)
|
||||
expect(body.messages[0]?.role).toBe('system')
|
||||
expect(body.messages.at(-1)?.role).toBe('user')
|
||||
expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key')
|
||||
@@ -152,7 +156,7 @@ describe('HarnessSdkServer', () => {
|
||||
meta: { cwd: storageDir },
|
||||
agentOptions: { provider: 'deepseek', model: 'dsagent-model' },
|
||||
})
|
||||
orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }])
|
||||
orphanHandle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } }))
|
||||
await orphanHandle.agent.whenIdle()
|
||||
await orphanHandle.dispose()
|
||||
expect(llmServer.requests).toHaveLength(3)
|
||||
@@ -170,23 +174,26 @@ describe('HarnessSdkServer', () => {
|
||||
const mainWhenIdle = vi.fn<() => Promise<void>>()
|
||||
.mockReturnValueOnce(firstMainIdle)
|
||||
.mockResolvedValue(undefined)
|
||||
const mainSend = vi.fn()
|
||||
const mainAgent = {
|
||||
send: mainSend,
|
||||
const mainFollowup = vi.fn<Agent['followup']>()
|
||||
const mainAgent = ({
|
||||
id: SessionId('main'),
|
||||
followup: mainFollowup,
|
||||
whenIdle: mainWhenIdle,
|
||||
} as unknown as Agent
|
||||
const otherSend = vi.fn()
|
||||
const otherAgent = {
|
||||
send: otherSend,
|
||||
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
const otherFollowup = vi.fn<Agent['followup']>()
|
||||
const otherAgent = ({
|
||||
id: SessionId('other'),
|
||||
followup: otherFollowup,
|
||||
whenIdle: vi.fn(() => Promise.resolve()),
|
||||
} as unknown as Agent
|
||||
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
|
||||
const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
|
||||
const create = vi.fn(async (options: { sessionId: SessionId }) =>
|
||||
String(options.sessionId) === 'main' ? mainHandle : otherHandle)
|
||||
const liveAgents = new Map<string, Agent>([['main', mainAgent], ['other', otherAgent]])
|
||||
const ctx = {
|
||||
on: vi.fn(() => () => undefined),
|
||||
agents: { create, get: () => undefined },
|
||||
agents: { create, get: (id: SessionId) => liveAgents.get(String(id)) },
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
@@ -196,7 +203,7 @@ describe('HarnessSdkServer', () => {
|
||||
})
|
||||
|
||||
const first = prompt('main', 'first')
|
||||
await vi.waitFor(() => { expect(mainSend).toHaveBeenCalledOnce() })
|
||||
await vi.waitFor(() => { expect(mainFollowup).toHaveBeenCalledOnce() })
|
||||
|
||||
await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main')
|
||||
await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true })
|
||||
@@ -208,16 +215,50 @@ describe('HarnessSdkServer', () => {
|
||||
await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed')
|
||||
await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true })
|
||||
|
||||
expect(mainSend).toHaveBeenCalledTimes(4)
|
||||
expect(otherSend).toHaveBeenCalledOnce()
|
||||
expect(mainFollowup).toHaveBeenCalledTimes(4)
|
||||
expect(otherFollowup).toHaveBeenCalledOnce()
|
||||
await server.shutdown()
|
||||
expect(mainHandle.dispose).toHaveBeenCalledOnce()
|
||||
expect(otherHandle.dispose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects a prompt for a session whose agent was disposed outside the server', async () => {
|
||||
const followup = vi.fn<Agent['followup']>()
|
||||
const agent = ({
|
||||
id: SessionId('zombie'),
|
||||
followup,
|
||||
whenIdle: vi.fn(() => Promise.resolve()),
|
||||
} satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
const handle = { agent, dispose: vi.fn(() => Promise.resolve()) }
|
||||
// The registry drops the agent after creation, modelling an agent-loop-only
|
||||
// reload that leaves the server's SessionRecord pointing at a detached agent.
|
||||
let live = true
|
||||
const ctx = {
|
||||
on: vi.fn(() => () => undefined),
|
||||
agents: {
|
||||
create: vi.fn(async () => handle),
|
||||
get: (id: SessionId) => (live && String(id) === 'zombie' ? agent : undefined),
|
||||
},
|
||||
get: () => undefined,
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
const prompt = (text: string) => server.prompt({
|
||||
sessionId: 'zombie',
|
||||
contentBlocks: [{ type: 'text', text }],
|
||||
})
|
||||
|
||||
await expect(prompt('while live')).resolves.toEqual({ accepted: true })
|
||||
live = false
|
||||
await expect(prompt('after detach')).rejects.toThrow('session agent was disposed outside the server: zombie')
|
||||
// The detached agent was never driven by the rejected prompt.
|
||||
expect(followup).toHaveBeenCalledOnce()
|
||||
await server.shutdown()
|
||||
})
|
||||
|
||||
it('reports the message-turn outcome when a later non-message turn settles before idle', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const transport = new FakeTransport()
|
||||
const server = new HarnessSdkServer(ctx, transport) as unknown as {
|
||||
prompt(params: { sessionId: string; contentBlocks: { type: 'text'; text: string }[] }): Promise<unknown>
|
||||
@@ -225,30 +266,30 @@ describe('HarnessSdkServer', () => {
|
||||
shutdown(): Promise<Record<string, never>>
|
||||
}
|
||||
const session = ctx.sessions.create(SessionId('message-outcome'))
|
||||
const agent = {
|
||||
const agent = ({
|
||||
id: SessionId('message-outcome'),
|
||||
session,
|
||||
send(content: { type: 'text'; text: string }[]) {
|
||||
followup(input: UserMessage) {
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
trigger: { kind: 'message', source: input.source },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content,
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('user/message', input, { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'max-tokens' } })
|
||||
session.append('turn/start', {
|
||||
turn: 2,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'late-metadata' } },
|
||||
})
|
||||
session.append('context/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'late metadata' }],
|
||||
source: { kind: 'plugin', plugin: 'late-metadata' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
|
||||
return input.id
|
||||
},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
} as unknown as Agent
|
||||
} satisfies Pick<Agent, 'id' | 'session' | 'followup' | 'whenIdle'>) as unknown as Agent
|
||||
ctx.agents.register(agent)
|
||||
server.sessions.set('message-outcome', {
|
||||
handle: { agent, dispose: () => Promise.resolve() },
|
||||
lastTurnEnd: undefined,
|
||||
@@ -821,6 +862,27 @@ describe('HarnessSdkServer', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
|
||||
'rejects invalid initialize maxTokens %s at the wire boundary',
|
||||
async (maxTokens) => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-invalid-max-tokens-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
try {
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport())
|
||||
await expect(server.initialize({
|
||||
cwd: storageDir,
|
||||
provider: 'deepseek',
|
||||
model: 'model',
|
||||
maxTokens,
|
||||
})).rejects.toThrow('initialize maxTokens must be a positive safe integer')
|
||||
await server.shutdown()
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
await rm(storageDir, { recursive: true, force: true })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('classifies defensive finish states', async () => {
|
||||
const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-'))
|
||||
const ctx = await makeHarness(storageDir)
|
||||
@@ -935,15 +997,18 @@ describe('HarnessSdkServer', () => {
|
||||
get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
|
||||
} as unknown as Context
|
||||
const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
|
||||
initialize(params: { cwd: string; provider: string; model: string }): Promise<unknown>
|
||||
initialize(params: { cwd: string; provider: string; model: string; maxTokens?: number }): Promise<unknown>
|
||||
getOrCreateSession(sessionId: string): Promise<unknown>
|
||||
shutdown(): Promise<Record<string, never>>
|
||||
}
|
||||
|
||||
await server.initialize({ cwd: '.', provider: 'mock', model: 'model' })
|
||||
await server.initialize({ cwd: '.', provider: 'mock', model: 'model', maxTokens: 123 })
|
||||
await server.getOrCreateSession('relative')
|
||||
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } }))
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
meta: { cwd: process.cwd() },
|
||||
agentOptions: { provider: 'mock', model: 'model', maxTokens: 123 },
|
||||
}))
|
||||
await server.shutdown()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
import { once } from 'node:events'
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { JsonRpcLineTransport } from '../src/index.ts'
|
||||
|
||||
function transportPair() {
|
||||
const aToB = new PassThrough()
|
||||
const bToA = new PassThrough()
|
||||
const a = new JsonRpcLineTransport(bToA, aToB)
|
||||
const b = new JsonRpcLineTransport(aToB, bToA)
|
||||
return { a, b, aToB, bToA }
|
||||
}
|
||||
|
||||
describe('JsonRpcLineTransport', () => {
|
||||
it('supports bidirectional requests and notifications over newline-delimited JSON-RPC', async () => {
|
||||
const { a, b } = transportPair()
|
||||
const notifications: Record<string, unknown>[] = []
|
||||
|
||||
a.onRequest(async (method, params) => {
|
||||
expect(method).toBe('echo')
|
||||
return { echoed: params }
|
||||
})
|
||||
b.onNotification((method, params) => {
|
||||
notifications.push({ method, params })
|
||||
})
|
||||
a.start()
|
||||
b.start()
|
||||
|
||||
const response = await b.request('echo', { value: 42 })
|
||||
expect(response).toEqual({ echoed: { value: 42 } })
|
||||
|
||||
a.notify('session.finished', { sessionId: 'main', status: 'ok' })
|
||||
a.notify('heartbeat')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(notifications).toEqual([
|
||||
{ method: 'session.finished', params: { sessionId: 'main', status: 'ok' } },
|
||||
{ method: 'heartbeat', params: {} },
|
||||
])
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('reports JSON-RPC request errors from the remote peer', async () => {
|
||||
const { a, b } = transportPair()
|
||||
a.onRequest(async () => {
|
||||
throw new Error('handler boom')
|
||||
})
|
||||
a.start()
|
||||
b.start()
|
||||
|
||||
await expect(b.request('explode', {})).rejects.toThrow('handler boom')
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('stringifies non-Error request handler failures', async () => {
|
||||
const { a, b } = transportPair()
|
||||
a.onRequest(async () => {
|
||||
throw 'string boom'
|
||||
})
|
||||
a.start()
|
||||
b.start()
|
||||
|
||||
await expect(b.request('explode-string', {})).rejects.toThrow('string boom')
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('reports method-not-found when no request handler is installed', async () => {
|
||||
const { a, b } = transportPair()
|
||||
a.start()
|
||||
b.start()
|
||||
|
||||
await expect(b.request('missing', {})).rejects.toThrow('method not found: missing')
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('normalizes non-object request params and ignores notifications without a handler', async () => {
|
||||
const { aToB, bToA, b } = transportPair()
|
||||
const seen: Record<string, unknown>[] = []
|
||||
b.onRequest(async (method, params) => {
|
||||
seen.push({ method, params })
|
||||
return { ok: true }
|
||||
})
|
||||
b.start()
|
||||
|
||||
aToB.write('{"jsonrpc":"2.0","method":"ignored"}\n')
|
||||
aToB.write('{"jsonrpc":"2.0","id":7,"method":"array-params","params":[]}\n')
|
||||
const chunk = (await once(bToA, 'data'))[0] as Buffer | string
|
||||
|
||||
expect(seen).toEqual([{ method: 'array-params', params: {} }])
|
||||
expect(JSON.parse(String(chunk))).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } })
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('ignores malformed frames and accepts notifications without params', async () => {
|
||||
const { aToB, b } = transportPair()
|
||||
const notifications: Record<string, unknown>[] = []
|
||||
b.onNotification((method, params) => {
|
||||
notifications.push({ method, params })
|
||||
})
|
||||
b.start()
|
||||
b.start()
|
||||
|
||||
aToB.write('not json\n')
|
||||
aToB.write('\n')
|
||||
aToB.write('null\n')
|
||||
aToB.write('{"jsonrpc":"2.0","params":{}}\n')
|
||||
aToB.write('{"jsonrpc":"2.0","method":"tick"}\n')
|
||||
aToB.emit('data', '{"jsonrpc":"2.0","method":"string-chunk"}\n')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{ method: 'tick', params: {} },
|
||||
{ method: 'string-chunk', params: {} },
|
||||
])
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('preserves multibyte UTF-8 characters split across Buffer chunks', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new PassThrough()
|
||||
const transport = new JsonRpcLineTransport(input, output)
|
||||
const notifications: Record<string, unknown>[] = []
|
||||
transport.onNotification((method, params) => { notifications.push({ method, params }) })
|
||||
transport.start()
|
||||
|
||||
const frame = Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', method: 'message', params: { text: '你好' } })}\n`)
|
||||
const character = Buffer.from('你')
|
||||
const characterStart = frame.indexOf(character)
|
||||
expect(characterStart).toBeGreaterThanOrEqual(0)
|
||||
input.write(frame.subarray(0, characterStart + 1))
|
||||
input.write(frame.subarray(characterStart + 1))
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
expect(notifications).toEqual([{ method: 'message', params: { text: '你好' } }])
|
||||
transport.close()
|
||||
})
|
||||
|
||||
it('flush waits for all earlier output writes', async () => {
|
||||
const events: string[] = []
|
||||
const output = new Writable({
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
const label = chunk.length === 0 ? 'barrier' : 'frame'
|
||||
events.push(`start:${label}`)
|
||||
setTimeout(() => {
|
||||
events.push(`finish:${label}`)
|
||||
callback()
|
||||
}, 5)
|
||||
},
|
||||
})
|
||||
const transport = new JsonRpcLineTransport(new PassThrough(), output)
|
||||
|
||||
transport.notify('tick')
|
||||
await transport.flush()
|
||||
|
||||
expect(events).toEqual([
|
||||
'start:frame',
|
||||
'finish:frame',
|
||||
'start:barrier',
|
||||
'finish:barrier',
|
||||
])
|
||||
transport.close()
|
||||
})
|
||||
|
||||
it('reports an output callback failure from flush', async () => {
|
||||
const output = {
|
||||
write(_chunk: string, callback?: (error?: Error) => void) {
|
||||
callback?.(new Error('flush failed'))
|
||||
return true
|
||||
},
|
||||
}
|
||||
const transport = new JsonRpcLineTransport(new PassThrough(), output as never)
|
||||
|
||||
await expect(transport.flush()).rejects.toThrow('flush failed')
|
||||
})
|
||||
|
||||
it('rejects pending requests when the input closes', async () => {
|
||||
const { aToB, b } = transportPair()
|
||||
b.start()
|
||||
|
||||
const pending = b.request('never-replies', {})
|
||||
aToB.end()
|
||||
|
||||
await expect(pending).rejects.toThrow('JSON-RPC input closed')
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('rejects pending requests when the input errors', async () => {
|
||||
const { aToB, b } = transportPair()
|
||||
b.start()
|
||||
|
||||
const pending = b.request('never-replies', {})
|
||||
aToB.emit('error', new Error('input broke'))
|
||||
|
||||
await expect(pending).rejects.toThrow('input broke')
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('rejects pending requests when the transport closes', async () => {
|
||||
const { b } = transportPair()
|
||||
|
||||
const pending = b.request('never-replies', {})
|
||||
b.close()
|
||||
|
||||
await expect(pending).rejects.toThrow('JSON-RPC transport closed')
|
||||
})
|
||||
|
||||
it('rejects a request when writing the frame throws', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = {
|
||||
write() {
|
||||
throw new Error('write exploded')
|
||||
},
|
||||
}
|
||||
const transport = new JsonRpcLineTransport(input, output as never)
|
||||
|
||||
await expect(transport.request('write-fails', {})).rejects.toThrow('write exploded')
|
||||
})
|
||||
|
||||
it('stringifies non-Error write failures', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = {
|
||||
write() {
|
||||
throw 'write string'
|
||||
},
|
||||
}
|
||||
const transport = new JsonRpcLineTransport(input, output as never)
|
||||
|
||||
await expect(transport.request('write-fails', {})).rejects.toThrow('write string')
|
||||
})
|
||||
|
||||
it('uses a fallback message for malformed JSON-RPC error responses', async () => {
|
||||
const { aToB, bToA, b } = transportPair()
|
||||
b.start()
|
||||
|
||||
const pending = b.request('remote-error', {})
|
||||
const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
|
||||
const request = JSON.parse(String(requestChunk)) as { id: string }
|
||||
aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: {} })}\n`)
|
||||
|
||||
await expect(pending).rejects.toThrow('JSON-RPC error')
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('ignores responses that do not match a pending request', async () => {
|
||||
const { aToB, b } = transportPair()
|
||||
b.start()
|
||||
|
||||
aToB.write('{"jsonrpc":"2.0","id":"unknown","result":{"ignored":true}}\n')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
b.close()
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../sdk/sdk-protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
|
||||
6
packages/ui/permission/README.i18n.yaml
Normal file
6
packages/ui/permission/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 6a59ad9425bf5bfeb89e9798304a2eb90ee55bfa
|
||||
README.zh.md: 0e7db1bd41a15ac4be18d33db7b9011a5bc24e7e
|
||||
@@ -1,10 +1,12 @@
|
||||
# @deepseek-ai/dsh-permission
|
||||
|
||||
User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). The ACP bridge exposes them as one `Permissions` select, while sandbox execution and approval continue to consume their own knobs.
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
User-facing permission presets through `ctx.permission` ([`PermissionService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs.
|
||||
|
||||
`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
|
||||
|
||||
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [acp-agent composition](../../../examples/acp-agent/) and [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -16,6 +18,7 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No shipped composition currently mounts the service** — the ACP bridge was its only selector before [ACP became automation-only](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md); the preset table is kept for the interactive front door that next exposes a runtime policy switch.
|
||||
- **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet.
|
||||
- **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service.
|
||||
- **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin.
|
||||
|
||||
24
packages/ui/permission/README.zh.md
Normal file
24
packages/ui/permission/README.zh.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# @deepseek-ai/dsh-permission
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
通过 `ctx.permission`([`PermissionService`](src/index.ts))提供面向用户的权限 preset。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。
|
||||
|
||||
`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
|
||||
|
||||
该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的调节项事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permission/preset` 本身只写入日志。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接使缓存失效;具名消费方拥有所有请求前缀变更。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **当前没有已交付的组合挂载此服务**:在 [ACP 变为仅用于自动化](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md)之前,ACP 桥接层是唯一的选择器;preset 表为下一个公开运行时策略切换的交互式入口保留。
|
||||
- **只组合两个机制调节项**:preset 选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。
|
||||
- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个具名 custom preset。
|
||||
- **preset 表位于进程级别**:配置在插件生命周期内固定;更改可用 preset 必须重新加载插件。
|
||||
@@ -51,7 +51,7 @@ export interface PresetSpec {
|
||||
|
||||
/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
|
||||
export interface PresetOption {
|
||||
/** The machine value (`session/set_config_option` vocabulary): the table key, or `custom`. */
|
||||
/** Stable option value: the table key, or `custom`. */
|
||||
value: string
|
||||
/** The display label. */
|
||||
name: string
|
||||
|
||||
6
packages/ui/tool-ask-user/README.i18n.yaml
Normal file
6
packages/ui/tool-ask-user/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 8e779f4025c20cd200344efb7cb8cd6bc09ba64d
|
||||
README.zh.md: fe1dc5559882532c4f44e705cc6daa2c7f4f8905
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-tool-ask-user
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the model ask the human a concise question when it needs confirmation, a choice, or missing information before continuing.
|
||||
|
||||
## Tool
|
||||
|
||||
57
packages/ui/tool-ask-user/README.zh.md
Normal file
57
packages/ui/tool-ask-user/README.zh.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# @deepseek-ai/dsh-tool-ask-user
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现。当模型需要确认、选择或缺失信息才能继续时,它可以借此向用户提出简明问题。
|
||||
|
||||
## 工具
|
||||
|
||||
`ask_user_question` 接受以下参数:
|
||||
|
||||
- `questions`:必填的非空问题对象数组。
|
||||
- `id`:每个问题必填的稳定 id,会原样包含在回答中。
|
||||
- `question`:每个问题必填的问题文本。
|
||||
- `header`:可选的简短标题。
|
||||
- `options`:可选选项,包含 `label` 和 `description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`。
|
||||
- `multi_select`:该问题是否可以返回多个选中的选项。
|
||||
|
||||
工具调用 `ctx.userInteraction.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;仅当用户自由填写回答时才会出现 `custom`,并覆盖选中的选项。Native renderer 会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。
|
||||
|
||||
## 职责
|
||||
|
||||
此包(package)是用户交互 seam 的消费方。它不渲染 UI,也不了解输入的收集方式;它只将模型参数转换为 `AskUserQuestionRequest`,并把用户回答返回给 agent loop(智能体循环)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 工具 schema
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型会看到生成的 [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user),其中包含问题 id、提示语、标题、选项和多选标志。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
工具可见的每个请求都会产生固定的 schema 开销。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要定义和可见性保持不变,前缀即可稳定复用。插件生命周期变化或作用域限制可能从此 schema 开始使复用失效。
|
||||
|
||||
### 工具调用历史与结果
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
模型提出的完整问题保留在 assistant 工具调用参数中。用户回答后,下一步骤会看到精确采用 `{"answers":[{"id":"<id>","selected":["<label>"],"custom":"<text>"}]}` 形式的紧凑 JSON;不使用 `custom` 时会省略该字段,`selected` 可以包含零个、一个或多个标签。调用等待期间的 UI 交互不属于模型上下文。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
参数和回答 JSON 是依数据而定的保留 token;等待用户时不会产生 token 开销。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。
|
||||
- **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。
|
||||
6
packages/ui/tui/README.i18n.yaml
Normal file
6
packages/ui/tui/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
|
||||
README.md: 5aafd6f5207320bf273c96a04f2d606577ca2da0
|
||||
README.zh.md: 1901faeb26c65126bc5475a991fedecd39a88ba5
|
||||
@@ -1,5 +1,7 @@
|
||||
# @deepseek-ai/dsh-tui
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the one-shot [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead.
|
||||
|
||||
The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [file-reference autocomplete Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md) owns path-only `@file` behavior; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
|
||||
@@ -10,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects `
|
||||
|
||||
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
|
||||
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
|
||||
|
||||
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.
|
||||
|
||||
@@ -18,17 +20,17 @@ Before model output, session events, tool presenters, questions, configuration,
|
||||
|
||||
Typing `@` at a token boundary searches files and directories under the session working directory. A bare fuzzy query uses a reusable bounded workspace index; a query containing `/` lists that directory directly, and selecting a folder keeps completion open for descent. Whitespace-bearing paths are inserted as `@"path with spaces"`. Selecting a file inserts only its path and a trailing space: the TUI does not read it, attach hidden context, or replace it with a reference object. When a model-facing `read` tool is registered, the TUI adds one fixed system-prompt instruction telling the model to read an explicit path when its contents are needed.
|
||||
|
||||
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.send()` from the status after that asynchronous preparation, so idle sends still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
|
||||
When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers metadata-only session candidates, inserts `@[label](dsh-session:<payload>)`, and prepares the selected snapshots before dispatch. Session references remain structured because the model has no filesystem-like tool for retrieving session snapshots later. Preparation disables duplicate submission and restores the editor input on failure. The TUI chooses `agent.steer()` or `agent.followup()` from the status after that asynchronous preparation, so idle follow-ups still dispatch `agent/prompt-submit` while in-turn steering joins at a checkpoint without that hook.
|
||||
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
|
||||
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. When an adapter does not advertise a default effort, the cycle also includes `provider default`, which clears an explicit selection; models without selectable effort metadata ignore Shift+Tab. The selector renders the exact advertised effort list—including `off` when present—and does not synthesize, clamp, or transfer an effort between models. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
|
||||
|
||||
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name.
|
||||
|
||||
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
|
||||
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
|
||||
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
|
||||
|
||||
`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.
|
||||
|
||||
@@ -47,7 +49,7 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
|
||||
| `maxResumeOptions` | `8` | Visible sessions in the resume selector |
|
||||
| `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal |
|
||||
| `questionDialogMaxHeight` | `20` | Question-panel maximum rows |
|
||||
| `modelDialogWidth` | `72` | Model-selector width in columns |
|
||||
| `modelDialogWidth` | `76` | Model-selector width in columns |
|
||||
| `modelDialogMaxHeight` | `20` | Model-selector maximum rows |
|
||||
| `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query |
|
||||
| `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries |
|
||||
@@ -72,7 +74,7 @@ Startup fails before mounting when either process stream is not a TTY. The compo
|
||||
|
||||
## Color
|
||||
|
||||
The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block; the question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, assistant replies, tool cards) are separated by a bold, underlined role header in the role color and blank-line spacing rather than a filled block or a per-line prefix, so a mouse drag-select copies the message text without any leading bar or indent; a tool card's status (pending, error, success) shows in its colored, underlined title glyph and title. The question panel emphasizes its active row with bold accent text, while selectors use reverse video. These treatments are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -80,7 +82,7 @@ The palette uses the standard 16-color ANSI foregrounds and SGR attributes, whic
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Each non-empty ordinary editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`.
|
||||
Each non-empty ordinary editor submission becomes one text block, sent with `agent.followup()` while the target agent is idle and `agent.steer()` while it is running. A session mention becomes readable `@label` text plus the durable untrusted context defined by [`dsh-session-reference`](../../context/session-reference/README.md); its full JSON is hidden behind a compact reference card. Slash commands and keybindings are TUI-only; command results remain terminal notices. A command producer may schedule a separate agent input, such as the optional message accepted by `/plan [message]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -114,7 +116,7 @@ The fixed instruction is part of the stable system-prompt prefix and is reusable
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model pair in both prompt variables and request routing.
|
||||
The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model route in prompt variables and the selected provider/model/reasoning-effort target in request routing.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -128,7 +130,7 @@ Changing provider or model enters that target's cache domain; no cache reuse acr
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same send-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
|
||||
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same followup-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
165
packages/ui/tui/README.zh.md
Normal file
165
packages/ui/tui/README.zh.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# @deepseek-ai/dsh-tui
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
DeepSeek Harness agent(智能体)的交互式终端入口,基于 [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui) 构建。它要求 stdin 和 stdout 均为 TTY;脚本和 Loader pipe 应改用单次执行的 [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app。
|
||||
|
||||
已实现的 [TUI 功能 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md)持有终端入口决策;[文件引用自动补全 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-tui-file-reference-autocomplete.md)持有仅路径的 `@file` 行为;[终端状态快照 Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)持有其验证策略。
|
||||
|
||||
支持 macOS、Linux 和 Windows 上的交互式终端。Windows 使用 pi-tui 原生控制台 VT 输入处理;[Windows 支持 Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md)持有平台决策与 ConPTY 进程验证。
|
||||
|
||||
本包(package)只持有交互式终端展示和输入。它注入 `agents`、[`commands`](../commands/README.md)、`llm`、`systemPrompt`、`tokenMeter`、`tools` 和 `userInteraction`,可选读取 `skills` 服务(仅在已挂载时存在),然后驱动由 app 或开发者代码创建或恢复的 agent。Agent 生命周期、持久化与模型侧 [`ask_user_question`](../tool-ask-user/README.md) 工具仍是独立组合项。
|
||||
|
||||
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号,但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect,因此卸载会移除排队工作,或在清理结算前关闭可见工作;终端关闭会先卸载依赖项,再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
|
||||
|
||||
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning,将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk,并在 transcript(文本记录)中渲染计划重试次数、延迟和失败;成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`,并显示工具卡片模式、当前模型,以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript,使经过压缩(compaction)的历史不会再次出现。
|
||||
|
||||
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`。
|
||||
|
||||
在模型输出、会话事件、工具 presenter、问题、配置或诊断到达 pi-tui 的 ANSI 感知 renderer 或终端标题前,TUI 会把换行之外的 C0 和 C1 控制字符渲染为可见 `\xNN` 文本。这些来源无法添加终端控制序列;终端渲染与样式仍由 TUI 和 pi-tui 持有。
|
||||
|
||||
在 token 边界输入 `@` 会搜索会话工作目录下的文件和目录。没有路径的模糊查询使用可复用的有界工作区索引;包含 `/` 的查询直接列出该目录,选择文件夹后会保持补全开启以继续深入。含空白的路径会插入为 `@"path with spaces"`。选择文件只会插入其路径和一个尾随空格:TUI 不会读取文件、附加隐藏上下文,也不会把路径替换为引用对象。注册模型侧 `read` 工具后,TUI 会添加一条固定系统提示词指令,要求模型在需要显式路径内容时读取该路径。
|
||||
|
||||
挂载可选的 `ctx.sessionReferences` 后,同一个 `@` 菜单还会提供仅含元数据的会话候选项,插入 `@[label](dsh-session:<payload>)`,并在分派前准备所选快照。会话引用保持结构化,因为模型没有类似文件系统的工具可在稍后检索会话快照。准备期间会禁止重复提交,并在失败时恢复编辑器输入。TUI 会在异步准备后根据状态选择 `agent.steer()` 或 `agent.followup()`,因此空闲 followup 仍会分派 `agent/prompt-submit`,而轮次中的 steering 会在检查点加入且不触发该 hook。
|
||||
|
||||
Agent 运行时,普通编辑器提交会调用 `agent.steer()`;其他时候调用 `agent.followup()`。提交行以斜杠开头时会改为进入 `ctx.commands`:已知命令直接执行,未知命令产生警告,两条路径都不会自动到达模型。命令生产方可以显式调度 agent 工作;[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) 使用该契约实现 `/plan [message]`。TUI 将 `/help`、`/model`、`/clear`、`/reasoning`、`/tools`、`/redraw`、`/reload`、`/resume`、`/status` 和 `/exit` 注册为 agent 作用域定义;其他所有有效命令都会动态加入自动补全与 `/help`,`/skill:` 补全也相同。编辑器上方的状态行会报告 TUI 从会话事件派生的轮次阶段,包括等待首个 token、思考、响应或执行工具;它显示该阶段已经过时间和运行中的步骤总数,每秒刷新,并以 `Enter sends steering, Esc cancels` 提示结尾。Steering 消息等待到达模型期间,会在提示前插入 `N queued ·` 徽标,每条消息排空后随即清除。Ctrl+C 或 Escape 会取消运行中的轮次。工具卡片把长主体折叠为可配置的头尾预览;Ctrl+O 在预览与完整输出之间切换所有卡片。Ctrl+R 切换 reasoning,Ctrl+L 重绘,Ctrl+D 在空闲时退出。
|
||||
|
||||
`/model` 将建议性的 `ctx.llm` catalog 打开为键盘选择器:Up/Down 移动,Shift+Tab 按显示顺序循环切换适配器为焦点模型公布的推理强度,Enter 选择模型和推理强度,Escape 关闭。适配器未公布默认推理强度时,循环还会包含 `provider default`,该项会清除显式选择;没有可选推理强度元数据的模型会忽略 Shift+Tab。选择器会原样呈现公布的推理强度列表(包括存在时的 `off`),不会合成、自动调整或在模型之间转移推理强度。`/model <model>` 仍可直接选择无歧义的模型 id,`/model <provider>/<model>` 则选择精确目标,并在存在时使用其适配器默认值。已配置目标或最新记录的请求 header 会初始化选择器;由于 catalog 仅提供建议,未列出的当前模型仍会显示。选择仅对本 TUI 会话有效。提示词组装会为一个步骤建立目标快照,替换 `{{provider}}` 和 `{{model}}`,并通过 `agent/request` 应用同一个提供方/模型/推理强度目标;因此组装期间的切换会从后续步骤开始生效。请求 header 会持久记录真正到达模型的目标,未使用的选择则只存在于进程本地。
|
||||
|
||||
`/reload`(实验性,仅开发环境)会重新读取所有基于文件的 loader 配置树,并把 diff 应用到运行中 app:它手动调用 HMR(热模块替换)watcher 的配置路径;上下文中必须有 cordis Loader,否则退化为警告。它只在 agent 空闲时运行,并拒绝 reload 进行期间的再次进入。模块源代码热重载仍由 watcher 持有。挂载 `skills` 服务后,`/skill:<name> [instructions]` 会把该 skill 的指令作为一个 user 轮次加载到会话中;自动补全列出模型可调用的 skill,任何 skill(包括模型禁用的 skill)都可通过精确名称加载。
|
||||
|
||||
Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任何输入计费后,后面会显示 `cache <rate>%`,表示提供方缓存服务的已计费提示词 token 占比(未缓存输入加缓存读写),并四舍五入为百分比。它还会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较(适配器没有容量元数据时省略上下文占比),并显示当前模型和工具卡片模式;footer 过窄时,右侧会优先裁剪。
|
||||
|
||||
`/status` 会向 transcript 添加一张时间点诊断卡片,并在 agent 运行时保持可用。它报告会话 id、标题、工作目录、所选提供方/模型、所选推理强度或默认行为、reasoning 块可见性、agent 状态、事件/轮次/步骤/工具调用计数、精确输入/输出/缓存 token bucket、KV-cache 命中率、token-meter 上下文用量与容量、创建时间和最新事件时间。缺失标题、模型、缓存输入或上下文容量时会明确标记,而非推断。该卡片只存在于终端,不会重复紧凑 footer。
|
||||
|
||||
`/resume` 会针对当前工作区打开全 viewport 键盘选择器,而非居中对话框。获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、cwd 不匹配或日志所记提供方没有当前适配器的会话仍会显示,但不可选择。选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并调用由宿主持有的可选 `TuiRuntime.handoffResume`;存在 `process.execve` 时,发布的 `dsh` 宿主会对 app 执行 dispose(资源释放)并替换自身进程。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。
|
||||
|
||||
`resumeCommand` 仍是部署持有的回退行为:只有当前会话已持久化后,退出才会打印它;不支持原地 handoff 的宿主会显示所选会话的命令。`{session}` 展开为会话 id。TUI 代码绝不会执行模板或任意 shell 文本。
|
||||
|
||||
## 配置
|
||||
|
||||
| 键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `welcome` | 未设置 | 会话出现已记录标题前使用的 banner 副标题行;未设置时,banner 进入时没有副标题 |
|
||||
| `sessionId` | `main` | 由终端驱动的精确共享 agent/会话身份 |
|
||||
| `showReasoning` | `true` | 渲染 reasoning 块 |
|
||||
| `maxToolOutputLines` | `6` | 折叠工具卡片的头尾预览所保留的输出行数 |
|
||||
| `maxQuestionOptions` | `8` | 问题面板中可见的选项数 |
|
||||
| `maxModelOptions` | `8` | 模型选择器中可见的模型数 |
|
||||
| `maxResumeOptions` | `8` | 恢复选择器中可见的会话数 |
|
||||
| `questionDialogWidth` | `200` | 问题面板宽度(列数),以终端宽度为上限 |
|
||||
| `questionDialogMaxHeight` | `20` | 问题面板最大行数 |
|
||||
| `modelDialogWidth` | `76` | 模型选择器宽度(列数) |
|
||||
| `modelDialogMaxHeight` | `20` | 模型选择器最大行数 |
|
||||
| `fileSearchMaxResults` | `20` | 一次 `@` 查询显示的最大文件和目录候选数 |
|
||||
| `fileSearchMaxEntries` | `10000` | 无路径模糊查询使用的有界工作区索引最多保留的路径数 |
|
||||
| `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | 遍历和直接补全时忽略的目录 basename |
|
||||
| `showHardwareCursor` | `false` | 在 pi-tui 的 IME marker 处显示硬件 cursor |
|
||||
| `color` | `true` | 应用内置 ANSI palette(参见[颜色](#color)) |
|
||||
| `title` | `DeepSeek Harness` | 终端窗口标题的产品后缀。 |
|
||||
| `resumeCommand` | 未设置 | 供退出提示和不支持原地 handoff 的宿主使用的 shell 命令模板,其中 `{session}` 会展开为会话 id |
|
||||
|
||||
```yaml
|
||||
- id: terminal
|
||||
name: '@deepseek-ai/dsh-tui'
|
||||
config:
|
||||
welcome: 'Coding agent ready.'
|
||||
sessionId: main-session-123
|
||||
showReasoning: true
|
||||
maxToolOutputLines: 6
|
||||
fileSearchExcludedDirectories: ['.git', 'node_modules', 'dist']
|
||||
```
|
||||
|
||||
任一进程流不是 TTY 时,启动会在挂载前失败。组合 app 必须先挂载 TUI,再挂载由配置创建的 agent,使入口能够观察 `agent-loop/config-start-failed`;完全匹配会话的失败会在全屏模式启动前写出并以状态 1 退出,而不是留下空白终端。dispose 会停止接收扩展请求,卸载 `ctx.tui` 提供方及其依赖插件,中止运行中的命令,移除 TUI 定义,停止 loader,拒绝待处理问题,排空终端输入,恢复终端状态,注销事件 listener 和用户交互提供方,并且绝不会在 HMR 期间退出替换进程。
|
||||
|
||||
## 颜色
|
||||
|
||||
Palette 使用标准 16 色 ANSI 前景色和 SGR 属性,每个终端都会将其重新映射到当前配色方案,因此浅色与深色背景下都保持可读。正文使用终端默认前景色,而非固定色调。成组区域(用户提示词、assistant 回复、工具卡片)通过以角色色渲染的粗体带下划线角色标题和空行分隔,而非填充背景块或逐行前缀,因此用鼠标框选复制时不会带上任何左侧竖条或缩进;工具卡片的状态(进行中、错误、成功)由其彩色带下划线的标题字形与标题体现。问题面板使用粗体强调色文本突出活跃行,选择器则使用反色。所有效果都只作用于前景色,因此不会与终端背景冲突。设置 `color: false` 可移除所有样式。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 交互式提示词输入
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每次非空普通编辑器提交都会成为一个文本块;目标 agent 空闲时通过 `agent.followup()` 发送,运行时通过 `agent.steer()` 发送。会话 mention 会变为可读的 `@label` 文本,加上由 [`dsh-session-reference`](../../context/session-reference/README.md) 定义的持久不受信任上下文;其完整 JSON 隐藏在紧凑引用卡片之后。斜杠命令和按键绑定仅用于 TUI;命令结果仍是终端通知。命令生产方可以调度单独的 agent 输入,例如 `/plan [message]` 接受的可选消息。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
提交的文本会按 agent loop 的普通会话历史与压缩规则保留。Header、已记录标题、卡片、Markdown 渲染、状态行、计划和帮助文本不会增加 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 文件引用自动补全
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
所选文件仍是普通 user 文本,例如 `@src/index.ts` 或 `@"docs/design notes.md"`;自动补全不会添加内容块、持久上下文或特殊引用 payload。注册 `read` 后,此 TUI agent 的每个请求还会包含下方固定系统提示词段落。模型会判断任务是否需要文件内容,并在需要时通过普通工具循环调用 `read`;只有路径不能证明文件已经过检查。
|
||||
|
||||
##### 精确系统提示词文本
|
||||
|
||||
```markdown
|
||||
Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
自动补全本身不增加 token。所选路径只贡献普通 user 文本 token;`read` 可用时,固定指令会贡献系统提示词 token。只有模型选择的 `read` 调用返回文件内容后,这些内容才会占用上下文。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
固定指令属于稳定系统提示词前缀,可以跨轮次复用。每个所选路径都是仅追加 user 文本;后续 `read` 结果通过普通工具 transcript 追加所请求内容。
|
||||
|
||||
### 会话模型选择
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
`/model` 命令文本和键盘选择器输入均不会记录或发送。新步骤会在提示词变量中收到所选提供方/模型路由,并在请求路由中收到所选提供方/模型/推理强度目标。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
选择器不会添加消息。更改目标可能改变插值后的系统提示词文本,并把后续请求发送给所选模型。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
更改提供方或模型会进入该目标的缓存域;不假定不同目标间可以复用缓存。
|
||||
|
||||
### 手动调用 skill
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
提交 `/skill:<name> [instructions]` 会加载具名 skill,并交付一个文本块:用 `<skill name="…">` 元素包装 skill 指令;提供方公开资源基准时,会先添加一行定位 skill 相对资源;最后附上用户输入的尾随指令。交付遵循普通输入同样的空闲时 followup、运行时 steer 规则。选择 skill 的是命令而非模型;模型禁用的 skill 不出现在自动补全中,但仍可按精确名称加载。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
渲染后的 skill 块与尾随指令会作为一个 user 轮次保留,并遵循 agent loop 的普通会话历史和压缩规则;重复调用会再次追加正文。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
### 交互式用户问题回答
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
消费方调用 `ctx.userInteraction.ask()` 时,此提供方会按顺序显示各个问题,并返回选中选项标签或 `custom` 文本。中止、取消或 UI dispose 会变为 `Error: ask_user_question was interrupted before the user answered`;该转换由 `dsh-tool-ask-user` 完成。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
等待和终端 overlay 不增加 token;已解析回答或错误只会通过调用工具或插件的结果对模型可见。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
- **恢复功能没有跨进程会话锁**:选择器会拒绝本运行时中已知处于活跃状态的会话,但另一个进程可以在 handoff 之前或期间恢复同一持久 id。能够运行并发宿主的部署必须在 TUI 外协调所有权。
|
||||
- **一个已配置会话持有 transcript 和编辑器**:其他 agent 的问题仍可使用共享 overlay 提供方,但会话渲染与提示词输入仍绑定到 `sessionId`。
|
||||
- **工具卡片是文本终端展示**:终端、diff 与通用卡片使用工具持有的标题/内容,但会话内容目前没有用于内联图像渲染的图像块。
|
||||
- **有意不支持非 TTY 运行**:需要自动化的 app bundle 必须组合单次执行或服务器入口(`dsh-cli-demo`、`dsh-acp`),而不能依赖内部回退。
|
||||
- **手动 `/skill:` 调用总会重新加载完整 skill 正文**:TUI 不会检测会话中是否已存在某项 skill,因此重复调用会再次追加其指令。
|
||||
- **文件发现只发现宿主工作区**:自动补全读取 TUI 进程的会话 `cwd`,所选文本随后由已配置 `read` 工具解释。挂载远程或虚拟文件系统的部署必须对齐这些 namespace,或提供其他补全接口。
|
||||
- **文件搜索使用显式目录排除项,而非 ignore 文件**:默认排除 `.git` 和 `node_modules`,部署还可以配置更多 basename,但不会解释 `.gitignore` 和 `.ignore`。目录 symlink 不会遍历。
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./prompt": {
|
||||
"types": "./lib/types/prompt.d.ts",
|
||||
"default": "./lib/prompt.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/prompt.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
@@ -35,9 +40,9 @@
|
||||
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
|
||||
"@deepseek-ai/dsh-goal": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-reference": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-reference": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-skill": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
@@ -59,6 +64,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-tui": "0.80.7",
|
||||
"saxes": "6.0.0",
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -71,9 +77,9 @@
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-retry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
|
||||
95
packages/ui/tui/src/chat/autocomplete.ts
Normal file
95
packages/ui/tui/src/chat/autocomplete.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Editor autocomplete provider merging path-only file candidates and optional
|
||||
* session-reference snapshots with the base slash-command completions.
|
||||
* @module @deepseek-ai/dsh-tui/chat/autocomplete
|
||||
*/
|
||||
|
||||
import {
|
||||
CombinedAutocompleteProvider,
|
||||
type AutocompleteItem,
|
||||
type AutocompleteProvider,
|
||||
type AutocompleteSuggestions,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
formatSessionReferenceMention,
|
||||
type SessionReferenceService,
|
||||
} from '@deepseek-ai/dsh-session-reference'
|
||||
import { displayInlineText } from '../components/text.ts'
|
||||
import { activeAtToken, formatFileMention, WorkspaceFileSearch } from './file-autocomplete.ts'
|
||||
|
||||
/** Merge path-only file candidates and optional session snapshots with commands. */
|
||||
export class ReferenceAutocompleteProvider implements AutocompleteProvider {
|
||||
constructor(
|
||||
private readonly base: CombinedAutocompleteProvider,
|
||||
private readonly files: WorkspaceFileSearch,
|
||||
private readonly sessions: SessionReferenceService | undefined,
|
||||
private readonly agent: Agent,
|
||||
) {}
|
||||
|
||||
async getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
options: { signal: AbortSignal; force?: boolean },
|
||||
): Promise<AutocompleteSuggestions | null> {
|
||||
const basePromise = this.base.getSuggestions(lines, cursorLine, cursorCol, options)
|
||||
const currentLine = lines[cursorLine]
|
||||
/* v8 ignore next -- Editor always supplies its current state line. */
|
||||
if (currentLine === undefined) return basePromise
|
||||
const token = activeAtToken(currentLine, cursorCol)
|
||||
if (token === undefined) {
|
||||
this.files.invalidate()
|
||||
return basePromise
|
||||
}
|
||||
const filePromise = this.files.list(token.query, options.signal).catch(() => [])
|
||||
const sessionPromise = this.sessions === undefined || token.quoted
|
||||
? Promise.resolve([])
|
||||
: this.sessions.listCandidates(this.agent, token.query, undefined, options.signal).catch(() => [])
|
||||
const [base, fileCandidates, sessionCandidates] = await Promise.all([
|
||||
basePromise,
|
||||
filePromise,
|
||||
sessionPromise,
|
||||
])
|
||||
if (options.signal.aborted) return base
|
||||
const fileItems: AutocompleteItem[] = fileCandidates.flatMap((candidate) => {
|
||||
const value = formatFileMention(candidate, token.quoted)
|
||||
if (value === undefined) return []
|
||||
const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1)
|
||||
const directory = candidate.kind === 'directory'
|
||||
return [{
|
||||
value,
|
||||
label: `${directory ? 'Folder' : 'File'} · ${displayInlineText(name)}${directory ? '/' : ''}`,
|
||||
description: displayInlineText(candidate.path),
|
||||
}]
|
||||
})
|
||||
const sessionItems: AutocompleteItem[] = sessionCandidates.map((candidate) => {
|
||||
const mentionLabel = displayInlineText(candidate.label)
|
||||
const sessionId = displayInlineText(candidate.sessionId)
|
||||
const location = candidate.cwd === undefined ? '(no cwd)' : displayInlineText(candidate.cwd)
|
||||
const description = `${candidate.label === candidate.sessionId ? '' : `${sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}`
|
||||
return {
|
||||
value: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: mentionLabel }),
|
||||
label: `Session · ${mentionLabel}`,
|
||||
description,
|
||||
}
|
||||
})
|
||||
const items = [...fileItems, ...sessionItems]
|
||||
if (items.length === 0) return base
|
||||
return { items: [...items, ...(base?.items ?? [])], prefix: token.prefix }
|
||||
}
|
||||
|
||||
applyCompletion(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
item: AutocompleteItem,
|
||||
prefix: string,
|
||||
): { lines: string[]; cursorLine: number; cursorCol: number } {
|
||||
return this.base.applyCompletion(lines, cursorLine, cursorCol, item, prefix)
|
||||
}
|
||||
|
||||
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
|
||||
return this.base.shouldTriggerFileCompletion(lines, cursorLine, cursorCol)
|
||||
}
|
||||
}
|
||||
31
packages/ui/tui/src/chat/channel.ts
Normal file
31
packages/ui/tui/src/chat/channel.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Shared collaborator surface every chat-channel sub-controller receives from
|
||||
* `createTuiChat`. Each controller's own `*Deps` extends {@link ChatChannelDeps}
|
||||
* (and {@link ChannelNotice} when it reports outcomes) with the extra services
|
||||
* it needs. Value collaborators (`ctx`, `resolved`, `palette`, `overlayManager`)
|
||||
* are stable for the channel's life; the callbacks stay on the object so a
|
||||
* controller always calls the channel's current implementation.
|
||||
* @module @deepseek-ai/dsh-tui/chat/channel
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { TuiOverlayManager } from '../extension/overlay-manager.ts'
|
||||
import type { Palette } from '../components/theme.ts'
|
||||
import type { ResolvedTuiConfig } from '../config.ts'
|
||||
|
||||
/** Collaborators shared by every chat-channel sub-controller. */
|
||||
export interface ChatChannelDeps {
|
||||
readonly ctx: Context
|
||||
readonly resolved: ResolvedTuiConfig
|
||||
readonly palette: Palette
|
||||
readonly overlayManager: TuiOverlayManager
|
||||
/** Redraw the channel. */
|
||||
requestRender(): void
|
||||
/** Whether the channel has begun shutting down. */
|
||||
isDisposed(): boolean
|
||||
}
|
||||
|
||||
/** Append a channel notice line; controllers that report outcomes mix this in. */
|
||||
export interface ChannelNotice {
|
||||
appendNotice(message: string, kind?: 'info' | 'warning' | 'error'): void
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
* paths only: selected values remain ordinary prompt text and file contents
|
||||
* stay behind the model-facing `read` tool.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tui/file-autocomplete
|
||||
* @module @deepseek-ai/dsh-tui/chat/file-autocomplete
|
||||
*/
|
||||
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
137
packages/ui/tui/src/chat/helpers.ts
Normal file
137
packages/ui/tui/src/chat/helpers.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Zero-state helpers for the interactive chat channel: prompt-directory and
|
||||
* Git-branch formatting, surface/tool-call derivations over the session log,
|
||||
* session-reference context cards, the placeholder editor, and banner-reveal
|
||||
* timing constants. None of these close over channel state.
|
||||
* @module @deepseek-ai/dsh-tui/chat/helpers
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { homedir } from 'node:os'
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
||||
import {
|
||||
CURSOR_MARKER,
|
||||
Editor,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Editor that shows a placeholder without making it editable content. */
|
||||
export class HintEditor extends Editor {
|
||||
/** Placeholder shown in the empty input row; `undefined` hides it. */
|
||||
hint: string | undefined
|
||||
/** Prompt text rendered before the placeholder, matching the live prompt width. */
|
||||
hintPrefix = ''
|
||||
|
||||
override render(width: number): string[] {
|
||||
const lines = super.render(width)
|
||||
if (this.hint === undefined || this.getText() !== '') return lines
|
||||
const content = lines[0]
|
||||
/* v8 ignore next -- Editor always renders one content row. */
|
||||
if (content === undefined) return lines
|
||||
const padding = ' '.repeat(this.getPaddingX())
|
||||
/* v8 ignore next -- the mounted editor is focused whenever its empty-input hint is rendered. */
|
||||
const marker = this.focused ? CURSOR_MARKER : ''
|
||||
const available = Math.max(0, width - visibleWidth(padding) - visibleWidth(this.hintPrefix))
|
||||
const placeholder = truncateToWidth(this.hint, available, '')
|
||||
const used = visibleWidth(padding) + visibleWidth(this.hintPrefix) + visibleWidth(placeholder)
|
||||
lines[0] = `${padding}${this.hintPrefix}${marker}${placeholder}${' '.repeat(Math.max(0, width - used))}`
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the session working directory as a prompt label: `~` for home,
|
||||
* `~/rel` for a home-relative path, the raw path otherwise.
|
||||
* @param cwd - operational working directory from the session header.
|
||||
* @returns unescaped prompt label.
|
||||
*/
|
||||
export function formatCwd(cwd: string | undefined): string {
|
||||
if (cwd === undefined) return 'cwd unset'
|
||||
const home = homedir()
|
||||
const rel = relative(resolve(home), resolve(cwd))
|
||||
if (rel === '') return '~'
|
||||
/* v8 ignore next -- Windows cross-drive coverage; POSIX relative() cannot return an absolute path. */
|
||||
if (isAbsolute(rel)) return cwd
|
||||
if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}`
|
||||
return cwd
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current Git branch for the prompt context line.
|
||||
* @param cwd - operational working directory to query.
|
||||
* @returns branch name, or `undefined` outside a worktree or on any failure.
|
||||
*/
|
||||
export function gitBranch(cwd: string): string | undefined {
|
||||
try {
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/iu.test(name)),
|
||||
)
|
||||
const branch = execFileSync('git', ['branch', '--show-current'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 1_000,
|
||||
}).trim()
|
||||
/* v8 ignore next -- detached-HEAD behavior is exercised by the runtime smoke, not the unit checkout. */
|
||||
return branch === '' ? undefined : branch
|
||||
} catch (_gitUnavailableOrOutsideWorktree) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence numbers currently visible on the session surface.
|
||||
* @param session - session whose surface nodes to read.
|
||||
* @returns the set of visible event sequence numbers.
|
||||
*/
|
||||
export function activeSurfaceSeqs(session: Session): Set<number> {
|
||||
return new Set(session.surface.nodes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool-call ids whose owning assistant message is on the active surface.
|
||||
* @param session - session whose events to scan.
|
||||
* @param active - sequence numbers currently on the surface.
|
||||
* @returns the set of active tool-call ids.
|
||||
*/
|
||||
export function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const event of session.events) {
|
||||
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
|
||||
for (const block of event.data.message.content) {
|
||||
if (block.type === 'tool-call') ids.add(block.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a session-reference context card's display labels from an event source.
|
||||
* @param source - event source to inspect.
|
||||
* @returns per-reference labels, or `undefined` when the source is not a reference card.
|
||||
*/
|
||||
export function sessionReferenceCard(source: unknown): string[] | undefined {
|
||||
if (typeof source !== 'object' || source === null) return undefined
|
||||
const record = source as Record<string, unknown>
|
||||
if (record['kind'] !== 'session-reference' || !Array.isArray(record['references'])) return undefined
|
||||
const references = record['references'] as unknown[]
|
||||
const labels: string[] = []
|
||||
for (const reference of references) {
|
||||
if (typeof reference !== 'object' || reference === null) return undefined
|
||||
const entry = reference as Record<string, unknown>
|
||||
const sessionId = entry['sessionId']
|
||||
const label = entry['label']
|
||||
if (typeof sessionId !== 'string' || typeof label !== 'string') return undefined
|
||||
labels.push(label === sessionId ? sessionId : `${label} (${sessionId})`)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
/** Milliseconds between banner sweep-reveal frames (~60 fps). */
|
||||
export const BANNER_REVEAL_INTERVAL_MS = 15
|
||||
|
||||
/** Number of sweep frames the banner reveal spreads the terminal width over. */
|
||||
export const BANNER_REVEAL_STEPS = 24
|
||||
191
packages/ui/tui/src/chat/model-command.ts
Normal file
191
packages/ui/tui/src/chat/model-command.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Model-selection sub-controller for the interactive chat channel: the queued
|
||||
* `/model` command, the keyboard model selector overlay with reasoning-effort
|
||||
* selection, and resolution of the selected model's context window. Owns the
|
||||
* context-window cache the prompt and status views read; the caller owns the
|
||||
* shared {@link AgentLlmTargetRef}.
|
||||
* @module @deepseek-ai/dsh-tui/chat/model-command
|
||||
*/
|
||||
|
||||
import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { displayText } from '../components/text.ts'
|
||||
import {
|
||||
ModelDialog,
|
||||
readModelChoices,
|
||||
targetLabel,
|
||||
targetReasoningLabel,
|
||||
type ModelChoice,
|
||||
type ModelDialogSelection,
|
||||
} from '../components/dialogs.ts'
|
||||
import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
|
||||
|
||||
/** Collaborators the model controller needs from the chat channel. */
|
||||
export interface ModelControllerDeps extends ChatChannelDeps, ChannelNotice {
|
||||
/** Shared selected-target handle owned by the channel. */
|
||||
readonly target: AgentLlmTargetRef
|
||||
}
|
||||
|
||||
/** Model-selection controller for one chat channel. */
|
||||
export interface ModelController {
|
||||
/** Resolved context window of the selected model, or `undefined` if unknown. */
|
||||
contextWindow(): number | undefined
|
||||
/** Queue a `/model` command; empty argument opens the selector. */
|
||||
queueModelCommand(raw: string): void
|
||||
/** Drop the pending context-window resolution (shutdown). */
|
||||
resetContextResolution(): void
|
||||
/** Forget the tracked selector overlay (shutdown). */
|
||||
clearOverlay(): void
|
||||
}
|
||||
|
||||
type ContextResolution =
|
||||
| { readonly kind: 'resolved'; readonly contextWindow: number | undefined }
|
||||
| { readonly kind: 'error'; readonly error: unknown }
|
||||
|
||||
/**
|
||||
* Build the model-selection controller for one chat channel.
|
||||
* @param deps - channel collaborators and shared target handle.
|
||||
* @returns the controller wired to the channel's overlay and prompt views.
|
||||
*/
|
||||
export function createModelController(deps: ModelControllerDeps): ModelController {
|
||||
const { ctx, resolved, palette, overlayManager, target } = deps
|
||||
let contextWindow: number | undefined
|
||||
let contextResolution: Promise<ContextResolution> | undefined
|
||||
let modelOverlay: TuiOverlaySession | undefined
|
||||
let modelCommands = Promise.resolve()
|
||||
|
||||
const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => {
|
||||
contextWindow = undefined
|
||||
const resolution: Promise<ContextResolution> = selected === undefined
|
||||
? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const)
|
||||
: ctx.llm.resolveModelInfo(selected.provider, selected.model).then(
|
||||
info => ({ kind: 'resolved', contextWindow: info.context?.contextWindow } as const),
|
||||
(error: unknown) => ({ kind: 'error', error } as const),
|
||||
)
|
||||
contextResolution = resolution
|
||||
void resolution.then((result) => {
|
||||
if (contextResolution !== resolution) return
|
||||
if (result.kind === 'error') {
|
||||
deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error')
|
||||
return
|
||||
}
|
||||
contextWindow = result.contextWindow
|
||||
deps.requestRender()
|
||||
})
|
||||
}
|
||||
resolveContextWindow(target.current)
|
||||
|
||||
const selectModel = (
|
||||
selected: ModelChoice,
|
||||
explicitReasoning?: { effort: ReasoningEffortId | undefined },
|
||||
): void => {
|
||||
const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model
|
||||
const reasoningEffort = explicitReasoning === undefined
|
||||
? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort)
|
||||
: explicitReasoning.effort
|
||||
if (sameRoute && target.current?.reasoningEffort === reasoningEffort) {
|
||||
const reasoning = targetReasoningLabel(selected, reasoningEffort)
|
||||
deps.appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`)
|
||||
return
|
||||
}
|
||||
target.current = {
|
||||
provider: selected.provider,
|
||||
model: selected.model,
|
||||
...reasoningEffort === undefined ? {} : { reasoningEffort },
|
||||
}
|
||||
resolveContextWindow(target.current)
|
||||
const reasoning = targetReasoningLabel(selected, reasoningEffort)
|
||||
deps.appendNotice([
|
||||
`Model selected: ${targetLabel(selected)}.`,
|
||||
...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`],
|
||||
'New steps will use it.',
|
||||
].join(' '))
|
||||
}
|
||||
|
||||
const showModelSelector = (choices: readonly ModelChoice[]): void => {
|
||||
const current = target.current === undefined ? 'unset' : targetLabel(target.current)
|
||||
if (choices.length === 0) {
|
||||
deps.appendNotice(`Current model: ${current}\nNo models are advertised by registered providers.`, 'warning')
|
||||
return
|
||||
}
|
||||
void modelOverlay?.close()
|
||||
const session = overlayManager.open({
|
||||
create: () => new ModelDialog(
|
||||
choices,
|
||||
target.current,
|
||||
resolved.maxModelOptions,
|
||||
palette,
|
||||
(selection: ModelDialogSelection) => {
|
||||
void session.close()
|
||||
selectModel(selection.choice, { effort: selection.reasoningEffort })
|
||||
},
|
||||
() => { void session.close() },
|
||||
),
|
||||
options: {
|
||||
width: resolved.modelDialogWidth,
|
||||
maxHeight: resolved.modelDialogMaxHeight,
|
||||
anchor: 'center',
|
||||
margin: 1,
|
||||
},
|
||||
})
|
||||
modelOverlay = session
|
||||
void session.closed.then(() => {
|
||||
if (modelOverlay === session) modelOverlay = undefined
|
||||
})
|
||||
deps.requestRender()
|
||||
}
|
||||
|
||||
const handleModelCommand = async (raw: string): Promise<void> => {
|
||||
const choices = await readModelChoices(ctx, target.current)
|
||||
if (deps.isDisposed()) return
|
||||
const argument = raw.trim()
|
||||
if (argument === '') {
|
||||
showModelSelector(choices)
|
||||
return
|
||||
}
|
||||
const parts = argument.split(/\s+/u)
|
||||
if (parts.length > 2) {
|
||||
deps.appendNotice('Usage: /model [provider/]model', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
let matches: ModelChoice[]
|
||||
if (parts.length === 2) {
|
||||
matches = choices.filter(choice => choice.provider === parts[0] && choice.model === parts[1])
|
||||
} else {
|
||||
const value = argument
|
||||
const qualified = choices.filter(choice => targetLabel(choice) === value)
|
||||
matches = qualified.length > 0 ? qualified : choices.filter(choice => choice.model === value)
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
deps.appendNotice(`Unknown model: ${argument}. Run /model to list available models.`, 'warning')
|
||||
return
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
deps.appendNotice(`Model "${argument}" is advertised by multiple providers; use /model <provider>/<model>.`, 'warning')
|
||||
return
|
||||
}
|
||||
const selected = matches[0]
|
||||
/* v8 ignore next -- a non-empty matches array always has index zero. */
|
||||
if (selected === undefined) return
|
||||
selectModel(selected)
|
||||
}
|
||||
|
||||
return {
|
||||
contextWindow: () => contextWindow,
|
||||
queueModelCommand(raw: string): void {
|
||||
modelCommands = modelCommands.then(async () => {
|
||||
await handleModelCommand(raw)
|
||||
}).catch((error: unknown) => {
|
||||
if (!deps.isDisposed()) deps.appendNotice(`Could not read the model catalog: ${errorChain(error)}`, 'error')
|
||||
})
|
||||
},
|
||||
resetContextResolution(): void {
|
||||
contextResolution = undefined
|
||||
},
|
||||
clearOverlay(): void {
|
||||
modelOverlay = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
168
packages/ui/tui/src/chat/questions.ts
Normal file
168
packages/ui/tui/src/chat/questions.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Ask-user-question sub-machine for the interactive chat channel. Registers the
|
||||
* user-interaction provider, presents one question overlay at a time in FIFO
|
||||
* order, and settles each request on answer, abort, overlay error, or channel
|
||||
* shutdown.
|
||||
* @module @deepseek-ai/dsh-tui/chat/questions
|
||||
*/
|
||||
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionAnswer,
|
||||
type AskUserQuestionAnswerItem,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import { QuestionDialog } from '../components/dialogs.ts'
|
||||
import type { ChatChannelDeps } from './channel.ts'
|
||||
|
||||
/** One queued or active ask-user-question request and its running answers. */
|
||||
interface PendingQuestion {
|
||||
request: AskUserQuestionRequest
|
||||
index: number
|
||||
answers: AskUserQuestionAnswerItem[]
|
||||
resolve(answer: AskUserQuestionAnswer): void
|
||||
reject(error: unknown): void
|
||||
onAbort: () => void
|
||||
overlay: TuiOverlaySession | undefined
|
||||
}
|
||||
|
||||
/** Collaborators the question queue needs from the chat channel. */
|
||||
export type QuestionQueueDeps = ChatChannelDeps
|
||||
|
||||
/** Ask-user-question controller for one chat channel. */
|
||||
export interface QuestionQueue {
|
||||
/** Reject the active and all queued questions (shutdown). */
|
||||
rejectAll(): void
|
||||
/** Remove the user-interaction provider registration. */
|
||||
unregister(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ask-user-question queue for one chat channel.
|
||||
* @param deps - channel collaborators and overlay host.
|
||||
* @returns the controller used at shutdown to drain and unregister.
|
||||
*/
|
||||
export function createQuestionQueue(deps: QuestionQueueDeps): QuestionQueue {
|
||||
const { ctx, resolved, palette, overlayManager } = deps
|
||||
const questionQueue: PendingQuestion[] = []
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
|
||||
const removeAbortListener = (pending: PendingQuestion): void => {
|
||||
pending.request.signal?.removeEventListener('abort', pending.onAbort)
|
||||
}
|
||||
|
||||
const rejectQuestion = (pending: PendingQuestion): void => {
|
||||
void pending.overlay?.close()
|
||||
pending.overlay = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError(
|
||||
'ask_user_question was interrupted before the user answered',
|
||||
'ASK_ABORTED',
|
||||
))
|
||||
}
|
||||
|
||||
const startNextQuestion = (): void => {
|
||||
if (activeQuestion !== undefined || deps.isDisposed()) return
|
||||
const pending = questionQueue.shift()
|
||||
if (pending === undefined) return
|
||||
activeQuestion = pending
|
||||
const show = (): void => {
|
||||
const question = pending.request.questions[pending.index]
|
||||
if (question === undefined) {
|
||||
activeQuestion = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.resolve({ answers: pending.answers })
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
const session = overlayManager.open({
|
||||
...pending.request.signal === undefined ? {} : { signal: pending.request.signal },
|
||||
create: () => new QuestionDialog(
|
||||
question,
|
||||
pending.index + 1,
|
||||
pending.request.questions.length,
|
||||
pending.request.questions.length - pending.answers.length,
|
||||
resolved.maxQuestionOptions,
|
||||
palette,
|
||||
(selection) => {
|
||||
pending.overlay = undefined
|
||||
void session.close()
|
||||
pending.answers.push({ id: question.id, ...selection })
|
||||
pending.index += 1
|
||||
show()
|
||||
},
|
||||
() => {
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
startNextQuestion()
|
||||
},
|
||||
),
|
||||
options: {
|
||||
width: resolved.questionDialogWidth,
|
||||
maxHeight: resolved.questionDialogMaxHeight,
|
||||
anchor: 'bottom-left',
|
||||
margin: { bottom: 1 },
|
||||
},
|
||||
})
|
||||
pending.overlay = session
|
||||
void session.closed.then((result) => {
|
||||
if (pending.overlay !== session) return
|
||||
pending.overlay = undefined
|
||||
/* v8 ignore next 2 -- close, abort, and shutdown settle the owner before this callback */
|
||||
if (result.reason !== 'error') return
|
||||
activeQuestion = undefined
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError(
|
||||
`ask_user_question TUI failed: ${errorChain(result.error)}`,
|
||||
'ASK_ABORTED',
|
||||
))
|
||||
startNextQuestion()
|
||||
})
|
||||
deps.requestRender()
|
||||
}
|
||||
show()
|
||||
}
|
||||
|
||||
const unregister = ctx.userInteraction.registerProvider({
|
||||
ask(request) {
|
||||
return new Promise<AskUserQuestionAnswer>((resolveAnswer, reject) => {
|
||||
const pending: PendingQuestion = {
|
||||
request,
|
||||
index: 0,
|
||||
answers: [],
|
||||
resolve: resolveAnswer,
|
||||
reject,
|
||||
overlay: undefined,
|
||||
onAbort: () => {
|
||||
if (activeQuestion === pending) {
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
// A non-active pending ask remains in the queue until this listener settles it.
|
||||
questionQueue.splice(questionQueue.indexOf(pending), 1)
|
||||
rejectQuestion(pending)
|
||||
},
|
||||
}
|
||||
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
|
||||
questionQueue.push(pending)
|
||||
startNextQuestion()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
rejectAll(): void {
|
||||
if (activeQuestion !== undefined) {
|
||||
const pending = activeQuestion
|
||||
activeQuestion = undefined
|
||||
rejectQuestion(pending)
|
||||
}
|
||||
for (const pending of questionQueue.splice(0)) rejectQuestion(pending)
|
||||
},
|
||||
unregister,
|
||||
}
|
||||
}
|
||||
245
packages/ui/tui/src/chat/resume.ts
Normal file
245
packages/ui/tui/src/chat/resume.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Session-resume sub-controller for the interactive chat channel: the
|
||||
* `/resume` selector, per-candidate summary reads that tolerate a corrupt
|
||||
* neighbor, the pre-handoff preflight, the terminal handoff itself, and the
|
||||
* durable resume-hint command printed on exit.
|
||||
* @module @deepseek-ai/dsh-tui/chat/resume
|
||||
*/
|
||||
|
||||
import type { TUI } from '@earendil-works/pi-tui'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionLogSnapshot,
|
||||
SessionQueryService,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { HintEditor } from './helpers.ts'
|
||||
import { formatCwd } from './helpers.ts'
|
||||
import type { TuiOverlaySession } from '../extension/types.ts'
|
||||
import type { TuiRuntime } from '../runtime.ts'
|
||||
import type { Config } from '../config.ts'
|
||||
import {
|
||||
ResumePicker,
|
||||
summarizeResumeCandidate,
|
||||
type ResumeCandidate,
|
||||
} from '../components/dialogs.ts'
|
||||
import type { ChannelNotice, ChatChannelDeps } from './channel.ts'
|
||||
|
||||
/** Collaborators the resume controller needs from the chat channel. */
|
||||
export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice {
|
||||
readonly agent: Agent
|
||||
readonly config: Config
|
||||
readonly runtime: TuiRuntime
|
||||
readonly persistence: SessionPersistence | undefined
|
||||
readonly sessionQuery: SessionQueryService | undefined
|
||||
readonly ui: TUI
|
||||
readonly editor: HintEditor
|
||||
/** Current agent status, re-read at each resume precondition point. */
|
||||
agentStatus(): AgentStatus
|
||||
}
|
||||
|
||||
/** Session-resume controller for one chat channel. */
|
||||
export interface ResumeController {
|
||||
/** Open the current-workspace searchable session selector. */
|
||||
showResume(): void
|
||||
/**
|
||||
* The resume command for the current session — the configured template with
|
||||
* every `{session}` filled — but only once the session is durably persisted;
|
||||
* `undefined` otherwise.
|
||||
*/
|
||||
currentResumeCommand(): Promise<string | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the session-resume controller for one chat channel.
|
||||
* @param deps - channel collaborators, terminal handles, and optional services.
|
||||
* @returns the controller wired to the `/resume` command and exit hint.
|
||||
*/
|
||||
export function createResumeController(deps: ResumeControllerDeps): ResumeController {
|
||||
const {
|
||||
ctx, agent, config, runtime, resolved, palette, overlayManager,
|
||||
persistence, sessionQuery, ui, editor,
|
||||
} = deps
|
||||
let resumeOverlay: TuiOverlaySession | undefined
|
||||
let resumeInFlight = false
|
||||
let resumeScan = 0
|
||||
|
||||
/**
|
||||
* Persisted sessions for this workspace, newest first. Empty when no
|
||||
* persistence backend is mounted or a listing failure would otherwise block
|
||||
* exit or crash `/resume`; the resume hint is best-effort convenience.
|
||||
*/
|
||||
const listWorkspaceSessions = async (): Promise<SessionHeader[]> => {
|
||||
if (persistence === undefined) return []
|
||||
let all: readonly SessionHeader[]
|
||||
try {
|
||||
all = await persistence.list()
|
||||
} catch {
|
||||
// A listing failure must never block terminal exit or crash `/resume`.
|
||||
return []
|
||||
}
|
||||
return all
|
||||
.filter(header => header.cwd === agent.session.header.cwd)
|
||||
}
|
||||
|
||||
/** Build one display candidate without letting a corrupt neighbor abort the selector. */
|
||||
const readResumeCandidate = async (
|
||||
record: SessionRecord,
|
||||
providers: ReadonlySet<string>,
|
||||
): Promise<ResumeCandidate> => {
|
||||
try {
|
||||
let snapshot: SessionLogSnapshot
|
||||
const live = ctx.sessions.get(record.header.id)
|
||||
if (live !== undefined) {
|
||||
snapshot = {
|
||||
session: structuredClone(live.header),
|
||||
events: live.events.map(event => structuredClone(event)),
|
||||
}
|
||||
} else {
|
||||
/* v8 ignore next -- caller checks the optional service before mapping records */
|
||||
if (sessionQuery === undefined) throw new Error('session query is unavailable')
|
||||
snapshot = await sessionQuery.readSession(record.header.id)
|
||||
}
|
||||
return summarizeResumeCandidate(
|
||||
record,
|
||||
snapshot,
|
||||
agent.session.id,
|
||||
agent.session.header.cwd,
|
||||
providers,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
record,
|
||||
title: 'Unreadable session',
|
||||
lastActivityAt: record.header.createdAt,
|
||||
lastTurn: 'log unavailable',
|
||||
disabledReason: `session cannot be loaded: ${errorChain(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read every mutable precondition immediately before terminal handoff. */
|
||||
const preflightResume = async (sessionId: SessionId): Promise<ResumeCandidate> => {
|
||||
/* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */
|
||||
if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.')
|
||||
const initialStatus = deps.agentStatus()
|
||||
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
|
||||
const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId)
|
||||
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
|
||||
const candidate = await readResumeCandidate(
|
||||
record,
|
||||
new Set(ctx.llm.listProviders().map(provider => provider.id)),
|
||||
)
|
||||
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
|
||||
const finalStatus = deps.agentStatus()
|
||||
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
|
||||
return candidate
|
||||
}
|
||||
|
||||
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
|
||||
if (resumeInFlight) return
|
||||
resumeInFlight = true
|
||||
let terminalReleased = false
|
||||
try {
|
||||
const checked = await preflightResume(candidate.record.header.id)
|
||||
const hostHandoff = runtime.handoffResume
|
||||
if (hostHandoff === undefined) {
|
||||
const template = config.resumeCommand
|
||||
const fallback = template?.replaceAll('{session}', checked.record.header.id)
|
||||
await overlay.close()
|
||||
resumeOverlay = undefined
|
||||
deps.appendNotice(fallback === undefined
|
||||
? 'Session is resumable, but this host cannot hand it off in place.'
|
||||
: `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning')
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */
|
||||
if (deps.isDisposed()) return
|
||||
await ctx.sessions.flush(agent.session)
|
||||
// Disposal can run while the flush promise is pending.
|
||||
if (deps.isDisposed()) return
|
||||
if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`)
|
||||
await overlay.close()
|
||||
resumeOverlay = undefined
|
||||
await runtime.terminal.drainInput(100, 20)
|
||||
// Disposal can run while terminal draining is pending.
|
||||
if (deps.isDisposed()) return
|
||||
ui.stop()
|
||||
terminalReleased = true
|
||||
await hostHandoff(checked.record.header.id)
|
||||
throw new Error('resume host returned without replacing the process')
|
||||
} catch (error: unknown) {
|
||||
if (!deps.isDisposed()) {
|
||||
if (terminalReleased) {
|
||||
ui.start()
|
||||
ui.setFocus(editor)
|
||||
deps.appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error')
|
||||
} else {
|
||||
await overlay.close()
|
||||
resumeOverlay = undefined
|
||||
deps.appendNotice(`Resume failed: ${errorChain(error)}`, 'error')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resumeInFlight = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
currentResumeCommand: async (): Promise<string | undefined> => {
|
||||
if (config.resumeCommand === undefined) return undefined
|
||||
const sessions = await listWorkspaceSessions()
|
||||
if (!sessions.some(header => header.id === agent.session.id)) return undefined
|
||||
return config.resumeCommand.replaceAll('{session}', agent.session.id)
|
||||
},
|
||||
showResume(): void {
|
||||
if (agent.status !== 'idle') {
|
||||
deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning')
|
||||
return
|
||||
}
|
||||
if (sessionQuery === undefined) {
|
||||
deps.appendNotice('Resume is not available: session query is not mounted.', 'warning')
|
||||
return
|
||||
}
|
||||
const scan = ++resumeScan
|
||||
void resumeOverlay?.close()
|
||||
void sessionQuery.listSessions().then(async (records) => {
|
||||
if (deps.isDisposed() || scan !== resumeScan) return
|
||||
const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd)
|
||||
const providers = new Set(ctx.llm.listProviders().map(provider => provider.id))
|
||||
const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers)))
|
||||
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|
||||
|| a.record.header.id.localeCompare(b.record.header.id))
|
||||
if (deps.isDisposed() || scan !== resumeScan) return
|
||||
const session = overlayManager.open({
|
||||
create: host => new ResumePicker(
|
||||
candidates,
|
||||
resolved.maxResumeOptions,
|
||||
runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd),
|
||||
() => host.viewport.rows,
|
||||
palette,
|
||||
(candidate) => { void handoffResume(candidate, session) },
|
||||
() => { void session.close() },
|
||||
),
|
||||
options: {
|
||||
width: '100%',
|
||||
maxHeight: '100%',
|
||||
anchor: 'top-left',
|
||||
margin: 0,
|
||||
},
|
||||
})
|
||||
resumeOverlay = session
|
||||
void session.closed.then(() => {
|
||||
/* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */
|
||||
if (resumeOverlay === session) resumeOverlay = undefined
|
||||
})
|
||||
deps.requestRender()
|
||||
}, (error: unknown) => {
|
||||
if (!deps.isDisposed() && scan === resumeScan) deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error')
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
67
packages/ui/tui/src/chat/skill-invocation.ts
Normal file
67
packages/ui/tui/src/chat/skill-invocation.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Manual `/skill:<name> [instructions]` parsing and model-visible rendering for
|
||||
* the terminal front door.
|
||||
* @module @deepseek-ai/dsh-tui/chat/skill-invocation
|
||||
*/
|
||||
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type { SkillDefinition, SkillResourceBase } from '@deepseek-ai/dsh-skill'
|
||||
|
||||
/** Prefix that marks an editor submission as a manual skill invocation. */
|
||||
export const SKILL_COMMAND_PREFIX = '/skill:'
|
||||
|
||||
/** Parsed `/skill:<name> [instructions]` submission; `name` is empty when the prefix carries no name. */
|
||||
export interface ParsedSkillCommand {
|
||||
/** Skill name typed after `/skill:`, up to the first space. */
|
||||
name: string
|
||||
/** Trimmed text after the name; empty when none was typed. */
|
||||
instructions: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a `/skill:<name> [instructions]` submission into its name and trailing instructions.
|
||||
* @param text - trimmed submission that starts with {@link SKILL_COMMAND_PREFIX}.
|
||||
* @returns the skill name and any trailing instructions.
|
||||
*/
|
||||
export function parseSkillCommand(text: string): ParsedSkillCommand {
|
||||
const rest = text.slice(SKILL_COMMAND_PREFIX.length)
|
||||
const spaceIndex = rest.indexOf(' ')
|
||||
if (spaceIndex === -1) return { name: rest, instructions: '' }
|
||||
return { name: rest.slice(0, spaceIndex), instructions: rest.slice(spaceIndex + 1).trim() }
|
||||
}
|
||||
|
||||
/** Model-visible line locating a manually invoked skill's relative resources, or `undefined` when the provider has no base. */
|
||||
function skillResourceReference(base: SkillResourceBase | undefined): string | undefined {
|
||||
if (base === undefined) return undefined
|
||||
switch (base.kind) {
|
||||
case 'directory':
|
||||
return `References in this skill are relative to ${base.path}.`
|
||||
case 'url':
|
||||
return `References in this skill are relative to ${base.url}.`
|
||||
case 'opaque':
|
||||
return base.description
|
||||
default:
|
||||
return assertNever(base, 'SkillResourceBase.kind')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a manually invoked skill into the model-visible user-message text. The
|
||||
* `<skill>` block carries the body and, when the provider supplies one, its
|
||||
* resource base; the trimmed `instructions` follow the block as the user's
|
||||
* request for this turn. The name is registry-validated kebab-case
|
||||
* (the skill registry rejects any other) and the resource base is trusted
|
||||
* same-process provider prose, so — unlike the model-facing `dsh-tool-skill`
|
||||
* result, which escapes for a tool channel — this user turn is assembled raw.
|
||||
* @param skill - the loaded skill definition.
|
||||
* @param instructions - trimmed text typed after `/skill:<name>`; empty when absent.
|
||||
* @returns the user-message text delivered to the agent.
|
||||
*/
|
||||
export function renderSkillInvocation(skill: SkillDefinition, instructions: string): string {
|
||||
const lines = [`<skill name="${skill.name}">`]
|
||||
const reference = skillResourceReference(skill.resourceBase)
|
||||
if (reference !== undefined) lines.push(reference, '')
|
||||
lines.push(skill.content, '</skill>')
|
||||
const block = lines.join('\n')
|
||||
return instructions === '' ? block : `${block}\n\n${instructions}`
|
||||
}
|
||||
347
packages/ui/tui/src/chat/timing.ts
Normal file
347
packages/ui/tui/src/chat/timing.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Per-step timing model and running-status glyph animation for the terminal
|
||||
* front door. Timing buckets are replayed from the session event stream; the
|
||||
* running glyph fades in on turn start, throbs while the turn runs, and fades
|
||||
* out on turn end.
|
||||
* @module @deepseek-ai/dsh-tui/chat/timing
|
||||
*/
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Palette } from '../components/theme.ts'
|
||||
|
||||
/**
|
||||
* Render cadence of the running prompt while active, and while the glyph fades
|
||||
* out after a turn ends. ~20 fps so the truecolor glyph fade reads smoothly;
|
||||
* the same tick keeps the elapsed-time text (0.1 s resolution) current. Only
|
||||
* changed terminal cells are re-emitted, so the faster tick stays cheap.
|
||||
*/
|
||||
export const STATUS_ANIMATION_INTERVAL_MS = 50
|
||||
|
||||
/**
|
||||
* Milliseconds over which the running glyph fades in when a turn starts and
|
||||
* fades out after it ends. The fade is an envelope over the running pulse:
|
||||
* inside it the glyph throbs (see {@link STATUS_PULSE_PERIOD_MS}).
|
||||
*/
|
||||
export const STATUS_FADE_MS = 300
|
||||
|
||||
/** Milliseconds for one full brightness throb of the running glyph. */
|
||||
export const STATUS_PULSE_PERIOD_MS = 1400
|
||||
|
||||
/**
|
||||
* Brightness floor of the running throb, as a fraction of the settled gray. At
|
||||
* 0 the pulse swells from the near-background trough up to full and back. The
|
||||
* trough is still rendered as the dimmest gray, not clipped to a blank, so the
|
||||
* cosine breathes symmetrically bold→dim→bold.
|
||||
*/
|
||||
export const STATUS_PULSE_FLOOR = 0
|
||||
|
||||
/**
|
||||
* Muted-gray foreground the truecolor running glyph fades through, from the
|
||||
* near-background trough (opacity 0) to the settled dim gray (opacity 1). Same
|
||||
* hue-free gray as the idle caret, so the glyph reads as the caret dimly
|
||||
* appearing rather than a colored indicator. Foreground-only, matching the
|
||||
* brand gradient, so it stays legible on any terminal background.
|
||||
*/
|
||||
const STATUS_FADE_GRAY = {
|
||||
trough: [43, 43, 43],
|
||||
settled: [136, 136, 136],
|
||||
} as const
|
||||
|
||||
/** The active phase of a running step, one bucket of accumulated wall time. */
|
||||
export type TimingBucket = 'ttft' | 'thinking' | 'responding' | 'tools'
|
||||
|
||||
/** Turn/step coordinates of one assistant step. */
|
||||
export type StepPosition = { turn: number; step: number }
|
||||
|
||||
/** Accumulated wall time per phase for one step or session slice. */
|
||||
export interface TimingTotals {
|
||||
ttft: number
|
||||
thinking: number
|
||||
responding: number
|
||||
tools: number
|
||||
}
|
||||
|
||||
interface TimingState {
|
||||
totals: TimingTotals
|
||||
active: { bucket: TimingBucket; since: number } | undefined
|
||||
}
|
||||
|
||||
const TIMING_BUCKET_LABELS: Record<TimingBucket, string> = {
|
||||
ttft: 'Model wait',
|
||||
thinking: 'Thinking',
|
||||
responding: 'Response',
|
||||
tools: 'Tools',
|
||||
}
|
||||
|
||||
const TIMING_BUCKETS: readonly TimingBucket[] = ['ttft', 'thinking', 'responding', 'tools']
|
||||
|
||||
function emptyTimingTotals(): TimingTotals {
|
||||
return { ttft: 0, thinking: 0, responding: 0, tools: 0 }
|
||||
}
|
||||
|
||||
function timingState(startedAt?: number): TimingState {
|
||||
return {
|
||||
totals: emptyTimingTotals(),
|
||||
/* v8 ignore next -- production timing state always begins at a logged step timestamp. */
|
||||
active: startedAt === undefined ? undefined : { bucket: 'ttft', since: startedAt },
|
||||
}
|
||||
}
|
||||
|
||||
function sameStep(event: SessionEvent, position: StepPosition): boolean {
|
||||
return typeof event.data === 'object'
|
||||
&& 'turn' in event.data && 'step' in event.data
|
||||
&& event.data.turn === position.turn && event.data.step === position.step
|
||||
}
|
||||
|
||||
function closeTimingBucket(state: TimingState, at: number): void {
|
||||
if (state.active === undefined) return
|
||||
state.totals[state.active.bucket] += Math.max(0, at - state.active.since)
|
||||
state.active = undefined
|
||||
}
|
||||
|
||||
function enterTimingBucket(state: TimingState, bucket: TimingBucket | undefined, at: number): void {
|
||||
if (state.active?.bucket === bucket) return
|
||||
closeTimingBucket(state, at)
|
||||
if (bucket !== undefined) state.active = { bucket, since: at }
|
||||
}
|
||||
|
||||
function advanceStepTiming(
|
||||
state: TimingState,
|
||||
event: Extract<SessionEvent, { type: 'assistant/chunk' | 'tool/call' | 'step/end' }>,
|
||||
): void {
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const chunk = event.data.chunk
|
||||
if (state.active?.bucket === 'ttft') enterTimingBucket(state, undefined, event.time)
|
||||
if (chunk.type === 'reasoning-delta' || (chunk.type === 'block-start' && chunk.blockType === 'reasoning')) {
|
||||
enterTimingBucket(state, 'thinking', event.time)
|
||||
} else if (chunk.type === 'text-delta' || (chunk.type === 'block-start' && chunk.blockType === 'text')) {
|
||||
enterTimingBucket(state, 'responding', event.time)
|
||||
}
|
||||
} else if (event.type === 'tool/call') {
|
||||
enterTimingBucket(state, 'tools', event.time)
|
||||
} else {
|
||||
closeTimingBucket(state, event.time)
|
||||
}
|
||||
}
|
||||
|
||||
function timingTotalsAt(state: TimingState, at?: number): TimingTotals {
|
||||
const totals = { ...state.totals }
|
||||
if (state.active !== undefined && at !== undefined) {
|
||||
totals[state.active.bucket] += Math.max(0, at - state.active.since)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay one step's accumulated per-phase timing up to clock `at`.
|
||||
* @param events - Session events to replay.
|
||||
* @param position - Turn/step coordinates of the step.
|
||||
* @param at - Render clock to accumulate the open bucket up to.
|
||||
* @returns The step's per-phase totals.
|
||||
*/
|
||||
export function stepTimingAt(
|
||||
events: readonly SessionEvent[],
|
||||
position: StepPosition,
|
||||
at: number,
|
||||
): TimingTotals {
|
||||
const startIndex = events.findIndex(event => event.type === 'step/start' && sameStep(event, position))
|
||||
if (startIndex < 0) return emptyTimingTotals()
|
||||
const start = events[startIndex] as Extract<SessionEvent, { type: 'step/start' }>
|
||||
const state = timingState(start.time)
|
||||
for (let index = startIndex + 1; index < events.length; index += 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.time > at) break
|
||||
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
|
||||
&& sameStep(event, position)) {
|
||||
advanceStepTiming(state, event)
|
||||
if (event.type === 'step/end') break
|
||||
}
|
||||
}
|
||||
return timingTotalsAt(state, at)
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn index of the currently open turn, or `undefined` when none is open.
|
||||
* @param events - Session events to scan from the tail.
|
||||
* @returns The open turn index, or `undefined`.
|
||||
*/
|
||||
export function openTurn(events: readonly SessionEvent[]): number | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'turn/end') return undefined
|
||||
if (event.type === 'turn/start') return event.data.turn
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-specific status glyph, keyed by the running step's active timing bucket.
|
||||
* `ttft` is the pre-first-token wait a running turn falls back to between steps.
|
||||
*/
|
||||
export const TIMING_BUCKET_GLYPHS: Record<TimingBucket, string> = {
|
||||
ttft: '◍',
|
||||
thinking: '✻',
|
||||
responding: '●',
|
||||
tools: '⚙',
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the currently open step's active timing bucket, or `undefined` when no
|
||||
* step is open. The open step is the last `step/start` with no later matching
|
||||
* `step/end`; its bucket is replayed with the same rules as {@link stepTimingAt}.
|
||||
* @param events - Session events to scan.
|
||||
* @returns The open step's active bucket, or `undefined`.
|
||||
*/
|
||||
export function openStepPhase(events: readonly SessionEvent[]): TimingBucket | undefined {
|
||||
let startIndex = -1
|
||||
let start: Extract<SessionEvent, { type: 'step/start' }> | undefined
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if (event.type === 'step/end') return undefined
|
||||
if (event.type === 'step/start') {
|
||||
startIndex = index
|
||||
start = event
|
||||
break
|
||||
}
|
||||
if (event.type === 'turn/end') return undefined
|
||||
}
|
||||
if (start === undefined) return undefined
|
||||
const position = start.data
|
||||
const state = timingState(start.time)
|
||||
for (let index = startIndex + 1; index < events.length; index += 1) {
|
||||
const event = events[index] as SessionEvent
|
||||
if ((event.type === 'assistant/chunk' || event.type === 'tool/call' || event.type === 'step/end')
|
||||
&& sameStep(event, position)) {
|
||||
advanceStepTiming(state, event)
|
||||
}
|
||||
}
|
||||
return state.active?.bucket
|
||||
}
|
||||
|
||||
/**
|
||||
* The running agent's phase glyph, or `undefined` when idle. A running turn
|
||||
* with no open step falls back to the pre-first-token wait so a glyph is always
|
||||
* available while the agent works; it fades in on turn start, throbs while the
|
||||
* turn runs, and fades out on turn end (see {@link fadeGlyph}).
|
||||
* @param events - Session events to derive the phase from.
|
||||
* @param running - Whether the agent is currently running.
|
||||
* @returns The phase glyph, or `undefined` when idle.
|
||||
*/
|
||||
export function runningPhaseGlyph(events: readonly SessionEvent[], running: boolean): string | undefined {
|
||||
if (!running) return undefined
|
||||
const bucket = openStepPhase(events) ?? 'ttft'
|
||||
return TIMING_BUCKET_GLYPHS[bucket]
|
||||
}
|
||||
|
||||
/**
|
||||
* The running throb's brightness at continuous clock `nowMs`: a cosine between
|
||||
* {@link STATUS_PULSE_FLOOR} and 1 over {@link STATUS_PULSE_PERIOD_MS}, so the
|
||||
* dim glyph breathes bold→dim→bold without ever blinking off. Multiplied by the
|
||||
* fade envelope, which alone drives appear/disappear at turn boundaries.
|
||||
*
|
||||
* @param nowMs - Monotonic render clock in milliseconds.
|
||||
* @returns Brightness fraction in [{@link STATUS_PULSE_FLOOR}, 1].
|
||||
*/
|
||||
export function pulseLevel(nowMs: number): number {
|
||||
const phase = (nowMs % STATUS_PULSE_PERIOD_MS) / STATUS_PULSE_PERIOD_MS
|
||||
const wave = 0.5 - 0.5 * Math.cos(2 * Math.PI * phase)
|
||||
return STATUS_PULSE_FLOOR + (1 - STATUS_PULSE_FLOOR) * wave
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame of the running glyph at fade `opacity` (0 = near-background trough
|
||||
* gray, 1 = settled dim gray). The character and its width never change — only
|
||||
* the gray fades — so the prompt caret column stays fixed and the glyph reads as
|
||||
* the caret dimly breathing, never a colored indicator.
|
||||
*
|
||||
* With truecolor the glyph's 24-bit gray foreground interpolates continuously
|
||||
* between {@link STATUS_FADE_GRAY}'s trough and settled stops, so both the fade
|
||||
* and the running throb render as a smooth, symmetric brightness swing with no
|
||||
* hard cutoff to clip the trough into a blank. Without truecolor there is no
|
||||
* per-frame gray, so `visible` (driven by the fade envelope, not the opacity)
|
||||
* shows the glyph in the palette's muted role or leaves a blank column — a
|
||||
* single dim appear/disappear at fixed width, still dim rather than accent, and
|
||||
* no throb-driven blink. With color off entirely a visible glyph is bare,
|
||||
* holding the caret column on a monochrome terminal.
|
||||
*
|
||||
* @param glyph - The phase glyph to paint.
|
||||
* @param palette - Active palette supplying the muted (dim gray) role.
|
||||
* @param colorEnabled - Whether ANSI is emitted at all.
|
||||
* @param truecolor - Whether the terminal accepts 24-bit foreground codes.
|
||||
* @param opacity - Brightness fraction in [0, 1] for the truecolor gray.
|
||||
* @param visible - Whether the non-truecolor fallback shows the glyph at all.
|
||||
* @returns The gray glyph at this opacity, or a single space when hidden.
|
||||
*/
|
||||
export function fadeGlyph(
|
||||
glyph: string,
|
||||
palette: Palette,
|
||||
colorEnabled: boolean,
|
||||
truecolor: boolean,
|
||||
opacity: number,
|
||||
visible: boolean,
|
||||
): string {
|
||||
if (truecolor && colorEnabled) {
|
||||
const o = Math.min(Math.max(opacity, 0), 1)
|
||||
const [tr, tg, tb] = STATUS_FADE_GRAY.trough
|
||||
const [sr, sg, sb] = STATUS_FADE_GRAY.settled
|
||||
const r = Math.round(tr + (sr - tr) * o)
|
||||
const g = Math.round(tg + (sg - tg) * o)
|
||||
const b = Math.round(tb + (sb - tb) * o)
|
||||
return `\x1b[38;2;${r};${g};${b}m${glyph}\x1b[39m`
|
||||
}
|
||||
if (!visible) return ' '
|
||||
return colorEnabled ? palette.muted(glyph) : glyph
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a non-negative elapsed span at 100 ms resolution.
|
||||
* @param elapsedMs - Elapsed milliseconds.
|
||||
* @returns The formatted duration (e.g. `1.5s`, `2m03.4s`).
|
||||
*/
|
||||
export function formatStatusDuration(elapsedMs: number): string {
|
||||
const tenths = Math.floor(Math.max(0, elapsedMs) / 100)
|
||||
const seconds = tenths / 10
|
||||
if (seconds < 60) return `${seconds.toFixed(1)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return `${minutes}m${(seconds - minutes * 60).toFixed(1).padStart(4, '0')}s`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the non-zero timing buckets of one step as a middot-joined summary.
|
||||
* @param totals - Per-phase totals to format.
|
||||
* @param includeModelWait - Whether to always include the model-wait bucket.
|
||||
* @returns The formatted timing summary.
|
||||
*/
|
||||
export function formatTimingTotals(totals: TimingTotals, includeModelWait = false): string {
|
||||
return TIMING_BUCKETS
|
||||
.filter(bucket => totals[bucket] > 0 || (includeModelWait && bucket === 'ttft'))
|
||||
.map(bucket => `${TIMING_BUCKET_LABELS[bucket]} ${formatStatusDuration(totals[bucket])}`)
|
||||
.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the queued-steering badge shown on the running status line.
|
||||
* @param queued - Number of queued steering messages.
|
||||
* @returns The badge text, or `undefined` when nothing is queued.
|
||||
*/
|
||||
export function formatQueuedStatus(queued: number): string | undefined {
|
||||
return queued > 0 ? `${queued} queued` : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a completion timestamp as `YYYY-MM-DD HH:MM:SS` in local time.
|
||||
* @param time - Epoch milliseconds.
|
||||
* @returns The formatted local timestamp.
|
||||
*/
|
||||
export function formatCompletionTime(time: number): string {
|
||||
const date = new Date(time)
|
||||
const parts = [
|
||||
date.getFullYear().toString().padStart(4, '0'),
|
||||
(date.getMonth() + 1).toString().padStart(2, '0'),
|
||||
date.getDate().toString().padStart(2, '0'),
|
||||
]
|
||||
const clock = [date.getHours(), date.getMinutes(), date.getSeconds()]
|
||||
.map(value => value.toString().padStart(2, '0'))
|
||||
.join(':')
|
||||
return `${parts.join('-')} ${clock}`
|
||||
}
|
||||
96
packages/ui/tui/src/chat/tokens.ts
Normal file
96
packages/ui/tui/src/chat/tokens.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Running token accounting for the terminal footer. Usage is keyed per
|
||||
* turn/step so replayed or re-emitted usage replaces rather than double-counts.
|
||||
* @module @deepseek-ai/dsh-tui/chat/tokens
|
||||
*/
|
||||
|
||||
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Running token totals for the footer, keyed per turn/step so replayed or
|
||||
* re-emitted usage replaces rather than double-counts; `input` is uncached
|
||||
* input, cache buckets are disjoint.
|
||||
*/
|
||||
export interface SessionTokenTotals {
|
||||
input: number
|
||||
output: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
readonly byStep: Map<string, TokenUsage>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one step's usage into the running totals, replacing any prior usage
|
||||
* logged for the same turn/step.
|
||||
* @param totals - Running totals mutated in place.
|
||||
* @param turn - Turn index of the usage.
|
||||
* @param step - Step index of the usage.
|
||||
* @param usage - The step's token usage.
|
||||
*/
|
||||
export function recordTokenUsage(totals: SessionTokenTotals, turn: number, step: number, usage: TokenUsage): void {
|
||||
const key = `${turn}:${step}`
|
||||
const previous = totals.byStep.get(key)
|
||||
if (previous !== undefined) {
|
||||
totals.input -= previous.inputTokens
|
||||
totals.output -= previous.outputTokens
|
||||
totals.cacheRead -= previous.cacheReadTokens ?? 0
|
||||
totals.cacheWrite -= previous.cacheWriteTokens ?? 0
|
||||
}
|
||||
totals.byStep.set(key, usage)
|
||||
totals.input += usage.inputTokens
|
||||
totals.output += usage.outputTokens
|
||||
totals.cacheRead += usage.cacheReadTokens ?? 0
|
||||
totals.cacheWrite += usage.cacheWriteTokens ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a usage-bearing session event into the running totals.
|
||||
* @param totals - Running totals mutated in place.
|
||||
* @param event - Session event; ignored when it carries no usage.
|
||||
*/
|
||||
export function recordEventUsage(totals: SessionTokenTotals, event: SessionEvent): void {
|
||||
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
|
||||
recordTokenUsage(totals, event.data.turn, event.data.step, event.data.chunk.usage)
|
||||
} else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
|
||||
recordTokenUsage(totals, event.data.turn, event.data.step, event.data.usage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Share of billed input (prompt) tokens served from the provider cache, as an
|
||||
* integer percent, or `undefined` before any input is billed (avoids 0/0 and a
|
||||
* meaningless rate on an empty session).
|
||||
* @param totals - Running totals to measure.
|
||||
* @returns The cache hit rate percent, or `undefined` when no input is billed.
|
||||
*/
|
||||
export function cacheHitRate(totals: SessionTokenTotals): number | undefined {
|
||||
const billedInput = totals.input + totals.cacheRead + totals.cacheWrite
|
||||
if (billedInput === 0) return undefined
|
||||
return Math.round((totals.cacheRead / billedInput) * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold every usage-bearing event in a session into fresh totals.
|
||||
* @param session - Session whose events supply usage.
|
||||
* @returns The accumulated token totals.
|
||||
*/
|
||||
export function sessionTokens(session: Session): SessionTokenTotals {
|
||||
const totals: SessionTokenTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byStep: new Map() }
|
||||
for (const event of session.events) {
|
||||
recordEventUsage(totals, event)
|
||||
}
|
||||
return totals
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a token count with a compact k/m suffix for the footer.
|
||||
* @param value - Token count.
|
||||
* @returns The compact display string.
|
||||
*/
|
||||
export function formatTokens(value: number): string {
|
||||
if (value < 1_000) return String(value)
|
||||
if (value < 10_000) return `${(value / 1_000).toFixed(1)}k`
|
||||
if (value < 1_000_000) return `${Math.round(value / 1_000)}k`
|
||||
return `${(value / 1_000_000).toFixed(1)}m`
|
||||
}
|
||||
56
packages/ui/tui/src/components/content.ts
Normal file
56
packages/ui/tui/src/components/content.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Content-block primitives shared across the terminal front door: flattening
|
||||
* session content to display text and parsing tool-call arguments.
|
||||
* @module @deepseek-ai/dsh-tui/components/content
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/**
|
||||
* Flatten content blocks into a single display string, recursing into
|
||||
* tool-result content and naming unknown block types.
|
||||
* @param content - Content blocks to flatten.
|
||||
* @returns The concatenated display text.
|
||||
*/
|
||||
export function contentText(content: readonly ContentBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
parts.push(block.text)
|
||||
break
|
||||
case 'tool-call':
|
||||
parts.push(`${block.name}(${block.arguments})`)
|
||||
break
|
||||
case 'tool-result':
|
||||
parts.push(contentText(block.content))
|
||||
break
|
||||
default: {
|
||||
const rawType = (block as { type?: unknown }).type
|
||||
parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** A tool call's arguments parsed from their JSON source, with a validity flag. */
|
||||
export interface ParsedArguments {
|
||||
value: unknown
|
||||
valid: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tool-call arguments from their JSON source.
|
||||
* @param raw - Raw JSON arguments text.
|
||||
* @returns The parsed value, or the raw text with `valid: false` on parse failure.
|
||||
*/
|
||||
export function parseArguments(raw: string): ParsedArguments {
|
||||
try {
|
||||
return { value: JSON.parse(raw), valid: true }
|
||||
} catch {
|
||||
return { value: raw, valid: false }
|
||||
}
|
||||
}
|
||||
789
packages/ui/tui/src/components/dialogs.ts
Normal file
789
packages/ui/tui/src/components/dialogs.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
/**
|
||||
* pi-tui dialog and selector components for the terminal front door: the status
|
||||
* card, prompt-context line, model selector, resume picker, and user-question
|
||||
* dialog, plus the model-choice and resume-candidate data they present.
|
||||
* @module @deepseek-ai/dsh-tui/components/dialogs
|
||||
*/
|
||||
|
||||
import {
|
||||
Input,
|
||||
Key,
|
||||
SelectList,
|
||||
matchesKey,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
type Component,
|
||||
type Focusable,
|
||||
type SelectItem,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Context } from 'cordis'
|
||||
import {
|
||||
type Agent,
|
||||
type AgentLlmTarget,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionLogSnapshot,
|
||||
SessionRecord,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
|
||||
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
|
||||
import { dialogSelectTheme, type Palette } from './theme.ts'
|
||||
import {
|
||||
renderTuiPromptTemplate,
|
||||
type TuiPromptTemplateToken,
|
||||
} from '../prompt.ts'
|
||||
|
||||
/** A selectable model advertised by a provider, with its display name, description, and reasoning metadata. */
|
||||
export interface ModelChoice extends AgentLlmTarget {
|
||||
modelName: string
|
||||
description?: string
|
||||
reasoning?: LlmModelReasoningInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider/model route and selected reasoning effort resolved from a model dialog.
|
||||
*/
|
||||
export interface ModelDialogSelection {
|
||||
choice: ModelChoice
|
||||
reasoningEffort: ReasoningEffortId | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a provider/model target as its `provider/model` label.
|
||||
* @param target - The LLM target.
|
||||
* @returns The `provider/model` label.
|
||||
*/
|
||||
export function targetLabel(target: AgentLlmTarget): string {
|
||||
return `${target.provider}/${target.model}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a target compactly as its model name with any selected reasoning effort appended.
|
||||
* @param target - The LLM target.
|
||||
* @returns The compact `model [effort]` label.
|
||||
*/
|
||||
export function compactTargetLabel(target: AgentLlmTarget): string {
|
||||
return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the display label for a choice's reasoning effort.
|
||||
* @param choice - The model choice carrying advertised reasoning metadata.
|
||||
* @param effort - The selected effort, or `undefined` for provider default.
|
||||
* @returns The effort's display name, `provider default`, or `undefined` when the model has no reasoning metadata.
|
||||
*/
|
||||
export function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined {
|
||||
if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default'
|
||||
return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the agent's initial LLM target from its logged request header or options.
|
||||
* @param agent - The driven agent.
|
||||
* @returns The initial target, or `undefined` when unset.
|
||||
*/
|
||||
export function initialTarget(agent: Agent): AgentLlmTarget | undefined {
|
||||
const logged = agent.session.requestHeader()?.config
|
||||
if (logged !== undefined) {
|
||||
if (logged.reasoningEffort === undefined) {
|
||||
return { provider: logged.provider, model: logged.model }
|
||||
}
|
||||
return { provider: logged.provider, model: logged.model, reasoningEffort: logged.reasoningEffort }
|
||||
}
|
||||
if (agent.options.provider === undefined || agent.options.model === undefined) return undefined
|
||||
return { provider: agent.options.provider, model: agent.options.model }
|
||||
}
|
||||
|
||||
/**
|
||||
* List every advertised model across registered providers, appending the current
|
||||
* target when a provider does not advertise it.
|
||||
* @param ctx - Context supplying the LLM service.
|
||||
* @param current - The current target, appended when unadvertised.
|
||||
* @returns The model choices, flattened across providers.
|
||||
*/
|
||||
export async function readModelChoices(
|
||||
ctx: Context,
|
||||
current: AgentLlmTarget | undefined,
|
||||
): Promise<ModelChoice[]> {
|
||||
const providers = ctx.llm.listProviders()
|
||||
const groups = await Promise.all(providers.map(async (provider) => {
|
||||
const advertised = await ctx.llm.listModels(provider.id)
|
||||
const models: LlmModelInfo[] = [...advertised]
|
||||
if (
|
||||
current?.provider === provider.id
|
||||
&& !models.some(model => model.id === current.model)
|
||||
) {
|
||||
models.push({ provider: provider.id, id: current.model, name: current.model })
|
||||
}
|
||||
return Promise.all(models.map(async (model): Promise<ModelChoice> => {
|
||||
const reasoning = (await ctx.llm.resolveModelInfo(provider.id, model.id)).reasoning
|
||||
return {
|
||||
provider: provider.id,
|
||||
model: model.id,
|
||||
modelName: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
...reasoning === undefined ? {} : { reasoning },
|
||||
}
|
||||
}))
|
||||
}))
|
||||
return groups.flat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a diagnostic integer with grouping separators.
|
||||
* @param value - Integer to format.
|
||||
* @returns The grouped decimal string.
|
||||
*/
|
||||
export function formatDiagnosticNumber(value: number): string {
|
||||
return value.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a diagnostic timestamp as an ISO date-time in UTC.
|
||||
* @param value - Epoch milliseconds.
|
||||
* @returns The formatted UTC timestamp.
|
||||
*/
|
||||
export function formatDiagnosticTime(value: number): string {
|
||||
return new Date(value).toISOString().replace('T', ' ').replace(/\.\d{3}Z$/u, ' UTC')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a pluralized count for a diagnostic row.
|
||||
* @param value - Count.
|
||||
* @param singular - Singular noun; an `s` is appended for other counts.
|
||||
* @returns The formatted count.
|
||||
*/
|
||||
export function formatDiagnosticCount(value: number, singular: string): string {
|
||||
return `${String(value)} ${singular}${value === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a fixed-width filled meter bar for a percentage.
|
||||
* @param percent - Percentage in [0, 100].
|
||||
* @param palette - Active role palette.
|
||||
* @returns The rendered meter.
|
||||
*/
|
||||
export function diagnosticMeter(percent: number, palette: Palette): string {
|
||||
const width = 16
|
||||
const filled = Math.round(Math.min(100, Math.max(0, percent)) / 100 * width)
|
||||
return `${palette.dim('[')}${palette.accent('█'.repeat(filled))}${palette.dim(`${'░'.repeat(width - filled)}]`)}`
|
||||
}
|
||||
|
||||
/** One `label: value` row of a status card group. */
|
||||
export type StatusCardRow = readonly [label: string, value: string]
|
||||
|
||||
/** Bordered, grouped field card for one point-in-time status snapshot. */
|
||||
export class StatusCardComponent implements Component {
|
||||
constructor(
|
||||
private readonly groups: readonly (readonly StatusCardRow[])[],
|
||||
private readonly palette: Palette,
|
||||
) {}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const labels = this.groups.flatMap(group => group.map(([label]) => `${label}:`))
|
||||
const naturalLabelWidth = Math.max(...labels.map(label => label.length))
|
||||
const naturalBodyWidth = Math.max(...this.groups.flatMap(group => group.map(([, value]) =>
|
||||
1 + naturalLabelWidth + 2 + visibleWidth(value))))
|
||||
const cardWidth = Math.min(
|
||||
Math.max(8, width),
|
||||
Math.max('Session status'.length + 5, naturalBodyWidth + 4),
|
||||
)
|
||||
const innerWidth = Math.max(1, cardWidth - 4)
|
||||
const labelWidth = Math.min(
|
||||
naturalLabelWidth,
|
||||
Math.max(1, Math.floor(innerWidth / 3)),
|
||||
)
|
||||
const body: string[] = []
|
||||
for (const [groupIndex, group] of this.groups.entries()) {
|
||||
if (groupIndex > 0) body.push('')
|
||||
for (const [label, value] of group) {
|
||||
const plainLabel = truncateToWidth(`${label}:`, labelWidth, '')
|
||||
const prefix = ` ${this.palette.muted(plainLabel.padEnd(labelWidth))} `
|
||||
const continuation = ' '.repeat(1 + labelWidth + 2)
|
||||
const valueWidth = Math.max(1, innerWidth - visibleWidth(prefix))
|
||||
const wrapped = wrapTextWithAnsi(value, valueWidth)
|
||||
for (const [lineIndex, line] of wrapped.entries()) {
|
||||
body.push(`${lineIndex === 0 ? prefix : continuation}${line}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const title = truncateToWidth('Session status', Math.max(1, cardWidth - 5), '')
|
||||
const topTail = '─'.repeat(Math.max(0, cardWidth - visibleWidth(title) - 5))
|
||||
const top = `${this.palette.dim('╭─ ')}${this.palette.bold(this.palette.accent(title))}${this.palette.dim(` ${topTail}╮`)}`
|
||||
const lines = [top]
|
||||
for (const line of body) {
|
||||
const clipped = truncateToWidth(line, innerWidth, '')
|
||||
lines.push(`${this.palette.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.dim('│')}`)
|
||||
}
|
||||
lines.push(this.palette.dim(`╰${'─'.repeat(Math.max(0, cardWidth - 2))}╯`))
|
||||
return lines
|
||||
}
|
||||
}
|
||||
|
||||
/** The left/right template line rendered above the editor. */
|
||||
export class PromptContextComponent implements Component {
|
||||
constructor(
|
||||
private readonly leftTemplate: readonly TuiPromptTemplateToken[],
|
||||
private readonly rightTemplate: readonly TuiPromptTemplateToken[],
|
||||
private readonly resolve: (name: string) => string | undefined,
|
||||
) {}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const right = truncateToWidth(renderTuiPromptTemplate(this.rightTemplate, this.resolve), width, '')
|
||||
const rightWidth = visibleWidth(right)
|
||||
const leftCapacity = Math.max(0, width - rightWidth - (rightWidth === 0 ? 0 : 2))
|
||||
const left = truncateToWidth(renderTuiPromptTemplate(this.leftTemplate, this.resolve), leftCapacity, '')
|
||||
if (rightWidth === 0) return [left]
|
||||
const gap = ' '.repeat(Math.max(0, width - visibleWidth(left) - rightWidth))
|
||||
return [`${left}${gap}${right}`]
|
||||
}
|
||||
}
|
||||
|
||||
/** A user's answer to one question: chosen option labels and an optional custom answer. */
|
||||
export interface QuestionSelection {
|
||||
selected: string[]
|
||||
custom?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a bordered dialog frame around body lines with a titled top edge.
|
||||
* @param title - Dialog title shown in the top border.
|
||||
* @param body - Body lines.
|
||||
* @param width - Dialog width in columns.
|
||||
* @param palette - Active role palette.
|
||||
* @returns The framed dialog lines.
|
||||
*/
|
||||
export function renderDialog(
|
||||
title: string,
|
||||
body: readonly string[],
|
||||
width: number,
|
||||
palette: Palette,
|
||||
): string[] {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
const topLabel = ` ${displayText(title)} `
|
||||
const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮`
|
||||
const lines: string[] = [palette.accent(top)]
|
||||
for (const line of body) {
|
||||
const clipped = truncateToWidth(line, innerWidth, '')
|
||||
lines.push(`${palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${palette.accent('│')}`)
|
||||
}
|
||||
lines.push(palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`))
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Keyboard model selector rendered as a bordered overlay, with per-model reasoning-effort cycling. */
|
||||
export class ModelDialog implements Component {
|
||||
private readonly list: SelectList
|
||||
private readonly items: Map<string, SelectItem>
|
||||
private readonly choices: Map<string, ModelChoice>
|
||||
private readonly efforts: Map<string, ReasoningEffortId | undefined>
|
||||
private readonly currentValue: string | undefined
|
||||
|
||||
constructor(
|
||||
choices: readonly ModelChoice[],
|
||||
current: AgentLlmTarget | undefined,
|
||||
maxVisible: number,
|
||||
private readonly palette: Palette,
|
||||
done: (selection: ModelDialogSelection) => void,
|
||||
cancel: () => void,
|
||||
) {
|
||||
this.items = new Map()
|
||||
this.choices = new Map()
|
||||
this.efforts = new Map()
|
||||
this.currentValue = current === undefined ? undefined : targetLabel(current)
|
||||
for (const choice of choices) {
|
||||
const value = targetLabel(choice)
|
||||
const isCurrent = current?.provider === choice.provider && current.model === choice.model
|
||||
this.choices.set(value, choice)
|
||||
this.efforts.set(
|
||||
value,
|
||||
isCurrent
|
||||
? current.reasoningEffort ?? choice.reasoning?.defaultEffort
|
||||
: choice.reasoning?.defaultEffort,
|
||||
)
|
||||
this.items.set(value, {
|
||||
value,
|
||||
label: displayText(value),
|
||||
description: this.describeChoice(choice, isCurrent),
|
||||
})
|
||||
}
|
||||
this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette))
|
||||
const currentIndex = current === undefined
|
||||
? 0
|
||||
: choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model)
|
||||
this.list.setSelectedIndex(currentIndex)
|
||||
this.list.onSelect = (item) => {
|
||||
const selected = choices.find(choice => targetLabel(choice) === item.value)
|
||||
/* v8 ignore next -- SelectList only returns values built from `choices`. */
|
||||
if (selected === undefined) return
|
||||
done({ choice: selected, reasoningEffort: this.efforts.get(item.value) })
|
||||
}
|
||||
this.list.onCancel = cancel
|
||||
}
|
||||
|
||||
private describeChoice(choice: ModelChoice, isCurrent: boolean): string {
|
||||
const effortLabel = targetReasoningLabel(choice, this.efforts.get(targetLabel(choice)))
|
||||
return [
|
||||
displayText(choice.modelName),
|
||||
...choice.description === undefined ? [] : [displayText(choice.description)],
|
||||
...effortLabel === undefined ? [] : [displayText(effortLabel)],
|
||||
...isCurrent ? ['current'] : [],
|
||||
].join(' — ')
|
||||
}
|
||||
|
||||
private cycleReasoningEffort(): void {
|
||||
const selectedItem = this.list.getSelectedItem()
|
||||
/* v8 ignore next -- the dialog is opened only for a non-empty catalog. */
|
||||
if (selectedItem === null) return
|
||||
const choice = this.choices.get(selectedItem.value)
|
||||
if (choice?.reasoning === undefined) return
|
||||
const current = this.efforts.get(selectedItem.value)
|
||||
const efforts: Array<ReasoningEffortId | undefined> = [
|
||||
...choice.reasoning.defaultEffort === undefined ? [undefined] : [],
|
||||
...choice.reasoning.efforts.map(effort => effort.id),
|
||||
]
|
||||
const currentIndex = efforts.indexOf(current)
|
||||
const next = efforts[(currentIndex + 1) % efforts.length]
|
||||
this.efforts.set(selectedItem.value, next)
|
||||
const item = this.items.get(selectedItem.value)
|
||||
/* v8 ignore next -- items and choices are constructed from the same values. */
|
||||
if (item === undefined) return
|
||||
item.description = this.describeChoice(choice, selectedItem.value === this.currentValue)
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.list.invalidate()
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (matchesKey(data, Key.shift(Key.tab))) {
|
||||
this.cycleReasoningEffort()
|
||||
} else {
|
||||
this.list.handleInput(data)
|
||||
}
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
return renderDialog('Select model', [
|
||||
...this.list.render(innerWidth),
|
||||
'',
|
||||
this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'),
|
||||
], width, this.palette)
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider/model route recovered from a resume candidate's log. */
|
||||
export interface ResumeRoute {
|
||||
provider: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/** A preflighted resume selector row summarizing one persisted session. */
|
||||
export interface ResumeCandidate {
|
||||
record: SessionRecord
|
||||
title: string
|
||||
lastActivityAt: number
|
||||
lastTurn: string
|
||||
route?: ResumeRoute
|
||||
goalPhase?: GoalPhase
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
function resumeTurnLabel(snapshot: SessionLogSnapshot): string {
|
||||
const event = snapshot.events.findLast(item => item.type === 'turn/end')
|
||||
if (event === undefined) return 'no completed turn'
|
||||
const reason = event.data.reason
|
||||
switch (reason.kind) {
|
||||
case 'completed': return `turn ${event.data.turn}: completed`
|
||||
case 'aborted': return `turn ${event.data.turn}: cancelled`
|
||||
case 'error': return `turn ${event.data.turn}: error`
|
||||
case 'disposed': return `turn ${event.data.turn}: disposed`
|
||||
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
|
||||
case 'interrupted': return `turn ${event.data.turn}: interrupted`
|
||||
default: return `turn ${event.data.turn}: unknown result`
|
||||
}
|
||||
}
|
||||
|
||||
function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined {
|
||||
const header = snapshot.events.findLast(item => item.type === 'request/header')
|
||||
if (header?.type === 'request/header') {
|
||||
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
|
||||
}
|
||||
const assistant = snapshot.events.findLast(item => item.type === 'assistant/message')
|
||||
return assistant?.type === 'assistant/message'
|
||||
? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model }
|
||||
: undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one resume selector row from a record and its log snapshot, deriving the
|
||||
* title, route, goal phase, and any reason the session cannot be resumed here.
|
||||
* @param record - The session record.
|
||||
* @param snapshot - The session's log snapshot.
|
||||
* @param currentId - The current session id.
|
||||
* @param cwd - The current workspace directory.
|
||||
* @param availableProviders - Providers registered in this runtime.
|
||||
* @returns The summarized resume candidate.
|
||||
*/
|
||||
export function summarizeResumeCandidate(
|
||||
record: SessionRecord,
|
||||
snapshot: SessionLogSnapshot,
|
||||
currentId: SessionId,
|
||||
cwd: string | undefined,
|
||||
availableProviders: ReadonlySet<string>,
|
||||
): ResumeCandidate {
|
||||
const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session'
|
||||
const route = resumeRoute(snapshot)
|
||||
const foldedGoal = foldGoal(snapshot.events).goal
|
||||
let disabledReason: string | undefined
|
||||
if (record.header.id === currentId) disabledReason = 'current session'
|
||||
else if (record.live) disabledReason = 'session is already live in this runtime'
|
||||
else if (record.header.cwd !== cwd) disabledReason = 'different workspace'
|
||||
else if (route !== undefined && !availableProviders.has(route.provider)) {
|
||||
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
|
||||
}
|
||||
return {
|
||||
record,
|
||||
title,
|
||||
lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt,
|
||||
lastTurn: resumeTurnLabel(snapshot),
|
||||
...route === undefined ? {} : { route },
|
||||
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
|
||||
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
|
||||
...disabledReason === undefined ? {} : { disabledReason },
|
||||
}
|
||||
}
|
||||
|
||||
/** Full-viewport keyboard selector over detached, preflighted resume summaries. */
|
||||
export class ResumePicker implements Component, Focusable {
|
||||
private readonly search = new Input()
|
||||
private pasteBuffer: string | undefined
|
||||
private selectedIndex = 0
|
||||
private error = ''
|
||||
focused = false
|
||||
|
||||
constructor(
|
||||
private readonly candidates: readonly ResumeCandidate[],
|
||||
private readonly maxVisible: number,
|
||||
private readonly workspaceLabel: string,
|
||||
private readonly viewportRows: () => number,
|
||||
private readonly palette: Palette,
|
||||
private readonly done: (candidate: ResumeCandidate) => void,
|
||||
private readonly cancel: () => void,
|
||||
) {}
|
||||
|
||||
invalidate(): void {
|
||||
this.search.invalidate()
|
||||
}
|
||||
|
||||
private filtered(): ResumeCandidate[] {
|
||||
const query = this.search.getValue().trim().toLocaleLowerCase()
|
||||
if (query === '') return [...this.candidates]
|
||||
return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query)
|
||||
|| candidate.record.header.id.toLocaleLowerCase().includes(query))
|
||||
}
|
||||
|
||||
private visibleCandidateCount(): number {
|
||||
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4))
|
||||
return Math.min(this.maxVisible, candidateBudget)
|
||||
}
|
||||
|
||||
private handleBracketedPaste(data: string): boolean {
|
||||
const start = data.indexOf(BRACKETED_PASTE_START)
|
||||
if (this.pasteBuffer === undefined && start < 0) return false
|
||||
if (this.pasteBuffer === undefined) {
|
||||
const prefix = data.slice(0, start)
|
||||
if (prefix !== '') this.handleInput(prefix)
|
||||
this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length)
|
||||
} else {
|
||||
this.pasteBuffer += data
|
||||
}
|
||||
const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END)
|
||||
if (end < 0) return true
|
||||
const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end))
|
||||
const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length)
|
||||
this.pasteBuffer = undefined
|
||||
const previous = this.search.getValue()
|
||||
this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`)
|
||||
if (this.search.getValue() !== previous) {
|
||||
this.selectedIndex = 0
|
||||
this.error = ''
|
||||
}
|
||||
if (remaining !== '') this.handleInput(remaining)
|
||||
this.invalidate()
|
||||
return true
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (this.handleBracketedPaste(data)) return
|
||||
const filtered = this.filtered()
|
||||
if (matchesKey(data, Key.ctrl('c'))) {
|
||||
this.cancel()
|
||||
return
|
||||
}
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
if (this.search.getValue() === '') this.cancel()
|
||||
else {
|
||||
this.search.setValue('')
|
||||
this.selectedIndex = 0
|
||||
this.error = ''
|
||||
}
|
||||
} else if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = filtered.length === 0
|
||||
? 0
|
||||
: (this.selectedIndex + filtered.length - 1) % filtered.length
|
||||
} else if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length
|
||||
} else if (matchesKey(data, Key.pageUp)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount())
|
||||
} else if (matchesKey(data, Key.pageDown)) {
|
||||
this.selectedIndex = Math.min(
|
||||
Math.max(0, filtered.length - 1),
|
||||
this.selectedIndex + this.visibleCandidateCount(),
|
||||
)
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
const selected = filtered[this.selectedIndex]
|
||||
if (selected === undefined) this.error = 'No session matches this search.'
|
||||
else if (selected.disabledReason !== undefined) this.error = selected.disabledReason
|
||||
else this.done(selected)
|
||||
} else {
|
||||
const previous = this.search.getValue()
|
||||
this.search.focused = this.focused
|
||||
this.search.handleInput(data)
|
||||
if (this.search.getValue() !== previous) {
|
||||
this.selectedIndex = 0
|
||||
this.error = ''
|
||||
}
|
||||
}
|
||||
this.invalidate()
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
this.search.focused = this.focused
|
||||
const height = Math.max(1, this.viewportRows())
|
||||
const horizontalPadding = width >= 12 ? 2 : 0
|
||||
const contentWidth = Math.max(1, width - horizontalPadding * 2)
|
||||
const indent = ' '.repeat(horizontalPadding)
|
||||
const filtered = this.filtered()
|
||||
if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1)
|
||||
const selected = filtered[this.selectedIndex]
|
||||
const position = selected === undefined ? 0 : this.selectedIndex + 1
|
||||
const lines: string[] = [
|
||||
'',
|
||||
`${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`,
|
||||
'',
|
||||
]
|
||||
|
||||
const searchInnerWidth = Math.max(1, contentWidth - 4)
|
||||
lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`)
|
||||
const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ')
|
||||
const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '')
|
||||
lines.push(
|
||||
`${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`,
|
||||
`${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`,
|
||||
'',
|
||||
`${indent}${this.palette.muted(displayText(this.workspaceLabel))}`,
|
||||
'',
|
||||
)
|
||||
|
||||
const visibleCount = this.visibleCandidateCount()
|
||||
const start = Math.max(0, Math.min(
|
||||
this.selectedIndex - Math.floor(visibleCount / 2),
|
||||
filtered.length - visibleCount,
|
||||
))
|
||||
const end = Math.min(filtered.length, start + visibleCount)
|
||||
const push = (line: string): void => {
|
||||
lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`)
|
||||
}
|
||||
for (let index = start; index < end; index += 1) {
|
||||
const candidate = filtered[index] as ResumeCandidate
|
||||
const active = index === this.selectedIndex
|
||||
const status = [
|
||||
candidate.disabledReason === 'current session' ? 'current' : undefined,
|
||||
candidate.record.live ? 'live' : undefined,
|
||||
candidate.record.persisted ? 'persisted' : undefined,
|
||||
].filter((value): value is string => value !== undefined).join(' · ')
|
||||
const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}`
|
||||
push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
|
||||
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
|
||||
/* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */
|
||||
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
|
||||
push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
|
||||
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
|
||||
if (candidate.disabledReason !== undefined) {
|
||||
push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`))
|
||||
}
|
||||
}
|
||||
if (filtered.length === 0) push(this.palette.warning('No matching sessions.'))
|
||||
if (this.error !== '') {
|
||||
lines.push('')
|
||||
push(this.palette.error(displayText(this.error)))
|
||||
}
|
||||
|
||||
const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}`
|
||||
while (lines.length < height - 2) lines.push('')
|
||||
lines.push(footer, '')
|
||||
return lines.slice(0, height)
|
||||
}
|
||||
}
|
||||
|
||||
/** Bottom-anchored dialog for one user question with option or custom-answer modes. */
|
||||
export class QuestionDialog implements Component, Focusable {
|
||||
private selectedIndex = 0
|
||||
private selected = new Set<number>()
|
||||
private mode: 'options' | 'custom'
|
||||
private error = ''
|
||||
private readonly input = new Input()
|
||||
private readonly options: NonNullable<AskUserQuestionItem['options']>
|
||||
focused = false
|
||||
|
||||
constructor(
|
||||
private readonly question: AskUserQuestionItem,
|
||||
private readonly position: number,
|
||||
private readonly total: number,
|
||||
private readonly unanswered: number,
|
||||
private readonly maxVisible: number,
|
||||
private readonly palette: Palette,
|
||||
private readonly done: (selection: QuestionSelection) => void,
|
||||
private readonly cancel: () => void,
|
||||
) {
|
||||
this.options = question.options ?? []
|
||||
this.mode = this.options.length > 0 ? 'options' : 'custom'
|
||||
this.input.onSubmit = (value) => { this.submitCustom(value) }
|
||||
this.input.onEscape = () => {
|
||||
if (this.options.length > 0) {
|
||||
this.mode = 'options'
|
||||
this.error = ''
|
||||
} else {
|
||||
this.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.input.invalidate()
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
this.invalidate()
|
||||
if (this.mode === 'custom') {
|
||||
this.input.focused = this.focused
|
||||
this.input.handleInput(data)
|
||||
return
|
||||
}
|
||||
const options = this.options
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1
|
||||
} else if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1
|
||||
} else if (matchesKey(data, Key.space) && this.question.multiSelect) {
|
||||
if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex)
|
||||
else this.selected.add(this.selectedIndex)
|
||||
} else if (matchesKey(data, Key.enter)) {
|
||||
const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex]
|
||||
if (indices.length === 0) {
|
||||
this.error = 'Select at least one option, or press Tab for a custom answer.'
|
||||
return
|
||||
}
|
||||
this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) })
|
||||
} else if (matchesKey(data, Key.tab) || data.toLowerCase() === 'c') {
|
||||
this.mode = 'custom'
|
||||
this.error = ''
|
||||
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
|
||||
this.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private submitCustom(value: string): void {
|
||||
const custom = value.trim()
|
||||
if (custom === '') {
|
||||
this.error = 'Enter an answer before submitting.'
|
||||
return
|
||||
}
|
||||
this.done({ selected: [], custom })
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
this.input.focused = this.focused
|
||||
const innerWidth = Math.max(1, width - 4)
|
||||
const header = `Question ${this.position}/${this.total} (${this.unanswered} unanswered)${this.question.header === undefined ? '' : ` · ${displayText(this.question.header)}`}`
|
||||
const lines = [
|
||||
this.palette.muted(header),
|
||||
...wrapTextWithAnsi(this.palette.text(displayText(this.question.question)), innerWidth),
|
||||
]
|
||||
const push = (line: string): void => { lines.push(line) }
|
||||
// Supporting detail (e.g. the full plan under review) renders between the
|
||||
// question and the answer surface, kept out of option labels.
|
||||
if (this.question.detail !== undefined) {
|
||||
push('')
|
||||
for (const line of wrapTextWithAnsi(displayText(this.question.detail), innerWidth)) push(line)
|
||||
}
|
||||
push('')
|
||||
if (this.mode === 'custom') {
|
||||
for (const line of this.input.render(innerWidth)) push(line)
|
||||
push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel'))
|
||||
} else {
|
||||
const options = this.options
|
||||
const start = Math.max(0, Math.min(
|
||||
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
||||
options.length - this.maxVisible,
|
||||
))
|
||||
const end = Math.min(options.length, start + this.maxVisible)
|
||||
const optionRows = options.slice(start, end).map((option, offset) => {
|
||||
const index = start + offset
|
||||
const mark = this.question.multiSelect
|
||||
? this.selected.has(index) ? '[x] ' : '[ ] '
|
||||
: ''
|
||||
return `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
|
||||
})
|
||||
const descriptionColumn = Math.min(
|
||||
Math.max(...optionRows.map(row => visibleWidth(row))) + 2,
|
||||
Math.max(1, Math.floor(innerWidth * 0.55)),
|
||||
)
|
||||
for (let index = start; index < end; index += 1) {
|
||||
// `index < end <= options.length`; the options array is borrowed immutably for this dialog.
|
||||
const option = options[index] as NonNullable<AskUserQuestionItem['options']>[number]
|
||||
const mark = this.question.multiSelect
|
||||
? this.selected.has(index) ? '[x] ' : '[ ] '
|
||||
: ''
|
||||
const left = `${index === this.selectedIndex ? '›' : ' '} ${index + 1}. ${mark}${displayText(option.label)}`
|
||||
const leftStyled = index === this.selectedIndex
|
||||
? this.palette.bold(this.palette.accent(left))
|
||||
: left
|
||||
const description = option.description === undefined
|
||||
? ''
|
||||
: `${' '.repeat(Math.max(1, descriptionColumn - visibleWidth(left)))}${this.palette.muted(displayText(option.description))}`
|
||||
push(`${leftStyled}${description}`)
|
||||
}
|
||||
if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`))
|
||||
const controls = [
|
||||
'Tab custom answer',
|
||||
...(options.length > 1 ? ['↑/↓ navigate'] : []),
|
||||
...(this.question.multiSelect ? ['Space toggle'] : []),
|
||||
'Enter submit',
|
||||
'Esc interrupt',
|
||||
]
|
||||
const hint = this.palette.dim(controls.join(' • '))
|
||||
for (const line of wrapTextWithAnsi(hint, innerWidth)) push(line)
|
||||
}
|
||||
if (this.error) {
|
||||
for (const line of wrapTextWithAnsi(this.palette.error(this.error), innerWidth)) push(line)
|
||||
}
|
||||
return ['', ...lines, ''].map((line) => {
|
||||
const clipped = truncateToWidth(line, innerWidth, '')
|
||||
return ` ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} `
|
||||
})
|
||||
}
|
||||
}
|
||||
49
packages/ui/tui/src/components/text.ts
Normal file
49
packages/ui/tui/src/components/text.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Terminal text sanitization shared across the pi-tui front door. External text
|
||||
* (model output, tool results, clipboard) is escaped or stripped of C0/C1
|
||||
* controls before the TUI adds its own application-owned ANSI.
|
||||
* @module @deepseek-ai/dsh-tui/components/text
|
||||
*/
|
||||
|
||||
const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu
|
||||
const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu
|
||||
const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu
|
||||
const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu
|
||||
|
||||
/** Bracketed-paste start marker emitted by terminals around pasted content. */
|
||||
export const BRACKETED_PASTE_START = '\u001B[200~'
|
||||
/** Bracketed-paste end marker emitted by terminals around pasted content. */
|
||||
export const BRACKETED_PASTE_END = '\u001B[201~'
|
||||
|
||||
/**
|
||||
* Escape external C0/C1 controls before pi-tui adds application-owned ANSI.
|
||||
* Line feeds remain structural so transcript and tool output retain their layout.
|
||||
* @param text - Untrusted text to render.
|
||||
* @returns The text with control characters escaped as `\xNN`.
|
||||
*/
|
||||
export function displayText(text: string): string {
|
||||
return text.replace(TERMINAL_CONTROL_PATTERN, control =>
|
||||
`\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape external controls for terminal fields that must remain on one line.
|
||||
* @param text - Untrusted text to render inline.
|
||||
* @returns The escaped text with newlines rendered as `\x0a`.
|
||||
*/
|
||||
export function displayInlineText(text: string): string {
|
||||
return displayText(text).replaceAll('\n', '\\x0a')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove terminal controls from clipboard text before an editable field stores it.
|
||||
* @param text - Raw pasted clipboard text.
|
||||
* @returns The text stripped of OSC, CSI, escape, and control sequences.
|
||||
*/
|
||||
export function sanitizePastedText(text: string): string {
|
||||
return text
|
||||
.replace(TERMINAL_OSC_PATTERN, '')
|
||||
.replace(TERMINAL_CSI_PATTERN, '')
|
||||
.replace(TERMINAL_ESCAPE_PATTERN, '')
|
||||
.replace(TERMINAL_CONTROL_PATTERN, '')
|
||||
}
|
||||
184
packages/ui/tui/src/components/theme.ts
Normal file
184
packages/ui/tui/src/components/theme.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Theme-agnostic ANSI palette and derived pi-tui themes for the terminal front
|
||||
* door. The palette is built from the standard 16-color ANSI set plus SGR
|
||||
* attributes so every terminal remaps it to its active color scheme.
|
||||
* @module @deepseek-ai/dsh-tui/components/theme
|
||||
*/
|
||||
|
||||
import type {
|
||||
MarkdownTheme,
|
||||
SelectListTheme,
|
||||
TerminalColorScheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
|
||||
/** Theme-agnostic role colors and SGR attribute wrappers. */
|
||||
export interface Palette {
|
||||
accent: (text: string) => string
|
||||
accent2: (text: string) => string
|
||||
text: (text: string) => string
|
||||
muted: (text: string) => string
|
||||
dim: (text: string) => string
|
||||
success: (text: string) => string
|
||||
warning: (text: string) => string
|
||||
error: (text: string) => string
|
||||
code: (text: string) => string
|
||||
added: (text: string) => string
|
||||
removed: (text: string) => string
|
||||
bold: (text: string) => string
|
||||
italic: (text: string) => string
|
||||
underline: (text: string) => string
|
||||
strike: (text: string) => string
|
||||
/** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */
|
||||
selected: (text: string) => string
|
||||
}
|
||||
|
||||
function ansi(open: string, close: string, enabled: boolean): (text: string) => string {
|
||||
return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme-agnostic palette built from the standard 16-color ANSI set plus SGR
|
||||
* attributes, which every terminal remaps to its active color scheme. Body
|
||||
* `text` stays the terminal's default foreground so it reads on light and dark
|
||||
* backgrounds alike; grouping uses foreground-only bold, underlined role
|
||||
* headers and reverse video rather than fixed background fills or per-line
|
||||
* prefixes, so a transcript drag-select copies message text without stray
|
||||
* glyphs.
|
||||
*
|
||||
* @param enabled - Whether ANSI is emitted at all.
|
||||
* @param scheme - Active terminal color scheme; adjusts dim and code roles.
|
||||
* @returns The role palette for the given scheme.
|
||||
*/
|
||||
export function createPalette(enabled: boolean, scheme: TerminalColorScheme = 'dark'): Palette {
|
||||
return {
|
||||
accent: ansi('94', '39', enabled),
|
||||
accent2: ansi('95', '39', enabled),
|
||||
text: text => text,
|
||||
muted: ansi('90', '39', enabled),
|
||||
// SGR 2 (dim) lightens text on a light background — substitute ANSI 90
|
||||
// (bright black / gray) which renders as a readable muted tone on any scheme.
|
||||
dim: scheme === 'light' ? ansi('90', '39', enabled) : ansi('2', '22', enabled),
|
||||
success: ansi('32', '39', enabled),
|
||||
warning: ansi('33', '39', enabled),
|
||||
error: ansi('31', '39', enabled),
|
||||
// ANSI 36 (cyan) is difficult to read on a light background — use
|
||||
// ANSI 34 (blue) which is legible on both light and dark schemes.
|
||||
code: scheme === 'light' ? ansi('34', '39', enabled) : ansi('36', '39', enabled),
|
||||
added: ansi('32', '39', enabled),
|
||||
removed: ansi('31', '39', enabled),
|
||||
bold: ansi('1', '22', enabled),
|
||||
italic: ansi('3', '23', enabled),
|
||||
underline: ansi('4', '24', enabled),
|
||||
strike: ansi('9', '29', enabled),
|
||||
selected: ansi('7', '27', enabled),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek brand gradient stops (indigo → light blue) taken from the
|
||||
* deepseek.com logo, painted across the startup banner's product name on
|
||||
* truecolor terminals. Fixed brand identity, deliberately outside the
|
||||
* theme-adaptive {@link Palette}.
|
||||
*/
|
||||
const BRAND_GRADIENT = [
|
||||
[77, 107, 254], // #4D6BFE
|
||||
[57, 130, 255], // #3982FF
|
||||
[36, 152, 255], // #2498FF
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Sample {@link BRAND_GRADIENT} at fraction `t` via piecewise-linear
|
||||
* interpolation across its stops.
|
||||
*
|
||||
* @param t - Position along the gradient; clamped to [0, 1].
|
||||
* @returns The interpolated `[r, g, b]` channels, each rounded to 0–255.
|
||||
*/
|
||||
function brandColorAt(t: number): readonly [number, number, number] {
|
||||
const span = Math.min(Math.max(t, 0), 1) * (BRAND_GRADIENT.length - 1)
|
||||
const index = Math.min(Math.floor(span), BRAND_GRADIENT.length - 2)
|
||||
const local = span - index
|
||||
// `index` is clamped to a valid adjacent pair, so both lookups are in-bounds.
|
||||
const from = BRAND_GRADIENT[index] as readonly [number, number, number]
|
||||
const to = BRAND_GRADIENT[index + 1] as readonly [number, number, number]
|
||||
return [
|
||||
Math.round(from[0] + (to[0] - from[0]) * local),
|
||||
Math.round(from[1] + (to[1] - from[1]) * local),
|
||||
Math.round(from[2] + (to[2] - from[2]) * local),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint `text` left-to-right in the DeepSeek brand gradient with per-character
|
||||
* 24-bit foreground codes, resetting to the default foreground at the end.
|
||||
* Foreground-only, so it stays legible on any terminal background; the caller
|
||||
* gates it on truecolor support and wraps it in bold.
|
||||
*
|
||||
* @param text - Text to colorize; sampled once per character.
|
||||
* @returns `text` wrapped in truecolor SGR foreground codes.
|
||||
*/
|
||||
export function gradientText(text: string): string {
|
||||
// The sole caller passes the ASCII product name, so UTF-16 unit iteration
|
||||
// samples exactly one color per visible letter.
|
||||
const last = Math.max(1, text.length - 1)
|
||||
let painted = ''
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const [r, g, b] = brandColorAt(index / last)
|
||||
painted += `\x1b[38;2;${r};${g};${b}m${text.charAt(index)}`
|
||||
}
|
||||
return `${painted}\x1b[39m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the pi-tui Markdown theme from a role palette.
|
||||
* @param palette - Active role palette.
|
||||
* @returns The Markdown theme wired to palette roles.
|
||||
*/
|
||||
export function markdownTheme(palette: Palette): MarkdownTheme {
|
||||
return {
|
||||
heading: text => palette.accent(text),
|
||||
link: text => palette.accent(text),
|
||||
// pi-tui requires this URL slot but its current Markdown renderer does not invoke it.
|
||||
/* v8 ignore next */
|
||||
linkUrl: text => palette.dim(text),
|
||||
code: text => palette.code(text),
|
||||
codeBlock: text => palette.code(text),
|
||||
// pi-tui presents both fence rows through this callback. Keep the opening
|
||||
// language label, but hide Markdown syntax and the otherwise-empty close.
|
||||
codeBlockBorder: text => palette.dim(text.slice(3)),
|
||||
quote: text => palette.muted(text),
|
||||
quoteBorder: text => palette.accent2(text),
|
||||
hr: text => palette.dim(text),
|
||||
listBullet: text => palette.accent(text),
|
||||
bold: text => palette.bold(text),
|
||||
italic: text => palette.italic(text),
|
||||
strikethrough: text => palette.strike(text),
|
||||
underline: text => palette.underline(text),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the pi-tui select-list theme from a role palette.
|
||||
* @param palette - Active role palette.
|
||||
* @returns The select-list theme wired to palette roles.
|
||||
*/
|
||||
export function selectTheme(palette: Palette): SelectListTheme {
|
||||
return {
|
||||
selectedPrefix: palette.accent,
|
||||
selectedText: palette.accent,
|
||||
description: palette.muted,
|
||||
scrollInfo: palette.dim,
|
||||
noMatch: palette.warning,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the reverse-video dialog select-list theme from a role palette.
|
||||
* @param palette - Active role palette.
|
||||
* @returns The dialog select-list theme with a reverse-video selection.
|
||||
*/
|
||||
export function dialogSelectTheme(palette: Palette): SelectListTheme {
|
||||
return {
|
||||
...selectTheme(palette),
|
||||
selectedText: text => palette.selected(palette.accent(text)),
|
||||
}
|
||||
}
|
||||
530
packages/ui/tui/src/components/transcript.ts
Normal file
530
packages/ui/tui/src/components/transcript.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* pi-tui transcript components: the startup banner, user/assistant messages,
|
||||
* per-step timing footer, streaming assistant buffer, tool cards, and the todo
|
||||
* panel. Each is a pure function of its inputs and the active palette.
|
||||
* @module @deepseek-ai/dsh-tui/components/transcript
|
||||
*/
|
||||
|
||||
import {
|
||||
Container,
|
||||
Markdown,
|
||||
Spacer,
|
||||
Text,
|
||||
truncateToWidth,
|
||||
wrapTextWithAnsi,
|
||||
type Component,
|
||||
type MarkdownTheme,
|
||||
} from '@earendil-works/pi-tui'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
TerminalCallView,
|
||||
ToolCallView,
|
||||
ToolDefinition,
|
||||
ToolResultView,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
import type { FileDiff } from '@deepseek-ai/dsh-tools'
|
||||
import { renderUnknownXml } from './xml-tool-output.ts'
|
||||
import { displayInlineText, displayText } from './text.ts'
|
||||
import { gradientText, type Palette } from './theme.ts'
|
||||
import { contentText, type ParsedArguments } from './content.ts'
|
||||
import {
|
||||
formatCompletionTime,
|
||||
formatTimingTotals,
|
||||
stepTimingAt,
|
||||
type StepPosition,
|
||||
} from '../chat/timing.ts'
|
||||
|
||||
/** Concatenate the text of every block of one type, separated by blank lines. */
|
||||
function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string {
|
||||
return content
|
||||
.filter((block): block is Extract<ContentBlock, { type: typeof type }> => block.type === type)
|
||||
.map(block => block.text)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
/** Render a value as terminal-safe text: strings escaped, other values as pretty JSON. */
|
||||
function pretty(value: unknown): string {
|
||||
if (typeof value === 'string') return displayText(value)
|
||||
// JSON.stringify is typed to return string but yields undefined for e.g. symbols.
|
||||
const serialized = JSON.stringify(value, null, 2) as string | undefined
|
||||
return displayText(serialized ?? String(value))
|
||||
}
|
||||
|
||||
/** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */
|
||||
function diffLines(diff: FileDiff, palette: Palette): string[] {
|
||||
// The card header is a fixed `Tool / <name>` frame that never names a file, so
|
||||
// each hunk always carries its own path header (no redundancy to suppress).
|
||||
const lines = [palette.bold(displayText(diff.path))]
|
||||
if (diff.oldText !== null) {
|
||||
for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`))
|
||||
}
|
||||
for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`))
|
||||
return lines
|
||||
}
|
||||
|
||||
/**
|
||||
* A message's bold, underlined role header in the role color. The underline
|
||||
* bands each role without a background fill or per-line prefix, so it reads on
|
||||
* any theme and a body drag-select copies the message text verbatim.
|
||||
*/
|
||||
function messageHeader(label: string, color: (text: string) => string, palette: Palette): string {
|
||||
return palette.bold(palette.underline(color(displayText(label))))
|
||||
}
|
||||
|
||||
/**
|
||||
* Borderless startup banner: product title, an optional configured subtitle,
|
||||
* and the session id. No box frame — each line renders as plain left-padded
|
||||
* text (matching transcript notices) so it reads on any theme.
|
||||
*/
|
||||
export class HeaderComponent implements Component {
|
||||
/** Columns of the banner currently revealed; `undefined` renders it whole. */
|
||||
private revealWidth: number | undefined
|
||||
|
||||
constructor(
|
||||
private readonly agent: Agent,
|
||||
private readonly subtitle: () => string | undefined,
|
||||
private readonly palette: Palette,
|
||||
private readonly gradient: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Clip the banner to `width` columns (the sweep reveal); `undefined` restores it.
|
||||
* @param width - Revealed banner width in columns, or `undefined` for the whole banner.
|
||||
*/
|
||||
setRevealWidth(width: number | undefined): void {
|
||||
this.revealWidth = width
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const usable = Math.max(1, width - 2)
|
||||
const name = this.gradient
|
||||
? this.palette.bold(gradientText('DEEPSEEK'))
|
||||
: this.palette.bold(this.palette.accent('DEEPSEEK'))
|
||||
const title = `${name} ${this.palette.bold('HARNESS')}`
|
||||
const detail = displayText(this.agent.session.id)
|
||||
const subtitle = this.subtitle()
|
||||
const lines = [
|
||||
title,
|
||||
...subtitle === undefined ? [] : [this.palette.muted(displayText(subtitle))],
|
||||
this.palette.dim(detail),
|
||||
]
|
||||
.flatMap(line => wrapTextWithAnsi(line, usable))
|
||||
.map(line => ` ${truncateToWidth(line, usable, '')}`)
|
||||
if (this.revealWidth === undefined) return lines
|
||||
const revealed = this.revealWidth
|
||||
return lines.map(line => truncateToWidth(line, revealed, ''))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A user or steering prompt in the transcript. An underlined accent role header
|
||||
* plus blank-line spacing separate it from surrounding blocks; body lines carry
|
||||
* no prefix or indent, so a terminal drag-select copies the prompt verbatim.
|
||||
*/
|
||||
export class UserMessageComponent extends Container {
|
||||
constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') {
|
||||
super()
|
||||
this.addChild(new Text(messageHeader(label, palette.accent, palette), 0, 0))
|
||||
this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, {
|
||||
preserveOrderedListMarkers: true,
|
||||
preserveBackslashEscapes: true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Children of a settled assistant message: optional reasoning block then the response text. */
|
||||
function assistantMessageChildren(
|
||||
content: readonly ContentBlock[],
|
||||
showReasoning: boolean,
|
||||
palette: Palette,
|
||||
mdTheme: MarkdownTheme,
|
||||
): Component[] {
|
||||
const reasoning = displayText(textBlocks(content, 'reasoning').trim())
|
||||
const text = displayText(textBlocks(content, 'text').trim())
|
||||
const children: Component[] = [
|
||||
new Spacer(1),
|
||||
new Text(messageHeader('Assistant', palette.accent2, palette), 0, 0),
|
||||
]
|
||||
if (reasoning && showReasoning) {
|
||||
children.push(
|
||||
new Text(palette.italic(palette.muted('Reasoning')), 0, 0),
|
||||
new Markdown(reasoning, 0, 0, mdTheme, { color: value => palette.muted(value), italic: true }),
|
||||
)
|
||||
}
|
||||
if (text) children.push(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }))
|
||||
return children
|
||||
}
|
||||
|
||||
/**
|
||||
* A step's timing summary, rendered as a self-refreshing footer that stays at
|
||||
* the tail of the step's output. Kept separate from the assistant message so
|
||||
* the timing line trails any tool cards the step appends after its message.
|
||||
*/
|
||||
class StepTimingComponent extends Container {
|
||||
private completionTime: number | undefined
|
||||
|
||||
constructor(
|
||||
private readonly position: StepPosition,
|
||||
private readonly events: () => readonly SessionEvent[],
|
||||
private readonly now: () => number,
|
||||
private readonly palette: Palette,
|
||||
) {
|
||||
super()
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
complete(time: number): void {
|
||||
this.completionTime = time
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
this.rebuild()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear()
|
||||
const totals = stepTimingAt(this.events(), this.position, this.completionTime ?? this.now())
|
||||
const timing = formatTimingTotals(totals, true)
|
||||
const header = this.completionTime === undefined
|
||||
? timing
|
||||
: `${timing} · Completed ${formatCompletionTime(this.completionTime)}`
|
||||
this.addChild(new Text(this.palette.dim(header), 0, 0))
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamingBlock {
|
||||
type: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/** A live assistant step: streamed reasoning/text blocks until the message settles. */
|
||||
export class StreamingAssistantComponent extends Container {
|
||||
private readonly blocks = new Map<number, StreamingBlock>()
|
||||
private settledContent: readonly ContentBlock[] | undefined
|
||||
/**
|
||||
* The step's timing footer. The renderer keeps it at the tail of the chat so
|
||||
* it trails any tool cards the step appends after this assistant message; it
|
||||
* is not a child of this component.
|
||||
*/
|
||||
readonly timing: StepTimingComponent
|
||||
|
||||
constructor(
|
||||
position: StepPosition,
|
||||
events: () => readonly SessionEvent[],
|
||||
now: () => number,
|
||||
private showReasoning: boolean,
|
||||
private readonly palette: Palette,
|
||||
private readonly mdTheme: MarkdownTheme,
|
||||
) {
|
||||
super()
|
||||
this.timing = new StepTimingComponent(position, events, now, palette)
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the streamed blocks with the step's settled content.
|
||||
* @param content - The settled assistant content blocks.
|
||||
*/
|
||||
settle(content: readonly ContentBlock[]): void {
|
||||
this.settledContent = content
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this step's assistant message has settled.
|
||||
* @returns `true` once {@link settle} has run.
|
||||
*/
|
||||
isSettled(): boolean {
|
||||
return this.settledContent !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the step's timing footer to its completion time.
|
||||
* @param time - Step completion time in epoch milliseconds.
|
||||
*/
|
||||
complete(time: number): void {
|
||||
this.timing.complete(time)
|
||||
}
|
||||
|
||||
override invalidate(): void {
|
||||
this.rebuild()
|
||||
this.timing.invalidate()
|
||||
super.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one streamed chunk into the live block buffer and re-render.
|
||||
* @param chunk - The streamed assistant chunk.
|
||||
*/
|
||||
update(chunk: StreamChunk): void {
|
||||
if (chunk.type === 'block-start') {
|
||||
this.blocks.set(chunk.index, { type: chunk.blockType, text: '' })
|
||||
} else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') {
|
||||
const type = chunk.type === 'text-delta' ? 'text' : 'reasoning'
|
||||
const block = this.blocks.get(chunk.index) ?? { type, text: '' }
|
||||
block.text += chunk.text
|
||||
this.blocks.set(chunk.index, block)
|
||||
} else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) {
|
||||
this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text })
|
||||
}
|
||||
this.rebuild()
|
||||
this.timing.invalidate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle whether reasoning blocks render, then re-render.
|
||||
* @param show - Whether to show reasoning blocks.
|
||||
*/
|
||||
setShowReasoning(show: boolean): void {
|
||||
this.showReasoning = show
|
||||
this.rebuild()
|
||||
}
|
||||
|
||||
private rebuild(): void {
|
||||
this.clear()
|
||||
const content: readonly ContentBlock[] = this.settledContent ?? [...this.blocks.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.flatMap<ContentBlock>(([, block]) => {
|
||||
if (block.type === 'text') return [{ type: 'text', text: block.text }]
|
||||
if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }]
|
||||
return []
|
||||
})
|
||||
for (const child of assistantMessageChildren(content, this.showReasoning, this.palette, this.mdTheme)) {
|
||||
this.addChild(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A tool call and its result, rendered as a collapsible status card. */
|
||||
export class ToolCardComponent implements Component {
|
||||
private result: { content: ContentBlock[]; isError: boolean; meta?: JsonValue } | undefined
|
||||
private expanded = false
|
||||
private callView: ToolCallView
|
||||
private resultView: ToolResultView | undefined
|
||||
|
||||
constructor(
|
||||
private readonly name: string,
|
||||
private readonly parsed: ParsedArguments,
|
||||
private readonly definition: ToolDefinition | undefined,
|
||||
private readonly maxOutputLines: number,
|
||||
private readonly palette: Palette,
|
||||
private readonly mdTheme: MarkdownTheme,
|
||||
) {
|
||||
this.callView = this.presentCall()
|
||||
}
|
||||
|
||||
private presentCall(): ToolCallView {
|
||||
if (this.parsed.valid && this.definition?.presentCall) {
|
||||
try {
|
||||
const view = this.definition.presentCall(this.parsed.value)
|
||||
if (view !== undefined) return view
|
||||
} catch (error: unknown) {
|
||||
return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` }
|
||||
}
|
||||
}
|
||||
return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value }
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the tool result and derive its result view.
|
||||
* @param event - The `tool/result` event payload.
|
||||
*/
|
||||
updateResult(event: Extract<SessionEvent, { type: 'tool/result' }>['data']): void {
|
||||
const result = event.message.content[0]
|
||||
this.result = {
|
||||
content: [...result.content],
|
||||
isError: result.isError === true,
|
||||
...event.meta !== undefined ? { meta: event.meta } : {},
|
||||
}
|
||||
if (this.parsed.valid && this.definition?.presentResult) {
|
||||
try {
|
||||
const view = this.definition.presentResult(this.parsed.value, this.result)
|
||||
if (view !== undefined) this.resultView = view
|
||||
} catch (error: unknown) {
|
||||
this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand or collapse the card's body preview.
|
||||
* @param expanded - Whether the full body is shown.
|
||||
*/
|
||||
setExpanded(expanded: boolean): void {
|
||||
this.expanded = expanded
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const isError = this.result?.isError ?? false
|
||||
// A ring marker: hollow while the call is pending, filled once it settles;
|
||||
// the header color (warning/success/error) tells pending from ok from error.
|
||||
const glyph = this.result === undefined ? '○' : '●'
|
||||
const rawBody = this.renderBody()
|
||||
const view = this.resultView ?? this.callView
|
||||
const genericContent = view.card === 'generic' ? view.content ?? this.result?.content : undefined
|
||||
const unknownXml = this.definition === undefined && genericContent !== undefined
|
||||
? renderUnknownXml(
|
||||
displayText(contentText(genericContent)),
|
||||
this.maxOutputLines,
|
||||
this.expanded,
|
||||
displayText,
|
||||
text => this.palette.muted(text),
|
||||
/* v8 ignore next -- renderUnknownXml calls the collapsed summary only when hidden XML children exceed this card's limit. */
|
||||
count => this.palette.dim(` … +${count} lines (Ctrl+O to expand)`),
|
||||
)
|
||||
: undefined
|
||||
const body = unknownXml ?? (genericContent !== undefined && rawBody.length > 0
|
||||
? new Markdown(rawBody.join('\n'), 0, 0, this.mdTheme, { color: value => this.palette.text(value) }).render(width)
|
||||
: rawBody)
|
||||
const headLines = Math.ceil(this.maxOutputLines / 2)
|
||||
const tailLines = this.maxOutputLines - headLines
|
||||
const visibleBody = unknownXml !== undefined || this.expanded || body.length <= this.maxOutputLines
|
||||
? body
|
||||
: [
|
||||
...body.slice(0, headLines),
|
||||
this.palette.dim(`… +${body.length - this.maxOutputLines} lines (Ctrl+O to expand)`),
|
||||
...body.slice(body.length - tailLines),
|
||||
]
|
||||
// The header is a fixed `Tool / <name>` frame in the status color (warning
|
||||
// pending / success ok / error), flat — no bold or underline, so one color
|
||||
// reads consistently across the whole row. Every tool-specific detail (a
|
||||
// read's path, a diff, command output) lives in the body below; the sole
|
||||
// header extra is a bash card's model-authored description, appended as a
|
||||
// `/ <desc>` segment. The body stays unprefixed so a drag-select copies only
|
||||
// the tool text; body lines pass through Text so overlong output wraps.
|
||||
const statusColor = this.result === undefined
|
||||
? this.palette.warning
|
||||
: isError ? this.palette.error : this.palette.success
|
||||
// The header is a single card row: collapse an embedded newline in the
|
||||
// description to an inline escape so it cannot break onto extra rows and
|
||||
// collide with the body lines that follow.
|
||||
const desc = this.headerDescription()
|
||||
const headerText = `${glyph} Tool / ${displayText(this.name)}${desc === undefined ? '' : ` / ${displayInlineText(desc)}`}`
|
||||
const header = truncateToWidth(headerText, Math.max(1, width - 2), '')
|
||||
const lines = [statusColor(header)]
|
||||
if (visibleBody.length > 0) lines.push(...new Text(visibleBody.join('\n'), 0, 0).render(width))
|
||||
return lines
|
||||
}
|
||||
|
||||
/** The pending terminal call view, when this row is a terminal card. */
|
||||
private terminalPending(): TerminalCallView | undefined {
|
||||
return this.callView.card === 'terminal' ? this.callView : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional header `/ <desc>` segment: a bash (terminal) card's
|
||||
* model-authored description. Non-terminal tools contribute no header detail —
|
||||
* their presenter title moves into the body instead.
|
||||
*/
|
||||
private headerDescription(): string | undefined {
|
||||
const description = this.terminalPending()?.description
|
||||
return description !== undefined && description !== '' ? description : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The presenter's title for a non-terminal card, shown as the first body line
|
||||
* (a read's `Read src/foo.ts`, a diff's `Edit files`) now that the header is a
|
||||
* fixed `Tool / <name>` frame. The result-state title replaces the pending one.
|
||||
*/
|
||||
private bodyTitle(): string {
|
||||
return this.resultView?.title ?? this.callView.title
|
||||
}
|
||||
|
||||
private renderBody(): string[] {
|
||||
const view = this.resultView ?? this.callView
|
||||
if (view.card === 'terminal') {
|
||||
const pending = this.terminalPending()
|
||||
const lines: string[] = []
|
||||
// The command shows as a $-line here whenever it is not the header: either a
|
||||
// description headlines the row (the command still belongs somewhere) or the row
|
||||
// is a pending undescribed call (the classic running-command echo). A completed
|
||||
// undescribed row keeps the command only in the header.
|
||||
// The command and cwd are each a single card row, so escape a multi-line
|
||||
// command inline (displayInlineText) — a real newline would break onto extra
|
||||
// rows and collide with the output below.
|
||||
const headlined = pending?.description !== undefined && pending.description !== ''
|
||||
const commandInBody = pending !== undefined && (headlined || this.result === undefined)
|
||||
if (commandInBody) lines.push(this.palette.code(`$ ${displayInlineText(pending.title)}`))
|
||||
if (pending?.cwd) lines.push(this.palette.dim(displayInlineText(pending.cwd)))
|
||||
if (this.resultView?.card === 'terminal') {
|
||||
if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n'))
|
||||
if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`))
|
||||
if (this.resultView.signal !== undefined) {
|
||||
lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`))
|
||||
}
|
||||
} else if (this.result !== undefined) {
|
||||
lines.push(...displayText(contentText(this.result.content)).split('\n'))
|
||||
}
|
||||
return lines.filter(Boolean)
|
||||
}
|
||||
if (view.card === 'diff') {
|
||||
// The header no longer names the file, so each diff keeps its own path
|
||||
// header. A trailing footer summarizes the change (`+A -R · N file(s)`).
|
||||
let added = 0
|
||||
let removed = 0
|
||||
const hunks = view.diffs.flatMap((diff, index) => {
|
||||
if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length
|
||||
added += displayText(diff.newText).split('\n').length
|
||||
return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)]
|
||||
})
|
||||
const files = view.diffs.length
|
||||
const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`)
|
||||
return [...hunks, footer]
|
||||
}
|
||||
const content = view.content ?? this.result?.content
|
||||
const lines: string[] = []
|
||||
// The presenter title headlines the body now that the header is a fixed
|
||||
// `Tool / <name>` frame (a terminal card keeps its command $-line instead).
|
||||
// Skip it when it only repeats the tool name (the fallback presenter for a
|
||||
// tool with no presentCall, or an unknown tool), which the header already shows.
|
||||
const bodyTitle = this.bodyTitle()
|
||||
if (bodyTitle !== displayText(this.name)) lines.push(displayInlineText(bodyTitle))
|
||||
if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n'))
|
||||
const rawInput = this.result === undefined && this.callView.card === 'generic'
|
||||
? this.callView.rawInput
|
||||
: undefined
|
||||
if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n'))
|
||||
return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1))
|
||||
}
|
||||
}
|
||||
|
||||
/** The plan/todo panel rendered above the prompt. */
|
||||
export class TodoComponent implements Component {
|
||||
private todos: readonly TodoItem[] = []
|
||||
|
||||
constructor(private readonly palette: Palette) {}
|
||||
|
||||
/**
|
||||
* Replace the rendered plan items.
|
||||
* @param todos - The current todo items.
|
||||
*/
|
||||
update(todos: readonly TodoItem[]): void {
|
||||
this.todos = todos
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
if (this.todos.length === 0) return []
|
||||
const lines = [this.palette.bold(this.palette.accent('Plan'))]
|
||||
for (const todo of this.todos) {
|
||||
const prefix = todo.status === 'completed'
|
||||
? this.palette.success('✓')
|
||||
: todo.status === 'in_progress'
|
||||
? this.palette.warning('●')
|
||||
: this.palette.dim('○')
|
||||
const content = displayText(todo.content)
|
||||
const text = todo.status === 'completed' ? this.palette.muted(content) : content
|
||||
lines.push(truncateToWidth(` ${prefix} ${text}`, width, ''))
|
||||
}
|
||||
return ['', ...lines]
|
||||
}
|
||||
}
|
||||
142
packages/ui/tui/src/components/xml-tool-output.ts
Normal file
142
packages/ui/tui/src/components/xml-tool-output.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Conservative readable-tree rendering for model-facing text containing one XML
|
||||
* document, used by the transcript's tool and context cards.
|
||||
* @module @deepseek-ai/dsh-tui/components/xml-tool-output
|
||||
*/
|
||||
|
||||
import { SaxesParser } from 'saxes'
|
||||
|
||||
interface XmlElement {
|
||||
readonly name: string
|
||||
readonly attributes: readonly XmlAttribute[]
|
||||
readonly children: XmlNode[]
|
||||
}
|
||||
|
||||
interface XmlAttribute {
|
||||
readonly name: string
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
type XmlNode = XmlElement | string
|
||||
|
||||
function parseXml(source: string, display: (text: string) => string): XmlElement | undefined {
|
||||
const parser = new SaxesParser({ xmlns: false })
|
||||
const stack: XmlElement[] = []
|
||||
let root: XmlElement | undefined
|
||||
const state = { invalid: false }
|
||||
const reject = (): void => { state.invalid = true }
|
||||
parser.on('opentag', (tag) => {
|
||||
const element: XmlElement = {
|
||||
name: tag.name,
|
||||
// Attribute values and text pass through `display` because character references can
|
||||
// expand to valid-XML control characters (tab, CR, DEL, C1) that pre-parse escaping
|
||||
// of the raw source never saw. Element names cannot carry them: control characters
|
||||
// are not XML name characters and character references do not apply inside names.
|
||||
attributes: Object.entries(tag.attributes).map(([name, value]) => ({ name, value: display(value) })),
|
||||
children: [],
|
||||
}
|
||||
const parent = stack.at(-1)
|
||||
if (parent === undefined) {
|
||||
if (root !== undefined) reject()
|
||||
root = element
|
||||
} else {
|
||||
parent.children.push(element)
|
||||
}
|
||||
stack.push(element)
|
||||
})
|
||||
parser.on('text', (text) => {
|
||||
const parent = stack.at(-1)
|
||||
if (parent === undefined) {
|
||||
if (text.trim() !== '') reject()
|
||||
} else {
|
||||
parent.children.push(display(text))
|
||||
}
|
||||
})
|
||||
parser.on('cdata', (text) => {
|
||||
const parent = stack.at(-1)
|
||||
if (parent === undefined) reject()
|
||||
else parent.children.push(display(text))
|
||||
})
|
||||
parser.on('closetag', () => { stack.pop() })
|
||||
parser.on('xmldecl', reject)
|
||||
parser.on('processinginstruction', reject)
|
||||
parser.on('doctype', reject)
|
||||
parser.on('comment', reject)
|
||||
parser.on('error', reject)
|
||||
parser.write(source).close()
|
||||
return state.invalid ? undefined : root
|
||||
}
|
||||
|
||||
function elementLabel(element: XmlElement): string {
|
||||
const attributes = element.attributes.map(attribute => `${attribute.name}=${JSON.stringify(attribute.value)}`).join(' ')
|
||||
return attributes === '' ? element.name : `${element.name} (${attributes})`
|
||||
}
|
||||
|
||||
function meaningfulChildren(element: XmlElement): readonly XmlNode[] {
|
||||
return element.children.filter(child => typeof child !== 'string' || child.trim() !== '')
|
||||
}
|
||||
|
||||
function textBlock(text: string, depth: number): string[] {
|
||||
return text.replace(/^\n|\n$/gu, '').split('\n').map(line => `${' '.repeat(depth)}${line}`)
|
||||
}
|
||||
|
||||
function treeLines(element: XmlElement, depth: number, label: (text: string) => string): string[] {
|
||||
const indent = ' '.repeat(depth)
|
||||
const children = meaningfulChildren(element)
|
||||
if (children.length === 0) return [`${indent}${label(elementLabel(element))}`]
|
||||
if (children.length === 1 && typeof children[0] === 'string' && !children[0].includes('\n')) {
|
||||
return [`${indent}${label(`${elementLabel(element)}:`)} ${children[0].trim()}`]
|
||||
}
|
||||
const lines = [`${indent}${label(elementLabel(element))}`]
|
||||
for (const child of children) {
|
||||
if (typeof child === 'string') lines.push(...textBlock(child, depth + 1))
|
||||
else lines.push(...treeLines(child, depth + 1, label))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
function preview(lines: readonly string[], limit: number, omitted: (count: number) => string): string[] {
|
||||
if (lines.length <= limit) return [...lines]
|
||||
const head = Math.ceil(limit / 2)
|
||||
const tail = limit - head
|
||||
return [...lines.slice(0, head), omitted(lines.length - limit), ...lines.slice(lines.length - tail)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a complete XML document as an indented tree, or decline without changing partial/mixed text.
|
||||
* @param source - Raw model-facing text from a context message or unknown tool result.
|
||||
* @param maxChildLines - Collapsed budget independently applied to each top-level child's lines and
|
||||
* to the number of top-level children, so many siblings cannot grow the collapsed card without bound.
|
||||
* @param expanded - Whether to retain every rendered child line.
|
||||
* @param display - Escapes parsed text and attribute values for terminal output; character references
|
||||
* can expand to control characters that pre-parse escaping never saw.
|
||||
* @param label - Styles element names and attributes.
|
||||
* @param omitted - Renders the omitted-line marker for a collapsed child or child range.
|
||||
* @returns Tree rows, or `undefined` when `source` is not one supported complete XML document.
|
||||
*/
|
||||
export function renderUnknownXml(
|
||||
source: string,
|
||||
maxChildLines: number,
|
||||
expanded: boolean,
|
||||
display: (text: string) => string,
|
||||
label: (text: string) => string,
|
||||
omitted: (count: number) => string,
|
||||
): string[] | undefined {
|
||||
const root = parseXml(source, display)
|
||||
if (root === undefined) return undefined
|
||||
const blocks = meaningfulChildren(root).map(child =>
|
||||
typeof child === 'string' ? textBlock(child, 1) : treeLines(child, 1, label))
|
||||
const rootLine = label(elementLabel(root))
|
||||
if (expanded) return [rootLine, ...blocks.flat()]
|
||||
const previewed = blocks.map(block => preview(block, maxChildLines, omitted))
|
||||
if (previewed.length <= maxChildLines) return [rootLine, ...previewed.flat()]
|
||||
const head = Math.ceil(maxChildLines / 2)
|
||||
const tail = maxChildLines - head
|
||||
const hidden = blocks.slice(head, blocks.length - tail).reduce((total, block) => total + block.length, 0)
|
||||
return [
|
||||
rootLine,
|
||||
...previewed.slice(0, head).flat(),
|
||||
omitted(hidden),
|
||||
...previewed.slice(previewed.length - tail).flat(),
|
||||
]
|
||||
}
|
||||
213
packages/ui/tui/src/config.ts
Normal file
213
packages/ui/tui/src/config.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Serializable configuration and defaults for the pi-tui terminal mode. Loader
|
||||
* schema validation normally fills defaults; {@link resolveTuiConfig} applies
|
||||
* the same defaults for direct callers that bypass the Loader.
|
||||
* @module @deepseek-ai/dsh-tui/config
|
||||
*/
|
||||
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
} from './chat/file-autocomplete.ts'
|
||||
|
||||
/** Theme and prompt-template settings for the pi-tui terminal mode. */
|
||||
export interface TuiThemeConfig {
|
||||
/** Apply the built-in ANSI color palette. */
|
||||
color?: boolean
|
||||
/** Paint the startup banner with the 24-bit DeepSeek brand gradient. */
|
||||
truecolor?: boolean
|
||||
/** Left-aligned template on the row above the editor. */
|
||||
leftPrompt?: string
|
||||
/** Right-aligned template on the row above the editor. */
|
||||
rightPrompt?: string
|
||||
/** Template used as the editor's first-line prefix. */
|
||||
inputPrompt?: string
|
||||
/** Static placeholder shown in an empty editor while the agent is running. */
|
||||
inputPlaceholder?: string
|
||||
}
|
||||
|
||||
/** Interaction and presentation settings for the pi-tui terminal mode. */
|
||||
export interface TuiConfig {
|
||||
/** Render model reasoning blocks. */
|
||||
showReasoning?: boolean
|
||||
/** Maximum tool-card body lines retained in its collapsed head/tail preview. */
|
||||
maxToolOutputLines?: number
|
||||
/** Maximum options visible at once in a user-question panel. */
|
||||
maxQuestionOptions?: number
|
||||
/** Maximum models visible at once in the model selector. */
|
||||
maxModelOptions?: number
|
||||
/** Maximum sessions visible at once in the resume selector. */
|
||||
maxResumeOptions?: number
|
||||
/** User-question panel width in terminal columns, clamped to the terminal. */
|
||||
questionDialogWidth?: number
|
||||
/** User-question panel maximum height in terminal rows. */
|
||||
questionDialogMaxHeight?: number
|
||||
/** Model-selector width in terminal columns. */
|
||||
modelDialogWidth?: number
|
||||
/** Model-selector maximum height in terminal rows. */
|
||||
modelDialogMaxHeight?: number
|
||||
/** Maximum fuzzy file candidates displayed for one `@` query. */
|
||||
fileSearchMaxResults?: number
|
||||
/** Maximum paths retained in one `@` workspace index. */
|
||||
fileSearchMaxEntries?: number
|
||||
/** Directory basenames excluded from `@` traversal and completion. */
|
||||
fileSearchExcludedDirectories?: string[]
|
||||
/** Show the terminal's hardware cursor at the pi editor's IME marker. */
|
||||
showHardwareCursor?: boolean
|
||||
/** Color and prompt-template settings. */
|
||||
theme?: TuiThemeConfig
|
||||
/** Terminal window title while the UI is mounted; a logged session title prefixes it. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
const showReasoningSchema = z.boolean().default(true)
|
||||
const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6)
|
||||
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
|
||||
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
|
||||
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
|
||||
const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
|
||||
const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS)
|
||||
const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES)
|
||||
const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES])
|
||||
const showHardwareCursorSchema = z.boolean().default(false)
|
||||
const colorSchema = z.boolean().default(true)
|
||||
// No default: an unset value auto-detects truecolor from COLORTERM in `apply`.
|
||||
const truecolorSchema = z.boolean()
|
||||
const DEFAULT_LEFT_PROMPT = '${cwd}${git/worktree}${model}${token_meter/cache_hit_rate}${context}'
|
||||
const DEFAULT_RIGHT_PROMPT = '${timing}'
|
||||
const DEFAULT_INPUT_PROMPT = '${symbol} ${indicator}'
|
||||
const DEFAULT_INPUT_PLACEHOLDER = 'press enter to steer and esc to cancel'
|
||||
const TuiThemeConfigSchema: z<TuiThemeConfig> = z.object({
|
||||
color: colorSchema,
|
||||
truecolor: truecolorSchema,
|
||||
leftPrompt: z.string().default(DEFAULT_LEFT_PROMPT),
|
||||
rightPrompt: z.string().default(DEFAULT_RIGHT_PROMPT),
|
||||
inputPrompt: z.string().default(DEFAULT_INPUT_PROMPT),
|
||||
inputPlaceholder: z.string().default(DEFAULT_INPUT_PLACEHOLDER),
|
||||
})
|
||||
const titleSchema = z.string().default('DeepSeek Harness')
|
||||
|
||||
const tuiConfigSchemaFields = {
|
||||
showReasoning: showReasoningSchema,
|
||||
maxToolOutputLines: maxToolOutputLinesSchema,
|
||||
maxQuestionOptions: maxQuestionOptionsSchema,
|
||||
maxModelOptions: maxModelOptionsSchema,
|
||||
maxResumeOptions: maxResumeOptionsSchema,
|
||||
questionDialogWidth: questionDialogWidthSchema,
|
||||
questionDialogMaxHeight: questionDialogMaxHeightSchema,
|
||||
modelDialogWidth: modelDialogWidthSchema,
|
||||
modelDialogMaxHeight: modelDialogMaxHeightSchema,
|
||||
fileSearchMaxResults: fileSearchMaxResultsSchema,
|
||||
fileSearchMaxEntries: fileSearchMaxEntriesSchema,
|
||||
fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema,
|
||||
showHardwareCursor: showHardwareCursorSchema,
|
||||
theme: TuiThemeConfigSchema,
|
||||
title: titleSchema,
|
||||
}
|
||||
|
||||
/** Schemastery schema for presentation settings embedded by app bundles. */
|
||||
export const TuiConfigSchema: z<TuiConfig> = z.object(tuiConfigSchemaFields)
|
||||
|
||||
/** Serializable plugin configuration. */
|
||||
export interface Config extends TuiConfig {
|
||||
/** Banner subtitle line. When absent, the banner has no subtitle and sweeps in on start. */
|
||||
welcome?: string
|
||||
/** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */
|
||||
sessionId?: string
|
||||
/**
|
||||
* Shell command fallback printed on exit or after selecting a session when
|
||||
* the host cannot hand off in place. Every `{session}` becomes the selected
|
||||
* id; the TUI never executes this text. Absent disables only the fallback,
|
||||
* not the interactive selector.
|
||||
*/
|
||||
resumeCommand?: string
|
||||
}
|
||||
|
||||
/** Schemastery schema for the full plugin configuration. */
|
||||
export const Config: z<Config> = z.object({
|
||||
welcome: z.string(),
|
||||
sessionId: z.string().default('main'),
|
||||
resumeCommand: z.string(),
|
||||
showReasoning: tuiConfigSchemaFields.showReasoning,
|
||||
maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines,
|
||||
maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions,
|
||||
maxModelOptions: tuiConfigSchemaFields.maxModelOptions,
|
||||
maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions,
|
||||
questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth,
|
||||
questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight,
|
||||
modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth,
|
||||
modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight,
|
||||
fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults,
|
||||
fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries,
|
||||
fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories,
|
||||
showHardwareCursor: tuiConfigSchemaFields.showHardwareCursor,
|
||||
theme: tuiConfigSchemaFields.theme,
|
||||
title: tuiConfigSchemaFields.title,
|
||||
})
|
||||
|
||||
/** Fully defaulted TUI theme settings. */
|
||||
export interface ResolvedTuiThemeConfig {
|
||||
color: boolean
|
||||
truecolor: boolean
|
||||
leftPrompt: string
|
||||
rightPrompt: string
|
||||
inputPrompt: string
|
||||
inputPlaceholder: string
|
||||
}
|
||||
|
||||
/** Fully defaulted TUI presentation settings. */
|
||||
export interface ResolvedTuiConfig {
|
||||
showReasoning: boolean
|
||||
maxToolOutputLines: number
|
||||
maxQuestionOptions: number
|
||||
maxModelOptions: number
|
||||
maxResumeOptions: number
|
||||
questionDialogWidth: number
|
||||
questionDialogMaxHeight: number
|
||||
modelDialogWidth: number
|
||||
modelDialogMaxHeight: number
|
||||
fileSearchMaxResults: number
|
||||
fileSearchMaxEntries: number
|
||||
fileSearchExcludedDirectories: string[]
|
||||
showHardwareCursor: boolean
|
||||
theme: ResolvedTuiThemeConfig
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply direct-call defaults after Loader schema validation has normally run.
|
||||
*
|
||||
* @param config - Deployment-provided terminal presentation settings.
|
||||
* @returns Complete settings consumed by the TUI renderer.
|
||||
*/
|
||||
export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig {
|
||||
return {
|
||||
showReasoning: config?.showReasoning ?? true,
|
||||
maxToolOutputLines: config?.maxToolOutputLines ?? 6,
|
||||
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
|
||||
maxModelOptions: config?.maxModelOptions ?? 8,
|
||||
maxResumeOptions: config?.maxResumeOptions ?? 8,
|
||||
questionDialogWidth: config?.questionDialogWidth ?? 200,
|
||||
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
|
||||
modelDialogWidth: config?.modelDialogWidth ?? 76,
|
||||
modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20,
|
||||
fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS,
|
||||
fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES,
|
||||
fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)],
|
||||
showHardwareCursor: config?.showHardwareCursor ?? false,
|
||||
theme: {
|
||||
color: config?.theme?.color ?? true,
|
||||
truecolor: config?.theme?.truecolor ?? false,
|
||||
leftPrompt: config?.theme?.leftPrompt ?? DEFAULT_LEFT_PROMPT,
|
||||
rightPrompt: config?.theme?.rightPrompt ?? DEFAULT_RIGHT_PROMPT,
|
||||
inputPrompt: config?.theme?.inputPrompt ?? DEFAULT_INPUT_PROMPT,
|
||||
inputPlaceholder: config?.theme?.inputPlaceholder ?? DEFAULT_INPUT_PLACEHOLDER,
|
||||
},
|
||||
title: config?.title ?? 'DeepSeek Harness',
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
*
|
||||
* The manager serializes modal ownership, guards extension callbacks, and
|
||||
* settles every queued or active operation before terminal teardown.
|
||||
* @module @deepseek-ai/dsh-tui/overlay-manager
|
||||
* @module @deepseek-ai/dsh-tui/extension/overlay-manager
|
||||
*/
|
||||
|
||||
import { Service, type Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { TuiExtensionService } from './index.ts'
|
||||
import type { TuiExtensionService } from '../index.ts'
|
||||
import type {
|
||||
Component,
|
||||
Focusable,
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
TuiOverlayState,
|
||||
TuiTheme,
|
||||
TuiViewport,
|
||||
} from './extension.ts'
|
||||
} from './types.ts'
|
||||
|
||||
/** pi-tui operations retained by the front door instead of exposed to plugins. */
|
||||
export interface TuiOverlayDriver {
|
||||
@@ -5,7 +5,7 @@
|
||||
* the live pi-tui tree, focus controller, overlay handles, or terminal
|
||||
* lifecycle. Registrations and open overlays remain owned by the calling
|
||||
* Cordis fiber.
|
||||
* @module @deepseek-ai/dsh-tui/extension
|
||||
* @module @deepseek-ai/dsh-tui/extension/types
|
||||
*/
|
||||
|
||||
/** Terminal component shape accepted from a trusted TUI extension. */
|
||||
File diff suppressed because it is too large
Load Diff
217
packages/ui/tui/src/prompt.ts
Normal file
217
packages/ui/tui/src/prompt.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Mutable terminal-prompt value registry consumed by the TUI template renderer.
|
||||
* Values are trusted presentation fragments and may contain ANSI control sequences.
|
||||
* @module @deepseek-ai/dsh-tui/prompt
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export const name = 'tui-prompt'
|
||||
|
||||
const VALUE_NAME = /^[a-z][a-z0-9_-]*(?:\/[a-z][a-z0-9_-]*)*$/u
|
||||
|
||||
/** Handle owned by one prompt-value registration. */
|
||||
export interface TuiPromptValueHandle {
|
||||
/**
|
||||
* Replace the current fragment and schedule a coalesced change notification
|
||||
* so the owning renderer redraws. Setting the current value again is a no-op.
|
||||
* @param value - Trusted ANSI-capable fragment, or `undefined` while unavailable.
|
||||
*/
|
||||
set(value: string | undefined): void
|
||||
|
||||
/** Unregister this value; subsequent {@link TuiPromptValueHandle.set} calls fail. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
interface RegisteredValue {
|
||||
value: string | undefined
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
tuiPrompt: TuiPromptService
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a change subscription registered with {@link TuiPromptService.subscribe}. */
|
||||
export type TuiPromptUnsubscribe = () => void
|
||||
|
||||
/** One literal or variable token in a parsed TUI prompt template. */
|
||||
export type TuiPromptTemplateToken =
|
||||
| { readonly kind: 'literal'; readonly value: string }
|
||||
| { readonly kind: 'value'; readonly name: string }
|
||||
|
||||
/**
|
||||
* Parse a prompt template into immutable literal and value tokens.
|
||||
* @param template - Text containing `${name}` references.
|
||||
* @returns Tokens consumed by {@link renderTuiPromptTemplate}.
|
||||
*/
|
||||
export function parseTuiPromptTemplate(template: string): readonly TuiPromptTemplateToken[] {
|
||||
const tokens: TuiPromptTemplateToken[] = []
|
||||
const pattern = /\$\{([^}]*)\}/gu
|
||||
let offset = 0
|
||||
for (const match of template.matchAll(pattern)) {
|
||||
const index = match.index
|
||||
const name = match[1]
|
||||
/* v8 ignore next -- the sole capture always exists when this pattern matches. */
|
||||
if (name === undefined) continue
|
||||
if (index > offset) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset, index) }))
|
||||
tokens.push(Object.freeze({ kind: 'value', name }))
|
||||
offset = index + match[0].length
|
||||
}
|
||||
if (offset < template.length) tokens.push(Object.freeze({ kind: 'literal', value: template.slice(offset) }))
|
||||
return Object.freeze(tokens)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate one parsed prompt while removing horizontal separators adjacent
|
||||
* only to unavailable values.
|
||||
* @param tokens - Parsed template tokens.
|
||||
* @param resolve - Current value lookup.
|
||||
* @returns ANSI-capable rendered prompt text.
|
||||
*/
|
||||
export function renderTuiPromptTemplate(
|
||||
tokens: readonly TuiPromptTemplateToken[],
|
||||
resolve: (name: string) => string | undefined,
|
||||
): string {
|
||||
const rendered: string[] = []
|
||||
let omitLeadingWhitespace = false
|
||||
for (const token of tokens) {
|
||||
if (token.kind === 'value') {
|
||||
const value = resolve(token.name)
|
||||
if (value === undefined) {
|
||||
omitLeadingWhitespace = true
|
||||
} else {
|
||||
rendered.push(value)
|
||||
omitLeadingWhitespace = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
rendered.push(omitLeadingWhitespace ? token.value.replace(/^[\t ]+/u, '') : token.value)
|
||||
omitLeadingWhitespace = false
|
||||
}
|
||||
return rendered.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Context-global mutable values interpolated by TUI theme prompt templates.
|
||||
* A registration, mutation, or disposal schedules one coalesced notification to
|
||||
* the renderer subscribed with {@link TuiPromptService.subscribe}, so a value
|
||||
* that changes on its own schedule (not only in response to a UI event) still
|
||||
* redraws. Notification is a direct in-service callback, not a Cordis event.
|
||||
*/
|
||||
export class TuiPromptService extends Service {
|
||||
private readonly values = new Map<string, RegisteredValue>()
|
||||
// Per-subscription record identity, not callback identity: two fibers may
|
||||
// subscribe the same function, and disposing one must not remove the other's.
|
||||
private readonly listeners = new Set<{ readonly listener: () => unknown }>()
|
||||
private notificationQueued = false
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'tuiPrompt')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register one globally unique template value under the calling Cordis effect.
|
||||
* @param name - Lowercase slash-separated template name.
|
||||
* @param initialValue - Initial trusted ANSI-capable fragment.
|
||||
* @returns A mutable handle whose disposal unregisters the name.
|
||||
*/
|
||||
register(name: string, initialValue?: string): TuiPromptValueHandle {
|
||||
if (!VALUE_NAME.test(name)) {
|
||||
throw new TypeError(`TUI prompt value name "${name}" must match ${String(VALUE_NAME)}`)
|
||||
}
|
||||
if (this.values.has(name)) throw new Error(`TUI prompt value "${name}" is already registered`)
|
||||
|
||||
const registered: RegisteredValue = { value: initialValue }
|
||||
let active = true
|
||||
const effectDisposer = this.ctx.effect(() => {
|
||||
this.values.set(name, registered)
|
||||
this.scheduleChange()
|
||||
// Cordis runs this cleanup at most once per effect, and deleting an
|
||||
// absent key is a no-op, so no re-entrancy guard is needed here; `active`
|
||||
// exists only to reject a late {@link TuiPromptValueHandle.set}.
|
||||
return () => {
|
||||
active = false
|
||||
this.values.delete(name)
|
||||
this.scheduleChange()
|
||||
}
|
||||
}, `tuiPrompt.register(${name})`)
|
||||
|
||||
return Object.freeze({
|
||||
set: (value: string | undefined): void => {
|
||||
if (!active) throw new Error(`TUI prompt value "${name}" is disposed`)
|
||||
if (registered.value === value) return
|
||||
registered.value = value
|
||||
this.scheduleChange()
|
||||
},
|
||||
dispose: (): void => { void effectDisposer() },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a registered fragment without evaluating plugin code.
|
||||
* @param name - Exact registered template name.
|
||||
* @returns The current fragment, or `undefined` when unknown or unavailable.
|
||||
*/
|
||||
get(name: string): string | undefined {
|
||||
return this.values.get(name)?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe registration and value changes. The listener runs after a coalesced
|
||||
* microtask following any burst of mutations; the renderer re-reads current
|
||||
* values on that callback. The subscription is owned by the calling Cordis
|
||||
* effect, so it is removed when the subscriber's fiber disposes; the returned
|
||||
* disposer removes it early. Listener failures are contained — a synchronous
|
||||
* throw or a rejected returned promise cannot starve the other observers.
|
||||
* @param listener - Invoked once per coalesced change burst. Delivery does
|
||||
* not wait on a returned promise; its rejection is only observed and logged,
|
||||
* never left unhandled, so an async listener cannot order later observers.
|
||||
* @returns A disposer that removes the subscription.
|
||||
*/
|
||||
subscribe(listener: () => unknown): TuiPromptUnsubscribe {
|
||||
const record = { listener }
|
||||
const disposeEffect = this.ctx.effect(() => {
|
||||
this.listeners.add(record)
|
||||
return () => { this.listeners.delete(record) }
|
||||
}, 'tuiPrompt.subscribe')
|
||||
return () => { void disposeEffect() }
|
||||
}
|
||||
|
||||
/** Coalesce mutation bursts into one notification while containing each observer. */
|
||||
private scheduleChange(): void {
|
||||
if (this.notificationQueued) return
|
||||
this.notificationQueued = true
|
||||
queueMicrotask(() => {
|
||||
this.notificationQueued = false
|
||||
// Snapshot so a listener may subscribe/unsubscribe during delivery, but
|
||||
// re-check liveness: a listener that synchronously unsubscribes another
|
||||
// observer earlier in the same burst must silence it now, keeping the
|
||||
// subscription set authoritative during reentrant notification.
|
||||
for (const record of [...this.listeners]) {
|
||||
if (this.listeners.has(record)) this.notifyOne(record.listener)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Deliver one change notification, containing a synchronous throw or a rejected promise. */
|
||||
private notifyOne(listener: () => unknown): void {
|
||||
let returned: unknown
|
||||
try {
|
||||
returned = listener()
|
||||
} catch (error: unknown) {
|
||||
// errorChain never throws, even on a hostile toString/getter, so the
|
||||
// notification microtask can never escape to starve later observers.
|
||||
this.ctx.logger.warn(`tui-prompt change listener threw: ${errorChain(error)}`)
|
||||
return
|
||||
}
|
||||
// A listener may be async; contain a rejected promise the same as a throw.
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`tui-prompt change listener rejected: ${errorChain(error)}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default TuiPromptService
|
||||
45
packages/ui/tui/src/runtime.ts
Normal file
45
packages/ui/tui/src/runtime.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Host and process boundary the interactive TUI runs against: the resume-handoff
|
||||
* host and the {@link TuiRuntime} the shipped CLI supplies (terminal, process
|
||||
* exit, clock, and optional prompt/git overrides). These are plain interfaces so
|
||||
* tests can drive the channel with a fake terminal.
|
||||
* @module @deepseek-ai/dsh-tui/runtime
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */
|
||||
export interface TuiResumeHost {
|
||||
/**
|
||||
* Dispose the current app and replace it with a runtime for `sessionId`.
|
||||
* Success does not return. A host may reject before it commits teardown;
|
||||
* after commit it owns fatal reporting and process exit.
|
||||
* @param sessionId - validated persisted session selected by the user.
|
||||
*/
|
||||
handoff(sessionId: SessionId): Promise<never>
|
||||
}
|
||||
|
||||
/** Runtime boundary used by the interactive TUI. */
|
||||
export interface TuiRuntime {
|
||||
/** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */
|
||||
terminal: Terminal
|
||||
/** Exit hook used by terminal shutdown or a target-agent startup failure. */
|
||||
exit(code: number): void
|
||||
/**
|
||||
* Override the prompt's logical working-directory label without changing the session directory used by tools.
|
||||
* @param cwd - Operational working directory from the session header.
|
||||
* @returns Unescaped label; the TUI makes terminal controls visible.
|
||||
*/
|
||||
formatCwd?: (cwd: string | undefined) => string
|
||||
/**
|
||||
* Override the Git branch shown in the prompt context line; production resolves it once at mount.
|
||||
* @param cwd - Operational working directory from the session header.
|
||||
* @returns Unescaped branch name, or `undefined` outside a Git worktree.
|
||||
*/
|
||||
gitBranch?: (cwd: string) => string | undefined
|
||||
/** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */
|
||||
now?(): number
|
||||
/** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */
|
||||
handoffResume?: TuiResumeHost['handoff']
|
||||
}
|
||||
@@ -11,12 +11,12 @@ import type {
|
||||
TuiOverlayOptions,
|
||||
TuiOverlaySession,
|
||||
TuiTheme,
|
||||
} from '../src/extension.ts'
|
||||
} from '../src/extension/types.ts'
|
||||
import {
|
||||
TuiExtensionServiceImpl,
|
||||
TuiOverlayManager,
|
||||
type TuiOverlayDriver,
|
||||
} from '../src/overlay-manager.ts'
|
||||
} from '../src/extension/overlay-manager.ts'
|
||||
|
||||
const theme: TuiTheme = Object.freeze({
|
||||
text: (value: string) => `text:${value}`,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
activeAtToken,
|
||||
formatFileMention,
|
||||
WorkspaceFileSearch,
|
||||
} from '../src/file-autocomplete.ts'
|
||||
} from '../src/chat/file-autocomplete.ts'
|
||||
|
||||
const searches: WorkspaceFileSearch[] = []
|
||||
const roots: string[] = []
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage, MessageId , createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { Context } from 'cordis'
|
||||
import type { Terminal } from '@earendil-works/pi-tui'
|
||||
import AgentRegistry, {
|
||||
@@ -7,26 +8,38 @@ import AgentRegistry, {
|
||||
type AgentStatus,
|
||||
type SendOptions,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock,
|
||||
LlmModelInfo,
|
||||
LlmProviderInfo,
|
||||
LlmResolvedModelInfo,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, type Session, type SessionHeader, type UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRegistry, { type ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts'
|
||||
import { TestSessionQueryService } from './session-query.ts'
|
||||
import TuiPromptService from '../src/prompt.ts'
|
||||
|
||||
interface FakeAgent extends Agent {
|
||||
status: AgentStatus
|
||||
sent: ContentBlock[][]
|
||||
sentMessages: UserMessage[]
|
||||
sentOptions: (SendOptions | undefined)[]
|
||||
steered: ContentBlock[][]
|
||||
steeredOptions: (SendOptions | undefined)[]
|
||||
steeredIds: MessageId[]
|
||||
steeredOptions: UserMessage[]
|
||||
injected: ContentBlock[][]
|
||||
injectedOptions: UserMessage[]
|
||||
cancelled: AgentCancelCause[]
|
||||
}
|
||||
|
||||
export interface TuiHarnessOptions {
|
||||
status?: AgentStatus
|
||||
/** Override the fake agent's next-step capability independently of status. */
|
||||
acceptsNextStep?: boolean
|
||||
config?: Config
|
||||
/** Leave the session event log empty instead of seeding one turn and step. */
|
||||
omitInitialLifecycle?: boolean
|
||||
@@ -37,6 +50,7 @@ export interface TuiHarnessOptions {
|
||||
beforeMount?: (session: Session) => void
|
||||
cwd?: string | null
|
||||
formatCwd?: TuiRuntime['formatCwd']
|
||||
gitBranch?: TuiRuntime['gitBranch']
|
||||
/** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */
|
||||
agentOptions?: AgentOptions
|
||||
contextWindow?: number
|
||||
@@ -46,7 +60,10 @@ export interface TuiHarnessOptions {
|
||||
providers: LlmProviderInfo[]
|
||||
models: LlmModelInfo[]
|
||||
listModels?: (provider: string) => Promise<LlmModelInfo[]>
|
||||
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
|
||||
resolveModelInfo?: (
|
||||
provider: string,
|
||||
model: string,
|
||||
) => Promise<Pick<LlmResolvedModelInfo, 'context' | 'reasoning'>>
|
||||
}
|
||||
/** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
|
||||
sessionPersistence?: {
|
||||
@@ -84,6 +101,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(TuiPromptService)
|
||||
const catalog = options.catalog ?? {
|
||||
providers: [{ id: 'deepseek', name: 'DeepSeek' }],
|
||||
models: [
|
||||
@@ -97,12 +115,9 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
},
|
||||
} as never)
|
||||
if (options.configureContext === undefined) {
|
||||
const tools = options.tools ?? {}
|
||||
ctx.provide('tools', {
|
||||
get(name: string) {
|
||||
return tools[name]
|
||||
},
|
||||
} as never)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
for (const tool of Object.values(options.tools ?? {})) ctx.tools.register(tool)
|
||||
} else {
|
||||
await options.configureContext(ctx)
|
||||
}
|
||||
@@ -117,9 +132,20 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
return catalog.listModels?.(provider)
|
||||
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
|
||||
},
|
||||
resolveModelContext(provider: string, model: string) {
|
||||
return catalog.resolveModelContext?.(provider, model)
|
||||
?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
|
||||
async resolveModelInfo(provider: string, model: string) {
|
||||
const advertised = catalog.models.find(candidate =>
|
||||
candidate.provider === provider && candidate.id === model)
|
||||
const capabilities = await (catalog.resolveModelInfo?.(provider, model)
|
||||
?? Promise.resolve({
|
||||
context: { contextWindow: options.contextWindow ?? 128_000 },
|
||||
}))
|
||||
return {
|
||||
provider,
|
||||
id: model,
|
||||
name: advertised?.name ?? model,
|
||||
...advertised?.description === undefined ? {} : { description: advertised.description },
|
||||
...capabilities,
|
||||
}
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
@@ -156,31 +182,57 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
}
|
||||
options.beforeMount?.(session)
|
||||
const sent: ContentBlock[][] = []
|
||||
const sentMessages: UserMessage[] = []
|
||||
const steered: ContentBlock[][] = []
|
||||
const steeredIds: MessageId[] = []
|
||||
const sentOptions: (SendOptions | undefined)[] = []
|
||||
const steeredOptions: (SendOptions | undefined)[] = []
|
||||
const steeredOptions: UserMessage[] = []
|
||||
const injected: ContentBlock[][] = []
|
||||
const injectedOptions: UserMessage[] = []
|
||||
const cancelled: AgentCancelCause[] = []
|
||||
const agent: FakeAgent = {
|
||||
id: sessionId,
|
||||
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
session,
|
||||
status: options.status ?? 'idle',
|
||||
get acceptsNextStep() {
|
||||
return options.acceptsNextStep ?? this.status === 'running'
|
||||
},
|
||||
ctx,
|
||||
sent,
|
||||
sentMessages,
|
||||
sentOptions,
|
||||
steered,
|
||||
steeredIds,
|
||||
steeredOptions,
|
||||
injected,
|
||||
injectedOptions,
|
||||
cancelled,
|
||||
send(content, options) {
|
||||
sent.push(content)
|
||||
send(input, options) {
|
||||
sent.push(input.content)
|
||||
sentMessages.push(input)
|
||||
sentOptions.push(options)
|
||||
return input.id
|
||||
},
|
||||
steer(content, options) {
|
||||
steered.push(content)
|
||||
steeredOptions.push(options)
|
||||
followup(input) {
|
||||
sent.push(input.content)
|
||||
sentMessages.push(input)
|
||||
sentOptions.push(undefined)
|
||||
return input.id
|
||||
},
|
||||
inject() {},
|
||||
cancel(cause = { kind: 'user' }) {
|
||||
steer(input) {
|
||||
steered.push(input.content)
|
||||
steeredOptions.push(input)
|
||||
const id = input.id
|
||||
steeredIds.push(id)
|
||||
return id
|
||||
},
|
||||
inject(input) {
|
||||
injected.push(input.content)
|
||||
injectedOptions.push(input)
|
||||
return input.id
|
||||
},
|
||||
cancel(cause) {
|
||||
cancelled.push(cause)
|
||||
},
|
||||
whenIdle() {
|
||||
@@ -191,7 +243,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
const controller = createTuiChat(ctx, Object.assign({
|
||||
...options.omitWelcome === true ? {} : { welcome: 'Coding agent ready.' },
|
||||
sessionId,
|
||||
color: false,
|
||||
theme: { color: false },
|
||||
}, options.config), {
|
||||
terminal,
|
||||
exit,
|
||||
@@ -201,6 +253,7 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
||||
...(options.now === undefined ? {} : { now: options.now }),
|
||||
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
|
||||
...(options.handoffResume === undefined ? {} : { handoffResume: options.handoffResume }),
|
||||
gitBranch: options.gitBranch ?? (() => 'tui-staging'),
|
||||
})
|
||||
return { ctx, session, agent, terminal, exit, controller }
|
||||
}
|
||||
@@ -215,10 +268,10 @@ export async function disposeTuiTestHarness(
|
||||
|
||||
/** Append a production-shaped user message to the active session surface. */
|
||||
export function appendUser(session: Session, text: string): void {
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
/** Append a production-shaped assistant message to the active session surface. */
|
||||
@@ -230,8 +283,11 @@ export function appendAssistant(
|
||||
): void {
|
||||
session.append('assistant/message', {
|
||||
...position,
|
||||
provenance: { provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
content,
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content,
|
||||
source: { kind: 'model', provider: 'mock', model: 'deepseek-v4-flash' },
|
||||
}),
|
||||
...usage === undefined ? {} : { usage },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ describe('dsh-tui plugin export shape', () => {
|
||||
'llm',
|
||||
'systemPrompt',
|
||||
'tokenMeter',
|
||||
'tuiPrompt',
|
||||
])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
|
||||
169
packages/ui/tui/tests/prompt.spec.ts
Normal file
169
packages/ui/tui/tests/prompt.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import TuiPromptService, {
|
||||
parseTuiPromptTemplate,
|
||||
renderTuiPromptTemplate,
|
||||
} from '../src/prompt.ts'
|
||||
|
||||
const tick = (): Promise<void> => new Promise((resolve) => { queueMicrotask(resolve) })
|
||||
|
||||
describe('TUI prompt values', () => {
|
||||
it('registers, updates, and disposes mutable values', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TuiPromptService)
|
||||
|
||||
const value = ctx.tuiPrompt.register('git/worktree', '\x1b[32m(main)\x1b[0m')
|
||||
expect(ctx.tuiPrompt.get('git/worktree')).toBe('\x1b[32m(main)\x1b[0m')
|
||||
value.set('next')
|
||||
expect(ctx.tuiPrompt.get('git/worktree')).toBe('next')
|
||||
|
||||
value.set(undefined)
|
||||
expect(ctx.tuiPrompt.get('git/worktree')).toBeUndefined()
|
||||
value.dispose()
|
||||
expect(() => { value.set('late') }).toThrow(/disposed/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('coalesces a change burst into one notification and contains each observer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TuiPromptService)
|
||||
// Capture the containment warnings so the rejected-promise and sync-throw
|
||||
// paths are each pinned (removing either catch drops its warning).
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
|
||||
// A synchronous thrower, an async rejecter, and a thrower whose error is
|
||||
// hostile to string coercion all sit BEFORE the observed listener, so
|
||||
// proving `after` still runs proves none of them starves it (a naive
|
||||
// `String(error)` inside the containment would itself throw on the last).
|
||||
const hostile = { toString() { throw new Error('hostile coercion') } }
|
||||
const thrower = vi.fn(() => { throw new Error('sync observer boom') })
|
||||
const rejecter = vi.fn(async () => { throw new Error('async observer boom') })
|
||||
const hostileThrower = vi.fn(() => { throw hostile })
|
||||
const after = vi.fn()
|
||||
ctx.tuiPrompt.subscribe(thrower)
|
||||
ctx.tuiPrompt.subscribe(rejecter)
|
||||
ctx.tuiPrompt.subscribe(hostileThrower)
|
||||
const unsubscribe = ctx.tuiPrompt.subscribe(after)
|
||||
await tick() // drain the registration notifications
|
||||
thrower.mockClear()
|
||||
rejecter.mockClear()
|
||||
hostileThrower.mockClear()
|
||||
after.mockClear()
|
||||
|
||||
const value = ctx.tuiPrompt.register('git/worktree', 'a')
|
||||
value.set('b')
|
||||
value.set('b') // unchanged: no additional schedule
|
||||
value.set('c')
|
||||
await tick()
|
||||
await tick() // settle the contained rejected promise
|
||||
// One coalesced callback for the whole burst; a throwing, rejecting, or
|
||||
// hostile-to-render observer is contained and does not stop later observers.
|
||||
expect(thrower).toHaveBeenCalledTimes(1)
|
||||
expect(rejecter).toHaveBeenCalledTimes(1)
|
||||
expect(hostileThrower).toHaveBeenCalledTimes(1)
|
||||
expect(after).toHaveBeenCalledTimes(1)
|
||||
// Each contained failure logged its own warning: the sync throw, the
|
||||
// rejected promise, and the hostile-to-render throw (via non-throwing
|
||||
// errorChain). Pinning the rejected-promise warning fails if its `.catch`
|
||||
// containment is removed.
|
||||
expect(warnings.some(w => w.includes('threw: sync observer boom'))).toBe(true)
|
||||
expect(warnings.some(w => w.includes('rejected: async observer boom'))).toBe(true)
|
||||
expect(warnings.some(w => w.includes('threw: <unrenderable value>'))).toBe(true)
|
||||
|
||||
// Unsubscribe stops further notifications for that listener.
|
||||
unsubscribe()
|
||||
value.set('d')
|
||||
await tick()
|
||||
expect(after).toHaveBeenCalledTimes(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('removes a subscription when the subscriber fiber disposes', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TuiPromptService)
|
||||
const observed = vi.fn()
|
||||
// Subscribe from a child plugin fiber that shares the service, then dispose
|
||||
// only that fiber; the effect-owned subscription must go with it.
|
||||
const child = ctx.plugin({
|
||||
inject: ['tuiPrompt'],
|
||||
apply: (childCtx) => { childCtx.tuiPrompt.subscribe(observed) },
|
||||
})
|
||||
await tick()
|
||||
observed.mockClear()
|
||||
await child.dispose()
|
||||
|
||||
const value = ctx.tuiPrompt.register('git/worktree', 'a')
|
||||
value.set('b')
|
||||
await tick()
|
||||
expect(observed).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps one fiber\'s subscription when another disposes the same callback', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TuiPromptService)
|
||||
// Both fibers subscribe the SAME function reference. Per-subscription record
|
||||
// identity (not callback identity) keeps them independent, so disposing one
|
||||
// must not silence the other.
|
||||
const shared = vi.fn()
|
||||
const first = ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } })
|
||||
ctx.plugin({ inject: ['tuiPrompt'], apply: (c) => { c.tuiPrompt.subscribe(shared) } })
|
||||
await tick()
|
||||
await first.dispose()
|
||||
shared.mockClear()
|
||||
|
||||
const value = ctx.tuiPrompt.register('git/worktree', 'a')
|
||||
value.set('b')
|
||||
await tick()
|
||||
// The second fiber's subscription survives the first's disposal.
|
||||
expect(shared).toHaveBeenCalledTimes(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not notify a subscription unsubscribed earlier in the same burst', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TuiPromptService)
|
||||
const victim = vi.fn()
|
||||
// This listener is delivered first (subscribed first) and synchronously
|
||||
// unsubscribes the victim during the same notification. The snapshot must
|
||||
// re-check liveness so the later victim record does not fire this burst.
|
||||
ctx.tuiPrompt.subscribe(() => { unsubscribeVictim() })
|
||||
const unsubscribeVictim = ctx.tuiPrompt.subscribe(victim)
|
||||
await tick()
|
||||
victim.mockClear()
|
||||
|
||||
const value = ctx.tuiPrompt.register('git/worktree', 'a')
|
||||
value.set('b')
|
||||
await tick()
|
||||
expect(victim).not.toHaveBeenCalled()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects invalid and duplicate names', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(TuiPromptService)
|
||||
expect(() => ctx.tuiPrompt.register('Bad Name')).toThrow(/must match/)
|
||||
ctx.tuiPrompt.register('status')
|
||||
expect(() => ctx.tuiPrompt.register('status')).toThrow(/already registered/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('TUI prompt templates', () => {
|
||||
it('interpolates values and removes separators around unavailable values', () => {
|
||||
const tokens = parseTuiPromptTemplate('${cwd} ${git/worktree} :: ${missing} ${model}')
|
||||
const values = new Map([['cwd', '/work'], ['model', 'deepseek']])
|
||||
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('/work :: deepseek')
|
||||
})
|
||||
|
||||
it('keeps a trailing literal after the last value', () => {
|
||||
const tokens = parseTuiPromptTemplate('${symbol} ${indicator} > ')
|
||||
const values = new Map([['symbol', 'dsh'], ['indicator', '●']])
|
||||
expect(renderTuiPromptTemplate(tokens, name => values.get(name))).toBe('dsh ● > ')
|
||||
})
|
||||
|
||||
it('preserves trusted ANSI fragments', () => {
|
||||
const powerline = '\x1b[44m work \x1b[34;46m\x1b[0m'
|
||||
expect(renderTuiPromptTemplate(parseTuiPromptTemplate('${powerline}'), () => powerline)).toBe(powerline)
|
||||
})
|
||||
})
|
||||
@@ -9,8 +9,11 @@ export class TestSessionQueryService extends SessionQueryService {
|
||||
}
|
||||
|
||||
override searchEvents(
|
||||
..._args: Parameters<SessionQueryService['searchEvents']>
|
||||
...args: Parameters<SessionQueryService['searchEvents']>
|
||||
): ReturnType<SessionQueryService['searchEvents']> {
|
||||
return Promise.resolve({ items: [] })
|
||||
return this.readSurface(args[0].sessionId).then(surface => ({
|
||||
session: surface.session,
|
||||
items: [],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { createUserMessage, LlmAdapter, type GenerateOptions, type StreamChunk , createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
@@ -12,7 +12,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import CommandService from '@deepseek-ai/dsh-commands'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference'
|
||||
import { createTuiChat } from '../src/index.ts'
|
||||
import { createTuiChat, TuiPromptService } from '../src/index.ts'
|
||||
import { HeadlessTerminal } from './headless-terminal.ts'
|
||||
import { TestSessionQueryService } from './session-query.ts'
|
||||
|
||||
@@ -24,10 +24,13 @@ class SnapshotAdapter extends LlmAdapter {
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const prompt = options.messages.at(-1)
|
||||
if (prompt?.role !== 'user' || prompt.content.length !== 3
|
||||
|| prompt.content[1]?.type !== 'text' || prompt.content[1].text !== '\n\n## My request:\n') {
|
||||
throw new Error('session reference did not reach the model as one prefixed user message')
|
||||
// The snapshot rides the prompt's admission: the loop appends the
|
||||
// prompt first, then its additional contexts (the branch-wide ordering
|
||||
// for plugin-sourced context).
|
||||
const [prompt, context] = options.messages.slice(-2)
|
||||
if (context?.role !== 'user' || prompt?.role !== 'user'
|
||||
|| prompt.content[0]?.type !== 'text' || prompt.content[0].text !== 'Use @Source session') {
|
||||
throw new Error('session reference context did not follow the direct user message')
|
||||
}
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
yield { type: 'text-delta', index: 0, text: 'Combined reference request accepted.' }
|
||||
@@ -48,6 +51,7 @@ function nextIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
|
||||
describe('TUI session-reference snapshot', () => {
|
||||
it('snapshots compacted current-surface context on send and displays only its reference card', async () => {
|
||||
const clock = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 12, 30, 0).getTime())
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -56,6 +60,7 @@ describe('TUI session-reference snapshot', () => {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(CommandService)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(TuiPromptService)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
await ctx.plugin(SessionReferenceService)
|
||||
@@ -63,27 +68,33 @@ describe('TUI session-reference snapshot', () => {
|
||||
const adapter = new SnapshotAdapter()
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: '/workspace/project', createdAt: 1 } })
|
||||
const oldUser = source.append('user/message', {
|
||||
const oldUser = source.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'SHADOWED OLD USER' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
const oldAssistant = source.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'SHADOWED OLD ASSISTANT' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
source.append('user/message', {
|
||||
source.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '<compacted-summary>Retained checkpoint.</compacted-summary>' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: oldUser.seq, end: oldAssistant.seq },
|
||||
sourceEventSeqs: [oldUser.seq, oldAssistant.seq],
|
||||
})
|
||||
source.append('user/message', {
|
||||
source.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'Recent retained question.' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
const target = ctx.agentLoop.create(
|
||||
SessionId('target-session'),
|
||||
@@ -94,7 +105,7 @@ describe('TUI session-reference snapshot', () => {
|
||||
const controller = createTuiChat(ctx, {
|
||||
sessionId: target.id,
|
||||
welcome: 'Session reference snapshot.',
|
||||
color: true,
|
||||
theme: { color: true },
|
||||
title: 'DSH session reference',
|
||||
}, { terminal, exit: () => {} })
|
||||
await terminal.waitForFrame(0)
|
||||
@@ -113,22 +124,17 @@ describe('TUI session-reference snapshot', () => {
|
||||
expect(request).toContain('Recent retained question.')
|
||||
expect(request).not.toContain('SHADOWED OLD USER')
|
||||
expect(request).not.toContain('SHADOWED OLD ASSISTANT')
|
||||
const user = target.session.events.find(event => event.type === 'user/message')
|
||||
expect(user?.type === 'user/message' && user.data.envelope).toMatchObject({
|
||||
displayContent: [{ type: 'text', text: 'Use @Source session' }],
|
||||
prefixContexts: [{
|
||||
source: { kind: 'plugin', plugin: 'session-reference' },
|
||||
meta: {
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'source-session', compacted: true }],
|
||||
},
|
||||
}],
|
||||
const context = target.session.events.find(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'session-reference')
|
||||
expect(context?.type === 'user/message' && context.data.source).toMatchObject({
|
||||
kind: 'session-reference',
|
||||
references: [{ sessionId: 'source-session', compacted: true }],
|
||||
})
|
||||
expect(user?.type === 'user/message' && user.data.content[1]).toEqual({
|
||||
type: 'text',
|
||||
text: '\n\n## My request:\n',
|
||||
})
|
||||
expect(target.session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
const user = target.session.events.find(event =>
|
||||
event.type === 'user/message' && event.data.source.kind === 'user')
|
||||
expect(user?.type === 'user/message' && user.data.content).toEqual([
|
||||
{ type: 'text', text: 'Use @Source session' },
|
||||
])
|
||||
|
||||
const snapshot = await terminal.snapshot({ includeScrollback: true })
|
||||
if (REFRESHING) {
|
||||
@@ -140,5 +146,6 @@ describe('TUI session-reference snapshot', () => {
|
||||
await controller.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
await terminal.dispose()
|
||||
clock.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,99 +1,69 @@
|
||||
terminal 100x40 buffer=normal length=40 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=36 bufferRow=36
|
||||
cursor hidden column=7 viewportRow=34 bufferRow=34
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "▌ "
|
||||
style 0-0 fg=green
|
||||
5| "▌ ✓ pnpm run test:coverage "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-25 bold
|
||||
6| "▌ Run the coverage gate "
|
||||
style 0-0 fg=green
|
||||
style 2-22 fg=bright-black
|
||||
7| "▌ /workspace/project "
|
||||
style 0-0 fg=green
|
||||
style 2-19 dim
|
||||
8| "▌ … +4 lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=green
|
||||
style 2-30 dim
|
||||
9| "▌ [exit 0] "
|
||||
style 0-0 fg=green
|
||||
style 2-9 dim
|
||||
10| "▌ "
|
||||
style 0-0 fg=green
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
7| "$ pnpm run test:coverage "
|
||||
style 0-23 fg=cyan
|
||||
8| "/workspace/project "
|
||||
style 0-17 dim
|
||||
9| "… +4 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
10| "[exit 0] "
|
||||
style 0-7 dim
|
||||
11| <blank>
|
||||
12| "▌ "
|
||||
style 0-0 fg=green
|
||||
13| "▌ ✓ Edit renderer "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-16 bold
|
||||
14| "▌ src/view.ts "
|
||||
style 0-0 fg=green
|
||||
style 2-12 bold
|
||||
15| "▌ - old line "
|
||||
style 0-0 fg=green
|
||||
style 2-11 fg=red
|
||||
16| "▌ … +5 lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=green
|
||||
style 2-30 dim
|
||||
17| "▌ + expect(screen).toMatchSnapshot() "
|
||||
style 0-0 fg=green
|
||||
style 2-35 fg=green
|
||||
18| "▌ "
|
||||
style 0-0 fg=green
|
||||
19| <blank>
|
||||
20| "▌ "
|
||||
style 0-0 fg=green
|
||||
21| "▌ ✓ Delegate renderer audit "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-26 bold
|
||||
22| "▌ The renderer has explicit lifecycle ownership. "
|
||||
style 0-0 fg=green
|
||||
23| "▌ "
|
||||
style 0-0 fg=green
|
||||
24| <blank>
|
||||
25| "▌ "
|
||||
style 0-0 fg=green
|
||||
26| "▌ ✓ Read output from background task subagent-7 "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-46 bold
|
||||
27| "▌ audit complete "
|
||||
style 0-0 fg=green
|
||||
28| "▌ [status: completed] "
|
||||
style 0-0 fg=green
|
||||
29| "▌ "
|
||||
style 0-0 fg=green
|
||||
30| <blank>
|
||||
31| "▌ "
|
||||
style 0-0 fg=green
|
||||
32| "▌ ✓ Load skill dsh-code-review "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-29 bold
|
||||
33| "▌ Loaded review instructions. "
|
||||
style 0-0 fg=green
|
||||
34| "▌ "
|
||||
style 0-0 fg=green
|
||||
35| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
36| " "
|
||||
style 1-1 inverse
|
||||
37| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
38| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 73-99 dim
|
||||
39| <blank>
|
||||
12| "● Tool / edit"
|
||||
style 0-12 fg=green
|
||||
13| "src/view.ts "
|
||||
style 0-10 bold
|
||||
14| "- old line "
|
||||
style 0-9 fg=red
|
||||
15| "… +3 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
16| "└ +2 -2 · 1 file "
|
||||
style 0-15 dim
|
||||
17| <blank>
|
||||
18| "● Tool / subagent"
|
||||
style 0-16 fg=green
|
||||
19| "Delegate renderer audit "
|
||||
20| "The renderer has explicit lifecycle ownership. "
|
||||
21| <blank>
|
||||
22| "● Tool / task_output"
|
||||
style 0-19 fg=green
|
||||
23| "Read output from background task subagent-7 "
|
||||
24| " "
|
||||
25| "… +2 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
26| " "
|
||||
27| <blank>
|
||||
28| "● Tool / skill"
|
||||
style 0-13 fg=green
|
||||
29| "Load skill dsh-code-review "
|
||||
30| "Loaded review instructions. "
|
||||
31| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
32| <blank>
|
||||
33| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
34| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-7 inverse
|
||||
35-39| <blank>
|
||||
|
||||
@@ -1,117 +1,79 @@
|
||||
terminal 100x40 buffer=normal length=48 base=8 viewport=8
|
||||
terminal 100x40 buffer=normal length=43 base=3 viewport=3
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=37 bufferRow=45
|
||||
cursor hidden column=7 viewportRow=39 bufferRow=42
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "▌ "
|
||||
style 0-0 fg=green
|
||||
5| "▌ ✓ pnpm run test:coverage "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-25 bold
|
||||
6| "▌ Run the coverage gate "
|
||||
style 0-0 fg=green
|
||||
style 2-22 fg=bright-black
|
||||
7| "▌ /workspace/project "
|
||||
style 0-0 fg=green
|
||||
style 2-19 dim
|
||||
8| "▌ packages/ui/tui 100% "
|
||||
style 0-0 fg=green
|
||||
9| "▌ 4016 tests passed "
|
||||
style 0-0 fg=green
|
||||
10| "▌ 1 test skipped "
|
||||
style 0-0 fg=green
|
||||
11| "▌ coverage complete "
|
||||
style 0-0 fg=green
|
||||
12| "▌ [exit 0] "
|
||||
style 0-0 fg=green
|
||||
style 2-9 dim
|
||||
13| "▌ "
|
||||
style 0-0 fg=green
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "● Tool / bash / Run the coverage gate"
|
||||
style 0-36 fg=green
|
||||
7| "$ pnpm run test:coverage "
|
||||
style 0-23 fg=cyan
|
||||
8| "/workspace/project "
|
||||
style 0-17 dim
|
||||
9| "packages/ui/tui 100% "
|
||||
10| "4016 tests passed "
|
||||
11| "1 test skipped "
|
||||
12| "coverage complete "
|
||||
13| "[exit 0] "
|
||||
style 0-7 dim
|
||||
14| <blank>
|
||||
15| "▌ "
|
||||
style 0-0 fg=green
|
||||
16| "▌ ✓ Edit renderer "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-16 bold
|
||||
17| "▌ src/view.ts "
|
||||
style 0-0 fg=green
|
||||
style 2-12 bold
|
||||
18| "▌ - old line "
|
||||
style 0-0 fg=green
|
||||
style 2-11 fg=red
|
||||
19| "▌ - keep "
|
||||
style 0-0 fg=green
|
||||
style 2-7 fg=red
|
||||
20| "▌ + new line "
|
||||
style 0-0 fg=green
|
||||
style 2-11 fg=green
|
||||
21| "▌ + keep "
|
||||
style 0-0 fg=green
|
||||
style 2-7 fg=green
|
||||
22| "▌ "
|
||||
style 0-0 fg=green
|
||||
23| "▌ tests/view.spec.ts "
|
||||
style 0-0 fg=green
|
||||
style 2-19 bold
|
||||
24| "▌ + expect(screen).toMatchSnapshot() "
|
||||
style 0-0 fg=green
|
||||
style 2-35 fg=green
|
||||
25| "▌ "
|
||||
style 0-0 fg=green
|
||||
15| "● Tool / edit"
|
||||
style 0-12 fg=green
|
||||
16| "src/view.ts "
|
||||
style 0-10 bold
|
||||
17| "- old line "
|
||||
style 0-9 fg=red
|
||||
18| "- keep "
|
||||
style 0-5 fg=red
|
||||
19| "+ new line "
|
||||
style 0-9 fg=green
|
||||
20| "+ keep "
|
||||
style 0-5 fg=green
|
||||
21| "└ +2 -2 · 1 file "
|
||||
style 0-15 dim
|
||||
22| <blank>
|
||||
23| "● Tool / subagent"
|
||||
style 0-16 fg=green
|
||||
24| "Delegate renderer audit "
|
||||
25| "The renderer has explicit lifecycle ownership. "
|
||||
26| <blank>
|
||||
27| "▌ "
|
||||
style 0-0 fg=green
|
||||
28| "▌ ✓ Delegate renderer audit "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-26 bold
|
||||
29| "▌ The renderer has explicit lifecycle ownership. "
|
||||
style 0-0 fg=green
|
||||
30| "▌ "
|
||||
style 0-0 fg=green
|
||||
31| <blank>
|
||||
32| "▌ "
|
||||
style 0-0 fg=green
|
||||
33| "▌ ✓ Read output from background task subagent-7 "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-46 bold
|
||||
34| "▌ audit complete "
|
||||
style 0-0 fg=green
|
||||
35| "▌ [status: completed] "
|
||||
style 0-0 fg=green
|
||||
36| "▌ "
|
||||
style 0-0 fg=green
|
||||
37| <blank>
|
||||
38| "▌ "
|
||||
style 0-0 fg=green
|
||||
39| "▌ ✓ Load skill dsh-code-review "
|
||||
style 0-0 fg=green
|
||||
style 2-2 fg=green bold
|
||||
style 3-29 bold
|
||||
40| "▌ Loaded review instructions. "
|
||||
style 0-0 fg=green
|
||||
41| "▌ "
|
||||
style 0-0 fg=green
|
||||
42| <blank>
|
||||
43| " Tool cards expanded. "
|
||||
style 1-20 fg=bright-black
|
||||
44| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
45| " "
|
||||
style 1-1 inverse
|
||||
46| "────────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-99 dim
|
||||
47| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:expanded"
|
||||
style 0-43 dim
|
||||
style 74-99 dim
|
||||
27| "● Tool / task_output"
|
||||
style 0-19 fg=green
|
||||
28| "Read output from background task subagent-7 "
|
||||
29| " "
|
||||
30| "console "
|
||||
style 0-6 dim
|
||||
31| " started background task bash-5 "
|
||||
style 2-31 fg=cyan
|
||||
32| " "
|
||||
33| <blank>
|
||||
34| "● Tool / skill"
|
||||
style 0-13 fg=green
|
||||
35| "Load skill dsh-code-review "
|
||||
36| "Loaded review instructions. "
|
||||
37| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
38| <blank>
|
||||
39| "Tool cards expanded. "
|
||||
style 0-19 fg=bright-black
|
||||
40| <blank>
|
||||
41| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
42| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-7 inverse
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=4 bufferRow=4
|
||||
cursor hidden column=7 viewportRow=8 bufferRow=8
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-1 fg=#4d6bfe bold
|
||||
@@ -15,15 +15,22 @@ viewport
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
3| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
4| " "
|
||||
style 1-1 inverse
|
||||
5| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 69-95 dim
|
||||
7-35| <blank>
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
8| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-7 inverse
|
||||
9-35| <blank>
|
||||
|
||||
@@ -1,42 +1,37 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=13 bufferRow=13
|
||||
cursor hidden column=7 viewportRow=15 bufferRow=15
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
5| "▌ ◌ const first = await tools.bash({ command: 'echo CODE_ONE' }) "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-95 bold
|
||||
6| "▌ const second = await tools.bas "
|
||||
style 0-0 fg=yellow
|
||||
style 2-31 bold
|
||||
7| "▌ const first = await tools.bash({ command: 'echo CODE_ONE' }) "
|
||||
style 0-0 fg=yellow
|
||||
8| "▌ const second = await tools.bash({ command: 'echo CODE_TWO' }) "
|
||||
style 0-0 fg=yellow
|
||||
9| "▌ console.log(first, second) "
|
||||
style 0-0 fg=yellow
|
||||
10| "▌ return `${first}+${second}` "
|
||||
style 0-0 fg=yellow
|
||||
11| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
13| " "
|
||||
style 1-1 inverse
|
||||
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
15| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 69-95 dim
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "○ Tool / run_code"
|
||||
style 0-16 fg=yellow
|
||||
7| "Echo two markers and combine them "
|
||||
8| "const first = await tools.bash({ command: 'echo CODE_ONE' }) "
|
||||
9| "const second = await tools.bash({ command: 'echo CODE_TWO' }) "
|
||||
10| "console.log(first, second) "
|
||||
11| "return `${first}+${second}` "
|
||||
12| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
13| <blank>
|
||||
14| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
15| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-7 inverse
|
||||
16-35| <blank>
|
||||
|
||||
@@ -1,46 +1,45 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=active
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=17 bufferRow=17
|
||||
cursor hidden column=7 viewportRow=18 bufferRow=18
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
5| "▌ You "
|
||||
style 0-0 fg=bright-blue
|
||||
style 2-4 fg=bright-blue bold
|
||||
6| "▌ Show the live update. "
|
||||
style 0-0 fg=bright-blue
|
||||
7| "▌ "
|
||||
style 0-0 fg=bright-blue
|
||||
8| <blank>
|
||||
9| " Reasoning "
|
||||
style 1-9 fg=bright-black italic
|
||||
10| " Inspecting width and styles. "
|
||||
style 1-28 fg=bright-black italic
|
||||
11| <blank>
|
||||
12| " Assistant "
|
||||
style 1-9 fg=bright-magenta bold
|
||||
13| " Streaming visible state… "
|
||||
style 11-23 bold
|
||||
14| <blank>
|
||||
15| " ⠋ Responding 0s · total 0s — Enter sends steering, Esc cancels "
|
||||
style 1-1 fg=bright-blue
|
||||
style 3-62 fg=bright-black
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 fg=bright-blue
|
||||
17| " "
|
||||
style 1-1 inverse
|
||||
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 fg=bright-blue
|
||||
19| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 69-95 dim
|
||||
20-35| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Reasoning "
|
||||
style 0-8 fg=bright-black italic
|
||||
6| "Inspecting width and styles. "
|
||||
style 0-27 fg=bright-black italic
|
||||
7| "Streaming visible state… "
|
||||
style 10-22 bold
|
||||
8| " "
|
||||
9| "ts "
|
||||
style 0-1 dim
|
||||
10| " const visible = true "
|
||||
style 2-21 fg=cyan
|
||||
11| " "
|
||||
12| "Model wait 1.0s · Thinking 2.0s "
|
||||
style 0-30 dim
|
||||
13| <blank>
|
||||
14| "You "
|
||||
style 0-2 fg=bright-blue bold underline
|
||||
15| "Show the live update. "
|
||||
16| <blank>
|
||||
17| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
18| " dsh ● press enter to steer and esc to cancel "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-44 dim
|
||||
19-35| <blank>
|
||||
|
||||
@@ -1,49 +1,45 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=16 bufferRow=16
|
||||
cursor hidden column=7 viewportRow=21 bufferRow=21
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "▌ ◌ Inspect cordis runtime: tools "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-32 bold
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
7| "▌ ◌ Mount plugin into live cordis runtime "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-40 bold
|
||||
8| "▌ { "
|
||||
style 0-0 fg=yellow
|
||||
9| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { "
|
||||
style 0-0 fg=yellow
|
||||
10| "▌ ready: true }) } }\" "
|
||||
style 0-0 fg=yellow
|
||||
11| "▌ } "
|
||||
style 0-0 fg=yellow
|
||||
12| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
13| <blank>
|
||||
14| "▌ ◌ Unmount dyn-1 "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-16 bold
|
||||
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
16| " "
|
||||
style 1-1 inverse
|
||||
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
18| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 69-95 dim
|
||||
19-35| <blank>
|
||||
6| "○ Tool / cordis_inspect"
|
||||
style 0-22 fg=yellow
|
||||
7| "Inspect cordis runtime: tools "
|
||||
8| <blank>
|
||||
9| "○ Tool / cordis_mount"
|
||||
style 0-20 fg=yellow
|
||||
10| "Mount temporary Cordis Plugin "
|
||||
11| "{ "
|
||||
12| " \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready:"
|
||||
13| "true }) } }\" "
|
||||
14| "} "
|
||||
15| <blank>
|
||||
16| "○ Tool / cordis_unmount"
|
||||
style 0-22 fg=yellow
|
||||
17| "Unmount temporary Cordis Plugin dyn-1 "
|
||||
18| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
19| <blank>
|
||||
20| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
21| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-7 inverse
|
||||
22-35| <blank>
|
||||
|
||||
@@ -1,63 +1,78 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
terminal 92x32 buffer=normal length=38 base=6 viewport=6
|
||||
lifecycle started=1 stopped=1 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor visible column=0 viewportRow=31 bufferRow=31
|
||||
cursor visible column=0 viewportRow=31 bufferRow=37
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| " Keyboard shortcuts "
|
||||
style 1-18 fg=bright-blue bold
|
||||
5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 1-61 fg=bright-black
|
||||
6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
|
||||
style 1-75 fg=bright-black
|
||||
7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 1-73 fg=bright-black
|
||||
8| " "
|
||||
9| " /clear — Clear the transcript view (session history is unchanged) "
|
||||
style 1-65 fg=bright-black
|
||||
10| " /exit — Exit after the active turn reaches idle "
|
||||
style 1-47 fg=bright-black
|
||||
11| " /help — Show keyboard shortcuts and commands "
|
||||
style 1-44 fg=bright-black
|
||||
12| " /model [[provider/]model] — Show or switch this session's model "
|
||||
style 1-63 fg=bright-black
|
||||
13| " /reasoning — Toggle reasoning blocks "
|
||||
style 1-36 fg=bright-black
|
||||
14| " /redraw — Invalidate components and redraw the terminal "
|
||||
style 1-55 fg=bright-black
|
||||
15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 1-88 fg=bright-black
|
||||
16| " /resume — List this workspace's resumable sessions "
|
||||
style 1-50 fg=bright-black
|
||||
17| " /status — Show detailed session diagnostics "
|
||||
style 1-43 fg=bright-black
|
||||
18| " /tools — Expand or collapse all tool cards "
|
||||
style 1-42 fg=bright-black
|
||||
19| " /skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 1-65 fg=bright-black
|
||||
20| <blank>
|
||||
21| " provider stream failed after partial output "
|
||||
style 1-43 fg=red
|
||||
22| <blank>
|
||||
23| " The previous process ended during this turn. "
|
||||
style 1-44 fg=yellow
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 "
|
||||
style 0-46 dim
|
||||
6| <blank>
|
||||
7| "Keyboard shortcuts "
|
||||
style 0-17 fg=bright-blue bold
|
||||
8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 0-60 fg=bright-black
|
||||
9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
|
||||
style 0-74 fg=bright-black
|
||||
10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 0-72 fg=bright-black
|
||||
11| " "
|
||||
12| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 fg=bright-black
|
||||
13| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 fg=bright-black
|
||||
14| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 fg=bright-black
|
||||
15| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 fg=bright-black
|
||||
16| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 fg=bright-black
|
||||
17| "/reasoning — Toggle reasoning blocks "
|
||||
style 0-35 fg=bright-black
|
||||
18| "/redraw — Invalidate components and redraw the terminal "
|
||||
style 0-54 fg=bright-black
|
||||
19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 0-87 fg=bright-black
|
||||
20| "/resume — List this workspace's resumable sessions "
|
||||
style 0-49 fg=bright-black
|
||||
21| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
style 0-70 fg=bright-black
|
||||
22| "/tools — Expand or collapse all tool cards "
|
||||
style 0-41 fg=bright-black
|
||||
23| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 fg=bright-black
|
||||
24| <blank>
|
||||
25| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
26| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
27| " "
|
||||
style 1-1 inverse
|
||||
28| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 65-91 dim
|
||||
30-31| <blank>
|
||||
25| "provider stream failed after partial output "
|
||||
style 0-42 fg=red
|
||||
26| <blank>
|
||||
27| "The previous process ended during this turn. "
|
||||
style 0-43 fg=yellow
|
||||
28| <blank>
|
||||
29| "Turn stopped: the agent was disposed. "
|
||||
style 0-36 fg=yellow
|
||||
30| <blank>
|
||||
31| "Turn ended: plugin-policy. "
|
||||
style 0-25 fg=yellow
|
||||
32| <blank>
|
||||
33| "Unknown command: /unknown-advanced-command "
|
||||
style 0-41 fg=yellow
|
||||
34| <blank>
|
||||
35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
36| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-7 inverse
|
||||
37| <blank>
|
||||
|
||||
@@ -1,46 +1,40 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=15 bufferRow=15
|
||||
cursor hidden column=7 viewportRow=17 bufferRow=17
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
5| "▌ ◌ workflow: tui-matrix "
|
||||
style 0-0 fg=yellow
|
||||
style 2-2 fg=yellow bold
|
||||
style 3-23 bold
|
||||
6| "▌ phase('Inspect') "
|
||||
style 0-0 fg=yellow
|
||||
7| "▌ const reports = await parallel([ "
|
||||
style 0-0 fg=yellow
|
||||
8| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), "
|
||||
style 0-0 fg=yellow
|
||||
9| "▌ … +1 lines (Ctrl+O to expand) "
|
||||
style 0-0 fg=yellow
|
||||
style 2-30 dim
|
||||
10| "▌ ]) "
|
||||
style 0-0 fg=yellow
|
||||
11| "▌ phase('Verify') "
|
||||
style 0-0 fg=yellow
|
||||
12| "▌ return { reports, verdict: 'covered' } "
|
||||
style 0-0 fg=yellow
|
||||
13| "▌ "
|
||||
style 0-0 fg=yellow
|
||||
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
15| " "
|
||||
style 1-1 inverse
|
||||
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
17| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 69-95 dim
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| <blank>
|
||||
6| "○ Tool / workflow"
|
||||
style 0-16 fg=yellow
|
||||
7| "workflow: tui-matrix "
|
||||
8| "phase('Inspect') "
|
||||
9| "const reports = await parallel([ "
|
||||
10| "… +2 lines (Ctrl+O to expand) "
|
||||
style 0-28 dim
|
||||
11| "]) "
|
||||
12| "phase('Verify') "
|
||||
13| "return { reports, verdict: 'covered' } "
|
||||
14| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
15| <blank>
|
||||
16| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
17| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-7 inverse
|
||||
18-35| <blank>
|
||||
|
||||
@@ -1,63 +1,77 @@
|
||||
terminal 92x32 buffer=normal length=32 base=0 viewport=0
|
||||
terminal 92x32 buffer=normal length=37 base=5 viewport=5
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=1 viewportRow=27 bufferRow=27
|
||||
cursor hidden column=7 viewportRow=31 bufferRow=36
|
||||
buffer
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| " Keyboard shortcuts "
|
||||
style 1-18 fg=bright-blue bold
|
||||
5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 1-61 fg=bright-black
|
||||
6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
|
||||
style 1-75 fg=bright-black
|
||||
7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 1-73 fg=bright-black
|
||||
8| " "
|
||||
9| " /clear — Clear the transcript view (session history is unchanged) "
|
||||
style 1-65 fg=bright-black
|
||||
10| " /exit — Exit after the active turn reaches idle "
|
||||
style 1-47 fg=bright-black
|
||||
11| " /help — Show keyboard shortcuts and commands "
|
||||
style 1-44 fg=bright-black
|
||||
12| " /model [[provider/]model] — Show or switch this session's model "
|
||||
style 1-63 fg=bright-black
|
||||
13| " /reasoning — Toggle reasoning blocks "
|
||||
style 1-36 fg=bright-black
|
||||
14| " /redraw — Invalidate components and redraw the terminal "
|
||||
style 1-55 fg=bright-black
|
||||
15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 1-88 fg=bright-black
|
||||
16| " /resume — List this workspace's resumable sessions "
|
||||
style 1-50 fg=bright-black
|
||||
17| " /status — Show detailed session diagnostics "
|
||||
style 1-43 fg=bright-black
|
||||
18| " /tools — Expand or collapse all tool cards "
|
||||
style 1-42 fg=bright-black
|
||||
19| " /skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 1-65 fg=bright-black
|
||||
20| <blank>
|
||||
21| " provider stream failed after partial output "
|
||||
style 1-43 fg=red
|
||||
22| <blank>
|
||||
23| " The previous process ended during this turn. "
|
||||
style 1-44 fg=yellow
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s · Completed 2026-07-21 15:05:00 "
|
||||
style 0-46 dim
|
||||
6| <blank>
|
||||
7| "Keyboard shortcuts "
|
||||
style 0-17 fg=bright-blue bold
|
||||
8| "Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
|
||||
style 0-60 fg=bright-black
|
||||
9| "Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
|
||||
style 0-74 fg=bright-black
|
||||
10| "Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
|
||||
style 0-72 fg=bright-black
|
||||
11| " "
|
||||
12| "/clear — Clear the transcript view (session history is unchanged) "
|
||||
style 0-64 fg=bright-black
|
||||
13| "/exit — Exit after the active turn reaches idle "
|
||||
style 0-46 fg=bright-black
|
||||
14| "/help — Show keyboard shortcuts and commands "
|
||||
style 0-43 fg=bright-black
|
||||
15| "/model [[provider/]model] — Show or switch this session's model "
|
||||
style 0-62 fg=bright-black
|
||||
16| "/quit — Exit after the active turn reaches idle "
|
||||
style 0-46 fg=bright-black
|
||||
17| "/reasoning — Toggle reasoning blocks "
|
||||
style 0-35 fg=bright-black
|
||||
18| "/redraw — Invalidate components and redraw the terminal "
|
||||
style 0-54 fg=bright-black
|
||||
19| "/reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
|
||||
style 0-87 fg=bright-black
|
||||
20| "/resume — List this workspace's resumable sessions "
|
||||
style 0-49 fg=bright-black
|
||||
21| "/status — Show session diagnostics, system prompt, and registered tools "
|
||||
style 0-70 fg=bright-black
|
||||
22| "/tools — Expand or collapse all tool cards "
|
||||
style 0-41 fg=bright-black
|
||||
23| "/skill:<name> [instructions] — load a skill into the conversation "
|
||||
style 0-64 fg=bright-black
|
||||
24| <blank>
|
||||
25| " Unknown command: /unknown-advanced-command "
|
||||
style 1-42 fg=yellow
|
||||
26| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
27| " "
|
||||
style 1-1 inverse
|
||||
28| "────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-91 dim
|
||||
29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 65-91 dim
|
||||
30-31| <blank>
|
||||
25| "provider stream failed after partial output "
|
||||
style 0-42 fg=red
|
||||
26| <blank>
|
||||
27| "The previous process ended during this turn. "
|
||||
style 0-43 fg=yellow
|
||||
28| <blank>
|
||||
29| "Turn stopped: the agent was disposed. "
|
||||
style 0-36 fg=yellow
|
||||
30| <blank>
|
||||
31| "Turn ended: plugin-policy. "
|
||||
style 0-25 fg=yellow
|
||||
32| <blank>
|
||||
33| "Unknown command: /unknown-advanced-command "
|
||||
style 0-41 fg=yellow
|
||||
34| <blank>
|
||||
35| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
36| " dsh > "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 7-7 inverse
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
terminal 96x36 buffer=normal length=36 base=0 viewport=0
|
||||
lifecycle started=1 stopped=0 progress=inactive
|
||||
title "DSH snapshot"
|
||||
cursor hidden column=5 viewportRow=4 bufferRow=4
|
||||
cursor hidden column=11 viewportRow=8 bufferRow=8
|
||||
viewport
|
||||
0| " DEEPSEEK HARNESS"
|
||||
style 1-8 fg=bright-blue bold
|
||||
style 10-16 bold
|
||||
1| " Snapshot agent ready."
|
||||
style 1-21 fg=bright-black
|
||||
2| " deepseek-v4-flash • main-session"
|
||||
style 1-34 dim
|
||||
3| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
4| " @tsc "
|
||||
style 5-5 inverse
|
||||
5| "────────────────────────────────────────────────────────────────────────────────────────────────"
|
||||
style 0-95 dim
|
||||
6| " → File · terminal-special-case.t src/terminal-special-case.ts "
|
||||
style 1-32 fg=bright-blue
|
||||
7| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
|
||||
style 0-43 dim
|
||||
style 69-95 dim
|
||||
8-35| <blank>
|
||||
2| " main-session"
|
||||
style 1-12 dim
|
||||
3| <blank>
|
||||
4| "Assistant "
|
||||
style 0-8 fg=bright-magenta bold underline
|
||||
5| "Model wait 0.0s "
|
||||
style 0-14 dim
|
||||
6| <blank>
|
||||
7| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
|
||||
style 0-17 fg=bright-blue bold
|
||||
style 18-31 fg=bright-black
|
||||
style 34-50 fg=bright-black
|
||||
style 53-57 fg=bright-black
|
||||
style 60-69 fg=bright-black
|
||||
8| " dsh > @tsc "
|
||||
style 1-3 fg=bright-blue bold
|
||||
style 5-6 fg=bright-black
|
||||
style 11-11 inverse
|
||||
9| " → File · terminal-special-case.t src/terminal-special-case.ts "
|
||||
style 7-38 fg=bright-blue
|
||||
10-35| <blank>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user