feat(ai): add ask_questions interactive clarifying-question tool (#22346)
## 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](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=105959-117153)) 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-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_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.ts` — `AskQuestionItem/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.ts` — `pendingQuestionMessageId` 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.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 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.com/claude-code) https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB --- _Generated by [Claude Code](https://claude.ai/code/session_01AArS8H3y3Z1Qwm763xhPLB)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22346?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
@@ -3380,6 +3380,7 @@ type Mutation {
|
||||
createChatThread: AgentChatThread!
|
||||
sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult!
|
||||
retryChatMessage(threadId: UUID!, modelId: String): SendChatMessageResult!
|
||||
answerAgentChatQuestion(threadId: UUID!, messageId: UUID!, answers: [AgentChatQuestionAnswerInput!]!, modelId: String): SendChatMessageResult!
|
||||
stopAgentChatStream(threadId: UUID!): Boolean!
|
||||
renameChatThread(id: UUID!, title: String!): AgentChatThread!
|
||||
archiveChatThread(id: UUID!): AgentChatThread!
|
||||
@@ -4486,6 +4487,12 @@ input FileAttachmentInput {
|
||||
filename: String!
|
||||
}
|
||||
|
||||
input AgentChatQuestionAnswerInput {
|
||||
questionIndex: Int!
|
||||
selectedOptionIndices: [Int!]!
|
||||
freeText: String
|
||||
}
|
||||
|
||||
input CreateSkillInput {
|
||||
id: UUID
|
||||
name: String!
|
||||
|
||||
@@ -2912,6 +2912,7 @@ export interface Mutation {
|
||||
createChatThread: AgentChatThread
|
||||
sendChatMessage: SendChatMessageResult
|
||||
retryChatMessage: SendChatMessageResult
|
||||
answerAgentChatQuestion: SendChatMessageResult
|
||||
stopAgentChatStream: Scalars['Boolean']
|
||||
renameChatThread: AgentChatThread
|
||||
archiveChatThread: AgentChatThread
|
||||
@@ -6088,6 +6089,7 @@ export interface MutationGenqlSelection{
|
||||
createChatThread?: AgentChatThreadGenqlSelection
|
||||
sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} })
|
||||
retryChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], modelId?: (Scalars['String'] | null)} })
|
||||
answerAgentChatQuestion?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], messageId: Scalars['UUID'], answers: AgentChatQuestionAnswerInput[], modelId?: (Scalars['String'] | null)} })
|
||||
stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} }
|
||||
renameChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID'], title: Scalars['String']} })
|
||||
archiveChatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
|
||||
@@ -6509,6 +6511,8 @@ export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChanne
|
||||
|
||||
export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']}
|
||||
|
||||
export interface AgentChatQuestionAnswerInput {questionIndex: Scalars['Int'],selectedOptionIndices: Scalars['Int'][],freeText?: (Scalars['String'] | null)}
|
||||
|
||||
export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']}
|
||||
|
||||
export interface UpdateSkillInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null)}
|
||||
|
||||
@@ -81,8 +81,8 @@ export default {
|
||||
349,
|
||||
356,
|
||||
439,
|
||||
484,
|
||||
493
|
||||
485,
|
||||
494
|
||||
],
|
||||
"types": {
|
||||
"BillingProductDTO": {
|
||||
@@ -8318,6 +8318,26 @@ export default {
|
||||
]
|
||||
}
|
||||
],
|
||||
"answerAgentChatQuestion": [
|
||||
318,
|
||||
{
|
||||
"threadId": [
|
||||
3,
|
||||
"UUID!"
|
||||
],
|
||||
"messageId": [
|
||||
3,
|
||||
"UUID!"
|
||||
],
|
||||
"answers": [
|
||||
475,
|
||||
"[AgentChatQuestionAnswerInput!]!"
|
||||
],
|
||||
"modelId": [
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
"stopAgentChatStream": [
|
||||
6,
|
||||
{
|
||||
@@ -8380,7 +8400,7 @@ export default {
|
||||
311,
|
||||
{
|
||||
"input": [
|
||||
475,
|
||||
476,
|
||||
"CreateSkillInput!"
|
||||
]
|
||||
}
|
||||
@@ -8389,7 +8409,7 @@ export default {
|
||||
311,
|
||||
{
|
||||
"input": [
|
||||
476,
|
||||
477,
|
||||
"UpdateSkillInput!"
|
||||
]
|
||||
}
|
||||
@@ -8447,7 +8467,7 @@ export default {
|
||||
243,
|
||||
{
|
||||
"input": [
|
||||
477,
|
||||
478,
|
||||
"GetAuthorizationUrlForSSOInput!"
|
||||
]
|
||||
}
|
||||
@@ -8613,7 +8633,7 @@ export default {
|
||||
246,
|
||||
{
|
||||
"input": [
|
||||
478
|
||||
479
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -8768,7 +8788,7 @@ export default {
|
||||
6,
|
||||
{
|
||||
"input": [
|
||||
479,
|
||||
480,
|
||||
"UpdateWorkspaceMemberSettingsInput!"
|
||||
]
|
||||
}
|
||||
@@ -8802,7 +8822,7 @@ export default {
|
||||
221,
|
||||
{
|
||||
"input": [
|
||||
480,
|
||||
481,
|
||||
"SetupOIDCSsoInput!"
|
||||
]
|
||||
}
|
||||
@@ -8811,7 +8831,7 @@ export default {
|
||||
221,
|
||||
{
|
||||
"input": [
|
||||
481,
|
||||
482,
|
||||
"SetupSAMLSsoInput!"
|
||||
]
|
||||
}
|
||||
@@ -8820,7 +8840,7 @@ export default {
|
||||
217,
|
||||
{
|
||||
"input": [
|
||||
482,
|
||||
483,
|
||||
"DeleteSsoInput!"
|
||||
]
|
||||
}
|
||||
@@ -8829,7 +8849,7 @@ export default {
|
||||
218,
|
||||
{
|
||||
"input": [
|
||||
483,
|
||||
484,
|
||||
"EditSsoInput!"
|
||||
]
|
||||
}
|
||||
@@ -8858,7 +8878,7 @@ export default {
|
||||
307,
|
||||
{
|
||||
"type": [
|
||||
484,
|
||||
485,
|
||||
"AnalyticsType!"
|
||||
],
|
||||
"name": [
|
||||
@@ -8898,7 +8918,7 @@ export default {
|
||||
297,
|
||||
{
|
||||
"input": [
|
||||
485,
|
||||
486,
|
||||
"CreateCalendarEventInput!"
|
||||
]
|
||||
}
|
||||
@@ -8907,7 +8927,7 @@ export default {
|
||||
306,
|
||||
{
|
||||
"input": [
|
||||
486,
|
||||
487,
|
||||
"SendEmailInput!"
|
||||
]
|
||||
}
|
||||
@@ -8929,7 +8949,7 @@ export default {
|
||||
"String!"
|
||||
],
|
||||
"connectionParameters": [
|
||||
488,
|
||||
489,
|
||||
"EmailAccountConnectionParameters!"
|
||||
],
|
||||
"id": [
|
||||
@@ -8941,7 +8961,7 @@ export default {
|
||||
167,
|
||||
{
|
||||
"input": [
|
||||
490,
|
||||
491,
|
||||
"UpdateLabPublicFeatureFlagInput!"
|
||||
]
|
||||
}
|
||||
@@ -8981,7 +9001,7 @@ export default {
|
||||
74,
|
||||
{
|
||||
"input": [
|
||||
491,
|
||||
492,
|
||||
"CreateOneAppTokenInput!"
|
||||
]
|
||||
}
|
||||
@@ -9059,7 +9079,7 @@ export default {
|
||||
"String!"
|
||||
],
|
||||
"fileFolder": [
|
||||
493,
|
||||
494,
|
||||
"FileFolder!"
|
||||
],
|
||||
"filePath": [
|
||||
@@ -11443,6 +11463,20 @@ export default {
|
||||
1
|
||||
]
|
||||
},
|
||||
"AgentChatQuestionAnswerInput": {
|
||||
"questionIndex": [
|
||||
21
|
||||
],
|
||||
"selectedOptionIndices": [
|
||||
21
|
||||
],
|
||||
"freeText": [
|
||||
1
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
]
|
||||
},
|
||||
"CreateSkillInput": {
|
||||
"id": [
|
||||
3
|
||||
@@ -11649,7 +11683,7 @@ export default {
|
||||
1
|
||||
],
|
||||
"files": [
|
||||
487
|
||||
488
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
@@ -11668,13 +11702,13 @@ export default {
|
||||
},
|
||||
"EmailAccountConnectionParameters": {
|
||||
"IMAP": [
|
||||
489
|
||||
490
|
||||
],
|
||||
"SMTP": [
|
||||
489
|
||||
490
|
||||
],
|
||||
"CALDAV": [
|
||||
489
|
||||
490
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
@@ -11713,7 +11747,7 @@ export default {
|
||||
},
|
||||
"CreateOneAppTokenInput": {
|
||||
"appToken": [
|
||||
492
|
||||
493
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
@@ -11742,7 +11776,7 @@ export default {
|
||||
231,
|
||||
{
|
||||
"input": [
|
||||
495,
|
||||
496,
|
||||
"LogicFunctionLogsInput!"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user