From e7e8c87ac47440bff498c875029e653a9793d7de Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:13:04 +0000 Subject: [PATCH 1/8] fix(discord): show buttons for multi-question prompts --- .../handlers/discord/request-user-input.ts | 128 +++++++++++++++++- .../discord-request-user-input.test.ts | 43 ++++-- .../src/discord-request-user-input.ts | 45 ++---- .../communication/src/request-user-input.ts | 19 +++ 4 files changed, 188 insertions(+), 47 deletions(-) diff --git a/apps/api/src/handlers/discord/request-user-input.ts b/apps/api/src/handlers/discord/request-user-input.ts index 7d7f14cd8..c66e69058 100644 --- a/apps/api/src/handlers/discord/request-user-input.ts +++ b/apps/api/src/handlers/discord/request-user-input.ts @@ -1,6 +1,9 @@ import { + advancePendingCommunicationRequestUserInputQuestion, buildDiscordAnsweredRequestUserInputText, buildDiscordCancelledRequestUserInputText, + buildDiscordRequestUserInputButtons, + buildDiscordRequestUserInputPromptText, getDiscordRequestUserInputCurrentQuestion, getPendingCommunicationRequestUserInput, clearPendingCommunicationRequestUserInput, @@ -190,8 +193,9 @@ export async function tryHandleDiscordRequestUserInputMessage(params: { return true; } + const current = getDiscordRequestUserInputCurrentQuestion(pendingRequest); const parsedReply = parseAcpRequestUserInputAnswerReply( - pendingRequest.questions, + current ? [current.question] : pendingRequest.questions, params.text, ); @@ -216,6 +220,79 @@ export async function tryHandleDiscordRequestUserInputMessage(params: { return true; } + if (current && pendingRequest.questions.length > 1) { + const answers = { + ...(pendingRequest.answers ?? {}), + ...parsedReply.answers, + }; + const nextQuestionIndex = current.questionIndex + 1; + + if (nextQuestionIndex < pendingRequest.questions.length) { + const advanced = + await advancePendingCommunicationRequestUserInputQuestion( + 'discord', + conversationId, + pendingRequest, + nextQuestionIndex, + answers, + ); + if (!advanced) { + await postAlreadyReceivedNotice({ + provider: params.provider, + applicationId: params.applicationId, + channel: params.channel, + replyToMessageId: params.replyToMessageId, + }); + return true; + } + + if (pendingRequest.promptMessageId) { + const nextPrompt = { + requestId: pendingRequest.requestId, + questions: pendingRequest.questions, + currentQuestionIndex: nextQuestionIndex, + }; + await params.provider.editMessage({ + channelId: conversationId, + messageId: pendingRequest.promptMessageId, + text: buildDiscordRequestUserInputPromptText(nextPrompt), + buttons: buildDiscordRequestUserInputButtons({ + runId: pendingRequest.runId, + request: nextPrompt, + }), + }); + } + + await replyToDiscordEvent({ + provider: params.provider, + applicationId: params.applicationId, + channel: params.channel, + ...(params.replyToMessageId + ? { replyToMessageId: params.replyToMessageId } + : {}), + text: `Picked: ${Object.values(parsedReply.answers) + .flatMap((entry) => entry.answers) + .join(', ')}`, + }); + return true; + } + + await finalizeDiscordRequestUserInputAnswer({ + provider: params.provider, + applicationId: params.applicationId, + channel: params.channel, + activeRunId: params.activeRun.id, + pendingRequest, + answers, + userId: params.userId, + answerText: Object.values(parsedReply.answers) + .flatMap((entry) => entry.answers) + .join(', '), + replyToMessageId: params.replyToMessageId, + }); + return true; + } + await finalizeDiscordRequestUserInputAnswer({ provider: params.provider, applicationId: params.applicationId, @@ -384,16 +461,61 @@ export async function tryHandleDiscordRequestUserInputCallback(params: { } const answers: AcpRequestUserInputAnswers = { + ...(pendingRequest.answers ?? {}), [current.question.id]: { answers: [option.label] }, }; - if (pendingRequest.questions.length !== 1) { + const nextQuestionIndex = current.questionIndex + 1; + if (nextQuestionIndex < pendingRequest.questions.length) { + const advanced = await advancePendingCommunicationRequestUserInputQuestion( + 'discord', + conversationId, + pendingRequest, + nextQuestionIndex, + answers, + ); + if (!advanced) { + await postAlreadyReceivedNotice({ + provider: params.provider, + applicationId: params.applicationId, + channel: params.channel, + interaction: interactionCtx, + }); + return true; + } + + if (!pendingRequest.promptMessageId) { + await replyToDiscordEvent({ + provider: params.provider, + applicationId: params.applicationId, + channel: params.channel, + interaction: interactionCtx, + text: 'Your answer was saved. Reply in the thread to continue.', + ephemeral: true, + }); + return true; + } + + const nextPrompt = { + requestId: pendingRequest.requestId, + questions: pendingRequest.questions, + currentQuestionIndex: nextQuestionIndex, + }; + await params.provider.editMessage({ + channelId: conversationId, + messageId: pendingRequest.promptMessageId, + text: buildDiscordRequestUserInputPromptText(nextPrompt), + buttons: buildDiscordRequestUserInputButtons({ + runId: pendingRequest.runId, + request: nextPrompt, + }), + }); await replyToDiscordEvent({ provider: params.provider, applicationId: params.applicationId, channel: params.channel, interaction: interactionCtx, - text: 'Reply in the thread with one answer per line for multi-question prompts.', + text: `Picked: ${option.label}`, ephemeral: true, }); return true; diff --git a/packages/communication/src/__tests__/discord-request-user-input.test.ts b/packages/communication/src/__tests__/discord-request-user-input.test.ts index 87ef40a97..d53fae55f 100644 --- a/packages/communication/src/__tests__/discord-request-user-input.test.ts +++ b/packages/communication/src/__tests__/discord-request-user-input.test.ts @@ -70,7 +70,7 @@ describe('discord request_user_input helpers', () => { expect(buttons!.at(-1)?.[0]?.text).toBe('Cancel'); }); - it('renders all questions for multi-question prompts without option buttons', () => { + it('renders the current question and option buttons for multi-question prompts', () => { const text = buildDiscordRequestUserInputPromptText({ requestId: 'rui:1', questions: [ @@ -85,8 +85,8 @@ describe('discord request_user_input helpers', () => { ], }); expect(text).toContain('Question 1 of 2'); - expect(text).toContain('Question 2 of 2'); - expect(text).toContain('one answer per line'); + expect(text).not.toContain('Question 2 of 2'); + expect(text).toContain('Pick a button'); const buttons = buildDiscordRequestUserInputButtons({ runId: 99, @@ -95,13 +95,34 @@ describe('discord request_user_input helpers', () => { questions: [sampleQuestion, { ...sampleQuestion, id: 'q2' }], }, }); - expect(buttons).toEqual([ - [ - { - text: 'Cancel', - callbackData: 'discord:rui_cancel:99:callid12', - }, - ], - ]); + expect(buttons?.[0]).toHaveLength(3); + expect(buttons?.at(-1)?.[0]?.text).toBe('Cancel'); + }); + + it('renders a later multi-question prompt from its current question index', () => { + const secondQuestion = { + ...sampleQuestion, + id: 'q2', + question: 'Which release branch should I use?', + }; + const text = buildDiscordRequestUserInputPromptText({ + requestId: 'rui:1', + questions: [sampleQuestion, secondQuestion], + currentQuestionIndex: 1, + }); + + expect(text).toContain('Question 2 of 2'); + expect(text).toContain('Which release branch should I use?'); + expect(text).not.toContain('What bump level should I cut?'); + + const buttons = buildDiscordRequestUserInputButtons({ + runId: 99, + request: { + requestId: 'rui:session:turn:callid12', + questions: [sampleQuestion, secondQuestion], + currentQuestionIndex: 1, + }, + }); + expect(buttons?.[0]?.[0]?.callbackData).toContain(':1:0:'); }); }); diff --git a/packages/communication/src/discord-request-user-input.ts b/packages/communication/src/discord-request-user-input.ts index 85c77af61..87d2520de 100644 --- a/packages/communication/src/discord-request-user-input.ts +++ b/packages/communication/src/discord-request-user-input.ts @@ -149,28 +149,23 @@ export function buildDiscordRequestUserInputPromptText( return '**Input needed**\n\nI could not render this question. Reply with your answer, or `cancel` to skip.'; } - const lines: string[] = []; - if (state.questions.length === 1) { - lines.push(formatSingleQuestion(state.questions[0]!)); - } else { - state.questions.forEach((question, index) => { - if (index > 0) { - lines.push(''); - } - lines.push(`**Question ${index + 1} of ${state.questions.length}**`); - lines.push(formatSingleQuestion(question)); - }); + const current = getDiscordRequestUserInputCurrentQuestion(state); + if (!current) { + return '**Input needed**\n\nI could not render this question. Reply with your answer, or `cancel` to skip.'; } - const single = state.questions.length === 1 ? state.questions[0]! : null; - lines.push(''); - if (!single) { + const lines: string[] = []; + if (state.questions.length > 1) { lines.push( - '_Reply with one answer per line in order (option number, label, or allowed custom text). Reply `cancel` to skip._', + `**Question ${current.questionIndex + 1} of ${state.questions.length}**`, ); - } else if (!single.options || single.options.length === 0) { + } + lines.push(formatSingleQuestion(current.question)); + + lines.push(''); + if (!current.question.options || current.question.options.length === 0) { lines.push('_Reply with your answer, or `cancel` to skip._'); - } else if (questionAllowsCustomAnswer(single)) { + } else if (questionAllowsCustomAnswer(current.question)) { lines.push( '_Pick a button, reply with an option number/label or a custom answer, or `cancel` to skip._', ); @@ -187,22 +182,6 @@ export function buildDiscordRequestUserInputButtons(params: { runId: number; request: DiscordRequestUserInputPromptState; }): CommunicationMessageButton[][] | undefined { - // Buttons are only safe for single-option questions. Multi-question prompts - // must be answered via thread text (one answer per line). - if (params.request.questions.length !== 1) { - return [ - [ - { - text: 'Cancel', - callbackData: buildDiscordRequestUserInputCancelCallbackData({ - runId: params.runId, - requestId: params.request.requestId, - }), - }, - ], - ]; - } - const current = getDiscordRequestUserInputCurrentQuestion(params.request); if (!current) { return undefined; diff --git a/packages/communication/src/request-user-input.ts b/packages/communication/src/request-user-input.ts index b47394905..9e3c18975 100644 --- a/packages/communication/src/request-user-input.ts +++ b/packages/communication/src/request-user-input.ts @@ -286,6 +286,25 @@ export async function submitPendingCommunicationRequestUserInputAnswer( }); } +export async function advancePendingCommunicationRequestUserInputQuestion( + provider: CommunicationProvider, + conversationId: string, + request: PendingCommunicationRequestUserInput, + nextQuestionIndex: number, + answers: AcpRequestUserInputAnswers, +): Promise { + return claimPendingCommunicationRequestUserInputUpdate({ + provider, + conversationId, + expectedQuestionIndex: request.currentQuestionIndex ?? 0, + request: { + ...request, + currentQuestionIndex: nextQuestionIndex, + answers, + }, + }); +} + export async function queueCommunicationRequestUserInputAnswer( provider: CommunicationProvider, runId: number, From 1da97f297e3d5b79e4d9b666f5e9519d5a836d83 Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:07:48 +0000 Subject: [PATCH 2/8] test(discord): cover cancel button row limit --- .../discord-request-user-input.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/communication/src/__tests__/discord-request-user-input.test.ts b/packages/communication/src/__tests__/discord-request-user-input.test.ts index d53fae55f..8ea6c78a7 100644 --- a/packages/communication/src/__tests__/discord-request-user-input.test.ts +++ b/packages/communication/src/__tests__/discord-request-user-input.test.ts @@ -125,4 +125,25 @@ describe('discord request_user_input helpers', () => { }); expect(buttons?.[0]?.[0]?.callbackData).toContain(':1:0:'); }); + + it('reserves a button row for Cancel when options exceed Discord row capacity', () => { + const buttons = buildDiscordRequestUserInputButtons({ + runId: 99, + request: { + requestId: 'rui:session:turn:callid12', + questions: [ + { + ...sampleQuestion, + options: Array.from({ length: 21 }, (_, index) => ({ + label: `Option ${index + 1}`, + description: '', + })), + }, + ], + }, + }); + + expect(buttons).toHaveLength(5); + expect(buttons?.at(-1)?.[0]?.text).toBe('Cancel'); + }); }); From 7c49ee9d48b4c3d1e3aa0c38ed8738c0e787e93f Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:51:03 +0000 Subject: [PATCH 3/8] fix(discord): recover from prompt update failures --- .../__tests__/request-user-input.test.ts | 202 ++++++++++++++++++ .../handlers/discord/request-user-input.ts | 182 ++++++++-------- 2 files changed, 297 insertions(+), 87 deletions(-) create mode 100644 apps/api/src/handlers/discord/__tests__/request-user-input.test.ts 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 000000000..5b6a5a356 --- /dev/null +++ b/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts @@ -0,0 +1,202 @@ +const mocks = vi.hoisted(() => ({ + advance: vi.fn(), + buildButtons: vi.fn(), + buildPrompt: vi.fn(), + getCurrentQuestion: vi.fn(), + getPending: vi.fn(), + parseAnswerCallback: vi.fn(), + parseCancelCallback: vi.fn(), + reply: vi.fn(), + setTrustedRunActingUserOnSuccess: vi.fn(), + submit: vi.fn(), +})); + +vi.mock('@roomote/communication', () => ({ + advancePendingCommunicationRequestUserInputQuestion: mocks.advance, + buildDiscordAnsweredRequestUserInputText: vi.fn(), + buildDiscordCancelledRequestUserInputText: vi.fn(), + buildDiscordRequestUserInputButtons: mocks.buildButtons, + buildDiscordRequestUserInputPromptText: mocks.buildPrompt, + clearPendingCommunicationRequestUserInput: vi.fn(), + getDiscordRequestUserInputCurrentQuestion: mocks.getCurrentQuestion, + getPendingCommunicationRequestUserInput: mocks.getPending, + parseDiscordRequestUserInputAnswerCallbackData: mocks.parseAnswerCallback, + parseDiscordRequestUserInputCancelCallbackData: mocks.parseCancelCallback, + submitPendingCommunicationRequestUserInputAnswer: mocks.submit, +})); + +vi.mock('@roomote/db/server', () => ({ + setTrustedRunActingUserOnSuccess: mocks.setTrustedRunActingUserOnSuccess, +})); + +vi.mock('../replies.js', () => ({ replyToDiscordEvent: mocks.reply })); +vi.mock('../../../logging.js', () => ({ apiLogger: { warn: vi.fn() } })); + +import { tryHandleDiscordRequestUserInputCallback } from '../request-user-input.js'; + +const firstQuestion = { + id: 'first', + header: 'First', + question: 'Choose the first answer', + isOther: false, + isSecret: false, + options: [{ label: 'First option', description: '' }], +}; + +const secondQuestion = { + ...firstQuestion, + id: 'second', + question: 'Choose the second answer', + options: [{ label: 'Second option', description: '' }], +}; + +function pendingRequest(currentQuestionIndex = 0) { + return { + requestId: 'request-quest123', + runId: 42, + taskId: 'task-1', + provider: 'discord' as const, + conversationId: 'thread-1', + questions: [firstQuestion, secondQuestion], + status: 'pending' as const, + promptMessageId: 'prompt-1', + currentQuestionIndex, + answers: currentQuestionIndex + ? { first: { answers: ['First option'] } } + : {}, + createdAt: Date.now(), + }; +} + +describe('Discord request_user_input callbacks', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.parseAnswerCallback.mockReturnValue({ + runId: 42, + questionIndex: 0, + optionIndex: 0, + requestToken: 'quest123', + }); + mocks.parseCancelCallback.mockReturnValue(null); + mocks.buildPrompt.mockReturnValue('NEXT PROMPT'); + mocks.buildButtons.mockReturnValue([[{ text: 'Second option' }]]); + mocks.reply.mockResolvedValue(undefined); + mocks.advance.mockResolvedValue(true); + mocks.submit.mockResolvedValue(true); + mocks.setTrustedRunActingUserOnSuccess.mockImplementation( + async ({ operation }: { operation: () => Promise }) => + operation(), + ); + }); + + function callbackParams(provider: { editMessage: ReturnType }) { + return { + provider: provider as never, + applicationId: 'app-1', + channel: { channelId: 'thread-1' } as never, + interaction: { id: 'interaction-1' } as never, + interactionDeferred: true, + customId: 'discord:rui:42:0:0:quest123', + userId: 'user-1', + }; + } + + it('advances and edits the prompt after a non-final button answer', async () => { + const request = pendingRequest(); + const provider = { editMessage: vi.fn().mockResolvedValue(undefined) }; + mocks.getPending.mockResolvedValue(request); + mocks.getCurrentQuestion.mockReturnValue({ + question: firstQuestion, + questionIndex: 0, + }); + + await tryHandleDiscordRequestUserInputCallback(callbackParams(provider)); + + expect(mocks.advance).toHaveBeenCalledWith( + 'discord', + 'thread-1', + request, + 1, + { first: { answers: ['First option'] } }, + ); + expect(provider.editMessage).toHaveBeenCalledWith( + expect.objectContaining({ messageId: 'prompt-1', text: 'NEXT PROMPT' }), + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Picked: First option', + ephemeral: true, + }), + ); + }); + + it('submits accumulated answers after the final button answer', async () => { + const request = pendingRequest(1); + const provider = { editMessage: vi.fn().mockResolvedValue(undefined) }; + mocks.getPending.mockResolvedValue(request); + mocks.getCurrentQuestion.mockReturnValue({ + question: secondQuestion, + questionIndex: 1, + }); + mocks.parseAnswerCallback.mockReturnValue({ + runId: 42, + questionIndex: 1, + optionIndex: 0, + requestToken: 'quest123', + }); + + await tryHandleDiscordRequestUserInputCallback(callbackParams(provider)); + + expect(mocks.advance).not.toHaveBeenCalled(); + expect(mocks.submit).toHaveBeenCalledWith( + 'discord', + 'thread-1', + request, + expect.objectContaining({ + answers: { + first: { answers: ['First option'] }, + second: { answers: ['Second option'] }, + }, + }), + ); + }); + + it('notifies a losing advance claim without editing the prompt', async () => { + const provider = { editMessage: vi.fn().mockResolvedValue(undefined) }; + mocks.getPending.mockResolvedValue(pendingRequest()); + mocks.getCurrentQuestion.mockReturnValue({ + question: firstQuestion, + questionIndex: 0, + }); + mocks.advance.mockResolvedValue(false); + + await tryHandleDiscordRequestUserInputCallback(callbackParams(provider)); + + expect(provider.editMessage).not.toHaveBeenCalled(); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'I already received your answer. Please wait for the agent to continue.', + }), + ); + }); + + it('shows the next question when editing the existing prompt fails', async () => { + const provider = { + editMessage: vi.fn().mockRejectedValue(new Error('gone')), + }; + mocks.getPending.mockResolvedValue(pendingRequest()); + mocks.getCurrentQuestion.mockReturnValue({ + question: firstQuestion, + questionIndex: 0, + }); + + await tryHandleDiscordRequestUserInputCallback(callbackParams(provider)); + + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + text: 'Picked: First option\n\nNEXT PROMPT', + 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 c66e69058..27380b768 100644 --- a/apps/api/src/handlers/discord/request-user-input.ts +++ b/apps/api/src/handlers/discord/request-user-input.ts @@ -153,6 +153,91 @@ async function finalizeDiscordRequestUserInputAnswer(params: { return 'queued'; } +async function advanceDiscordRequestUserInputQuestion(params: { + provider: DiscordCommunicationProvider; + applicationId: string; + channel: DiscordChannelContext; + pendingRequest: PendingCommunicationRequestUserInput; + answers: AcpRequestUserInputAnswers; + answerText: string; + interaction?: { + interaction: DiscordInteraction; + interactionDeferred: boolean; + }; + replyToMessageId?: string; +}): Promise<'advanced' | 'already_received'> { + const conversationId = conversationIdForChannel(params.channel); + const current = getDiscordRequestUserInputCurrentQuestion( + params.pendingRequest, + ); + if (!current) { + return 'already_received'; + } + + const nextQuestionIndex = current.questionIndex + 1; + const advanced = await advancePendingCommunicationRequestUserInputQuestion( + 'discord', + conversationId, + params.pendingRequest, + nextQuestionIndex, + params.answers, + ); + if (!advanced) { + await postAlreadyReceivedNotice({ + provider: params.provider, + applicationId: params.applicationId, + channel: params.channel, + interaction: params.interaction, + replyToMessageId: params.replyToMessageId, + }); + return 'already_received'; + } + + const nextPrompt = { + requestId: params.pendingRequest.requestId, + questions: params.pendingRequest.questions, + currentQuestionIndex: nextQuestionIndex, + }; + const nextPromptText = buildDiscordRequestUserInputPromptText(nextPrompt); + let rendered = false; + + if (params.pendingRequest.promptMessageId) { + try { + await params.provider.editMessage({ + channelId: conversationId, + messageId: params.pendingRequest.promptMessageId, + text: nextPromptText, + buttons: buildDiscordRequestUserInputButtons({ + runId: params.pendingRequest.runId, + request: nextPrompt, + }), + }); + rendered = true; + } catch (error) { + apiLogger.warn( + `[discord.request_user_input] Failed to advance prompt message ${params.pendingRequest.promptMessageId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + await replyToDiscordEvent({ + provider: params.provider, + applicationId: params.applicationId, + channel: params.channel, + ...(params.interaction ? { interaction: params.interaction } : {}), + ...(params.replyToMessageId + ? { replyToMessageId: params.replyToMessageId } + : {}), + text: rendered + ? `Picked: ${params.answerText}` + : `Picked: ${params.answerText}\n\n${nextPromptText}`, + ...(params.interaction ? { ephemeral: true } : {}), + }); + return 'advanced'; +} + /** * Try to treat an inbound Discord message as an answer to a pending * request_user_input prompt. Returns true when the message was consumed. @@ -228,51 +313,16 @@ export async function tryHandleDiscordRequestUserInputMessage(params: { const nextQuestionIndex = current.questionIndex + 1; if (nextQuestionIndex < pendingRequest.questions.length) { - const advanced = - await advancePendingCommunicationRequestUserInputQuestion( - 'discord', - conversationId, - pendingRequest, - nextQuestionIndex, - answers, - ); - if (!advanced) { - await postAlreadyReceivedNotice({ - provider: params.provider, - applicationId: params.applicationId, - channel: params.channel, - replyToMessageId: params.replyToMessageId, - }); - return true; - } - - if (pendingRequest.promptMessageId) { - const nextPrompt = { - requestId: pendingRequest.requestId, - questions: pendingRequest.questions, - currentQuestionIndex: nextQuestionIndex, - }; - await params.provider.editMessage({ - channelId: conversationId, - messageId: pendingRequest.promptMessageId, - text: buildDiscordRequestUserInputPromptText(nextPrompt), - buttons: buildDiscordRequestUserInputButtons({ - runId: pendingRequest.runId, - request: nextPrompt, - }), - }); - } - - await replyToDiscordEvent({ + await advanceDiscordRequestUserInputQuestion({ provider: params.provider, applicationId: params.applicationId, channel: params.channel, - ...(params.replyToMessageId - ? { replyToMessageId: params.replyToMessageId } - : {}), - text: `Picked: ${Object.values(parsedReply.answers) + pendingRequest, + answers, + answerText: Object.values(parsedReply.answers) .flatMap((entry) => entry.answers) - .join(', ')}`, + .join(', '), + replyToMessageId: params.replyToMessageId, }); return true; } @@ -467,56 +517,14 @@ export async function tryHandleDiscordRequestUserInputCallback(params: { const nextQuestionIndex = current.questionIndex + 1; if (nextQuestionIndex < pendingRequest.questions.length) { - const advanced = await advancePendingCommunicationRequestUserInputQuestion( - 'discord', - conversationId, - pendingRequest, - nextQuestionIndex, - answers, - ); - if (!advanced) { - await postAlreadyReceivedNotice({ - provider: params.provider, - applicationId: params.applicationId, - channel: params.channel, - interaction: interactionCtx, - }); - return true; - } - - if (!pendingRequest.promptMessageId) { - await replyToDiscordEvent({ - provider: params.provider, - applicationId: params.applicationId, - channel: params.channel, - interaction: interactionCtx, - text: 'Your answer was saved. Reply in the thread to continue.', - ephemeral: true, - }); - return true; - } - - const nextPrompt = { - requestId: pendingRequest.requestId, - questions: pendingRequest.questions, - currentQuestionIndex: nextQuestionIndex, - }; - await params.provider.editMessage({ - channelId: conversationId, - messageId: pendingRequest.promptMessageId, - text: buildDiscordRequestUserInputPromptText(nextPrompt), - buttons: buildDiscordRequestUserInputButtons({ - runId: pendingRequest.runId, - request: nextPrompt, - }), - }); - await replyToDiscordEvent({ + await advanceDiscordRequestUserInputQuestion({ provider: params.provider, applicationId: params.applicationId, channel: params.channel, + pendingRequest, + answers, + answerText: option.label, interaction: interactionCtx, - text: `Picked: ${option.label}`, - ephemeral: true, }); return true; } From 19ef5eb17c379ab3b60163f1cbaf84bc3ce2d271 Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:57:15 +0000 Subject: [PATCH 4/8] fix(discord): retain buttons in prompt fallback --- .../discord/__tests__/request-user-input.test.ts | 1 + apps/api/src/handlers/discord/request-user-input.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 4 deletions(-) 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 index 5b6a5a356..5dfba076e 100644 --- a/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts +++ b/apps/api/src/handlers/discord/__tests__/request-user-input.test.ts @@ -195,6 +195,7 @@ describe('Discord request_user_input callbacks', () => { expect(mocks.reply).toHaveBeenCalledWith( expect.objectContaining({ text: 'Picked: First option\n\nNEXT PROMPT', + buttons: [[{ text: 'Second option' }]], 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 27380b768..c719500fc 100644 --- a/apps/api/src/handlers/discord/request-user-input.ts +++ b/apps/api/src/handlers/discord/request-user-input.ts @@ -199,6 +199,10 @@ async function advanceDiscordRequestUserInputQuestion(params: { currentQuestionIndex: nextQuestionIndex, }; const nextPromptText = buildDiscordRequestUserInputPromptText(nextPrompt); + const nextPromptButtons = buildDiscordRequestUserInputButtons({ + runId: params.pendingRequest.runId, + request: nextPrompt, + }); let rendered = false; if (params.pendingRequest.promptMessageId) { @@ -207,10 +211,7 @@ async function advanceDiscordRequestUserInputQuestion(params: { channelId: conversationId, messageId: params.pendingRequest.promptMessageId, text: nextPromptText, - buttons: buildDiscordRequestUserInputButtons({ - runId: params.pendingRequest.runId, - request: nextPrompt, - }), + buttons: nextPromptButtons, }); rendered = true; } catch (error) { @@ -233,6 +234,7 @@ async function advanceDiscordRequestUserInputQuestion(params: { text: rendered ? `Picked: ${params.answerText}` : `Picked: ${params.answerText}\n\n${nextPromptText}`, + ...(!rendered && nextPromptButtons ? { buttons: nextPromptButtons } : {}), ...(params.interaction ? { ephemeral: true } : {}), }); return 'advanced'; From 9a0248dc5aea1785fe95311ca59bbb19137cf1db Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:14:45 +0000 Subject: [PATCH 5/8] fix(communication): preserve multi-question prompts --- .../__tests__/discord-request-user-input.test.ts | 15 +++++++++++++++ .../src/discord-request-user-input.ts | 16 ++++++++++++++++ .../lib/communication-request-user-input.ts | 11 ++++++++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/communication/src/__tests__/discord-request-user-input.test.ts b/packages/communication/src/__tests__/discord-request-user-input.test.ts index 8ea6c78a7..2c470cfd0 100644 --- a/packages/communication/src/__tests__/discord-request-user-input.test.ts +++ b/packages/communication/src/__tests__/discord-request-user-input.test.ts @@ -126,6 +126,21 @@ describe('discord request_user_input helpers', () => { expect(buttons?.[0]?.[0]?.callbackData).toContain(':1:0:'); }); + it('renders every question for providers that collect one text reply', () => { + const text = buildDiscordRequestUserInputPromptText({ + requestId: 'rui:1', + questions: [ + sampleQuestion, + { ...sampleQuestion, id: 'q2', question: 'Second question?' }, + ], + showAllQuestions: true, + }); + + expect(text).toContain('Question 1 of 2'); + expect(text).toContain('Question 2 of 2'); + expect(text).toContain('one answer per line'); + }); + it('reserves a button row for Cancel when options exceed Discord row capacity', () => { const buttons = buildDiscordRequestUserInputButtons({ runId: 99, diff --git a/packages/communication/src/discord-request-user-input.ts b/packages/communication/src/discord-request-user-input.ts index 87d2520de..774b178ef 100644 --- a/packages/communication/src/discord-request-user-input.ts +++ b/packages/communication/src/discord-request-user-input.ts @@ -6,6 +6,7 @@ type DiscordRequestUserInputPromptState = { requestId: string; questions: AcpRequestUserInputQuestion[]; currentQuestionIndex?: number; + showAllQuestions?: boolean; }; function formatDisplayedOptionLabel(label: string): string { @@ -155,6 +156,21 @@ export function buildDiscordRequestUserInputPromptText( } const lines: string[] = []; + if (state.showAllQuestions && state.questions.length > 1) { + state.questions.forEach((question, index) => { + if (index > 0) { + lines.push(''); + } + lines.push(`**Question ${index + 1} of ${state.questions.length}**`); + lines.push(formatSingleQuestion(question)); + }); + lines.push(''); + lines.push( + '_Reply with one answer per line in order (option number, label, or allowed custom text). Reply `cancel` to skip._', + ); + return lines.join('\n'); + } + if (state.questions.length > 1) { lines.push( `**Question ${current.questionIndex + 1} of ${state.questions.length}**`, diff --git a/packages/sdk/src/server/lib/communication-request-user-input.ts b/packages/sdk/src/server/lib/communication-request-user-input.ts index 8d7b8f9b4..be3c1c413 100644 --- a/packages/sdk/src/server/lib/communication-request-user-input.ts +++ b/packages/sdk/src/server/lib/communication-request-user-input.ts @@ -91,10 +91,15 @@ export async function publishCommunicationRequestUserInput(params: { answers: existingForEdit?.answers, }); - const promptText = buildDiscordRequestUserInputPromptText(promptState); - // Teams has no callback-button intake yet; keep options in the text body. + const promptText = buildDiscordRequestUserInputPromptText({ + ...promptState, + // Telegram and Teams still collect multi-question answers in one text + // reply, so retain the full form until their handlers support advancing. + ...(provider === 'discord' ? {} : { showAllQuestions: true }), + }); + // Discord is the only provider with a per-question callback flow. const buttons = - provider === 'teams' + provider !== 'discord' ? undefined : buildDiscordRequestUserInputButtons({ runId: params.runId, From 792956dcc488b2898f1673fc92878d5bbb744975 Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:17:43 +0000 Subject: [PATCH 6/8] test(sdk): cover request input provider rendering --- .../communication-request-user-input.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts diff --git a/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts b/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts new file mode 100644 index 000000000..6562ba7c7 --- /dev/null +++ b/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts @@ -0,0 +1,86 @@ +const mocks = vi.hoisted(() => ({ + buildButtons: vi.fn(), + buildPrompt: vi.fn(), + getAdapter: vi.fn(), + getPending: vi.fn(), + postMessage: vi.fn(), + setPending: vi.fn(), +})); + +vi.mock('@roomote/communication', () => ({ + buildDiscordRequestUserInputButtons: mocks.buildButtons, + buildDiscordRequestUserInputPromptText: mocks.buildPrompt, + getCommunicationRequestUserInputConversationId: vi.fn(() => 'conversation-1'), + getPendingCommunicationRequestUserInput: mocks.getPending, + setPendingCommunicationRequestUserInput: mocks.setPending, +})); + +vi.mock('@roomote/types', () => ({ + getCommunicationChannelFromTaskPayload: vi.fn(() => 'channel-1'), + getCommunicationProviderFromTaskPayload: vi.fn((payload) => payload.provider), + getCommunicationServiceUrlFromTaskPayload: vi.fn(() => null), + getCommunicationThreadIdFromTaskPayload: vi.fn(() => null), +})); + +vi.mock('../communication-providers', () => ({ + getCommunicationProviderAdapter: mocks.getAdapter, +})); + +import { publishCommunicationRequestUserInput } from '../communication-request-user-input'; + +const question = { + id: 'question-1', + header: 'Question', + question: 'Choose one', + isOther: false, + isSecret: false, + options: [{ label: 'One', description: '' }], +}; + +describe('publishCommunicationRequestUserInput', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getPending.mockResolvedValue(null); + mocks.setPending.mockResolvedValue(undefined); + mocks.buildPrompt.mockReturnValue('prompt'); + mocks.buildButtons.mockReturnValue([[{ text: 'One' }]]); + mocks.postMessage.mockResolvedValue({ messageId: 'prompt-1' }); + mocks.getAdapter.mockResolvedValue({ postMessage: mocks.postMessage }); + }); + + it('uses the wizard renderer only for Discord', async () => { + for (const provider of ['discord', 'telegram', 'teams'] as const) { + await publishCommunicationRequestUserInput({ + runId: 1, + taskId: 'task-1', + payload: { provider }, + request: { requestId: 'request-1', questions: [question, question] }, + }); + } + + expect(mocks.buildPrompt).toHaveBeenNthCalledWith( + 1, + expect.not.objectContaining({ showAllQuestions: true }), + ); + expect(mocks.buildPrompt).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ showAllQuestions: true }), + ); + expect(mocks.buildPrompt).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ showAllQuestions: true }), + ); + expect(mocks.postMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ buttons: [[{ text: 'One' }]] }), + ); + expect(mocks.postMessage).toHaveBeenNthCalledWith( + 2, + expect.not.objectContaining({ buttons: expect.anything() }), + ); + expect(mocks.postMessage).toHaveBeenNthCalledWith( + 3, + expect.not.objectContaining({ buttons: expect.anything() }), + ); + }); +}); From 6adbfeb5513eed4a98816cf0ab0af2c608d42cd7 Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:23:06 +0000 Subject: [PATCH 7/8] fix(telegram): retain single-question buttons --- .../communication-request-user-input.test.ts | 13 +++++++++++++ .../server/lib/communication-request-user-input.ts | 6 ++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts b/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts index 6562ba7c7..888985ff8 100644 --- a/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts +++ b/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts @@ -83,4 +83,17 @@ describe('publishCommunicationRequestUserInput', () => { expect.not.objectContaining({ buttons: expect.anything() }), ); }); + + it('keeps Telegram buttons for a single-question prompt', async () => { + await publishCommunicationRequestUserInput({ + runId: 1, + taskId: 'task-1', + payload: { provider: 'telegram' }, + request: { requestId: 'request-1', questions: [question] }, + }); + + expect(mocks.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ buttons: [[{ text: 'One' }]] }), + ); + }); }); diff --git a/packages/sdk/src/server/lib/communication-request-user-input.ts b/packages/sdk/src/server/lib/communication-request-user-input.ts index be3c1c413..093bd9ef6 100644 --- a/packages/sdk/src/server/lib/communication-request-user-input.ts +++ b/packages/sdk/src/server/lib/communication-request-user-input.ts @@ -97,9 +97,11 @@ export async function publishCommunicationRequestUserInput(params: { // reply, so retain the full form until their handlers support advancing. ...(provider === 'discord' ? {} : { showAllQuestions: true }), }); - // Discord is the only provider with a per-question callback flow. + // Telegram supports buttons for a single answer, but multi-question prompts + // still use its existing one-reply text flow. const buttons = - provider !== 'discord' + provider !== 'discord' && + !(provider === 'telegram' && params.request.questions.length === 1) ? undefined : buildDiscordRequestUserInputButtons({ runId: params.runId, From 66dba05d73d516693d4c54775323deedfaa01efd Mon Sep 17 00:00:00 2001 From: Daniel Riccio <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:27:33 +0000 Subject: [PATCH 8/8] fix(telegram): retain cancel for empty prompts --- .../__tests__/discord-request-user-input.test.ts | 16 ++++++++++++++++ .../src/discord-request-user-input.ts | 12 +++++++++++- .../communication-request-user-input.test.ts | 15 +++++++++++++++ .../lib/communication-request-user-input.ts | 2 +- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/communication/src/__tests__/discord-request-user-input.test.ts b/packages/communication/src/__tests__/discord-request-user-input.test.ts index 2c470cfd0..8c5247681 100644 --- a/packages/communication/src/__tests__/discord-request-user-input.test.ts +++ b/packages/communication/src/__tests__/discord-request-user-input.test.ts @@ -70,6 +70,22 @@ describe('discord request_user_input helpers', () => { expect(buttons!.at(-1)?.[0]?.text).toBe('Cancel'); }); + it('builds a Cancel button when no question can be rendered', () => { + expect( + buildDiscordRequestUserInputButtons({ + runId: 99, + request: { requestId: 'rui:session:turn:callid12', questions: [] }, + }), + ).toEqual([ + [ + { + text: 'Cancel', + callbackData: 'discord:rui_cancel:99:callid12', + }, + ], + ]); + }); + it('renders the current question and option buttons for multi-question prompts', () => { const text = buildDiscordRequestUserInputPromptText({ requestId: 'rui:1', diff --git a/packages/communication/src/discord-request-user-input.ts b/packages/communication/src/discord-request-user-input.ts index 774b178ef..be4b434bd 100644 --- a/packages/communication/src/discord-request-user-input.ts +++ b/packages/communication/src/discord-request-user-input.ts @@ -200,7 +200,17 @@ export function buildDiscordRequestUserInputButtons(params: { }): CommunicationMessageButton[][] | undefined { const current = getDiscordRequestUserInputCurrentQuestion(params.request); if (!current) { - return undefined; + return [ + [ + { + text: 'Cancel', + callbackData: buildDiscordRequestUserInputCancelCallbackData({ + runId: params.runId, + requestId: params.request.requestId, + }), + }, + ], + ]; } const { question, questionIndex } = current; diff --git a/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts b/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts index 888985ff8..643dabe63 100644 --- a/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts +++ b/packages/sdk/src/server/lib/__tests__/communication-request-user-input.test.ts @@ -96,4 +96,19 @@ describe('publishCommunicationRequestUserInput', () => { expect.objectContaining({ buttons: [[{ text: 'One' }]] }), ); }); + + it('keeps Telegram Cancel available for an empty prompt', async () => { + mocks.buildButtons.mockReturnValue([[{ text: 'Cancel' }]]); + + await publishCommunicationRequestUserInput({ + runId: 1, + taskId: 'task-1', + payload: { provider: 'telegram' }, + request: { requestId: 'request-1', questions: [] }, + }); + + expect(mocks.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ buttons: [[{ text: 'Cancel' }]] }), + ); + }); }); diff --git a/packages/sdk/src/server/lib/communication-request-user-input.ts b/packages/sdk/src/server/lib/communication-request-user-input.ts index 093bd9ef6..90d17cba9 100644 --- a/packages/sdk/src/server/lib/communication-request-user-input.ts +++ b/packages/sdk/src/server/lib/communication-request-user-input.ts @@ -101,7 +101,7 @@ export async function publishCommunicationRequestUserInput(params: { // still use its existing one-reply text flow. const buttons = provider !== 'discord' && - !(provider === 'telegram' && params.request.questions.length === 1) + !(provider === 'telegram' && params.request.questions.length <= 1) ? undefined : buildDiscordRequestUserInputButtons({ runId: params.runId,