diff --git a/README.md b/README.md index 27e7724d..c65f54c8 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ bunx --force dreb ### Tools and interaction -dreb ships with 12 built-in tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, and `search` (semantic codebase search). Two more tools are always active: `skill` for loading workflows, and `tasks_update` for visible task tracking. `suggest_next` (ghost text command suggestions, Tab to accept) is active by default but excluded when `--tools` is specified. +dreb ships with 13 built-in tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, `search` (semantic codebase search), and `ask_user` (pause and ask the user a structured multiple-choice or free-text question, rendered natively in the TUI and Dashboard). Two more tools are always active: `skill` for loading workflows, and `tasks_update` for visible task tracking. `suggest_next` (ghost text command suggestions, Tab to accept) is active by default but excluded when `--tools` is specified. Interactive mode adds slash commands such as `/model`, `/settings`, `/resume`, `/tree`, `/fork`, `/compact`, `/dream`, `/buddy`, `/export`, `/reload`, and `/hotkeys`. The message queue lets you steer a running agent or queue follow-up work without waiting for the current turn to finish. diff --git a/package-lock.json b/package-lock.json index 18ea6ada..ab961110 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dreb", - "version": "2.46.0", + "version": "2.47.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dreb", - "version": "2.46.0", + "version": "2.47.0", "workspaces": [ "packages/*", "packages/coding-agent/examples/extensions/with-deps", @@ -10955,7 +10955,7 @@ }, "packages/agent": { "name": "@dreb/agent-core", - "version": "2.46.0", + "version": "2.47.0", "license": "MIT", "dependencies": { "@dreb/ai": "*" @@ -10984,7 +10984,7 @@ }, "packages/ai": { "name": "@dreb/ai", - "version": "2.46.0", + "version": "2.47.0", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.73.0", @@ -11040,7 +11040,7 @@ }, "packages/coding-agent": { "name": "@dreb/coding-agent", - "version": "2.46.0", + "version": "2.47.0", "license": "MIT", "dependencies": { "@dreb/agent-core": "*", @@ -11169,7 +11169,7 @@ }, "packages/dashboard": { "name": "@dreb/dashboard", - "version": "2.46.0", + "version": "2.47.0", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -11401,7 +11401,7 @@ }, "packages/semantic-search": { "name": "@dreb/semantic-search", - "version": "2.46.0", + "version": "2.47.0", "license": "MIT", "dependencies": { "@huggingface/transformers": "^4.0.1", @@ -11450,7 +11450,7 @@ }, "packages/telegram": { "name": "@dreb/telegram", - "version": "2.46.0", + "version": "2.47.0", "license": "MIT", "dependencies": { "@dreb/coding-agent": "*", @@ -11483,7 +11483,7 @@ }, "packages/tui": { "name": "@dreb/tui", - "version": "2.46.0", + "version": "2.47.0", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", diff --git a/package.json b/package.json index 9e19810d..c657c256 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "node": "22.x" }, "packageManager": "npm@11.5.1", - "version": "2.46.0", + "version": "2.47.0", "dependencies": { "@dreb/coding-agent": "*", "@mariozechner/jiti": "^2.6.5", diff --git a/packages/agent/package.json b/packages/agent/package.json index 75136da5..5e7c1034 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/agent-core", - "version": "2.46.0", + "version": "2.47.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/package.json b/packages/ai/package.json index 82b3e703..75611769 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/ai", - "version": "2.46.0", + "version": "2.47.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 91f8af06..5a4d1077 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -79,7 +79,7 @@ dreb Or use a custom provider (corporate proxy, Bedrock, etc.) — see [Custom providers & models](#providers--models). -Then just talk to dreb. All 11 built-in tools are enabled by default: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, and `wait`. Use `--tools` to restrict to a subset (e.g., `--tools read,grep,find,ls` for read-only). Three additional tools — `search`, `skill`, and `tasks_update` — are always active regardless of `--tools`. `suggest_next` is active by default but excluded when `--tools` is specified. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [packages](#packages). +Then just talk to dreb. All 12 built-in tools are enabled by default: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, and `ask_user`. Use `--tools` to restrict to a subset (e.g., `--tools read,grep,find,ls` for read-only). Three additional tools — `search`, `skill`, and `tasks_update` — are always active regardless of `--tools`. `suggest_next` is active by default but excluded when `--tools` is specified. The model uses these to fulfill your requests. Add capabilities via [skills](#skills), [prompt templates](#prompt-templates), [extensions](#extensions), or [packages](#packages). **Also available:** [`@dreb/telegram`](https://www.npmjs.com/package/@dreb/telegram) — run dreb as a Telegram bot with live tool status and visible results for user-facing tools (`npm install -g @dreb/telegram`). [`@dreb/dashboard`](https://www.npmjs.com/package/@dreb/dashboard) — run `dreb dashboard` for a browser UI with fleet overview, full chat steering, inline provider/API failures with partial output preserved, sanitized raster tool images plus sent user uploads retained as bounded transcript previews by default, subagent observability, host file browser, curated appearance themes (per-browser light/dark), and Tailscale/rotating-code pairing (`npm install -g @dreb/dashboard`; see [docs/dashboard.md](docs/dashboard.md)). Tool images cross browser-facing transport as content-addressed references; browser-local Settings offers placeholders, bounded previews, or informed-opt-in originals, with size disclosure and confirmation above 1 MiB. Full-resolution HTML export remains self-contained. Compact SSE snapshots update live fleet cards without repeatedly fetching the cross-project inventory, and session drill-in hydrates state, messages, and background agents through one ordered snapshot request. Terminal provider failures show their reason on fleet cards, while transient failures clear terminal state when automatic retry begins and remain recorded inline on the failed attempt. Its top bar and persistent session header indicators report connecting, connected, retrying, resyncing, disconnected, or auth failed; bounded SSE replay plus an explicit snapshot barrier restores session state, tasks, and image references after a reload, restart, gap, backpressure disconnect, or stalled stream, while authenticated image routes recover bytes separately from authoritative transcripts. @@ -643,7 +643,9 @@ cat README.md | dreb -p "Summarize this text" | `--tools ` | Comma-separated list of tools to enable (default: all) | | `--no-tools` | Disable all built-in tools (extension tools still work) | -Available built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, `search` +Available built-in tools: `read`, `bash`, `edit`, `write`, `grep`, `find`, `ls`, `web_search`, `web_fetch`, `subagent`, `wait`, `search`, `ask_user` + +`ask_user` pauses the turn and asks you a structured clarifying question — optional 2-4 multiple-choice options (single- or multi-select) plus a "type your own answer" field — rendered natively in the TUI and the Dashboard, and over RPC. Skipping, Escape, tool abort, timeout, and headless/no-UI modes all resolve gracefully, so the agent never deadlocks waiting on an absent user. Options must contain nonblank text. When there are no options the free-text field is always shown, so every question is answerable. An optional `timeoutSeconds` auto-skips the question after the given number of seconds with a live countdown on every surface; recovered Dashboard sessions retain the original deadline rather than restarting it. UI or response-protocol failures are reported distinctly from an intentional user skip. Concurrent `ask_user` calls are shown strictly one at a time (FIFO). In the TUI: `↑`/`↓` move between options and the free-text field, `Space` toggles a checkbox (multi-select), `Enter` submits, and `Esc` skips. In the Dashboard: native radios/checkboxes plus a text field, a skip button, and `Esc` to skip. Three additional tools are always active but don't appear in `--tools`: - `skill` — invokes [skills](#skills) programmatically diff --git a/packages/coding-agent/docs/dashboard.md b/packages/coding-agent/docs/dashboard.md index a0d5d91b..4f274c97 100644 --- a/packages/coding-agent/docs/dashboard.md +++ b/packages/coding-agent/docs/dashboard.md @@ -120,7 +120,7 @@ networking window above. | Screen | What it does | |---|---| | **Fleet** | Home. Live-first: one grid of every live session at the top — status chip (● running / ◆ needs-attention / ○ idle / ✕ error), project path, activity line, live subagent lines, tasks progress, ctx%, model, terminal provider-error reason, last activity. Live cards keep a deterministic order by project path, then session start time; needs-attention cards badge the browser tab without jumping around. Below the grid: past sessions grouped by project, three compact rows per group with an "all N on disk" expander, resume and delete. | -| **Session view** | Full chat drill-in. Markdown streaming transcript (text, thinking blocks with expand preference, inline provider/API failures with partial output preserved, agent-result cards, tool cards with bespoke read/write/edit/bash bodies plus full expandable inputs, markdown-rendered results for markdown-contract tools like subagent/skill/web_fetch/suggest_next, and inline tool-result images, compaction/branch summaries, custom messages), per-message copy, tasks panel, subagent strip, status line with elapsed time plus ■ stop and compaction/retry aborts, a persistent session-header live indicator, and an info bar with cwd, branch, session name, token breakdown, cost/(sub)/daily rollup, ctx%, median tok/s, and a stats popover. Composer supports auto-grow, history, `/` autocomplete from `get_commands`, image attach/paste with sent images retained as user-message previews, queued-message chips with restore-all, steer/follow-up modes, and suggest-next. The ⋯ menu covers export HTML, compact, rename, fork-from-message, loaded context, and tool expand/collapse. Session names update live from manual rename or auto-naming. Extension UI requests (select/confirm/input/editor) render as modals; notifications as toasts. | +| **Session view** | Full chat drill-in. Markdown streaming transcript (text, thinking blocks with expand preference, inline provider/API failures with partial output preserved, agent-result cards, tool cards with bespoke read/write/edit/bash bodies plus full expandable inputs, markdown-rendered results for markdown-contract tools like subagent/skill/web_fetch/suggest_next, and inline tool-result images, compaction/branch summaries, custom messages), per-message copy, tasks panel, subagent strip, status line with elapsed time plus ■ stop and compaction/retry aborts, a persistent session-header live indicator, and an info bar with cwd, branch, session name, token breakdown, cost/(sub)/daily rollup, ctx%, median tok/s, and a stats popover. Composer supports auto-grow, history, `/` autocomplete from `get_commands`, image attach/paste with sent images retained as user-message previews, queued-message chips with restore-all, steer/follow-up modes, and suggest-next. The ⋯ menu covers export HTML, compact, rename, fork-from-message, loaded context, and tool expand/collapse. Session names update live from manual rename or auto-naming. Extension UI requests for select/confirm/input/editor render as modals; rich `ask`/`ask_user` questions render inline with choices, optional free text, skip/Escape, and the authoritative timeout countdown. Pending questions set needs-attention state and use the existing hidden-page notification path. Extension notifications render as toasts. | | **Subagent view** | Read-only transcript of a background agent: live events via the RPC relay, hydrated from the agent's on-disk session log (`/subagents/:agentId/messages`) so the transcript survives browser reloads. Shows the task, streaming output, tool activity, and any safe Dispatch Arbiter changed/unchanged/failure records with the final agent/model/thinking. No raw arbiter output is displayed or transported. No composer — subagents can't be steered yet; the parent session controls them. | | **Files** | Host-wide browser with places shortcuts (home, /tmp, project roots), breadcrumbs to `/`, new-folder, download, drop-zone/picker upload with explicit collision prompts, and "new session here" on any directory. It also shows the **effective global nested-context trust** for the displayed canonical directory: untrusted, trusted by that root, inherited from a granting root, or global expert trust-all. You can trust the displayed folder and descendants, or untrust the actual granting root; untrusting an inherited folder removes that root's trust for all descendants. | | **Settings** | Persistent defaults (default model, thinking level, steering/follow-up queue modes, auto-compaction, auto-retry) via `get_settings`/`set_settings` — validation errors are shown verbatim. The global-only Dispatch Arbiter card exposes enable/disable, exact authenticated model selection, thinking, guide path, and readiness guidance; model-less enablement is blocked and RPC/runtime validation remains fail-closed. Entering Settings flushes pending writes and reloads durable global + project settings, so external edits appear; read, parse, or write failures fail loudly instead of showing stale settings. The global-only nested-context policy lists every explicit trusted root for audit and revoke, offers a simple add-by-path control, and includes a prominently warned expert trust-all toggle; the Files view remains the primary place to grant trust while browsing. Most defaults seed new sessions; context-trust changes are observed by active main/subagent processes for future lazy loads, but cannot remove already injected content. Dashboard-local preferences (always expand thinking, transcript image display mode, needs-attention notification permission) live in the browser, alongside an appearance section: a theme gallery of eight curated themes (entropist.ca, Dim, Solarized, Gruvbox, Caves of Qud, Van Gogh, and the colorblind-safe Okabe-Ito and Paul Tol) with live preview cards and a system/light/dark mode selector, saved per browser. Shows the current rotating pairing code on the host/local dashboard, plus the paired-devices list with unpair. | diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 1d984a02..cd28d7e5 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -9,7 +9,7 @@ Extensions are TypeScript modules that extend dreb's behavior. They can subscrib **Key capabilities:** - **Custom tools** - Register tools the LLM can call via `dreb.registerTool()` - **Event interception** - Block or modify tool calls, inject context, customize compaction -- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, notify) +- **User interaction** - Prompt users via `ctx.ui` (select, confirm, input, ask, notify) - **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()` for complex interactions - **Custom commands** - Register commands like `/mycommand` via `dreb.registerCommand()` - **Session persistence** - Store state that survives restarts via `dreb.appendEntry()` @@ -161,6 +161,26 @@ export default function (dreb: ExtensionAPI) { ctx.ui.notify("Done!", "success"); ctx.ui.setStatus("my-ext", "Processing..."); // Footer status ctx.ui.setWidget("my-ext", ["Line 1", "Line 2"]); // Widget above editor (default) + + // ctx.ui.ask — a rich clarifying question with options + free text, + // rendered natively in the TUI and Dashboard (and over RPC). Resolves to + // { selected: string[], customText?: string }, or undefined when skipped, + // cancelled, or timed out. This is the same primitive that powers the + // built-in `ask_user` tool. + const answer = await ctx.ui.ask( + { + title: "Choose a database", + question: "Which persistence strategy should I use?", + options: ["SQLite", "PostgreSQL", "Keep the JSON file"], + allowFreeText: true, // default; offers a "type your own answer" field + multiSelect: false, // true → checkboxes, combined with any free text + multiline: false, // true → multi-line free-text area + }, + { signal, timeout: 60000 }, // both optional; absent user never deadlocks + ); + if (!answer) { + // user skipped / cancelled / timed out — continue gracefully + } }); // Register tools, commands, shortcuts, flags @@ -1782,6 +1802,14 @@ const ok = await ctx.ui.confirm("Delete?", "This cannot be undone"); // Text input const name = await ctx.ui.input("Name:", "placeholder"); +// Rich question: options and free text can be combined; undefined means skipped +const answer = await ctx.ui.ask({ + title: "Choose a database", + question: "Which persistence strategy should I use?", + options: ["SQLite", "PostgreSQL"], + allowFreeText: true, +}); + // Multi-line editor const text = await ctx.ui.editor("Edit:", "prefilled text"); @@ -1812,6 +1840,7 @@ if (confirmed) { - `select()` returns `undefined` - `confirm()` returns `false` - `input()` returns `undefined` +- `ask()` returns `undefined` #### Manual Dismissal with AbortSignal diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md index 6c4ac6b7..1b7aabd7 100644 --- a/packages/coding-agent/docs/rpc.md +++ b/packages/coding-agent/docs/rpc.md @@ -224,17 +224,17 @@ The `model` field is a full [Model](#model) object or `null`. `scopedModels` is #### get_dashboard_snapshot -Capture the dashboard-visible parent-session state, full parent transcript, and background-agent registry at one RPC command boundary. This is for authoritative recovery, not ordinary incremental refreshes. +Capture the dashboard-visible parent-session state, full parent transcript, background-agent registry, and blocking extension UI requests at one RPC command boundary. This is for authoritative recovery, not ordinary incremental refreshes. ```json {"id": "snapshot-7", "type": "get_dashboard_snapshot"} ``` -The `RpcDashboardSnapshot` result is a `snapshotId`, a complete `RpcSessionState` (including `tasks`), `messages`, and `backgroundAgents`. The RPC child writes a `RpcDashboardSnapshotBarrierEvent` to stdout **immediately before** the matching response line: +The `RpcDashboardSnapshot` result is a `snapshotId`, a complete `RpcSessionState` (including `tasks`), `messages`, `backgroundAgents`, and `pendingExtensionUiRequests`. The last field contains every blocking `select`, `confirm`, `input`, `editor`, or `ask` request still awaiting a host response, so a recovering Dashboard can restore the answer UI instead of leaving the runtime blocked. The RPC child writes a `RpcDashboardSnapshotBarrierEvent` to stdout **immediately before** the matching response line: ```json {"type":"dashboard_snapshot_barrier","snapshotId":"snapshot-7"} -{"id":"snapshot-7","type":"response","command":"get_dashboard_snapshot","success":true,"data":{"snapshotId":"snapshot-7","state":{...},"messages":[...],"backgroundAgents":[...]}} +{"id":"snapshot-7","type":"response","command":"get_dashboard_snapshot","success":true,"data":{"snapshotId":"snapshot-7","state":{...},"messages":[...],"backgroundAgents":[...],"pendingExtensionUiRequests":[...]}} ``` Stdout JSONL ordering is the contract: a relay records its current event-stream sequence when the marker arrives, before resolving the response, and pairs the snapshot only with that exact marker. The dashboard returns that captured sequence as `/api/resync.barrierSeq`; consumers discard queued events through it and replay only later events. The marker itself is not broadcast as another browser event, so one recovering client does not interrupt healthy clients. Do not infer ordering from request/response timing; see [dashboard recovery](dashboard.md#live-connection-and-recovery). @@ -1925,7 +1925,7 @@ Extensions can request user interaction via `ctx.ui.select()`, `ctx.ui.confirm() There are two categories of extension UI methods: -- **Dialog methods** (`select`, `confirm`, `input`, `editor`): emit an `extension_ui_request` on stdout and block until the client sends back an `extension_ui_response` on stdin with the matching `id`. +- **Dialog methods** (`select`, `confirm`, `input`, `editor`, `ask`): emit an `extension_ui_request` on stdout and block until the client sends back an `extension_ui_response` on stdin with the matching `id`. - **Fire-and-forget methods** (`notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`): emit an `extension_ui_request` on stdout but do not expect a response. The client can display the information or ignore it. If a dialog method includes a `timeout` field, the agent-side will auto-resolve with a default value when the timeout expires. The client does not need to track timeouts. @@ -2012,6 +2012,34 @@ Open a multi-line text editor with optional prefilled content. Expected response: `extension_ui_response` with `value` (the edited text) or `cancelled: true`. +#### ask + +Ask the user a rich clarifying question with optional single- or multi-select options and an optional free-text field. This powers the built-in `ask_user` tool. `options` (2-4 nonblank strings) is optional; `allowFreeText` (default `true`), `multiSelect`, and `multiline` are optional booleans. + +```json +{ + "type": "extension_ui_request", + "id": "uuid-5", + "method": "ask", + "title": "Choose a database", + "question": "Which persistence strategy should I use?", + "options": ["SQLite", "PostgreSQL", "Keep the JSON file"], + "allowFreeText": true, + "multiSelect": false, + "multiline": false, + "timeout": 60000, + "expiresAt": 1785434460000 +} +``` + +`timeout` is the original duration in milliseconds. `expiresAt` is the corresponding absolute Unix timestamp in milliseconds; Dashboard clients should use it for the visible countdown so reload, resync, or drill-in recovery does not restart the full duration. + +Expected response: `extension_ui_response` with `selected` (an array of strings, possibly empty) and optional string `customText` (the typed answer), or `cancelled: true` to skip. The client should treat an empty `selected` with no `customText` as a skip. Malformed ask responses are rejected as protocol failures rather than being reported to the model as user skips. + +```json +{ "type": "extension_ui_response", "id": "uuid-5", "selected": ["SQLite"], "customText": "with WAL enabled" } +``` + #### notify Display a notification. Fire-and-forget, no response expected. @@ -2089,7 +2117,7 @@ Set the text in the input editor. Fire-and-forget. ### Extension UI Responses (stdin) -Responses are sent for dialog methods only (`select`, `confirm`, `input`, `editor`). The `id` must match the request. +Responses are sent for dialog methods only (`select`, `confirm`, `input`, `editor`, `ask`). The `id` must match the request. #### Value response (select, input, editor) @@ -2111,6 +2139,17 @@ Dismiss any dialog method. The extension receives `undefined` (for select/input/ {"type": "extension_ui_response", "id": "uuid-3", "cancelled": true} ``` +After any dialog settles, RPC emits a lifecycle event on stdout so hosts can +remove the matching UI even when the dialog ended locally because of timeout, +abort, or runtime shutdown: + +```json +{"type": "extension_ui_response_handled", "id": "uuid-3"} +``` + +Hosts should treat this event as idempotent; it can arrive after the host has +already removed a successfully answered request. + ## Error Handling Failed commands return a response with `success: false`: diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 9455bb8e..09272fcf 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/coding-agent", - "version": "2.46.0", + "version": "2.47.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "type": "module", "drebConfig": { diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index 5ffe1095..cd9228cd 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -1170,7 +1170,12 @@ export class AgentSession { /** Emit extension events based on agent events */ private async _emitExtensionEvent(event: AgentEvent): Promise { - if (!this._extensionRunner) return; + // The runner is created unconditionally (so built-in tools like ask_user + // always have a UI context). When no extensions are loaded there are no + // handlers to invoke — return synchronously to avoid inserting an extra + // await tick per event, which would otherwise delay the final agent_end + // emission past when prompt() resolves. + if (!this._extensionRunner || !this._extensionRunner.hasExtensions) return; if (event.type === "agent_start") { this._turnIndex = 0; @@ -2973,10 +2978,16 @@ export class AgentSession { ? wrapRegisteredTools(allCustomTools, this._extensionRunner) : []; + // Give base tools a per-execution extension context so built-ins like + // ask_user can reach ctx.ui. createContext() snapshots hasUI at call time, + // so print/RPC-without-host modes degrade to the no-op UI (hasUI === false). + const baseToolCtxFactory = this._extensionRunner + ? () => (this._extensionRunner as ExtensionRunner).createContext() + : undefined; const toolRegistry = new Map( Array.from(this._baseToolDefinitions.values()).map((definition) => [ definition.name, - wrapToolDefinition(definition), + wrapToolDefinition(definition, baseToolCtxFactory), ]), ); for (const tool of wrappedExtensionTools as AgentTool[]) { @@ -3088,18 +3099,17 @@ export class AgentSession { } } - const hasExtensions = extensionsResult.extensions.length > 0; - const hasCustomTools = this._customTools.length > 0; - this._extensionRunner = - hasExtensions || hasCustomTools - ? new ExtensionRunner( - extensionsResult.extensions, - extensionsResult.runtime, - this._cwd, - this.sessionManager, - this._modelRegistry, - ) - : undefined; + // The runner also owns the cross-surface UI context used by built-in + // tools such as ask_user. Create it even when no third-party extensions + // are loaded; otherwise ordinary TUI/Dashboard sessions give base tools + // no ctx.ui and ask_user can never open its dialog. + this._extensionRunner = new ExtensionRunner( + extensionsResult.extensions, + extensionsResult.runtime, + this._cwd, + this.sessionManager, + this._modelRegistry, + ); if (this._extensionRunnerRef) { this._extensionRunnerRef.current = this._extensionRunner; } @@ -3123,6 +3133,7 @@ export class AgentSession { "subagent", "wait", "search", + "ask_user", "skill", "tasks_update", "suggest_next", diff --git a/packages/coding-agent/src/core/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 793d24ba..3089fa4e 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -28,6 +28,8 @@ export type { AppendEntryHandler, // App keybindings (for custom editors) AppKeybinding, + AskRequest, + AskResult, // Events - Tool (ToolCallEvent types) BashToolCallEvent, BashToolResultEvent, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 106f9184..2009317d 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -174,6 +174,7 @@ const noOpUIContext: ExtensionUIContext = { select: async () => undefined, confirm: async () => false, input: async () => undefined, + ask: async () => undefined, notify: () => {}, onTerminalInput: () => () => {}, setStatus: () => {}, @@ -448,6 +449,16 @@ export class ExtensionRunner { } } + /** + * Whether any extensions are loaded. The runner is now created + * unconditionally so built-in tools (e.g. ask_user) always have a UI + * context, but when no extensions are present there are no event handlers + * to run — callers can skip the async emit path entirely. + */ + get hasExtensions(): boolean { + return this.extensions.length > 0; + } + hasHandlers(eventType: string): boolean { for (const ext of this.extensions) { const handlers = ext.handlers.get(eventType); diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 8c730bac..1ad6ca8d 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -84,6 +84,37 @@ export interface ExtensionUIDialogOptions { timeout?: number; } +/** + * A rich question for {@link ExtensionUIContext.ask}. Presents optional + * suggested options (single- or multi-select) alongside an optional free-text + * field, rendered natively in every surface (TUI, Dashboard, RPC host). + */ +export interface AskRequest { + /** The question to ask the user. */ + question: string; + /** Short bold header. Defaults to a generic prompt title when omitted. */ + title?: string; + /** 2-4 suggested options. */ + options?: string[]; + /** Offer a "type your own answer" field. Defaults to true. */ + allowFreeText?: boolean; + /** Render options as checkboxes (multiple answers) instead of radios. */ + multiSelect?: boolean; + /** Use a multi-line text area for the free-text field. */ + multiline?: boolean; +} + +/** + * The user's answer to an {@link AskRequest}. A cancelled/skipped/timed-out + * question resolves to `undefined` instead of an {@link AskResult}. + */ +export interface AskResult { + /** Options the user selected (empty when only free text was provided). */ + selected: string[]; + /** Free-text answer, when the user typed one. */ + customText?: string; +} + /** Placement for extension widgets. */ export type WidgetPlacement = "aboveEditor" | "belowEditor"; @@ -110,6 +141,13 @@ export interface ExtensionUIContext { /** Show a text input dialog. */ input(title: string, placeholder?: string, opts?: ExtensionUIDialogOptions): Promise; + /** + * Show a rich question with suggested options, optional free text, and + * single- or multi-select. Resolves to the user's answer, or `undefined` + * when the question is skipped, cancelled, or times out. + */ + ask(request: AskRequest, opts?: ExtensionUIDialogOptions): Promise; + /** Show a notification to the user. */ notify(message: string, type?: "info" | "warning" | "error"): void; diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index d4f274c6..727f0d34 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -277,6 +277,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} "web_fetch", "subagent", "wait", + "ask_user", ]; const initialActiveToolNames: string[] = options.tools ? [...options.tools.map((t) => t.name).filter((n): n is ToolName => n in allTools), ...alwaysActiveBuiltins] diff --git a/packages/coding-agent/src/core/tools/ask-user.ts b/packages/coding-agent/src/core/tools/ask-user.ts new file mode 100644 index 00000000..25e20a86 --- /dev/null +++ b/packages/coding-agent/src/core/tools/ask-user.ts @@ -0,0 +1,270 @@ +/** + * ask_user tool. + * + * Lets the agent pause and ask the user a structured clarifying question — + * with optional multiple-choice options, single- or multi-select, and a + * "type your own answer" free-text field — rendered natively in the TUI, the + * Dashboard, and over RPC. Cancelling, skipping, aborting, or timing out + * always resolves gracefully so the agent never deadlocks on an absent user. + * + * Concurrent calls are serialized through a per-session FIFO queue: only one + * question is ever shown at a time, and a queued call whose signal aborts + * resolves as skipped without opening any UI. + */ + +import { Text } from "@dreb/tui"; +import { type Static, Type } from "@sinclair/typebox"; +import type { AskRequest, AskResult, ExtensionContext, ToolDefinition } from "../extensions/types.js"; + +// ============================================================================ +// Types + +export interface AskUserDetails { + question: string; + title?: string; + selected: string[]; + customText?: string; + /** True when the user skipped/cancelled/timed out. */ + skipped: boolean; + /** True when no interactive UI was available (headless/print mode). */ + unavailable: boolean; + /** True when the UI host or response protocol failed. */ + failed?: boolean; +} + +// ============================================================================ +// Schema + +const askUserSchema = Type.Object({ + question: Type.String({ + description: "The question to ask the user. Be specific about what you need to decide.", + }), + title: Type.Optional( + Type.String({ + description: "Short bold header shown above the question.", + }), + ), + options: Type.Optional( + Type.Array(Type.String({ minLength: 1, pattern: "\\S" }), { + minItems: 2, + maxItems: 4, + description: "2-4 nonblank suggested answers the user can pick from.", + }), + ), + allowFreeText: Type.Optional( + Type.Boolean({ + description: "Offer a 'type your own answer' field. Defaults to true.", + }), + ), + multiSelect: Type.Optional( + Type.Boolean({ + description: "Allow selecting multiple options (checkboxes). Only meaningful with options.", + }), + ), + multiline: Type.Optional( + Type.Boolean({ + description: "Use a large multi-line text area for open-ended answers.", + }), + ), + timeoutSeconds: Type.Optional( + Type.Number({ + minimum: 5, + maximum: 3600, + description: + "Optional: auto-skip the question after this many seconds if the user does not respond. " + + "Shows a live countdown. Omit to wait indefinitely.", + }), + ), +}); + +export type AskUserInput = Static; + +// ============================================================================ +// Result helpers + +function textResult(text: string, details: AskUserDetails) { + return { + content: [{ type: "text" as const, text }], + details, + }; +} + +function baseDetails(input: AskUserInput): Omit { + return { question: input.question, title: input.title }; +} + +function unavailableResult(input: AskUserInput) { + return textResult( + "The ask_user tool requires an interactive UI, which is not available in this mode. " + + "Proceed using your best judgment without waiting for an answer.", + { ...baseDetails(input), selected: [], skipped: true, unavailable: true }, + ); +} + +function skippedResult(input: AskUserInput) { + return textResult("The user skipped the question without answering. Continue without this input.", { + ...baseDetails(input), + selected: [], + skipped: true, + unavailable: false, + }); +} + +function failedResult(input: AskUserInput) { + return textResult( + "The question could not be delivered because the interactive UI or response protocol failed. " + + "Continue without this input.", + { + ...baseDetails(input), + selected: [], + skipped: false, + unavailable: false, + failed: true, + }, + ); +} + +function answeredResult(input: AskUserInput, answer: AskResult) { + const customText = answer.customText?.trim() || undefined; + const selected = answer.selected; + const parts: string[] = []; + if (selected.length > 0) { + parts.push( + selected.length === 1 ? `The user selected: ${selected[0]}` : `The user selected: ${selected.join(", ")}`, + ); + } + if (customText) { + parts.push(selected.length > 0 ? `They also wrote: "${customText}"` : `The user answered: "${customText}"`); + } + return textResult(parts.join(" "), { + ...baseDetails(input), + selected, + customText, + skipped: false, + unavailable: false, + }); +} + +// ============================================================================ +// Render helpers + +function formatCall(args: { question?: string; title?: string } | undefined, theme: any): string { + const label = (args?.title || args?.question || "").replace(/\s+/g, " ").trim(); + const shown = label.length > 80 ? `${label.slice(0, 79)}…` : label; + return `${theme.fg("toolTitle", theme.bold("ask_user"))} ${theme.fg("accent", shown)}`; +} + +function formatResult(details: AskUserDetails, theme: any): string { + if (details.unavailable) return theme.fg("toolOutput", "no interactive UI — continued without asking"); + if (details.failed) return theme.fg("toolOutput", "interactive UI failed — continued without an answer"); + if (details.skipped) return theme.fg("toolOutput", "user skipped"); + const parts: string[] = []; + if (details.selected.length > 0) parts.push(details.selected.join(", ")); + if (details.customText) parts.push(`"${details.customText}"`); + return theme.fg("toolOutput", `→ ${parts.join(" + ")}`); +} + +// ============================================================================ +// Tool definition factory + +/** + * Create an `ask_user` tool definition. Each call creates an isolated FIFO + * queue, so concurrent `ask_user` calls in a single session are shown strictly + * one at a time. + */ +export function createAskUserToolDefinition(): ToolDefinition { + // Per-session serialization: only one question is ever open at a time. + let tail: Promise = Promise.resolve(); + const serialize = (run: () => Promise): Promise => { + const result = tail.then(run, run); + // Always advance the queue, whether the call resolved, cancelled, timed + // out, or threw — so a failure can never wedge later questions. + tail = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + + return { + name: "ask_user", + label: "ask_user", + description: + "Pause and ask the user a structured clarifying question with optional multiple-choice options and a " + + "free-text answer. Use only when genuinely blocked by ambiguity with multiple viable paths — not for routine confirmation.", + + parameters: askUserSchema, + + promptSnippet: "Ask the user a clarifying question with optional multiple-choice options and free text", + + promptGuidelines: [ + "Call ask_user ONLY when you are genuinely blocked by ambiguity and there are multiple viable paths forward", + "Do NOT use it for routine confirmation, permission, or things you can reasonably decide yourself", + "Provide 2-4 concrete `options` when there are clear candidate answers; the user can always type their own", + "Set `multiSelect: true` when several options can be combined; `multiline: true` for open-ended answers", + "The user may skip — always handle a skipped answer gracefully and continue with a sensible default", + "Prefer one focused question over many; the question blocks the turn until the user responds or skips", + ], + + async execute(_toolCallId, input: AskUserInput, signal, _onUpdate, ctx?: ExtensionContext) { + const hasOptions = (input.options?.length ?? 0) > 0; + const request: AskRequest = { + question: input.question, + title: input.title, + options: input.options, + // Guarantee at least one answer control: with no options, free text + // must be offered regardless of the requested flag, otherwise both + // surfaces would render only a Skip button and no way to answer. + allowFreeText: hasOptions ? input.allowFreeText : true, + // multiSelect is only meaningful with options; multiline only with + // free text — normalize away impossible combinations. + multiSelect: hasOptions ? input.multiSelect : undefined, + multiline: hasOptions ? (input.allowFreeText === false ? undefined : input.multiline) : input.multiline, + }; + + // Optional auto-skip timeout, forwarded to every UI surface (TUI + // countdown, RPC/Dashboard). Model-facing units are seconds. + const timeout = input.timeoutSeconds && input.timeoutSeconds > 0 ? input.timeoutSeconds * 1000 : undefined; + + // Headless / print / no-host modes: never block on an unreachable UI. + if (!ctx?.hasUI) { + return unavailableResult(input); + } + + return serialize(async () => { + // A queued call whose signal already aborted resolves as skipped + // without ever opening the UI. + if (signal?.aborted) return skippedResult(input); + try { + const answer = await ctx.ui.ask(request, { signal, timeout }); + if (!answer || (answer.selected.length === 0 && !answer.customText?.trim())) { + return skippedResult(input); + } + return answeredResult(input, answer); + } catch { + // Host/protocol failure must still release the queue and never + // deadlock, but it must not masquerade as an intentional user skip. + return failedResult(input); + } + }); + }, + + renderCall(args, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0, undefined, true); + text.setText(formatCall(args, theme)); + return text; + }, + + renderResult(result, _options, theme, context) { + const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0, undefined, true); + const details = (result as any).details as AskUserDetails | undefined; + if (details) { + text.setText(formatResult(details, theme)); + } else { + const content = result.content?.[0]; + text.setText(theme.fg("toolOutput", content?.type === "text" ? content.text : "")); + } + return text; + }, + }; +} diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 51c5680b..bd6d640f 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,3 +1,8 @@ +export { + type AskUserDetails, + type AskUserInput, + createAskUserToolDefinition, +} from "./ask-user.js"; export { type BashOperations, type BashSpawnContext, @@ -159,6 +164,7 @@ export { import type { AgentTool } from "@dreb/agent-core"; import type { ToolDefinition } from "../extensions/types.js"; +import { createAskUserToolDefinition } from "./ask-user.js"; import { type BashToolOptions, bashTool, @@ -213,6 +219,8 @@ export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool]; const tmpReadToolDefinition = createTmpReadToolDefinition(); const tmpReadTool = wrapToolDefinition(tmpReadToolDefinition); const waitTool = wrapToolDefinition(waitToolDefinition); +const askUserToolDefinition = createAskUserToolDefinition(); +const askUserTool = wrapToolDefinition(askUserToolDefinition); export const allTools = { read: readTool, @@ -228,6 +236,7 @@ export const allTools = { tmp_read: tmpReadTool, wait: waitTool, search: searchTool, + ask_user: askUserTool, }; export const allToolDefinitions = { @@ -244,6 +253,7 @@ export const allToolDefinitions = { tmp_read: tmpReadToolDefinition, wait: waitToolDefinition, search: searchToolDefinition, + ask_user: askUserToolDefinition, }; export type ToolName = keyof typeof allTools; @@ -290,6 +300,7 @@ export function createAllToolDefinitions(cwd: string, options?: ToolsOptions): R tmp_read: createTmpReadToolDefinition(options?.read), wait: createWaitToolDefinition({ getRunningAgents: getRunningBackgroundAgents }), search: createSearchToolDefinition(cwd), + ask_user: createAskUserToolDefinition(), }; if (options?.skill) { tools.skill = createSkillToolDefinition(cwd, options.skill); @@ -331,6 +342,7 @@ export function createAllTools(cwd: string, options?: ToolsOptions): Record void; + private onCancelCallback: () => void; + + private titleText: Text; + private baseTitle: string; + private optionsContainer: Container; + private fieldLabel: Text | undefined; + private input: Input | undefined; + private editor: Editor | undefined; + private countdown: CountdownTimer | undefined; + private submitted = false; + + private _focused = false; + get focused(): boolean { + return this._focused; + } + set focused(value: boolean) { + this._focused = value; + this.syncFieldFocus(); + } + + private get freeTextRow(): number { + return this.allowFreeText ? this.options.length : -1; + } + + private get lastRow(): number { + return this.allowFreeText ? this.options.length : this.options.length - 1; + } + + private cursorOnField(): boolean { + return this.allowFreeText && this.cursorIndex === this.freeTextRow; + } + + constructor( + request: AskRequest, + onSubmit: (result: AskResult) => void, + onCancel: () => void, + opts?: AskUserComponentOptions, + ) { + super(); + + this.options = request.options ?? []; + this.allowFreeText = request.allowFreeText !== false; + this.multiSelect = request.multiSelect === true && this.options.length > 0; + this.multiline = request.multiline === true; + this.checked = this.options.map(() => false); + this.onSubmitCallback = onSubmit; + this.onCancelCallback = onCancel; + this.baseTitle = request.title?.trim() || "Question"; + + // Start the cursor on the first option, or the free-text field when there + // are no options. + this.cursorIndex = this.options.length > 0 ? 0 : this.freeTextRow; + + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + this.titleText = new Text(theme.fg("accent", theme.bold(this.baseTitle)), 1, 0); + this.addChild(this.titleText); + this.addChild(new Spacer(1)); + + this.addChild(new Text(theme.fg("text", request.question), 1, 0)); + this.addChild(new Spacer(1)); + + if (opts?.timeout && opts.timeout > 0 && opts.tui) { + this.countdown = new CountdownTimer( + opts.timeout, + opts.tui, + (s) => this.titleText.setText(theme.fg("accent", theme.bold(`${this.baseTitle} (${s}s)`))), + () => this.cancel(), + ); + } + + this.optionsContainer = new Container(); + this.addChild(this.optionsContainer); + + if (this.allowFreeText) { + this.addChild(new Spacer(1)); + this.fieldLabel = new Text("", 1, 0); + this.addChild(this.fieldLabel); + if (this.multiline && opts?.tui) { + this.editor = new Editor(opts.tui, getEditorTheme(), {}); + // The Editor clears its own state before invoking onSubmit, so we must + // use the text it hands us rather than re-reading the now-empty editor. + this.editor.onSubmit = (text) => this.submit(text); + this.addChild(this.editor); + } else { + this.input = new Input(); + this.addChild(this.input); + } + } + + this.addChild(new Spacer(1)); + this.addChild(new Text(this.buildHint(), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + this.renderRows(); + this.syncFieldFocus(); + } + + private buildHint(): string { + const parts = [rawKeyHint("↑↓", "move")]; + if (this.multiSelect) parts.push(rawKeyHint("Space", "toggle")); + parts.push(keyHint("tui.select.confirm", "submit")); + if (this.multiline) parts.push(keyHint("tui.input.newLine", "newline")); + parts.push(keyHint("tui.select.cancel", "skip")); + return parts.join(" "); + } + + private renderRows(): void { + this.optionsContainer.clear(); + for (let i = 0; i < this.options.length; i++) { + const focused = this.cursorIndex === i; + const cursor = focused ? theme.fg("accent", "→ ") : " "; + const glyph = this.multiSelect ? (this.checked[i] ? "[x]" : "[ ]") : focused ? "(•)" : "( )"; + const label = focused ? theme.fg("accent", this.options[i]) : theme.fg("text", this.options[i]); + const glyphColored = this.checked[i] || (!this.multiSelect && focused) ? theme.fg("accent", glyph) : glyph; + this.optionsContainer.addChild(new Text(`${cursor}${glyphColored} ${label}`, 1, 0)); + } + if (this.fieldLabel) { + const focused = this.cursorOnField(); + const prefix = focused ? theme.fg("accent", "→ ") : " "; + const labelText = this.options.length > 0 ? "Or type your own answer:" : "Your answer:"; + this.fieldLabel.setText(prefix + (focused ? theme.fg("accent", labelText) : theme.fg("muted", labelText))); + } + } + + private syncFieldFocus(): void { + const fieldFocused = this._focused && this.cursorOnField(); + if (this.input) this.input.focused = fieldFocused; + if (this.editor) this.editor.focused = fieldFocused; + } + + private moveCursor(delta: number): void { + const first = this.options.length > 0 ? 0 : this.freeTextRow; + const next = Math.max(first, Math.min(this.lastRow, this.cursorIndex + delta)); + if (next === this.cursorIndex) return; + this.cursorIndex = next; + this.renderRows(); + this.syncFieldFocus(); + } + + private fieldText(editorTextOverride?: string): string { + if (this.editor) return (editorTextOverride ?? this.editor.getText()).trim(); + if (this.input) return this.input.getValue().trim(); + return ""; + } + + private currentAnswer(editorTextOverride?: string): AskResult | undefined { + const customText = this.fieldText(editorTextOverride) || undefined; + if (this.multiSelect) { + const selected = this.options.filter((_, i) => this.checked[i]); + if (selected.length === 0 && !customText) return undefined; + return { selected, customText }; + } + if (this.cursorOnField()) { + if (!customText) return undefined; + return { selected: [], customText }; + } + const option = this.options[this.cursorIndex]; + // Single-select: submit the highlighted option together with any typed + // free text, matching the Dashboard, which combines a radio selection with + // custom text (and the tool's own combined-answer result formatting). + return option ? { selected: [option], customText } : undefined; + } + + private submit(editorTextOverride?: string): void { + if (this.submitted) return; + const answer = this.currentAnswer(editorTextOverride); + if (!answer) return; // Nothing to submit yet — Esc skips instead. + this.submitted = true; + this.onSubmitCallback(answer); + } + + private cancel(): void { + if (this.submitted) return; + this.submitted = true; + this.onCancelCallback(); + } + + handleInput(keyData: string): void { + const kb = getKeybindings(); + + if (kb.matches(keyData, "tui.select.cancel")) { + this.cancel(); + return; + } + + if (this.cursorOnField()) { + // Multiline editor: Enter submits (via onSubmit), Shift+Enter inserts a + // newline, ↑ at the top line leaves to the options, everything else edits. + if (this.editor) { + if (kb.matches(keyData, "tui.select.up") && this.editor.getCursor().line === 0) { + this.moveCursor(-1); + return; + } + this.editor.handleInput(keyData); + return; + } + // Single-line input: ↑ leaves to the options, Enter submits, else edits. + if (this.input) { + if (kb.matches(keyData, "tui.select.up")) { + this.moveCursor(-1); + return; + } + if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + this.submit(); + return; + } + this.input.handleInput(keyData); + return; + } + return; + } + + // Cursor on an option row. + if (kb.matches(keyData, "tui.select.up")) { + this.moveCursor(-1); + } else if (kb.matches(keyData, "tui.select.down")) { + this.moveCursor(1); + } else if (this.multiSelect && keyData === " ") { + this.checked[this.cursorIndex] = !this.checked[this.cursorIndex]; + this.renderRows(); + } else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") { + this.submit(); + } + } + + dispose(): void { + this.countdown?.dispose(); + } +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 82f5a81c..fea521f7 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -56,6 +56,8 @@ import { validateMemoryLinks, } from "../../core/dream.js"; import type { + AskRequest, + AskResult, ExtensionContext, ExtensionRunner, ExtensionUIContext, @@ -91,6 +93,7 @@ import { } from "../../utils/message-text.js"; import { ensureTool } from "../../utils/tools-manager.js"; import { ArminComponent } from "./components/armin.js"; +import { AskUserComponent } from "./components/ask-user.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; import { BorderedLoader } from "./components/bordered-loader.js"; @@ -257,6 +260,8 @@ export class InteractiveMode { private extensionSelector: ExtensionSelectorComponent | undefined = undefined; private extensionInput: ExtensionInputComponent | undefined = undefined; private extensionEditor: ExtensionEditorComponent | undefined = undefined; + private extensionAsk: AskUserComponent | undefined = undefined; + private extensionDialogQueue: Promise = Promise.resolve(); private extensionTerminalInputUnsubscribers = new Set<() => void>(); // Extension widgets (components rendered above/below the editor) @@ -1515,6 +1520,9 @@ export class InteractiveMode { if (this.extensionEditor) { this.hideExtensionEditor(); } + if (this.extensionAsk) { + this.hideExtensionAsk(); + } this.ui.hideOverlay(); this.clearExtensionTerminalInputListeners(); this.setExtensionFooter(undefined); @@ -1777,9 +1785,26 @@ export class InteractiveMode { */ private createExtensionUIContext(): ExtensionUIContext { return { - select: (title, options, opts) => this.showExtensionSelector(title, options, opts), - confirm: (title, message, opts) => this.showExtensionConfirm(title, message, opts), - input: (title, placeholder, opts) => this.showExtensionInput(title, placeholder, opts), + select: (title, options, opts) => + this.enqueueExtensionDialog(() => this.showExtensionSelector(title, options, opts), { + signal: opts?.signal, + abortedValue: undefined, + }), + confirm: (title, message, opts) => + this.enqueueExtensionDialog(() => this.showExtensionConfirm(title, message, opts), { + signal: opts?.signal, + abortedValue: false, + }), + input: (title, placeholder, opts) => + this.enqueueExtensionDialog(() => this.showExtensionInput(title, placeholder, opts), { + signal: opts?.signal, + abortedValue: undefined, + }), + ask: (request, opts) => + this.enqueueExtensionDialog(() => this.showExtensionAsk(request, opts), { + signal: opts?.signal, + abortedValue: undefined, + }), notify: (message, type) => this.showExtensionNotify(message, type), onTerminalInput: (handler) => this.addExtensionTerminalInputListener(handler), setStatus: (key, text) => this.setExtensionStatus(key, text), @@ -1788,11 +1813,11 @@ export class InteractiveMode { setFooter: (factory) => this.setExtensionFooter(factory), setHeader: (factory) => this.setExtensionHeader(factory), setTitle: (title) => this.ui.terminal.setTitle(title), - custom: (factory, options) => this.showExtensionCustom(factory, options), + custom: (factory, options) => this.enqueueExtensionDialog(() => this.showExtensionCustom(factory, options)), pasteToEditor: (text) => this.editor.handleInput(`\x1b[200~${text}\x1b[201~`), setEditorText: (text) => this.editor.setText(text), getEditorText: () => this.editor.getExpandedText?.() ?? this.editor.getText(), - editor: (title, prefill) => this.showExtensionEditor(title, prefill), + editor: (title, prefill) => this.enqueueExtensionDialog(() => this.showExtensionEditor(title, prefill)), setEditorComponent: (factory) => this.setCustomEditorComponent(factory), get theme() { return theme; @@ -1819,6 +1844,42 @@ export class InteractiveMode { }; } + /** Serialize every blocking extension dialog that can take over keyboard focus. */ + private enqueueExtensionDialog( + open: () => Promise, + opts?: { signal?: AbortSignal; abortedValue: T }, + ): Promise { + if (!opts?.signal) { + const result = this.extensionDialogQueue.then(open, open); + this.extensionDialogQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + const { signal, abortedValue } = opts; + if (signal.aborted) return Promise.resolve(abortedValue); + + // Settle the caller as soon as a queued dialog aborts, even if an earlier + // unbounded dialog still owns the UI. Its queue slot remains as a no-op so + // later dialogs keep their FIFO order. + let onQueuedAbort!: () => void; + const aborted = new Promise((resolve) => { + onQueuedAbort = () => resolve(abortedValue); + signal.addEventListener("abort", onQueuedAbort, { once: true }); + }); + const run = () => { + signal.removeEventListener("abort", onQueuedAbort); + return signal.aborted ? Promise.resolve(abortedValue) : open(); + }; + const queued = this.extensionDialogQueue.then(run, run); + this.extensionDialogQueue = queued.then( + () => undefined, + () => undefined, + ); + return Promise.race([queued, aborted]).finally(() => signal.removeEventListener("abort", onQueuedAbort)); + } + /** * Show a selector for extensions. */ @@ -1935,6 +1996,53 @@ export class InteractiveMode { this.restoreEditorComponent(); } + /** + * Show a rich ask_user question for extensions/built-in tools. + */ + private showExtensionAsk(request: AskRequest, opts?: ExtensionUIDialogOptions): Promise { + return new Promise((resolve) => { + if (opts?.signal?.aborted) { + resolve(undefined); + return; + } + + const onAbort = () => { + this.hideExtensionAsk(); + resolve(undefined); + }; + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + + this.extensionAsk = new AskUserComponent( + request, + (result) => { + opts?.signal?.removeEventListener("abort", onAbort); + this.hideExtensionAsk(); + resolve(result); + }, + () => { + opts?.signal?.removeEventListener("abort", onAbort); + this.hideExtensionAsk(); + resolve(undefined); + }, + { tui: this.ui, timeout: opts?.timeout }, + ); + + this.editorContainer.clear(); + this.editorContainer.addChild(this.extensionAsk); + this.ui.setFocus(this.extensionAsk); + this.ui.requestRender(); + }); + } + + /** + * Hide the ask_user question. + */ + private hideExtensionAsk(): void { + this.extensionAsk?.dispose(); + this.extensionAsk = undefined; + this.restoreEditorComponent(); + } + /** * Show a multi-line editor for extensions (with Ctrl+G support). */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 385f22f7..f24160b7 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -48,6 +48,7 @@ import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js"; import type { RpcAgentTypeInfo, RpcBackgroundAgentInfo, + RpcBlockingExtensionUIRequest, RpcCommand, RpcContextTrustEvaluation, RpcContextTrustMutationResult, @@ -1081,48 +1082,22 @@ export async function navigateTreeForRpc( * Run in RPC mode. * Listens for JSON commands on stdin, outputs events and responses on stdout. */ -export async function runRpcMode(session: AgentSession, modelFallbackMessage?: string): Promise { - takeOverStdout(); - - const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { - writeRawStdout(serializeJsonLine(obj)); - }; - - const success = ( - id: string | undefined, - command: T, - data?: object | null, - ): RpcResponse => { - if (data === undefined) { - return { id, type: "response", command, success: true } as RpcResponse; - } - return { id, type: "response", command, success: true, data } as RpcResponse; - }; - - const error = (id: string | undefined, command: string, message: string): RpcResponse => { - return { id, type: "response", command, success: false, error: message }; - }; - - if (session.sessionFile && session.messages.length > 0) { - const rehydratedCount = rehydrateBackgroundAgentsFromDisk(session.sessionFile); - if (rehydratedCount > 0) { - console.error( - `[rpc] Rehydrated ${rehydratedCount} background subagent${rehydratedCount === 1 ? "" : "s"} from disk`, - ); - } - } - - // Pending extension UI requests waiting for response - const pendingExtensionRequests = new Map< - string, - { resolve: (value: any) => void; reject: (error: Error) => void } - >(); - - // Shutdown request flag - let shutdownRequested = false; - let dailyCostTracker: DailyCostTracker | undefined; - let dailyCostTrackerPrimed = false; +/** + * Build the RPC-mode extension UI context. Extracted from `runRpcMode` so the + * dialog request emission and response mapping (select/confirm/input/ask/editor) + * can be unit-tested without spawning the CLI. `output` is the JSONL sink and + * `pendingExtensionRequests` is the shared map keyed by request id. + */ +export interface PendingRpcExtensionRequest { + request: RpcBlockingExtensionUIRequest; + resolve: (value: any) => void; + reject: (error: Error) => void; +} +export function createRpcExtensionUIContext( + output: (obj: RpcResponse | RpcExtensionUIRequest | object) => void, + pendingExtensionRequests: Map, +): ExtensionUIContext { /** Helper for dialog methods with signal/timeout support */ function createDialogPromise( opts: ExtensionUIDialogOptions | undefined, @@ -1135,41 +1110,59 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s const id = crypto.randomUUID(); return new Promise((resolve, reject) => { let timeoutId: ReturnType | undefined; + let settled = false; - const cleanup = () => { + const cleanup = (): boolean => { + if (settled) return false; + settled = true; if (timeoutId) clearTimeout(timeoutId); opts?.signal?.removeEventListener("abort", onAbort); pendingExtensionRequests.delete(id); + output({ type: "extension_ui_response_handled", id }); + return true; }; const onAbort = () => { - cleanup(); + if (!cleanup()) return; resolve(defaultValue); }; opts?.signal?.addEventListener("abort", onAbort, { once: true }); if (opts?.timeout) { timeoutId = setTimeout(() => { - cleanup(); + if (!cleanup()) return; resolve(defaultValue); }, opts.timeout); } + const rpcRequest = { + type: "extension_ui_request", + id, + ...request, + } as RpcBlockingExtensionUIRequest; pendingExtensionRequests.set(id, { + request: rpcRequest, resolve: (response: RpcExtensionUIResponse) => { - cleanup(); - resolve(parseResponse(response)); + if (!cleanup()) return; + try { + resolve(parseResponse(response)); + } catch (error) { + reject(error); + } + }, + reject: (error) => { + if (!cleanup()) return; + reject(error); }, - reject, }); - output({ type: "extension_ui_request", id, ...request } as RpcExtensionUIRequest); + output(rpcRequest); }); } /** * Create an extension UI context that uses the RPC protocol. */ - const createExtensionUIContext = (): ExtensionUIContext => ({ + return { select: (title, options, opts) => createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, (r) => "cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined, @@ -1185,6 +1178,38 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s "cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined, ), + ask: (request, opts) => + createDialogPromise( + opts, + undefined, + { + method: "ask", + title: request.title ?? "Question", + question: request.question, + options: request.options, + allowFreeText: request.allowFreeText, + multiSelect: request.multiSelect, + multiline: request.multiline, + timeout: opts?.timeout, + // Preserve the authoritative deadline across Dashboard reload, + // resync, and drill-in hydration instead of restarting the full + // duration whenever the question component remounts. + expiresAt: opts?.timeout ? Date.now() + opts.timeout : undefined, + }, + (response) => { + if ("cancelled" in response && response.cancelled) return undefined; + const selected = (response as { selected?: unknown }).selected; + const customText = (response as { customText?: unknown }).customText; + if (!Array.isArray(selected) || !selected.every((value) => typeof value === "string")) { + throw new Error("Invalid RPC ask response: selected must be an array of strings"); + } + if (customText !== undefined && typeof customText !== "string") { + throw new Error("Invalid RPC ask response: customText must be a string"); + } + return { selected, customText }; + }, + ), + notify(message: string, type?: "info" | "warning" | "error"): void { // Fire and forget - no response needed output({ @@ -1275,24 +1300,14 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s return ""; }, - async editor(title: string, prefill?: string): Promise { - const id = crypto.randomUUID(); - return new Promise((resolve, reject) => { - pendingExtensionRequests.set(id, { - resolve: (response: RpcExtensionUIResponse) => { - if ("cancelled" in response && response.cancelled) { - resolve(undefined); - } else if ("value" in response) { - resolve(response.value); - } else { - resolve(undefined); - } - }, - reject, - }); - output({ type: "extension_ui_request", id, method: "editor", title, prefill } as RpcExtensionUIRequest); - }); - }, + editor: (title, prefill) => + createDialogPromise(undefined, undefined, { method: "editor", title, prefill }, (response) => + "cancelled" in response && response.cancelled + ? undefined + : "value" in response + ? response.value + : undefined, + ), setEditorComponent(): void { // Custom editor components not supported in RPC mode @@ -1323,7 +1338,63 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s setToolsExpanded(_expanded: boolean) { // Tool expansion not supported in RPC mode - no TUI }, - }); + }; +} + +/** Cancel every pending dialog during RPC host teardown. */ +export function cancelPendingRpcExtensionRequests( + pendingExtensionRequests: Map, +): void { + for (const [id, pending] of [...pendingExtensionRequests]) { + pending.resolve({ type: "extension_ui_response", id, cancelled: true }); + } +} + +export async function runRpcMode(session: AgentSession, modelFallbackMessage?: string): Promise { + takeOverStdout(); + + const output = (obj: RpcResponse | RpcExtensionUIRequest | object) => { + writeRawStdout(serializeJsonLine(obj)); + }; + + const success = ( + id: string | undefined, + command: T, + data?: object | null, + ): RpcResponse => { + if (data === undefined) { + return { id, type: "response", command, success: true } as RpcResponse; + } + return { id, type: "response", command, success: true, data } as RpcResponse; + }; + + const error = (id: string | undefined, command: string, message: string): RpcResponse => { + return { id, type: "response", command, success: false, error: message }; + }; + + if (session.sessionFile && session.messages.length > 0) { + const rehydratedCount = rehydrateBackgroundAgentsFromDisk(session.sessionFile); + if (rehydratedCount > 0) { + console.error( + `[rpc] Rehydrated ${rehydratedCount} background subagent${rehydratedCount === 1 ? "" : "s"} from disk`, + ); + } + } + + // Pending extension UI requests waiting for response. Keep the emitted + // payload alongside its callbacks so recovery snapshots can reconstruct the + // answer UI after a Dashboard reload or replay gap. + const pendingExtensionRequests = new Map(); + + // Shutdown request flag + let shutdownRequested = false; + let dailyCostTracker: DailyCostTracker | undefined; + let dailyCostTrackerPrimed = false; + + // Extension UI context uses the RPC protocol; built by a module-scope + // factory so the dialog round trip is unit-testable (see createRpcExtensionUIContext). + const createExtensionUIContext = (): ExtensionUIContext => + createRpcExtensionUIContext(output, pendingExtensionRequests); // Set up extensions with RPC-based UI context await session.bindExtensions({ @@ -1478,6 +1549,7 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s state: getStateForRpc(session, modelFallbackMessage), messages: getDashboardMessagesForRpc(session), backgroundAgents: getBackgroundAgents().map(toRpcBackgroundAgentInfo), + pendingExtensionUiRequests: [...pendingExtensionRequests.values()].map(({ request }) => request), }; return success(id, "get_dashboard_snapshot", data); } @@ -1876,6 +1948,8 @@ export async function runRpcMode(session: AgentSession, modelFallbackMessage?: s let detachInput = () => {}; async function shutdown(): Promise { + cancelPendingRpcExtensionRequests(pendingExtensionRequests); + const currentRunner = session.extensionRunner; if (currentRunner?.hasHandlers("session_shutdown")) { await currentRunner.emit({ type: "session_shutdown" }); diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index a6a50fe3..a8759ab8 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -186,6 +186,8 @@ export interface RpcDashboardSnapshot { messages: AgentMessage[]; /** Current background-agent registry at that boundary. */ backgroundAgents: RpcBackgroundAgentInfo[]; + /** Blocking UI requests that are still waiting for a host response. */ + pendingExtensionUiRequests: RpcBlockingExtensionUIRequest[]; } /** Ordering marker emitted immediately before a matching dashboard snapshot response. */ @@ -651,6 +653,20 @@ export type RpcExtensionUIRequest = timeout?: number; } | { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string } + | { + type: "extension_ui_request"; + id: string; + method: "ask"; + title: string; + question: string; + options?: string[]; + allowFreeText?: boolean; + multiSelect?: boolean; + multiline?: boolean; + timeout?: number; + /** Absolute Unix timestamp in milliseconds when the RPC-side timeout fires. */ + expiresAt?: number; + } | { type: "extension_ui_request"; id: string; @@ -676,6 +692,12 @@ export type RpcExtensionUIRequest = | { type: "extension_ui_request"; id: string; method: "setTitle"; title: string } | { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string }; +/** Blocking dialog requests retained until a matching host response arrives. */ +export type RpcBlockingExtensionUIRequest = Extract< + RpcExtensionUIRequest, + { method: "select" | "confirm" | "input" | "editor" | "ask" } +>; + // ============================================================================ // Extension UI Commands (stdin) // ============================================================================ @@ -684,6 +706,7 @@ export type RpcExtensionUIRequest = export type RpcExtensionUIResponse = | { type: "extension_ui_response"; id: string; value: string } | { type: "extension_ui_response"; id: string; confirmed: boolean } + | { type: "extension_ui_response"; id: string; selected: string[]; customText?: string } | { type: "extension_ui_response"; id: string; cancelled: true }; // ============================================================================ diff --git a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts index 8dbee997..082d8e52 100644 --- a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts +++ b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { findModel } from "@dreb/ai"; import { Type } from "@sinclair/typebox"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DefaultResourceLoader } from "../src/core/resource-loader.js"; import { createAgentSession } from "../src/core/sdk.js"; import { SessionManager } from "../src/core/session-manager.js"; @@ -91,6 +91,63 @@ describe("AgentSession dynamic tool registration", () => { session.dispose(); }); + it("keeps ask_user active and binds UI context when no extensions are installed", async () => { + const settingsManager = SettingsManager.create(tempDir, agentDir); + const sessionManager = SessionManager.inMemory(); + const resourceLoader = new DefaultResourceLoader({ cwd: tempDir, agentDir, settingsManager }); + await resourceLoader.reload(); + + const { session } = await createAgentSession({ + cwd: tempDir, + agentDir, + model: findModel("anthropic", "sonnet")!, + settingsManager, + sessionManager, + resourceLoader, + }); + + expect(session.getActiveToolNames()).toContain("ask_user"); + expect(session.extensionRunner).toBeDefined(); + expect(session.extensionRunner?.hasUI()).toBe(false); + + const executeAskUser = async () => { + const tool = session.agent.state.tools.find((candidate) => candidate.name === "ask_user"); + expect(tool).toBeDefined(); + return tool!.execute( + "ask-call", + { question: "Which database?", options: ["SQLite", "Postgres"] }, + new AbortController().signal, + () => {}, + ); + }; + + // The actual session-wrapped tool must receive the no-host context rather + // than calling an unreachable UI implementation. + await expect(executeAskUser()).resolves.toMatchObject({ + details: { unavailable: true, skipped: true }, + }); + + const firstAsk = vi.fn(async () => ({ selected: ["SQLite"] })); + await session.bindExtensions({ uiContext: { ask: firstAsk } as any }); + await expect(executeAskUser()).resolves.toMatchObject({ + details: { selected: ["SQLite"], unavailable: false, skipped: false }, + }); + expect(firstAsk).toHaveBeenCalledWith( + expect.objectContaining({ question: "Which database?" }), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + + // Context is resolved for each execution, so rebinding the host UI must + // not leave the base-tool wrapper pointing at the previous implementation. + const secondAsk = vi.fn(async () => ({ selected: ["Postgres"] })); + await session.bindExtensions({ uiContext: { ask: secondAsk } as any }); + await expect(executeAskUser()).resolves.toMatchObject({ details: { selected: ["Postgres"] } }); + expect(firstAsk).toHaveBeenCalledTimes(1); + expect(secondAsk).toHaveBeenCalledTimes(1); + + session.dispose(); + }); + it("returns source metadata for SDK custom tools", async () => { const settingsManager = SettingsManager.create(tempDir, agentDir); const sessionManager = SessionManager.inMemory(); diff --git a/packages/coding-agent/test/agent-session-retry.test.ts b/packages/coding-agent/test/agent-session-retry.test.ts index a7f900fb..113303b0 100644 --- a/packages/coding-agent/test/agent-session-retry.test.ts +++ b/packages/coding-agent/test/agent-session-retry.test.ts @@ -49,6 +49,7 @@ function createAssistantMessage(text: string, overrides?: Partial Promise; _extensionRunner?: { + hasExtensions: boolean; hasHandlers: (eventType: string) => boolean; emit: (event: { type: string; [key: string]: unknown }) => Promise; emitBeforeAgentStart: () => Promise; @@ -250,6 +251,7 @@ describe("AgentSession retry", () => { const extensionEvents: Array<{ type: string; [key: string]: unknown }> = []; const sessionWithRunner = session as unknown as SessionWithExtensionEmitHook; sessionWithRunner._extensionRunner = { + hasExtensions: true, hasHandlers: () => false, emit: async (event) => { extensionEvents.push(event); @@ -333,6 +335,7 @@ describe("AgentSession retry", () => { const extensionEvents: Array<{ type: string; [key: string]: unknown }> = []; const sessionWithRunner = session as unknown as SessionWithExtensionEmitHook; sessionWithRunner._extensionRunner = { + hasExtensions: true, hasHandlers: () => false, emit: async (event) => { extensionEvents.push(event); @@ -409,6 +412,7 @@ describe("AgentSession retry", () => { const extensionEvents: Array<{ type: string; [key: string]: unknown }> = []; const sessionWithRunner = session as unknown as SessionWithExtensionEmitHook; sessionWithRunner._extensionRunner = { + hasExtensions: true, hasHandlers: () => false, emit: async (event) => { extensionEvents.push(event); diff --git a/packages/coding-agent/test/ask-user-component.test.ts b/packages/coding-agent/test/ask-user-component.test.ts new file mode 100644 index 00000000..b695bf2a --- /dev/null +++ b/packages/coding-agent/test/ask-user-component.test.ts @@ -0,0 +1,174 @@ +import { setKeybindings, TUI } from "@dreb/tui"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal.js"; +import type { AskRequest, AskResult } from "../src/core/extensions/types.js"; +import { KeybindingsManager } from "../src/core/keybindings.js"; +import { AskUserComponent } from "../src/modes/interactive/components/ask-user.js"; +import { initTheme } from "../src/modes/interactive/theme/theme.js"; + +const UP = "\x1b[A"; +const DOWN = "\x1b[B"; +const ENTER = "\n"; +const ESC = "\x1b"; +const SPACE = " "; + +beforeAll(() => { + initTheme("dark"); +}); + +beforeEach(() => { + setKeybindings(new KeybindingsManager()); +}); + +function mount(request: AskRequest) { + const onSubmit = vi.fn<(result: AskResult) => void>(); + const onCancel = vi.fn<() => void>(); + const component = new AskUserComponent(request, onSubmit, onCancel); + component.focused = true; + return { component, onSubmit, onCancel }; +} + +/** Mount with a real TUI so the multiline `Editor` branch is exercised. */ +function mountWithTui(request: AskRequest) { + const tui = new TUI(new VirtualTerminal(80, 24)); + const onSubmit = vi.fn<(result: AskResult) => void>(); + const onCancel = vi.fn<() => void>(); + const component = new AskUserComponent(request, onSubmit, onCancel, { tui }); + component.focused = true; + return { component, onSubmit, onCancel, tui }; +} + +function type(component: AskUserComponent, text: string) { + for (const char of text) component.handleInput(char); +} + +describe("AskUserComponent", () => { + it("single-select: Enter picks the highlighted option and submits", () => { + const { component, onSubmit } = mount({ question: "DB?", options: ["SQLite", "Postgres", "JSON"] }); + component.handleInput(DOWN); // move to Postgres + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledWith({ selected: ["Postgres"], customText: undefined }); + }); + + it("single-select: submitting the free-text field returns only the typed text", () => { + const { component, onSubmit } = mount({ question: "DB?", options: ["SQLite", "Postgres"] }); + component.handleInput(DOWN); // Postgres + component.handleInput(DOWN); // free-text row + type(component, "duckdb"); + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledWith({ selected: [], customText: "duckdb" }); + }); + + it("single-select: submits the highlighted option together with typed free text", () => { + // Parity with the Dashboard, which combines a radio selection with custom + // text. Type in the field, move back up to an option, then submit. + const { component, onSubmit } = mount({ question: "DB?", options: ["SQLite", "Postgres"] }); + component.handleInput(DOWN); // Postgres + component.handleInput(DOWN); // free-text row + type(component, "duckdb"); + component.handleInput(UP); // back to Postgres, keeping the typed text + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledWith({ selected: ["Postgres"], customText: "duckdb" }); + }); + + it("multi-select: Space toggles checkboxes and Enter submits the combined answer", () => { + const { component, onSubmit } = mount({ + question: "Checks?", + options: ["unit", "browser", "types"], + multiSelect: true, + }); + component.handleInput(SPACE); // check unit + component.handleInput(DOWN); + component.handleInput(DOWN); // types + component.handleInput(SPACE); // check types + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledWith({ selected: ["unit", "types"], customText: undefined }); + }); + + it("multi-select: combines checked options with free text", () => { + const { component, onSubmit } = mount({ + question: "Checks?", + options: ["unit", "browser"], + multiSelect: true, + }); + component.handleInput(SPACE); // check unit + component.handleInput(DOWN); // browser + component.handleInput(DOWN); // free-text row + type(component, "lint"); + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledWith({ selected: ["unit"], customText: "lint" }); + }); + + it("Esc skips without answering", () => { + const { component, onSubmit, onCancel } = mount({ question: "DB?", options: ["a", "b"] }); + component.handleInput(ESC); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("ignores Enter on an empty free-text field (Esc is the way to skip)", () => { + const { component, onSubmit } = mount({ question: "Name?" }); + // Free-text-only question: cursor starts on the field, which is empty. + component.handleInput(ENTER); + expect(onSubmit).not.toHaveBeenCalled(); + type(component, "hi"); + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledWith({ selected: [], customText: "hi" }); + }); + + it("does not offer a free-text field when allowFreeText is false", () => { + const { component, onSubmit } = mount({ question: "Pick", options: ["a", "b"], allowFreeText: false }); + // Arrowing down past the last option must not reach a field — Enter still + // submits the highlighted option. + component.handleInput(DOWN); + component.handleInput(DOWN); + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledWith({ selected: ["b"], customText: undefined }); + }); + + it("submits only once even if Enter is pressed repeatedly", () => { + const { component, onSubmit } = mount({ question: "DB?", options: ["a", "b"] }); + component.handleInput(ENTER); + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("does not navigate above the first row", () => { + const { component, onSubmit } = mount({ question: "DB?", options: ["a", "b"] }); + component.handleInput(UP); + component.handleInput(UP); + component.handleInput(ENTER); + expect(onSubmit).toHaveBeenCalledWith({ selected: ["a"], customText: undefined }); + }); + + describe("multiline (real Editor branch)", () => { + const CR = "\r"; // Enter → submit in the Editor + const LF = "\n"; // bare LF → insert a newline in the Editor + + it("inserts newlines without submitting, then submits the multi-line answer", () => { + const { component, onSubmit } = mountWithTui({ question: "Describe", multiline: true }); + type(component, "line1"); + component.handleInput(LF); // newline, not a submit + type(component, "line2"); + expect(onSubmit).not.toHaveBeenCalled(); + component.handleInput(CR); // Enter submits + expect(onSubmit).toHaveBeenCalledWith({ selected: [], customText: "line1\nline2" }); + }); + + it("submits only once even if Enter is pressed repeatedly (one-time teardown)", () => { + const { component, onSubmit } = mountWithTui({ question: "Describe", multiline: true }); + type(component, "hello"); + component.handleInput(CR); + component.handleInput(CR); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it("dispose is safe and idempotent", () => { + const { component } = mountWithTui({ question: "Describe", multiline: true }); + expect(() => { + component.dispose(); + component.dispose(); + }).not.toThrow(); + }); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index d5522024..8337b5f2 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -480,6 +480,204 @@ describe("InteractiveMode.createExtensionUIContext setTheme", () => { }); }); +describe("InteractiveMode extension dialog queue", () => { + test("serializes ask with other blocking extension dialogs", async () => { + let resolveSelector!: (value: string | undefined) => void; + const showExtensionSelector = vi.fn( + () => + new Promise((resolve) => { + resolveSelector = resolve; + }), + ); + const showExtensionAsk = vi.fn(async () => ({ selected: ["yes"] })); + const fakeThis: any = { + extensionDialogQueue: Promise.resolve(), + enqueueExtensionDialog: (InteractiveMode as any).prototype.enqueueExtensionDialog, + showExtensionSelector, + showExtensionAsk, + }; + const uiContext = (InteractiveMode as any).prototype.createExtensionUIContext.call(fakeThis); + + const selector = uiContext.select("First", ["a", "b"]); + const ask = uiContext.ask({ question: "Second?", options: ["yes", "no"] }); + await Promise.resolve(); + + expect(showExtensionSelector).toHaveBeenCalledTimes(1); + expect(showExtensionAsk).not.toHaveBeenCalled(); + + resolveSelector("a"); + await expect(selector).resolves.toBe("a"); + await expect(ask).resolves.toEqual({ selected: ["yes"] }); + expect(showExtensionAsk).toHaveBeenCalledTimes(1); + }); + + test("settles a queued ask immediately on abort without opening it or reordering later dialogs", async () => { + let resolveSelector!: (value: string | undefined) => void; + const showExtensionSelector = vi.fn( + () => + new Promise((resolve) => { + resolveSelector = resolve; + }), + ); + const showExtensionAsk = vi.fn(async (request: { question: string }) => ({ selected: [request.question] })); + const fakeThis: any = { + extensionDialogQueue: Promise.resolve(), + enqueueExtensionDialog: (InteractiveMode as any).prototype.enqueueExtensionDialog, + showExtensionSelector, + showExtensionAsk, + }; + const uiContext = (InteractiveMode as any).prototype.createExtensionUIContext.call(fakeThis); + const controller = new AbortController(); + + const selector = uiContext.select("First", ["a", "b"]); + const abortedAsk = uiContext.ask({ question: "aborted" }, { signal: controller.signal }); + const laterAsk = uiContext.ask({ question: "later" }); + await Promise.resolve(); + expect(showExtensionSelector).toHaveBeenCalledTimes(1); + expect(showExtensionAsk).not.toHaveBeenCalled(); + + controller.abort(); + // This must settle before the unrelated selector does; the old queue only + // observed the abort after the selector released its slot. + await expect(abortedAsk).resolves.toBeUndefined(); + expect(showExtensionAsk).not.toHaveBeenCalled(); + + resolveSelector("a"); + await expect(selector).resolves.toBe("a"); + await expect(laterAsk).resolves.toEqual({ selected: ["later"] }); + expect(showExtensionAsk).toHaveBeenCalledTimes(1); + expect(showExtensionAsk).toHaveBeenCalledWith({ question: "later" }, undefined); + }); +}); + +describe("InteractiveMode.showExtensionAsk lifecycle", () => { + beforeAll(() => { + initTheme("dark"); + }); + + type AskFakeThis = { + ui: { setFocus: ReturnType; requestRender: ReturnType }; + editorContainer: Container; + extensionAsk: unknown; + restoreEditorComponent: ReturnType; + hideExtensionAsk: unknown; + }; + + function createAskFakeThis(): AskFakeThis { + const fakeThis: AskFakeThis = { + ui: { setFocus: vi.fn(), requestRender: vi.fn() }, + editorContainer: new Container(), + extensionAsk: undefined, + restoreEditorComponent: vi.fn(), + hideExtensionAsk: (InteractiveMode as any).prototype.hideExtensionAsk, + }; + return fakeThis; + } + + function show(fakeThis: AskFakeThis, request: object, opts?: object) { + return (InteractiveMode as any).prototype.showExtensionAsk.call(fakeThis, request, opts); + } + + test("mounts the question, focuses it, and resolves the submitted answer, then restores the editor", async () => { + const fakeThis = createAskFakeThis(); + const p = show(fakeThis, { question: "DB?", options: ["a", "b"] }); + + // Mounted into the shared editor container and focused exactly once. + expect(fakeThis.editorContainer.children).toHaveLength(1); + expect(fakeThis.ui.setFocus).toHaveBeenCalledWith(fakeThis.extensionAsk); + expect(fakeThis.extensionAsk).toBeDefined(); + + // Submit the highlighted option. + (fakeThis.extensionAsk as any).handleInput("\n"); + + await expect(p).resolves.toEqual({ selected: ["a"], customText: undefined }); + // Torn down exactly once: component disposed/cleared and editor restored. + expect(fakeThis.extensionAsk).toBeUndefined(); + expect(fakeThis.restoreEditorComponent).toHaveBeenCalledTimes(1); + }); + + test("Esc skips and resolves undefined without leaving a mounted component", async () => { + const fakeThis = createAskFakeThis(); + const p = show(fakeThis, { question: "DB?", options: ["a", "b"] }); + (fakeThis.extensionAsk as any).handleInput("\x1b"); // Esc + await expect(p).resolves.toBeUndefined(); + expect(fakeThis.extensionAsk).toBeUndefined(); + expect(fakeThis.restoreEditorComponent).toHaveBeenCalledTimes(1); + }); + + test("aborting while pending resolves undefined, disposes, and restores the editor (no orphaned promise)", async () => { + const fakeThis = createAskFakeThis(); + const controller = new AbortController(); + const p = show(fakeThis, { question: "DB?", options: ["a"] }, { signal: controller.signal }); + expect(fakeThis.extensionAsk).toBeDefined(); + + controller.abort(); + await expect(p).resolves.toBeUndefined(); + expect(fakeThis.extensionAsk).toBeUndefined(); + expect(fakeThis.restoreEditorComponent).toHaveBeenCalledTimes(1); + }); + + test("an already-aborted signal resolves undefined without mounting or focusing", async () => { + const fakeThis = createAskFakeThis(); + const controller = new AbortController(); + controller.abort(); + const p = show(fakeThis, { question: "DB?", options: ["a"] }, { signal: controller.signal }); + await expect(p).resolves.toBeUndefined(); + expect(fakeThis.editorContainer.children).toHaveLength(0); + expect(fakeThis.ui.setFocus).not.toHaveBeenCalled(); + expect(fakeThis.extensionAsk).toBeUndefined(); + }); + + test("a multiline request builds the editor branch and tears down cleanly on abort", async () => { + // The multiline Editor is only constructed when a real TUI is provided. + const terminal = new VirtualTerminal(60, 16); + const ui = new TUI(terminal); + const fakeThis: any = { + ui, + editorContainer: new Container(), + extensionAsk: undefined, + restoreEditorComponent: vi.fn(), + hideExtensionAsk: (InteractiveMode as any).prototype.hideExtensionAsk, + }; + const controller = new AbortController(); + const p = (InteractiveMode as any).prototype.showExtensionAsk.call( + fakeThis, + { question: "Notes?", multiline: true }, + { signal: controller.signal }, + ); + // Constructed and mounted without throwing (exercises the Editor branch). + expect(fakeThis.editorContainer.children).toHaveLength(1); + expect(fakeThis.extensionAsk).toBeDefined(); + + controller.abort(); + await expect(p).resolves.toBeUndefined(); + expect(fakeThis.extensionAsk).toBeUndefined(); + expect(fakeThis.restoreEditorComponent).toHaveBeenCalledTimes(1); + }); + + test("a timeout builds a countdown that expires to a skip and disposes the interval", async () => { + vi.useFakeTimers(); + try { + const fakeThis = createAskFakeThis(); + const p = show(fakeThis, { question: "DB?", options: ["a"] }, { timeout: 3000 }); + expect(fakeThis.extensionAsk).toBeDefined(); + + // The countdown ticks every second and expires at 0 → skip (undefined). + vi.advanceTimersByTime(3000); + await expect(p).resolves.toBeUndefined(); + expect(fakeThis.extensionAsk).toBeUndefined(); + expect(fakeThis.restoreEditorComponent).toHaveBeenCalledTimes(1); + + // Interval disposed: no further ticks drive renders after teardown. + const rendersAfter = fakeThis.ui.requestRender.mock.calls.length; + vi.advanceTimersByTime(5000); + expect(fakeThis.ui.requestRender.mock.calls.length).toBe(rendersAfter); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("InteractiveMode.showLoadedResources", () => { beforeAll(() => { initTheme("dark"); diff --git a/packages/coding-agent/test/rpc-dashboard-commands.test.ts b/packages/coding-agent/test/rpc-dashboard-commands.test.ts index 420a8eb0..3c976285 100644 --- a/packages/coding-agent/test/rpc-dashboard-commands.test.ts +++ b/packages/coding-agent/test/rpc-dashboard-commands.test.ts @@ -273,7 +273,7 @@ describe("git branch helper used by RPC", () => { }); describe("runRpcMode dashboard dispatcher", () => { - it("emits a dashboard snapshot barrier before the snapshot response", async () => { + it("emits a dashboard snapshot barrier and includes pending UI requests in the response", async () => { const { session, cleanup } = createTestSession({ inMemory: true }); const tasks = [ { id: "read", title: "Read dispatcher pattern", status: "completed" as const }, @@ -282,9 +282,9 @@ describe("runRpcMode dashboard dispatcher", () => { (session as unknown as { _tasks: typeof tasks })._tasks = tasks; const outputs: Array> = []; let handleInputLine: ((line: string) => void) | undefined; - let resolveTwoOutputs: (() => void) | undefined; - const twoOutputs = new Promise((resolve) => { - resolveTwoOutputs = resolve; + let resolveSnapshotOutputs: (() => void) | undefined; + const snapshotOutputs = new Promise((resolve) => { + resolveSnapshotOutputs = resolve; }); const existingEndListeners = new Set(process.stdin.listeners("end")); const existingErrorListeners = new Set(process.stdin.listeners("error")); @@ -292,8 +292,8 @@ describe("runRpcMode dashboard dispatcher", () => { vi.spyOn(outputGuard, "takeOverStdout").mockImplementation(() => {}); vi.spyOn(outputGuard, "writeRawStdout").mockImplementation((line) => { outputs.push(JSON.parse(line) as Record); - if (outputs.length === 2) { - resolveTwoOutputs?.(); + if (outputs.length === 3) { + resolveSnapshotOutputs?.(); } }); vi.spyOn(jsonl, "attachJsonlLineReader").mockImplementation((_stream, onLine) => { @@ -307,12 +307,20 @@ describe("runRpcMode dashboard dispatcher", () => { void runRpcMode(session); await vi.waitFor(() => expect(handleInputLine).toBeDefined()); + const pendingAsk = session.extensionRunner!.createContext().ui.ask({ + title: "Choose a database", + question: "Which one?", + options: ["SQLite", "Postgres"], + }); + const request = outputs[0]!; + expect(request).toMatchObject({ type: "extension_ui_request", method: "ask" }); + handleInputLine!(JSON.stringify({ type: "get_dashboard_snapshot" })); - await twoOutputs; + await snapshotOutputs; - expect(outputs).toHaveLength(2); - const barrier = outputs[0]!; - const response = outputs[1]!; + expect(outputs).toHaveLength(3); + const barrier = outputs[1]!; + const response = outputs[2]!; expect(barrier).toEqual({ type: "dashboard_snapshot_barrier", snapshotId: expect.any(String) }); const snapshotId = barrier.snapshotId; @@ -324,8 +332,12 @@ describe("runRpcMode dashboard dispatcher", () => { data: { snapshotId, state: { tasks }, + pendingExtensionUiRequests: [request], }, }); + + handleInputLine!(JSON.stringify({ type: "extension_ui_response", id: request.id, selected: ["SQLite"] })); + await expect(pendingAsk).resolves.toEqual({ selected: ["SQLite"], customText: undefined }); } finally { cleanup(); for (const listener of process.stdin.listeners("end")) { @@ -396,6 +408,7 @@ describe("RpcClient dashboard command methods", () => { }, messages: [], backgroundAgents: [], + pendingExtensionUiRequests: [], } satisfies RpcDashboardSnapshot; const aggregateSnapshot: AggregateRpcDashboardSnapshot = snapshot; diff --git a/packages/coding-agent/test/rpc-extension-ui-ask.test.ts b/packages/coding-agent/test/rpc-extension-ui-ask.test.ts new file mode 100644 index 00000000..a9cc57d7 --- /dev/null +++ b/packages/coding-agent/test/rpc-extension-ui-ask.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AskResult } from "../src/core/extensions/types.js"; +import { + cancelPendingRpcExtensionRequests, + createRpcExtensionUIContext, + type PendingRpcExtensionRequest, +} from "../src/modes/rpc/rpc-mode.js"; +import type { RpcExtensionUIRequest, RpcExtensionUIResponse } from "../src/modes/rpc/rpc-types.js"; + +/** + * Round-trip tests for the RPC `ask` dialog: request emission, response + * mapping, cancellation, timeout, abort, and cleanup dedup. These exercise + * `createRpcExtensionUIContext` directly (no CLI spawn) so the transport is + * verified deterministically. + */ +function harness() { + const emitted: RpcExtensionUIRequest[] = []; + const handled: string[] = []; + const pending = new Map(); + const output = (obj: any) => { + if (obj?.type === "extension_ui_request") emitted.push(obj as RpcExtensionUIRequest); + else if (obj?.type === "extension_ui_response_handled") handled.push(obj.id as string); + }; + const ctx = createRpcExtensionUIContext(output, pending); + // The only emitted request in these tests is the ask, so its id is the map key. + const lastRequestId = () => emitted[emitted.length - 1]!.id; + const respond = (response: Omit) => { + const id = lastRequestId(); + pending.get(id)?.resolve({ type: "extension_ui_response", id, ...response } as RpcExtensionUIResponse); + }; + return { ctx, emitted, handled, pending, lastRequestId, respond }; +} + +describe("RPC ask round trip", () => { + it("emits an extension_ui_request with all ask fields and defaults the title", async () => { + const h = harness(); + const p = h.ctx.ask({ + question: "Which DB?", + options: ["SQLite", "Postgres"], + allowFreeText: true, + multiSelect: false, + multiline: false, + }); + expect(h.emitted).toHaveLength(1); + const req = h.emitted[0]!; + expect(req).toMatchObject({ + type: "extension_ui_request", + method: "ask", + title: "Question", + question: "Which DB?", + options: ["SQLite", "Postgres"], + allowFreeText: true, + multiSelect: false, + multiline: false, + }); + expect(typeof req.id).toBe("string"); + h.respond({ selected: ["SQLite"] } as any); + await p; + }); + + it("emits the auto-skip duration and absolute deadline in the ask request", async () => { + const now = vi.spyOn(Date, "now").mockReturnValue(1_000_000); + try { + const h = harness(); + const p = h.ctx.ask({ question: "Which?", options: ["a"] }, { timeout: 30_000 }); + // The absolute deadline keeps a recovered Dashboard countdown aligned + // with the already-running authoritative RPC timer. + expect(h.emitted[0]).toMatchObject({ timeout: 30_000, expiresAt: 1_030_000 }); + h.respond({ selected: ["a"] } as any); + await p; + } finally { + now.mockRestore(); + } + }); + + it("omits the timeout and deadline when none is provided", async () => { + const h = harness(); + const p = h.ctx.ask({ question: "Which?", options: ["a"] }); + expect(h.emitted[0]).toMatchObject({ timeout: undefined, expiresAt: undefined }); + h.respond({ selected: ["a"] } as any); + await p; + }); + + it("uses the provided title when set", async () => { + const h = harness(); + const p = h.ctx.ask({ title: "Pick storage", question: "Which DB?", options: ["a"] }); + expect((h.emitted[0] as any).title).toBe("Pick storage"); + h.respond({ selected: ["a"] } as any); + await p; + }); + + it("retains the emitted request payload while waiting for a response", async () => { + const h = harness(); + const p = h.ctx.ask({ question: "Which?", options: ["a", "b"] }); + const id = h.lastRequestId(); + expect(h.pending.get(id)?.request).toBe(h.emitted[0]); + h.respond({ selected: ["a"] } as any); + await p; + }); + + it("maps a selected + customText response into an AskResult", async () => { + const h = harness(); + const p = h.ctx.ask({ question: "Which?", options: ["a", "b"] }); + h.respond({ selected: ["b"], customText: "duckdb" } as any); + const result = (await p) as AskResult; + expect(result).toEqual({ selected: ["b"], customText: "duckdb" }); + }); + + it("maps a cancelled response to undefined (skip)", async () => { + const h = harness(); + const p = h.ctx.ask({ question: "Which?", options: ["a"] }); + h.respond({ cancelled: true } as any); + await expect(p).resolves.toBeUndefined(); + }); + + it.each([ + { selected: null }, + { selected: ["a", 2] }, + { selected: ["a"], customText: 42 }, + { value: "wrong response variant" }, + ])("rejects a malformed ask response: %o", async (response) => { + const h = harness(); + const p = h.ctx.ask({ question: "Which?", options: ["a"] }); + const id = h.lastRequestId(); + h.pending.get(id)?.resolve({ type: "extension_ui_response", id, ...response } as any); + await expect(p).rejects.toThrow("Invalid RPC ask response"); + expect(h.handled).toEqual([id]); + expect(h.pending.has(id)).toBe(false); + }); + + it("emits extension_ui_response_handled exactly once on response and clears the pending entry", async () => { + const h = harness(); + const p = h.ctx.ask({ question: "Which?", options: ["a"] }); + const id = h.lastRequestId(); + h.respond({ selected: ["a"] } as any); + await p; + expect(h.handled).toEqual([id]); + expect(h.pending.has(id)).toBe(false); + }); + + it("ignores a duplicate/late response after the first settles", async () => { + const h = harness(); + const p = h.ctx.ask({ question: "Which?", options: ["a", "b"] }); + const id = h.lastRequestId(); + const pending = h.pending.get(id)!; + pending.resolve({ type: "extension_ui_response", id, selected: ["a"] }); + const result = (await p) as AskResult; + // Second resolve must be a no-op (settled guard) — no throw, no re-handle. + pending.resolve({ type: "extension_ui_response", id, selected: ["b"] }); + expect(result).toEqual({ selected: ["a"], customText: undefined }); + expect(h.handled).toEqual([id]); + }); + + it("resolves undefined without emitting when the signal is already aborted", async () => { + const h = harness(); + const controller = new AbortController(); + controller.abort(); + const p = h.ctx.ask({ question: "Which?", options: ["a"] }, { signal: controller.signal }); + await expect(p).resolves.toBeUndefined(); + expect(h.emitted).toHaveLength(0); + }); + + it("resolves undefined and cleans up when the signal aborts while pending", async () => { + const h = harness(); + const controller = new AbortController(); + const p = h.ctx.ask({ question: "Which?", options: ["a"] }, { signal: controller.signal }); + const id = h.lastRequestId(); + expect(h.pending.has(id)).toBe(true); + controller.abort(); + await expect(p).resolves.toBeUndefined(); + expect(h.handled).toEqual([id]); + expect(h.pending.has(id)).toBe(false); + }); + + it("cancels pending dialogs exactly once during RPC shutdown", async () => { + const h = harness(); + const first = h.ctx.ask({ question: "First?", options: ["a"] }); + const firstId = h.lastRequestId(); + const second = h.ctx.ask({ question: "Second?", options: ["b"] }); + const secondId = h.lastRequestId(); + + cancelPendingRpcExtensionRequests(h.pending); + + await expect(first).resolves.toBeUndefined(); + await expect(second).resolves.toBeUndefined(); + expect(h.pending.size).toBe(0); + expect(h.handled).toEqual([firstId, secondId]); + cancelPendingRpcExtensionRequests(h.pending); + expect(h.handled).toEqual([firstId, secondId]); + }); + + it("resolves undefined on timeout and emits the handled event", async () => { + vi.useFakeTimers(); + try { + const h = harness(); + const p = h.ctx.ask({ question: "Which?", options: ["a"] }, { timeout: 50 }); + const id = h.lastRequestId(); + vi.advanceTimersByTime(50); + await expect(p).resolves.toBeUndefined(); + expect(h.handled).toEqual([id]); + expect(h.pending.has(id)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 66288a90..67139c23 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -10,6 +10,7 @@ import { createGrepTool, type GrepOperations, grepTool } from "../src/core/tools import { allToolDefinitions, allTools, createAllToolDefinitions, createAllTools } from "../src/core/tools/index.js"; import { lsTool } from "../src/core/tools/ls.js"; import { readTool } from "../src/core/tools/read.js"; +import { wrapToolDefinition } from "../src/core/tools/tool-definition-wrapper.js"; import { writeTool } from "../src/core/tools/write.js"; import * as shellModule from "../src/utils/shell.js"; @@ -948,4 +949,57 @@ describe("allTools / allToolDefinitions", () => { const tools = createAllTools("/tmp"); expect(tools.search).toBeDefined(); }); + + it("includes ask_user in both registries unconditionally", () => { + expect("ask_user" in allTools).toBe(true); + expect("ask_user" in allToolDefinitions).toBe(true); + expect(allTools.ask_user.name).toBe("ask_user"); + expect(typeof allTools.ask_user.execute).toBe("function"); + expect(createAllToolDefinitions("/tmp").ask_user).toBeDefined(); + expect(createAllTools("/tmp").ask_user).toBeDefined(); + }); +}); + +describe("wrapToolDefinition ctxFactory (base-tool ctx.ui wiring)", () => { + it("forwards a per-execution ExtensionContext from the factory to execute", async () => { + const seen: unknown[] = []; + const definition = { + name: "probe", + label: "probe", + description: "d", + parameters: {} as any, + execute: async (_id: string, _params: any, _signal: any, _onUpdate: any, ctx: any) => { + seen.push(ctx); + return { content: [{ type: "text" as const, text: "ok" }] }; + }, + }; + let created = 0; + const factory = () => { + created += 1; + return { hasUI: true, ui: {} } as any; + }; + const tool = wrapToolDefinition(definition as any, factory); + await tool.execute("c1", {}, undefined, undefined); + await tool.execute("c2", {}, undefined, undefined); + expect(created).toBe(2); // resolved fresh per execution + expect(seen).toHaveLength(2); + expect((seen[0] as any).hasUI).toBe(true); + }); + + it("passes undefined ctx when no factory is provided", async () => { + let received: unknown = "sentinel"; + const definition = { + name: "probe2", + label: "probe2", + description: "d", + parameters: {} as any, + execute: async (_id: string, _params: any, _signal: any, _onUpdate: any, ctx: any) => { + received = ctx; + return { content: [{ type: "text" as const, text: "ok" }] }; + }, + }; + const tool = wrapToolDefinition(definition as any); + await tool.execute("c1", {}, undefined, undefined); + expect(received).toBeUndefined(); + }); }); diff --git a/packages/coding-agent/test/tools/ask-user.test.ts b/packages/coding-agent/test/tools/ask-user.test.ts new file mode 100644 index 00000000..88b0deef --- /dev/null +++ b/packages/coding-agent/test/tools/ask-user.test.ts @@ -0,0 +1,334 @@ +import { Value } from "@sinclair/typebox/value"; +import { describe, expect, it, vi } from "vitest"; +import type { AskRequest, AskResult, ExtensionContext } from "../../src/core/extensions/types.js"; +import { type AskUserDetails, createAskUserToolDefinition } from "../../src/core/tools/ask-user.js"; + +const mockTheme = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +}; + +interface ExecResult { + content: Array<{ type: string; text?: string }>; + details?: AskUserDetails; + endTurn?: boolean; +} + +/** Build a ctx whose ui.ask returns a scripted answer (or undefined for skip). */ +function makeCtx( + ask: (request: AskRequest, opts?: { signal?: AbortSignal }) => Promise, + hasUI = true, +): ExtensionContext { + return { hasUI, ui: { ask } } as unknown as ExtensionContext; +} + +function run( + def: ReturnType, + params: Record, + ctx?: ExtensionContext, + signal?: AbortSignal, +): Promise { + return (def.execute as any)("call-1", params, signal, undefined, ctx) as Promise; +} + +describe("ask_user tool", () => { + it("exposes narrow usage guidance in prompt metadata", () => { + const def = createAskUserToolDefinition(); + expect(def.name).toBe("ask_user"); + expect(def.promptSnippet).toBeTruthy(); + const guidelines = (def.promptGuidelines as string[]).join(" ").toLowerCase(); + expect(guidelines).toContain("only"); + expect(guidelines).toContain("blocked"); + expect(guidelines).toContain("skip"); + }); + + it("validates option bounds (2-4) and rejects blank options in its schema", () => { + const def = createAskUserToolDefinition(); + const props = (def.parameters as any).properties; + expect(props.options.minItems).toBe(2); + expect(props.options.maxItems).toBe(4); + expect(props.question.type).toBe("string"); + expect(Value.Check(def.parameters, { question: "Pick", options: ["A", "B"] })).toBe(true); + expect(Value.Check(def.parameters, { question: "Pick", options: ["", "B"] })).toBe(false); + expect(Value.Check(def.parameters, { question: "Pick", options: [" ", "B"] })).toBe(false); + }); + + it("returns a graceful non-blocking result when no UI is available", async () => { + const def = createAskUserToolDefinition(); + const ask = vi.fn(); + const result = await run(def, { question: "Which?" }, makeCtx(ask, false)); + expect(ask).not.toHaveBeenCalled(); + expect(result.details?.unavailable).toBe(true); + expect(result.details?.skipped).toBe(true); + expect(result.content[0].text).toContain("not available"); + }); + + it("returns unavailable when ctx is entirely absent", async () => { + const def = createAskUserToolDefinition(); + const result = await run(def, { question: "Which?" }, undefined); + expect(result.details?.unavailable).toBe(true); + }); + + it("formats a single selected option", async () => { + const def = createAskUserToolDefinition(); + const ctx = makeCtx(async () => ({ selected: ["SQLite"] })); + const result = await run(def, { question: "DB?", options: ["SQLite", "Postgres"] }, ctx); + expect(result.details?.skipped).toBe(false); + expect(result.details?.selected).toEqual(["SQLite"]); + expect(result.content[0].text).toBe("The user selected: SQLite"); + }); + + it("combines multiple selections with custom text", async () => { + const def = createAskUserToolDefinition(); + const ctx = makeCtx(async () => ({ selected: ["A", "B"], customText: "plus C" })); + const result = await run(def, { question: "Pick", options: ["A", "B"], multiSelect: true }, ctx); + expect(result.details?.selected).toEqual(["A", "B"]); + expect(result.details?.customText).toBe("plus C"); + expect(result.content[0].text).toContain("The user selected: A, B"); + expect(result.content[0].text).toContain('They also wrote: "plus C"'); + }); + + it("formats a free-text-only answer", async () => { + const def = createAskUserToolDefinition(); + const ctx = makeCtx(async () => ({ selected: [], customText: "my own answer" })); + const result = await run(def, { question: "Name?" }, ctx); + expect(result.content[0].text).toBe('The user answered: "my own answer"'); + }); + + it("treats an undefined answer as a graceful skip", async () => { + const def = createAskUserToolDefinition(); + const ctx = makeCtx(async () => undefined); + const result = await run(def, { question: "Which?" }, ctx); + expect(result.details?.skipped).toBe(true); + expect(result.details?.unavailable).toBe(false); + expect(result.content[0].text).toContain("skipped"); + }); + + it("treats an empty answer as a skip", async () => { + const def = createAskUserToolDefinition(); + const ctx = makeCtx(async () => ({ selected: [], customText: " " })); + const result = await run(def, { question: "Which?" }, ctx); + expect(result.details?.skipped).toBe(true); + }); + + it("resolves as skipped without opening UI when the signal is already aborted", async () => { + const def = createAskUserToolDefinition(); + const ask = vi.fn(async () => ({ selected: ["x"] })); + const controller = new AbortController(); + controller.abort(); + const result = await run(def, { question: "Which?" }, makeCtx(ask), controller.signal); + expect(ask).not.toHaveBeenCalled(); + expect(result.details?.skipped).toBe(true); + expect(result.details?.unavailable).toBe(false); + }); + + it("reports a host failure distinctly from an intentional user skip", async () => { + const def = createAskUserToolDefinition(); + const ctx = makeCtx(async () => { + throw new Error("host exploded"); + }); + const result = await run(def, { question: "Which?" }, ctx); + expect(result.details?.failed).toBe(true); + expect(result.details?.skipped).toBe(false); + expect(result.content[0].text).toContain("interactive UI or response protocol failed"); + expect(result.content[0].text).not.toContain("user skipped"); + }); + + it("serializes concurrent calls strictly one at a time (FIFO)", async () => { + const def = createAskUserToolDefinition(); + let active = 0; + let maxActive = 0; + const order: string[] = []; + const ctx = makeCtx(async (request) => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((r) => setTimeout(r, 10)); + order.push(request.question); + active -= 1; + return { selected: [request.question] }; + }); + + const results = await Promise.all([ + run(def, { question: "first" }, ctx), + run(def, { question: "second" }, ctx), + run(def, { question: "third" }, ctx), + ]); + + expect(maxActive).toBe(1); // never two questions open at once + expect(order).toEqual(["first", "second", "third"]); + expect(results.map((r) => r.details?.selected?.[0])).toEqual(["first", "second", "third"]); + }); + + it("releases the queue after a failure so later calls still run", async () => { + const def = createAskUserToolDefinition(); + let calls = 0; + const ctx = makeCtx(async (request) => { + calls += 1; + if (request.question === "boom") throw new Error("fail"); + return { selected: [request.question] }; + }); + const [first, second] = await Promise.all([ + run(def, { question: "boom" }, ctx), + run(def, { question: "ok" }, ctx), + ]); + expect(calls).toBe(2); + expect(first.details?.failed).toBe(true); + expect(first.details?.skipped).toBe(false); + expect(second.details?.selected).toEqual(["ok"]); + }); + + it("passes the abort signal through to the UI layer", async () => { + const def = createAskUserToolDefinition(); + const controller = new AbortController(); + let receivedSignal: AbortSignal | undefined; + const ctx = makeCtx(async (_req, opts) => { + receivedSignal = opts?.signal; + return { selected: ["x"] }; + }); + await run(def, { question: "Which?" }, ctx, controller.signal); + expect(receivedSignal).toBe(controller.signal); + }); + + it("always offers free text when there are no options (never an unanswerable question)", async () => { + const def = createAskUserToolDefinition(); + let received: AskRequest | undefined; + const ctx = makeCtx(async (request) => { + received = request; + return { selected: [], customText: "x" }; + }); + // allowFreeText:false with no options would leave only a Skip button — + // the tool must normalize this so the question can always be answered. + await run(def, { question: "Open ended?", allowFreeText: false }, ctx); + expect(received?.allowFreeText).toBe(true); + }); + + it("drops multiSelect and preserves free text when there are no options", async () => { + const def = createAskUserToolDefinition(); + let received: AskRequest | undefined; + const ctx = makeCtx(async (request) => { + received = request; + return { selected: [], customText: "x" }; + }); + await run(def, { question: "Open ended?", multiSelect: true }, ctx); + expect(received?.multiSelect).toBeUndefined(); + expect(received?.allowFreeText).toBe(true); + }); + + it("drops multiline when options exist and free text is disabled", async () => { + const def = createAskUserToolDefinition(); + let received: AskRequest | undefined; + const ctx = makeCtx(async (request) => { + received = request; + return { selected: ["a"] }; + }); + // With options and no free-text field there is no text area to make + // multiline; the flag must be dropped rather than forwarded meaninglessly. + await run(def, { question: "Pick?", options: ["a", "b"], allowFreeText: false, multiline: true }, ctx); + expect(received?.multiline).toBeUndefined(); + expect(received?.allowFreeText).toBe(false); + }); + + it("preserves multiline for an open-ended question with no options", async () => { + const def = createAskUserToolDefinition(); + let received: AskRequest | undefined; + const ctx = makeCtx(async (request) => { + received = request; + return { selected: [], customText: "line1\nline2" }; + }); + // No options → free text is always offered, so a multiline request must + // survive normalization (otherwise open-ended answers silently collapse + // to a single-line input on both surfaces). + await run(def, { question: "Describe the bug", multiline: true }, ctx); + expect(received?.multiline).toBe(true); + expect(received?.allowFreeText).toBe(true); + }); + + it("exposes a validated optional timeoutSeconds field in its schema", () => { + const def = createAskUserToolDefinition(); + const props = (def.parameters as any).properties; + expect(props.timeoutSeconds.minimum).toBe(5); + expect(props.timeoutSeconds.maximum).toBe(3600); + }); + + it("forwards an optional timeout (seconds → milliseconds) to the UI layer", async () => { + const def = createAskUserToolDefinition(); + let receivedOpts: { signal?: AbortSignal; timeout?: number } | undefined; + const ctx = makeCtx(async (_req, opts) => { + receivedOpts = opts; + return { selected: ["x"] }; + }); + await run(def, { question: "?", options: ["a", "b"], timeoutSeconds: 30 }, ctx); + expect(receivedOpts?.timeout).toBe(30_000); + }); + + it("passes no timeout when timeoutSeconds is omitted", async () => { + const def = createAskUserToolDefinition(); + let receivedOpts: { signal?: AbortSignal; timeout?: number } | undefined; + const ctx = makeCtx(async (_req, opts) => { + receivedOpts = opts; + return { selected: ["x"] }; + }); + await run(def, { question: "?", options: ["a", "b"] }, ctx); + expect(receivedOpts?.timeout).toBeUndefined(); + }); + + it("aborts a call while it waits in the queue without ever opening UI", async () => { + const def = createAskUserToolDefinition(); + const asked: string[] = []; + let releaseFirst!: (result: AskResult) => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const ctx = makeCtx(async (request) => { + asked.push(request.question); + if (request.question === "first") return firstGate; // hangs until released + return { selected: [request.question] }; + }); + + const controller = new AbortController(); + const first = run(def, { question: "first" }, ctx); + const second = run(def, { question: "second" }, ctx, controller.signal); + const third = run(def, { question: "third" }, ctx); + + // Let the active first call open its UI; the others must wait behind it. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(asked).toEqual(["first"]); + + // Abort the second call while it is genuinely queued behind the active one. + controller.abort(); + // Release the first so the queue can advance to the queued calls. + releaseFirst({ selected: ["first"] }); + + const [r1, r2, r3] = await Promise.all([first, second, third]); + expect(r1.details?.selected).toEqual(["first"]); + // The queued-then-aborted call resolves skipped and never opened the UI. + expect(r2.details?.skipped).toBe(true); + expect(r2.details?.unavailable).toBe(false); + // A later call still advances after the aborted one is dequeued. + expect(r3.details?.selected).toEqual(["third"]); + expect(asked).toEqual(["first", "third"]); + }); + + it("renders a call and result without throwing", () => { + const def = createAskUserToolDefinition(); + const call = def.renderCall?.( + { question: "Which database?" } as any, + mockTheme as any, + { + lastComponent: undefined, + } as any, + ); + expect(call).toBeDefined(); + + const result = def.renderResult?.( + { + content: [{ type: "text", text: "done" }], + details: { question: "q", selected: ["A"], skipped: false, unavailable: false }, + } as any, + {} as any, + mockTheme as any, + { lastComponent: undefined } as any, + ); + expect(result).toBeDefined(); + }); +}); diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index a8b867c0..10c0f16d 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@dreb/dashboard", - "version": "2.46.0", + "version": "2.47.0", "description": "Web dashboard for dreb — fleet overview, chat parity, subagent observability", "license": "MIT", "type": "module", diff --git a/packages/dashboard/src/client/components/transcript.tsx b/packages/dashboard/src/client/components/transcript.tsx index 38f433d7..6ec893e4 100644 --- a/packages/dashboard/src/client/components/transcript.tsx +++ b/packages/dashboard/src/client/components/transcript.tsx @@ -590,7 +590,12 @@ function ToolCard(props: { entry: ToolEntry; imageScope: TranscriptImageScope }) const isBash = () => props.entry.toolName === "bash" || props.entry.toolName === "bash (user)"; const inputSections = () => toolInputSections(props.entry); const autoOpen = () => isToolAutoOpen(props.entry.toolName); - const [open, setOpen] = createSignal(autoOpen() || props.entry.status === "running"); + // ask_user "runs" for the entire time it waits on the user, and its body + // (the raw options JSON) is redundant with the inline question card. Keep it + // collapsed by default and let the user expand/collapse freely instead of + // force-opening it while running like other in-flight tools. + const forceOpenWhileRunning = props.entry.toolName !== "ask_user"; + const [open, setOpen] = createSignal(autoOpen() || (props.entry.status === "running" && forceOpenWhileRunning)); let wasRunning = props.entry.status === "running"; let userToggled = false; let suppressToggle = false; @@ -608,7 +613,7 @@ function ToolCard(props: { entry: ToolEntry; imageScope: TranscriptImageScope }) createEffect(() => { const running = props.entry.status === "running"; const shouldAutoOpen = autoOpen(); - if (running) { + if (running && forceOpenWhileRunning) { userToggled = false; setProgrammaticOpen(true); } else if (wasRunning) { @@ -632,7 +637,7 @@ function ToolCard(props: { entry: ToolEntry; imageScope: TranscriptImageScope }) setOpen(next); return; } - if (props.entry.status === "running" && !next) { + if (props.entry.status === "running" && forceOpenWhileRunning && !next) { suppressToggle = true; event.currentTarget.open = true; setOpen(true); diff --git a/packages/dashboard/src/client/screens/session.tsx b/packages/dashboard/src/client/screens/session.tsx index 589af4b3..a2f3b5c3 100644 --- a/packages/dashboard/src/client/screens/session.tsx +++ b/packages/dashboard/src/client/screens/session.tsx @@ -182,6 +182,147 @@ function ExtensionUiModal(props: { ); } +function AskUiInline(props: { + request: ExtensionUiRequest; + onRespond: (response: Record) => void; +}): JSX.Element { + const options = () => props.request.options ?? []; + const allowFreeText = () => props.request.allowFreeText !== false; + const multiSelect = () => props.request.multiSelect === true && options().length > 0; + const [selected, setSelected] = createSignal([]); + const [customText, setCustomText] = createSignal(""); + const respond = (body: Record) => + props.onRespond({ type: "extension_ui_response", id: props.request.id, ...body }); + + const toggle = (option: string) => { + if (multiSelect()) { + setSelected((current) => + current.includes(option) ? current.filter((value) => value !== option) : [...current, option], + ); + } else { + setSelected([option]); + } + }; + + const answer = () => { + const text = customText().trim(); + return { selected: selected(), customText: text ? text : undefined }; + }; + const canSubmit = () => answer().selected.length > 0 || answer().customText !== undefined; + const submit = () => { + if (canSubmit()) respond(answer()); + }; + const skip = () => respond({ cancelled: true }); + + // Escape-to-skip, matching the TUI. A skip is always safe. + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + skip(); + } + }; + onMount(() => window.addEventListener("keydown", onKeyDown)); + onCleanup(() => window.removeEventListener("keydown", onKeyDown)); + + // Optional visible countdown. `expiresAt` is the authoritative RPC-side + // deadline and survives reload/resync/drill-in recovery. Fall back to a + // local deadline for older runtimes that only send the original duration. + const expiresAt = + typeof props.request.expiresAt === "number" + ? props.request.expiresAt + : props.request.timeout && props.request.timeout > 0 + ? Date.now() + props.request.timeout + : undefined; + const secondsRemaining = () => + expiresAt === undefined ? 0 : Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)); + const [remaining, setRemaining] = createSignal(secondsRemaining()); + if (expiresAt !== undefined) { + let interval: ReturnType | undefined; + const updateCountdown = () => { + const next = secondsRemaining(); + setRemaining(next); + if (next <= 0) { + if (interval !== undefined) clearInterval(interval); + interval = undefined; + skip(); + } + }; + onMount(() => { + updateCountdown(); + if (expiresAt > Date.now()) interval = setInterval(updateCountdown, 1000); + }); + onCleanup(() => { + if (interval !== undefined) clearInterval(interval); + }); + } + + return ( +
+
+ {props.request.title} + + (auto-skips in {remaining()}s) + +
+ +

{props.request.question}

+
+ 0}> +
+ Answer options + + {(option) => ( + + )} + +
+
+ +
+ + setCustomText(e.currentTarget.value)} + /> + } + > +