Fix ask_user_question review findings

This commit is contained in:
Yichen Jiang
2026-06-29 10:49:07 +08:00
parent 08fc8467bc
commit 51700d4685
38 changed files with 611 additions and 54 deletions

View File

@@ -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.

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 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 |

View File

@@ -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"
}

View File

@@ -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 })
}

View File

@@ -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()

View File

@@ -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',

View File

@@ -23,6 +23,12 @@
{
"path": "../../core/agent-core"
},
{
"path": "../../core/user-interaction"
},
{
"path": "../tool-ask-user"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
}

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 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.

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.

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": {
@@ -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"
}
}

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,
@@ -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

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,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: {} })

View File

@@ -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

View File

@@ -29,6 +29,9 @@
{
"path": "../../core/tools"
},
{
"path": "../../core/user-interaction"
},
{
"path": "../../session-persistence/session-persistence"
}

View File

@@ -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',

View File

@@ -33,7 +33,7 @@
"path": "../../core/user-interaction"
},
{
"path": "../../core/tool-ask-user"
"path": "../tool-ask-user"
},
{
"path": "../../session-persistence/session-persistence-jsonl"

View File

@@ -0,0 +1,18 @@
# @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:
- `question` — required question text.
- `header` — optional short heading.
- `options` — optional choices with `label`, `value`, `description`, and `recommended`.
- `allow_custom` — whether free-form answers are allowed; defaults to the provider's normal `true` behavior.
The tool calls `ctx.userInteraction.ask()` and returns the selected option value or custom answer as a text tool result.
## 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.

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"
}
}

View File

@@ -0,0 +1,63 @@
/**
* 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 type {} from '@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. '
+ 'Use options when possible; mark the recommended option when one is safest.'
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'ask_user_question',
description,
parameters: {
header: {
type: 'string',
description: 'Optional short heading for the question, such as "Confirm" or "Choose Mode".',
},
question: {
type: 'string',
required: true,
description: 'The specific question to ask the user.',
},
options: {
type: 'array',
description: 'Optional mutually exclusive choices to show the user.',
items: {
type: 'object',
properties: {
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
value: { type: 'string', description: 'Answer text returned to you if this option is selected. Defaults to label.' },
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
recommended: { type: 'boolean', description: 'True for the recommended/default option.' },
},
},
},
allow_custom: {
type: 'boolean',
description: 'Whether the user may type a free-form answer instead of selecting an option. Defaults to true.',
},
},
async execute(args, exec) {
const result = await ctx.userInteraction.ask({
question: args.question,
...args.header !== undefined ? { header: args.header } : {},
...args.options !== undefined ? { options: args.options } : {},
...args.allow_custom !== undefined ? { allowCustom: args.allow_custom } : {},
...exec.agent !== undefined ? { agent: exec.agent } : {},
...exec.signal !== undefined ? { signal: exec.signal } : {},
})
return [{ type: 'text', text: result.answer }]
},
}))
}

View File

@@ -0,0 +1,200 @@
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: {
options: {
items: {
properties: Record<string, { type: string }>
}
}
}
}
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: {
question: { type: 'string' },
options: { type: 'array' },
allow_custom: { type: 'boolean' },
},
required: ['question'],
},
})
const parameters = schema?.parameters as unknown as OptionSchemaShape
expect(parameters.properties.options.items.properties).toMatchObject({
description: { type: 'string' },
recommended: { type: 'boolean' },
})
expect(parameters.properties.options.items.properties).not.toHaveProperty('desc')
})
it('asks the registered user-interaction provider and returns the answer text', async () => {
const ctx = await setup()
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
seen.push(request)
const option = request.options?.[0]
return option === undefined ? { answer: 'Use pnpm' } : { answer: 'Use pnpm', option }
},
})
const result = await ctx.tools.execute({
callId: CallId('ask-1'),
name: 'ask_user_question',
arguments: {
question: 'Which package manager should I use?',
options: [{ label: 'pnpm', value: 'Use pnpm', recommended: true }],
allow_custom: false,
},
})
expect(result).toMatchObject({
isError: false,
content: [{ type: 'text', text: 'Use pnpm' }],
})
expect(seen).toMatchObject([{
question: 'Which package manager should I use?',
options: [{ label: 'pnpm', value: 'Use pnpm', recommended: true }],
allowCustom: false,
}])
})
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 { answer: 'ok' }
},
})
const controller = new AbortController()
await ctx.tools.execute({
callId: CallId('ask-2'),
name: 'ask_user_question',
arguments: { 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 { answer: 'ok' }
},
})
const agent = { id: 'main' } as unknown as Agent
const result = await ctx.tools.execute({
callId: CallId('ask-3'),
name: 'ask_user_question',
arguments: { header: 'Confirm', question: 'Continue?' },
agent,
})
expect(result.content).toEqual([{ type: 'text', text: 'ok' }])
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({
async ask(request) {
const option = request.options?.[0]
if (option === undefined) throw new Error('missing option')
return { answer: option.label, option }
},
})
const result = await ctx.tools.execute({
callId: CallId('ask-4'),
name: 'ask_user_question',
arguments: {
question: 'Pick one',
options: [{ label: 'Fallback label' }],
},
})
expect(result.content).toEqual([{ type: 'text', text: 'Fallback label' }])
})
it('returns the provider-computed answer even when option metadata is present', async () => {
const ctx = await setup()
ctx.userInteraction.registerProvider({
async ask(request) {
const option = request.options?.[0]
if (option === undefined) throw new Error('missing option')
return { answer: `selected ${option.value}`, option }
},
})
const result = await ctx.tools.execute({
callId: CallId('ask-5'),
name: 'ask_user_question',
arguments: {
question: 'Pick one',
options: [{ label: 'A', value: 'a' }],
},
})
expect(result.content).toEqual([{ type: 'text', text: 'selected a' }])
})
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()
})
})

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": "../../core/user-interaction"
}
]
}