diff --git a/apps/api/src/handlers/slack/events/active-run-request-user-input.test.ts b/apps/api/src/handlers/slack/events/active-run-request-user-input.test.ts new file mode 100644 index 000000000..21b2da61d --- /dev/null +++ b/apps/api/src/handlers/slack/events/active-run-request-user-input.test.ts @@ -0,0 +1,185 @@ +const { + advancePendingSlackRequestUserInputQuestionMock, + authorizeSlackRunReplyTargetMock, + deliveryTrackerCommitMock, + deliveryTrackerTrackMock, + deliverPendingSlackRequestUserInputQuestionMock, + getPendingSlackRequestUserInputMock, + parseAcpRequestUserInputAnswerReplyMock, +} = vi.hoisted(() => ({ + advancePendingSlackRequestUserInputQuestionMock: vi.fn(), + authorizeSlackRunReplyTargetMock: vi.fn(), + deliveryTrackerCommitMock: vi.fn(), + deliveryTrackerTrackMock: vi.fn(), + deliverPendingSlackRequestUserInputQuestionMock: vi.fn(), + getPendingSlackRequestUserInputMock: vi.fn(), + parseAcpRequestUserInputAnswerReplyMock: vi.fn(), +})); + +vi.mock('@roomote/env', () => ({ + Env: { R_APP_URL: 'http://localhost:3000' }, +})); + +vi.mock('@roomote/types', async (importOriginal) => ({ + ...(await importOriginal()), + parseAcpRequestUserInputAnswerReply: parseAcpRequestUserInputAnswerReplyMock, +})); + +vi.mock('@roomote/cloud-agents', () => ({ + stripLeadingRawSlackMention: vi.fn((text: string) => text), + stripLeadingSlackProductMention: vi.fn((text: string) => text), +})); + +vi.mock('@roomote/slack', async (importOriginal) => ({ + ...(await importOriginal()), + advancePendingSlackRequestUserInputQuestion: + advancePendingSlackRequestUserInputQuestionMock, + authorizeSlackRunReplyTarget: authorizeSlackRunReplyTargetMock, + deliverPendingSlackRequestUserInputQuestion: + deliverPendingSlackRequestUserInputQuestionMock, + getPendingSlackRequestUserInput: getPendingSlackRequestUserInputMock, + getSlackRequestUserInputCurrentQuestion: vi.fn((request) => ({ + question: request.questions[request.currentQuestionIndex], + questionIndex: request.currentQuestionIndex, + })), + SlackThreadDeliveryTracker: class { + commit = deliveryTrackerCommitMock; + rollback = vi.fn(); + track = deliveryTrackerTrackMock; + }, +})); + +vi.mock('@roomote/db/server', async (importOriginal) => ({ + ...(await importOriginal()), + setTrustedRunActingUserOnSuccess: vi.fn(), +})); + +import { processActiveRunMessage } from './active-run'; + +describe('typed Slack request_user_input replies', () => { + const questions = [ + { + id: 'language', + header: 'Language', + question: 'Which language should I use?', + isOther: true, + isSecret: false, + options: [{ label: 'TypeScript', description: 'Use the app stack.' }], + }, + { + id: 'style', + header: 'Style', + question: 'What should the UI optimize for?', + isOther: true, + isSecret: false, + options: [{ label: 'Dashboard', description: 'Pane-first interface.' }], + }, + ]; + const pendingRequest = { + requestId: 'rui:session:turn:call', + runId: 42, + taskId: 'task-1', + promptMessageTs: 'prompt-ts', + questions, + currentQuestionIndex: 0, + answers: {}, + status: 'pending' as const, + createdAt: 123, + }; + const event = { + type: 'message', + channel: 'C123', + channel_type: 'channel', + thread_ts: '111.222', + user: 'U123', + ts: '333.444', + text: 'TypeScript', + } as never; + const activeRun = { + id: 42, + actingUserId: 'user-1', + result: null, + taskId: 'task-1', + }; + + beforeEach(() => { + vi.clearAllMocks(); + getPendingSlackRequestUserInputMock.mockResolvedValue(pendingRequest); + authorizeSlackRunReplyTargetMock.mockResolvedValue(undefined); + advancePendingSlackRequestUserInputQuestionMock.mockResolvedValue(true); + deliverPendingSlackRequestUserInputQuestionMock.mockResolvedValue( + undefined, + ); + deliveryTrackerCommitMock.mockResolvedValue(undefined); + parseAcpRequestUserInputAnswerReplyMock + .mockReturnValueOnce(null) + .mockReturnValueOnce({ + resolution: 'answered', + answers: { language: { answers: ['TypeScript'] } }, + }); + }); + + it('routes delivery through the shared lifecycle after advancing once', async () => { + const slack = { + normalizeIncomingText: vi.fn(async (text: string) => text), + postMessage: vi.fn(), + updateMessage: vi.fn(), + }; + + await processActiveRunMessage( + event, + slack as never, + 'user-1', + activeRun, + 'T123', + ); + + expect( + advancePendingSlackRequestUserInputQuestionMock, + ).toHaveBeenCalledTimes(1); + expect( + deliverPendingSlackRequestUserInputQuestionMock, + ).toHaveBeenCalledWith( + expect.objectContaining({ + slack, + channel: 'C123', + threadId: '111.222', + request: expect.objectContaining({ + currentQuestionIndex: 1, + answers: { language: { answers: ['TypeScript'] } }, + }), + previousQuestion: questions[0], + previousAnswer: 'TypeScript', + }), + ); + expect(slack.updateMessage).not.toHaveBeenCalled(); + expect(slack.postMessage).not.toHaveBeenCalled(); + }); + + it('does not retire the prompt directly when shared delivery fails', async () => { + const slack = { + normalizeIncomingText: vi.fn(async (text: string) => text), + postMessage: vi.fn(), + updateMessage: vi.fn(), + }; + deliverPendingSlackRequestUserInputQuestionMock.mockRejectedValueOnce( + new Error('next prompt delivery failed'), + ); + + await expect( + processActiveRunMessage( + event, + slack as never, + 'user-1', + activeRun, + 'T123', + ), + ).rejects.toThrow('next prompt delivery failed'); + + expect( + advancePendingSlackRequestUserInputQuestionMock, + ).toHaveBeenCalledTimes(1); + expect(slack.updateMessage).not.toHaveBeenCalled(); + expect(slack.postMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/handlers/slack/events/active-run.ts b/apps/api/src/handlers/slack/events/active-run.ts index 3fdf230f7..fefd684ed 100644 --- a/apps/api/src/handlers/slack/events/active-run.ts +++ b/apps/api/src/handlers/slack/events/active-run.ts @@ -8,20 +8,18 @@ import { authorizeSlackRunReplyTarget, clearLatestUserMessage, clearPendingSlackRequestUserInput, + deliverPendingSlackRequestUserInputQuestion, buildSlackAnsweredRequestUserInputBlocks, - buildSlackRequestUserInputBlocks, collectAndProcessThreadImages, getPromptReadyThreadMessages, getLatestSlackBotReply, getPendingSlackRequestUserInput, getSlackRequestUserInputCurrentQuestion, - getSlackThreadFooterText, queueSlackMessage, resolveCurrentSlackMessageFiles, type SlackThreadMessage, type SlackEvent, type SlackNotifier, - setPendingSlackRequestUserInputPromptMessageTs, SlackThreadDeliveryTracker, submitPendingSlackRequestUserInputAnswer, advancePendingSlackRequestUserInputQuestion, @@ -276,46 +274,19 @@ async function handlePendingRequestUserInputReply(params: { return 'handled'; } - if (pendingRequest.promptMessageTs && selectedAnswer) { - await slack.updateMessage({ - channel: event.channel, - ts: pendingRequest.promptMessageTs, - message: { - blocks: buildSlackAnsweredRequestUserInputBlocks({ - question: currentQuestion.question, - answer: selectedAnswer, - }), - }, - }); - } - - const nextPromptMessageTs = await slack.postMessage({ + await deliverPendingSlackRequestUserInputQuestion({ + slack, channel: event.channel, - thread_ts: threadId, - blocks: buildSlackRequestUserInputBlocks({ - requestId: pendingRequest.requestId, - questions: pendingRequest.questions, + threadId, + request: { + ...pendingRequest, currentQuestionIndex: nextQuestionIndex, answers: nextAnswers, - footerText: await getSlackThreadFooterText({ - taskUrl: buildSlackRequestUserInputTaskUrl(activeRun), - taskId: activeRun.taskId, - prRepo: null, - prNumber: null, - channelId: event.channel, - threadTs: threadId, - }), - }), + }, + previousQuestion: currentQuestion.question, + previousAnswer: selectedAnswer, + taskUrl: buildSlackRequestUserInputTaskUrl(activeRun), }); - - if (nextPromptMessageTs) { - await setPendingSlackRequestUserInputPromptMessageTs( - threadId, - pendingRequest.requestId, - nextQuestionIndex, - nextPromptMessageTs, - ); - } deliveryTracker.track(event.ts); return 'handled'; diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index db6579775..ba9b7bb98 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -592,7 +592,7 @@ export function CustomAutomationsSection({ [ row.name, row.prompt, - cadenceLabel(row), + cadenceLabel(row, schedulingTimeZone), environmentName, destinationName, destinationLabel, diff --git a/packages/slack/src/__tests__/handle-followup-answer.test.ts b/packages/slack/src/__tests__/handle-followup-answer.test.ts index 882a675da..1ea788186 100644 --- a/packages/slack/src/__tests__/handle-followup-answer.test.ts +++ b/packages/slack/src/__tests__/handle-followup-answer.test.ts @@ -428,6 +428,9 @@ describe('handleFollowupAnswer', () => { expect.objectContaining({ channel: 'C123', thread_ts: '111.222', + client_msg_id: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ), blocks: expect.arrayContaining([ expect.objectContaining({ type: 'context', @@ -454,6 +457,217 @@ describe('handleFollowupAnswer', () => { ts: 'prompt-ts', }), ); + expect(postMessageMock.mock.invocationCallOrder[0]!).toBeLessThan( + updateMessageMock.mock.invocationCallOrder[0]!, + ); + }); + + it('automatically retries next-prompt delivery without advancing state twice', async () => { + selectLimitMock.mockResolvedValue([{ userId: 'user-1' }]); + const questions = [ + { + id: 'language', + header: 'Language', + question: 'Which language should I use?', + isOther: true, + isSecret: false, + options: [{ label: 'TypeScript', description: 'Use the app stack.' }], + }, + { + id: 'style', + header: 'Style', + question: 'What should the UI optimize for?', + isOther: true, + isSecret: false, + options: [{ label: 'Dashboard', description: 'Pane-first interface.' }], + }, + ]; + getPendingSlackRequestUserInputMock.mockResolvedValue({ + requestId: 'rui:session:turn:call', + runId: 42, + taskId: 'task-1', + promptMessageTs: '111.222', + questions, + currentQuestionIndex: 0, + answers: {}, + status: 'pending', + createdAt: 123, + }); + postMessageMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce('next-prompt-ts'); + + await handleFollowupAnswer(buildPayload()); + + expect(updateMessageMock).toHaveBeenCalledTimes(1); + expect(postMessageMock.mock.invocationCallOrder[1]!).toBeLessThan( + updateMessageMock.mock.invocationCallOrder[0]!, + ); + expect( + advancePendingSlackRequestUserInputQuestionMock, + ).toHaveBeenCalledTimes(1); + expect(postMessageMock).toHaveBeenCalledTimes(2); + expect(postMessageMock.mock.calls[0]![0].client_msg_id).toBe( + postMessageMock.mock.calls[1]![0].client_msg_id, + ); + expect( + setPendingSlackRequestUserInputPromptMessageTsMock, + ).toHaveBeenCalledTimes(1); + expect( + setPendingSlackRequestUserInputPromptMessageTsMock, + ).toHaveBeenCalledWith( + '111.222', + 'rui:session:turn:call', + 1, + 'next-prompt-ts', + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('keeps one duplicate-safe delivery identity across automatic attempts', async () => { + selectLimitMock.mockResolvedValue([{ userId: 'user-1' }]); + getPendingSlackRequestUserInputMock.mockResolvedValue({ + requestId: 'rui:session:turn:call', + runId: 42, + taskId: 'task-1', + promptMessageTs: '111.222', + questions: [ + { + id: 'language', + header: 'Language', + question: 'Which language should I use?', + isOther: true, + isSecret: false, + options: [{ label: 'TypeScript', description: 'Use the app stack.' }], + }, + { + id: 'style', + header: 'Style', + question: 'What should the UI optimize for?', + isOther: true, + isSecret: false, + options: [ + { label: 'Dashboard', description: 'Pane-first interface.' }, + ], + }, + ], + currentQuestionIndex: 0, + answers: {}, + status: 'pending', + createdAt: 123, + }); + postMessageMock + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce('next-prompt-ts'); + + await handleFollowupAnswer(buildPayload()); + + expect(postMessageMock.mock.calls[0]![0]).toEqual( + postMessageMock.mock.calls[1]![0], + ); + expect(postMessageMock.mock.calls[0]![0].client_msg_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + }); + + it('reports a concise failure after automatic delivery attempts are exhausted', async () => { + selectLimitMock.mockResolvedValue([{ userId: 'user-1' }]); + getPendingSlackRequestUserInputMock.mockResolvedValue({ + requestId: 'rui:session:turn:call', + runId: 42, + taskId: 'task-1', + promptMessageTs: '111.222', + questions: [ + { + id: 'language', + header: 'Language', + question: 'Which language should I use?', + isOther: true, + isSecret: false, + options: [{ label: 'TypeScript', description: 'Use the app stack.' }], + }, + { + id: 'style', + header: 'Style', + question: 'What should the UI optimize for?', + isOther: true, + isSecret: false, + options: [ + { label: 'Dashboard', description: 'Pane-first interface.' }, + ], + }, + ], + currentQuestionIndex: 0, + answers: {}, + status: 'pending', + createdAt: 123, + }); + postMessageMock.mockResolvedValue(undefined); + + await handleFollowupAnswer(buildPayload()); + + expect(postMessageMock).toHaveBeenCalledTimes(2); + expect( + advancePendingSlackRequestUserInputQuestionMock, + ).toHaveBeenCalledTimes(1); + expect(updateMessageMock).not.toHaveBeenCalled(); + expect( + setPendingSlackRequestUserInputPromptMessageTsMock, + ).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledWith( + 'https://slack.test/response', + expect.objectContaining({ + body: JSON.stringify({ + replace_original: false, + text: '❌ Failed to process answer: Your answer was saved, but I could not deliver the next question.', + }), + }), + ); + }); + + it('still delivers the next prompt when updating the answered prompt fails', async () => { + selectLimitMock.mockResolvedValue([{ userId: 'user-1' }]); + getPendingSlackRequestUserInputMock.mockResolvedValue({ + requestId: 'rui:session:turn:call', + runId: 42, + taskId: 'task-1', + promptMessageTs: '111.222', + questions: [ + { + id: 'language', + header: 'Language', + question: 'Which language should I use?', + isOther: true, + isSecret: false, + options: [{ label: 'TypeScript', description: 'Use the app stack.' }], + }, + { + id: 'style', + header: 'Style', + question: 'What should the UI optimize for?', + isOther: true, + isSecret: false, + options: [ + { label: 'Dashboard', description: 'Pane-first interface.' }, + ], + }, + ], + currentQuestionIndex: 0, + answers: {}, + status: 'pending', + createdAt: 123, + }); + updateMessageMock.mockRejectedValueOnce(new Error('chat.update failed')); + + await handleFollowupAnswer(buildPayload()); + + expect(postMessageMock).toHaveBeenCalledTimes(1); + expect( + setPendingSlackRequestUserInputPromptMessageTsMock, + ).toHaveBeenCalledWith('111.222', 'rui:session:turn:call', 1, 'posted-ts'); + expect(consoleErrorMock).toHaveBeenCalledWith( + 'Failed to update answered Slack request_user_input prompt: chat.update failed', + ); }); it('queues a plain follow-up answer while the job is still booting', async () => { diff --git a/packages/slack/src/handle-followup-answer.ts b/packages/slack/src/handle-followup-answer.ts index ed81cfa30..afd4d9b01 100644 --- a/packages/slack/src/handle-followup-answer.ts +++ b/packages/slack/src/handle-followup-answer.ts @@ -1,8 +1,11 @@ +import { createHash } from 'node:crypto'; + import { PRODUCT_NAME, activeRunStatuses, getFastAgentParentFromPayload, type AcpRequestUserInputAnswers, + type AcpRequestUserInputQuestion, } from '@roomote/types'; import { Env } from '@roomote/env'; import { @@ -40,6 +43,7 @@ import { getPendingSlackRequestUserInput, setPendingSlackRequestUserInputPromptMessageTs, submitPendingSlackRequestUserInputAnswer, + type PendingSlackRequestUserInput, } from './request-user-input'; import { getSlackThreadFooterText } from './thread-footer'; @@ -53,6 +57,7 @@ interface StructuredRequestUserInputButtonValue { const REQUEST_USER_INPUT_ALREADY_RECEIVED_TEXT = 'I already received your answer. Please wait for the agent to continue.'; +const REQUEST_USER_INPUT_PROMPT_DELIVERY_ATTEMPTS = 2; function buildSlackRequestUserInputTaskUrl(params: { taskId: string | null | undefined; @@ -194,6 +199,98 @@ function mergeRequestUserInputAnswers( }; } +function buildRequestUserInputPromptClientMessageId( + requestId: string, + questionIndex: number, +): string { + const digest = createHash('sha256') + .update(`${requestId}:${questionIndex}`) + .digest('hex'); + + return `${digest.slice(0, 8)}-${digest.slice(8, 12)}-5${digest.slice(13, 16)}-${((parseInt(digest[16]!, 16) & 0x3) | 0x8).toString(16)}${digest.slice(17, 20)}-${digest.slice(20, 32)}`; +} + +export async function deliverPendingSlackRequestUserInputQuestion(params: { + slack: SlackNotifier; + channel: string; + threadId: string; + request: PendingSlackRequestUserInput; + previousQuestion?: AcpRequestUserInputQuestion; + previousAnswer?: string; + taskUrl: string; +}): Promise { + const nextPromptMessage = { + channel: params.channel, + thread_ts: params.threadId, + client_msg_id: buildRequestUserInputPromptClientMessageId( + params.request.requestId, + params.request.currentQuestionIndex, + ), + blocks: buildSlackRequestUserInputBlocks({ + requestId: params.request.requestId, + questions: params.request.questions, + currentQuestionIndex: params.request.currentQuestionIndex, + answers: params.request.answers, + footerText: await getSlackThreadFooterText({ + taskUrl: params.taskUrl, + taskId: params.request.taskId, + prRepo: null, + prNumber: null, + channelId: params.channel, + threadTs: params.threadId, + }), + }), + }; + let nextPromptMessageTs: string | undefined; + + for ( + let attempt = 0; + attempt < REQUEST_USER_INPUT_PROMPT_DELIVERY_ATTEMPTS; + attempt += 1 + ) { + nextPromptMessageTs = await params.slack.postMessage(nextPromptMessage); + if (nextPromptMessageTs) { + break; + } + } + + if (!nextPromptMessageTs) { + throw new Error( + 'Your answer was saved, but I could not deliver the next question.', + ); + } + + if ( + params.request.promptMessageTs && + params.previousQuestion && + params.previousAnswer + ) { + await params.slack + .updateMessage({ + channel: params.channel, + ts: params.request.promptMessageTs, + message: { + blocks: buildSlackAnsweredRequestUserInputBlocks({ + question: params.previousQuestion, + answer: params.previousAnswer, + }), + }, + }) + .catch((error) => { + console.error( + `Failed to update answered Slack request_user_input prompt: ${error instanceof Error ? error.message : String(error)}`, + ); + }); + } + + await setPendingSlackRequestUserInputPromptMessageTs( + params.threadId, + params.request.requestId, + params.request.currentQuestionIndex, + nextPromptMessageTs, + ); +} + async function getSlackTeamContext(teamId: string): Promise<{ slack: SlackNotifier; slackInstallation: SlackInstallation; @@ -336,6 +433,44 @@ export async function handleFollowupAnswer(payload: SlackInteractivePayload) { ); } + const previousQuestionIndex = currentQuestion.questionIndex - 1; + const previousQuestion = pendingRequest.questions[previousQuestionIndex]; + const previousAnswers = structuredAnswer.questionId + ? pendingRequest.answers[structuredAnswer.questionId]?.answers + : undefined; + const isPendingDeliveryRetry = + previousQuestionIndex >= 0 && + structuredAnswer.questionIndex === previousQuestionIndex && + structuredAnswer.questionId === previousQuestion?.id && + previousAnswers?.length === 1 && + previousAnswers[0] === structuredAnswer.answer; + + if (isPendingDeliveryRetry) { + if ( + pendingRequest.promptMessageTs && + pendingRequest.promptMessageTs !== payload.message.ts + ) { + await postRequestUserInputAlreadyReceivedResponse( + payload.response_url, + ); + return; + } + + await deliverPendingSlackRequestUserInputQuestion({ + slack, + channel: payload.channel.id, + threadId, + request: pendingRequest, + previousQuestion: previousQuestion!, + previousAnswer: structuredAnswer.answer!, + taskUrl: buildSlackRequestUserInputTaskUrl({ + taskId: activeRun.taskId, + payload: activeRun.payload, + }), + }); + return; + } + if ( structuredAnswer.questionIndex !== undefined && structuredAnswer.questionIndex !== currentQuestion.questionIndex @@ -448,52 +583,23 @@ export async function handleFollowupAnswer(payload: SlackInteractivePayload) { return; } - if (pendingRequest.promptMessageTs) { - await slack.updateMessage({ - channel: payload.channel.id, - ts: pendingRequest.promptMessageTs, - message: { - blocks: buildSlackAnsweredRequestUserInputBlocks({ - question: currentQuestion.question, - answer: structuredAnswer.answer!, - }), - }, - }); - } - - const nextPromptMessageTs = await slack.postMessage({ + await deliverPendingSlackRequestUserInputQuestion({ + slack, channel: payload.channel.id, - thread_ts: threadId, - blocks: buildSlackRequestUserInputBlocks({ - requestId: pendingRequest.requestId, - questions: pendingRequest.questions, + threadId, + request: { + ...pendingRequest, currentQuestionIndex: nextQuestionIndex, answers: nextAnswers, - footerText: await getSlackThreadFooterText({ - taskUrl: buildSlackRequestUserInputTaskUrl({ - taskId: activeRun.taskId, - payload: activeRun.payload, - }), - taskId: activeRun.taskId, - // PR linkage lives on task_pull_requests now; the footer context - // resolves it from the taskId, so no run-level fallback remains. - prRepo: null, - prNumber: null, - channelId: payload.channel.id, - threadTs: threadId, - }), + }, + previousQuestion: currentQuestion.question, + previousAnswer: structuredAnswer.answer!, + taskUrl: buildSlackRequestUserInputTaskUrl({ + taskId: activeRun.taskId, + payload: activeRun.payload, }), }); - if (nextPromptMessageTs) { - await setPendingSlackRequestUserInputPromptMessageTs( - threadId, - pendingRequest.requestId, - nextQuestionIndex, - nextPromptMessageTs, - ); - } - return; }