feat(ai): add ask_questions interactive clarifying-question tool#22346
feat(ai): add ask_questions interactive clarifying-question tool#22346FelixMalfait wants to merge 18 commits into
Conversation
Add a design plan for a harness-only AI chat tool that lets the assistant ask the user one or more multiple-choice clarifying questions and resume once answered. Covers the chat-only wiring (inline activeTools), turn-pause via stopWhen + hasToolCall, MCP/workflow exclusion by construction, and the frontend interactive card + answer-as-follow-up-message flow. Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
Replace the answer-as-user-message sketch with the long-term design: the user's answer is a structured tool result bound to the toolCallId and the same agent turn resumes (matching Anthropic/OpenAI HITL semantics). Key decisions, verified against the code: - Tool has an execute that returns a 'pending' result so the part is always output-available and immune to finalizeDanglingToolParts (the naive no-execute / input-available form is corrupted to an error on persist and on model-reload). - stopWhen(hasToolCall) halts the turn; the answer mutation updates the same tool part's output and re-enqueues with existingTurnId to continue the turn. - A nullable pendingQuestion marker on the thread gates the send path and the queue drain so a pending question takes priority and survives refresh, scoped per-thread. - Frontend mounts the question card in the composer (per Figma), keyed by threadId; transcript shows an 'Asking questions...' status line. Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
Lets the in-app AI assistant pause a turn to ask the user one or more multiple-choice questions and resume once answered (approach B: the answer is a structured tool result bound to the toolCallId; the same turn resumes). Backend: - Inline, chat-only ask_questions tool (execute returns a pending result so the tool part stays output-available and survives finalizeDanglingToolParts); halts the turn via stopWhen(hasToolCall). Never registered, so it is absent from MCP and from workflow agents by construction. - thread.pendingQuestionMessageId marker set when a turn halts on a question; gates the queue drain and sendChatMessage so a pending question takes priority and survives refresh, scoped per-thread. - answerAgentChatQuestion mutation: atomically claims the question, writes the answer onto the same tool part, and resumes the turn via existingTurnId (isResume bypasses the per-turn dedup guard). Frontend: - Pixel-perfect AiChatQuestionCard that replaces the composer while a question is pending (question + pager, numbered option rows with info-icon descriptions and Recommended badge, free-text fallback, model select + send). - Per-thread pending-question selector derived from persisted messages; optimistic useSubmitQuestionAnswer; inline 'Asking questions...' transcript renderer; Storybook story and unit tests. Verification (barrels regen, graphql:generate, migration, typecheck, lint, tests) pending dependency install. Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
|
🚀 Preview Environment Ready! Your preview environment is available at: https://assessed-sharp-medal-managers.trycloudflare.com This environment will automatically shut down after 5 hours. |
🔍 Visual Regression Review —
|
- AiChatQuestionCard: import AppTooltip/TooltipDelay from twenty-ui/surfaces (twenty-ui/display is not an exported subpath; was breaking front-build) - Merge duplicate twenty-ui/input and twenty-shared/ai imports (oxlint) - Harden the question-summary value to a definite string (avoids the type-aware inconsistent-truthiness lint) - Use an imported ReactNode type in the story instead of React.ReactNode - Apply oxfmt formatting to the new server tool + spec files Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
…tion - AiChatQuestionStatusRenderer: import isNonEmptyString from @sniptt/guards (it is not exported from twenty-shared/utils) — fixes front-build - ai-graphql-api-exception-handler: handle new QUESTION_NOT_PENDING code in the exhaustive switch — fixes server typecheck - Add fast instance command creating thread.pendingQuestionMessageId column Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
Spreading {...part, output} broke ai-sdk's state-discriminated ToolUIPart
union (TS treated an input-streaming part as receiving an output). Cast the
rebuilt part to ExtendedUIMessagePart, mirroring mapDBPartToUIMessagePart.
Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
🔍 Visual Regression Review —
|
| Story | Verdict | Confidence | Explained by | |
|---|---|---|---|---|
| 🟡 | modules-settings-playground-graphqlplayground--default |
uncertain | 72% | — |
| 🟡 | modules-settings-accounts-blocklist-settingsaccountsblocklistinput--default |
uncertain | 75% | — |
Changed stories
| Story | Diff % |
|---|---|
| modules-settings-playground-graphqlplayground--default | 3% |
| modules-settings-accounts-blocklist-settingsaccountsblocklistinput--default | 1% |
| modules-objectrecord-recordcalendar-month--default | 0% |
2 new stories
- modules-aichat-aichatquestioncard--multiple-questions
- modules-aichat-aichatquestioncard--single-question
View run details · advisory mode
- Register AddPendingQuestionMessageIdToAgentChatThreadFastInstanceCommand in instance-commands.constant.ts so database:init runs it and the pendingQuestionMessageId column matches the entity (clears the pending migration check). - Rewrite twenty-shared/src/ai/index.ts to match generateBarrels output exactly (type+value exports grouped at the AskQuestionsToolTypes sort position). Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
The 'Merge branch main' auto-resolution concatenated both sides' appends to
instance-commands.constant.ts, duplicating five import declarations
(AddPrimaryPublicDomainToApplication, MakePublicDomainApplicationIdNotNull,
AddServerTriggerSettingsToLogicFunction, CreateDpaAgreementCoreTable,
CreateApplicationTranslationCoreTable) and breaking the server lint/typecheck
('Identifier ... has already been declared'). Reconstruct the file as
generate:instance-command would: main's content with the ask_questions command
appended once (import before export const, entry before the array close).
Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
…atQuestion Run graphql:generate (metadata) and generate-metadata-client to pick up the new answerAgentChatQuestion mutation and AgentChatQuestionAnswerInput input type. Resolves server-validation's 'Check for Pending Code Generation'. Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
| @@ -0,0 +1,48 @@ | |||
| // Types for the `ask_questions` AI chat tool: the assistant pauses the turn to | |||
There was a problem hiding this comment.
1 export = 1 file // (unless it's not the standard pattern in twenty-shared but I'm pretty sure it is for the majority)
| @@ -0,0 +1,331 @@ | |||
| # Design Plan — `ask_question` AI Tool (Approach B: true tool-result resume) | |||
There was a problem hiding this comment.
Why is this in the PR?
| @@ -0,0 +1,9 @@ | |||
| import { type AskQuestionItem } from 'twenty-shared/ai'; | |||
|
|
|||
| // The unanswered `ask_questions` tool call on the displayed thread, surfaced as | |||
There was a problem hiding this comment.
Remove all comments (unless they are really really really useful)
🔍 Automated Pre-Review✅ No issues detected - This PR is ready for human review. Automated pre-review — human approval still required. |
…m comments - Split AskQuestionsToolTypes.ts into one-export-per-file (ASK_QUESTIONS_TOOL_NAME -> constants/, each type -> its own types/ file) and regenerate the ai barrel. - Remove packages/twenty-server/docs/ASK_USER_QUESTION_TOOL_PLAN.md (planning doc). - Drop descriptive comments from the new files. Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
- main bumped TWENTY_CURRENT_VERSION to 2.19.0, so move the pendingQuestionMessageId fast instance command from 2-18/ to 2-19/ (satisfies the previous-version upgrade mutation guard) and update its registration version + constant import. - Remove remaining descriptive comments from the ai-chat changes per review. Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
There was a problem hiding this comment.
10 issues found across 40 files
You’re at about 99% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Server: - answerAgentChatQuestion: validate model availability before consuming the pending question (so an unavailable model can't clear the marker then fail). - resolvePendingQuestion: validate answers against the pending question payload (reject unknown question/option indices and invalid multi-select), and make the claim + tool-output write atomic via a compensating revert on failure. - stream-agent-chat job: count-based idempotency guard so a retried resume job can't persist a duplicate assistant answer / double-count usage. - ask_questions schema: reject more than one recommended option; add boundary tests (0/5 questions, 5 options, multiple recommended). Front: - AiChatQuestionCard: store free text per question, require every question answered before submit/resume, and make option rows keyboard-accessible. - useSubmitQuestionAnswer: on error, revert only the affected question part from current state instead of restoring a stale full-message snapshot. Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
etiennejouan
left a comment
There was a problem hiding this comment.
LGTM. Haven't check FE rendering yet as you told me it wasn't finished.
For the record we discussed yesterday, two approaches : a specially-formatted assistant message, with the user's reply coming back as a new user message. Second one, was pushing this PR implem further into a generic "AgentInterruption" concept, more complex without clear gain (what could be other interruption ? third party app auth ? not clear)
| } | ||
|
|
||
| const previousOutput = | ||
| typeof part.output === 'object' && part.output !== null |
There was a problem hiding this comment.
nit: we tend to use guards instead of "typeof", !==null or for definition (isDefined)
| }; | ||
| }); | ||
|
|
||
| export const markQuestionPending = ( |
| const expectedExistingAssistantMessages = isResume ? 1 : 0; | ||
| const existingAssistantMessageCount = isDefined(userMessage.turnId) | ||
| ? await this.agentChatService.countAssistantMessagesForTurn({ | ||
| turnId: userMessage.turnId, | ||
| workspaceId, | ||
| }) | ||
| : 0; | ||
|
|
||
| if (existingAssistantMessageCount > expectedExistingAssistantMessages) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
question: you have experienced this issue, to set this guard ?
There was a problem hiding this comment.
Does it work to have more than one ask_question tool exec in a turn ?
- markQuestionAnswered.ts: use isDefined() instead of typeof/!==null for the tool-output guard. - Move markQuestionPending into its own file (one export per file); update the import in useSubmitQuestionAnswer. Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
There was a problem hiding this comment.
2 issues found across 11 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Enter/Space on the per-option info button bubbled to the row's onKeyDown and unexpectedly selected the option. Guard on event.target === currentTarget. Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
The count-based idempotency guard assumed a single ask_questions pause per turn: a second resume in the same turn saw more than one assistant message and was skipped, dropping its message. Derive assistantMessageId deterministically from the per-stream streamId and skip persistence only when a message with that id already exists — so retried jobs are still deduped while each distinct resume in a turn persists its own message. Claude-Session: https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
What & why
Adds an
ask_questionstool that lets the in-app Ask AI assistant pause a turn to ask the user one or more multiple-choice questions (per the Figma design) and resume once answered — instead of guessing on ambiguous/consequential decisions.The tool is harness-only: an interactive question UI is meaningless without a user to answer it, so it must be absent from MCP and from head-less workflow agents.
Design — true tool-result resume (not a synthetic user message)
The user's answer is a structured tool result bound to the
toolCallId, and the same agent turn resumes — exactly how Anthropic (tool_resultbytool_use_id) and OpenAI (function_call_output) model human-in-the-loop.The naive form of this (leave the tool call in
input-availableto mean "pending") is impossible here:finalizeDanglingToolPartsrewritesinput-available→output-error("Tool execution was interrupted") on both the persist path (addMessage) and the model-reload path (chat-execution.service.ts). That util is a load-bearing safety net, so weakening it is the wrong move.Instead:
ask_questionsis an inline, chat-only tool with anexecutethat returns astatus: 'pending'result immediately, so the tool part is alwaysoutput-availableand immune tofinalizeDanglingToolParts.stopWhen(hasToolCall('ask_questions'))halts the turn right after the call (the model never sees the placeholder).thread.pendingQuestionMessageIdmarker records that a turn is awaiting an answer.answerAgentChatQuestionmutation atomically claims the question (clears the marker, marks the thread streaming), writes the answer onto the same tool part (status: 'answered'), and re-enqueues the turn via the existingexistingTurnIdplumbing (isResumebypasses the per-turn dedup guard). On resumefinalizeDanglingToolPartsleaves theoutput-availablepart untouched andconvertToModelMessagesemitsassistant(tool_use)+tool_result(answers), so the model continues.This achieves the platform-aligned semantics without weakening the finalize safety net or inventing a fragile new part state.
Meets the two requirements
output-availablepart + the thread marker; the frontend card is derived per-thread from the loaded messages, so it re-appears on reload and only on its own thread.isBlocked = activeStreamId || pendingQuestionMessageIdgate is applied in bothsendChatMessage(new messages queue) andflushNextQueuedMessage(the drain). The queue cannot unpile until the question is answered and the resumed turn completes.Harness-only by construction
ask_questionsis added only to the chat's inlineactiveTools(likelearn_tools/execute_tool/load_skills). It never enters the tool registry/catalog, so it is invisible to MCP and to workflow agents — noMCP_EXCLUDED_TOOL_NAMESentry needed.UX
While a question is pending, the composer is replaced by the question card (matching the Figma): question title + pager (
1/2), numbered option rows (IconSquareNumber*) with per-option info-icon descriptions and a "Recommended" badge, and the normal composer as the free-text fallback ("Type anything to do differently."). The transcript shows a compact "Asking questions…" status line that becomes an answered summary.Changes
twenty-shared
ai/types/AskQuestionsToolTypes.ts—AskQuestionItem/Option/Answer/Result,ASK_QUESTIONS_TOOL_NAME.twenty-server
ai-chat/tools/ask-questions.tool.ts— inline tool factory (pending-resultexecute, zod schema, 1–4 questions × 2–4 options).chat-execution.service.ts— add toactiveTools+preloadedToolNames;hasToolCallinstopWhen.chat-system-prompts.const.ts— when-to-use guidance.entities/agent-chat-thread.entity.ts—pendingQuestionMessageIdcolumn.stream-agent-chat.job.ts— set the marker on a question pause; bypass the dedup guard on resume; suppress the no-text warning for question pauses.agent-chat-streaming.service.ts— gateflushNextQueuedMessage;enqueueResumeStream.agent-chat.resolver.ts— gatesendChatMessage;answerAgentChatQuestionmutation.agent-chat.service.ts—resolvePendingQuestion(atomic claim + write answer).dtos/agent-chat-question-answer.input.ts,ai.exception.ts(QUESTION_NOT_PENDING),utils/find-pending-question-part.util.ts.twenty-front
components/AiChatQuestionCard.tsx— the interactive card (matches Figma tokens) +__stories__/AiChatQuestionCard.stories.tsx.components/AiChatEditorSection.tsx— swap the composer for the card while pending.components/AiChatQuestionStatusRenderer.tsx+ branch inAiChatAssistantMessageRenderer.tsx.states/selectors/agentChatPendingQuestionComponentSelector.ts,types/AgentChatPendingQuestion.ts.hooks/useSubmitQuestionAnswer.ts+utils/markQuestionAnswered.ts(optimistic) +graphql/mutations/answerAgentChatQuestion.ts.A design doc lives at
packages/twenty-server/docs/ASK_USER_QUESTION_TOOL_PLAN.md.Migration
Adds a nullable
pendingQuestionMessageId(uuid) column tocore.agentChatThread. Needs a generated fast instance command (database:migrate:generate --name addThreadPendingQuestion --type fast) — see "Verification status".Tests
ask-questions.tool.spec.ts(pending echo + schema bounds),find-pending-question-part.util.spec.ts.markQuestionAnswered.test.ts, plus the Storybook story.Verification status (please read)
This branch was authored in an environment where the monorepo
yarn installrepeatedly failed on transient TLS resets from the package registry, so I could not locally run the mechanical gates. The logic was reviewed by hand and theai@6.0.97exports used (hasToolCall,stepCountIs,generateId) were confirmed against the package's type defs. Still TODO (will rely on CI / a follow-up once deps install):nx run twenty-shared:generateBarrels(theai/index.tsexport was added by hand; regen to reconcile)nx run twenty-front:graphql:generate(new mutation + input type)typecheck+lint:diff-with-main(front + server) — expect minor import-ordering autofixesScreenshots: reproducing the live flow needs an AI provider API key (to get the model to actually call
ask_questions), which isn't available here. The card can be screenshotted from its Storybook story (AiChatQuestionCard.stories.tsx) with no API key — I'll add that image once deps install, or a reviewer can runnx storybook twenty-front.🤖 Generated with Claude Code
https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB
Generated by Claude Code