refactor(chat): unify ask-user flow on <question-form>, delete AskUserQuestion mechanism (#4114)

* refactor(chat): unify ask-user flow on <question-form>, delete AskUserQuestion mechanism

There were two parallel "ask the user a clarifying question" mechanisms:

  A. The AskUserQuestion SDK tool — an inline interactive card in the chat
     stream (AskUserQuestionCard / StreamingAskUserQuestionCard), whose answer
     was fed back into the still-open stream-json child via
     POST /api/runs/:id/tool-result + submitChatRunToolResult.
  B. The <question-form> markdown artifact — a QuestionsBanner entry point in
     chat, the form rendered in the right-hand Questions tab
     (QuestionsPanel / QuestionFormView), answers returned as the next user
     message.

Two visual languages for the same job, and mechanism A's card was visually
broken (the text status "Awaiting your answer" was stuffed into the 24px
.op-status icon badge and overflowed onto the title). Mechanism A was pinned to
mid-conversation clarification while B was pinned to turn-1 discovery — an
artificial split.

This deletes mechanism A entirely and keeps only B, and widens <question-form>
from "turn-1 discovery only" to ANY turn so mid-conversation clarification flows
through the same banner → Questions tab → next user message loop.

Web:
- Delete apps/web/src/runtime/ask-user-question.ts (parser trio).
- ToolCard.tsx: drop the AUQ card/streaming-card/dispatch/imports and the
  onAnswerToolUse / onSubmitForm props.
- AssistantMessage.tsx: drop the live AUQ render path,
  suppressAskUserQuestionFallbackText, the AUQ name from SNAPSHOT_TOOL_NAMES,
  and the onSubmitForm bridge. Keep suppressDuplicateQuestionForms.
- Delete the whole onSubmitForm / onAssistantFormSubmitStart return chain
  across AssistantMessage / ChatPane / ProjectView / SideChatTab, and
  submitChatRunToolResult in providers/daemon.ts.

Daemon (conservative — keep the generic stream-json input skeleton):
- applyClaudeStreamJsonRunBookkeeping: drop the AUQ detection branch and
  run.pendingHostAnswers; stdin now closes on any non-tool_use clean terminal
  turn.
- Delete run-tool-results.ts, submitToolResultToRun, and the
  POST /api/runs/:id/tool-result endpoint.
- run-artifacts.ts:runAskedUserQuestion now scans streamed text for a
  <question-form> marker (reassembled across text_delta chunks) instead of an
  AUQ tool_use, preserving the run_finished.asked_user_question analytics
  signal.
- Keep --input-format stream-json / promptInputFormat: 'stream-json' as generic
  mid-turn input infra; refresh the comments that referenced AUQ.

System prompt / contracts:
- Replace the Claude-only AskUserQuestion clarification section with generic
  "emit <question-form> for mid-conversation clarification" guidance; relax the
  API-mode override wording; drop "Do not call AskUserQuestion" from the
  skip-discovery override.

i18n / CSS:
- Remove the tool.askQuestion* keys from types.ts + all locales.
- Remove the op-ask-question* block and .op-status-awaiting from tools.css;
  keep .op-status (the icon badge) and the qf-* classes that mechanism B uses.

Docs / tests:
- AGENTS.md: rewrite the runtime-conventions + chat-UI sections; add an
  "Asking the user questions" section documenting the single mechanism.
- Delete the AUQ-only tests; rewrite the quiet-period bookkeeping, run-artifacts,
  prompt, and plugin-folder tests to the new behavior.

Validation: pnpm guard (54), full typecheck, web tests, daemon suite
(4351 pass), contracts tests; replayed two Claude stream-json traces through the
mock CLI (incl. the [form answers — discovery] trace) — 0 parse failures, clean
end_turn termination.

* fix(chat): unify mid-conversation question-form across API/BYOK + analytics alias

Address review feedback on the ask-user unification:

- contracts composer (used by API/BYOK 'plain' mode) now mirrors the
  daemon-side '## Clarifying questions mid-conversation' section and widens
  the API_MODE_OVERRIDE allowed-output list from 'discovery on turn 1' to
  'discovery (turn 1) and mid-conversation clarification', so BYOK/API runs
  route follow-up choices through the unified Questions tab instead of
  drifting back to plain markdown option lists. Makes the AGENTS.md
  'mirrored through packages/contracts/src/prompts/system.ts' claim true.
- runAskedUserQuestion now matches the '<ask-question>' alias (already
  whitelisted by the UI parser and daemon open-tag matcher), so a model that
  drifts to the alias still records run_finished.asked_user_question instead
  of being misclassified in the artifact funnel.
- Tests: contracts api-mode asserts mid-conversation forms are permitted in
  both daemon and plain mode; run-artifacts asserts the alias is detected.

* fix(chat): make <ask-question> alias consistent across all paths + legacy AUQ renderer

Follow-ups from re-review of the ask-user unification:

- The <ask-question> alias is already accepted by the UI parser and the
  daemon open-tag matcher, but two consumers still only recognized the
  canonical <question-form>: db.listProjectsAwaitingInput's awaiting-input
  filter and the next-turn transcript sanitizer. Both now match either tag,
  so an alias-form turn (a) marks the project awaiting input and (b) gets
  scrubbed from the prior assistant turn instead of replaying verbatim and
  re-triggering the discovery-form loop.
- Re-add a read-only legacy AskUserQuestion renderer in ToolCard. The
  interactive mechanism is gone, but persisted AUQ tool_use events survive
  in upgraded chat history; without this they fell through to GenericCard and
  surfaced the raw {"questions":[...]} JSON. The card now renders an inert
  question summary (model-authored text, no new i18n keys, no submit path).

Tests: alias awaiting-input case (project-status), alias-form sanitizer
regression (sse), and legacy AUQ render + unparseable fallback (tool-renderers).

* fix(chat): correct asked_user_question detector + legacy AUQ answer rendering

Re-review follow-ups:

- runAskedUserQuestion read `data.text` for text_delta events, but the
  persisted SSE shape carries the chunk on `delta` (packages/contracts/src/
  sse/chat.ts), so production <question-form> runs appended nothing and
  run_finished.asked_user_question was permanently false (the old tests passed
  only because they built the wrong `text` field). Now reads `delta` (still
  accepts `text` defensively).
- It also matched a bare open-tag, so a run that merely shows the literal
  markup in a generated doc / code sample / HTML artifact could be
  misclassified as a clarification turn and excluded from the artifact funnel.
  Now requires a renderable closed block via a shared detector.
- Extracted the renderable-form detector + helpers out of server.ts into
  apps/daemon/src/question-form-detect.ts so the missing-artifacts guard,
  awaiting-input status, and run analytics share ONE check (the codebase
  comment already anticipated promoting this once it had a third consumer).
  The `<ask-question>` alias is covered by that shared matcher.
- LegacyAskUserQuestionCard now parses the persisted answer from
  result.content and surfaces it ("question -> answer") so two historical AUQ
  runs with different answers no longer render identically.

Tests: run-artifacts now uses the real `delta` shape + renderable bodies, adds
a literal-markup negative case; tool-renderers asserts the persisted answer is
surfaced.

---------

Co-authored-by: qiongyu1999 <2694684348@qq.com>
Co-authored-by: lefarcen <935902669@qq.com>
This commit is contained in:
elihahah666
2026-06-10 19:21:07 +00:00
committed by GitHub
co-authored by qiongyu1999 lefarcen
parent 4c8914dc71
commit ca22620b4f
62 changed files with 529 additions and 1655 deletions
+12 -7
View File
@@ -130,18 +130,23 @@ root `pnpm tools-pr` script without a new explicit maintainer decision.
## Agent runtime conventions
- `RuntimeAgentDef.promptInputFormat` selects how the daemon writes the prompt to a child's stdin. The default `'text'` writes the composed prompt and ends stdin immediately. `'stream-json'` wraps the prompt as one JSONL `user` message and KEEPS stdin open so the daemon can stream further user messages back in mid-turn. Claude (`apps/daemon/src/runtimes/defs/claude.ts`) ships `'stream-json'` together with `--input-format stream-json` so the host can answer interactive tools like `AskUserQuestion` with a real `tool_result` block. Every other agent stays on `'text'`.
- `apps/daemon/src/server.ts` tracks `run.pendingHostAnswers` (a Set of `tool_use_id` strings) and `run.stdinOpen` on the run object. The `claude-stream-json` event handler adds AskUserQuestion ids to the set and closes stdin only when both the set is empty AND a `turn_end` (or `usage`) event arrives with a non `tool_use` `stop_reason`. The `tool_use` stop reason means the model paused mid tool (waiting on claude-code's internal runner or on a host answer); closing stdin there would truncate the follow up response.
- `claude-stream.ts` emits the `turn_end` event AFTER iterating the assistant message's content blocks, not before. When `--include-partial-messages` is unsupported, tool_use events surface only from the assistant wrapper, so emitting `turn_end` first would let the daemon close stdin before the host had registered any pending answers.
- `POST /api/runs/:id/tool-result` is the daemon endpoint for feeding a `tool_result` block back into a still running stream-json child. Body shape: `{ toolUseId: string, content: string, isError?: boolean }`. Web callers use `submitChatRunToolResult` from `apps/web/src/providers/daemon.ts`. The daemon writes a JSONL `user` message containing one `tool_result` content block, removes the id from `pendingHostAnswers`, and lets the next `turn_end` decide when to close stdin.
- AskUserQuestion specifically: Claude's system prompt section in `apps/daemon/src/prompts/system.ts` (Claude only block at the bottom of `composeSystemPrompt`) tells the model to use the tool for 2 to 4 finite choices, and to stop generating tokens after the tool call instead of also writing a markdown duplicate. `AssistantMessage.suppressAskUserQuestionFallbackText` is the belt and suspenders that hides any trailing markdown text in the same turn.
- `RuntimeAgentDef.promptInputFormat` selects how the daemon writes the prompt to a child's stdin. The default `'text'` writes the composed prompt and ends stdin immediately. `'stream-json'` wraps the prompt as one JSONL `user` message and KEEPS stdin open so the daemon can stream further user messages back in mid-turn. Claude (`apps/daemon/src/runtimes/defs/claude.ts`) ships `'stream-json'` together with `--input-format stream-json` as generic mid-turn input infrastructure; the daemon closes stdin once the turn terminates cleanly. Every other agent stays on `'text'`.
- `apps/daemon/src/server.ts` tracks `run.stdinOpen` on the run object. `applyClaudeStreamJsonRunBookkeeping` closes stdin (and records `turnCompletedCleanly`) when a `turn_end` (or `usage`) event arrives with a non `tool_use` `stop_reason`. The `tool_use` stop reason means the model paused mid tool (waiting on claude-code's internal runner); closing stdin there would truncate the follow up response.
- `claude-stream.ts` emits the `turn_end` event AFTER iterating the assistant message's content blocks, not before, so the daemon sees the final `stop_reason` and every tool_use of the turn before deciding whether to close stdin.
- The host asks the user clarifying questions through the `<question-form>` artifact (see "Asking the user questions" below), NOT through a stdin-injected `tool_result`. There is no `AskUserQuestion` tool wiring, no `/api/runs/:id/tool-result` endpoint, and no host-answer return path; the stream-json input skeleton is retained only as generic infrastructure.
## Asking the user questions
- There is exactly one mechanism for clarifying user intent: the `<question-form>` markdown artifact the model emits inline. The chat renders a `QuestionsBanner` entry point (`AssistantMessage.tsx`); the form itself renders in the right-hand Questions tab (`QuestionsPanel` + `QuestionFormView`), and answers flow back as the next user message (`formatFormAnswers` in `apps/web/src/artifacts/question-form.ts` → `POST /api/chat`). There is no inline interactive tool card.
- `<question-form>` is valid on ANY turn, not just turn-1 discovery. Use it for turn-1 discovery briefs AND for mid-conversation clarification (e.g. an ambiguous annotation). The system-prompt guidance lives in `apps/daemon/src/prompts/system.ts` and `discovery.ts`; the API/BYOK-mode wording is mirrored through `packages/contracts/src/prompts/system.ts`.
- `run-artifacts.ts:runAskedUserQuestion` powers the `run_finished.asked_user_question` analytics signal by scanning the run's streamed text for a `<question-form` marker (reassembled across `text_delta` chunks), not by detecting any tool call.
## Chat UI conventions
- `apps/web/src/components/file-viewer-render-mode.ts` decides URL-load vs srcDoc for HTML previews. Bridges (deck, comment/inspect selection, palette, edit, tweaks) can ONLY inject through the srcDoc path. Add a new disqualifier to `UrlLoadDecision` whenever a feature needs a srcDoc-only bridge; pass it from `FileViewer.tsx` based on a source-content heuristic where appropriate (e.g. `hasTweaksTemplate`). The host keeps both iframes mounted simultaneously and swaps CSS visibility so toggling render mode does not cause an iframe reload flash; `iframeRef.current` stays aligned with the active iframe via `useEffect`. Receive filters use `isOurIframe(ev.source)` to accept messages from either iframe but signals that should ONLY come from the active iframe (e.g. `od:tweaks-available`) re-check `ev.source === iframeRef.current?.contentWindow`.
- TodoWrite UI pins one canonical task list above the chat composer via `PinnedTodoSlot` in `ChatPane.tsx`. The slot reads the latest TodoWrite snapshot across the conversation through `latestTodoWriteInputFromMessages` (`apps/web/src/runtime/todos.ts`). `AssistantMessage.stripTodoToolGroups` removes any TodoWrite tool groups from per message rendering so there is exactly one TodoCard on screen. The progress count includes both `completed` and `in_progress` items (1/4 reads "one underway" not "zero finished"). Dismissal via the Done button is keyed on the snapshot's JSON, so a fresh TodoWrite from the agent automatically re shows the card. `PinnedTodoSlot` sits OUTSIDE the `.chat-log` scroll container, so auto-scroll requires explicit coverage: `ChatPane`'s `ResizeObserver` accepts a `containerRef` from `PinnedTodoSlot` and observes that element directly, and a pane-level `MutationObserver` (`childList: true` on the chat pane ancestor) re-syncs that observation whenever the slot mounts or unmounts as new TodoWrite snapshots arrive.
- `AskUserQuestionCard` (in `ToolCard.tsx`) prefers the live `onAnswerToolUse(toolUseId, content)` route (POSTs to `/api/runs/:id/tool-result`) and falls back to the legacy `onSubmitForm(text)` path when the run has already terminated. Selected chips persist across reloads by parsing the stored `tool_result.content` back into the selections shape.
- Tool group rendering uses `dedupeSnapshotToolRetries` to collapse identical `AskUserQuestion` retries (one card per unique input, keeping the latest tool_use_id) and `TodoWrite` snapshots (only the most recent call, since each call is a state replace).
- Clarifying questions render through the `<question-form>` artifact and the Questions tab, not an inline tool card — see "Asking the user questions" above.
- Tool group rendering uses `dedupeSnapshotToolRetries` to collapse `TodoWrite` snapshots (only the most recent call survives, since each call is a state replace). `SNAPSHOT_TOOL_NAMES` lists the snapshot-style tools; non-snapshot tools pass through untouched.
## Web CSS ownership
-46
View File
@@ -59,7 +59,6 @@ export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'htt
export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
const { db, design } = ctx;
const { sendApiError, createSseResponse } = ctx.http;
const { submitToolResultToRun } = ctx.chat;
const { testProviderConnection, testAgentConnection, getAgentDef, isKnownModel, sanitizeCustomModel, listProviderModels } = ctx.agents;
const {
handleCritiqueArtifact,
@@ -124,51 +123,6 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
res.json(body);
});
// Feed a `tool_result` content block into a running stream-json child.
// Currently used to answer Claude's `AskUserQuestion` tool: the host UI
// collects the user's choice, the web POSTs the formatted answer here,
// and the daemon writes a JSONL line into the still-open stdin. Without
// this path Claude auto-errors the tool in headless mode and falls back
// to a markdown duplicate of the same options.
app.post('/api/runs/:id/tool-result', (req, res) => {
if (typeof submitToolResultToRun !== 'function') {
return sendApiError(res, 501, 'NOT_IMPLEMENTED', 'tool-result wiring is not available');
}
const body = (req.body || {}) as {
toolUseId?: unknown;
content?: unknown;
isError?: unknown;
runId?: unknown;
};
if (typeof body.runId === 'string' && body.runId.length > 0 && body.runId !== req.params.id) {
return sendApiError(res, 400, 'BAD_REQUEST', 'runId must match the path run id');
}
const toolUseId = typeof body.toolUseId === 'string' ? body.toolUseId : '';
const content = typeof body.content === 'string' ? body.content : '';
const isError = body.isError === true;
if (!toolUseId) {
return sendApiError(res, 400, 'BAD_REQUEST', 'toolUseId is required');
}
const result = submitToolResultToRun(req.params.id, toolUseId, content, isError);
if (!result || !result.ok) {
const reason = result && result.reason ? result.reason : 'unknown';
if (reason === 'not_found') {
return sendApiError(res, 404, 'NOT_FOUND', 'run not found');
}
if (reason === 'run_terminal' || reason === 'stdin_closed') {
return sendApiError(res, 410, 'GONE', `run is no longer accepting tool results (${reason})`);
}
if (reason === 'stdin_text_mode') {
return sendApiError(res, 400, 'BAD_REQUEST', 'run does not support interactive tool results');
}
if (reason === 'bad_tool_use_id') {
return sendApiError(res, 400, 'BAD_REQUEST', 'toolUseId is invalid');
}
return sendApiError(res, 500, 'INTERNAL', `tool result write failed: ${reason}`);
}
res.json({ ok: true });
});
// Receives the user's thumbs-up/down (+ reason codes) for an assistant
// turn and forwards it to Langfuse as a `score-create`. Web persists the
// feedback itself via PUT /messages/:id; this endpoint exists only as a
+6 -7
View File
@@ -350,11 +350,10 @@ export function createClaudeStreamHandler(onEvent: EventSink) {
// Per-turn `stop_reason` is emitted as `turn_end` AFTER the content
// blocks have been processed (see below). When `--include-partial-
// messages` is unsupported, tool_use events surface only from the
// assistant wrapper here — emitting `turn_end` before that loop
// would let the daemon's stdin-close handler see an empty
// `pendingHostAnswers` set and close stdin before the
// AskUserQuestion tool_use was registered, which made the round
// trip silently fail. Read the stop_reason now, emit after.
// assistant wrapper here — emitting `turn_end` before that loop would
// let the daemon's stdin-close handler act on the turn before its
// tool_use blocks were seen, closing stdin mid-tool. Read the
// stop_reason now, emit after.
const stopReason = typeof obj.message.stop_reason === 'string'
? obj.message.stop_reason
: null;
@@ -384,8 +383,8 @@ export function createClaudeStreamHandler(onEvent: EventSink) {
}
// Surface the turn_end signal now that every tool_use in this
// assistant message has been emitted, so the daemon's stdin-close
// handler has the up-to-date `pendingHostAnswers` set before
// deciding whether to close stream-json input stdin.
// handler sees the final `stop_reason` before deciding whether to
// close stream-json input stdin.
if (stopReason) {
onEvent({ type: 'turn_end', stopReason });
}
+7 -1
View File
@@ -620,7 +620,13 @@ export function listProjectsAwaitingInput(db: SqliteDb) {
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE m.role = 'assistant'
AND LOWER(m.content) LIKE '%<question-form%'
-- ask-question is an accepted alias for question-form (UI parser
-- + daemon open-tag matcher), so an alias-form turn must also
-- count as awaiting input.
AND (
LOWER(m.content) LIKE '%<question-form%'
OR LOWER(m.content) LIKE '%<ask-question%'
)
) latest
WHERE latest.rowNum = 1
AND NOT EXISTS (
+9 -13
View File
@@ -800,18 +800,14 @@ export function composeSystemPrompt({
);
}
// Claude only: nudge the model toward the `AskUserQuestion` tool for
// mid-conversation clarifications. Without this hint Claude tends to fall
// back to a markdown bulleted list of options, which the chat UI cannot
// turn into clickable buttons. Discovery (turn 1) is still owned by the
// `<question-form>` flow defined in DISCOVERY_AND_PHILOSOPHY; this only
// covers follow-ups where the next action depends on a small set of
// choices the user can pick quickly.
if (agentId === 'claude') {
parts.push(
"\n\n---\n\n## Clarifying questions\n\nWhen you need a mid-conversation clarification AND the natural answer is one of a small finite set of choices (2-4 options per question), call the `AskUserQuestion` tool instead of writing a bulleted list in markdown. The host chat renders the tool call as inline choice buttons; a markdown list renders as plain text and forces the user to type a reply. Skip the tool when the answer is naturally free-form text, when the answer needs more than ~4 options, or when you only have one yes/no choice to ask. First-turn discovery still uses the `<question-form id=\"discovery\">` workflow described earlier; `AskUserQuestion` is for follow-ups only.\n\n**When you call `AskUserQuestion`, that tool call is the entire response.** Do NOT also write the same questions or options as markdown text alongside it, do NOT add a trailing prose paragraph like \"what sounds right?\", do NOT hedge by listing the options twice. Emit the tool call and stop generating tokens. The host is waiting on the tool's `tool_result` and will resume your turn the moment the user answers. Anything you write before, between, or after the tool call in the same message just duplicates what the card already shows and confuses the user.",
);
}
// Mid-conversation clarification reuses the same `<question-form>` flow as
// turn-1 discovery (DISCOVERY_AND_PHILOSOPHY) so the host keeps ONE unified
// questions surface: the chat shows a banner, the form renders in the
// right-hand Questions tab, and answers return as the next user message.
// Applies to every agent — question-form is UI-parsed markup, not a tool.
parts.push(
"\n\n---\n\n## Clarifying questions mid-conversation\n\nWhen you need a clarification AFTER turn 1 and the natural answer is one of a small finite set of choices (2-4 options per question), emit a `<question-form>` block — the same markup turn-1 discovery uses — instead of writing a bulleted list of options in markdown. The host renders it as a Questions banner the user opens in the side tab; a markdown list renders as plain text and forces the user to type a reply. Use free-form prose questions only when the answer is naturally open-ended, needs more than ~4 options, or is a single yes/no. Do NOT also duplicate the form's questions as markdown text alongside it.",
);
// Pinned LAST so recency bias reinforces the role-marker prohibition.
// This is the canonical anti-roleplay instruction;
@@ -856,7 +852,7 @@ Every later instruction in this prompt that tells you to "call TodoWrite", "run
**Allowed output:**
- Plain chat prose to the user (in their language). State your plan as prose — a short numbered list in markdown is fine; it just must not be wrapped in \`<todo-list>\` or claim to be a tool call.
- A final \`<artifact type="text/html">...</artifact>\` block containing a complete \`<!doctype html>\` document when the brief is ready to deliver.
- \`<question-form>\` blocks for discovery on turn 1, exactly as the rules below describe — question-form is markup the UI parses, not a tool call.
- \`<question-form>\` blocks for discovery (turn 1) and for mid-conversation clarification, exactly as the rules below describe — question-form is markup the UI parses, not a tool call.
If the rules below tell you to plan with TodoWrite, write the plan as prose instead. If they tell you to read skill side files before writing, describe in one sentence which patterns/conventions you're going to apply and proceed. If they tell you to run brand-spec extraction via Bash + Read + WebFetch, ask the user the missing brand questions in the discovery form instead.`;
+76
View File
@@ -0,0 +1,76 @@
// Renderable `<question-form>` detection, shared across daemon consumers.
//
// Canonical open tag is `<question-form>`; `<ask-question>` is an accepted
// alias models drift to. This mirrors the open-tag set + body contract in the
// web parser (`apps/web/src/artifacts/question-form.ts`). The app boundary
// forbids `apps/daemon` importing `apps/web/src`, so the mirror is deliberate —
// keep it in sync, or promote a shared parser into `packages/contracts` if the
// two drift. Kept as a daemon-internal module so every daemon consumer (the
// missing-artifacts guard, awaiting-input status, and run analytics) shares ONE
// renderable-form check instead of each re-deriving a naive open-tag regex.
// Canonical open tag plus the `<ask-question>` alias. Matching only the open
// tag is intentionally NOT enough on its own (see `emittedRenderableQuestionForm`).
export const QUESTION_FORM_OPEN_RE = /<(question-form|ask-question)\b[^>]*>/i;
// True when `body` is a renderable question-form body: JSON (optionally fenced)
// parsing to an object with a non-empty `questions` array. This is the minimal
// contract `tryParseForm` enforces in the web parser; a body that fails it is
// kept as raw prose by the UI (no form card renders).
export function questionFormBodyIsRenderable(body: string): boolean {
const trimmed = typeof body === 'string' ? body.trim() : '';
if (!trimmed) return false;
const stripped = trimmed
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
let data: unknown;
try {
data = JSON.parse(stripped);
} catch {
return false;
}
if (!data || typeof data !== 'object') return false;
const questions = (data as { questions?: unknown }).questions;
return Array.isArray(questions) && questions.some((q) => q && typeof q === 'object');
}
// Locate `closeTag` (case-insensitively) at or after `from`, returning an index
// in the ORIGINAL `text` coordinate space. Mirrors the web parser's
// `findCloseTag`: scanning char-by-char and lowercasing each fixed-length
// candidate slice keeps the result aligned with `openEnd`. Lowercasing the
// whole string up front instead would desync the index, because some code
// points expand under `toLowerCase()` (e.g. `"İ" -> "i̇"`) and shift every
// offset after them — corrupting the body slice and failing a valid form.
export function findQuestionFormCloseTag(text: string, from: number, closeTag: string): number {
const closeLower = closeTag.toLowerCase();
const tagLen = closeTag.length;
const maxStart = text.length - tagLen;
for (let i = from; i <= maxStart; i++) {
if (text.slice(i, i + tagLen).toLowerCase() === closeLower) return i;
}
return -1;
}
// Whether the agent's visible text contains a *renderable* clarifying form — a
// closed `<question-form>`/`<ask-question>` block whose body satisfies the
// parser contract above. Matching only the open tag would let a malformed,
// non-renderable body (or the literal tag shown inside a code sample / generated
// doc) count as a clarification turn, so artifact-generating runs that merely
// mention the markup are not misclassified.
export function emittedRenderableQuestionForm(text: unknown): boolean {
if (typeof text !== 'string' || !text) return false;
let cursor = 0;
while (cursor < text.length) {
const m = QUESTION_FORM_OPEN_RE.exec(text.slice(cursor));
if (!m) return false;
const tagName = (m[1] ?? 'question-form').toLowerCase();
const closeTag = `</${tagName}>`;
const openEnd = cursor + m.index + m[0].length;
const closeIdx = findQuestionFormCloseTag(text, openEnd, closeTag);
if (closeIdx === -1) return false;
if (questionFormBodyIsRenderable(text.slice(openEnd, closeIdx))) return true;
cursor = closeIdx + closeTag.length;
}
return false;
}
+30 -16
View File
@@ -24,6 +24,8 @@
// uniform zero on PostHog and made the same funnel useless from the
// other direction.
import { emittedRenderableQuestionForm } from './question-form-detect.js';
// Tool names cover Claude-style, Codex-style, and the ACP/MCP shapes
// the daemon proxies. Keep aligned with the web-side `WRITE_NAMES` /
// `EDIT_NAMES` sets in `apps/web/src/runtime/file-ops.ts`.
@@ -36,16 +38,6 @@ const WRITE_OR_EDIT_TOOL_NAMES: ReadonlySet<string> = new Set([
'multi_edit',
]);
// Tool names the daemon recognizes as an intent-clarification card. Claude
// emits `AskUserQuestion`; the ACP/MCP snake_case proxy shape emits
// `ask_user_question`. Keep aligned with the AskUserQuestion detection in
// `apps/daemon/src/server.ts` and the card rendering in
// `apps/web/src/components/ToolCard.tsx`.
const ASK_USER_QUESTION_TOOL_NAMES: ReadonlySet<string> = new Set([
'AskUserQuestion',
'ask_user_question',
]);
function extractToolFilePath(input: unknown): string | null {
if (!input || typeof input !== 'object') return null;
const obj = input as { file_path?: unknown; path?: unknown };
@@ -208,25 +200,47 @@ export function countNewHtmlArtifacts(events: readonly RunEventLike[]): number {
return writtenPaths.size;
}
// True iff the run raised an AskUserQuestion clarification card. Fed into
// True iff the run raised an intent-clarification question. Fed into
// `run_finished.asked_user_question`. A clarification turn is the agent
// stopping to ask the user a finite-choice question; it inherently produces
// no artifact, so the dashboard uses this flag to exclude such runs from the
// "run finished -> has artifact" funnel rather than scoring them as
// artifact-generation failures.
//
// Clarification now surfaces as a `<question-form>` artifact in the
// assistant's streamed text (the AskUserQuestion tool was retired in favor of
// the unified question-form flow; see `apps/web/src/artifacts/question-form.ts`
// and the `awaiting_input` detection in `apps/daemon/src/db.ts`). Assistant
// text arrives as `text_delta` chunks, so the marker can straddle a chunk
// boundary — concatenate the run's text before testing for it.
//
// Two correctness points enforced here:
// 1. Persisted `text_delta` events carry the chunk on `delta`
// (`{ type: 'text_delta'; delta }`, see packages/contracts/src/sse/chat.ts),
// NOT `text`. Reading `text` appended nothing for real runs, leaving the
// signal permanently false. We read `delta` first, and still accept a
// `text` field for any runtime that emits a whole-text event.
// 2. We require a *renderable* closed `<question-form>`/`<ask-question>`
// block (shared `emittedRenderableQuestionForm`), not a raw open-tag
// match — so a run that merely shows the literal markup inside a generated
// doc, code sample, or HTML artifact is not misclassified as a
// clarification turn and wrongly excluded from the artifact funnel. The
// `<ask-question>` alias is covered by that shared matcher.
export function runAskedUserQuestion(
events: readonly RunEventLike[],
): boolean {
if (!events || events.length === 0) return false;
let text = '';
for (const rec of events) {
if (rec?.event !== 'agent') continue;
const data = rec.data as
| { type?: string; name?: unknown }
| { type?: unknown; text?: unknown; delta?: unknown }
| null
| undefined;
if (data?.type !== 'tool_use') continue;
if (typeof data.name !== 'string') continue;
if (ASK_USER_QUESTION_TOOL_NAMES.has(data.name)) return true;
if (!data) continue;
if (data.type !== 'text_delta' && data.type !== 'text') continue;
if (typeof data.delta === 'string') text += data.delta;
else if (typeof data.text === 'string') text += data.text;
}
return false;
return emittedRenderableQuestionForm(text);
}
-80
View File
@@ -1,80 +0,0 @@
export type SubmitToolResultReason =
| 'run_terminal'
| 'stdin_closed'
| 'stdin_text_mode'
| 'bad_tool_use_id'
| 'write_failed';
export type SubmitToolResultResult =
| { ok: true }
| { ok: false; reason: SubmitToolResultReason; error?: string };
interface WritableStdin {
destroyed?: boolean;
write(chunk: string, encoding?: BufferEncoding): unknown;
}
interface ToolResultRunState {
child?: { stdin?: WritableStdin | null } | null;
pendingHostAnswers?: Set<string> | null;
stdinOpen?: boolean;
}
export interface SubmitToolResultInput {
content: unknown;
isError?: boolean;
isTerminal: boolean;
toolUseId: unknown;
}
export function submitToolResultToRunState(
run: ToolResultRunState,
input: SubmitToolResultInput,
): SubmitToolResultResult {
if (input.isTerminal) {
return { ok: false, reason: 'run_terminal' };
}
if (!run.child?.stdin || run.child.stdin.destroyed) {
return { ok: false, reason: 'stdin_closed' };
}
if (!run.stdinOpen) {
return { ok: false, reason: 'stdin_text_mode' };
}
if (typeof input.toolUseId !== 'string' || input.toolUseId.length === 0) {
return { ok: false, reason: 'bad_tool_use_id' };
}
if (!run.pendingHostAnswers?.has(input.toolUseId)) {
return { ok: false, reason: 'bad_tool_use_id' };
}
const safeContent =
typeof input.content === 'string' ? input.content : String(input.content ?? '');
const userMessage = JSON.stringify({
type: 'user',
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: input.toolUseId,
content: safeContent,
is_error: input.isError === true,
},
],
},
});
try {
run.child.stdin.write(`${userMessage}\n`, 'utf8');
} catch (err) {
return {
ok: false,
reason: 'write_failed',
error: err instanceof Error ? err.message : String(err),
};
}
run.pendingHostAnswers.delete(input.toolUseId);
return { ok: true };
}
+4 -6
View File
@@ -48,12 +48,10 @@ export const claudeAgentDef = {
buildArgs: (_prompt, _imagePaths, extraAllowedDirs = [], options = {}, runtimeContext = {}) => {
const caps = agentCapabilities.get('claude') || {};
// `--input-format stream-json` lets the daemon stream multiple JSONL
// messages into stdin instead of closing it after the initial prompt.
// This is what lets us answer Claude's `AskUserQuestion` tool calls
// with a real `tool_result` block — without it claude-code auto errors
// the tool because it cannot prompt the user interactively in headless
// mode, and the model falls back to a markdown duplicate of the same
// options.
// messages into stdin instead of closing it after the initial prompt,
// keeping the turn open so the daemon can stream further user messages
// mid-conversation. Paired with `--output-format stream-json` so the
// adapter parses structured events (see claude-stream.ts).
const args = ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'];
// `--include-partial-messages` lands richer streaming events but only
// exists in newer Claude Code builds. Older installs reject it with
+30 -128
View File
@@ -26,6 +26,7 @@ import {
shouldRenderCodexImagegenOverride,
} from './prompts/system.js';
import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js';
import { emittedRenderableQuestionForm } from './question-form-detect.js';
import { resolveProjectRoot } from './project-root.js';
import {
applyBakedPreviews,
@@ -489,7 +490,6 @@ import { registerMemoryRoutes } from './routes/memory.js';
import { registerStaticResourceRoutes } from './routes/static-resource.js';
import { registerRoutineRoutes, routineDbRowToContract } from './routes/routine.js';
import { installRouteRegistrationGuard } from './route-registration-guard.js';
import { submitToolResultToRunState } from './run-tool-results.js';
import { assertServerContextSatisfiesRoutes } from './route-context-contract.js';
import { configureConnectorCredentialStore, connectorService, ConnectorServiceError, FileConnectorCredentialStore } from './connectors/service.js';
import { composioConnectorProvider } from './connectors/composio.js';
@@ -2610,77 +2610,10 @@ async function hasGeneratedPluginArtifacts(projectRoot) {
}
}
// Canonical open tag is `<question-form>`; `<ask-question>` is an accepted
// alias models drift to. Mirrors the open-tag set in the web parser.
const QUESTION_FORM_OPEN_RE = /<(question-form|ask-question)\b[^>]*>/i;
// True when `body` is a renderable question-form body: JSON (optionally
// fenced) parsing to an object with a non-empty `questions` array. This is
// the minimal contract `tryParseForm` enforces in
// `apps/web/src/artifacts/question-form.ts`; a body that fails it is kept as
// raw prose by the UI (no form card renders).
function questionFormBodyIsRenderable(body) {
const trimmed = typeof body === 'string' ? body.trim() : '';
if (!trimmed) return false;
const stripped = trimmed
.replace(/^```(?:json)?\s*/i, '')
.replace(/```\s*$/i, '')
.trim();
let data;
try {
data = JSON.parse(stripped);
} catch {
return false;
}
if (!data || typeof data !== 'object') return false;
const questions = data.questions;
return Array.isArray(questions) && questions.some((q) => q && typeof q === 'object');
}
// Locate `closeTag` (case-insensitively) at or after `from`, returning an
// index in the ORIGINAL `text` coordinate space. Mirrors the web parser's
// `findCloseTag`: scanning char-by-char and lowercasing each fixed-length
// candidate slice keeps the result aligned with `openEnd`. Lowercasing the
// whole string up front instead would desync the index, because some code
// points expand under `toLowerCase()` (e.g. `"İ" -> "i̇"`) and shift every
// offset after them — corrupting the body slice and failing a valid form.
function findQuestionFormCloseTag(text, from, closeTag) {
const closeLower = closeTag.toLowerCase();
const tagLen = closeTag.length;
const maxStart = text.length - tagLen;
for (let i = from; i <= maxStart; i++) {
if (text.slice(i, i + tagLen).toLowerCase() === closeLower) return i;
}
return -1;
}
// Whether the agent's visible text contains a *renderable* clarifying form —
// a closed `<question-form>`/`<ask-question>` block whose body satisfies the
// parser contract above. The plugin-authoring missing-artifacts guard treats
// this as a legitimate "paused to ask the user" turn rather than a failure.
//
// We deliberately mirror (not import) the web parser: the app boundary
// forbids `apps/daemon` importing `apps/web/src`. Matching only the open tag
// would let a malformed, non-renderable body suppress the failure — a false
// success with no usable brief card. Keep this in sync with
// `apps/web/src/artifacts/question-form.ts`, or promote a shared parser into
// `packages/contracts` if the two drift.
function emittedRenderableQuestionForm(text) {
if (typeof text !== 'string' || !text) return false;
let cursor = 0;
while (cursor < text.length) {
const m = QUESTION_FORM_OPEN_RE.exec(text.slice(cursor));
if (!m) return false;
const tagName = (m[1] ?? 'question-form').toLowerCase();
const closeTag = `</${tagName}>`;
const openEnd = cursor + m.index + m[0].length;
const closeIdx = findQuestionFormCloseTag(text, openEnd, closeTag);
if (closeIdx === -1) return false;
if (questionFormBodyIsRenderable(text.slice(openEnd, closeIdx))) return true;
cursor = closeIdx + closeTag.length;
}
return false;
}
// Renderable `<question-form>`/`<ask-question>` detection now lives in
// `./question-form-detect.ts` so the missing-artifacts guard, awaiting-input
// status, and run analytics all share ONE renderable-form check. See
// `emittedRenderableQuestionForm` imported above.
function assistantMessageEmittedQuestionForm(db, assistantMessageId) {
if (!assistantMessageId) return false;
@@ -4577,7 +4510,6 @@ export function classifyChatRunCloseStatus(params: {
type ClaudeStreamJsonBookkeepingRun = {
stdinOpen?: boolean;
pendingHostAnswers?: Set<string>;
turnCompletedCleanly?: boolean;
child?: {
stdin?: {
@@ -4587,6 +4519,11 @@ type ClaudeStreamJsonBookkeepingRun = {
} | null;
};
// Stream-json input mode keeps the child's stdin open across the turn so the
// daemon can stream further user messages mid-conversation. The child has no
// other way to know the turn is over, though — without an EOF it idles until
// the inactivity watchdog kills it. So when a turn terminates cleanly we close
// stdin to let the child exit.
export function applyClaudeStreamJsonRunBookkeeping(
run: ClaudeStreamJsonBookkeepingRun,
ev: unknown,
@@ -4594,34 +4531,18 @@ export function applyClaudeStreamJsonRunBookkeeping(
if (!ev || typeof ev !== 'object') return;
const event = ev as {
type?: unknown;
name?: unknown;
id?: unknown;
stopReason?: unknown;
};
if (
run.stdinOpen &&
event.type === 'tool_use' &&
(event.name === 'AskUserQuestion' || event.name === 'ask_user_question') &&
typeof event.id === 'string'
) {
if (!run.pendingHostAnswers) run.pendingHostAnswers = new Set();
run.pendingHostAnswers.add(event.id);
return;
}
const cleanTerminalTurn =
((event.type === 'turn_end' &&
// `stop_reason: tool_use` means the model paused to wait for tool
// execution (claude-code is about to run an internal tool, or we owe a
// host tool_result). Either way the conversation is still in flight.
event.stopReason !== 'tool_use') ||
event.type === 'usage') &&
(!run.pendingHostAnswers || run.pendingHostAnswers.size === 0);
// `stop_reason: tool_use` means the model paused to wait for tool
// execution (claude-code is about to run an internal tool). The
// conversation is still in flight, so don't close stdin yet.
(event.type === 'turn_end' && event.stopReason !== 'tool_use') ||
event.type === 'usage';
if (!cleanTerminalTurn) return;
// Record clean completion even if stdin was already closed by the
// host-answer path. The close-status classifier reads this to ignore late
// Record clean completion so the close-status classifier ignores late
// SessionEnd hook failures after the final assistant turn completed.
run.turnCompletedCleanly = true;
if (run.stdinOpen) {
@@ -11924,7 +11845,6 @@ export async function startServer({
run.error = null;
run.errorCode = null;
run.stdinOpen = false;
run.pendingHostAnswers?.clear?.();
run.analyticsTelemetry = {
startRequestedAt: run.analyticsTelemetry?.startRequestedAt ?? run.createdAt,
};
@@ -13430,12 +13350,10 @@ export async function startServer({
abortForRoleMarker(typeof m === 'string' ? m : 'role marker');
}
// Stream-json input mode keeps the child's stdin open across the
// turn so we can answer interactive tools like `AskUserQuestion`
// with a real `tool_result`. The child has no other way to know
// the conversation is over, though — without an EOF it sits idle
// until the inactivity watchdog kills it. Bookkeeping here:
// - tool_use(AskUserQuestion): record the id so we know we owe
// the model a tool_result before the turn can end.
// turn so the daemon can stream further user messages mid-turn. The
// child has no other way to know the turn is over, though — without
// an EOF it sits idle until the inactivity watchdog kills it.
// Bookkeeping here closes stdin on a clean terminal turn:
// - turn_end (per-turn synthesized from `stop_reason`): fire on
// `end_turn` etc. but NOT on `tool_use` — that stop reason
// means the model paused mid-tool, not "turn complete".
@@ -14001,11 +13919,10 @@ export async function startServer({
if (promptInputFormat === 'stream-json') {
// Wrap the prompt as an Anthropic user message and write it as one
// JSONL line. Do NOT close stdin: claude-code keeps reading further
// messages until EOF, which is what lets us inject a `tool_result`
// block later when the user answers an `AskUserQuestion` card. The
// stdin is closed implicitly when the child exits (run terminates,
// user cancels, or the model finishes without an outstanding tool
// call).
// messages until EOF, which is what lets the daemon stream more user
// messages into the same turn. The stdin is closed on a clean terminal
// turn (see applyClaudeStreamJsonRunBookkeeping) or when the child
// exits (run terminates, user cancels).
const userMessage = JSON.stringify({
type: 'user',
message: {
@@ -14028,21 +13945,6 @@ export async function startServer({
}
};
// Send a `tool_result` content block into a still-running stream-json
// child. Used for interactive tools that the host answers (currently:
// Claude's `AskUserQuestion`). The run must still be active and its
// stdin must still be open — we never re-spawn a closed child.
const submitToolResultToRun = (runId, toolUseId, content, isError = false) => {
const run = design.runs.get(runId);
if (!run) return { ok: false, reason: 'not_found' };
return submitToolResultToRunState(run, {
content,
isError,
isTerminal: design.runs.isTerminal(run.status),
toolUseId,
});
};
orbitService.setRunHandler(async ({
trigger,
startedAt,
@@ -14831,10 +14733,10 @@ export async function startServer({
// for the dedup semantics; tested in
// `tests/run-artifacts.test.ts`.
artifact_count: artifactCount,
// True when the run raised an AskUserQuestion clarification
// card. Clarification turns inherently produce no artifact, so
// the dashboard excludes them from the "run finished -> has
// artifact" funnel instead of counting them as failures. See
// True when the run raised a `<question-form>` clarification.
// Clarification turns inherently produce no artifact, so the
// dashboard excludes them from the "run finished -> has artifact"
// funnel instead of counting them as failures. See
// `run-artifacts.ts`; tested in `tests/run-artifacts.test.ts`.
asked_user_question: runAskedUserQuestion(run.events),
retry_attempt_count: run.retryAttemptCount ?? 0,
@@ -15416,7 +15318,7 @@ export async function startServer({
validation: validationDeps,
finalize: finalizeDeps,
handoff: handoffDeps,
chat: { startChatRun, submitToolResultToRun },
chat: { startChatRun },
agents: agentDeps,
critique: critiqueDeps,
lifecycle: { isDaemonShuttingDown: () => daemonShuttingDown },
@@ -15441,7 +15343,7 @@ export async function startServer({
design,
http: httpDeps,
paths: pathDeps,
chat: { startChatRun, submitToolResultToRun },
chat: { startChatRun },
agents: agentDeps,
critique: critiqueDeps,
validation: validationDeps,
@@ -483,10 +483,9 @@ describe('classifyChatRunCloseStatus (#1451 close-handler classification)', () =
});
describe('applyClaudeStreamJsonRunBookkeeping', () => {
it('records clean completion when the host-answer path already closed stdin', () => {
it('records clean completion without re-ending an already-closed stdin', () => {
const run = {
stdinOpen: false,
pendingHostAnswers: new Set<string>(),
turnCompletedCleanly: false,
child: {
stdin: {
@@ -505,10 +504,9 @@ describe('applyClaudeStreamJsonRunBookkeeping', () => {
expect(run.child.stdin.end).not.toHaveBeenCalled();
});
it('keeps waiting when a terminal event arrives with host answers still pending', () => {
it('closes stdin and records clean completion on a non-tool_use terminal turn', () => {
const run = {
stdinOpen: true,
pendingHostAnswers: new Set(['toolu_1']),
turnCompletedCleanly: false,
child: {
stdin: {
@@ -523,6 +521,28 @@ describe('applyClaudeStreamJsonRunBookkeeping', () => {
stopReason: 'end_turn',
});
expect(run.turnCompletedCleanly).toBe(true);
expect(run.stdinOpen).toBe(false);
expect(run.child.stdin.end).toHaveBeenCalled();
});
it('keeps stdin open when the turn ended on a tool_use stop reason', () => {
const run = {
stdinOpen: true,
turnCompletedCleanly: false,
child: {
stdin: {
destroyed: false,
end: vi.fn(),
},
},
};
applyClaudeStreamJsonRunBookkeeping(run, {
type: 'turn_end',
stopReason: 'tool_use',
});
expect(run.turnCompletedCleanly).toBe(false);
expect(run.stdinOpen).toBe(true);
expect(run.child.stdin.end).not.toHaveBeenCalled();
@@ -1,121 +0,0 @@
import express from 'express';
import http from 'node:http';
import { afterEach, describe, expect, it } from 'vitest';
import { registerChatRoutes } from '../src/chat-routes.js';
let server: http.Server | undefined;
afterEach(async () => {
await new Promise<void>((resolve, reject) => {
if (!server) return resolve();
server.close((error?: Error) => (error ? reject(error) : resolve()));
});
server = undefined;
});
function baseDeps(calls: unknown[][] = []) {
return {
agents: {
getAgentDef: () => undefined,
isKnownModel: () => false,
listProviderModels: () => [],
sanitizeCustomModel: (model: unknown) => model,
testAgentConnection: async () => ({ ok: true }),
testProviderConnection: async () => ({ ok: true }),
},
chat: {
submitToolResultToRun: (...args: unknown[]) => {
calls.push(args);
return { ok: true };
},
},
critique: {
critiqueArtifactsRoot: '',
critiqueResponseCapBytes: 0,
critiqueRunRegistry: new Map(),
handleCritiqueArtifact: () => (_req: express.Request, res: express.Response) =>
res.status(501).end(),
handleCritiqueInterrupt: () => (_req: express.Request, res: express.Response) =>
res.status(501).end(),
},
db: {},
design: {
runs: {
cancel: () => undefined,
get: () => null,
list: () => [],
statusBody: (run: unknown) => run,
stream: () => undefined,
},
},
http: {
createSseResponse: () => undefined,
sendApiError: (
res: express.Response,
status: number,
code: string,
message: string,
extras: Record<string, unknown> = {},
) => res.status(status).json({ error: { code, message, ...extras } }),
},
lifecycle: {},
paths: {},
telemetry: {},
validation: {},
};
}
async function startChatRouteServer(calls: unknown[][] = []): Promise<string> {
const app = express();
app.use(express.json());
registerChatRoutes(app, baseDeps(calls) as any);
server = app.listen(0);
await new Promise<void>((resolve) => server?.once('listening', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('unexpected listen address');
return `http://127.0.0.1:${address.port}`;
}
describe('chat tool-result route', () => {
it('rejects a body runId that disagrees with the path run id before submission', async () => {
const calls: unknown[][] = [];
const baseUrl = await startChatRouteServer(calls);
const response = await fetch(`${baseUrl}/api/runs/run-a/tool-result`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: 'answer',
runId: 'run-b',
toolUseId: 'tool-1',
}),
});
const body = await response.json() as { error: { code: string; message: string } };
expect(response.status).toBe(400);
expect(body.error).toMatchObject({
code: 'BAD_REQUEST',
message: 'runId must match the path run id',
});
expect(calls).toEqual([]);
});
it('uses the path run id as the authoritative run id', async () => {
const calls: unknown[][] = [];
const baseUrl = await startChatRouteServer(calls);
const response = await fetch(`${baseUrl}/api/runs/run-a/tool-result`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: 'answer',
runId: 'run-a',
toolUseId: 'tool-1',
}),
});
expect(response.status).toBe(200);
expect(calls).toEqual([['run-a', 'tool-1', 'answer', false]]);
});
});
+11
View File
@@ -77,6 +77,17 @@ test('unanswered structured question marks project as awaiting input', () => {
assert.deepEqual([...listProjectsAwaitingInput(db)], ['project-a']);
});
test('ask-question alias of question-form also marks project as awaiting input', () => {
const db = createDb();
const conversationId = seedProject(db, 'project-a-alias');
// <ask-question> is an accepted alias for <question-form>; an alias-form
// turn must mark the project awaiting input just like the canonical tag.
addMessage(db, conversationId, 'assistant-question', 'assistant', 'Need one choice\n<ask-question id="q1">');
assert.deepEqual([...listProjectsAwaitingInput(db)], ['project-a-alias']);
});
test('user reply after structured question clears awaiting input', () => {
const db = createDb();
const conversationId = seedProject(db, 'project-b');
+4 -4
View File
@@ -199,21 +199,21 @@ describe('prompt telemetry builder', () => {
it('preserves semantic slash-prefixed prompt tokens', () => {
const telemetry = buildPromptStackTelemetry({
composedPrompt:
'POST /api/runs/:id/tool-result, match /foo/bar, cwd /app/project',
'POST /api/runs/:id/cancel, match /foo/bar, cwd /app/project',
sections: [
{
kind: 'daemonSystemPrompt',
content:
'POST /api/runs/:id/tool-result, match /foo/bar, cwd /app/project',
'POST /api/runs/:id/cancel, match /foo/bar, cwd /app/project',
},
],
});
expect(telemetry.sections[0]!.redactedContent).toBe(
`POST /api/runs/:id/tool-result, match /foo/bar, cwd ${PROMPT_STACK_PATH_MARKER}`,
`POST /api/runs/:id/cancel, match /foo/bar, cwd ${PROMPT_STACK_PATH_MARKER}`,
);
expect(telemetry.sections[0]!.redactedContent).toContain(
'/api/runs/:id/tool-result',
'/api/runs/:id/cancel',
);
expect(telemetry.sections[0]!.redactedContent).toContain('/foo/bar');
expect(telemetry.sections[0]!.redactedContent).not.toContain('/app/project');
+56 -9
View File
@@ -290,9 +290,7 @@ describe('countDesignSystemPreviewModules', () => {
});
});
// Helper: emit a bare tool_use (no result) for a named tool. Clarification
// detection only needs the tool_use to appear; AskUserQuestion is answered
// out-of-band via POST /api/runs/:id/tool-result, not a stream tool_result.
// Helper: emit a bare tool_use (no result) for a named tool.
function toolUse(name: string, id = freshId()) {
return [
{
@@ -302,17 +300,66 @@ function toolUse(name: string, id = freshId()) {
];
}
// A renderable question-form body — JSON with a non-empty `questions` array.
// `runAskedUserQuestion` requires a *closed, renderable* block, not a bare tag.
const RENDERABLE_BODY = '{"questions":[{"question":"Which framework?"}]}';
// Helper: emit assistant streamed text as a `text_delta` agent event. Note the
// real persisted shape carries the chunk on `delta` (not `text`) — see
// packages/contracts/src/sse/chat.ts. Building the wrong field here is exactly
// what made the old tests pass while production runs detected nothing.
function questionFormText(text = `Quick brief <question-form id="q">${RENDERABLE_BODY}</question-form>`) {
return [{ event: 'agent', data: { type: 'text_delta', delta: text } }];
}
describe('runAskedUserQuestion', () => {
it('returns false for an empty event list', () => {
expect(runAskedUserQuestion([])).toBe(false);
});
it('returns true when the run raised an AskUserQuestion card', () => {
expect(runAskedUserQuestion(toolUse('AskUserQuestion'))).toBe(true);
it('returns true when the run emitted a renderable <question-form> clarification', () => {
expect(runAskedUserQuestion(questionFormText())).toBe(true);
});
it('matches the snake_case ask_user_question proxy shape', () => {
expect(runAskedUserQuestion(toolUse('ask_user_question'))).toBe(true);
it('reads the `delta` field of text_delta events (the real persisted shape)', () => {
// Guards the production bug where the helper read `text` and so appended
// nothing for real runs, leaving run_finished.asked_user_question false.
expect(
runAskedUserQuestion([
{ event: 'agent', data: { type: 'text_delta', delta: `<question-form id="q">${RENDERABLE_BODY}</question-form>` } },
]),
).toBe(true);
});
it('reassembles a marker split across text_delta chunks', () => {
expect(
runAskedUserQuestion([
{ event: 'agent', data: { type: 'text_delta', delta: 'ask a <question-form id="q">{"questions":[' } },
{ event: 'agent', data: { type: 'text_delta', delta: '{"question":"X"}]}</question-form>' } },
]),
).toBe(true);
});
// `<ask-question>` is an accepted alias for `<question-form>` (whitelisted by
// the UI parser and the daemon open-tag matcher). A model that drifts to the
// alias still renders the clarification banner, so the analytics signal must
// recognize it too — otherwise the run gets misclassified in the funnel.
it('returns true for the renderable <ask-question> alias', () => {
expect(
runAskedUserQuestion([
{ event: 'agent', data: { type: 'text_delta', delta: `one quick check <ask-question id="q">${RENDERABLE_BODY}</ask-question>` } },
]),
).toBe(true);
});
it('returns false when the tag is only shown as literal markup (no renderable body)', () => {
// A doc/code sample that mentions the markup must NOT be misclassified as a
// clarification turn and excluded from the artifact funnel.
expect(
runAskedUserQuestion([
{ event: 'agent', data: { type: 'text_delta', delta: 'Use a `<question-form id="x">` block like this in your skill.' } },
]),
).toBe(false);
});
it('returns false for a run that only wrote artifacts', () => {
@@ -324,11 +371,11 @@ describe('runAskedUserQuestion', () => {
).toBe(false);
});
it('detects the card even when mixed with other tool calls', () => {
it('detects the form even when mixed with other tool calls', () => {
expect(
runAskedUserQuestion([
...pair('Write', '/proj/index.html'),
...toolUse('AskUserQuestion'),
...questionFormText(),
]),
).toBe(true);
});
-116
View File
@@ -1,116 +0,0 @@
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';
import { submitToolResultToRunState } from '../src/run-tool-results.js';
function makeRun(pendingToolUseIds: string[]) {
const stdin = new PassThrough();
const writes: string[] = [];
stdin.on('data', (chunk) => writes.push(String(chunk)));
const run = {
child: { stdin },
pendingHostAnswers: new Set(pendingToolUseIds),
stdinOpen: true,
};
return { run, stdin, writes };
}
describe('submitToolResultToRunState', () => {
it('rejects unknown toolUseId values before writing to child stdin', () => {
const { run, writes } = makeRun(['known-tool']);
const result = submitToolResultToRunState(run, {
content: 'answer',
isTerminal: false,
toolUseId: 'other-tool',
});
expect(result).toEqual({ ok: false, reason: 'bad_tool_use_id' });
expect(writes).toEqual([]);
expect(run.pendingHostAnswers.has('known-tool')).toBe(true);
expect(run.stdinOpen).toBe(true);
});
it('writes valid tool results and leaves stdin open after the final pending answer', () => {
const { run, stdin, writes } = makeRun(['tool-1']);
const result = submitToolResultToRunState(run, {
content: 'choice A',
isError: true,
isTerminal: false,
toolUseId: 'tool-1',
});
expect(result).toEqual({ ok: true });
expect(run.pendingHostAnswers.has('tool-1')).toBe(false);
expect(run.stdinOpen).toBe(true);
expect(stdin.destroyed || stdin.writableEnded).toBe(false);
expect(writes).toHaveLength(1);
const [write] = writes;
expect(JSON.parse(write!)).toMatchObject({
type: 'user',
message: {
role: 'user',
content: [
{
content: 'choice A',
is_error: true,
tool_use_id: 'tool-1',
type: 'tool_result',
},
],
},
});
});
it('keeps stdin open when another pending host answer remains', () => {
const { run, stdin } = makeRun(['tool-1', 'tool-2']);
const result = submitToolResultToRunState(run, {
content: 'first',
isTerminal: false,
toolUseId: 'tool-1',
});
expect(result).toEqual({ ok: true });
expect(run.pendingHostAnswers.has('tool-1')).toBe(false);
expect(run.pendingHostAnswers.has('tool-2')).toBe(true);
expect(run.stdinOpen).toBe(true);
expect(stdin.writableEnded).toBe(false);
});
it('rejects duplicate tool results after the first successful submission', () => {
const { run, writes } = makeRun(['tool-1', 'tool-2']);
expect(
submitToolResultToRunState(run, {
content: 'first',
isTerminal: false,
toolUseId: 'tool-1',
}),
).toEqual({ ok: true });
const duplicate = submitToolResultToRunState(run, {
content: 'second',
isTerminal: false,
toolUseId: 'tool-1',
});
expect(duplicate).toEqual({ ok: false, reason: 'bad_tool_use_id' });
expect(writes).toHaveLength(1);
expect(run.stdinOpen).toBe(true);
});
it('rejects terminal runs before touching stdin', () => {
const { run, writes } = makeRun(['tool-1']);
const result = submitToolResultToRunState(run, {
content: 'answer',
isTerminal: true,
toolUseId: 'tool-1',
});
expect(result).toEqual({ ok: false, reason: 'run_terminal' });
expect(writes).toEqual([]);
});
});
+1 -1
View File
@@ -370,7 +370,7 @@ function endsInsideJsonString(s: string): boolean {
// Unlike a complete-objects-only pass, the repaired prefix (see
// `parsePartialJson`) means a question shows the moment its `label` (prompt)
// text exists and its options grow in one at a time — true token-by-token
// streaming, matching the AskUserQuestion card. The trailing in-flight object
// streaming, matching the question-form card. The trailing in-flight object
// with no label yet is held back (no "q1" placeholder flicker); it appears
// once its label lands.
// Return a top-level (depth-1) string field's value ONLY if its string literal
+15 -131
View File
@@ -1,5 +1,5 @@
import { Fragment, memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ToolCard, StreamingAskUserQuestionCard, isAskUserQuestionName } from "./ToolCard";
import { ToolCard } from "./ToolCard";
import { FileOpsSummary } from "./FileOpsSummary";
import {
renderMarkdown,
@@ -7,7 +7,6 @@ import {
} from "../runtime/markdown";
import { asInProjectFilePath } from "../runtime/in-project-link";
import { projectFileUrl } from "../providers/registry";
import { submitChatRunToolResult } from "../providers/daemon";
import { useAnalytics } from "../analytics/provider";
import {
trackAssistantFeedbackButtonClick,
@@ -292,9 +291,6 @@ interface Props {
// Kept for ChatPane compatibility; chat-side question forms now always
// render as a compact Questions banner.
nextUserContent?: string;
// Submit handler the form fires when the user picks answers — opaque
// to AssistantMessage; ProjectView wires it into onSend.
onSubmitForm?: (text: string) => void;
// Open the right-hand Questions tab. The active discovery form renders
// there (Claude-Design style) instead of inline; this assistant message
// shows a banner that focuses the tab on click.
@@ -320,9 +316,8 @@ interface Props {
}
// Props compared by reference to decide whether a memoized AssistantMessage can
// skip re-rendering. The interaction callbacks (onSubmitForm,
// onContinueRemainingTasks, onForkFromMessage, onFeedback, and next-step
// actions) are DELIBERATELY
// skip re-rendering. The interaction callbacks (onContinueRemainingTasks,
// onForkFromMessage, onFeedback, and next-step actions) are DELIBERATELY
// excluded: ChatPane re-creates them per render, but routes them through a ref
// so their behavior is reference-stable — comparing them would defeat the memo
// on every streamed frame. `isLast` is compared, which captures the only state
@@ -406,7 +401,6 @@ function AssistantMessageImpl({
isLast,
errorCardOwnerId = null,
nextUserContent,
onSubmitForm,
onOpenQuestions,
onContinueRemainingTasks,
onForkFromMessage,
@@ -423,11 +417,6 @@ function AssistantMessageImpl({
}: Props) {
const t = useT();
const events = message.events ?? [];
// Claude sometimes hedges by emitting a markdown duplicate of the same
// questions alongside an `AskUserQuestion` tool call. The card already
// shows the questions + options, so suppressing the trailing prose
// avoids rendering the same content twice. The system prompt asks the
// model not to do this; this is the belt-and-suspenders.
// ChatPane renders the canonical TodoWrite card as a standalone chat row, so
// we strip TodoWrite tool-groups out of the per-message flow to avoid the
// same task list rendering twice.
@@ -435,19 +424,7 @@ function AssistantMessageImpl({
() => new Set(events.filter((e) => e.kind === "tool_use").map((e) => e.id)),
[events],
);
// The earliest still-streaming AskUserQuestion (no full `tool_use` yet),
// tagged with the event position the tool call started at so we can place
// its live card there — text before it is preamble, text after is hedging.
const liveAuq = useMemo(() => {
if (!streaming || !liveToolInput) return null;
const entries = Object.entries(liveToolInput)
.filter(([id, e]) => !settledUseIds.has(id) && isAskUserQuestionName(e.name))
.map(([id, e]) => ({ id, raw: e.text, seq: e.seq ?? events.length }))
.sort((a, b) => a.seq - b.seq);
return entries[0] ?? null;
}, [streaming, liveToolInput, settledUseIds, events.length]);
// Live code boxes (Write/Edit streaming) append after everything else; they
// aren't part of the fallback-suppression ordering.
// Live code boxes (Write/Edit streaming) append after everything else.
const liveCodeBlocks = useMemo<Block[]>(() => {
if (!streaming || !liveToolInput) return [];
const out: Block[] = [];
@@ -458,33 +435,17 @@ function AssistantMessageImpl({
}
return out;
}, [streaming, liveToolInput, settledUseIds]);
// Compose the block list with the live AskUserQuestion at its real stream
// position (split the events around `seq`), then run the strip/suppress
// pipeline once. Because the live AUQ sits between preamble and any hedging,
// `suppressAskUserQuestionFallbackText` keeps the preamble and drops the
// hedging — matching the settled-case semantics.
// Compose the block list, then run the strip/suppress pipeline once.
const blocks = useMemo(() => {
const rawBlocks = liveAuq
? (() => {
const n = Math.min(Math.max(liveAuq.seq, 0), events.length);
return [
...buildBlocks(events.slice(0, n)),
{ kind: "live-tool", id: liveAuq.id, name: "AskUserQuestion", raw: liveAuq.raw } as Block,
...buildBlocks(events.slice(n)),
...liveCodeBlocks,
];
})()
: [...buildBlocks(events), ...liveCodeBlocks];
const rawBlocks = [...buildBlocks(events), ...liveCodeBlocks];
return placeConversationTodoCard(
stripEmptyThinkingBlocks(
suppressDuplicateQuestionForms(suppressAskUserQuestionFallbackText(rawBlocks)),
),
stripEmptyThinkingBlocks(suppressDuplicateQuestionForms(rawBlocks)),
{
show: showConversationTodoCard,
input: conversationTodoInput,
},
);
}, [events, liveAuq, liveCodeBlocks, showConversationTodoCard, conversationTodoInput]);
}, [events, liveCodeBlocks, showConversationTodoCard, conversationTodoInput]);
const fileOps = useMemo(() => deriveFileOps(events), [events]);
const produced = message.producedFiles ?? [];
const displayedProduced = useMemo(
@@ -625,22 +586,6 @@ function AssistantMessageImpl({
// start so switching project tabs or remounting the message cannot restart it.
const hasContent = blocks.some((b) => b.kind !== "status") || fileOps.length > 0;
const preparing = streaming && !hasContent;
// Route interactive tool answers (currently AskUserQuestion) back to the
// still-open stream-json child via the daemon. We resolve to `true` on
// success so the card can flip into its answered state; on `false` (run
// already terminated, stdin closed, etc.) the card falls back to the
// plain-text `onSubmitForm` path so the user is never stuck. Only wire
// this when we have an active run id and the message is the latest turn
// — older messages whose run is gone use the fallback exclusively.
const onAnswerToolUse = useCallback(
async (toolUseId: string, content: string) => {
if (!isLast) return false;
if (!message.runId) return false;
const resp = await submitChatRunToolResult(message.runId, toolUseId, content);
return resp.ok;
},
[isLast, message.runId],
);
// Index of the trailing text block — the streaming caret rides the end of
// the last prose block so it tracks the final character as tokens arrive.
@@ -706,16 +651,10 @@ function AssistantMessageImpl({
runSucceeded={runSucceeded}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
isLast={!!isLast}
onSubmitForm={onSubmitForm}
onAnswerToolUse={onAnswerToolUse}
/>
);
}
if (b.kind === "live-tool") {
if (isAskUserQuestionName(b.name)) {
return <StreamingAskUserQuestionCard key={b.id} raw={b.raw} />;
}
return <LiveCodeBox key={b.id} name={b.name} raw={b.raw} />;
}
if (b.kind === "plugin-candidate") {
@@ -2300,12 +2239,8 @@ interface ToolItem {
// - TodoWrite / todowrite: the input replaces the previous list, so the
// latest call is the only one worth showing; older identical or
// superseded snapshots are pure duplication.
// - AskUserQuestion / ask_user_question: claude-code -p auto-errors the
// tool and the model retries with identical input until it gives up.
// Other tool names pass through untouched.
const SNAPSHOT_TOOL_NAMES = new Set([
"AskUserQuestion",
"ask_user_question",
"TodoWrite",
"todowrite",
"todo_write",
@@ -2317,11 +2252,9 @@ function dedupeSnapshotToolRetries(items: ToolItem[]): ToolItem[] {
const allSnapshot = items.every((it) => SNAPSHOT_TOOL_NAMES.has(it.use.name));
if (!allSnapshot) return items;
// For TodoWrite specifically, the LATEST call always wins regardless of
// input — it is a state replace, not an append. For AskUserQuestion we
// group by input so a sequence of distinct questions (rare) renders as
// distinct cards. The cheap unifying behavior: keep the last item per
// `(name, JSON.stringify(input))` key; for TodoWrite a single name+input
// is the snapshot identity, for AskUserQuestion it's the question text.
// input — it is a state replace, not an append. The cheap unifying
// behavior: keep the last item per `(name, JSON.stringify(input))` key;
// for TodoWrite a single name+input is the snapshot identity.
const lastByKey = new Map<string, ToolItem>();
for (const it of items) {
let key: string;
@@ -2483,29 +2416,20 @@ function ToolGroupCard({
runSucceeded,
projectFileNames,
onRequestOpenFile,
isLast,
onSubmitForm,
onAnswerToolUse,
}: {
items: ToolItem[];
runStreaming: boolean;
runSucceeded: boolean;
projectFileNames?: Set<string>;
onRequestOpenFile?: (name: string) => void;
isLast?: boolean;
onSubmitForm?: (text: string) => void;
onAnswerToolUse?: (toolUseId: string, content: string) => Promise<boolean> | boolean;
}) {
const t = useT();
const [open, setOpen] = useState(false);
// `claude-code -p` (headless) auto errors `AskUserQuestion` because it
// cannot prompt the user, so the model retries the call up to ~4 times
// within a single turn. Each retry produces an identical tool_use event,
// which used to render as a stack of duplicate question cards. Collapse
// consecutive AskUserQuestion uses with identical input to the LAST one
// (it has the most up to date tool_use id, which is what we route the
// answer against if a backend tool_result wire is added later).
// Snapshot-style tools (TodoWrite and friends) replace their whole state on
// each call, so a turn that wrote the list several times would otherwise
// render a stack of superseded cards. Collapse those retries to the latest
// snapshot; every other tool passes through untouched.
items = dedupeSnapshotToolRetries(items);
// A run of one tool collapses to that tool's card directly so we don't
@@ -2519,9 +2443,6 @@ function ToolGroupCard({
runSucceeded={runSucceeded}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
isLast={isLast}
onSubmitForm={onSubmitForm}
onAnswerToolUse={onAnswerToolUse}
/>
);
}
@@ -2564,9 +2485,6 @@ function ToolGroupCard({
runSucceeded={runSucceeded}
projectFileNames={projectFileNames}
onRequestOpenFile={onRequestOpenFile}
isLast={isLast}
onSubmitForm={onSubmitForm}
onAnswerToolUse={onAnswerToolUse}
/>
))}
</div>
@@ -2744,40 +2662,6 @@ function suppressDuplicateQuestionForms(blocks: Block[]): Block[] {
});
}
// Hide text blocks that follow an `AskUserQuestion` tool use in the same
// assistant message. Claude tends to also write the same questions as
// markdown text alongside the tool call. The card already shows the
// content; the prose is hedge that duplicates and confuses the user.
function suppressAskUserQuestionFallbackText(blocks: Block[]): Block[] {
let seenAskUserQuestion = false;
const filtered: Block[] = [];
for (const block of blocks) {
if (block.kind === "tool-group") {
const hasAuq = block.items.some(
(it) =>
it.use.name === "AskUserQuestion" ||
it.use.name === "ask_user_question",
);
if (hasAuq) seenAskUserQuestion = true;
filtered.push(block);
continue;
}
// A still-streaming AskUserQuestion (live block, no persisted tool_use yet)
// counts the same as a settled one: hedging text after it is suppressed,
// preamble before it is kept.
if (block.kind === "live-tool" && isAskUserQuestionName(block.name)) {
seenAskUserQuestion = true;
filtered.push(block);
continue;
}
if (seenAskUserQuestion && block.kind === "text") {
continue;
}
filtered.push(block);
}
return filtered;
}
function buildBlocks(events: AgentEvent[]): Block[] {
const out: Block[] = [];
const resultByToolId = new Map<
-17
View File
@@ -484,9 +484,6 @@ interface Props {
// time before the full `tool_use` arrives. Never persisted.
liveToolInput?: Record<string, { name: string; text: string; seq?: number }>;
initialDraft?: string;
// Question-form submissions become a normal user message; the parent
// routes that text through onSend (no attachments).
onSubmitForm?: (text: string) => void;
// Focus the right-hand Questions tab from the chat banner.
onOpenQuestions?: (request?: QuestionFormOpenRequest) => void;
onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void;
@@ -685,7 +682,6 @@ export function ChatPane({
forceStreamingMessageIds,
liveToolInput,
initialDraft,
onSubmitForm,
onOpenQuestions,
onContinueRemainingTasks,
onAssistantFeedback,
@@ -785,7 +781,6 @@ export function ChatPane({
// message). Route them through this ref so a memoized message still calls the
// LATEST handler. See areAssistantMessagePropsEqual in AssistantMessage.tsx.
const assistantCallbacksRef = useRef({
onSubmitForm,
onContinueRemainingTasks,
onAssistantFeedback,
onArtifactShare,
@@ -793,7 +788,6 @@ export function ChatPane({
onShareToOpenDesign,
});
assistantCallbacksRef.current = {
onSubmitForm,
onContinueRemainingTasks,
onAssistantFeedback,
onArtifactShare,
@@ -1959,10 +1953,6 @@ export function ChatPane({
onAssistantFeedback={onAssistantFeedback}
forkingMessageId={forkingMessageId}
t={t}
onAssistantFormSubmitStart={() => {
pinnedToBottomRef.current = true;
scrolledToFormRef.current = new Set();
}}
onOpenQuestions={onOpenQuestions}
scrollContainerRef={logRef}
/>
@@ -2186,7 +2176,6 @@ export function ChatPane({
}
interface AssistantCallbacks {
onSubmitForm: ((text: string) => void) | undefined;
onContinueRemainingTasks:
| ((assistantMessage: ChatMessage, todos: TodoItem[]) => void)
| undefined;
@@ -2260,7 +2249,6 @@ function ChatRows({
onAssistantFeedback,
forkingMessageId,
t,
onAssistantFormSubmitStart,
onOpenQuestions,
scrollContainerRef,
}: {
@@ -2301,7 +2289,6 @@ function ChatRows({
onAssistantFeedback?: (message: ChatMessage, change: ChatMessageFeedbackChange) => void;
forkingMessageId?: string | null;
t: TranslateFn;
onAssistantFormSubmitStart: () => void;
onOpenQuestions?: (request?: QuestionFormOpenRequest) => void;
scrollContainerRef: MutableRefObject<HTMLDivElement | null>;
}) {
@@ -2392,10 +2379,6 @@ function ChatRows({
nextUserContent={nextUserContentByAssistantId.get(m.id)}
suppressDirectionForms={hasActiveDesignSystem}
hasDesignSystemContext={hasActiveDesignSystem || !!activeDesignSystem}
onSubmitForm={(text) => {
onAssistantFormSubmitStart();
assistantCallbacksRef.current.onSubmitForm?.(text);
}}
onOpenQuestions={onOpenQuestions}
onContinueRemainingTasks={
m.id === lastAssistantId && onContinueRemainingTasks
+1 -1
View File
@@ -4030,7 +4030,7 @@ function buildCreationAgentPrompt(
'',
'Autonomy requirement:',
'- Do not ask setup or clarification questions during design-system generation.',
'- Do not emit `<question-form>`, "Quick brief — 30 seconds", `AskUserQuestion`, direction cards, choice cards, or any UI that waits for user input.',
'- Do not emit `<question-form>`, "Quick brief — 30 seconds", direction cards, choice cards, or any UI that waits for user input.',
'- The setup page already collected the brief. If target surfaces, review priority, or workspace depth are missing, choose sensible defaults and begin generating the design-system artifacts immediately.',
'',
'Project boundary:',
+5 -5
View File
@@ -1454,7 +1454,7 @@ export function HomeView({
const submittedApplyInputs = submittedActive ? submittedActive.inputs : defaultInputs;
// Inputs forwarded to the run AND used to build the run-facing snapshot:
// drop every now-hidden footer/media setting so the first-turn
// AskUserQuestion flow collects them instead of inheriting a baked-in
// question-form flow collects them instead of inheriting a baked-in
// default (`ratio: 16:9`, `duration: 5`, `audioType: speech`, …). The
// snapshot is resolved from these stripped inputs too — the daemon renders
// `## Plugin inputs` from `snapshot.inputs` and tells the agent not to
@@ -1872,7 +1872,7 @@ function homeHeroChipLabelForId(chipId: string, t: ReturnType<typeof useI18n>['t
// the design-system picker. Media surfaces (image/video/audio/hyperframes)
// now defer the same way: image/video keep only the design-system picker and
// audio/hyperframes keep nothing, with model / ratio / resolution / duration /
// audio type collected by the agent via AskUserQuestion during the run instead
// audio type collected by the agent via question-form during the run instead
// of inline pre-flight controls.
const ARTIFACT_FOOTER_FIELD_NAMES = new Set([
'fidelity',
@@ -1882,7 +1882,7 @@ const ARTIFACT_FOOTER_FIELD_NAMES = new Set([
// were dropped from the footer but `buildHomeMediaComposer` still seeds them
// (`model: gpt-image-2`, `ratio: 16:9`, `duration: 5`, `audioType: speech`,
// …) so they must be stripped before submission — otherwise the run arrives
// with baked-in defaults and the first-turn AskUserQuestion flow has nothing
// with baked-in defaults and the first-turn question-form flow has nothing
// left to ask. `subject` / `style` / `aspect` / `mediaKind` are intentionally
// NOT listed: the od-media-generation apply still validates against them.
'model',
@@ -1915,7 +1915,7 @@ function footerInputNamesForChip(chipId: string | null): string[] {
if (chipId === 'prototype' || chipId === 'deck') return ['designSystem'];
if (chipId === 'image' || chipId === 'video') return ['designSystem'];
// hyperframes / audio surface no pre-flight settings — the agent asks for
// ratio / duration / model / audio kind via AskUserQuestion during the run.
// ratio / duration / model / audio kind via question-form during the run.
return [];
}
@@ -1929,7 +1929,7 @@ function homeCreateProjectMetadata(
// Artifact-specific settings (fidelity, speaker notes, slide count, …) are no
// longer collected in the home composer; the agent asks for them via
// AskUserQuestion, so we only seed `kind` here and let those fields stay
// question-form, so we only seed `kind` here and let those fields stay
// unset (the system prompt then marks them "unknown — ask").
const next: ProjectMetadata = {
...(existing ?? {}),
+1 -1
View File
@@ -200,7 +200,7 @@ export const PluginsSection = forwardRef<PluginsSectionHandle, Props>(
plugin. Inputs fall back to their schema `default` values (seeded
in handleApplied), so the brief still renders fully. When a plugin
genuinely needs a user decision, that should surface as an
AskUserQuestion card in the chat stream, not as composer chrome.
question-form card in the chat stream, not as composer chrome.
*/}
</div>
) : null}
-8
View File
@@ -4823,10 +4823,6 @@ export function ProjectView({
onSend: handleSend,
onRetry: handleRetry,
onStop: handleStop,
onSubmitForm: (text: string) => {
if (currentConversationActionDisabled) return;
void handleSend(text, [], []);
},
onRemoveQueuedSend: removeQueuedChatSend,
onUpdateQueuedSend: updateQueuedChatSend,
onReorderQueuedSends: reorderCurrentConversationQueuedChatSends,
@@ -5615,10 +5611,6 @@ export function ProjectView({
shareToOpenDesignBusyMessageId={shareToOpenDesignBusyMessageId}
forceStreamingMessageIds={forceStreamingPluginMessageIds}
initialDraft={chatInitialDraft}
onSubmitForm={(text) => {
if (currentConversationActionDisabled) return;
void handleSend(text, [], []);
}}
onOpenQuestions={openQuestionsTab}
onContinueRemainingTasks={handleContinueRemainingTasks}
onAssistantFeedback={handleAssistantFeedback}
+111 -327
View File
@@ -12,17 +12,9 @@ import { useState } from 'react';
import { useT } from '../i18n';
import { isTodoWriteToolName, parseTodoWriteInput } from '../runtime/todos';
import { getToolRenderer, toRenderProps } from '../runtime/tool-renderers';
import {
parseAskUserQuestionInput,
parsePartialAskUserQuestion,
} from '../runtime/ask-user-question';
import type { AgentEvent } from '../types';
import { Icon } from './Icon';
export function isAskUserQuestionName(name: string): boolean {
return name === 'AskUserQuestion' || name === 'ask_user_question';
}
interface Props {
use: Extract<AgentEvent, { kind: 'tool_use' }>;
result?: Extract<AgentEvent, { kind: 'tool_result' }> | undefined;
@@ -40,21 +32,6 @@ interface Props {
// Lifts a basename up to ProjectView so it can focus the matching tab
// in FileWorkspace.
onRequestOpenFile?: (name: string) => void;
// True only for tool calls inside the most recent assistant message.
// Older AskUserQuestion cards lock to a read-only summary so the user
// cannot re-answer a stale question.
isLast?: boolean;
// Submit handler for interactive tool cards (currently AskUserQuestion).
// Opaque to ToolCard. ChatPane / ProjectView wire it into the normal
// chat send pipeline so the answer flows back to the model as the next
// user message. Used as a fallback when no live `onAnswerToolUse` route
// is available.
onSubmitForm?: (text: string) => void;
// When set, AskUserQuestion cards route the user's pick directly back to
// the still-open stream-json child via /api/runs/:id/tool-result instead
// of sending it as a fresh user message. AssistantMessage derives it
// from `message.runId` so each assistant turn targets its own run.
onAnswerToolUse?: (toolUseId: string, content: string) => Promise<boolean> | boolean;
}
export function ToolCard({
@@ -64,9 +41,6 @@ export function ToolCard({
runSucceeded,
projectFileNames,
onRequestOpenFile,
isLast,
onSubmitForm,
onAnswerToolUse,
}: Props) {
const name = use.name;
const isStreaming = runStreaming ?? false;
@@ -85,19 +59,6 @@ export function ToolCard({
}
}
const ctx: FileToolCtx = { projectFileNames, onRequestOpenFile };
if (isAskUserQuestionName(name))
return (
<AskUserQuestionCard
toolUseId={use.id}
input={use.input}
result={result}
runStreaming={isStreaming}
runSucceeded={isSucceeded}
isLast={isLast ?? false}
onSubmitForm={onSubmitForm}
onAnswerToolUse={onAnswerToolUse}
/>
);
if (isTodoWriteToolName(name)) return <TodoCard input={use.input} runStreaming={isStreaming} runSucceeded={isSucceeded} />;
if (name === 'Write' || name === 'write' || name === 'create_file')
return <FileWriteCard input={use.input} result={result} runStreaming={isStreaming} runSucceeded={isSucceeded} ctx={ctx} />;
@@ -110,9 +71,120 @@ export function ToolCard({
if (name === 'Grep') return <GrepCard input={use.input} result={result} runStreaming={isStreaming} runSucceeded={isSucceeded} />;
if (name === 'WebFetch' || name === 'web_fetch') return <WebFetchCard input={use.input} result={result} runStreaming={isStreaming} runSucceeded={isSucceeded} />;
if (name === 'WebSearch' || name === 'web_search') return <WebSearchCard input={use.input} result={result} runStreaming={isStreaming} runSucceeded={isSucceeded} />;
if (isAskUserQuestionName(name))
return <LegacyAskUserQuestionCard input={use.input} result={result} runStreaming={isStreaming} runSucceeded={isSucceeded} />;
return <GenericCard name={name} input={use.input} result={result} runStreaming={isStreaming} runSucceeded={isSucceeded} />;
}
// The interactive `AskUserQuestion` mechanism was retired in favor of the
// unified `<question-form>` flow, but chat history survives app upgrades, so
// existing projects still carry persisted `AskUserQuestion` tool_use events.
// Without a dedicated renderer they fall through to GenericCard and surface
// the raw `{"questions":[...]}` JSON payload under an "AskUserQuestion" title.
// This read-only card renders those historical turns as an inert question
// summary — no submit path, no interactive answer route — so old history stays
// legible after the cleanup.
export function isAskUserQuestionName(name: string): boolean {
return name === 'AskUserQuestion' || name === 'ask_user_question';
}
type LegacyAuqQuestion = { question: string; header?: string; options: string[] };
// Minimal, defensive parse of the legacy AUQ input shape
// `{ questions: [{ question, header, options: [{ label }] | [string] }] }`.
// Kept inline (the dedicated parser module was deleted with the mechanism);
// this only needs enough to render a read-only summary.
function parseLegacyAskUserQuestion(input: unknown): LegacyAuqQuestion[] {
const obj = (input ?? {}) as { questions?: unknown };
if (!Array.isArray(obj.questions)) return [];
const out: LegacyAuqQuestion[] = [];
for (const raw of obj.questions) {
if (!raw || typeof raw !== 'object') continue;
const q = raw as Record<string, unknown>;
const question = typeof q.question === 'string' ? q.question : '';
if (!question) continue;
const header = typeof q.header === 'string' && q.header.trim() ? q.header.trim() : undefined;
const options: string[] = [];
if (Array.isArray(q.options)) {
for (const opt of q.options) {
if (typeof opt === 'string') {
if (opt) options.push(opt);
continue;
}
if (opt && typeof opt === 'object' && typeof (opt as { label?: unknown }).label === 'string') {
const label = (opt as { label: string }).label;
if (label) options.push(label);
}
}
}
out.push(header ? { question, header, options } : { question, options });
}
return out;
}
// Recover the user's persisted answer from a completed AUQ `tool_result`. The
// retired interactive card serialized answers as one `${question}\n${answer}`
// block per question, blocks joined by a blank line, with multi-select answers
// written as `- option` bullet lines. Surfacing it keeps old conversations
// auditable — two different answers no longer render identically.
function parseLegacyAuqAnswer(content: string | undefined): Map<string, string> {
const map = new Map<string, string>();
if (!content) return map;
for (const block of content.split('\n\n')) {
const nl = block.indexOf('\n');
if (nl === -1) continue;
const q = block.slice(0, nl).trim();
const a = block
.slice(nl + 1)
.split('\n')
.map((s) => s.replace(/^- /, '').trim())
.filter(Boolean)
.join(', ');
if (q && a) map.set(q, a);
}
return map;
}
function LegacyAskUserQuestionCard({
input,
result,
runStreaming,
runSucceeded,
}: {
input: unknown;
result?: Props['result'];
runStreaming: boolean;
runSucceeded: boolean;
}) {
const questions = parseLegacyAskUserQuestion(input);
const first = questions[0];
// Unparseable payload → defer to the generic card rather than inventing UI.
if (!first)
return <GenericCard name="AskUserQuestion" input={input} result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />;
// Title + summary are model-authored text (already in the user's locale), so
// no new i18n keys are needed for this historical-only surface.
const title = first.header ?? truncate(first.question, 60);
const answers = result && !result.isError ? parseLegacyAuqAnswer(result.content) : new Map<string, string>();
// Surface the persisted pick(s) so completed history shows the actual answer,
// not just the prompt. Falls back to the bare prompt when no answer is stored.
const summary = questions
.map((q) => {
const answer = answers.get(q.question);
return answer ? `${q.question} → ${answer}` : q.question;
})
.filter(Boolean)
.join(' · ');
return (
<div className="op-card op-generic">
<div className="op-card-head">
<ResultBadge result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />
<span className="op-title">{title}</span>
{summary ? <span className="op-meta">{truncate(summary, 240)}</span> : null}
</div>
</div>
);
}
interface FileToolCtx {
projectFileNames?: Set<string> | undefined;
onRequestOpenFile?: ((name: string) => void) | undefined;
@@ -139,294 +211,6 @@ function OpenInTabButton({ filePath, ctx }: { filePath: string; ctx: FileToolCtx
);
}
// Inline rendering of Claude's `AskUserQuestion` tool. The tool ships as
// part of the Claude Agent SDK (no daemon-side execution); when the model
// calls it the host is expected to collect the user's selection and feed
// it back as the tool result. We hijack the same `onSubmitForm` channel
// QuestionFormView uses, which ChatPane / ProjectView route into a normal
// chat send, so the answer arrives at the next turn as a user message.
//
// Input shape (per the SDK):
// { questions: [{ question, header, options: [{ label, description }],
// multiSelect }, ...] }
// We accept either array of objects or array of plain strings for `options`
// to stay tolerant of small protocol drift. Parsing lives in
// `../runtime/ask-user-question` (strict for finished input, lenient +
// truncation-tolerant for the streaming pass).
/**
* Read-only AskUserQuestion card rendered from the still-streaming raw JSON
* input (the daemon's accumulated `tool_input_delta` for this tool id). It
* grows token-by-token via the tolerant parser and plays the reveal; once the
* full `tool_use` lands, the id leaves `liveToolInput` and the interactive
* card (the persisted block) takes over.
*/
export function StreamingAskUserQuestionCard({ raw }: { raw: string }) {
const t = useT();
const questions = parsePartialAskUserQuestion(raw);
if (questions.length === 0) {
// Frame-only: the questions array hasn't produced a prompt yet.
return (
<div className="op-card op-ask-question op-ask-question-locked op-ask-question-streaming" data-testid="ask-user-question" aria-busy>
<div className="op-card-head">
<span className="op-icon" aria-hidden>?</span>
<span className="op-title">{t('tool.askQuestion')}</span>
<span className="op-ask-question-typing" aria-hidden><i /><i /><i /></span>
</div>
</div>
);
}
return (
<div
className="op-card op-ask-question op-ask-question-locked op-ask-question-streaming"
data-testid="ask-user-question"
aria-busy
>
<div className="op-card-head">
<span className="op-icon" aria-hidden>?</span>
<span className="op-title">{t('tool.askQuestion')}</span>
<span className="op-ask-question-typing" aria-hidden><i /><i /><i /></span>
</div>
<div className="op-ask-question-body">
{/* Positional keys: the prompt/label strings are themselves growing
token-by-token, so keying on them would remount each field/option
every delta and replay the reveal. Questions and options only
append during streaming, so the index is stable and each node
mounts once and updates its text in place. */}
{questions.map((q, qi) => (
<div key={qi} className="qf-field">
<label className="qf-label">
<span>{q.question}</span>
</label>
{q.header ? <div className="qf-help">{q.header}</div> : null}
<div className="qf-options">
{q.options.map((opt, oi) => (
<label key={`${qi}:${oi}`} className="qf-chip" title={opt.description}>
<input type={q.multiSelect ? 'checkbox' : 'radio'} disabled aria-label={opt.label} />
<span className="qf-chip-copy">
{opt.label}
{opt.description ? (
<span className="qf-chip-desc">{opt.description}</span>
) : null}
</span>
</label>
))}
</div>
</div>
))}
</div>
</div>
);
}
function AskUserQuestionCard({
toolUseId,
input,
result,
runStreaming,
runSucceeded,
isLast,
onSubmitForm,
onAnswerToolUse,
}: {
toolUseId: string;
input: unknown;
result?: Props['result'];
runStreaming: boolean;
runSucceeded: boolean;
isLast: boolean;
onSubmitForm?: (text: string) => void;
onAnswerToolUse?: (toolUseId: string, content: string) => Promise<boolean> | boolean;
}) {
const t = useT();
const questions = parseAskUserQuestionInput(input);
// Initial selections: empty string per question for single-select, empty
// array for multi-select. Indexing by question text keeps things simple
// because the SDK does not assign explicit ids.
const [selections, setSelections] = useState<Record<string, string | string[]>>(() => {
const seed: Record<string, string | string[]> = {};
for (const q of questions) seed[q.question] = q.multiSelect ? [] : '';
return seed;
});
// Track local submission so the card locks immediately on Submit. We
// cannot rely on `result` alone because `claude-code -p` ships an auto
// error tool_result that does not represent a real answer.
const [submitted, setSubmitted] = useState(false);
if (questions.length === 0) {
return <GenericCard name="AskUserQuestion" input={input} result={result} runStreaming={runStreaming} runSucceeded={runSucceeded} />;
}
// Real answer == non-error upstream tool_result OR our local submit
// flag. Headless mode's auto error never lands in this bucket.
const hasRealAnswer = (!!result && !result.isError) || submitted;
// After a page reload the locally-held `selections` is empty because
// useState resets, so the card would render its locked summary with no
// chips highlighted. The persisted answer lives on `result.content`
// (the tool_result the daemon wrote back into the run); parse it so the
// user's pick stays visible across reloads. Multi-select answers are
// serialized as a `- ` bullet list (one option per line) so labels
// containing commas round-trip exactly.
const answeredSelections = (() => {
if (!result || result.isError || !result.content) return null;
const out: Record<string, string | string[]> = {};
const pairs = result.content.split('\n\n');
for (const pair of pairs) {
const newlineIdx = pair.indexOf('\n');
if (newlineIdx === -1) continue;
const q = pair.slice(0, newlineIdx).trim();
const a = pair.slice(newlineIdx + 1).trim();
if (!q) continue;
const question = questions.find((qq) => qq.question === q);
if (!question) continue;
out[q] = question.multiSelect
? a.split('\n').map((s) => s.replace(/^- /, '').trim()).filter(Boolean)
: a;
}
return out;
})();
// While the user is actively picking (card not yet locked), the local
// `selections` is authoritative. Once locked, prefer the persisted
// answer if available so reloads / cached messages still highlight.
const effectiveSelections = hasRealAnswer && answeredSelections
? answeredSelections
: selections;
// We need at least one viable submit channel to be interactive: the live
// `onAnswerToolUse` (preferred — feeds the tool_result back into the
// open stream-json child) or the legacy `onSubmitForm` (fallback that
// sends the answer as a fresh user message).
const canSubmit = !!onAnswerToolUse || !!onSubmitForm;
const locked = hasRealAnswer || !isLast || !canSubmit;
const ready = questions.every((q) => {
const v = selections[q.question];
return Array.isArray(v) ? v.length > 0 : typeof v === 'string' && v.trim().length > 0;
});
function pickSingle(question: string, label: string) {
if (locked) return;
setSelections((prev) => ({ ...prev, [question]: label }));
}
function toggleMulti(question: string, label: string) {
if (locked) return;
setSelections((prev) => {
const current = Array.isArray(prev[question]) ? (prev[question] as string[]) : [];
const next = current.includes(label)
? current.filter((v) => v !== label)
: [...current, label];
return { ...prev, [question]: next };
});
}
async function handleSubmit() {
if (locked || !ready) return;
const lines = questions.map((q) => {
const v = selections[q.question];
const answer = Array.isArray(v) ? v.map((s) => `- ${s}`).join('\n') : (v ?? '');
return `${q.question}\n${answer}`;
});
const formatted = lines.join('\n\n');
// Prefer the direct tool-result route: keeps the answer scoped to the
// open stream-json child so claude-code's `AskUserQuestion` returns
// without an auto error. Fall back to onSubmitForm only if no run is
// wired up (e.g. older messages where the run already terminated).
if (onAnswerToolUse) {
setSubmitted(true);
try {
const ok = await onAnswerToolUse(toolUseId, formatted);
if (ok === false) {
// Live route failed (run gone, stdin closed). Revert the local
// lock and try the legacy fallback so the user is not stuck.
setSubmitted(false);
onSubmitForm?.(formatted);
}
} catch {
setSubmitted(false);
onSubmitForm?.(formatted);
}
return;
}
if (onSubmitForm) {
setSubmitted(true);
onSubmitForm(formatted);
}
}
// Status pill driven by our own answered/pending state, not the upstream
// tool_result. claude-code's headless auto-error would otherwise surface
// as a misleading `error` badge on what is really an open question. When
// the card is locked because the user moved past it without a real
// answer, drop the pill entirely.
const statusLabel = hasRealAnswer
? t('tool.askQuestionAnswered')
: !locked
? t('tool.askQuestionPending')
: null;
// Use the awaiting variant instead of `op-status-running`: pending input
// is a steady waiting state, not active work, so the global pulsing
// animation on `op-status-running` is misleading and noisy.
const statusClass = hasRealAnswer ? 'op-status-ok' : 'op-status-awaiting';
return (
<div className={`op-card op-ask-question${locked ? ' op-ask-question-locked' : ''}`} data-testid="ask-user-question">
<div className="op-card-head">
<span className="op-icon" aria-hidden>?</span>
<span className="op-title">{t('tool.askQuestion')}</span>
{statusLabel ? (
<span className={`op-status ${statusClass}`}>{statusLabel}</span>
) : null}
</div>
<div className="op-ask-question-body">
{questions.map((q, qi) => {
const selected = effectiveSelections[q.question];
return (
<div key={q.question} className="qf-field">
<label className="qf-label">
<span>{q.question}</span>
</label>
{q.header ? <div className="qf-help">{q.header}</div> : null}
<div className="qf-options">
{q.options.map((opt) => {
const isOn = Array.isArray(selected)
? selected.includes(opt.label)
: selected === opt.label;
return (
<label
key={opt.label}
className={`qf-chip${isOn ? ' qf-chip-on' : ''}`}
title={opt.description}
>
<input
type={q.multiSelect ? 'checkbox' : 'radio'}
name={`auq-${toolUseId}-q${qi}`}
checked={isOn}
disabled={locked}
aria-label={opt.label}
onChange={() => (q.multiSelect ? toggleMulti(q.question, opt.label) : pickSingle(q.question, opt.label))}
/>
<span className="qf-chip-copy">
{opt.label}
{opt.description ? (
<span className="qf-chip-desc">{opt.description}</span>
) : null}
</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
{!locked ? (
<div className="op-ask-question-foot">
<button
type="button"
className="op-ask-question-submit"
disabled={!ready}
onClick={handleSubmit}
>
{t('tool.askQuestionSubmit')}
</button>
</div>
) : null}
</div>
);
}
export function TodoCard({ input, runStreaming, runSucceeded, onDismiss }: { input: unknown; runStreaming: boolean; runSucceeded: boolean; onDismiss?: () => void }) {
const t = useT();
const todos = parseTodoWriteInput(input);
@@ -32,9 +32,9 @@ export function buildPluginFolderAgentActionPrompt(
// - `--web` flag preserves the author's final review window (see
// `apps/daemon/src/plugins/publish.ts` "We never POST anywhere" — the
// author always sees the PR form before it lands).
// - Hard ban on `AskUserQuestion`: a previous run stalled for 600s when
// the agent paused mid-turn waiting for a host answer card that the
// user expected the plugin-folder buttons to satisfy.
// - Hard ban on mid-turn clarification forms: a previous run stalled for
// 600s when the agent paused waiting for an answer the user expected the
// plugin-folder buttons to satisfy.
function buildContributePrompt(folderPath: string): string {
return [
'Open a draft Pull Request that adds this generated plugin to the Open Design community catalog at `nexu-io/open-design`.',
@@ -51,7 +51,7 @@ function buildContributePrompt(folderPath: string): string {
'Report the exact command, any structured CLI error, and the final PR URL printed by the CLI. Stop on failure; do not recreate the git/gh workflow manually.',
'',
'**Hard constraints.** Treat these as inviolable:',
'- Do NOT call the `AskUserQuestion` tool at any point in this turn. This flow is fire-and-forget; no mid-turn questions.',
'- Do NOT emit a `<question-form>` or any clarification UI that waits for the user. This flow is fire-and-forget; no mid-turn questions.',
'- Do NOT try to install `gh`, `git`, or any other binary. Detect-and-instruct only.',
'- Do NOT auto-submit the PR. The final Create click is the author\'s.',
'- Do NOT retry a failed step. Report the error and stop.',
@@ -87,7 +87,7 @@ function buildPublishPrompt(folderPath: string): string {
'',
'**Hard constraints.** Treat these as inviolable:',
'- Do NOT call `od plugin publish --to open-design` (or any `--to <catalog>` variant). That is the registry-submission flow, not the repository-publish flow.',
'- Do NOT call the `AskUserQuestion` tool at any point in this turn. Fire-and-forget.',
'- Do NOT emit a `<question-form>` or any clarification UI that waits for the user. Fire-and-forget.',
'- Do NOT try to install `gh`, `git`, or any other binary. Detect-and-instruct only.',
'- Do NOT force-push (`--force` / `--force-with-lease`) and do NOT overwrite an existing tag. Fail and report instead.',
'- Do NOT retry a failed step. Report the error and stop.',
@@ -37,7 +37,6 @@ export interface ActiveConversationChatState {
) => void;
onRetry?: (assistantMessage: ChatMessage) => void;
onStop: () => void;
onSubmitForm?: (text: string) => void;
onRemoveQueuedSend?: (id: string) => void;
// Editing a queued send replaces its full payload (prompt + attachments +
// comment attachments + meta), matching ChatPane's QueuedSendUpdate, not just
@@ -151,10 +150,6 @@ export function SideChatTab({
onSend={controlledChat?.onSend ?? chat.onSend}
onRetry={controlledChat?.onRetry ?? chat.onRetry}
onStop={controlledChat?.onStop ?? chat.onStop}
onSubmitForm={(text) => {
if (controlledChat?.onSubmitForm) controlledChat.onSubmitForm(text);
else chat.onSend(text, [], []);
}}
onAssistantFeedback={controlledChat?.onAssistantFeedback}
onRequestOpenFile={onRequestOpenFile}
conversations={conversations}
-4
View File
@@ -2297,10 +2297,6 @@ export const ar: Dict = {
'tool.openInTab': 'فتح {name} في علامة تبويب',
'tool.open': 'فتح',
'tool.todos': 'المهام',
'tool.askQuestion': 'سؤال',
'tool.askQuestionSubmit': 'إرسال',
'tool.askQuestionPending': 'في انتظار إجابتك',
'tool.askQuestionAnswered': 'تمت الإجابة',
'tool.todosExpand': 'عرض المهام',
'tool.todosCollapse': 'إخفاء المهام',
'tool.todosDone': 'تم',
-4
View File
@@ -2297,10 +2297,6 @@ export const de: Dict = {
'tool.openInTab': '{name} in einem Tab öffnen',
'tool.open': 'öffnen',
'tool.todos': 'Aufgaben',
'tool.askQuestion': 'Frage',
'tool.askQuestionSubmit': 'Senden',
'tool.askQuestionPending': 'Warte auf deine Antwort',
'tool.askQuestionAnswered': 'Beantwortet',
'tool.todosExpand': 'Aufgaben einblenden',
'tool.todosCollapse': 'Aufgaben ausblenden',
'tool.todosDone': 'Fertig',
-4
View File
@@ -2297,10 +2297,6 @@ export const en: Dict = {
'tool.openInTab': 'Open {name} in a tab',
'tool.open': 'open',
'tool.todos': 'Todos',
'tool.askQuestion': 'Question',
'tool.askQuestionSubmit': 'Submit',
'tool.askQuestionPending': 'Awaiting your answer',
'tool.askQuestionAnswered': 'Answered',
'tool.todosExpand': 'Show tasks',
'tool.todosCollapse': 'Hide tasks',
'tool.todosDone': 'Done',
-4
View File
@@ -2297,10 +2297,6 @@ export const esES: Dict = {
'tool.openInTab': 'Abrir {name} en una pestaña',
'tool.open': 'abrir',
'tool.todos': 'Tareas',
'tool.askQuestion': 'Pregunta',
'tool.askQuestionSubmit': 'Enviar',
'tool.askQuestionPending': 'Esperando tu respuesta',
'tool.askQuestionAnswered': 'Respondido',
'tool.todosExpand': 'Mostrar tareas',
'tool.todosCollapse': 'Ocultar tareas',
'tool.todosDone': 'Listo',
-4
View File
@@ -2297,10 +2297,6 @@ export const fa: Dict = {
'tool.openInTab': 'باز کردن {name} در یک تب',
'tool.open': 'باز کردن',
'tool.todos': 'وظایف',
'tool.askQuestion': 'پرسش',
'tool.askQuestionSubmit': 'ارسال',
'tool.askQuestionPending': 'در انتظار پاسخ شما',
'tool.askQuestionAnswered': 'پاسخ داده شد',
'tool.todosExpand': 'نمایش وظایف',
'tool.todosCollapse': 'پنهان کردن وظایف',
'tool.todosDone': 'انجام شد',
-4
View File
@@ -2297,10 +2297,6 @@ export const fr: Dict = {
'tool.openInTab': 'Ouvrir {name} dans un onglet',
'tool.open': 'ouvrir',
'tool.todos': 'Tâches',
'tool.askQuestion': 'Question',
'tool.askQuestionSubmit': 'Envoyer',
'tool.askQuestionPending': 'En attente de votre réponse',
'tool.askQuestionAnswered': 'Répondu',
'tool.todosExpand': 'Afficher les tâches',
'tool.todosCollapse': 'Masquer les tâches',
'tool.todosDone': 'Terminé',
-4
View File
@@ -2297,10 +2297,6 @@ export const hu: Dict = {
'tool.openInTab': '{name} megnyitása lapon',
'tool.open': 'megnyitás',
'tool.todos': 'Feladatok',
'tool.askQuestion': 'Kérdés',
'tool.askQuestionSubmit': 'Beküldés',
'tool.askQuestionPending': 'Várjuk a válaszodat',
'tool.askQuestionAnswered': 'Megválaszolva',
'tool.todosExpand': 'Feladatok mutatása',
'tool.todosCollapse': 'Feladatok elrejtése',
'tool.todosDone': 'Kész',
-4
View File
@@ -2297,10 +2297,6 @@ export const id: Dict = {
'tool.openInTab': 'Buka {name} di tab',
'tool.open': 'Buka',
'tool.todos': 'Todo',
'tool.askQuestion': 'Pertanyaan',
'tool.askQuestionSubmit': 'Kirim',
'tool.askQuestionPending': 'Menunggu jawabanmu',
'tool.askQuestionAnswered': 'Dijawab',
'tool.todosExpand': 'Tampilkan tugas',
'tool.todosCollapse': 'Sembunyikan tugas',
'tool.todosDone': 'Selesai',
-4
View File
@@ -2297,10 +2297,6 @@ export const it: Dict = {
'tool.openInTab': 'Apri {name} in una scheda',
'tool.open': 'apri',
'tool.todos': 'Attività',
'tool.askQuestion': 'Domanda',
'tool.askQuestionSubmit': 'Invia',
'tool.askQuestionPending': 'In attesa della tua risposta',
'tool.askQuestionAnswered': 'Risposto',
'tool.todosExpand': 'Mostra attività',
'tool.todosCollapse': 'Nascondi attività',
'tool.todosDone': 'Fatto',
-4
View File
@@ -2297,10 +2297,6 @@ export const ja: Dict = {
'tool.openInTab': '{name} をタブで開く',
'tool.open': '開く',
'tool.todos': 'To-Do',
'tool.askQuestion': '質問',
'tool.askQuestionSubmit': '送信',
'tool.askQuestionPending': '回答をお待ちしています',
'tool.askQuestionAnswered': '回答済み',
'tool.todosExpand': 'タスクを表示',
'tool.todosCollapse': 'タスクを非表示',
'tool.todosDone': '完了',
-4
View File
@@ -2297,10 +2297,6 @@ export const ko: Dict = {
'tool.openInTab': '탭에서 {name} 열기',
'tool.open': '열기',
'tool.todos': '할 일 (Todos)',
'tool.askQuestion': '질문',
'tool.askQuestionSubmit': '제출',
'tool.askQuestionPending': '답변 대기 중',
'tool.askQuestionAnswered': '답변 완료',
'tool.todosExpand': '할 일 표시',
'tool.todosCollapse': '할 일 숨기기',
'tool.todosDone': '완료',
-4
View File
@@ -2297,10 +2297,6 @@ export const pl: Dict = {
'tool.openInTab': 'Otwórz {name} w karcie',
'tool.open': 'otwórz',
'tool.todos': 'Zadania',
'tool.askQuestion': 'Pytanie',
'tool.askQuestionSubmit': 'Wyślij',
'tool.askQuestionPending': 'Czeka na Twoją odpowiedź',
'tool.askQuestionAnswered': 'Udzielono odpowiedzi',
'tool.todosExpand': 'Pokaż zadania',
'tool.todosCollapse': 'Ukryj zadania',
'tool.todosDone': 'Gotowe',
-4
View File
@@ -2297,10 +2297,6 @@ export const ptBR: Dict = {
'tool.openInTab': 'Abrir {name} em uma aba',
'tool.open': 'abrir',
'tool.todos': 'Tarefas',
'tool.askQuestion': 'Pergunta',
'tool.askQuestionSubmit': 'Enviar',
'tool.askQuestionPending': 'Aguardando sua resposta',
'tool.askQuestionAnswered': 'Respondido',
'tool.todosExpand': 'Mostrar tarefas',
'tool.todosCollapse': 'Ocultar tarefas',
'tool.todosDone': 'Concluído',
-4
View File
@@ -2297,10 +2297,6 @@ export const ru: Dict = {
'tool.openInTab': 'Открыть {name} во вкладке',
'tool.open': 'открыть',
'tool.todos': 'Задачи',
'tool.askQuestion': 'Вопрос',
'tool.askQuestionSubmit': 'Отправить',
'tool.askQuestionPending': 'Ожидание вашего ответа',
'tool.askQuestionAnswered': 'Отвечено',
'tool.todosExpand': 'Показать задачи',
'tool.todosCollapse': 'Скрыть задачи',
'tool.todosDone': 'Готово',
-4
View File
@@ -2297,10 +2297,6 @@ export const th: Dict = {
'tool.openInTab': 'จับเรียก {name} ไว้ให้เข้าหน้าใหม่ของแท็บ',
'tool.open': 'เข้าไป',
'tool.todos': 'สิ่งที่วางแผนต้องทำ',
'tool.askQuestion': 'คำถาม',
'tool.askQuestionSubmit': 'ส่ง',
'tool.askQuestionPending': 'รอคำตอบของคุณ',
'tool.askQuestionAnswered': 'ตอบแล้ว',
'tool.todosExpand': 'แสดงงาน',
'tool.todosCollapse': 'ซ่อนงาน',
'tool.todosDone': 'เสร็จสิ้น',
-4
View File
@@ -2297,10 +2297,6 @@ export const tr: Dict = {
'tool.openInTab': '{name}’ı bir sekmede aç ',
'tool.open': 'aç',
'tool.todos': 'Yapılacaklar',
'tool.askQuestion': 'Soru',
'tool.askQuestionSubmit': 'Gönder',
'tool.askQuestionPending': 'Cevabınız bekleniyor',
'tool.askQuestionAnswered': 'Cevaplandı',
'tool.todosExpand': 'Görevleri göster',
'tool.todosCollapse': 'Görevleri gizle',
'tool.todosDone': 'Tamam',
-4
View File
@@ -2297,10 +2297,6 @@ export const uk: Dict = {
'tool.openInTab': 'Відкрити {name} на вкладці',
'tool.open': 'відкрити',
'tool.todos': 'Завдання',
'tool.askQuestion': 'Питання',
'tool.askQuestionSubmit': 'Надіслати',
'tool.askQuestionPending': 'Очікую вашої відповіді',
'tool.askQuestionAnswered': 'Відповідь надано',
'tool.todosExpand': 'Показати завдання',
'tool.todosCollapse': 'Сховати завдання',
'tool.todosDone': 'Готово',
-4
View File
@@ -2297,10 +2297,6 @@ export const zhCN: Dict = {
'tool.openInTab': '在标签页中打开 {name}',
'tool.open': '打开',
'tool.todos': '待办',
'tool.askQuestion': '问题',
'tool.askQuestionSubmit': '提交',
'tool.askQuestionPending': '等待你的回答',
'tool.askQuestionAnswered': '已回答',
'tool.todosExpand': '展开任务',
'tool.todosCollapse': '收起任务',
'tool.todosDone': '完成',
-4
View File
@@ -2297,10 +2297,6 @@ export const zhTW: Dict = {
'tool.openInTab': '在分頁中開啟 {name}',
'tool.open': '開啟',
'tool.todos': '待辦',
'tool.askQuestion': '問題',
'tool.askQuestionSubmit': '提交',
'tool.askQuestionPending': '等待你的回答',
'tool.askQuestionAnswered': '已回答',
'tool.todosExpand': '展開任務',
'tool.todosCollapse': '收起任務',
'tool.todosDone': '完成',
-4
View File
@@ -2870,10 +2870,6 @@ export interface Dict {
'tool.openInTab': string;
'tool.open': string;
'tool.todos': string;
'tool.askQuestion': string;
'tool.askQuestionSubmit': string;
'tool.askQuestionPending': string;
'tool.askQuestionAnswered': string;
'tool.todosExpand': string;
'tool.todosCollapse': string;
'tool.todosDone': string;
+8 -27
View File
@@ -141,19 +141,24 @@ function scopeHistoryToAgent(history: ChatMessage[], targetAgentId?: string): Ch
// Strip OD-specific markup that the agent emitted on a prior turn but
// that the model would otherwise pattern-match as a template to echo.
// Today this is `<question-form>` blocks and the ```json fenced schemas
// Today this is `<question-form>` blocks (and the `<ask-question>` alias the
// UI parser and the daemon open-tag matcher both accept) and the ```json
// fenced schemas
// some models (GPT-OSS-120B Medium, Gemini 3.5 Flash) emit alongside
// them — leaving those literal in the transcript causes weak/medium
// plain-stream models to re-emit an identical form on the user's
// follow-up turn, looking like the discovery form loop never breaks
// (see PR #3157 form-loop investigation).
// (see PR #3157 form-loop investigation). If we only scrubbed the canonical
// tag, an alias-form turn would replay verbatim and re-trigger that loop.
//
// User content is preserved verbatim — a user message that legitimately
// quotes `<question-form>` (e.g. discussing the markup with the agent)
// must not be mangled.
export function sanitizePriorAssistantTurnForTranscript(content: string): string {
let sanitized = content.replace(
/<question-form\b[^>]*>[\s\S]*?<\/question-form>/g,
// `\1` backreference keeps the open/close tag names matched so we never
// splice across a `<question-form>…</ask-question>` mismatch.
/<(question-form|ask-question)\b[^>]*>[\s\S]*?<\/\1>/g,
'[question-form was emitted here on a prior turn; the user already answered, see their reply below.]',
);
// Strip ```json (or plain ```) fenced blocks whose body matches the
@@ -643,30 +648,6 @@ export async function fetchChatRunStatus(runId: string): Promise<ChatRunStatusRe
}
}
// Push a `tool_result` content block back into a running stream-json child.
// Used to answer Claude's `AskUserQuestion` tool: the host card collects the
// user's pick, formats it as one text string, and we route it through the
// daemon's POST /api/runs/:id/tool-result. The daemon writes it as a JSONL
// line on the still-open stdin so claude-code can resume mid-call instead
// of auto-erroring the tool in headless mode.
export async function submitChatRunToolResult(
runId: string,
toolUseId: string,
content: string,
options: { isError?: boolean } = {},
): Promise<{ ok: boolean; status?: number }> {
try {
const resp = await fetch(`/api/runs/${encodeURIComponent(runId)}/tool-result`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ toolUseId, content, isError: !!options.isError }),
});
return { ok: resp.ok, status: resp.status };
} catch {
return { ok: false };
}
}
// PR #3157: Antigravity's auth banner can offer a one-click "open
// system terminal with agy" button. The daemon endpoint spawns
// osascript / x-terminal-emulator / `cmd /c start` for the user; on
-104
View File
@@ -1,104 +0,0 @@
/**
* Parsing for Claude's `AskUserQuestion` tool input, in two modes:
*
* - parseAskUserQuestionInput — strict. For a *finished* tool_use whose
* input is already a complete object. Drops anything malformed so the
* locked, interactive card never shows half-built options.
*
* - parsePartialAskUserQuestion — lenient + truncation-tolerant. For the
* live streaming pass, where the input is the raw JSON *prefix* that grows
* one token at a time (fed from the daemon's `tool_input_delta`). It
* repairs the truncated prefix, then keeps every question that already has
* its prompt text (even with zero options yet) so a question doesn't
* flicker in → out → back in as its options arrive.
*
* Input shape (per the SDK):
* { questions: [{ question, header, options: [{ label, description }],
* multiSelect }, ...] }
* Both parsers accept `options` as an array of objects or of plain strings
* to stay tolerant of small protocol drift.
*/
import { parsePartialJson } from './partial-json';
// Re-exported so importers/tests of these generic helpers can reach them
// through this module too.
export { repairJsonPrefix, parsePartialJson } from './partial-json';
export type AuqOption = { label: string; description?: string };
export type AuqQuestion = {
question: string;
header?: string;
options: AuqOption[];
multiSelect: boolean;
};
function coerceOptions(raw: unknown): AuqOption[] {
if (!Array.isArray(raw)) return [];
const options: AuqOption[] = [];
for (const opt of raw) {
if (typeof opt === 'string') {
if (opt) options.push({ label: opt });
continue;
}
if (!opt || typeof opt !== 'object') continue;
const o = opt as Record<string, unknown>;
const label = typeof o.label === 'string' ? o.label : '';
// While streaming, an option object can exist before its label has
// finished typing; skip it for now (it reappears once a char lands).
if (!label) continue;
const description = typeof o.description === 'string' ? o.description : undefined;
options.push(description ? { label, description } : { label });
}
return options;
}
/**
* Strict parser for a finished AskUserQuestion input. Drops questions with no
* prompt or no options so the interactive card is always well-formed.
*/
export function parseAskUserQuestionInput(input: unknown): AuqQuestion[] {
const obj = (input ?? {}) as { questions?: unknown };
if (!Array.isArray(obj.questions)) return [];
const result: AuqQuestion[] = [];
for (const raw of obj.questions) {
if (!raw || typeof raw !== 'object') continue;
const q = raw as Record<string, unknown>;
const question = typeof q.question === 'string' ? q.question : '';
if (!question) continue;
const header = typeof q.header === 'string' ? q.header : undefined;
const multiSelect = q.multiSelect === true;
const options = coerceOptions(q.options);
if (options.length === 0) continue;
result.push({ question, header, options, multiSelect });
}
return result;
}
/**
* Lenient parser for the streaming pass. Takes the *accumulated raw JSON
* buffer* (concatenated `tool_input_delta` fragments), repairs the truncation,
* and shapes it — keeping any question that already has prompt text even if
* its options have not arrived. The card renders these read-only until the
* final tool_use supersedes them.
*/
export function parsePartialAskUserQuestion(buf: string): AuqQuestion[] {
return shapePartialQuestions(parsePartialJson(buf));
}
/** Shape an already-parsed (lenient) object into questions. */
export function shapePartialQuestions(input: unknown): AuqQuestion[] {
const obj = (input ?? {}) as { questions?: unknown };
if (!Array.isArray(obj.questions)) return [];
const result: AuqQuestion[] = [];
for (const raw of obj.questions) {
if (!raw || typeof raw !== 'object') continue;
const q = raw as Record<string, unknown>;
const question = typeof q.question === 'string' ? q.question : '';
if (!question) continue; // need at least the prompt to show anything
const header = typeof q.header === 'string' ? q.header : undefined;
const multiSelect = q.multiSelect === true;
const options = coerceOptions(q.options);
result.push({ question, header, options, multiSelect });
}
return result;
}
+3 -4
View File
@@ -1,8 +1,7 @@
/**
* Truncation-tolerant JSON parsing for streamed LLM output. Both question
* surfaces (the `AskUserQuestion` tool card and the `<question-form>`
* discovery form) need to render a JSON *prefix* that grows one token at a
* time, so they share this repair pass.
* Truncation-tolerant JSON parsing for streamed LLM output. The
* `<question-form>` discovery / clarification form needs to render a JSON
* *prefix* that grows one token at a time, so it relies on this repair pass.
*
* It deliberately handles only the shapes a streaming model emits — balanced
* structure that simply hasn't finished — not arbitrary corruption. Callers
-93
View File
@@ -1,95 +1,3 @@
/*
* Inline rendering of Claude's AskUserQuestion tool. Question text reads
* like a normal assistant message, options live as a row of chip buttons
* the user picks from. Once an answer ships (or the user has moved past
* the turn) the card locks into a read only state: chips remain visible
* but unclickable so the user can see what was asked.
*/
.op-ask-question .op-card-head { padding-bottom: 4px; }
.op-ask-question-body {
display: flex;
flex-direction: column;
gap: 14px;
padding: 4px 12px 12px;
}
/* The question fields + option chips now reuse the QuestionForm visual
language (.qf-field / .qf-options / .qf-chip — see composio.css) so the
inline AskUserQuestion card and the right-side Questions panel read as one
surface. Only the card frame (head / body / foot / submit / typing) keeps
its op-ask-question styling here. */
.op-ask-question-foot {
display: flex;
justify-content: flex-end;
padding: 4px 12px 12px;
}
/* Match the composer's Send button styling so the two actions read as
* the same control family. The composer-send is bordered with the
* accent color and uses the same padding/typography; we drop the icon
* by skipping the gap/svg children entirely. */
.op-ask-question-submit {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--accent);
border: 1px solid var(--accent);
border-radius: var(--radius);
color: white;
font-weight: 500;
padding: 4px 14px;
font-size: 12.5px;
cursor: pointer;
}
.op-ask-question-submit:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.op-ask-question-submit:disabled {
opacity: 0.5;
cursor: default;
}
/*
* Streaming state: the tool's input JSON is still arriving token-by-token
* (rendered from the live `tool_input_delta` accumulation, before the full
* tool_use lands). The card frame paints immediately; each question field and
* option mounts the moment its text settles in the partial parse and animates
* in once. The live card is replaced by the interactive (persisted) card when
* the tool_use completes, so the reveal never replays.
*/
@keyframes op-auq-reveal {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
.op-ask-question-streaming .qf-field,
.op-ask-question-streaming .qf-chip {
animation: op-auq-reveal 200ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
/* Three pulsing dots standing in for the "still writing" status pill — no
* text, so it needs no translation. */
.op-ask-question-typing {
display: inline-flex;
gap: 3px;
margin-left: auto;
align-items: center;
}
.op-ask-question-typing i {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--text-muted);
animation: op-auq-typing 1s ease-in-out infinite;
}
.op-ask-question-typing i:nth-child(2) { animation-delay: 0.15s; }
.op-ask-question-typing i:nth-child(3) { animation-delay: 0.3s; }
@keyframes op-auq-typing {
0%, 100% { opacity: 0.25; transform: translateY(0); }
50% { opacity: 0.9; transform: translateY(-2px); }
}
@media (prefers-reduced-motion: reduce) {
.op-ask-question-streaming .qf-field,
.op-ask-question-streaming .qf-chip { animation: none; }
.op-ask-question-typing i { animation: none; opacity: 0.6; }
}
.op-meta { color: var(--text-muted); font-size: 11.5px; }
.op-desc { font-style: italic; }
.op-path {
@@ -119,7 +27,6 @@
font-size: 12px;
}
.op-status-running { color: var(--purple); }
.op-status-awaiting { color: var(--purple); }
.op-status-ok { color: var(--green); }
.op-status-error { color: var(--red); }
.op-toggle {
@@ -235,35 +235,6 @@ describe('AssistantMessage feedback gate', () => {
});
});
describe('AssistantMessage re-renders on live tool input changes', () => {
it('updates the streaming card when only liveToolInput changes (memo includes it)', () => {
// Same message object across renders — only liveToolInput differs, the way
// a burst of tool_input_delta arrives. The memo comparator must compare
// liveToolInput or the card freezes at its first frame.
const message = baseMessage({ content: '', runStatus: 'running', endedAt: undefined, events: [] });
const { container, rerender } = render(
<AssistantMessage
message={message}
streaming
projectId="proj-1"
liveToolInput={{ t1: { name: 'AskUserQuestion', text: '{"questions":[{"question":"Which databa","options":[]}]}', seq: 0 } }}
/>,
);
expect(container.querySelector('.qf-label')?.textContent).toBe('Which databa');
rerender(
<AssistantMessage
message={message}
streaming
projectId="proj-1"
liveToolInput={{ t1: { name: 'AskUserQuestion', text: '{"questions":[{"question":"Which database?","options":[]}]}', seq: 0 } }}
/>,
);
// Re-rendered to the grown prompt rather than frozen at "Which databa".
expect(container.querySelector('.qf-label')?.textContent).toBe('Which database?');
});
});
describe('AssistantMessage status badge updates (Bug A)', () => {
// Regression coverage for the model-badge stale-detail bug. ACP agents
// emit two `status: 'model'` events per turn:
@@ -656,52 +627,3 @@ describe('AssistantMessage recovered produced files', () => {
});
});
describe('AssistantMessage live AskUserQuestion fallback suppression', () => {
it('keeps preamble before a live AskUserQuestion but drops hedging after it', () => {
const message = baseMessage({
content: '',
runStatus: 'running',
endedAt: undefined,
// event[0] = preamble (before the tool call), event[1] = duplicate
// hedging the model emitted after starting the tool call.
events: [
{ kind: 'text', text: 'INTROPREAMBLEXYZ' } as ChatMessage['events'][number],
{ kind: 'text', text: 'HEDGINGDUPEXYZ' } as ChatMessage['events'][number],
],
});
const { container } = render(
<AssistantMessage
message={message}
streaming
projectId="proj-1"
// seq=1 → the tool call started after event[0], so it sits between the
// preamble and the hedging.
liveToolInput={{
t1: {
name: 'AskUserQuestion',
text: '{"questions":[{"question":"Which database?","options":[{"label":"Postgres"}]}]}',
seq: 1,
},
}}
/>,
);
expect(container.querySelector('[data-testid="ask-user-question"]')).not.toBeNull();
// Preamble before the card is preserved…
expect(container.textContent).toContain('INTROPREAMBLEXYZ');
// …hedging after the card is suppressed.
expect(container.textContent).not.toContain('HEDGINGDUPEXYZ');
});
it('keeps assistant prose when no live AskUserQuestion is present', () => {
const message = baseMessage({
content: '',
runStatus: 'running',
endedAt: undefined,
events: [{ kind: 'text', text: 'UNIQUENORMALPROSEXYZ' } as ChatMessage['events'][number]],
});
const { container } = render(
<AssistantMessage message={message} streaming projectId="proj-1" />,
);
expect(container.textContent).toContain('UNIQUENORMALPROSEXYZ');
});
});
@@ -448,7 +448,7 @@ describe('DesignSystemCreationFlow', () => {
expect(mocks.patchProject).toHaveBeenCalledWith(
project.id,
expect.objectContaining({
pendingPrompt: expect.stringContaining('Do not emit `<question-form>`, "Quick brief — 30 seconds", `AskUserQuestion`'),
pendingPrompt: expect.stringContaining('Do not emit `<question-form>`, "Quick brief — 30 seconds"'),
}),
);
expect(mocks.patchProject).toHaveBeenCalledWith(
@@ -306,7 +306,7 @@ describe('HomeView media composer options', () => {
it('strips deferred media settings from the forwarded pluginInputs', async () => {
// The footer pills for ratio / duration / model / resolution / audioType /
// voice were removed so the agent asks for them via AskUserQuestion during
// voice were removed so the agent asks for them via question-form during
// the run. `buildHomeMediaComposer` still seeds those defaults into the
// composer state, so submission must strip them before forwarding —
// otherwise the run arrives with `ratio: 16:9` / `duration: 5` baked in and
@@ -335,7 +335,7 @@ describe('HomeView media composer options', () => {
// the `/apply` call that yields `appliedPluginSnapshotId`, so submission
// must re-apply with the deferred footer/media fields stripped — otherwise
// the run prompt carries `ratio: 16:9` / `duration: 5` / `model: …` and the
// first-turn AskUserQuestion discovery flow stays suppressed even though
// first-turn question-form discovery flow stays suppressed even though
// `onSubmit.pluginInputs` was stripped.
const fetchMock = stubFetch();
const onSubmit = vi.fn();
@@ -779,7 +779,7 @@ describe('HomeView prompt handoff', () => {
})));
// Fidelity is deferred to first-turn discovery: the plugin is still applied
// with its full inputs, but its default must NOT be forwarded to the run, so
// the AskUserQuestion flow collects it instead of inheriting a baked-in value.
// the question-form flow collects it instead of inheriting a baked-in value.
const [{ pluginInputs: protoSubmittedInputs }] = onSubmit.mock.calls[0] as [
{ pluginInputs?: Record<string, unknown> },
];
@@ -1,35 +0,0 @@
// @vitest-environment jsdom
import { cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { StreamingAskUserQuestionCard } from '../../src/components/ToolCard';
afterEach(() => cleanup());
describe('StreamingAskUserQuestionCard key stability', () => {
it('keeps the same field/option DOM nodes as the prompt grows token-by-token', () => {
// Successive streamed prefixes of the same tool input.
const p1 = '{"questions":[{"question":"Which databa","options":[{"label":"Postgr"}]}]}';
const p2 = '{"questions":[{"question":"Which database?","options":[{"label":"Postgres"}]}]}';
const { container, rerender } = render(<StreamingAskUserQuestionCard raw={p1} />);
const field1 = container.querySelector('.qf-field');
const option1 = container.querySelector('.qf-chip');
expect(field1).not.toBeNull();
expect(option1).not.toBeNull();
// text reflects the early prefix
expect(container.querySelector('.qf-label')?.textContent).toBe('Which databa');
rerender(<StreamingAskUserQuestionCard raw={p2} />);
const field2 = container.querySelector('.qf-field');
const option2 = container.querySelector('.qf-chip');
// Positional keys mean React updates these nodes in place rather than
// remounting them (which would replay the reveal every token).
expect(field2).toBe(field1);
expect(option2).toBe(option1);
// …and the text updated in place to the longer prefix.
expect(container.querySelector('.qf-label')?.textContent).toBe('Which database?');
expect(container.querySelector('.qf-chip-copy')?.textContent).toBe('Postgres');
});
});
@@ -53,9 +53,9 @@ describe('buildPluginFolderAgentActionPrompt', () => {
);
});
it('hard-bans AskUserQuestion + auto-install + force-push + retry', () => {
expect(prompt).toContain('AskUserQuestion');
expect(prompt).toMatch(/fire-and-forget|do not call the `AskUserQuestion`/i);
it('hard-bans mid-turn clarification + auto-install + force-push + retry', () => {
expect(prompt).toContain('<question-form>');
expect(prompt).toMatch(/fire-and-forget|clarification UI that waits/i);
expect(prompt).toMatch(/do not try to install/i);
expect(prompt).toMatch(/do not force-push|--force/i);
expect(prompt).toMatch(/do not retry/i);
@@ -100,12 +100,12 @@ describe('buildPluginFolderAgentActionPrompt', () => {
expect(prompt).toMatch(/do not auto-submit/i);
});
it('hard-bans AskUserQuestion to avoid 600s mid-turn stalls', () => {
it('hard-bans mid-turn clarification forms to avoid 600s stalls', () => {
// Regression guard for the stall we observed during e2e: agent paused
// mid-turn on an AskUserQuestion tool waiting for a host answer the
// user never sent (they clicked the plugin-folder card instead).
expect(prompt).toContain('AskUserQuestion');
expect(prompt).toMatch(/do not call the `AskUserQuestion` tool|fire-and-forget/i);
// mid-turn on a clarification waiting for a host answer the user never
// sent (they clicked the plugin-folder card instead).
expect(prompt).toContain('<question-form>');
expect(prompt).toMatch(/clarification UI that waits|fire-and-forget/i);
});
it('forbids the agent from installing tools or retrying failures', () => {
+22
View File
@@ -423,6 +423,28 @@ describe('streamViaDaemon', () => {
expect(transcript).not.toContain('<question-form id="discovery" title="Brief">');
});
it('scrubs the <ask-question> alias from a prior assistant turn so it does not replay into the next send', () => {
// `<ask-question>` is an accepted alias for `<question-form>`. If the
// sanitizer only matched the canonical tag, an alias-form turn would
// replay verbatim on the follow-up send and re-trigger the form loop
// sanitizePriorAssistantTurnForTranscript() exists to break.
const transcript = buildDaemonTranscript([
{
id: '1',
role: 'assistant',
content: [
'<ask-question id="discovery" title="Brief">',
'{ "questions": [] }',
'</ask-question>',
].join('\n'),
},
{ id: '2', role: 'user', content: 'react native' },
]);
expect(transcript).toContain('question-form was emitted here on a prior turn');
expect(transcript).not.toContain('<ask-question id="discovery" title="Brief">');
});
it('escapes role delimiter lines in prior message content', () => {
const transcript = buildDaemonTranscript([
{
@@ -1,136 +0,0 @@
import { describe, it, expect } from 'vitest';
import {
repairJsonPrefix,
parsePartialJson,
parsePartialAskUserQuestion,
parseAskUserQuestionInput,
} from '../../src/runtime/ask-user-question';
const FULL =
'{"questions":[{"header":"DB","question":"Which database?","multiSelect":false,"options":[{"label":"Postgres","description":"Relational"},{"label":"SQLite"}]}]}';
describe('repairJsonPrefix', () => {
it('closes open containers in a truncated object', () => {
expect(JSON.parse(repairJsonPrefix('{"a":1'))).toEqual({ a: 1 });
expect(JSON.parse(repairJsonPrefix('{"a":[1,2'))).toEqual({ a: [1, 2] });
});
it('closes a string cut off mid-value', () => {
expect(JSON.parse(repairJsonPrefix('{"a":"hel'))).toEqual({ a: 'hel' });
});
it('drops a dangling key with a colon and no value', () => {
expect(JSON.parse(repairJsonPrefix('{"a":1,"b":'))).toEqual({ a: 1 });
});
it('drops a bare pending key with no colon', () => {
expect(JSON.parse(repairJsonPrefix('{"a":1,"b'))).toEqual({ a: 1 });
});
it('drops a trailing comma', () => {
expect(JSON.parse(repairJsonPrefix('[1,2,'))).toEqual([1, 2]);
});
it('does not mistake an escaped quote for the string end', () => {
expect(JSON.parse(repairJsonPrefix('{"a":"x\\"y'))).toEqual({ a: 'x"y' });
});
it('leaves complete JSON untouched', () => {
expect(JSON.parse(repairJsonPrefix(FULL))).toEqual(JSON.parse(FULL));
});
it('neutralizes a dangling escape inside an open string', () => {
expect(JSON.parse(repairJsonPrefix('{"a":"x\\'))).toEqual({ a: 'x' });
expect(JSON.parse(repairJsonPrefix('{"a":"x\\u'))).toEqual({ a: 'x' });
expect(JSON.parse(repairJsonPrefix('{"a":"x\\u12'))).toEqual({ a: 'x' });
});
it('keeps a completed escape and even backslash runs', () => {
expect(JSON.parse(repairJsonPrefix('{"a":"x\\u0041'))).toEqual({ a: 'xA' });
// Windows path mid-stream: escaped backslashes are complete pairs.
expect(JSON.parse(repairJsonPrefix('{"a":"C:\\\\Users\\\\foo'))).toEqual({ a: 'C:\\Users\\foo' });
// A lone trailing backslash (odd run) is the incomplete one — drop it.
expect(JSON.parse(repairJsonPrefix('{"a":"C:\\\\Users\\'))).toEqual({ a: 'C:\\Users' });
});
it('drops an unfinished scalar value cut mid-token', () => {
expect(JSON.parse(repairJsonPrefix('{"a":f'))).toEqual({});
expect(JSON.parse(repairJsonPrefix('{"a":tru'))).toEqual({});
expect(JSON.parse(repairJsonPrefix('{"a":nul'))).toEqual({});
expect(JSON.parse(repairJsonPrefix('{"a":true,"b":f'))).toEqual({ a: true });
expect(JSON.parse(repairJsonPrefix('{"a":1.'))).toEqual({});
expect(JSON.parse(repairJsonPrefix('{"a":1e'))).toEqual({});
expect(JSON.parse(repairJsonPrefix('{"a":-'))).toEqual({});
expect(JSON.parse(repairJsonPrefix('{"a":[1,2,f'))).toEqual({ a: [1, 2] });
});
it('keeps complete scalar literals (not prefixes of the partial patterns)', () => {
expect(JSON.parse(repairJsonPrefix('{"a":true'))).toEqual({ a: true });
expect(JSON.parse(repairJsonPrefix('{"a":false'))).toEqual({ a: false });
expect(JSON.parse(repairJsonPrefix('{"a":null'))).toEqual({ a: null });
expect(JSON.parse(repairJsonPrefix('{"a":12'))).toEqual({ a: 12 });
expect(JSON.parse(repairJsonPrefix('{"a":1.5'))).toEqual({ a: 1.5 });
expect(JSON.parse(repairJsonPrefix('{"a":1e3'))).toEqual({ a: 1000 });
// a string value that merely starts like a literal must not be touched
expect(JSON.parse(repairJsonPrefix('{"a":"f'))).toEqual({ a: 'f' });
});
});
describe('parsePartialJson', () => {
it('returns null for empty / unparseable input', () => {
expect(parsePartialJson('')).toBeNull();
expect(parsePartialJson(' ')).toBeNull();
});
});
describe('parsePartialAskUserQuestion (token-by-token stability)', () => {
it('keeps a question as soon as its prompt text exists, before options arrive', () => {
const buf = '{"questions":[{"header":"DB","question":"Which database?"';
const qs = parsePartialAskUserQuestion(buf);
expect(qs).toHaveLength(1);
expect(qs[0]).toMatchObject({ question: 'Which database?', header: 'DB', options: [] });
});
it('does not surface a question before its prompt has any text', () => {
expect(parsePartialAskUserQuestion('{"questions":[{"header":"DB"')).toHaveLength(0);
expect(parsePartialAskUserQuestion('{"questions":[{"header":"DB","question":""')).toHaveLength(0);
});
it('grows options as they stream in, skipping a half-typed label', () => {
const afterFirst = parsePartialAskUserQuestion(
'{"questions":[{"question":"Q","options":[{"label":"Postgres"},{"label":"SQ',
);
expect(afterFirst[0]?.options.map((o) => o.label)).toEqual(['Postgres', 'SQ']);
const labelNotYetStarted = parsePartialAskUserQuestion(
'{"questions":[{"question":"Q","options":[{"label":"Postgres"},{"lab',
);
expect(labelNotYetStarted[0]?.options.map((o) => o.label)).toEqual(['Postgres']);
});
it('does not collapse when a boolean value is split across deltas', () => {
// `multiSelect` cut mid-`false` used to make the whole prefix unparseable.
const qs = parsePartialAskUserQuestion('{"questions":[{"question":"Q","multiSelect":f');
expect(qs.map((q) => q.question)).toEqual(['Q']);
});
it('does not collapse mid-stream on a prompt ending in a dangling escape', () => {
// A question mentioning a Windows path, cut right after a backslash, used
// to make the repaired JSON invalid and drop the whole preview to [].
const qs = parsePartialAskUserQuestion('{"questions":[{"question":"Edit C:\\');
expect(qs).toHaveLength(1);
expect(qs[0]?.question).toBe('Edit C:');
});
it('monotonically converges to the strict parse at completion', () => {
let sawPrompt = false;
for (let i = 1; i <= FULL.length; i++) {
const qs = parsePartialAskUserQuestion(FULL.slice(0, i));
if (qs.length > 0 && qs[0]!.question === 'Which database?') sawPrompt = true;
if (sawPrompt && qs.length > 0) {
expect('Which database?'.startsWith(qs[0]!.question) || qs[0]!.question === 'Which database?').toBe(true);
}
}
expect(parsePartialAskUserQuestion(FULL)).toEqual(parseAskUserQuestionInput(JSON.parse(FULL)));
});
});
+40 -23
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ToolCard, StreamingAskUserQuestionCard } from '../../src/components/ToolCard';
import { ToolCard } from '../../src/components/ToolCard';
import {
clearToolRenderers,
deriveToolStatus,
@@ -126,6 +126,45 @@ describe('ToolCard dispatch', () => {
expect(markup).toContain('Q3 revenue');
});
it('renders a persisted AskUserQuestion turn as a read-only summary with the answer, not raw JSON', () => {
// Legacy AUQ tool_use events survive in upgraded chat history. They must
// render the model-authored question text AND the persisted answer, not
// the `{"questions":[...]}` protocol blob GenericCard would surface.
const input = {
questions: [
{
question: 'Which framework should we target?',
header: 'Framework',
options: [{ label: 'React Native' }, { label: 'Flutter' }],
},
],
};
// Persisted answer format: `${question}\n${answer}`.
const markup = renderToStaticMarkup(
<ToolCard
use={use(input, 'AskUserQuestion')}
result={ok('Which framework should we target?\nReact Native')}
runStreaming={false}
runSucceeded={true}
/>,
);
expect(markup).toContain('Framework');
expect(markup).toContain('Which framework should we target?');
// The persisted answer is surfaced so history stays auditable.
expect(markup).toContain('React Native');
// The raw JSON payload must not leak into the card.
expect(markup).not.toContain('&quot;questions&quot;');
expect(markup).not.toContain('"questions"');
});
it('falls back to the generic card for an unparseable AskUserQuestion payload', () => {
const markup = renderToStaticMarkup(
<ToolCard use={use({ junk: true }, 'ask_user_question')} runStreaming={false} runSucceeded={true} />,
);
expect(markup).toContain('op-generic');
expect(markup).toContain('AskUserQuestion');
});
it('passes the result content through as the `result` prop on completion', () => {
registerToolRenderer('render_chart', ({ status, result }) => (
<span data-testid="custom-chart" data-status={status}>
@@ -225,25 +264,3 @@ describe('ToolCard dispatch', () => {
expect(markup).toContain('String to replace was not found');
});
});
describe('StreamingAskUserQuestionCard', () => {
it('renders partial questions read-only from a truncated raw JSON prefix', () => {
// Mid-stream: prompt + one option arrived, object not yet closed.
const raw = '{"questions":[{"header":"DB","question":"Which database?","options":[{"label":"Postgres"}';
const markup = renderToStaticMarkup(<StreamingAskUserQuestionCard raw={raw} />);
expect(markup).toContain('op-ask-question-streaming');
expect(markup).toContain('Which database?');
expect(markup).toContain('Postgres');
// Read-only: options are disabled and there is no submit affordance.
expect(markup).toContain('disabled=""');
expect(markup).not.toContain('op-ask-question-submit');
expect(markup).toContain('op-ask-question-typing');
});
it('shows a frame-only card before any question prompt has arrived', () => {
const markup = renderToStaticMarkup(<StreamingAskUserQuestionCard raw={'{"questions":[{'} />);
expect(markup).toContain('op-ask-question-streaming');
expect(markup).toContain('op-ask-question-typing');
expect(markup).not.toContain('op-ask-question-field');
});
});
+1 -1
View File
@@ -2607,7 +2607,7 @@ export interface RunFinishedProps extends Omit<RunCreatedProps, 'area'> {
artifact_write_seen?: boolean;
live_artifact_seen?: boolean;
artifact_count: number;
// True when the run raised an AskUserQuestion clarification card. Such runs
// True when the run raised a `<question-form>` clarification. Such runs
// are intent-clarification turns (the agent stops to ask the user a question)
// and therefore inherently produce no artifact, so the dashboard can exclude
// them from the "run finished -> has artifact" funnel instead of counting
+14 -2
View File
@@ -123,7 +123,7 @@ export function formatElevenLabsVoiceOptionsErrorForPrompt(
export const SKIP_DISCOVERY_BRIEF_OVERRIDE = `# Automated project mode — skip discovery form
This project was created through the daemon API with \`skipDiscoveryBrief: true\`. Override the discovery rules below: do NOT emit \`<question-form id="discovery">\`, do NOT show "Quick brief — 30 seconds", and do NOT ask a first-turn clarification form. Do not call AskUserQuestion, do not emit any question form or choice card, and do not wait for user input. Treat the user's first message and project metadata as the brief, choose reasonable defaults for any missing details, then proceed directly to planning/building under the normal artifact workflow.`;
This project was created through the daemon API with \`skipDiscoveryBrief: true\`. Override the discovery rules below: do NOT emit \`<question-form id="discovery">\`, do NOT show "Quick brief — 30 seconds", and do NOT ask a first-turn clarification form. Do not emit any question form or choice card, and do not wait for user input. Treat the user's first message and project metadata as the brief, choose reasonable defaults for any missing details, then proceed directly to planning/building under the normal artifact workflow.`;
export function buildExamplePromptOverride(
title?: string | null,
@@ -307,6 +307,18 @@ export function composeSystemPrompt({
parts.push('# Identity and workflow charter (background)\n\n', BASE_SYSTEM_PROMPT);
// Mid-conversation clarification reuses the same `<question-form>` flow as
// turn-1 discovery (DISCOVERY_AND_PHILOSOPHY) so the host keeps ONE unified
// questions surface: a chat banner, the form in the right-hand Questions
// tab, and answers returned as the next user message. Mirrors the
// daemon-side composer's "## Clarifying questions mid-conversation" section
// in apps/daemon/src/prompts/system.ts — keep both in sync so a daemon chat
// and a BYOK/API chat route follow-up choices through the same surface
// instead of drifting back to plain markdown option lists.
parts.push(
"\n\n---\n\n## Clarifying questions mid-conversation\n\nWhen you need a clarification AFTER turn 1 and the natural answer is one of a small finite set of choices (2-4 options per question), emit a `<question-form>` block — the same markup turn-1 discovery uses — instead of writing a bulleted list of options in markdown. The host renders it as a Questions banner the user opens in the side tab; a markdown list renders as plain text and forces the user to type a reply. Use free-form prose questions only when the answer is naturally open-ended, needs more than ~4 options, or is a single yes/no. Do NOT also duplicate the form's questions as markdown text alongside it.",
);
// Mirrors the daemon-side composer in apps/daemon/src/prompts/system.ts —
// keep both copies of this preamble in sync so a CLI chat and a BYOK
// chat with the same memory both see the same wording. The "brand
@@ -437,7 +449,7 @@ Every later instruction in this prompt that tells you to "call TodoWrite", "run
**Allowed output:**
- Plain chat prose to the user (in their language). State your plan as prose — a short numbered list in markdown is fine; it just must not be wrapped in \`<todo-list>\` or claim to be a tool call.
- A final \`<artifact type="text/html">...</artifact>\` block containing a complete \`<!doctype html>\` document when the brief is ready to deliver.
- \`<question-form>\` blocks for discovery on turn 1, exactly as the rules below describe — question-form is markup the UI parses, not a tool call.
- \`<question-form>\` blocks for discovery (turn 1) and for mid-conversation clarification, exactly as the rules below describe — question-form is markup the UI parses, not a tool call.
If the rules below tell you to plan with TodoWrite, write the plan as prose instead. If they tell you to read skill side files before writing, describe in one sentence which patterns/conventions you're going to apply and proceed. If they tell you to run brand-spec extraction via Bash + Read + WebFetch, ask the user the missing brand questions in the discovery form instead.`;
@@ -59,6 +59,11 @@ describe('composeSystemPrompt — API mode (#313)', () => {
const prompt = composeSystemPrompt({});
expect(prompt).not.toMatch(/API mode — no tools available/i);
});
it('carries the mid-conversation clarification guidance for daemon mode too', () => {
const prompt = composeSystemPrompt({});
expect(prompt).toContain('Clarifying questions mid-conversation');
});
});
describe('API mode (streamFormat: plain)', () => {
@@ -120,6 +125,21 @@ describe('composeSystemPrompt — API mode (#313)', () => {
expect(prompt).toMatch(/<artifact>/);
});
// Regression coverage for the unified ask-user flow: API/BYOK mode must
// route mid-conversation clarification through the same `<question-form>`
// Questions-tab surface as daemon mode, not fall back to plain-text
// markdown option lists. The API-mode allowed-output list must NOT scope
// `<question-form>` to turn-1 only, and the composer must carry the
// daemon-mirrored "Clarifying questions mid-conversation" guidance.
it('permits mid-conversation clarification forms, not just turn-1 discovery', () => {
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
expect(prompt).toContain('Clarifying questions mid-conversation');
expect(prompt).toMatch(/discovery \(turn 1\) and for mid-conversation clarification/);
// The old turn-1-only allowance must be gone so it can't re-scope the
// form back to discovery in BYOK/API runs.
expect(prompt).not.toContain('blocks for discovery on turn 1, exactly');
});
it('honors metadata.skipDiscoveryBrief before the discovery rules', () => {
const prompt = composeSystemPrompt({
streamFormat: 'plain',
@@ -130,7 +150,7 @@ describe('composeSystemPrompt — API mode (#313)', () => {
expect(skipIdx).toBeGreaterThanOrEqual(0);
expect(skipIdx).toBeLessThan(discoveryIdx);
expect(prompt).toMatch(/do NOT emit `?<question-form id="discovery">`?/i);
expect(prompt).toContain('Do not call AskUserQuestion');
expect(prompt).toContain('Do not emit any question form');
expect(prompt).toContain('choose reasonable defaults for any missing details');
});
});