Merge remote-tracking branch 'origin/master' into tool-cordis

# Conflicts:
#	docs/capability-seams.md
#	packages/core/tools/tests/gen-tool-catalog.spec.ts
#	scripts/gen-doc-graphs.ts
This commit is contained in:
imccyu
2026-07-09 22:02:06 +08:00
52 changed files with 2090 additions and 32 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai
cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime
hooks/ Claude Code / Codex hook bridges + shared wire-protocol library
session-persistence/ persistence seam + JSONL/SQLite backends
ui/ ACP bridge + app-boot glue + the stdio/ACP app bins
ui/ ACP bridge, app-boot glue, stdio/ACP app bins, user-interaction seam, ask-user tool
support/ dev/test infrastructure packages
util/ zero-dependency utilities
examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md)
+13 -2
View File
@@ -30,12 +30,15 @@ flowchart LR
pkg_tool_fs["tool-fs"]
pkg_tool_web["tool-web"]
svc_tools["ctx.tools<br/>Tool registry and execution waterfall"]
pkg_tool_ask_user["tool-ask-user"]
pkg_tool_bash["tool-bash"]
pkg_tool_cordis["tool-cordis"]
pkg_tool_subagent["tool-subagent"]
pkg_tool_todo["tool-todo"]
svc_agents["ctx.agents<br/>Agent registry"]
pkg_user_interaction["user-interaction"]
svc_userInteraction["ctx.userInteraction<br/>Human question/answer seam"]
pkg_stdio_agent["stdio-agent"]
svc_agents["ctx.agents<br/>Agent registry"]
svc_agentLoop["ctx.agentLoop<br/>Concrete loop driver"]
pkg_agent_core["agent-core"]
pkg_bash["bash"]
@@ -64,6 +67,7 @@ flowchart LR
pkg_web_search_perplexity["web-search-perplexity"]
pkg_web_search_deepseek["web-search-deepseek"]
pkg_web_fetch_local["web-fetch-local"]
pkg_acp --> svc_userInteraction
pkg_agent --> svc_agents
pkg_agent_loop --> svc_agentLoop
pkg_bash --> svc_bash
@@ -82,6 +86,7 @@ flowchart LR
pkg_session_persistence --> svc_sessionPersistence
pkg_session_persistence_jsonl --> svc_sessionPersistence
pkg_session_persistence_sqlite --> svc_sessionPersistence
pkg_stdio_agent --> svc_userInteraction
pkg_subagent --> svc_subagents
pkg_subagent_acp --> svc_subagents
pkg_subagent_fork --> svc_subagents
@@ -89,6 +94,7 @@ flowchart LR
pkg_subagent_spawn --> svc_subagents
pkg_system_prompt --> svc_systemPrompt
pkg_tools --> svc_tools
pkg_user_interaction --> svc_userInteraction
pkg_web --> svc_web
pkg_web_fetch_local --> svc_web
pkg_web_search_deepseek --> svc_web
@@ -121,12 +127,16 @@ flowchart LR
svc_systemPrompt --> pkg_tools
svc_tools --> pkg_acp
svc_tools --> pkg_agent_loop
svc_tools --> pkg_tool_ask_user
svc_tools --> pkg_tool_bash
svc_tools --> pkg_tool_cordis
svc_tools --> pkg_tool_fs
svc_tools --> pkg_tool_subagent
svc_tools --> pkg_tool_todo
svc_tools --> pkg_tool_web
svc_userInteraction --> pkg_acp
svc_userInteraction --> pkg_stdio_agent
svc_userInteraction --> pkg_tool_ask_user
svc_web --> pkg_tool_web
svc_fs -. event gate .-> pkg_fs_policy
```
@@ -137,7 +147,8 @@ flowchart LR
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. |
| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute. |
| `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. |
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`stdio-agent`](../packages/ui/stdio-agent), [`invariants`](../packages/support/invariants) | - | Owns live Agent handles and the create/resume factory seam. |
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-core`](../packages/core/agent-core) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
| `ctx.bash` | `seam` | [`bash`](../packages/bash/bash) | [`bash-local`](../packages/bash/bash-local) | [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | - | The model-facing bash tools and hook bridges consume this seam; sandboxed or remote executors can replace bash-local. |
+6 -4
View File
@@ -11,7 +11,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml`
## `@deepseek-ai/dsh-acp`
Requires: `agents` · `sessions` · `sessionPersistence` · `tools`
Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInteraction`
```ts config-catalog
/** Plugin config: the agent template ACP sessions are created from. */
@@ -31,7 +31,7 @@ export interface AcpConfig {
Depends on: `Stream` (`@agentclientprotocol/sdk`)
Source: [`packages/ui/acp/src/index.ts:115`](../packages/ui/acp/src/index.ts)
Source: [`packages/ui/acp/src/index.ts:236`](../packages/ui/acp/src/index.ts)
## `@deepseek-ai/dsh-acp-agent`
@@ -56,7 +56,7 @@ export interface Config {
}
```
Source: [`packages/ui/acp-agent/src/index.ts:49`](../packages/ui/acp-agent/src/index.ts)
Source: [`packages/ui/acp-agent/src/index.ts:50`](../packages/ui/acp-agent/src/index.ts)
## `@deepseek-ai/dsh-agent-core`
@@ -505,7 +505,7 @@ export interface Config {
}
```
Source: [`packages/ui/stdio-agent/src/index.ts:60`](../packages/ui/stdio-agent/src/index.ts)
Source: [`packages/ui/stdio-agent/src/index.ts:62`](../packages/ui/stdio-agent/src/index.ts)
## `@deepseek-ai/dsh-subagent-acp`
@@ -884,9 +884,11 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
- `@deepseek-ai/dsh-tool-bash` — requires `tools` · `bash` · `systemPrompt` ([`packages/bash/tool-bash/src/index.ts`](../packages/bash/tool-bash/src/index.ts))
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
- `@deepseek-ai/dsh-tools` — requires `systemPrompt` ([`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts))
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
## Seam packages (not directly loadable)
+11
View File
@@ -226,6 +226,17 @@ Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../
Source: [`packages/core/tools/src/index.ts:307`](../../packages/core/tools/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
`ctx.userInteraction`: one active UI provider plus an `ask()` surface.
```ts cordis-catalog
registerProvider(provider: UserInteractionProvider): () => void
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
```
Source: [`packages/ui/user-interaction/src/index.ts:82`](../../packages/ui/user-interaction/src/index.ts)
## `ctx.web` — `WebService`
The web access service. Registered as `ctx.web` (one instance per context).
+1
View File
@@ -19,6 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
| [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
| [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
| [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline |
| [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
| [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |
| [code-runtime.md](code-runtime.md) | the code-execution seam: `CodeRunRequest`/`Result`, binding namespaces, captured logs, the `CodeRunFailure` taxonomy |
| [filesystem.md](filesystem.md) | the filesystem seam: `FsTarget`, read/write/edit outcomes, observed-file state, `FsErrorCode` |
@@ -0,0 +1,97 @@
# User Interaction
The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-agent` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations.
Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts)
## Question options
`AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text.
```ts type-equiv
interface AskUserQuestionOption {
/** User-facing label. */
label: string
/** Optional extra context rendered by capable UIs. */
description?: string
}
```
## Question item
`AskUserQuestionItem` is one question in a request. The model supplies a stable `id`, which is echoed back with the answer so batched questions remain routable.
```ts type-equiv
interface AskUserQuestionItem {
/** Stable model-provided question id, echoed in the answer. */
id: string
/** The question to display. */
question: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */
options?: AskUserQuestionOption[]
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
}
```
## Ask request
`AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer.
```ts type-equiv
interface AskUserQuestionRequest {
/** Questions to display. */
questions: AskUserQuestionItem[]
/** Calling agent, when the request came from an agent tool call. */
agent?: Agent
/** Abort signal for the owning tool/step. */
signal?: AbortSignal
}
```
## Answer
Providers return one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices.
```ts type-equiv
interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty when the answer is purely custom text. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string
}
```
```ts type-equiv
interface AskUserQuestionAnswer {
/** Structured answers keyed by question id. */
answers: AskUserQuestionAnswerItem[]
}
```
## Provider
Only one provider may be active in a context. Provider registration is effect-bound so HMR/disposal removes the active UI.
```ts type-equiv
interface UserInteractionProvider {
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
}
```
## Errors
`UserInteractionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `ASK_ABORTED`, or ACP-side cancellation.
```ts type-equiv
class UserInteractionError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'UserInteractionError'
}
}
```
+16 -3
View File
@@ -85,6 +85,8 @@ flowchart TD
pkg_acp_agent["acp-agent"]
pkg_app_boot["app-boot"]
pkg_stdio_agent["stdio-agent"]
pkg_tool_ask_user["tool-ask-user"]
pkg_user_interaction["user-interaction"]
end
subgraph group_code_runtime["packages/code-runtime"]
pkg_code_runtime["code-runtime"]
@@ -138,6 +140,8 @@ flowchart TD
pkg_invariants --> pkg_agent
pkg_invariants --> pkg_llm
pkg_invariants --> pkg_session
pkg_user_interaction --> pkg_agent
pkg_user_interaction --> pkg_llm
pkg_agent_loop --> pkg_agent
pkg_agent_loop --> pkg_llm
pkg_agent_loop --> pkg_session
@@ -178,6 +182,10 @@ flowchart TD
pkg_acp --> pkg_session
pkg_acp --> pkg_session_persistence
pkg_acp --> pkg_tools
pkg_acp --> pkg_user_interaction
pkg_tool_ask_user --> pkg_agent
pkg_tool_ask_user --> pkg_tools
pkg_tool_ask_user --> pkg_user_interaction
pkg_repeat_tool_guard --> pkg_agent
pkg_repeat_tool_guard --> pkg_tools
pkg_agent_core --> pkg_agent
@@ -220,12 +228,15 @@ flowchart TD
pkg_acp_agent --> pkg_agent_core
pkg_acp_agent --> pkg_app_boot
pkg_acp_agent --> pkg_session_persistence_jsonl
pkg_acp_agent --> pkg_user_interaction
pkg_stdio_agent --> pkg_agent
pkg_stdio_agent --> pkg_agent_core
pkg_stdio_agent --> pkg_app_boot
pkg_stdio_agent --> pkg_llm
pkg_stdio_agent --> pkg_session
pkg_stdio_agent --> pkg_session_persistence_jsonl
pkg_stdio_agent --> pkg_tool_ask_user
pkg_stdio_agent --> pkg_user_interaction
```
| Package | Group | Depends on |
@@ -261,6 +272,7 @@ flowchart TD
| [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
| [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) |
| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -270,7 +282,8 @@ flowchart TD
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) |
| [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tools`](../packages/core/tools) |
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
@@ -280,5 +293,5 @@ flowchart TD
| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) |
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) |
| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) |
| [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`user-interaction`](../packages/ui/user-interaction) |
| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`user-interaction`](../packages/ui/user-interaction) |
+1
View File
@@ -55,6 +55,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
| [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.md) | 2026-06-18 |
| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 |
| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 |
| [Ask-user question capability](implemented/feature/2026-06-25-ask-user-question.md) | 2026-06-25 |
| [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 |
| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 |
| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 |
@@ -0,0 +1,49 @@
# RFC: Ask-user question capability
Status: implemented
## Problem
The agent sometimes cannot proceed safely from model inference alone: it needs the human to choose a path, confirm a risky/default action, or provide missing information. Before this change, the only way to get that answer was for the model to ask in assistant text and then stop, which broke the normal tool-call loop: the agent had no structured way to pause, no option metadata for UIs, no abort/error taxonomy, and no way for non-stdio front doors to present the question consistently.
This is a user-facing capability, but it also crosses package boundaries. A model-facing tool needs a provider-neutral request vocabulary; each UI surface needs to decide how to show and collect the answer; the agent loop should remain unchanged because a tool call already has the right async shape.
## Decision
Introduce `dsh-user-interaction` as the provider-neutral interface package for `ctx.userInteraction`, colocated with the model-facing consumer `dsh-tool-ask-user` under `packages/ui`. The grouping is intentional: asking a human is a UI-backed product affordance, not part of the providerless core spine. The seam still owns the stable request/answer/error vocabulary, while UI product surfaces provide the concrete provider that collects the answer. The tool registers `ask_user_question`, forwards `{ questions, agent, signal }`, and returns the provider-computed structured answers as the tool result.
The model-facing request vocabulary is deliberately aligned with the product-research schema: `ask_user_question({ questions: [{ id, question, header?, options?: [{ label, description? }], multi_select? }] })`. `id` is supplied per question and echoed in the result so a batch can be routed without relying on question text. `label` is both user-facing display text and the selected value returned to the model; there is no separate `value`, no `recommended`, no `allow_custom`, and no `desc` alias.
Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is always an array of selected option labels, so single-select and `multi_select` answers share one result shape. `custom` carries a free-text "Other" answer; optionless questions collect `custom` directly. When `custom` is present, it overrides any selected choices and `selected` is empty.
`UserInteractionError` extends `HarnessError`, so failures such as `NO_PROVIDER`, `ASK_ABORTED`, ACP cancellation, or missing session routing survive `ctx.tools.execute()` as machine-routable `{ name, code }` tool errors. This matches the structured-error taxonomy and lets the model or a wrapping plugin distinguish "user cancelled" from a generic thrown exception.
## UI mappings
`dsh-stdio-agent`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time.
`dsh-acp` provides the same seam for ACP sessions. It routes an ask request from the calling `Agent` through the bridge's `agent→sessionId` reverse map and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s.
The ACP mapping deliberately uses elicitation, not `session/request_permission`. `request_permission` is still reserved for the separate permission gate: it is a yes/no-or-policy authorization protocol around tool execution. `ask_user_question` is a general information-gathering tool with optional free-form answers, so ACP form elicitation is the closer protocol fit. The bridge's session routing is shared with the future permission gate, but the user intent is different.
## Alternatives considered
**Assistant text followed by a stopped turn.** The model could ask the user in plain assistant text and then stop. That loses the structured option metadata, gives UIs no provider-neutral way to render a choice, and forces the next human answer to arrive as a new user prompt rather than as the result of the operation that needed the answer.
**Core-owned ask-user packages.** The first implementation split the seam and the model-facing tool across `packages/core` and `packages/ui`, but both names describe one UI-backed human-interaction affordance. The seam remains provider-neutral, but it is not providerless core infrastructure like sessions, tools, or the agent registry. Keeping `dsh-user-interaction` and `dsh-tool-ask-user` together under `packages/ui` makes the package map match the product boundary: apps and bridges provide the human-answer provider, and the stdio app opts into the model-facing tool.
**ACP `session/request_permission`.** Permission requests are authorization around tool execution; `ask_user_question` is information gathering with optional free-form answers. Using permission for general questions would collapse two different product concepts and make the future permission gate harder to reason about.
**A loop-level pause primitive.** The agent loop already knows how to await a tool call and resume from a tool result. Adding a new loop special case would duplicate that async shape and make every loop implementation learn about a UI concern.
## Consequences
ACP elicitation is currently marked unstable in the SDK. The fallback is still structured: if a client does not implement it, the tool returns `ASK_FAILED` rather than hanging. A later ACP stabilization may rename or reshape the method; that migration should stay inside `dsh-acp` because the core `ctx.userInteraction` vocabulary is provider-neutral.
The feature gives the model a powerful pause primitive, so prompt guidance matters. The tool description tells the model to ask concise questions and use options when possible. Product policy can later wrap `tools/execute` to restrict when the tool is allowed, but the loop should not special-case it.
`dsh-user-interaction` and `dsh-tool-ask-user` both live in `packages/ui` because they form one product-facing human-interaction capability. `agent-core` does not load either the tool or a provider. `stdio-agent` opts into the seam, its readline provider, and the model-facing tool. `acp-agent` keeps only the `userInteraction` seam/provider by default: ACP elicitation support is still client-dependent, so an ACP leaf must opt into the model-facing tool deliberately once its client can complete elicitation requests.
## Testing
Unit coverage pins provider registration/disposal, duplicate-provider rejection, abort-before-provider, empty-question rejection, structured tool errors through `ctx.tools.execute()`, batched answers, multi-select answers, custom answers, and the model schema including the removal of `value`, `recommended`, `allow_custom`, and `desc`. `dsh-stdio-agent` tests cover option descriptions, queued requests, EOF/abort cleanup, optionless free-form input, invalid option reprompts, duplicate multi-select numbers, and batched question flows. ACP bridge tests drive a real in-memory ACP connection with the real `ask_user_question` tool and verify selected-option, custom-overrides-choice, multi-select, and optionless free-form elicitation paths continue the agent loop.
+71
View File
@@ -15,6 +15,7 @@ This table connects model-visible tool names to the plugin package and service s
| Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note |
| --- | --- | --- | --- | --- | --- |
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userInteraction` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
| `@deepseek-ai/dsh-tool-bash` | `bash`, `bash_kill`, `bash_output` | `ctx.tools`, `ctx.bash` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The bash/bash_output/bash_kill tools are model-facing consumers of the bash executor seam. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `live plugin-tree mutations (mount/unmount)` | - | Ships in examples/cordis-agent only (a deliberate opt-in — mounted code gets the real ctx, see docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins the model mounts may register ADDITIONAL model-visible tools at runtime; the request-header ToolsDelta logs those tool-set changes. |
| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. |
@@ -22,6 +23,76 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. |
| `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
## `@deepseek-ai/dsh-tool-ask-user`
### `ask_user_question`
Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.
```json
{
"type": "object",
"properties": {
"questions": {
"type": "array",
"description": "Questions to ask the user before continuing.",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Stable id for this question; echoed in the answer."
},
"question": {
"type": "string",
"description": "The specific question to ask the user."
},
"header": {
"type": "string",
"description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"."
},
"options": {
"type": "array",
"description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.",
"items": {
"type": "object",
"properties": {
"label": {
"type": "string",
"description": "Short user-facing option label."
},
"description": {
"type": "string",
"description": "One sentence explaining the tradeoff or impact."
}
},
"required": [
"label"
]
}
},
"multi_select": {
"type": "boolean",
"description": "Whether the user may select more than one option. Defaults to false."
}
},
"required": [
"id",
"question"
]
}
}
},
"required": [
"questions"
]
}
```
Source: [`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts)
ask_user_question pauses the tool call until the active UI provider returns a human answer.
## `@deepseek-ai/dsh-tool-bash`
### `bash`
+1 -1
View File
@@ -22,7 +22,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
@@ -40,12 +40,14 @@ async function readUntil(
): Promise<BashTaskRead> {
const deadline = Date.now() + timeoutMs
let last: BashTaskRead | undefined
let delta = ''
while (Date.now() < deadline) {
last = bash.readOutput(id)
if (last.delta.includes(expected)) return last
delta += last.delta
if (delta.includes(expected)) return { ...last, delta }
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; last delta was ${JSON.stringify(last?.delta ?? '')}`)
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; output was ${JSON.stringify(delta)}, last delta was ${JSON.stringify(last?.delta ?? '')}`)
}
describe('LocalBashExecutor.run', () => {
@@ -178,6 +178,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
'async execute(exec: ToolExecution): Promise<ToolExecutionResult>',
],
},
{
key: 'userInteraction',
summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
methods: [
'registerProvider(provider: UserInteractionProvider): () => void',
'async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>',
],
},
{
key: 'web',
summary: 'The web access service.',
@@ -388,6 +396,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'AgentStatus',
declaration: 'export type AgentStatus = \'idle\' | \'running\' | \'disposed\';',
},
{
name: 'AskUserQuestionAnswer',
declaration: 'export interface AskUserQuestionAnswer {\n answers: AskUserQuestionAnswerItem[];\n}',
},
{
name: 'AskUserQuestionAnswerItem',
declaration: 'export interface AskUserQuestionAnswerItem {\n id: string;\n selected: string[];\n custom?: string;\n}',
},
{
name: 'AskUserQuestionItem',
declaration: 'export interface AskUserQuestionItem {\n id: string;\n question: string;\n header?: string;\n options?: AskUserQuestionOption[];\n multiSelect?: boolean;\n}',
},
{
name: 'AskUserQuestionOption',
declaration: 'export interface AskUserQuestionOption {\n label: string;\n description?: string;\n}',
},
{
name: 'AskUserQuestionRequest',
declaration: 'export interface AskUserQuestionRequest {\n questions: AskUserQuestionItem[];\n agent?: Agent;\n signal?: AbortSignal;\n}',
},
{
name: 'AssembleContext',
declaration: 'export interface AssembleContext {\n}',
@@ -760,6 +788,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TurnTriggerMap',
declaration: 'export interface TurnTriggerMap {\n message: {\n kind: \'message\';\n source: MessageSource;\n };\n injection: {\n kind: \'injection\';\n source: MessageSource;\n };\n}',
},
{
name: 'UserInteractionProvider',
declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
},
{
name: 'WebExecContext',
declaration: 'export interface WebExecContext {\n readonly signal?: AbortSignal;\n}',
@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
+10 -6
View File
@@ -5,6 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -297,7 +298,7 @@ describe('ToolRegistry', () => {
}))
ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() })
ctx.on('tools/execute', async (_exec, next) => {
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
order.push('execute:before')
const result = await next()
order.push('execute:after')
@@ -317,7 +318,10 @@ describe('ToolRegistry', () => {
let entered = false
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
ctx.on('tools/execute', async (_exec, next) => { entered = true; return next() })
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
entered = true
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
@@ -334,7 +338,7 @@ describe('ToolRegistry', () => {
})
let seen: { isError: boolean; error?: unknown } | undefined
ctx.on('tools/execute', async (_exec, next) => {
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
const result = await next()
// The base next() IS dispatch-with-normalization: the wrapper sees the
// normalized isError result, never a raw throw from the tool body.
@@ -357,7 +361,7 @@ describe('ToolRegistry', () => {
})
let postSaw: boolean | undefined
ctx.on('tools/execute', async (_exec, next) => next())
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
ctx.on('tools/post-execute', async (_exec, result, next) => {
postSaw = result.isError
return next()
@@ -383,7 +387,7 @@ describe('ToolRegistry', () => {
const upstream = new AbortController().signal
const replacement = new AbortController().signal
ctx.on('tools/execute', async (exec, next) => {
ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
expect(exec.signal).toBe(upstream)
// Cordis next() ignores passed arguments, so a wrapper mutates exec in
// place (the documented "mutate the shared object, then delegate" idiom).
@@ -404,7 +408,7 @@ describe('ToolRegistry', () => {
async execute() { dispatched = true; return [] },
})
ctx.on('tools/execute', async (exec, _next): Promise<import('@deepseek-ai/dsh-tools').ToolExecutionResult> =>
ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
+4
View File
@@ -5,10 +5,14 @@ Integrations that expose the agent to an external editor or client. These are **
| Package | Role | ctx key |
|---|---|---|
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
`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. The seam remains provider-neutral (`ctx.userInteraction`), while the tool is the model-facing consumer and the app/bridge packages provide concrete providers.
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.
+3 -1
View File
@@ -11,8 +11,10 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
| Plugin | Why |
|---|---|
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) |
| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |
+2
View File
@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
},
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
}
+2
View File
@@ -35,6 +35,7 @@ import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
export const name = 'acp-agent'
@@ -79,6 +80,7 @@ export function apply(ctx: Context, config: Config): void {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(acp, { model: config.model })
}
@@ -30,6 +30,8 @@ describe('dsh-acp-agent composition', () => {
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
// No pre-created agents — ACP session/new creates them on demand.
expect(ctx.get('agents')!.list()).toHaveLength(0)
await ctx.fiber.dispose()
+6
View File
@@ -26,6 +26,12 @@
{
"path": "../../core/agent-core"
},
{
"path": "../user-interaction"
},
{
"path": "../tool-ask-user"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
}
+2 -1
View File
@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation).
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session.
### Config
@@ -30,6 +30,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` 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 |
## Multi-session
+2 -2
View File
@@ -47,7 +47,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
| `terminal/wait_for_exit` | S | ❌ | ❌ | ❌ | As above. |
| `terminal/kill` | S | ❌ | ❌ | ❌ | As above. |
| `terminal/release` | S | ❌ | ❌ | ❌ | As above. |
| `elicitation/create` · `elicitation/complete` | U | | ✅ | ⚠️ | Structured user-input forms. Claude calls the `unstable_*` elicitation methods (to surface MCP server elicitations); Codex does NOT — its `CodexElicitationHandler` maps elicitations onto `session/request_permission` instead. |
| `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
@@ -140,7 +140,7 @@ The bridge rejects unsupported prompt blocks rather than silently dropping them
Ranked by how commonly the reference adapters ship them and how much UX they unlock:
1. **Permission gate**`session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired. Foundational, and a prerequisite for modes.
1. **Permission gate**`session/request_permission` + permission options. Tracked `TODO(rfc010-permission-gate)`; the reverse map is already wired and shared with `ask_user_question` routing. Foundational, and a prerequisite for modes.
2. **Session lifecycle**`session/list` + `session/delete` (the persistence layer already lists), then `session/resume` / `session/close`.
3. **Modes / config options / model selection** — coupled to the permission gate.
4. **Agent plan** (`sessionUpdate: 'plan'`) — surface the loop's plan as structured entries.
+3
View File
@@ -32,6 +32,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
@@ -49,6 +50,8 @@
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+159 -1
View File
@@ -47,6 +47,9 @@ import {
type AuthenticateRequest,
type CancelNotification,
type ContentBlock as AcpContentBlock,
type CreateElicitationRequest,
type ElicitationContentValue,
type EnumOption,
type InitializeRequest,
type InitializeResponse,
type LoadSessionRequest,
@@ -71,6 +74,14 @@ import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } f
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
// Context (the bridge injects it and reads `list()` for load cwd validation).
import type {} from '@deepseek-ai/dsh-session-persistence'
import {
UserInteractionError,
type AskUserQuestionAnswer,
type AskUserQuestionAnswerItem,
type AskUserQuestionItem,
type AskUserQuestionOption,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import {
acpPromptToText,
harnessBlockToAcpContent,
@@ -84,7 +95,7 @@ export const name = 'acp'
// because `initialize` advertises `loadSession: true`. `tools` lets a tool own
// how its calls render (`presentCall`/`presentResult`); the bridge looks up the
// definition by name and falls back to a generic presentation when absent.
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools']
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']
/**
* Build an ACP "invalid params" error whose human detail rides in the message.
@@ -111,6 +122,116 @@ function sameWorkspaceCwd(left: string, right: string): boolean {
return resolvePath(left) === resolvePath(right)
}
function optionDescription(option: AskUserQuestionOption): string {
return option.description === undefined
? option.label
: `${option.label}: ${option.description}`
}
function requireStringContent(
content: Record<string, ElicitationContentValue> | null | undefined,
key: string,
): string | undefined {
const value = content?.[key]
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
}
function askAbortError(): UserInteractionError {
return new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
}
function withAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return promise
if (signal.aborted) return Promise.reject(askAbortError())
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
signal.removeEventListener('abort', onAbort)
reject(askAbortError())
}
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(new Error(String(error), { cause: error }))
},
)
})
}
function elicitationForQuestion(
sessionId: SessionId,
question: AskUserQuestionItem,
options: AskUserQuestionOption[],
): CreateElicitationRequest {
const title = question.header ?? 'Question'
if (options.length === 0) {
return {
sessionId,
mode: 'form',
message: question.question,
requestedSchema: {
type: 'object',
title,
properties: {
custom: { type: 'string', title: question.question },
},
required: ['custom'],
},
}
}
const choiceOptions: EnumOption[] = options.map(option => ({
const: option.label,
title: optionDescription(option),
}))
const choice = question.multiSelect === true
? {
type: 'array' as const,
title: question.question,
description: 'Choose one or more options, or fill a custom answer below.',
items: {
anyOf: choiceOptions,
},
}
: {
type: 'string' as const,
title: question.question,
description: 'Choose one option, or fill a custom answer below.',
oneOf: choiceOptions,
}
return {
sessionId,
mode: 'form',
message: question.question,
requestedSchema: {
type: 'object',
title,
properties: {
choice,
custom: {
type: 'string',
title: 'Custom answer',
description: 'Optional free-form answer. Leave empty to use the selected option.',
},
},
required: [],
},
}
}
function stringArrayContent(
content: Record<string, ElicitationContentValue> | null | undefined,
key: string,
): string[] {
const value = content?.[key]
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string' && item.length > 0)
return typeof value === 'string' && value.length > 0 ? [value] : []
}
/** Plugin config: the agent template ACP sessions are created from. */
export interface AcpConfig {
/** Model name for created agents (must have a registered adapter). */
@@ -211,6 +332,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
const sessionPersistence = ctx.sessionPersistence
const logger = ctx.logger
const tools = ctx.tools
const userInteraction = ctx.userInteraction
// A new ToolPresenter per session (and a throwaway per load replay), each given
// this warn sink so a throwing tool presenter is logged, not propagated.
const makePresenter = (): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) })
@@ -241,6 +363,42 @@ export function apply(ctx: Context, config: AcpConfig): void {
// `notify` never observes it unset — no undefined guard needed.
let conn: AgentSideConnection
userInteraction.registerProvider({
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.agent === undefined) {
throw new UserInteractionError('ACP user questions must come from an agent-owned request', 'NO_AGENT')
}
const sessionId = bySession.get(request.agent)
if (sessionId === undefined) {
throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION')
}
const answers: AskUserQuestionAnswerItem[] = []
for (const question of request.questions) {
const options = question.options ?? []
const response = await withAbort(conn.unstable_createElicitation(
elicitationForQuestion(sessionId, question, options),
), request.signal).catch((error: unknown) => {
if (error instanceof UserInteractionError) throw error
throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error })
})
if (response.action !== 'accept') {
throw new UserInteractionError('ask_user_question was cancelled by the user', 'ASK_CANCELLED')
}
const custom = requireStringContent(response.content, 'custom')
const selected = stringArrayContent(response.content, 'choice')
if (custom === undefined && selected.length === 0) {
throw new UserInteractionError('ask_user_question returned no answer', 'NO_ANSWER')
}
answers.push({
id: question.id,
selected: custom === undefined ? selected : [],
...custom !== undefined ? { custom } : {},
})
}
return { answers }
},
})
/**
* Reject any RPC after the bridge has torn down. The `AgentSideConnection`
* receive loop can outlive the plugin fiber — under an ACP-only HMR reload the
+204 -1
View File
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
import { AgentId } from '@deepseek-ai/dsh-agent'
import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts'
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts'
/**
* End-to-end bridge specs over an in-memory transport: a real
@@ -53,6 +53,209 @@ describe('acp bridge', () => {
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(AgentId(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(AgentId(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(AgentId(sessionId))!
const result = await harness.ctx.userInteraction.ask({
agent,
questions: [{
id: 'language',
question: 'Which language?',
options: [{ label: 'TypeScript' }],
}],
})
expect(result).toEqual({ answers: [{ id: 'language', selected: [], custom: 'Use Zig' }] })
expect(harness.elicitationRequests[0]).toMatchObject({
requestedSchema: {
properties: {
choice: {
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(AgentId(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(AgentId(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(AgentId(sessionId))!
await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, 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(AgentId(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: {} })
+21
View File
@@ -29,11 +29,15 @@ import {
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 * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as AcpPlugin from '../src/index.ts'
import { type AcpConfig } from '../src/index.ts'
@@ -121,6 +125,10 @@ export interface BridgeHarness {
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
/**
@@ -164,6 +172,8 @@ export async function makeBridgeHarness(options: {
* 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
@@ -190,6 +200,10 @@ export async function makeBridgeHarness(options: {
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
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)
@@ -226,6 +240,7 @@ export async function makeBridgeHarness(options: {
const updates: CapturedUpdate[] = []
const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = []
const permissionRequests: RequestPermissionRequest[] = []
const elicitationRequests: CreateElicitationRequest[] = []
const harness: BridgeHarness = {
ctx,
adapter,
@@ -233,6 +248,8 @@ export async function makeBridgeHarness(options: {
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'],
@@ -258,6 +275,10 @@ export async function makeBridgeHarness(options: {
permissionRequests.push(params)
return Promise.resolve(harness.onPermission(params))
},
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse> {
elicitationRequests.push(params)
return Promise.resolve(harness.onElicitation(params))
},
})
// Wire the bridge (agent side) and the client (test side). The test config
+3
View File
@@ -29,6 +29,9 @@
{
"path": "../../core/tools"
},
{
"path": "../user-interaction"
},
{
"path": "../../session-persistence/session-persistence"
}
+2
View File
@@ -13,6 +13,8 @@ A terminal chat always wants the same cluster, so the package owns it rather tha
| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) |
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` and carrying its `persona` |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools |
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent |
`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`.
+4
View File
@@ -39,6 +39,8 @@
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-tool-ask-user": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
},
@@ -53,6 +55,8 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-tool-ask-user": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
}
+4
View File
@@ -45,6 +45,8 @@ import { AgentId } from '@deepseek-ai/dsh-agent'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as uiStdio from './stdio-chat.ts'
export const name = 'stdio-agent'
@@ -107,5 +109,7 @@ export function apply(ctx: Context, config: Config): void {
}],
})
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(UserInteractionService)
ctx.plugin(toolAskUser)
ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' })
}
+173 -1
View File
@@ -20,9 +20,17 @@ import type { Readable, Writable } from 'node:stream'
import type { Context } from 'cordis'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import {
UserInteractionError,
type AskUserQuestionAnswer,
type AskUserQuestionAnswerItem,
type AskUserQuestionItem,
type AskUserQuestionOption,
type AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
export const name = 'ui-stdio'
export const inject = ['agents']
export const inject = ['agents', 'userInteraction']
/** Serializable plugin configuration (cordis-native, schemastery). */
export interface Config {
@@ -57,6 +65,20 @@ function isTTYPair(input: Readable, output: Writable): boolean {
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
}
interface PendingQuestion {
request: AskUserQuestionRequest
questionIndex: number
answers: AskUserQuestionAnswerItem[]
resolve(answer: AskUserQuestionAnswer): void
reject(error: unknown): void
onAbort: () => void
}
type OptionSelection =
| { kind: 'selected'; options: AskUserQuestionOption[] }
| { kind: 'custom' }
| { kind: 'invalid' }
/**
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
* production wrapper that binds the real `process` streams; tests call this
@@ -154,6 +176,8 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
let submittedWork = false
let sawRunning = false
let exitTimer: ReturnType<typeof setTimeout> | undefined
let activeQuestion: PendingQuestion | undefined
const questionQueue: PendingQuestion[] = []
const maybeExit = (): void => {
if (disposed || !stdinClosed) return
@@ -180,7 +204,152 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
if (status === 'idle') maybeExit()
})
const activeQuestionItem = (pending: PendingQuestion): AskUserQuestionItem =>
pending.request.questions[pending.questionIndex] as AskUserQuestionItem
const renderQuestion = (pending: PendingQuestion): void => {
const question = activeQuestionItem(pending)
const options = question.options ?? []
output.write('\n')
output.write(question.header ? `[${question.header}] ${question.question}\n` : `${question.question}\n`)
options.forEach((option, index) => {
output.write(` ${index + 1}. ${option.label}\n`)
if (option.description) output.write(` ${option.description}\n`)
})
output.write('> ')
}
const removeAbortListener = (pending: PendingQuestion): void => {
pending.request.signal?.removeEventListener('abort', pending.onAbort)
}
const startNextQuestion = (): void => {
if (activeQuestion !== undefined) return
const pending = questionQueue.shift()
if (pending === undefined) return
// The queue never contains an aborted pending ask: the seam rejects an
// already-aborted request synchronously, and queued asks attach their
// abort listener before enqueueing.
activeQuestion = pending
renderQuestion(pending)
}
const disposeQuestion = (pending: PendingQuestion): void => {
removeAbortListener(pending)
pending.reject(new UserInteractionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'))
}
const disposePendingQuestions = (): void => {
if (activeQuestion !== undefined) {
disposeQuestion(activeQuestion)
activeQuestion = undefined
}
for (const pending of questionQueue.splice(0)) {
disposeQuestion(pending)
}
}
const finishQuestion = (pending: PendingQuestion): void => {
activeQuestion = undefined
removeAbortListener(pending)
pending.resolve({ answers: pending.answers })
output.write('\n')
startNextQuestion()
}
const answerCurrentQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswerItem): void => {
pending.answers.push(answer)
pending.questionIndex += 1
if (pending.questionIndex >= pending.request.questions.length) {
finishQuestion(pending)
return
}
renderQuestion(pending)
}
const selectedOptions = (text: string, options: AskUserQuestionOption[], multiSelect: boolean): OptionSelection => {
if (text === '') return { kind: 'invalid' }
if (!multiSelect) {
if (!/^\d+$/.test(text)) return { kind: 'custom' }
const selected = options[Number(text) - 1]
return selected === undefined ? { kind: 'invalid' } : { kind: 'selected', options: [selected] }
}
const indices = text.split(/[,\s]+/).filter(Boolean)
if (indices.length === 0) return { kind: 'invalid' }
if (indices.some(part => !/^\d+$/.test(part))) return { kind: 'custom' }
const uniqueIndices = [...new Set(indices)]
const selected = uniqueIndices.map(part => options[Number(part) - 1])
return selected.some(option => option === undefined)
? { kind: 'invalid' }
: { kind: 'selected', options: selected as AskUserQuestionOption[] }
}
const answerQuestion = (line: string): void => {
const pending = activeQuestion as PendingQuestion
const question = activeQuestionItem(pending)
const text = line.trim()
const options = question.options ?? []
const selection = options.length > 0
? selectedOptions(text, options, question.multiSelect ?? false)
: { kind: text === '' ? 'invalid' : 'custom' } as OptionSelection
if (selection.kind === 'selected') {
answerCurrentQuestion(pending, { id: question.id, selected: selection.options.map(option => option.label) })
return
}
if (selection.kind === 'custom' && text !== '') {
answerCurrentQuestion(pending, { id: question.id, selected: [], custom: text })
return
}
output.write(options.length > 0
? 'Please enter one of the option numbers'
+ (question.multiSelect ? ' (comma or space separated)' : '')
+ ' or a custom answer'
+ '.\n> '
: 'Please enter an answer.\n> ')
}
const disposeUserInteractionProvider = ctx.userInteraction.registerProvider({
ask(request) {
if (disposed || stdinClosed) {
return Promise.reject(
new UserInteractionError('ask_user_question cannot be answered because stdin is closed', 'ASK_ABORTED'),
)
}
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
const pending: PendingQuestion = {
request,
questionIndex: 0,
answers: [],
resolve,
reject,
onAbort: () => {
if (activeQuestion === pending) {
activeQuestion = undefined
disposeQuestion(pending)
startNextQuestion()
return
}
// If it is not active, this listener can only fire while the ask
// remains queued; settled asks remove the listener first.
questionQueue.splice(questionQueue.indexOf(pending), 1)
disposeQuestion(pending)
},
}
request.signal?.addEventListener('abort', pending.onAbort, { once: true })
questionQueue.push(pending)
startNextQuestion()
})
},
})
reader.on('line', (line) => {
if (activeQuestion !== undefined) {
answerQuestion(line)
return
}
const text = line.trim()
if (!text) return
const agent = ctx.agents.get(agentId)
@@ -199,12 +368,15 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
// Fires for BOTH stdin EOF and plugin disposal (reader.close() below);
// `disposed` guards teardown so HMR/dispose never exits the process.
stdinClosed = true
if (!disposed) disposePendingQuestions()
maybeExit()
})
output.write(`${welcome}\n> `)
return () => {
disposed = true
if (exitTimer !== undefined) clearTimeout(exitTimer)
disposePendingQuestions()
disposeUserInteractionProvider()
disposeStatusListener()
reader.close()
}
@@ -19,6 +19,7 @@ function fakeContext(): Context {
// The UI seeds its label map from the registry at install; this suite only
// exercises readline terminal-mode selection, so an empty roster suffices.
agents: { list: vi.fn(() => []) },
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
} as unknown as Context
}
@@ -37,6 +37,8 @@ describe('dsh-stdio-agent app', () => {
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined()
// The pre-created `main` agent the UI drives.
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
await ctx.fiber.dispose()
@@ -92,7 +94,7 @@ describe('dsh-stdio-agent app', () => {
})
}
const assembly = await ctx.get('systemPrompt')!.assemble()
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha'])
expect(assembly.tools.map(tool => tool.name)).toEqual(['zulu', 'alpha', 'ask_user_question'])
await ctx.fiber.dispose()
})
@@ -1,10 +1,11 @@
import { Readable } from 'node:stream'
import { Readable, Writable } from 'node:stream'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts'
/**
@@ -79,10 +80,11 @@ const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const { runtime, input, out, exit } = makeRuntime(runtimeOver)
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, config, runtime)
}, { inject: ['agents'] }))
}, { inject: ['agents', 'userInteraction'] }))
return { ctx, fiber, input, out, exit }
}
@@ -105,6 +107,30 @@ describe('createStdioChat rendering', () => {
// And it drives the default agent id 'main'.
})
it('detects readline terminal mode from both stream TTY flags', async () => {
for (const [inputTTY, outputTTY] of [[true, false], [true, true]] as const) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
let text = ''
const output = new Writable({
write(chunk, _encoding, callback) {
text += String(chunk)
callback()
},
}) as Writable & { isTTY?: boolean }
const { runtime } = makeRuntime({ output })
;(runtime.input as Readable & { isTTY?: boolean }).isTTY = inputTTY
output.isTTY = outputTTY
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, CONFIG, runtime)
}, { inject: ['agents', 'userInteraction'] }))
expect(text).toContain('hi there')
await fiber.dispose()
}
})
it('renders text-delta chunks verbatim', async () => {
const { ctx, out } = await setup()
ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' }))
@@ -160,12 +186,13 @@ describe('createStdioChat rendering', () => {
// of the raw session id.
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const agent = makeAgent('main')
ctx.agents.register(agent) // registered BEFORE the UI plugin below
const { runtime, out } = makeRuntime()
await ctx.plugin(Object.assign((inner: Context) => {
createStdioChat(inner, CONFIG, runtime)
}, { inject: ['agents'] }))
}, { inject: ['agents', 'userInteraction'] }))
ctx.emit('session/event', makeSession('main'), {
type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
} as SessionEvent)
@@ -264,6 +291,347 @@ describe('createStdioChat rendering', () => {
})
describe('createStdioChat input', () => {
it('answers a pending user question instead of sending the line to the agent', async () => {
const { ctx, input, out } = await setup()
const agent = makeAgent('main', 'idle')
ctx.agents.register(agent)
const answer = ctx.userInteraction.ask({
questions: [{
id: 'confirm',
header: 'Confirm',
question: 'Proceed with the edit?',
options: [{ label: 'Yes', description: 'Apply the edit now.' }],
}],
})
await new Promise(r => setImmediate(r))
input.feed('Use a smaller change')
await expect(answer).resolves.toEqual({ answers: [{ id: 'confirm', selected: [], custom: 'Use a smaller change' }] })
expect(agent.sent).toEqual([])
expect(out.text()).toContain('[Confirm] Proceed with the edit?')
expect(out.text()).toContain('1. Yes')
expect(out.text()).toContain('Apply the edit now.')
})
it('answers a pending user question by numeric option selection', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [
{ label: 'Safe' },
{ label: 'Fast' },
],
}],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Fast'] }],
})
})
it('renders options in input order and selects by displayed number', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'topic',
question: 'Which topic?',
options: [
{ label: 'Hobbies' },
{ label: 'Work', description: 'Questions about current projects.' },
{ label: 'Casual', description: 'Easy conversation.' },
],
}],
})
await new Promise(r => setImmediate(r))
expect(out.text()).toContain([
'Which topic?',
' 1. Hobbies',
' 2. Work',
' Questions about current projects.',
' 3. Casual',
' Easy conversation.',
].join('\n'))
input.feed('3')
await expect(answer).resolves.toEqual({
answers: [{ id: 'topic', selected: ['Casual'] }],
})
})
it('answers a multi-select question with multiple numeric selections', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'targets',
question: 'What should I update?',
options: [{ label: 'Tests' }, { label: 'Docs' }, { label: 'Code' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed('1 1, 3')
await expect(answer).resolves.toEqual({
answers: [{ id: 'targets', selected: ['Tests', 'Code'] }],
})
})
it('accepts non-numeric multi-select input as a custom answer', async () => {
const { ctx, input } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'targets',
question: 'What should I update?',
options: [{ label: 'Tests' }, { label: 'Docs' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed('the release notes')
await expect(answer).resolves.toEqual({
answers: [{ id: 'targets', selected: [], custom: 'the release notes' }],
})
})
it('asks every question in a batch and returns answers by id', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [
{ id: 'language', question: 'Which language?', options: [{ label: 'Python' }, { label: 'TypeScript' }] },
{ id: 'note', question: 'Any note?' },
],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('\nAny note?\n')
input.feed('ship today')
await expect(answer).resolves.toEqual({
answers: [
{ id: 'language', selected: ['TypeScript'] },
{ id: 'note', selected: [], custom: 'ship today' },
],
})
})
it('re-prompts when option input is invalid', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when single-select option input is out of range', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
}],
})
await new Promise(r => setImmediate(r))
input.feed('2')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when multi-select input contains no option numbers', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
multiSelect: true,
}],
})
await new Promise(r => setImmediate(r))
input.feed(',')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when an option question receives an empty answer', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({
questions: [{
id: 'mode',
question: 'Which mode?',
options: [{ label: 'Safe' }],
}],
})
await new Promise(r => setImmediate(r))
input.feed('')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
input.feed('1')
await expect(answer).resolves.toEqual({
answers: [{ id: 'mode', selected: ['Safe'] }],
})
})
it('re-prompts when a question receives an empty answer', async () => {
const { ctx, input, out } = await setup()
const answer = ctx.userInteraction.ask({ questions: [{ id: 'path', question: 'What should I use?' }] })
await new Promise(r => setImmediate(r))
input.feed('')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('Please enter an answer.')
input.feed('Use defaults')
await expect(answer).resolves.toEqual({ answers: [{ id: 'path', selected: [], custom: 'Use defaults' }] })
})
it('rejects an active question when its signal aborts', async () => {
const { ctx } = await setup()
const controller = new AbortController()
const answer = ctx.userInteraction.ask({ questions: [{ id: 'continue', question: 'Continue?' }], signal: controller.signal })
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
controller.abort()
await rejected
})
it('continues to the next queued question when the active question aborts', async () => {
const { ctx, input, out } = await setup()
const controller = new AbortController()
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }], signal: controller.signal })
const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }] })
await new Promise(r => setImmediate(r))
controller.abort()
await firstRejected
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('\nSecond?\n')
input.feed('second answer')
await expect(second).resolves.toEqual({ answers: [{ id: 'second', selected: [], custom: 'second answer' }] })
})
it('skips a queued question whose signal aborted before it became active', async () => {
const { ctx, input, out } = await setup()
const controller = new AbortController()
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
await new Promise(r => setImmediate(r))
controller.abort()
await expect(Promise.race([
second.then(
() => 'resolved',
(error: unknown) => (error as { code?: string }).code,
),
new Promise<string>((resolve) => { setImmediate(() => { resolve('pending') }) }),
])).resolves.toBe('ASK_ABORTED')
expect(out.text()).not.toContain('\nSecond?\n')
input.feed('first answer')
await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
})
it('removes an aborted queued question without promoting later queued work early', async () => {
const { ctx, input, out } = await setup()
const controller = new AbortController()
const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
const third = ctx.userInteraction.ask({ questions: [{ id: 'third', question: 'Third?' }] })
await new Promise(r => setImmediate(r))
controller.abort()
await expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' })
expect(out.text()).toContain('\nFirst?\n')
expect(out.text()).not.toContain('\nSecond?\n')
expect(out.text()).not.toContain('\nThird?\n')
input.feed('first answer')
await new Promise(r => setImmediate(r))
expect(out.text()).toContain('\nThird?\n')
input.feed('third answer')
await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
await expect(third).resolves.toEqual({ answers: [{ id: 'third', selected: [], custom: 'third answer' }] })
})
it('rejects active and queued questions when the UI is disposed', async () => {
const { ctx, fiber } = await setup()
const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
await fiber.dispose()
await activeRejected
await queuedRejected
})
it('rejects active and queued questions when stdin closes before the user answers', async () => {
const { ctx, input, exit } = await setup()
const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
await new Promise(r => setImmediate(r))
input.finish()
await new Promise(r => setImmediate(r))
await activeRejected
await queuedRejected
expect(exit).not.toHaveBeenCalled()
})
it('rejects new questions immediately after stdin has closed', async () => {
const { ctx, input, out } = await setup()
input.finish()
await new Promise(r => setImmediate(r))
const before = out.text()
const answer = ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Too late?' }] })
await expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
expect(out.text()).toBe(before)
})
it('sends a typed line to an idle agent', async () => {
const { ctx, input } = await setup()
const agent = makeAgent('main', 'idle')
+6
View File
@@ -32,6 +32,12 @@
{
"path": "../../core/agent-core"
},
{
"path": "../user-interaction"
},
{
"path": "../tool-ask-user"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
}
+20
View File
@@ -0,0 +1,20 @@
# @deepseek-ai/dsh-tool-ask-user
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
`ask_user_question` accepts:
- `questions` — required non-empty array of question objects.
- `id` — required stable id on each question, echoed in the answer.
- `question` — required question text for each question.
- `header` — optional short heading.
- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label.
- `multi_select` — whether that question may return more than one selected option.
The tool calls `ctx.userInteraction.ask()` and returns JSON text shaped as `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. `selected` contains option labels; `custom` is present only for a free-form answer and overrides selected choices.
## Role
This is the consumer package for the user-interaction seam. It does not render UI and does not know how input is collected; it only translates model arguments into `AskUserQuestionRequest` and returns the human answer to the agent loop.
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@deepseek-ai/dsh-tool-ask-user",
"description": "Model-facing ask_user_question tool over the ctx.userInteraction seam",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Model-facing `ask_user_question` tool over the `ctx.userInteraction` seam.
* The tool pauses until a UI provider returns a human answer, then feeds that
* answer back into the agent loop as an ordinary tool result.
*
* @module @deepseek-ai/dsh-tool-ask-user
*/
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import '@deepseek-ai/dsh-user-interaction'
export const name = 'tool-ask-user'
export const inject = ['tools', 'userInteraction']
const description = 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. '
+ 'Send one or more questions, each with a stable id that will be echoed in the answer.'
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'ask_user_question',
description,
parameters: {
questions: {
type: 'array',
required: true,
description: 'Questions to ask the user before continuing.',
items: {
type: 'object',
properties: {
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
header: {
type: 'string',
description: 'Optional short heading for the question, such as "Confirm" or "Choose Mode".',
},
options: {
type: 'array',
description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.',
items: {
type: 'object',
properties: {
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
},
},
},
multi_select: {
type: 'boolean',
description: 'Whether the user may select more than one option. Defaults to false.',
},
},
},
},
},
async execute(args, exec) {
const result = await ctx.userInteraction.ask({
questions: args.questions.map(question => ({
id: question.id,
question: question.question,
...question.header !== undefined ? { header: question.header } : {},
...question.options !== undefined ? { options: question.options } : {},
...question.multi_select !== undefined ? { multiSelect: question.multi_select } : {},
})),
...exec.agent !== undefined ? { agent: exec.agent } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
})
return [{ type: 'text', text: JSON.stringify(result) }]
},
}))
}
@@ -0,0 +1,253 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
interface OptionSchemaShape {
properties: {
questions: {
items: {
properties: {
options: {
items: {
properties: Record<string, { type: string }>
}
}
} & Record<string, unknown>
}
}
}
}
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(toolAskUser)
return ctx
}
describe('ask_user_question tool', () => {
it('registers a model-facing tool schema', async () => {
const ctx = await setup()
const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')
expect(schema).toMatchObject({
name: 'ask_user_question',
parameters: {
type: 'object',
properties: {
questions: { type: 'array' },
},
required: ['questions'],
},
})
const parameters = schema?.parameters as unknown as OptionSchemaShape
expect(parameters.properties.questions.items.properties).toMatchObject({
id: { type: 'string' },
question: { type: 'string' },
header: { type: 'string' },
options: { type: 'array' },
multi_select: { type: 'boolean' },
})
expect(parameters.properties.questions.items.properties.options.items.properties).toMatchObject({
label: { type: 'string' },
description: { type: 'string' },
})
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('value')
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('recommended')
expect(parameters.properties.questions.items.properties.options.items.properties).not.toHaveProperty('preview')
})
it('asks the registered user-interaction provider and projects structured answers to text', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'pkg', selected: ['pnpm'] }] }
},
})
const result = await ctx.tools.execute({
callId: CallId('ask-1'),
name: 'ask_user_question',
arguments: {
questions: [{
id: 'pkg',
question: 'Which package manager should I use?',
options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
}],
},
})
expect(result).toMatchObject({
isError: false,
content: [{ type: 'text', text: '{"answers":[{"id":"pkg","selected":["pnpm"]}]}' }],
})
expect(seen).toMatchObject([{
questions: [{
id: 'pkg',
question: 'Which package manager should I use?',
options: [{ label: 'pnpm', description: 'Use pnpm workspaces.' }],
}],
}])
})
it('passes recommended option labels through without adding schema fields', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] }
},
})
await ctx.tools.execute({
callId: CallId('ask-recommended'),
name: 'ask_user_question',
arguments: {
questions: [{
id: 'pkg',
question: 'Which package manager should I use?',
options: [
{ label: 'pnpm (Recommended)' },
{ label: 'npm' },
],
}],
},
})
expect(seen[0]?.questions[0]?.options).toEqual([
{ label: 'pnpm (Recommended)' },
{ label: 'npm' },
])
})
it('projects custom answers and multi-select choices', async () => {
const ctx = await setup()
ctx.userInteraction.registerProvider({
async ask() {
return {
answers: [
{ id: 'targets', selected: ['tests', 'docs'] },
{ id: 'notes', selected: [], custom: 'ship today' },
],
}
},
})
const result = await ctx.tools.execute({
callId: CallId('ask-multi'),
name: 'ask_user_question',
arguments: {
questions: [
{
id: 'targets',
question: 'What should I update?',
options: [{ label: 'tests' }, { label: 'docs' }],
multi_select: true,
},
{ id: 'notes', question: 'Any note?' },
],
},
})
expect(result.content).toEqual([{
type: 'text',
text: '{"answers":[{"id":"targets","selected":["tests","docs"]},{"id":"notes","selected":[],"custom":"ship today"}]}',
}])
})
it('passes the tool abort signal to the user-interaction request', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const controller = new AbortController()
await ctx.tools.execute({
callId: CallId('ask-2'),
name: 'ask_user_question',
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
signal: controller.signal,
})
expect(seen[0]?.signal).toBe(controller.signal)
})
it('passes optional header and agent through to the user-interaction request', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const agent = { id: 'main' } as unknown as Agent
const result = await ctx.tools.execute({
callId: CallId('ask-3'),
name: 'ask_user_question',
arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] },
agent,
})
expect(result.content).toEqual([{ type: 'text', text: '{"answers":[{"id":"continue","selected":["ok"]}]}' }])
expect(seen[0]).toMatchObject({ questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }], agent })
})
it('returns structured user-interaction errors through tool execution', async () => {
const ctx = await setup()
const result = await ctx.tools.execute({
callId: CallId('ask-no-provider'),
name: 'ask_user_question',
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
})
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'NO_PROVIDER' },
})
})
it('returns a structured error for empty question batches', async () => {
const ctx = await setup()
const result = await ctx.tools.execute({
callId: CallId('ask-empty'),
name: 'ask_user_question',
arguments: { questions: [] },
})
expect(result).toMatchObject({
isError: true,
error: { name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' },
})
})
it('unregisters the tool when its plugin fiber is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
const fiber = await ctx.plugin(toolAskUser)
expect(ctx.tools.get('ask_user_question')).toBeDefined()
await fiber.dispose()
expect(ctx.tools.get('ask_user_question')).toBeUndefined()
})
})
+36
View File
@@ -0,0 +1,36 @@
{
"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": "../../core/agent"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../user-interaction"
}
]
}
+24
View File
@@ -0,0 +1,24 @@
# @deepseek-ai/dsh-user-interaction
Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision.
## Service: `UserInteractionService` (ctx key: `userInteraction`)
### Public API
- `ctx.userInteraction.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it.
- `ctx.userInteraction.ask(request): Promise<AskUserQuestionAnswer>` Ask the active provider and wait for the answer.
### Key Types
- `AskUserQuestionRequest``{ questions: [{ id, question, header?, options?, multiSelect? }], agent?, signal? }`.
- `AskUserQuestionOption``{ label, description? }`.
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
When an answer includes `custom`, `selected` is empty; custom text is an override rather than a supplement to selected choices.
## Role
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop.
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@deepseek-ai/dsh-user-interaction",
"description": "Abstract user-interaction seam (ctx.userInteraction) for asking the human during agent runs",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}
+128
View File
@@ -0,0 +1,128 @@
/**
* User-interaction seam (`ctx.userInteraction`): a UI-backed service for
* pausing an agent tool call until the human answers a question. The model-
* facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide
* the single active provider.
*
* @module @deepseek-ai/dsh-user-interaction
*/
import { Context, Service } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { HarnessError } from '@deepseek-ai/dsh-llm'
declare module 'cordis' {
interface Context {
userInteraction: UserInteractionService
}
}
/** One selectable answer offered to the user. */
export interface AskUserQuestionOption {
/** User-facing label. */
label: string
/** Optional extra context rendered by capable UIs. */
description?: string
}
/** One question in an ask_user_question request. */
export interface AskUserQuestionItem {
/** Stable model-provided question id, echoed in the answer. */
id: string
/** The question to display. */
question: string
/** Optional short heading/group label. */
header?: string
/** Optional choices the UI can render as a menu. */
options?: AskUserQuestionOption[]
/** Whether more than one option may be selected. Defaults to single-select. */
multiSelect?: boolean
}
/** Request for a human answer. */
export interface AskUserQuestionRequest {
/** Questions to display. */
questions: AskUserQuestionItem[]
/** Calling agent, when the request came from an agent tool call. */
agent?: Agent
/** Abort signal for the owning tool/step. */
signal?: AbortSignal
}
/** Answer to one question. */
export interface AskUserQuestionAnswerItem {
/** The answered question id. */
id: string
/** Selected option labels. Empty when the answer is purely custom text. */
selected: string[]
/** Optional free-text "Other" answer. */
custom?: string
}
/** The human's answer. */
export interface AskUserQuestionAnswer {
/** Structured answers keyed by question id. */
answers: AskUserQuestionAnswerItem[]
}
/** UI-side provider for user questions. */
export interface UserInteractionProvider {
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
}
/** Stable error taxonomy for user-interaction failures. */
export class UserInteractionError extends HarnessError {
constructor(message: string, code: string, options?: ErrorOptions) {
super(message, code, options)
this.name = 'UserInteractionError'
}
}
/** `ctx.userInteraction`: one active UI provider plus an `ask()` surface. */
export class UserInteractionService extends Service {
private provider: UserInteractionProvider | undefined
constructor(ctx: Context) {
super(ctx, 'userInteraction')
}
/**
* Register the UI provider. Only one provider may be active in a context.
*
* @param provider UI-side implementation that collects answers.
* @returns Disposer that unregisters this provider.
*/
registerProvider(provider: UserInteractionProvider): () => void {
const dispose = this.ctx.effect(function* (this: UserInteractionService) {
if (this.provider !== undefined) {
throw new UserInteractionError('a user-interaction provider is already registered', 'DUPLICATE_PROVIDER')
}
this.provider = provider
yield () => {
this.provider = undefined
}
}.bind(this), 'userInteraction.registerProvider()')
return () => void dispose()
}
/**
* Ask the active UI provider and wait for the user's answer.
*
* @param request Questions, owner agent, and abort signal.
* @returns The answer chosen or typed by the human.
*/
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.signal?.aborted) {
throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
}
if (request.questions.length === 0) {
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
}
if (this.provider === undefined) {
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
}
return this.provider.ask(request)
}
}
export default UserInteractionService
@@ -0,0 +1,86 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import UserInteractionService, {
UserInteractionError,
type AskUserQuestionRequest,
type UserInteractionProvider,
} from '@deepseek-ai/dsh-user-interaction'
function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUserQuestionRequest[] } {
const seen: AskUserQuestionRequest[] = []
return {
seen,
async ask(request) {
seen.push(request)
return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] }
},
}
}
describe('UserInteractionService', () => {
it('delegates ask requests to the registered provider', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = provider('yes')
ctx.userInteraction.registerProvider(p)
const result = await ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] })
expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }])
})
it('rejects ask requests when no provider is registered', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' })
})
it('registers providers with HMR-safe disposal', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = provider()
const dispose = ctx.userInteraction.registerProvider(p)
dispose()
dispose()
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }))
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
})
it('rejects duplicate providers instead of replacing the active UI', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
ctx.userInteraction.registerProvider(provider('first'))
expect(() => ctx.userInteraction.registerProvider(provider('second')))
.toThrow(UserInteractionError)
})
it('fails before reaching the provider when the signal is already aborted', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) }
ctx.userInteraction.registerProvider(p)
const controller = new AbortController()
controller.abort()
await expect(ctx.userInteraction.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], signal: controller.signal }))
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects empty question batches before reaching the provider', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
await expect(ctx.userInteraction.ask({ questions: [] }))
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'EMPTY_QUESTIONS' })
expect(p.ask).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../core/agent"
},
{
"path": "../../llm/llm"
}
]
}
+48
View File
@@ -354,6 +354,18 @@ importers:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/ui/user-interaction:
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/fs/fs:
devDependencies:
'@deepseek-ai/dsh-brand':
@@ -978,6 +990,9 @@ importers:
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tool-ask-user':
specifier: workspace:^
version: link:../tool-ask-user
'@deepseek-ai/dsh-tool-bash':
specifier: workspace:^
version: link:../../bash/tool-bash
@@ -990,6 +1005,9 @@ importers:
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@deepseek-ai/dsh-user-interaction':
specifier: workspace:^
version: link:../user-interaction
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
@@ -1017,6 +1035,9 @@ importers:
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-user-interaction':
specifier: workspace:^
version: link:../user-interaction
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
@@ -1068,6 +1089,12 @@ importers:
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tool-ask-user':
specifier: workspace:^
version: link:../tool-ask-user
'@deepseek-ai/dsh-user-interaction':
specifier: workspace:^
version: link:../user-interaction
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader)
@@ -1075,6 +1102,27 @@ importers:
specifier: ^3.17.0
version: 3.18.0
packages/ui/tool-ask-user:
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@deepseek-ai/dsh-user-interaction':
specifier: workspace:^
version: link:../user-interaction
cordis:
specifier: ^4.0.0-rc.6
version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4)
packages/util/brand:
devDependencies:
cordis:
+10 -1
View File
@@ -122,9 +122,18 @@ const SERVICE_ROLES: ServiceRole[] = [
pkg: 'tools',
title: 'Tool registry and execution waterfall',
mode: 'core',
consumers: ['agent-loop', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-subagent', 'tool-todo', 'tool-web', 'acp'],
note: 'Registers tool definitions, exposes schemas to the prompt, and routes calls through tools/pre-execute and tools/post-execute.',
},
{
key: 'userInteraction',
pkg: 'user-interaction',
title: 'Human question/answer seam',
mode: 'seam',
implementations: ['stdio-agent', 'acp'],
consumers: ['tool-ask-user', 'stdio-agent', 'acp'],
note: 'UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise.',
},
{
key: 'agents',
pkg: 'agent',
+15
View File
@@ -41,11 +41,13 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import WebService from '@deepseek-ai/dsh-web'
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
import SubagentService from '@deepseek-ai/dsh-subagent'
import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock'
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
@@ -101,6 +103,19 @@ interface ToolPackage {
* guard proves it is exhaustive against the on-disk glob.
*/
const TOOL_PACKAGES: ToolPackage[] = [
{
pkg: '@deepseek-ai/dsh-tool-ask-user',
dir: 'tool-ask-user',
source: 'packages/ui/tool-ask-user/src/index.ts',
requires: ['ctx.tools', 'ctx.userInteraction'],
writes: ['tool/call', 'tool/result after a UI/provider answers the question'],
async mount(ctx) {
await ctx.plugin(UserInteractionService)
await ctx.plugin(ToolAskUser)
},
note:
'ask_user_question pauses the tool call until the active UI provider returns a human answer.',
},
{
pkg: '@deepseek-ai/dsh-tool-bash',
dir: 'tool-bash',
+8
View File
@@ -48,6 +48,14 @@
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredSchemaNode", "source": "packages/core/tools/src/json-schema.ts" },
{ "doc": "docs/core-data-structures/tools.md", "symbol": "StructuredOutputSchema", "source": "packages/core/tools/src/json-schema.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionOption", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionItem", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionRequest", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswerItem", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "AskUserQuestionAnswer", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionProvider", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/user-interaction.md", "symbol": "UserInteractionError", "source": "packages/ui/user-interaction/src/index.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecRequest", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashExecSpec", "source": "packages/bash/bash/src/types.ts" },
{ "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" },
+2
View File
@@ -19,7 +19,9 @@
{ "path": "./packages/session-persistence/session-persistence-sqlite" },
{ "path": "./packages/core/system-prompt" },
{ "path": "./packages/core/agent" },
{ "path": "./packages/ui/user-interaction" },
{ "path": "./packages/core/tools" },
{ "path": "./packages/ui/tool-ask-user" },
{ "path": "./packages/core/agent-loop" },
{ "path": "./packages/core/agent-core" },
{ "path": "./packages/bash/bash" },
+2
View File
@@ -30,7 +30,9 @@
{ "path": "./packages/session-persistence/session-persistence-sqlite" },
{ "path": "./packages/core/system-prompt" },
{ "path": "./packages/core/agent" },
{ "path": "./packages/ui/user-interaction" },
{ "path": "./packages/core/tools" },
{ "path": "./packages/ui/tool-ask-user" },
{ "path": "./packages/core/agent-loop" },
{ "path": "./packages/core/agent-core" },
{ "path": "./packages/bash/bash" },