Fix ask_user_question review findings
This commit is contained in:
@@ -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"
|
||||
|
||||
18
packages/ui/tool-ask-user/README.md
Normal file
18
packages/ui/tool-ask-user/README.md
Normal 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.
|
||||
38
packages/ui/tool-ask-user/package.json
Normal file
38
packages/ui/tool-ask-user/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
63
packages/ui/tool-ask-user/src/index.ts
Normal file
63
packages/ui/tool-ask-user/src/index.ts
Normal 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 }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
200
packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts
Normal file
200
packages/ui/tool-ask-user/tests/tool-ask-user.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
36
packages/ui/tool-ask-user/tsconfig.json
Normal file
36
packages/ui/tool-ask-user/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user