4aaf171d63
## 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. -->
204 lines
7.2 KiB
TypeScript
204 lines
7.2 KiB
TypeScript
import { styled } from '@linaria/react';
|
|
import { EditorContent } from '@tiptap/react';
|
|
import { useLingui } from '@lingui/react/macro';
|
|
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
|
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { AiChatBanner } from '@/ai/components/AiChatBanner';
|
|
import { AiChatEmptyState } from '@/ai/components/AiChatEmptyState';
|
|
import { AiChatQuestionCard } from '@/ai/components/AiChatQuestionCard';
|
|
import { AIChatNoMoreBillingCreditsBanner } from '@/ai/components/AIChatNoMoreBillingCreditsBanner';
|
|
import { AiChatStandaloneError } from '@/ai/components/AiChatStandaloneError';
|
|
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
|
|
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
|
|
import { AiChatContextUsageButton } from '@/ai/components/internal/AiChatContextUsageButton';
|
|
import { AiChatEditorFocusEffect } from '@/ai/components/internal/AiChatEditorFocusEffect';
|
|
import { AiChatSkeletonLoader } from '@/ai/components/internal/AiChatSkeletonLoader';
|
|
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
|
|
import { useAgentChatModelId } from '@/ai/hooks/useAgentChatModelId';
|
|
import { useAiChatEditor } from '@/ai/hooks/useAiChatEditor';
|
|
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
|
|
import { useWorkspaceAiModelAvailability } from '@/ai/hooks/useWorkspaceAiModelAvailability';
|
|
import { agentChatUserSelectedModelState } from '@/ai/states/agentChatUserSelectedModelState';
|
|
import { agentChatPendingQuestionComponentSelector } from '@/ai/states/selectors/agentChatPendingQuestionComponentSelector';
|
|
import { Select } from '@/ui/input/components/Select';
|
|
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
|
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
|
|
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
|
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
|
import { hasReachedCurrentBillingPeriodCapSelector } from '@/workspace/states/hasReachedCurrentBillingPeriodCapSelector';
|
|
import { type SelectOption } from 'twenty-ui/input';
|
|
|
|
const StyledInputArea = styled.div<{ isMobile: boolean }>`
|
|
align-items: flex-end;
|
|
background: ${themeCssVariables.background.primary};
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex-shrink: 0;
|
|
gap: ${themeCssVariables.spacing[2]};
|
|
padding-block: ${({ isMobile }) =>
|
|
isMobile ? '0' : themeCssVariables.spacing[3]};
|
|
padding-inline: ${themeCssVariables.spacing[3]};
|
|
`;
|
|
|
|
const StyledInputBox = styled.div`
|
|
background-color: ${themeCssVariables.background.transparent.lighter};
|
|
border: 1px solid ${themeCssVariables.border.color.medium};
|
|
border-radius: ${themeCssVariables.border.radius.sm};
|
|
box-sizing: border-box;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: ${themeCssVariables.spacing[2]};
|
|
min-height: 140px;
|
|
padding: ${themeCssVariables.spacing[2]};
|
|
width: 100%;
|
|
|
|
&:focus-within {
|
|
border-color: ${themeCssVariables.color.blue};
|
|
box-shadow: 0px 0px 0px 3px ${themeCssVariables.color.transparent.blue2};
|
|
}
|
|
`;
|
|
|
|
const StyledEditorWrapper = styled.div`
|
|
display: flex;
|
|
flex: 1;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
|
|
.tiptap {
|
|
background: transparent;
|
|
border: none;
|
|
box-shadow: none;
|
|
color: ${themeCssVariables.font.color.primary};
|
|
font-family: inherit;
|
|
font-size: ${themeCssVariables.font.size.md};
|
|
font-weight: ${themeCssVariables.font.weight.regular};
|
|
line-height: 16px;
|
|
max-height: 320px;
|
|
min-height: 48px;
|
|
outline: none;
|
|
overflow-y: auto;
|
|
padding: 0;
|
|
|
|
p {
|
|
margin: 0;
|
|
}
|
|
|
|
p.is-editor-empty:first-of-type::before {
|
|
color: ${themeCssVariables.font.color.light};
|
|
content: attr(data-placeholder);
|
|
float: left;
|
|
font-weight: ${themeCssVariables.font.weight.regular};
|
|
height: 0;
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
`;
|
|
|
|
const StyledButtonsContainer = styled.div`
|
|
align-items: center;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
width: 100%;
|
|
`;
|
|
|
|
const StyledLeftButtonsContainer = styled.div`
|
|
align-items: center;
|
|
display: flex;
|
|
gap: ${themeCssVariables.spacing['0.5']};
|
|
`;
|
|
|
|
const StyledRightButtonsContainer = styled.div`
|
|
align-items: center;
|
|
display: flex;
|
|
gap: ${themeCssVariables.spacing[1]};
|
|
`;
|
|
|
|
export const AiChatEditorSection = () => {
|
|
const { t } = useLingui();
|
|
const isMobile = useIsMobile();
|
|
const hasReachedCurrentBillingPeriodCap = useAtomStateValue(
|
|
hasReachedCurrentBillingPeriodCapSelector,
|
|
);
|
|
const { enabledModels } = useWorkspaceAiModelAvailability();
|
|
const hasNoEnabledModels = enabledModels.length === 0;
|
|
const { options, pinnedOption } = useAiModelOptions({
|
|
variant: 'pinned-default',
|
|
});
|
|
|
|
const smartModelOptions: SelectOption<string | null>[] = options;
|
|
const defaultPinnedOption: SelectOption<string | null> | undefined =
|
|
pinnedOption
|
|
? {
|
|
...pinnedOption,
|
|
value: null,
|
|
}
|
|
: undefined;
|
|
const setAgentChatUserSelectedModel = useSetAtomState(
|
|
agentChatUserSelectedModelState,
|
|
);
|
|
const { selectedModelId } = useAgentChatModelId();
|
|
|
|
const { editor, handleSendAndClear } = useAiChatEditor();
|
|
|
|
const pendingQuestion = useAtomComponentSelectorValue(
|
|
agentChatPendingQuestionComponentSelector,
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<AiChatEditorFocusEffect editor={editor} />
|
|
<AiChatEmptyState editor={editor} />
|
|
<AiChatStandaloneError />
|
|
<AiChatSkeletonLoader />
|
|
|
|
<StyledInputArea isMobile={isMobile}>
|
|
<AgentChatContextPreview />
|
|
{hasNoEnabledModels && (
|
|
<AiChatBanner
|
|
message={t`No AI models are enabled in this workspace.`}
|
|
variant="warning"
|
|
/>
|
|
)}
|
|
{hasReachedCurrentBillingPeriodCap && (
|
|
<AIChatNoMoreBillingCreditsBanner />
|
|
)}
|
|
{isDefined(pendingQuestion) ? (
|
|
<AiChatQuestionCard pendingQuestion={pendingQuestion} />
|
|
) : (
|
|
<StyledInputBox>
|
|
<StyledEditorWrapper>
|
|
<EditorContent editor={editor} />
|
|
</StyledEditorWrapper>
|
|
<StyledButtonsContainer>
|
|
<StyledLeftButtonsContainer>
|
|
<AgentChatFileUploadButton />
|
|
<AiChatContextUsageButton />
|
|
</StyledLeftButtonsContainer>
|
|
<StyledRightButtonsContainer>
|
|
<Select
|
|
dropdownId="ai-chat-smart-model-select"
|
|
value={selectedModelId}
|
|
onChange={setAgentChatUserSelectedModel}
|
|
options={smartModelOptions}
|
|
pinnedOption={defaultPinnedOption}
|
|
disabled={hasNoEnabledModels}
|
|
selectSizeVariant="small"
|
|
showContextualTextInControl={false}
|
|
withSearchInput
|
|
dropdownOffset={{ x: 0, y: 8 }}
|
|
/>
|
|
<SendMessageButton
|
|
onSend={handleSendAndClear}
|
|
isDisabled={hasNoEnabledModels}
|
|
/>
|
|
</StyledRightButtonsContainer>
|
|
</StyledButtonsContainer>
|
|
</StyledInputBox>
|
|
)}
|
|
</StyledInputArea>
|
|
</>
|
|
);
|
|
};
|