Skip to content

Adopt the SDK's native AskUserQuestion and rebuild the ask card (#4) - #96

Merged
derekhearst merged 10 commits into
mainfrom
feat/4-native-ask-user
Sep 24, 2026
Merged

derekhearst merged 10 commits into
mainfrom
feat/4-native-ask-user

Conversation

@derekhearst

Copy link
Copy Markdown
Owner

Summary

AgentStudio's custom ask_user tool is replaced by the Agent SDK's own AskUserQuestion. I read the installed SDK (0.3.278, CLI 2.1.278) first. buildEngineOptions now sets toolConfig.askUserQuestion.previewFormat 'html' and settings.askUserQuestionTimeout 'never'. The timeout is a Settings field, not an Option. The CLI's permission check for this tool always asks, so every call reaches canUseTool. The engine hands the call to a new pure module ($lib/engine/ask-user-question). That module allows the call with updatedInput.answers, keyed by question text, or refuses it straight away when there is no host, a subagent asked, the call is malformed, nobody answered, or the run stopped. It never hangs. The chat host (ask-user-host.server.ts) saves the question to chat_runs.pending_questions, which still opens the /review inbox item and the needs-input push. It then emits the ask_user frame under the SDK's tool_use id and waits; the wait now ends early when the run is stopped. Saved blocks keep the answers as details {kind:'ask_user_question'}, so the questions and answers still show after a reload. AskUserQuestionCard was rebuilt with a header chip, option cards with a Recommended badge, and an HTML preview shown in an iframe sandbox="" whose document blocks every load. It also has multi-select and an automatic Other box. The chat card, the modal a reloaded page opens and the /review inbox all use it. The old ask_user tool is removed: its schema, handler, special cases in the engine and MCP server, the old loop's branch plus its isOrchestrator flag, and the dead trimToolResult helper. Old ask_user transcripts still display.

After review: The branch replaces AgentStudio's in-house ask_user tool with the SDK's native AskUserQuestion. It is enabled with toolConfig.askUserQuestion.previewFormat 'html' and settings.askUserQuestionTimeout 'never'. Each call is answered through canUseTool via the chat's ask-user host, which records the question in chat_runs.pending_questions (so /review and the needs-input push still work), sends the ask_user frame and waits up to 5 minutes or until Stop. A saved block carries the answers so a reload shows them. The card was rebuilt with a header chip, option cards with a Recommended badge, a sandboxed srcdoc preview, multi-select and an automatic Other. The legacy tool was removed. This round fixed the three should_fix review items. (1) Each inline card now submits under its own block token. pendingAskUser is now only what the composer and modal answer, and a new pure helper, settlePendingAskUser, hands it to the newest card still waiting when its own card is settled. A tool_result settles the card it landed on (askUserTokenFor), and a retry resends to the same token. (2) Other is now its own aria-pressed button: clicking it or typing in the box chooses it, and focus alone never does, so a keyboard user can pick an option and Tab on to Submit. chooseOther replaces focusOther, and a blank edit of an Other box that is not chosen no longer clears the picked option. (3) The under-1.5 s refusal spec now imports stream.server before starting its clock.

Closes #4

What was addressed

Audit findings (from the 2026-09-22 subsystem audit) were re-checked against the code before any change; "not a bug" means the claim did not hold up.

# Item Verdict Note
should_fix-1 src/routes/chat/[id]/+page.svelte:1639 inline card answers under pendingAskUser.token should_fix-1 src/routes/chat/[id]/+page.svelte:1639 inline card answers under pendingAskUser.token fixed The inline card calls resolveAskUser(answers, block.token). resolveAskUser takes the token as a parameter, falling back to pendingAskUser.token for the modal and composer. It then calls settleAskUser(token), which keeps pendingAskUser when a different card settles and hands it to the newest open card when its own card settles. The tool_result handler now settles only the card the result landed on, where it used to clear pendingAskUser unconditionally. The retry intent carries the token. The helpers live in src/lib/chat/streaming-blocks.ts. Pinned by unit specs in tests/chat.ask-user-answers.sp
should_fix-2 src/lib/chat/AskUserQuestionCard.svelte:147 focusing Other clears a single-select option should_fix-2 src/lib/chat/AskUserQuestionCard.svelte:147 focusing Other clears a single-select option fixed Removed the onfocus handler. Other is now a button with aria-pressed and aria-controls: clicking it chooses Other and focuses the box, and typing chooses it as before. chooseOther replaces focusOther and toggles on a multi-select question. writeOther no longer clears the picked option on a blank edit while Other is not chosen. The unit spec was updated, and new page specs cover clicking Other and a keyboard-only path (Space on an option, Tab through the Other box and the preview to Submit, Enter), which posts the option.
should_fix-3 tests/engine.ask-user-question.spec.ts:446 timing budget includes cold import should_fix-3 tests/engine.ask-user-question.spec.ts:446 timing budget includes cold import fixed The test awaits import('../src/lib/engine/stream.server') before const started = Date.now(). It passes when run alone with --grep on both projects.

Verification

  • bun run check: 0 errors
  • bun run build: passes
  • Specs: Both projects (desktop and mobile), E2E_PORT=4512, port patch reverted afterwards. 268 passed, 0 failed across: tests/chat.agent-tool-policy, chat.ask-user-answers, chat.ask-user-host, chat.ask-user-question-card, chat.askuser-render, chat.askuser-resume, engine.agent-definitions, engine.ask-user-question, engine.stream-approvals, hooks.chat-run, review.prompt-items, runs.questions, runtime.detached-tools, runtime.offered-tools, settings.tool-approval, tools.mcp-surface. Also ran the scoped-refusal timing test alone with --grep: 2 passed.
  • Independent review: approve (0 must-fix, 3 should-fix), addressed in a follow-up round.

Docs updated

  • README.md
  • docs/chat/spec.md
  • docs/hooks/spec.md
  • docs/notifications/spec.md
  • docs/observability/spec.md
  • docs/runs/spec.md
  • docs/runtime/spec.md
  • docs/settings/spec.md
  • docs/structure/boundaries.md
  • docs/structure/claude-desktop-parity.md
  • docs/structure/parity-reaudit-2026-09-22.md
  • docs/structure/spec.md
  • docs/tools/spec.md
  • docs/tools/tools.md
  • docs/ui/spec.md

Follow-ups / residual risk

Deferred on purpose, from the reviewer's minor notes:
(a) The server does not check answer keys. recordQuestionAnswers and /chat/[id]/ask-user still accept any keys, so a wrong-token post would still use up that question. The client no longer sends one. A server check would need a separate refusal reason in both /ask-user and answerQuestionFromReview, because the review path closes the item as 'expired' on any non-resolved result. I left it out rather than widen the change.
(b) The abort branch of awaitQuestionAnswers (removes the pending row, closes the review item as 'expired') is covered only through the fake store. No DB-level spec covers it.
(c) The engine's fallback that copies its recorded answers onto details does not check is_error. A call the CLI fails after canUseTool allowed it would show those answers on the saved card.
(d) The preview frame forces color-scheme: light, so the preview pane is white in dark mode.
Carried over from the earlier round: the settings Options key may collide with other branches that edit options.server.ts; the host waits 5 minutes (QUESTION_TIMEOUT_MS); there is no per-answer length cap on /chat/[id]/ask-user.
In this round the only hot file edited was src/routes/chat/[id]/+page.svelte, with small localized changes: 2 new imports, the retry-intent token, resolveAskUser/settleAskUser, one line in the tool_result handler and the inline card's onSubmit. The new logic is in streaming-blocks.ts. Nothing was pushed or merged.

🤖 Generated with Claude Code

derekhearst and others added 10 commits September 23, 2026 20:19
buildEngineOptions turns on the SDK's own question tool with
toolConfig.askUserQuestion.previewFormat 'html' and
settings.askUserQuestionTimeout 'never' (a Settings field, not an
Option). Read off the installed SDK 0.3.278 / CLI 2.1.278: the tool's
permission check always asks, so every call reaches canUseTool, and the
host answers by allowing with updatedInput.answers (question text ->
answer, multi-select comma-separated), which the CLI echoes back as
tool_use_result.answers.

The engine hands the call to the run's askUser host
($lib/engine/ask-user-question): no tool frames, refused at once with a
reason when there is no host or a subagent asked, settled on Stop, and
never left pending. The chat's host records the question on
chat_runs.pending_questions (opening the /review item and needs-input
push as before), emits the ask_user frame under the SDK's tool_use id,
and waits; the wait now ends early on abort. Saved blocks keep the
answers as details { kind: 'ask_user_question' }.

A question is exempt from approval settings and modes but not from an
agent's tool scope; ask_user is kept as an alias so an agent listing it
can still ask, and every subagent disallows AskUserQuestion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing live needs it once the SDK's AskUserQuestion is on: its schema,
description and "cannot run directly" handler leave the registry, the
engine's MCP server no longer special-cases it, and the old loop's
ask_user branch (and the trimToolResult helper only it used) goes with
the isOrchestrator flag that gated it — that loop's callers are
unattended and were never offered it; a model that asks anyway meets the
offered-list gate. /api/mcp and the settings approval list no longer
have a host-owned registry tool to leave out.

The read-only agents' allow-list carries AskUserQuestion in place of
ask_user, and the orchestrator prompt tells the model "Other" is
automatic and when to give an HTML preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The card now shows each question with its header chip, option cards
(label, description, a Recommended badge in place of the model's
"(Recommended)" suffix), a preview pane for the option under the pointer
or keyboard, multi-select, and an automatic free-text Other. The same
AskUserQuestionCard serves the inline card, the modal a reloaded page
opens and the /review inbox; the click rules and the answer string
(labels in option order, then Other, comma-separated) live in the pure
$lib/engine/ask-user-question so the specs reach them.

Previews are model HTML, so they only ever render in an
<iframe sandbox=""> whose srcdoc forbids every load and turns acting
tags into text. Answers are keyed by question text (header for an old
ask_user question), the /review command accepts keys that long, and a
saved AskUserQuestion shows each question with its answer after a
reload, or "Not answered." when the call was refused. Old ask_user
transcripts still render as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chat spec describes the new card, how and where a question is
answered, what happens when nobody answers, who may ask, why previews
are safe, and what was verified in the SDK rather than assumed. Runs,
tools, settings, hooks, notifications, observability, UI and structure
docs drop ask_user; the README says what the card does; the parity doc
gains a row and the re-audit marks #4 shipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Focusing the Other box chose it, which on a single-select question
dropped the option already picked. Other sits between the options and
Submit in tab order, so a keyboard user who picked an option and tabbed
on to Submit cleared their own answer on the way and could not answer
with an option at all.

Other is now a button of its own (aria-pressed, like the option cards):
clicking it chooses it and puts the caret in the box, and typing in the
box chooses it as before. Focus alone changes nothing. chooseOther
replaces focusOther (a toggle on a multi-select question), and a blank
edit of an unchosen Other no longer takes away the picked option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The bundled CLI marks AskUserQuestion concurrency-safe, so one assistant
message can open two or three cards at once. Every inline card sent its
answers under the page's single pendingAskUser token, which is always
the newest card's: answering the first card recorded its answers
against the last question, the engine then refused that question as
unanswered, and the first one waited out the five minutes.

Each inline card now submits with its own block's token. pendingAskUser
is only what the composer and the modal answer; settlePendingAskUser
keeps it when another card settles and, when its own card is answered
or gets its result, hands the role to the newest card still waiting.
A tool_result settles the card it landed on (askUserTokenFor), not
whichever question was pending, and a retry resends to the same token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The under-1.5 s refusal check started its clock before driveQuestions
imported stream.server, so run alone or as a retry in a fresh worker
the cold import counted against the budget. Import it first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The chat spec says that several questions can wait at once, that each
card answers only its own, and where a composer reply goes; and that
Other is chosen by clicking or typing, so an option survives tabbing
on to Submit. The re-audit's #4 entry records both review follow-ups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #24 (per-turn SDKUserMessage with a minted uuid, sdkTurn /
sdkTailUuid on messages.metadata, edit/regenerate cutting the session with
resumeSessionAt, enableFileCheckpointing, the rewind control session and
/compact) together with the #22 composer (`@` mentions, `/` palette), the
#9 gateway (SUBSCRIPTION_MODEL_IDS allow-list, OpenRouter gateway, backend
labels) and the #14 right rail that feat/24-rewind already stacks.

Conflicts:
- src/lib/engine/agent-definitions.ts: keep both imports -- #4's
  ASK_USER_QUESTION_TOOL (SUBAGENT_DISALLOWED_TOOLS) and #9's
  isSubscriptionModel/normalizeModelId (a subagent's Claude model).
- README.md: one paragraph per feature -- edit/regenerate/restore (#24),
  the AskUserQuestion card (#4), models and the gateway (#9), composer
  shortcuts (#22), read aloud, the right rail (#14); the creation-flow
  paragraph keeps #4's "question card" wording instead of ask_user.
- docs/chat/spec.md: blocking actions keep #4's "answers to the agent's
  questions"; mobile behaviour takes #14's right-rail drawer.
- docs/structure/claude-desktop-parity.md: both rows kept -- "Agent asks
  you a question" (#4 landed) and "Slash commands and @ file mentions"
  (#22).

Checked for semantic conflicts in the files that merged cleanly:
- stream/+server.ts: #4's askUser host (createAskUserHost) now rides
  inside #24's runWithResumeFallback; its tokens are keyed by the SDK's
  tool_use id, so a fallback attempt cannot collide with the first. #4's
  removal of the enqueuePendingQuestion/awaitQuestionAnswers import and
  fulfilAskUser stays removed.
- stream.server.ts: #24's transcript-tail tracking runs before #4's
  host-owned AskUserQuestion branch, so a question turn still records
  its sdkTailUuid; the streamed single user message keeps stdin open for
  canUseTool, which is how the question is answered.
- options.server.ts: #4's toolConfig/settings sit beside #9's gateway env
  and #24's resumeSessionAt/enableFileCheckpointing.
- parity-reaudit: #14 struck through as shipped in the "cheap
  independents" list alongside #4.
- composer-commands.ts / chat-page-commands.ts (#22): comments name the
  agent's AskUserQuestion rather than the retired ask_user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@derekhearst
derekhearst merged commit 6a3159e into main Sep 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Adopt the SDK's native AskUserQuestion and redesign the ask card

1 participant