Skip to content

feat(ai): add ask_questions interactive clarifying-question tool#22346

Open
FelixMalfait wants to merge 18 commits into
mainfrom
claude/ai-question-tool-plan-28j3d5
Open

feat(ai): add ask_questions interactive clarifying-question tool#22346
FelixMalfait wants to merge 18 commits into
mainfrom
claude/ai-question-tool-plan-28j3d5

Conversation

@FelixMalfait

@FelixMalfait FelixMalfait commented Jun 30, 2026

Copy link
Copy Markdown
Member

What & why

Adds an ask_questions tool 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_result by tool_use_id) and OpenAI (function_call_output) model human-in-the-loop.

The naive form of this (leave the tool call in input-available to mean "pending") is impossible here: finalizeDanglingToolParts rewrites input-availableoutput-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_questions is an inline, chat-only tool with an execute that returns a status: 'pending' result immediately, so the tool part is always output-available and immune to finalizeDanglingToolParts. stopWhen(hasToolCall('ask_questions')) halts the turn right after the call (the model never sees the placeholder).
  • A nullable thread.pendingQuestionMessageId marker records that a turn is awaiting an answer.
  • The new answerAgentChatQuestion mutation 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 existing existingTurnId plumbing (isResume bypasses the per-turn dedup guard). On resume finalizeDanglingToolParts leaves the output-available part untouched and convertToModelMessages emits assistant(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

  • Survives refresh, scoped per-thread — the pending state is a normal persisted output-available part + 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.
  • Takes priority over the queue — a unified isBlocked = activeStreamId || pendingQuestionMessageId gate is applied in both sendChatMessage (new messages queue) and flushNextQueuedMessage (the drain). The queue cannot unpile until the question is answered and the resumed turn completes.

Harness-only by construction

ask_questions is added only to the chat's inline activeTools (like learn_tools/execute_tool/load_skills). It never enters the tool registry/catalog, so it is invisible to MCP and to workflow agents — no MCP_EXCLUDED_TOOL_NAMES entry 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.tsAskQuestionItem/Option/Answer/Result, ASK_QUESTIONS_TOOL_NAME.

twenty-server

  • ai-chat/tools/ask-questions.tool.ts — inline tool factory (pending-result execute, zod schema, 1–4 questions × 2–4 options).
  • chat-execution.service.ts — add to activeTools + preloadedToolNames; hasToolCall in stopWhen.
  • chat-system-prompts.const.ts — when-to-use guidance.
  • entities/agent-chat-thread.entity.tspendingQuestionMessageId column.
  • 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 — gate flushNextQueuedMessage; enqueueResumeStream.
  • agent-chat.resolver.ts — gate sendChatMessage; answerAgentChatQuestion mutation.
  • agent-chat.service.tsresolvePendingQuestion (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 in AiChatAssistantMessageRenderer.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 to core.agentChatThread. Needs a generated fast instance command (database:migrate:generate --name addThreadPendingQuestion --type fast) — see "Verification status".

Tests

  • Server: ask-questions.tool.spec.ts (pending echo + schema bounds), find-pending-question-part.util.spec.ts.
  • Front: markQuestionAnswered.test.ts, plus the Storybook story.

Verification status (please read)

This branch was authored in an environment where the monorepo yarn install repeatedly 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 the ai@6.0.97 exports 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 (the ai/index.ts export was added by hand; regen to reconcile)
  • nx run twenty-front:graphql:generate (new mutation + input type)
  • generate the fast instance command (migration) for the new column
  • typecheck + lint:diff-with-main (front + server) — expect minor import-ordering autofixes
  • run the unit tests

Screenshots: 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 run nx storybook twenty-front.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB


Generated by Claude Code

Review in cubic

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
@twenty-ci-bot-public

twenty-ci-bot-public Bot commented Jun 30, 2026

Copy link
Copy Markdown

🚀 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.

@twenty-ci-bot-public

twenty-ci-bot-public Bot commented Jun 30, 2026

Copy link
Copy Markdown

🔍 Visual Regression Review — twenty-ui

✅ No visual changes to review.

Changed: 0 · Added: 0 · Removed: 0 · Unchanged: 230


View run details · advisory mode

- 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
@twenty-ci-bot-public

twenty-ci-bot-public Bot commented Jun 30, 2026

Copy link
Copy Markdown

🔍 Visual Regression Review — twenty-front

✅ 4 visual change(s) reviewed — all explained by this PR.

Changed: 3 · Added: 2 · Removed: 0 · Unchanged: 699

2 item(s) to double-check (uncertain / low confidence)
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove all comments (unless they are really really really useful)

@FelixMalfait FelixMalfait marked this pull request as ready for review June 30, 2026 19:37
@twenty-ci-bot-public

twenty-ci-bot-public Bot commented Jun 30, 2026

Copy link
Copy Markdown

🔍 Automated Pre-Review

No issues detected - This PR is ready for human review.


View details

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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx Outdated
Comment thread packages/twenty-front/src/modules/ai/components/AiChatQuestionCard.tsx Outdated
Comment thread packages/twenty-front/src/modules/ai/hooks/useSubmitQuestionAnswer.ts Outdated
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 etiennejouan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we tend to use guards instead of "typeof", !==null or for definition (isDefined)

};
});

export const markQuestionPending = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: multiple export

Comment on lines 520 to 530
const expectedExistingAssistantMessages = isResume ? 1 : 0;
const existingAssistantMessageCount = isDefined(userMessage.turnId)
? await this.agentChatService.countAssistantMessagesForTurn({
turnId: userMessage.turnId,
workspaceId,
})
: 0;

if (existingAssistantMessageCount > expectedExistingAssistantMessages) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: you have experienced this issue, to set this guard ?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants