diff --git a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts
index 53e44604a1..82f7cc91c1 100644
--- a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts
+++ b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts
@@ -3,6 +3,7 @@ import * as suggestionLaunch from '../../tasks/suggestion-launch.js';
const mocks = vi.hoisted(() => ({
findRun: vi.fn(),
+ findActiveCommunicationRun: vi.fn(),
stopTaskRun: vi.fn(),
reply: vi.fn(),
findMappedUser: vi.fn(),
@@ -49,6 +50,9 @@ vi.mock('../replies.js', () => ({ replyToDiscordEvent: mocks.reply }));
vi.mock('@roomote/sdk/server', () => ({
findDiscordMappedUserId: mocks.findMappedUser,
}));
+vi.mock('@roomote/sdk/server/communication', () => ({
+ findActiveCommunicationTaskRun: mocks.findActiveCommunicationRun,
+}));
vi.mock('../../fast-agent-entry.js', () => ({
resolveFastAgentEntryMode: ({
userDefaultEnabled,
diff --git a/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts b/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts
new file mode 100644
index 0000000000..cad410b6c2
--- /dev/null
+++ b/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts
@@ -0,0 +1,221 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { TaskPayloadKind } from '@roomote/types';
+
+const mocks = vi.hoisted(() => ({
+ findActiveRun: vi.fn(),
+ getPending: vi.fn(),
+ rebindPending: vi.fn(),
+ reply: vi.fn(),
+ setActingUserOnSuccess: vi.fn(),
+ submitAnswer: vi.fn(),
+}));
+
+vi.mock('@roomote/communication', async (importOriginal) => ({
+ ...(await importOriginal()),
+ getPendingCommunicationRequestUserInput: mocks.getPending,
+ rebindPendingCommunicationRequestUserInputRun: mocks.rebindPending,
+ submitPendingCommunicationRequestUserInputAnswer: mocks.submitAnswer,
+}));
+
+vi.mock('@roomote/db/server', () => ({
+ setTrustedRunActingUserOnSuccess: mocks.setActingUserOnSuccess,
+}));
+
+vi.mock('@roomote/sdk/server/communication', () => ({
+ findActiveCommunicationTaskRun: mocks.findActiveRun,
+}));
+
+vi.mock('../replies.js', () => ({ replyToDiscordEvent: mocks.reply }));
+
+import { buildDiscordRequestUserInputAnswerCallbackData } from '@roomote/communication';
+
+import { tryHandleDiscordRequestUserInputCallback } from '../request-user-input.js';
+
+const pendingRequest = {
+ requestId: 'rui:session:turn:callid12',
+ runId: 42,
+ taskId: 'task-1',
+ provider: 'discord' as const,
+ conversationId: 'thread-1',
+ questions: [
+ {
+ id: 'q1',
+ header: 'Bump',
+ question: 'What bump level should I cut?',
+ isOther: false,
+ isSecret: false,
+ options: [{ label: 'minor', description: 'Recommended' }],
+ },
+ ],
+ status: 'pending' as const,
+ promptMessageId: 'prompt-1',
+ currentQuestionIndex: 0,
+ answers: {},
+ createdAt: 123,
+};
+
+const channel = {
+ channelId: 'thread-1',
+ channelName: 'Task thread',
+ channelType: 11,
+ guildId: 'guild-1',
+ parentChannelId: 'channel-1',
+ isDirectMessage: false,
+ isThread: true,
+};
+
+const interaction = {
+ id: 'interaction-1',
+ application_id: 'app-1',
+ type: 3,
+ token: 'token-1',
+ channel_id: 'thread-1',
+ user: { id: 'discord-user-1', username: 'matt' },
+ data: { component_type: 2 },
+};
+
+function answerCustomId(): string {
+ return buildDiscordRequestUserInputAnswerCallbackData({
+ runId: 42,
+ requestId: pendingRequest.requestId,
+ questionIndex: 0,
+ optionIndex: 0,
+ });
+}
+
+describe('Discord request_user_input callbacks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.getPending.mockResolvedValue(pendingRequest);
+ mocks.rebindPending.mockResolvedValue(true);
+ mocks.reply.mockResolvedValue({ messageId: 'response-1' });
+ mocks.submitAnswer.mockResolvedValue(true);
+ mocks.setActingUserOnSuccess.mockImplementation(
+ async ({ operation }: { operation: () => Promise }) =>
+ operation(),
+ );
+ });
+
+ it('rejects a structured answer unless the task owns the active reply target', async () => {
+ mocks.findActiveRun.mockResolvedValue(undefined);
+ const provider = { editMessage: vi.fn() } as never;
+
+ await expect(
+ tryHandleDiscordRequestUserInputCallback({
+ provider,
+ applicationId: 'app-1',
+ channel,
+ interaction: interaction as never,
+ interactionDeferred: true,
+ customId: answerCustomId(),
+ userId: 'user-1',
+ }),
+ ).resolves.toBe(true);
+
+ expect(mocks.findActiveRun).toHaveBeenCalledWith({
+ provider: 'discord',
+ channelId: 'channel-1',
+ threadId: 'thread-1',
+ taskId: 'task-1',
+ });
+ expect(mocks.setActingUserOnSuccess).not.toHaveBeenCalled();
+ expect(mocks.submitAnswer).not.toHaveBeenCalled();
+ expect(mocks.reply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: 'This prompt is no longer active.',
+ ephemeral: true,
+ }),
+ );
+ });
+
+ it('accepts an authorized answer without rebinding the current run', async () => {
+ mocks.findActiveRun.mockResolvedValue({ id: 42 });
+ const editMessage = vi.fn().mockResolvedValue(undefined);
+
+ await tryHandleDiscordRequestUserInputCallback({
+ provider: { editMessage } as never,
+ applicationId: 'app-1',
+ channel,
+ interaction: interaction as never,
+ interactionDeferred: true,
+ customId: answerCustomId(),
+ userId: 'user-1',
+ });
+
+ expect(mocks.rebindPending).not.toHaveBeenCalled();
+ expect(mocks.setActingUserOnSuccess).toHaveBeenCalledWith(
+ expect.objectContaining({ runId: 42, userId: 'user-1' }),
+ );
+ expect(mocks.submitAnswer).toHaveBeenCalledWith(
+ 'discord',
+ 'thread-1',
+ pendingRequest,
+ expect.objectContaining({ userId: 'user-1' }),
+ );
+ expect(editMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ channelId: 'thread-1',
+ messageId: 'prompt-1',
+ buttons: [],
+ }),
+ );
+ });
+
+ it('atomically rebinds an authorized legacy prompt to its resumed run', async () => {
+ mocks.findActiveRun.mockResolvedValue({
+ id: 84,
+ payloadKind: TaskPayloadKind.SnapshotResume,
+ payload: { sourceRunId: 42 },
+ });
+
+ await tryHandleDiscordRequestUserInputCallback({
+ provider: { editMessage: vi.fn().mockResolvedValue(undefined) } as never,
+ applicationId: 'app-1',
+ channel,
+ interaction: interaction as never,
+ interactionDeferred: true,
+ customId: 'discord:rui:42:0:0:callid12',
+ userId: 'user-1',
+ });
+
+ expect(mocks.rebindPending).toHaveBeenCalledWith({
+ provider: 'discord',
+ conversationId: 'thread-1',
+ taskId: 'task-1',
+ sourceRunId: 42,
+ resumedRunId: 84,
+ });
+ expect(mocks.setActingUserOnSuccess).toHaveBeenCalledWith(
+ expect.objectContaining({ runId: 84, userId: 'user-1' }),
+ );
+ expect(mocks.submitAnswer).toHaveBeenCalledWith(
+ 'discord',
+ 'thread-1',
+ { ...pendingRequest, runId: 84 },
+ expect.objectContaining({ userId: 'user-1' }),
+ );
+ });
+
+ it('does not rebind a later run without snapshot-resume lineage', async () => {
+ mocks.findActiveRun.mockResolvedValue({ id: 84, payload: {} });
+
+ await tryHandleDiscordRequestUserInputCallback({
+ provider: { editMessage: vi.fn() } as never,
+ applicationId: 'app-1',
+ channel,
+ interaction: interaction as never,
+ interactionDeferred: true,
+ customId: answerCustomId(),
+ userId: 'user-1',
+ });
+
+ expect(mocks.rebindPending).not.toHaveBeenCalled();
+ expect(mocks.submitAnswer).not.toHaveBeenCalled();
+ expect(mocks.reply).toHaveBeenCalledWith(
+ expect.objectContaining({
+ text: 'This prompt is no longer active.',
+ ephemeral: true,
+ }),
+ );
+ });
+});
diff --git a/apps/api/src/handlers/discord/request-user-input.ts b/apps/api/src/handlers/discord/request-user-input.ts
index 3441447cd6..7f6e0be69e 100644
--- a/apps/api/src/handlers/discord/request-user-input.ts
+++ b/apps/api/src/handlers/discord/request-user-input.ts
@@ -3,15 +3,21 @@ import {
buildDiscordCancelledRequestUserInputText,
getDiscordRequestUserInputCurrentQuestion,
getPendingCommunicationRequestUserInput,
+ matchesDiscordRequestUserInputRequestToken,
parseDiscordRequestUserInputAnswerCallbackData,
parseDiscordRequestUserInputCancelCallbackData,
+ rebindPendingCommunicationRequestUserInputRun,
submitPendingCommunicationRequestUserInputAnswer,
type PendingCommunicationRequestUserInput,
} from '@roomote/communication';
import type { DiscordInteraction } from '@roomote/communication/discord-event';
import type { DiscordCommunicationProvider } from '@roomote/communication/discord-provider';
-import { type AcpRequestUserInputAnswers } from '@roomote/types';
+import {
+ TaskPayloadKind,
+ type AcpRequestUserInputAnswers,
+} from '@roomote/types';
import { setTrustedRunActingUserOnSuccess } from '@roomote/db/server';
+import { findActiveCommunicationTaskRun } from '@roomote/sdk/server/communication';
import { apiLogger } from '../../logging.js';
import { replyToDiscordEvent } from './replies.js';
@@ -186,7 +192,7 @@ export async function tryHandleDiscordRequestUserInputCallback(params: {
}
const conversationId = conversationIdForChannel(params.channel);
- const pendingRequest = await getPendingCommunicationRequestUserInput(
+ let pendingRequest = await getPendingCommunicationRequestUserInput(
'discord',
conversationId,
);
@@ -207,10 +213,15 @@ export async function tryHandleDiscordRequestUserInputCallback(params: {
return true;
}
- const expectedToken = pendingRequest.requestId.slice(-8);
const receivedToken =
answerCallback?.requestToken ?? cancelCallback?.requestToken;
- if (receivedToken !== expectedToken) {
+ if (
+ !receivedToken ||
+ !matchesDiscordRequestUserInputRequestToken(
+ pendingRequest.requestId,
+ receivedToken,
+ )
+ ) {
await replyToDiscordEvent({
provider: params.provider,
applicationId: params.applicationId,
@@ -225,6 +236,75 @@ export async function tryHandleDiscordRequestUserInputCallback(params: {
return true;
}
+ const activeRun = await findActiveCommunicationTaskRun({
+ provider: 'discord',
+ channelId: params.channel.parentChannelId ?? params.channel.channelId,
+ ...(params.channel.parentChannelId
+ ? { threadId: params.channel.channelId }
+ : {}),
+ taskId: pendingRequest.taskId,
+ });
+ if (!activeRun) {
+ await replyToDiscordEvent({
+ provider: params.provider,
+ applicationId: params.applicationId,
+ channel: params.channel,
+ interaction: {
+ interaction: params.interaction,
+ interactionDeferred: params.interactionDeferred,
+ },
+ text: 'This prompt is no longer active.',
+ ephemeral: true,
+ });
+ return true;
+ }
+
+ if (activeRun.id !== pendingRequest.runId) {
+ const sourceRunId =
+ activeRun.payloadKind === TaskPayloadKind.SnapshotResume &&
+ activeRun.payload &&
+ typeof activeRun.payload === 'object'
+ ? (activeRun.payload as { sourceRunId?: unknown }).sourceRunId
+ : undefined;
+ if (sourceRunId !== pendingRequest.runId) {
+ await replyToDiscordEvent({
+ provider: params.provider,
+ applicationId: params.applicationId,
+ channel: params.channel,
+ interaction: {
+ interaction: params.interaction,
+ interactionDeferred: params.interactionDeferred,
+ },
+ text: 'This prompt is no longer active.',
+ ephemeral: true,
+ });
+ return true;
+ }
+
+ const rebound = await rebindPendingCommunicationRequestUserInputRun({
+ provider: 'discord',
+ conversationId,
+ taskId: pendingRequest.taskId,
+ sourceRunId: pendingRequest.runId,
+ resumedRunId: activeRun.id,
+ });
+ if (!rebound) {
+ await replyToDiscordEvent({
+ provider: params.provider,
+ applicationId: params.applicationId,
+ channel: params.channel,
+ interaction: {
+ interaction: params.interaction,
+ interactionDeferred: params.interactionDeferred,
+ },
+ text: 'This prompt is no longer active.',
+ ephemeral: true,
+ });
+ return true;
+ }
+ pendingRequest = { ...pendingRequest, runId: activeRun.id };
+ }
+
if (pendingRequest.status === 'submitted') {
await postAlreadyReceivedNotice({
provider: params.provider,
diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx
index 8809652286..1d93032ae6 100644
--- a/apps/docs/self-hosting.mdx
+++ b/apps/docs/self-hosting.mdx
@@ -202,6 +202,19 @@ recommendations. Detailed provider instructions and credential entry open in a
dialog from the source-control card; Roomote never asks for credentials in
chat.
+The conversation also asks briefly about the tools your team uses for
+documents, monitoring, and project tracking, one topic at a time.
+You can skip these questions. The optional integrations card lists only supported
+tools you said you use and opens their secure configuration without leaving setup.
+If there are no eligible matches, setup moves on without showing suggestions.
+Tools without a built-in connector are not presented as supported. Use
+**Keep going** to move on without connecting; you can connect tools later in
+Settings. Integration choices do not change the starter tasks offered.
+Services that are also source-control, communications, inference, or sandbox
+providers are excluded from this optional step; their separate setup is unchanged.
+The Vercel deployments integration remains available separately from Vercel AI
+Gateway inference.
+
Setup completes once inference and a sandbox provider are ready, source control
is successfully configured, and at least one repository has synchronized.
Roomote then offers preselected starter tasks in one structured multi-select
diff --git a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx
index 1a45f0b95e..fd2bb4b1f0 100644
--- a/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx
+++ b/apps/web/src/app/(authenticated)/home/OnboardingCard.tsx
@@ -5,7 +5,12 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { AnimatePresence, motion } from 'motion/react';
import { toast } from 'sonner';
-import { MCP_INTEGRATIONS } from '@roomote/types';
+import {
+ MCP_INTEGRATIONS,
+ ADMIN_INTEGRATION_ORDER,
+ COMMUNICATION_PROVIDER_ORDER,
+ SOURCE_CONTROL_PROVIDER_ORDER,
+} from '@roomote/types';
import { useAuthorizedUser } from '@/hooks/useUser';
import {
@@ -50,19 +55,6 @@ import { TelegramLinkAccountStep } from '@/components/settings/TelegramLinkAccou
const DISMISSED_KEY = 'OnboardingCardsDismissedByOrg';
const DISMISSED_DEPLOYMENT_KEY = 'deployment';
-const ADMIN_INTEGRATION_ORDER = [
- 'notion',
- 'sentry',
- 'linear',
- 'jira',
- 'monday',
- 'vercel',
- 'supabase',
- 'posthog',
- 'grafana',
- 'asana',
-] as const;
-
const PERSONAL_MCP_INTEGRATION_ORDER = ['monday', 'supabase'] as const;
const CARD_EXIT_TRANSITION = {
@@ -82,21 +74,6 @@ const CARD_ANIMATION = {
exit: { opacity: 0, y: -20, transition: CARD_EXIT_TRANSITION },
} as const;
-const COMMUNICATION_PROVIDER_ORDER = [
- 'slack',
- 'microsoft',
- 'telegram',
- 'discord',
-] as const;
-
-const SOURCE_CONTROL_PROVIDER_ORDER = [
- 'github',
- 'gitlab',
- 'gitea',
- 'bitbucket',
- 'ado',
-] as const;
-
type CardConfig = {
id: string;
icon: ReactNode;
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx
index fd0aafce29..ca7505e714 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx
@@ -179,6 +179,9 @@ vi.mock('./SessionUserInputCard', async (importOriginal) => ({
vi.mock('./setup/SetupStarterTasksCard', () => ({
SetupStarterTasksCard: () => Setup starter tasks
,
}));
+vi.mock('./setup/SetupIntegrationsCard', () => ({
+ SetupIntegrationsCard: () => Optional integration setup
,
+}));
class FakeEventSource {
static instances: FakeEventSource[] = [];
@@ -482,14 +485,93 @@ describe('FastSessionTranscript', () => {
});
});
- it('removes a structured-input card when its response control event arrives', () => {
- const requestId = 'rui:setup-starters';
+ it.each(['setup_starter_tasks', 'setup_integrations'])(
+ 'renders and removes the %s card when its response control event arrives',
+ (preset) => {
+ const requestId = 'rui:setup-starters';
+ const request = {
+ ...textMessage({
+ id: 'starter-request',
+ role: 'assistant',
+ text: 'Choose starter tasks',
+ ts: 1,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ payload: {
+ requestId,
+ status: 'pending',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ preset,
+ questions: [
+ {
+ id: 'starters',
+ question: 'What should I work on first?',
+ multiple: true,
+ isOther: false,
+ isSecret: false,
+ options: [{ label: 'Speed up CI', description: 'Improve CI.' }],
+ },
+ ],
+ },
+ };
+ const response = {
+ ...textMessage({
+ id: 'starter-response',
+ role: 'user',
+ text: 'Structured response',
+ ts: 2,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
+ payload: {
+ requestId,
+ answers: { starters: { answers: ['Speed up CI'] } },
+ resolution: 'submitted',
+ },
+ };
+
+ const { unmount } = render(
+ ,
+ );
+ const cardLabel =
+ preset === 'setup_integrations'
+ ? 'Optional integration setup'
+ : 'Setup starter tasks';
+ expect(screen.getByText(cardLabel)).toBeInTheDocument();
+ unmount();
+ render(
+ ,
+ );
+
+ expect(screen.queryByText('Structured input request')).toBeNull();
+ expect(screen.getByText('Structured response')).toBeInTheDocument();
+ expect(screen.getByLabelText('Test User')).toBeInTheDocument();
+ expect(screen.queryByText(cardLabel)).toBeNull();
+ },
+ );
+
+ it('renders a structured response once in chronology as human-authored text', () => {
+ const requestId = 'rui:chronology';
+ const question = 'Which direction should I take?';
const request = {
...textMessage({
- id: 'starter-request',
+ id: 'input-request',
role: 'assistant',
- text: 'Choose starter tasks',
- ts: 1,
+ text: question,
+ ts: 2,
}),
eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
payload: {
@@ -498,30 +580,31 @@ describe('FastSessionTranscript', () => {
sessionId: 'session-1',
turnId: 'turn-1',
callId: 'call-1',
- preset: 'setup_starter_tasks',
questions: [
{
- id: 'starters',
- question: 'What should I work on first?',
- multiple: true,
- isOther: false,
+ id: 'direction',
+ header: 'Direction',
+ question,
+ isOther: true,
isSecret: false,
- options: [{ label: 'Speed up CI', description: 'Improve CI.' }],
},
],
},
};
const response = {
...textMessage({
- id: 'starter-response',
+ id: 'input-response',
role: 'user',
- text: 'Structured response',
- ts: 2,
+ text: 'Legacy persisted answer',
+ ts: 3,
}),
eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
payload: {
requestId,
- answers: { starters: { answers: ['Speed up CI'] } },
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ answers: { direction: { answers: ['Use the narrow path'] } },
resolution: 'submitted',
},
};
@@ -529,12 +612,313 @@ describe('FastSessionTranscript', () => {
render(
,
+ );
+
+ const before = screen.getByText('Before the question');
+ const answer = screen.getByText('Use the narrow path');
+ const after = screen.getByText('After the answer');
+ expect(before.compareDocumentPosition(answer)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(answer.compareDocumentPosition(after)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(screen.getAllByText(question)).toHaveLength(1);
+ expect(screen.queryByText('Legacy persisted answer')).toBeNull();
+ expect(screen.getByLabelText('Transcript Owner')).toBeInTheDocument();
+ });
+
+ it('hides request_user_input tool lifecycle rows while keeping the interaction card', () => {
+ const requestId = 'rui:hidden-tools';
+ const toolPayload = {
+ toolCallId: 'turn-1:tool:0',
+ title: 'request_user_input',
+ kind: 'tool',
+ status: 'completed',
+ isExecute: false,
+ isRead: false,
+ isMcp: false,
+ mcpServerName: null,
+ mcpToolName: null,
+ toolName: 'request_user_input',
+ command: null,
+ rawInput: { arguments: { question: 'Hidden tool question' } },
+ };
+ const toolBase = {
+ id: 'request-tool',
+ eventId: 'turn-1:tool:0',
+ turnId: 'turn-1',
+ turnSeq: 1,
+ ts: 1,
+ role: 'tool' as const,
+ metadata: { visibleInTranscript: true },
+ source: 'web',
+ nativeSessionId: 'opencode-1',
+ nativeMessageId: null,
+ createdAt: new Date('2026-01-01T00:00:00.000Z'),
+ };
+ const request = {
+ ...textMessage({
+ id: 'input-request',
+ role: 'assistant',
+ text: 'Choose a path',
+ ts: 2,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ payload: {
+ requestId,
+ status: 'pending',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ questions: [
+ {
+ id: 'path',
+ header: 'Path',
+ question: 'Choose a path',
+ isOther: true,
+ isSecret: false,
+ },
+ ],
+ },
+ };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByText('Structured input request')).toBeInTheDocument();
+ expect(screen.queryByText('Asked for')).toBeNull();
+ expect(screen.queryByText('human guidance')).toBeNull();
+ expect(screen.queryByText('Hidden tool result')).toBeNull();
+ expect(screen.queryByText('Choose a path')).toBeNull();
+ });
+
+ it.each([
+ ['failed', 'Failed to Ask for'],
+ ['completed', 'Asked for'],
+ ] as const)(
+ 'keeps a %s request_user_input tool row when no interaction card was persisted',
+ (status, actionLabel) => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText(actionLabel)).toBeInTheDocument();
+ expect(screen.getByText('human guidance')).toBeInTheDocument();
+ if (status === 'failed') {
+ expect(screen.getByText('Failed')).toBeInTheDocument();
+ } else {
+ expect(screen.getByText('Completed')).toBeInTheDocument();
+ }
+ expect(screen.queryByText('Structured input request')).toBeNull();
+ },
+ );
+
+ it('places a pending interaction at its chronological position', () => {
+ const request = {
+ ...textMessage({
+ id: 'input-request',
+ role: 'assistant',
+ text: 'Choose a path',
+ ts: 2,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ payload: {
+ requestId: 'rui:pending-order',
+ status: 'pending',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ questions: [
+ {
+ id: 'path',
+ header: 'Path',
+ question: 'Choose a path',
+ isOther: true,
+ isSecret: false,
+ },
+ ],
+ },
+ };
+ render(
+ ,
);
- expect(screen.queryByText('Structured input request')).toBeNull();
- expect(screen.queryByText('Structured response')).toBeNull();
+ const before = screen.getByText('Before pending input');
+ const interaction = screen.getByText('Structured input request');
+ const after = screen.getByText('Later transcript activity');
+ expect(before.compareDocumentPosition(interaction)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ expect(interaction.compareDocumentPosition(after)).toBe(
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ );
+ });
+
+ it('keeps the composer available for non-preset input requests', () => {
+ const request = {
+ ...textMessage({
+ id: 'input-request',
+ role: 'assistant',
+ text: 'Choose or write another direction',
+ ts: 1,
+ }),
+ eventType: ACP_ENVELOPE_EVENT_TYPES.RequestUserInput,
+ payload: {
+ requestId: 'rui:optional',
+ status: 'pending',
+ sessionId: 'session-1',
+ turnId: 'turn-1',
+ callId: 'call-1',
+ questions: [
+ {
+ id: 'direction',
+ header: 'Direction',
+ question: 'Choose or write another direction',
+ isOther: true,
+ isSecret: false,
+ },
+ ],
+ },
+ };
+
+ const { unmount } = render(
+ ,
+ );
+ expect(screen.getByPlaceholderText('Message agent')).toBeInTheDocument();
+
+ unmount();
+ render(
+ ,
+ );
+ expect(screen.queryByPlaceholderText('Message agent')).toBeNull();
+ expect(screen.getByText('Setup starter tasks')).toBeInTheDocument();
});
it.each([
@@ -1942,7 +2326,7 @@ describe('FastSessionTranscript', () => {
expect(input.value).toBe('Do not lose me');
});
- it('shows structured input instead of the ordinary composer while pending', () => {
+ it('shows structured input with the ordinary composer while non-preset input is pending', () => {
render(
{
);
expect(screen.getByText('Structured input request')).toBeVisible();
- expect(screen.queryByPlaceholderText('Message agent')).toBeNull();
+ expect(screen.getByPlaceholderText('Message agent')).toBeInTheDocument();
});
it('updates the header title from the session stream event', () => {
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
index 72ed37fe06..ea9581a36f 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx
@@ -12,9 +12,12 @@ import {
import {
ACP_ENVELOPE_EVENT_TYPES,
SETUP_RECEIPT_INPUT_KIND,
+ formatRequestUserInputResponseText,
getImageUrisFromContentBlocks,
getTextFromContentBlocks,
inferAcpMessageKind,
+ parseAcpRequestUserInputPayload,
+ parseAcpRequestUserInputResponsePayload,
parsePrReviewActionOffer,
getTaskModelDisplayName,
type AcpMessage,
@@ -63,6 +66,7 @@ import {
SessionUserInputCard,
} from './SessionUserInputCard';
import { SetupStarterTasksCard } from './setup/SetupStarterTasksCard';
+import { SetupIntegrationsCard } from './setup/SetupIntegrationsCard';
import { SESSION_HEADER_CONTENT_CLASS_NAME } from './session-header-layout';
import {
@@ -101,6 +105,33 @@ function getTranscriptMessageText(message: TranscriptMessage) {
: text;
}
+function shouldSuppressRequestUserInputToolMessage(
+ message: TranscriptMessage,
+ requestTurnIds: ReadonlySet,
+) {
+ if (
+ message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolCall &&
+ message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolCallUpdate &&
+ message.eventType !== ACP_ENVELOPE_EVENT_TYPES.ToolResult
+ ) {
+ return false;
+ }
+
+ const payload = message.payload as {
+ toolName?: unknown;
+ title?: unknown;
+ status?: unknown;
+ } | null;
+ const isRequestUserInput =
+ payload?.toolName === 'request_user_input' ||
+ payload?.title === 'request_user_input';
+ return (
+ isRequestUserInput &&
+ payload?.status !== 'failed' &&
+ requestTurnIds.has(message.turnId)
+ );
+}
+
type PendingResponseState = {
pendingAfter: TranscriptOrder | null;
latestVisibleResponse: TranscriptOrder | null;
@@ -589,59 +620,148 @@ export function FastSessionTranscript({
return { messageCount, assistantCount };
}, [serverMessages]);
- const persistedUiMessages = useMemo(
- () =>
- messages
- .filter(
- (message) =>
- !(
- message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage &&
- (message.payload as { taskNavigation?: unknown } | null)
- ?.taskNavigation === true
- ) &&
- message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput &&
- message.eventType !==
- ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse,
+ const pendingInputRequest = useMemo(
+ () => findPendingSessionInputRequest(messages),
+ [messages],
+ );
+ const pendingInputRequestOrder = useMemo(() => {
+ if (!pendingInputRequest) return null;
+
+ return (
+ messages.find((message) => {
+ if (message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput) {
+ return false;
+ }
+ return (
+ parseAcpRequestUserInputPayload(message.payload)?.requestId ===
+ pendingInputRequest.requestId
+ );
+ }) ?? null
+ );
+ }, [messages, pendingInputRequest]);
+ const { requestUserInputById, requestUserInputTurnIds } = useMemo(() => {
+ const requests = new Map<
+ string,
+ NonNullable>
+ >();
+ const turnIds = new Set();
+ for (const message of messages) {
+ if (message.eventType !== ACP_ENVELOPE_EVENT_TYPES.RequestUserInput) {
+ continue;
+ }
+ const request = parseAcpRequestUserInputPayload(message.payload);
+ if (request) {
+ requests.set(request.requestId, request);
+ turnIds.add(request.turnId);
+ }
+ }
+ return {
+ requestUserInputById: requests,
+ requestUserInputTurnIds: turnIds,
+ };
+ }, [messages]);
+ const { persistedBeforeInput, persistedAfterInput } = useMemo(() => {
+ const before: AcpUiMessage[] = [];
+ const after: AcpUiMessage[] = [];
+
+ for (const message of messages) {
+ if (
+ (message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage &&
+ (message.payload as { taskNavigation?: unknown } | null)
+ ?.taskNavigation === true) ||
+ message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInput ||
+ shouldSuppressRequestUserInputToolMessage(
+ message,
+ requestUserInputTurnIds,
)
- .map((message) => {
- const uiMessage = toAcpUiMessage({
- // A reply keeps the id its streamed chunks rendered under, so the
- // persisted row reconciles in place instead of remounting.
- id:
- message.role === 'assistant' &&
- message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage
- ? `assistant:${message.eventId}`
- : message.id,
- ts: message.ts,
- eventType: message.eventType as AcpEventType,
- role: message.role,
- kind: inferAcpMessageKind(message.eventType),
- contentBlocks: message.contentBlocks,
- metadata: message.metadata,
- payload: message.payload,
- text: getTranscriptMessageText(message),
- userName: message.userName,
- userEmail: message.userEmail,
- userImageUrl: message.userImageUrl,
- });
+ ) {
+ continue;
+ }
- if (
- uiMessage.role !== 'user' ||
- !owner ||
- uiMessage.userId !== owner.userId
- ) {
- return uiMessage;
- }
+ let uiMessage = toAcpUiMessage({
+ // A reply keeps the id its streamed chunks rendered under, so the
+ // persisted row reconciles in place instead of remounting.
+ id:
+ message.role === 'assistant' &&
+ message.eventType === ACP_ENVELOPE_EVENT_TYPES.AssistantMessage
+ ? `assistant:${message.eventId}`
+ : message.id,
+ ts: message.ts,
+ eventType: message.eventType as AcpEventType,
+ role: message.role,
+ kind: inferAcpMessageKind(message.eventType),
+ contentBlocks: message.contentBlocks,
+ metadata: message.metadata,
+ payload: message.payload,
+ text: getTranscriptMessageText(message),
+ userName: message.userName,
+ userEmail: message.userEmail,
+ userImageUrl: message.userImageUrl,
+ });
- return {
- ...uiMessage,
- userName: uiMessage.userName ?? owner.name,
- userEmail: uiMessage.userEmail ?? owner.email,
- userImageUrl: uiMessage.userImageUrl ?? owner.imageUrl,
- };
- }),
- [messages, owner],
- );
+ if (
+ message.eventType === ACP_ENVELOPE_EVENT_TYPES.RequestUserInputResponse
+ ) {
+ const response = parseAcpRequestUserInputResponsePayload(
+ message.payload,
+ );
+ const requestId =
+ response?.requestId ??
+ (typeof message.payload?.requestId === 'string'
+ ? message.payload.requestId
+ : null);
+ const request = requestId
+ ? (requestUserInputById.get(requestId) ?? null)
+ : null;
+ uiMessage = {
+ ...uiMessage,
+ role: 'user',
+ kind: 'text',
+ text:
+ response !== null
+ ? formatRequestUserInputResponseText(request, response)
+ : (getTranscriptMessageText(message) ??
+ 'Submitted input response'),
+ data: request
+ ? { ...(message.payload ?? {}), request }
+ : (message.payload ?? {}),
+ userId: uiMessage.userId ?? owner?.userId,
+ userName: uiMessage.userName ?? owner?.name,
+ userEmail: uiMessage.userEmail ?? owner?.email,
+ userImageUrl: uiMessage.userImageUrl ?? owner?.imageUrl,
+ };
+ } else if (
+ uiMessage.role === 'user' &&
+ owner &&
+ uiMessage.userId === owner.userId
+ ) {
+ uiMessage = {
+ ...uiMessage,
+ userName: uiMessage.userName ?? owner.name,
+ userEmail: uiMessage.userEmail ?? owner.email,
+ userImageUrl: uiMessage.userImageUrl ?? owner.imageUrl,
+ };
+ }
+
+ const target =
+ pendingInputRequestOrder &&
+ compareTranscriptOrder(message, pendingInputRequestOrder) > 0
+ ? after
+ : before;
+ target.push(uiMessage);
+ }
+
+ return {
+ persistedBeforeInput: before,
+ persistedAfterInput: after,
+ };
+ }, [
+ messages,
+ owner,
+ pendingInputRequestOrder,
+ requestUserInputById,
+ requestUserInputTurnIds,
+ ]);
const hasVisibleAssistantMessage = useMemo(
() =>
messages.some(
@@ -652,10 +772,6 @@ export function FastSessionTranscript({
),
[messages],
);
- const pendingInputRequest = useMemo(
- () => findPendingSessionInputRequest(messages),
- [messages],
- );
const reviewOffers = useMemo(
() =>
messages.flatMap((message) => {
@@ -664,15 +780,37 @@ export function FastSessionTranscript({
}),
[messages],
);
- const uiMessages = useMemo(
- () =>
- streamMessages.length === 0
- ? persistedUiMessages
- : [...persistedUiMessages, ...streamMessages],
- [persistedUiMessages, streamMessages],
- );
- const { renderBlocks, suppressMessage } = useAcpTranscriptBlocks({
- messages: uiMessages,
+ const { uiMessagesBeforeInput, uiMessagesAfterInput } = useMemo(() => {
+ if (!pendingInputRequestOrder) {
+ return {
+ uiMessagesBeforeInput: [
+ ...persistedBeforeInput,
+ ...persistedAfterInput,
+ ...streamMessages,
+ ],
+ uiMessagesAfterInput: [],
+ };
+ }
+
+ const before = [...persistedBeforeInput];
+ const after = [...persistedAfterInput];
+ for (const message of streamMessages) {
+ (message.ts <= pendingInputRequestOrder.ts ? before : after).push(
+ message,
+ );
+ }
+ return { uiMessagesBeforeInput: before, uiMessagesAfterInput: after };
+ }, [
+ pendingInputRequestOrder,
+ persistedAfterInput,
+ persistedBeforeInput,
+ streamMessages,
+ ]);
+ const {
+ renderBlocks: renderBlocksBeforeInput,
+ suppressMessage: suppressMessageBeforeInput,
+ } = useAcpTranscriptBlocks({
+ messages: uiMessagesBeforeInput,
artifacts: [],
displayMode,
initialPrompt: null,
@@ -680,7 +818,21 @@ export function FastSessionTranscript({
showInternalMessages: false,
hasLeadingTextBoundary: false,
keepDelegatedTasksVisible: true,
- resetKey: `${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`,
+ resetKey: `before:${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`,
+ });
+ const {
+ renderBlocks: renderBlocksAfterInput,
+ suppressMessage: suppressMessageAfterInput,
+ } = useAcpTranscriptBlocks({
+ messages: uiMessagesAfterInput,
+ artifacts: [],
+ displayMode,
+ initialPrompt: null,
+ shouldHideFirstMessage: false,
+ showInternalMessages: false,
+ hasLeadingTextBoundary: false,
+ keepDelegatedTasksVisible: true,
+ resetKey: `after:${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`,
});
const sendReply = useCallback(
@@ -815,9 +967,36 @@ export function FastSessionTranscript({
) : null}
+ {pendingInputRequest ? (
+
+ {pendingInputRequest.preset === 'setup_starter_tasks' ? (
+
+ ) : pendingInputRequest.preset === 'setup_integrations' ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+
{hasVisibleAssistantMessage ? timelineExtras : null}
@@ -848,25 +1027,10 @@ export function FastSessionTranscript({
}
/>
))}
- {pendingInputRequest ? (
-
- {pendingInputRequest.preset === 'setup_starter_tasks' ? (
-
- ) : (
-
- )}
-
- ) : null}
- {canReply && !pendingInputRequest ? (
+ {canReply && !pendingInputRequest?.preset ? (
{
mockMutate.mockClear();
});
+ it('allows skipping tool discovery before entering an answer', () => {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Skip tool setup' }));
+ expect(mockMutate).toHaveBeenCalledWith({
+ sessionId: 's',
+ requestId: 'tools',
+ answers: {},
+ resolution: 'cancelled',
+ });
+ });
+
it('requires the minimum number of selections before submitting', () => {
render();
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx
index 3af15b1246..c1d7479e21 100644
--- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx
+++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionUserInputCard.tsx
@@ -3,6 +3,8 @@
import { useMemo, useState } from 'react';
import {
parseAcpRequestUserInputPayload,
+ SETUP_INTEGRATION_CATEGORIES,
+ getSetupIntegrationQuestionId,
type AcpRequestUserInputPayload,
} from '@roomote/types';
@@ -356,7 +358,14 @@ export function SessionUserInputCard({
})
}
>
- Cancel
+ {request.questions.some((question) =>
+ SETUP_INTEGRATION_CATEGORIES.some(
+ (category) =>
+ getSetupIntegrationQuestionId(category.id) === question.id,
+ ),
+ )
+ ? 'Skip tool setup'
+ : 'Cancel'}
) : null}