Fix ask_user_question review findings
This commit is contained in:
@@ -28,7 +28,7 @@ dsh-bash ← dsh-brand (abstract executor seam; b
|
||||
dsh-session ← dsh-llm, dsh-brand
|
||||
dsh-system-prompt ← dsh-llm
|
||||
dsh-agent ← dsh-llm, dsh-session, dsh-brand
|
||||
dsh-user-interaction ← dsh-agent
|
||||
dsh-user-interaction ← dsh-agent, dsh-llm
|
||||
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
|
||||
dsh-tool-ask-user ← dsh-tools, dsh-user-interaction
|
||||
dsh-bash-local ← dsh-bash (BashExecutor impl)
|
||||
@@ -37,7 +37,7 @@ dsh-llm-deepseek ← dsh-llm (DeepSeek adapter)
|
||||
dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter)
|
||||
dsh-agent-loop ← dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent
|
||||
dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks)
|
||||
dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence (ACP JSON-RPC bridge)
|
||||
dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence, dsh-tools, dsh-user-interaction (ACP JSON-RPC bridge + user-interaction provider)
|
||||
dsh-ui-stdio ← dsh-agent, dsh-session, dsh-user-interaction (stdio readline UI plugin + user-interaction provider)
|
||||
dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests)
|
||||
dsh-subagent ← dsh-agent, dsh-llm, dsh-tools (abstract subagent provider-registry seam)
|
||||
@@ -48,7 +48,7 @@ dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk
|
||||
dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool)
|
||||
dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin)
|
||||
dsh-stdio-agent ← dsh-agent-core, dsh-user-interaction, dsh-tool-ask-user, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin)
|
||||
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin)
|
||||
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-user-interaction, dsh-tool-ask-user, dsh-session-persistence-jsonl (ACP server APP + bin)
|
||||
```
|
||||
|
||||
The rule: **extension** plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
|
||||
@@ -62,7 +62,6 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
|
||||
| `system-prompt/` | `core` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
|
||||
| `tools/` | `core` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
|
||||
| `user-interaction/` | `core` | Abstract human question/answer seam | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | `core` | Model-facing `ask_user_question` tool | (registers on `ctx.tools`) |
|
||||
| `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
|
||||
| `agent-core/` | `core` | Bundle plugin: the providerless/executor-less/UI-less spine as code (forwards `agent-loop`'s `agents`) | (loads the spine) |
|
||||
@@ -76,6 +75,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop
|
||||
| `session-persistence-sqlite/` | `session-persistence` | SQLite persistence backend | (registers `ctx.sessionPersistence`) |
|
||||
| `invariants/` | `support` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
|
||||
| `acp/` | `ui` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `tool-ask-user/` | `ui` | Model-facing `ask_user_question` tool | (registers on `ctx.tools`) |
|
||||
| `stdio-agent/` | `ui` | Terminal stdio chat APP: agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
|
||||
| `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
|
||||
| `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) |
|
||||
|
||||
@@ -8,7 +8,6 @@ The packages every harness build is assembled from: the session log, the system-
|
||||
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
|
||||
| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
|
||||
| `user-interaction/` | Human question/answer seam for tools and permission flows | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
|
||||
| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) |
|
||||
|
||||
@@ -23,10 +23,12 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
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 {
|
||||
@@ -58,12 +59,9 @@ export interface UserInteractionProvider {
|
||||
}
|
||||
|
||||
/** Stable error taxonomy for user-interaction failures. */
|
||||
export class UserInteractionError extends Error {
|
||||
readonly code: string
|
||||
|
||||
export class UserInteractionError extends HarnessError {
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.code = code
|
||||
super(message, code, options)
|
||||
this.name = 'UserInteractionError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
return
|
||||
}
|
||||
|
||||
const allowCustom = pending.request.allowCustom ?? true
|
||||
const allowCustom = options.length === 0 || (pending.request.allowCustom ?? true)
|
||||
if (allowCustom && text !== '') {
|
||||
finishQuestion(pending, { answer: text })
|
||||
return
|
||||
|
||||
@@ -328,6 +328,19 @@ describe('createStdioChat input', () => {
|
||||
await expect(answer).resolves.toEqual({ answer: 'Use defaults' })
|
||||
})
|
||||
|
||||
it('accepts free-form input for an optionless question even when allowCustom is false', async () => {
|
||||
const { ctx, input } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
question: 'Choose?',
|
||||
allowCustom: false,
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
input.feed('Use the default path')
|
||||
|
||||
await expect(answer).resolves.toEqual({ answer: 'Use the default path' })
|
||||
})
|
||||
|
||||
it('rejects an active question when its signal aborts', async () => {
|
||||
const { ctx } = await setup()
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -5,9 +5,12 @@ 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`) |
|
||||
| `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`) |
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline `ui-stdio` plugin is the unstructured analogue but lives in `support/` because it exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product.
|
||||
|
||||
`tool-ask-user` lives here because it is a model-facing product affordance that depends on a UI/provider seam; it is not part of the providerless core spine.
|
||||
|
||||
`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 just the swappable backends plus one app entry. 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.
|
||||
|
||||
@@ -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 confirmation tools |
|
||||
| `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool |
|
||||
| `@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 |
|
||||
| ~~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 |
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
"@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-tool-ask-user": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6",
|
||||
"schemastery": "^3.17.0"
|
||||
},
|
||||
@@ -44,6 +46,8 @@
|
||||
"@deepseek-ai/dsh-acp": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-core": "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"
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ 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'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
export const name = 'acp-agent'
|
||||
|
||||
@@ -67,6 +69,8 @@ export const Config: z<Config> = z.object({
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
ctx.plugin(agentCore)
|
||||
ctx.plugin(UserInteractionService)
|
||||
ctx.plugin(toolAskUser)
|
||||
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
|
||||
ctx.plugin(acp, { model: config.model, systemPrompt: config.systemPrompt })
|
||||
}
|
||||
|
||||
@@ -29,6 +29,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')).toBeDefined()
|
||||
// No pre-created agents — ACP session/new creates them on demand.
|
||||
expect(ctx.get('agents')!.list()).toHaveLength(0)
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -39,10 +39,12 @@ const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js')
|
||||
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'support/invariants',
|
||||
'core/tools', 'core/user-interaction', 'core/agent-loop', 'llm/llm',
|
||||
'llm/llm-deepseek', 'bash/bash', 'bash/bash-local', 'bash/tool-bash',
|
||||
'support/invariants',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent',
|
||||
'ui/tool-ask-user',
|
||||
]
|
||||
const vendorPackages = [
|
||||
'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console',
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
{
|
||||
"path": "../../core/agent-core"
|
||||
},
|
||||
{
|
||||
"path": "../../core/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
}
|
||||
|
||||
@@ -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 the bridge provide ACP-backed answers for tools such as `ask_user_question`.
|
||||
|
||||
### Config
|
||||
|
||||
@@ -29,10 +29,11 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
|
||||
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
|
||||
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
|
||||
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) |
|
||||
| `elicitation/create` | `ctx.userInteraction` provider | `ask_user_question` pauses the tool call and asks the ACP client for a session-scoped form; choices use a `choice` single-select field, free-form answers use `answer`/`custom_answer`, and cancel/decline returns a structured `UserInteractionError` |
|
||||
|
||||
## Multi-session
|
||||
|
||||
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
|
||||
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — and `ctx.userInteraction` requests, which carry only the calling `Agent` — demux in O(1). Every `session/event`, `agent/status`, and ask-user elicitation is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
|
||||
|
||||
Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and predictable, so each task carries an opaque owner token — the owning agent's `session.header.id` — stored on the task inside the executor (`dsh-bash`'s `ownerOf(id)` seam). `bash_output`/`bash_kill` reject a task whose token differs from the caller's session token, so one session's agent can't read or kill another's task. Ownership is by session TOKEN, not `Agent` object identity — a different `Agent` object on the same session may access the task — and because the token lives on the executor's task it survives a `tool-bash` HMR reload.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
@@ -45,6 +46,8 @@
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -69,6 +72,12 @@ import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresen
|
||||
// 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 AskUserQuestionOption,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
import {
|
||||
acpPromptToText,
|
||||
harnessBlockToAcpContent,
|
||||
@@ -82,7 +91,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.
|
||||
@@ -109,6 +118,119 @@ function sameWorkspaceCwd(left: string, right: string): boolean {
|
||||
return resolvePath(left) === resolvePath(right)
|
||||
}
|
||||
|
||||
function optionAnswer(option: AskUserQuestionOption): string {
|
||||
return option.value ?? option.label
|
||||
}
|
||||
|
||||
function orderedOptions(options: readonly AskUserQuestionOption[] | undefined): AskUserQuestionOption[] {
|
||||
return [...(options ?? [])].sort((a, b) => Number(Boolean(b.recommended)) - Number(Boolean(a.recommended)))
|
||||
}
|
||||
|
||||
function optionDescription(option: AskUserQuestionOption): string {
|
||||
return option.description === undefined
|
||||
? option.label
|
||||
: `${option.label}: ${option.description}`
|
||||
}
|
||||
|
||||
function selectedOption(
|
||||
options: readonly AskUserQuestionOption[],
|
||||
answer: string,
|
||||
): AskUserQuestionOption | undefined {
|
||||
return options.find(option => optionAnswer(option) === answer)
|
||||
}
|
||||
|
||||
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,
|
||||
request: AskUserQuestionRequest,
|
||||
options: AskUserQuestionOption[],
|
||||
): CreateElicitationRequest {
|
||||
const allowCustom = options.length === 0 || (request.allowCustom ?? true)
|
||||
const title = request.header ?? 'Question'
|
||||
if (options.length === 0) {
|
||||
return {
|
||||
sessionId,
|
||||
mode: 'form',
|
||||
message: request.question,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
title,
|
||||
properties: {
|
||||
answer: { type: 'string', title: request.question },
|
||||
},
|
||||
required: ['answer'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const choiceOptions: EnumOption[] = options.map(option => ({
|
||||
const: optionAnswer(option),
|
||||
title: optionDescription(option),
|
||||
}))
|
||||
const recommended = options.find(option => option.recommended)
|
||||
return {
|
||||
sessionId,
|
||||
mode: 'form',
|
||||
message: request.question,
|
||||
requestedSchema: {
|
||||
type: 'object',
|
||||
title,
|
||||
properties: {
|
||||
choice: {
|
||||
type: 'string',
|
||||
title: request.question,
|
||||
description: allowCustom ? 'Choose one option, or fill a custom answer below.' : 'Choose one option.',
|
||||
oneOf: choiceOptions,
|
||||
...recommended !== undefined ? { default: optionAnswer(recommended) } : {},
|
||||
},
|
||||
...allowCustom
|
||||
? {
|
||||
custom_answer: {
|
||||
type: 'string' as const,
|
||||
title: 'Custom answer',
|
||||
description: 'Optional free-form answer. Leave empty to use the selected option.',
|
||||
},
|
||||
}
|
||||
: {},
|
||||
},
|
||||
required: allowCustom ? [] : ['choice'],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Plugin config: the agent template ACP sessions are created from. */
|
||||
export interface AcpConfig {
|
||||
/** Model name for created agents (must have a registered adapter). */
|
||||
@@ -224,6 +346,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) })
|
||||
@@ -254,6 +377,37 @@ 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 options = orderedOptions(request.options)
|
||||
const response = await withAbort(conn.unstable_createElicitation(
|
||||
elicitationForQuestion(sessionId, request, 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 customAnswer = requireStringContent(response.content, 'custom_answer')
|
||||
if (customAnswer !== undefined) return { answer: customAnswer }
|
||||
|
||||
const answer = requireStringContent(response.content, options.length === 0 ? 'answer' : 'choice')
|
||||
if (answer === undefined) {
|
||||
throw new UserInteractionError('ask_user_question returned no answer', 'NO_ANSWER')
|
||||
}
|
||||
const option = selectedOption(options, answer)
|
||||
return option === undefined ? { answer } : { answer, option }
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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,183 @@ 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', {
|
||||
header: 'Project config',
|
||||
question: 'Which language should I use?',
|
||||
options: [
|
||||
{ label: 'TypeScript', value: 'ts', description: 'Good for UI apps' },
|
||||
{ label: 'Python', value: 'py', description: 'Good for scripts', recommended: true },
|
||||
],
|
||||
allow_custom: false,
|
||||
}),
|
||||
textResponse('Python it is.'),
|
||||
],
|
||||
})
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'py' } })
|
||||
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: {
|
||||
default: 'py',
|
||||
oneOf: [
|
||||
{ const: 'py', title: 'Python: Good for scripts' },
|
||||
{ const: 'ts', title: 'TypeScript: Good for UI apps' },
|
||||
],
|
||||
},
|
||||
},
|
||||
required: ['choice'],
|
||||
},
|
||||
})
|
||||
const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result')
|
||||
expect(JSON.stringify(toolResult)).toContain('py')
|
||||
})
|
||||
|
||||
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', {
|
||||
question: 'What should I name it?',
|
||||
allow_custom: false,
|
||||
}),
|
||||
textResponse('Name recorded.'),
|
||||
],
|
||||
})
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { answer: '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: { answer: { type: 'string', title: 'What should I name it?' } },
|
||||
required: ['answer'],
|
||||
},
|
||||
})
|
||||
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_answer: '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,
|
||||
question: 'Which language?',
|
||||
options: [{ label: 'TypeScript' }],
|
||||
})
|
||||
|
||||
expect(result).toEqual({ answer: '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_answer: { type: 'string' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns raw ACP answers when they do not match a provided option', async () => {
|
||||
harness = await makeBridgeHarness({ storageDir, withAskUser: true })
|
||||
harness.onElicitation = () => ({ action: 'accept', content: { choice: 'something else' } })
|
||||
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,
|
||||
question: 'Pick',
|
||||
options: [{ label: 'A', value: 'a' }],
|
||||
allowCustom: false,
|
||||
})).resolves.toEqual({ answer: 'something else' })
|
||||
})
|
||||
|
||||
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({ question: 'No agent?' }))
|
||||
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' })
|
||||
await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, question: 'No session?' }))
|
||||
.rejects.toMatchObject({ code: 'NO_SESSION' })
|
||||
|
||||
harness.onElicitation = () => ({ action: 'cancel' })
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, question: 'Cancel?' }))
|
||||
.rejects.toMatchObject({ code: 'ASK_CANCELLED' })
|
||||
|
||||
harness.onElicitation = () => ({ action: 'accept', content: {} })
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, question: 'Empty?' }))
|
||||
.rejects.toMatchObject({ code: 'NO_ANSWER' })
|
||||
|
||||
harness.onElicitation = () => { throw new Error('client boom') }
|
||||
await expect(harness.ctx.userInteraction.ask({ agent, 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, 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, question: 'Raced?', signal: racingAbort }))
|
||||
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
|
||||
let release: ((value: { action: 'accept'; content: { answer: string } }) => void) | undefined
|
||||
harness.onElicitation = () => new Promise((resolve) => { release = resolve })
|
||||
const pendingAbort = new AbortController()
|
||||
const ask = harness.ctx.userInteraction.ask({ agent, 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: { answer: '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: {} })
|
||||
|
||||
@@ -25,11 +25,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'
|
||||
|
||||
@@ -117,6 +121,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
|
||||
/**
|
||||
@@ -158,6 +166,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
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
|
||||
@@ -169,6 +179,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)
|
||||
@@ -197,6 +211,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,
|
||||
@@ -204,6 +219,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'],
|
||||
@@ -229,6 +246,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
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
|
||||
// to the built `lib/` (package.json `main`), exactly as an installed dep would.
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/user-interaction', 'core/tool-ask-user',
|
||||
'core/tools', 'core/user-interaction', 'ui/tool-ask-user',
|
||||
'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
|
||||
'bash/tool-bash', 'support/invariants', 'support/ui-stdio',
|
||||
'session-persistence/session-persistence',
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"path": "../../core/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tool-ask-user"
|
||||
"path": "../tool-ask-user"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
|
||||
@@ -126,6 +126,21 @@ describe('ask_user_question tool', () => {
|
||||
expect(seen[0]).toMatchObject({ header: 'Confirm', 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: { question: 'Continue?' },
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: true,
|
||||
error: { name: 'UserInteractionError', code: 'NO_PROVIDER' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses an option label when the selected option has no explicit value', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.userInteraction.registerProvider({
|
||||
@@ -21,16 +21,16 @@
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../agent"
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../system-prompt"
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../tools"
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
"path": "../../core/user-interaction"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user