From 77994670695be0637eded85b0e55599e47994cb3 Mon Sep 17 00:00:00 2001 From: Nathan Metelak <8656820044+nathansmetelak@users.noreply.github.com> Date: Wed, 6 May 2026 07:39:14 -0500 Subject: [PATCH 1/6] Handle OpenCode question prompts in Discord flow --- src/__tests__/buttonHandler.test.ts | 106 +++++++++++++++++++++++++++ src/__tests__/sessionManager.test.ts | 51 +++++++++++++ src/__tests__/sseClient.test.ts | 54 ++++++++++++++ src/handlers/buttonHandler.ts | 97 ++++++++++++++++++++++++ src/services/executionService.ts | 43 +++++++++++ src/services/sessionManager.ts | 61 +++++++++++++++ src/services/sseClient.ts | 13 +++- src/types/index.ts | 23 ++++++ 8 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/buttonHandler.test.ts diff --git a/src/__tests__/buttonHandler.test.ts b/src/__tests__/buttonHandler.test.ts new file mode 100644 index 0000000..166f232 --- /dev/null +++ b/src/__tests__/buttonHandler.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const sessionManagerMock = vi.hoisted(() => ({ + getSessionForThread: vi.fn(), + listQuestions: vi.fn(), + replyQuestion: vi.fn(), + rejectQuestion: vi.fn(), + abortSession: vi.fn(), + ensureSessionForThread: vi.fn(), + sendPrompt: vi.fn(), +})); + +vi.mock("../services/sessionManager.js", () => sessionManagerMock); +vi.mock("../services/serveManager.js", () => ({ + getPort: vi.fn(), + spawnServe: vi.fn(), + waitForReady: vi.fn(), +})); +vi.mock("../services/dataStore.js", () => ({ + getChannelModel: vi.fn(), + getWorktreeMapping: vi.fn(), + removeWorktreeMapping: vi.fn(), +})); +vi.mock("../services/worktreeManager.js", () => ({ + worktreeExists: vi.fn(), + removeWorktree: vi.fn(), +})); + +import { handleButton } from "../handlers/buttonHandler.js"; + +function mockInteraction(customId: string) { + return { + customId, + reply: vi.fn(), + deferReply: vi.fn(), + editReply: vi.fn(), + channel: { id: "channel-1", isThread: () => false }, + } as any; +} + +describe("handleButton question responses", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("answers OpenCode questions with the selected option", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_dfcfdc0e70013EvGpyc0soVaR7", + sessionID: "ses_123", + questions: [ + { + question: "Approve this plan?", + options: [{ label: "Approve plan" }, { label: "Revise plan" }], + }, + ], + }, + ]); + sessionManagerMock.replyQuestion.mockResolvedValue(true); + + const interaction = mockInteraction( + "qanswer:thread123:que_dfcfdc0e70013EvGpyc0soVaR7:0", + ); + + await handleButton(interaction); + + expect(interaction.deferReply).toHaveBeenCalled(); + expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith( + 14098, + "que_dfcfdc0e70013EvGpyc0soVaR7", + [["Approve plan"]], + ); + expect(interaction.editReply).toHaveBeenCalledWith({ + content: "βœ… Sent response: Approve plan", + }); + }); + + it("rejects OpenCode questions", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.rejectQuestion.mockResolvedValue(true); + + const interaction = mockInteraction( + "qreject:thread123:que_dfcfdc0e70013EvGpyc0soVaR7", + ); + + await handleButton(interaction); + + expect(interaction.deferReply).toHaveBeenCalled(); + expect(sessionManagerMock.rejectQuestion).toHaveBeenCalledWith( + 14098, + "que_dfcfdc0e70013EvGpyc0soVaR7", + ); + expect(interaction.editReply).toHaveBeenCalledWith({ + content: "🚫 Question rejected.", + }); + }); +}); diff --git a/src/__tests__/sessionManager.test.ts b/src/__tests__/sessionManager.test.ts index 64e4f6a..ff6d52b 100644 --- a/src/__tests__/sessionManager.test.ts +++ b/src/__tests__/sessionManager.test.ts @@ -53,6 +53,9 @@ import { getSessionInfo, listSessions, abortSession, + listQuestions, + replyQuestion, + rejectQuestion, ensureSessionForThread, getSessionForThread, setSessionForThread, @@ -190,6 +193,54 @@ describe("SessionManager", () => { }); }); + describe("question helpers", () => { + it("should list pending questions", async () => { + const questions = [{ id: "que_123", sessionID: "ses_123", questions: [] }]; + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => questions, + }); + + await expect(listQuestions(3000)).resolves.toEqual(questions); + + expect(mockFetch).toHaveBeenCalledWith("http://127.0.0.1:3000/question", { + method: "GET", + headers: {}, + }); + }); + + it("should reply to a pending question", async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + await expect( + replyQuestion(3000, "que_123", [["Approve plan"]]), + ).resolves.toBe(true); + + expect(mockFetch).toHaveBeenCalledWith( + "http://127.0.0.1:3000/question/que_123/reply", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ answers: [["Approve plan"]] }), + }, + ); + }); + + it("should reject a pending question", async () => { + mockFetch.mockResolvedValueOnce({ ok: true }); + + await expect(rejectQuestion(3000, "que_123")).resolves.toBe(true); + + expect(mockFetch).toHaveBeenCalledWith( + "http://127.0.0.1:3000/question/que_123/reject", + { + method: "POST", + headers: {}, + }, + ); + }); + }); + describe("thread-session mapping", () => { it("should store and retrieve session for thread", () => { setSessionForThread("thread1", "ses_123", "/path/to/project", 4000); diff --git a/src/__tests__/sseClient.test.ts b/src/__tests__/sseClient.test.ts index 441f75e..7766e99 100644 --- a/src/__tests__/sseClient.test.ts +++ b/src/__tests__/sseClient.test.ts @@ -336,6 +336,60 @@ describe("SSEClient", () => { }); }); + describe("onQuestionAsked", () => { + it("should trigger callback for question.asked events", () => { + const callback = vi.fn(); + client.connect("http://127.0.0.1:3000"); + client.onQuestionAsked(callback); + + const messageHandler = + mockEventSourceInstance.addEventListener.mock.calls.find( + (call: any) => call[0] === "message", + )?.[1]; + + const request = { + id: "que_123", + sessionID: "session-1", + questions: [ + { + header: "Plan Approval", + question: "Approve this plan?", + options: [{ label: "Approve plan" }, { label: "Revise plan" }], + }, + ], + }; + + messageHandler({ + data: JSON.stringify({ + type: "question.asked", + properties: request, + }), + }); + + expect(callback).toHaveBeenCalledWith(request); + }); + + it("should not trigger callback for malformed question.asked events", () => { + const callback = vi.fn(); + client.connect("http://127.0.0.1:3000"); + client.onQuestionAsked(callback); + + const messageHandler = + mockEventSourceInstance.addEventListener.mock.calls.find( + (call: any) => call[0] === "message", + )?.[1]; + + messageHandler({ + data: JSON.stringify({ + type: "question.asked", + properties: { id: "que_123" }, + }), + }); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + describe("onError", () => { it("should trigger callback on error", () => { const callback = vi.fn(); diff --git a/src/handlers/buttonHandler.ts b/src/handlers/buttonHandler.ts index d282d2b..cd508c1 100644 --- a/src/handlers/buttonHandler.ts +++ b/src/handlers/buttonHandler.ts @@ -1,4 +1,5 @@ import { ButtonInteraction, ThreadChannel, MessageFlags } from 'discord.js'; +import type { QuestionRequest } from '../types/index.js'; import * as sessionManager from '../services/sessionManager.js'; import * as serveManager from '../services/serveManager.js'; import * as dataStore from '../services/dataStore.js'; @@ -6,6 +7,18 @@ import * as worktreeManager from '../services/worktreeManager.js'; export async function handleButton(interaction: ButtonInteraction) { const customId = interaction.customId; + + if (customId.startsWith('qanswer:')) { + const [, threadId, requestId, optionIndexRaw] = customId.split(':'); + await handleQuestionAnswer(interaction, threadId, requestId, optionIndexRaw); + return; + } + + if (customId.startsWith('qreject:')) { + const [, threadId, requestId] = customId.split(':'); + await handleQuestionReject(interaction, threadId, requestId); + return; + } const [action, threadId] = customId.split('_'); @@ -67,6 +80,90 @@ async function handleInterrupt(interaction: ButtonInteraction, threadId: string) } } + +async function handleQuestionAnswer( + interaction: ButtonInteraction, + threadId: string | undefined, + requestId: string | undefined, + optionIndexRaw: string | undefined, +) { + const optionIndex = Number(optionIndexRaw); + + if (!threadId || !requestId || !Number.isInteger(optionIndex)) { + await interaction.reply({ + content: '❌ Invalid question response.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const session = sessionManager.getSessionForThread(threadId); + if (!session) { + await interaction.reply({ + content: '⚠️ Session not found.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; + const request = questions.find((q) => q.id === requestId); + const question = request?.questions?.[0]; + const option = question?.options?.[optionIndex]; + + if (!option?.label) { + await interaction.editReply({ + content: '⚠️ Pending question/option not found. It may have already been answered.', + }); + return; + } + + await sessionManager.replyQuestion(session.port, requestId, [[option.label]]); + await interaction.editReply({ content: `βœ… Sent response: ${option.label}` }); + } catch (error) { + await interaction.editReply({ + content: `❌ Failed to answer question: ${(error as Error).message}`, + }); + } +} + +async function handleQuestionReject( + interaction: ButtonInteraction, + threadId: string | undefined, + requestId: string | undefined, +) { + if (!threadId || !requestId) { + await interaction.reply({ + content: '❌ Invalid question rejection.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const session = sessionManager.getSessionForThread(threadId); + if (!session) { + await interaction.reply({ + content: '⚠️ Session not found.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + await sessionManager.rejectQuestion(session.port, requestId); + await interaction.editReply({ content: '🚫 Question rejected.' }); + } catch (error) { + await interaction.editReply({ + content: `❌ Failed to reject question: ${(error as Error).message}`, + }); + } +} + async function handleWorktreeDelete(interaction: ButtonInteraction, threadId: string) { const mapping = dataStore.getWorktreeMapping(threadId); if (!mapping) { diff --git a/src/services/executionService.ts b/src/services/executionService.ts index 25d9c41..70f4033 100644 --- a/src/services/executionService.ts +++ b/src/services/executionService.ts @@ -13,6 +13,7 @@ import * as worktreeManager from './worktreeManager.js'; import { SSEClient } from './sseClient.js'; import { formatOutput, formatOutputForMobile, buildContextHeader } from '../utils/messageFormatter.js'; import { processNextInQueue } from './queueManager.js'; +import type { QuestionRequest } from '../types/index.js'; export async function runPrompt( channel: TextBasedChannel, @@ -267,6 +268,48 @@ export async function runPrompt( })(); }); + sseClient.onQuestionAsked((request: QuestionRequest) => { + if (request.sessionID !== sessionId) return; + + if (updateInterval) { + clearInterval(updateInterval); + updateInterval = null; + } + + (async () => { + try { + const question = request.questions?.[0]; + const header = question?.header ? `**${question.header}**` : '**OpenCode needs input**'; + const body = question?.question ?? 'OpenCode is waiting for a response.'; + const optionButtons = (question?.options ?? []).slice(0, 4).map((option, index) => + new ButtonBuilder() + .setCustomId(`qanswer:${threadId}:${request.id}:${index}`) + .setLabel((option.label ?? `Option ${index + 1}`).slice(0, 80)) + .setStyle(index === 0 ? ButtonStyle.Primary : ButtonStyle.Secondary) + ); + const rejectButton = new ButtonBuilder() + .setCustomId(`qreject:${threadId}:${request.id}`) + .setLabel('Reject') + .setStyle(ButtonStyle.Danger); + const questionButtons = new ActionRowBuilder().addComponents( + ...optionButtons, + rejectButton, + ); + + const edited = await updateStreamMessage( + `${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\n⏸️ **Waiting for OpenCode input**\n${header}\n\n${body.slice(0, 1500)}`, + [questionButtons], + ); + if (!edited) { + await safeSend(`⏸️ OpenCode is waiting for input: ${header}`); + } + } catch (error) { + console.error('Error in onQuestionAsked:', error); + await safeSend('❌ OpenCode asked a question, but I could not render it in Discord.'); + } + })(); + }); + sseClient.onError((error) => { if (updateInterval) { clearInterval(updateInterval); diff --git a/src/services/sessionManager.ts b/src/services/sessionManager.ts index 31be14a..26c01a4 100644 --- a/src/services/sessionManager.ts +++ b/src/services/sessionManager.ts @@ -181,6 +181,67 @@ export async function abortSession( return response.ok; } + +export async function listQuestions(port: number): Promise { + const url = `http://127.0.0.1:${port}/question`; + const response = await fetch(url, { + method: "GET", + headers: getAuthHeaders(), + }); + + if (!response.ok) { + assertNotAuthError(response.status, "Failed to list questions"); + throw new Error(`Failed to list questions: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + return Array.isArray(data) ? data : []; +} + +export async function replyQuestion( + port: number, + requestId: string, + answers: string[][], +): Promise { + const url = `http://127.0.0.1:${port}/question/${requestId}/reply`; + const response = await fetch(url, { + method: "POST", + headers: jsonHeaders(), + body: JSON.stringify({ answers }), + }); + + if (!response.ok) { + const responseBody = await response.text(); + assertNotAuthError(response.status, "Failed to answer question"); + throw new Error( + `Failed to answer question: ${response.status} ${response.statusText} β€” ${responseBody}`, + ); + } + + return true; +} + +export async function rejectQuestion( + port: number, + requestId: string, +): Promise { + const url = `http://127.0.0.1:${port}/question/${requestId}/reject`; + const response = await fetch(url, { + method: "POST", + headers: getAuthHeaders(), + }); + + if (!response.ok) { + const responseBody = await response.text(); + assertNotAuthError(response.status, "Failed to reject question"); + throw new Error( + `Failed to reject question: ${response.status} ${response.statusText} β€” ${responseBody}`, + ); + } + + return true; +} + export function getSessionForThread( threadId: string, ): { sessionId: string; projectPath: string; port: number } | undefined { diff --git a/src/services/sseClient.ts b/src/services/sseClient.ts index 43682ae..f05191e 100644 --- a/src/services/sseClient.ts +++ b/src/services/sseClient.ts @@ -1,5 +1,5 @@ import { EventSource } from "eventsource"; -import type { TextPart, SSEEvent, SessionErrorInfo } from "../types/index.js"; +import type { TextPart, SSEEvent, SessionErrorInfo, QuestionRequest } from "../types/index.js"; import { getAuthHeaders } from "./serverAuth.js"; type PartUpdatedCallback = (part: TextPart) => void; @@ -8,6 +8,7 @@ type SessionErrorCallback = ( sessionId: string, error: SessionErrorInfo, ) => void; +type QuestionAskedCallback = (request: QuestionRequest) => void; type ErrorCallback = (error: Error) => void; export class SSEClient { @@ -15,6 +16,7 @@ export class SSEClient { private partUpdatedCallbacks: PartUpdatedCallback[] = []; private sessionIdleCallbacks: SessionIdleCallback[] = []; private sessionErrorCallbacks: SessionErrorCallback[] = []; + private questionAskedCallbacks: QuestionAskedCallback[] = []; private errorCallbacks: ErrorCallback[] = []; connect(baseUrl: string): void { @@ -64,6 +66,10 @@ export class SSEClient { this.sessionErrorCallbacks.push(callback); } + onQuestionAsked(callback: QuestionAskedCallback): void { + this.questionAskedCallbacks.push(callback); + } + onError(callback: ErrorCallback): void { this.errorCallbacks.push(callback); } @@ -107,6 +113,11 @@ export class SSEClient { if (sessionID && error) { this.sessionErrorCallbacks.forEach((cb) => cb(sessionID, error)); } + } else if (event.type === "question.asked") { + const request = event.properties as unknown as QuestionRequest; + if (request?.sessionID && request?.id) { + this.questionAskedCallbacks.forEach((cb) => cb(request)); + } } } diff --git a/src/types/index.ts b/src/types/index.ts index f6ae651..c03407b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -50,6 +50,29 @@ export interface SSEEvent { properties: Record; } +export interface QuestionOption { + label: string; + description?: string; +} + +export interface QuestionItem { + question: string; + header?: string; + options?: QuestionOption[]; + multiple?: boolean; + custom?: boolean; +} + +export interface QuestionRequest { + id: string; + sessionID: string; + questions: QuestionItem[]; + tool?: { + messageID: string; + callID: string; + }; +} + export interface ServeInstance { port: number; process: ChildProcess; From 9bc60f0bba8bc46011bf70934e4451a32efe96eb Mon Sep 17 00:00:00 2001 From: fox3000foxy <40730498+fox3000foxy@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:49:30 +0200 Subject: [PATCH 2/6] Discord: handle multi-question/multi-select questions as requested changes --- src/__tests__/buttonHandler.test.ts | 246 +++++++++++-- src/handlers/buttonHandler.ts | 538 +++++++++++++++++++++++++--- src/handlers/interactionHandler.ts | 30 +- src/services/executionService.ts | 42 +-- 4 files changed, 753 insertions(+), 103 deletions(-) diff --git a/src/__tests__/buttonHandler.test.ts b/src/__tests__/buttonHandler.test.ts index 166f232..c0c5ec1 100644 --- a/src/__tests__/buttonHandler.test.ts +++ b/src/__tests__/buttonHandler.test.ts @@ -26,7 +26,7 @@ vi.mock("../services/worktreeManager.js", () => ({ removeWorktree: vi.fn(), })); -import { handleButton } from "../handlers/buttonHandler.js"; +import { handleButton, handleSelectMenu } from "../handlers/buttonHandler.js"; function mockInteraction(customId: string) { return { @@ -34,6 +34,23 @@ function mockInteraction(customId: string) { reply: vi.fn(), deferReply: vi.fn(), editReply: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + followUp: vi.fn().mockResolvedValue(undefined), + message: { id: "msg_1" }, + channel: { id: "channel-1", isThread: () => false }, + } as any; +} + +function mockSelectInteraction(customId: string, values: string[]) { + return { + customId, + values, + reply: vi.fn(), + deferReply: vi.fn(), + editReply: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + followUp: vi.fn().mockResolvedValue(undefined), + message: { id: "msg_1" }, channel: { id: "channel-1", isThread: () => false }, } as any; } @@ -43,7 +60,7 @@ describe("handleButton question responses", () => { vi.clearAllMocks(); }); - it("answers OpenCode questions with the selected option", async () => { + it("answers a single-select question with one question (auto-submit)", async () => { sessionManagerMock.getSessionForThread.mockReturnValue({ sessionId: "ses_123", projectPath: "/repo", @@ -51,7 +68,7 @@ describe("handleButton question responses", () => { }); sessionManagerMock.listQuestions.mockResolvedValue([ { - id: "que_dfcfdc0e70013EvGpyc0soVaR7", + id: "que_abc", sessionID: "ses_123", questions: [ { @@ -63,20 +80,16 @@ describe("handleButton question responses", () => { ]); sessionManagerMock.replyQuestion.mockResolvedValue(true); - const interaction = mockInteraction( - "qanswer:thread123:que_dfcfdc0e70013EvGpyc0soVaR7:0", - ); - + const interaction = mockInteraction("qanswer:thread123:que_abc:0:0"); await handleButton(interaction); - expect(interaction.deferReply).toHaveBeenCalled(); - expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith( - 14098, - "que_dfcfdc0e70013EvGpyc0soVaR7", - [["Approve plan"]], - ); - expect(interaction.editReply).toHaveBeenCalledWith({ - content: "βœ… Sent response: Approve plan", + expect(interaction.update).toHaveBeenCalled(); + expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_abc", [ + ["Approve plan"], + ]); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: "βœ… All questions answered.", + flags: 64, }); }); @@ -88,19 +101,204 @@ describe("handleButton question responses", () => { }); sessionManagerMock.rejectQuestion.mockResolvedValue(true); - const interaction = mockInteraction( - "qreject:thread123:que_dfcfdc0e70013EvGpyc0soVaR7", - ); - + const interaction = mockInteraction("qreject:thread123:que_abc"); await handleButton(interaction); - expect(interaction.deferReply).toHaveBeenCalled(); - expect(sessionManagerMock.rejectQuestion).toHaveBeenCalledWith( + expect(sessionManagerMock.rejectQuestion).toHaveBeenCalledWith(14098, "que_abc"); + expect(interaction.update).toHaveBeenCalled(); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: "🚫 Question rejected.", + flags: 64, + }); + }); + + it("handles multi-question single-select: answers progressively, submits when done", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_xyz", + sessionID: "ses_123", + questions: [ + { + question: "Choose framework?", + options: [{ label: "React" }, { label: "Vue" }, { label: "Svelte" }], + }, + { + header: "Styling", + question: "Choose styling approach?", + options: [{ label: "CSS" }, { label: "Tailwind" }], + }, + ], + }, + ]); + sessionManagerMock.replyQuestion.mockResolvedValue(true); + + const q1Interaction = mockInteraction("qanswer:thread123:que_xyz:0:1"); + await handleButton(q1Interaction); + + expect(sessionManagerMock.replyQuestion).not.toHaveBeenCalled(); + expect(q1Interaction.update).toHaveBeenCalled(); + expect(q1Interaction.followUp).toHaveBeenCalledWith({ + content: "βœ… Q1 answered: Vue", + flags: 64, + }); + + const q2Interaction = mockInteraction("qanswer:thread123:que_xyz:1:0"); + await handleButton(q2Interaction); + + expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_xyz", [ + ["Vue"], + ["CSS"], + ]); + expect(q2Interaction.followUp).toHaveBeenCalledWith({ + content: "βœ… All questions answered.", + flags: 64, + }); + }); + + it("handles multi-select toggle: toggle in, then submit", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_multi", + sessionID: "ses_123", + questions: [ + { + question: "Select toppings?", + multiple: true, + options: [ + { label: "Cheese" }, + { label: "Pepperoni" }, + { label: "Mushrooms" }, + ], + }, + ], + }, + ]); + sessionManagerMock.replyQuestion.mockResolvedValue(true); + + const toggle1 = mockInteraction("qtoggle:thread123:que_multi:0:0"); + await handleButton(toggle1); + expect(sessionManagerMock.replyQuestion).not.toHaveBeenCalled(); + expect(toggle1.update).toHaveBeenCalled(); + + const toggle2 = mockInteraction("qtoggle:thread123:que_multi:0:2"); + await handleButton(toggle2); + expect(sessionManagerMock.replyQuestion).not.toHaveBeenCalled(); + + const submit = mockInteraction("qsubmit:thread123:que_multi"); + await handleButton(submit); + expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_multi", [ + ["Cheese", "Mushrooms"], + ]); + expect(submit.followUp).toHaveBeenCalledWith({ + content: "βœ… All questions answered.", + flags: 64, + }); + }); + + it("handles select menu interaction for single-select", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_select", + sessionID: "ses_123", + questions: [ + { + question: "Pick one?", + options: [ + { label: "Option A" }, + { label: "Option B" }, + { label: "Option C" }, + ], + }, + ], + }, + ]); + sessionManagerMock.replyQuestion.mockResolvedValue(true); + + const interaction = mockSelectInteraction("qselect:thread123:que_select:0", ["1"]); + await handleSelectMenu(interaction); + + expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_select", [ + ["Option B"], + ]); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: "βœ… All questions answered.", + flags: 64, + }); + }); + + it("handles select menu interaction for multi-select", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_multi_select", + sessionID: "ses_123", + questions: [ + { + question: "Pick multiple?", + multiple: true, + options: [ + { label: "Alpha" }, + { label: "Beta" }, + { label: "Gamma" }, + ], + }, + ], + }, + ]); + sessionManagerMock.replyQuestion.mockResolvedValue(true); + + const interaction = mockSelectInteraction("qselect:thread123:que_multi_select:0", [ + "0", + "2", + ]); + await handleSelectMenu(interaction); + + expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith( 14098, - "que_dfcfdc0e70013EvGpyc0soVaR7", + "que_multi_select", + [["Alpha", "Gamma"]], ); - expect(interaction.editReply).toHaveBeenCalledWith({ - content: "🚫 Question rejected.", + expect(interaction.followUp).toHaveBeenCalledWith({ + content: "βœ… All questions answered.", + flags: 64, + }); + }); + + it("returns error for invalid button customId", async () => { + const interaction = mockInteraction("qanswer:thread123:que_abc:notanumber:0"); + await handleButton(interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + content: "❌ Invalid question response.", + flags: 64, + }); + }); + + it("returns error when session not found", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue(undefined); + const interaction = mockInteraction("qanswer:thread123:que_abc:0:0"); + await handleButton(interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + content: "⚠️ Session not found.", + flags: 64, }); }); }); diff --git a/src/handlers/buttonHandler.ts b/src/handlers/buttonHandler.ts index cd508c1..115b50e 100644 --- a/src/handlers/buttonHandler.ts +++ b/src/handlers/buttonHandler.ts @@ -1,16 +1,181 @@ -import { ButtonInteraction, ThreadChannel, MessageFlags } from 'discord.js'; -import type { QuestionRequest } from '../types/index.js'; +import { + ButtonInteraction, + StringSelectMenuInteraction, + ThreadChannel, + MessageFlags, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, +} from 'discord.js'; +import type { QuestionItem, QuestionRequest } from '../types/index.js'; import * as sessionManager from '../services/sessionManager.js'; import * as serveManager from '../services/serveManager.js'; import * as dataStore from '../services/dataStore.js'; import * as worktreeManager from '../services/worktreeManager.js'; +const pendingAnswers = new Map>(); + +export function setPendingAnswers(key: string, selections: Map): void { + pendingAnswers.set(key, selections); +} + +function clearPendingAnswers(key: string): void { + pendingAnswers.delete(key); +} + +export function buildQuestionText( + questions: QuestionItem[], + selections: Map, +): string { + const parts = questions.map((q, i) => { + const labels = selections.get(i) ?? []; + const isAnswered = labels.length > 0; + const status = isAnswered ? `βœ… ${labels.join(', ')}` : '⬜ Pending'; + const header = q.header ? `**${q.header}**` : ''; + const body = q.question.slice(0, 200); + return `**Q${i + 1}:** ${header} (${status})\n${body}`; + }); + return `⏸️ **Waiting for OpenCode input**\n\n${parts.join('\n\n')}`; +} + +function buildQuestionTextAnswered( + questions: QuestionItem[], + selections: Map, +): string { + const parts = questions.map((q, i) => { + const labels = selections.get(i) ?? []; + return `**Q${i + 1}:** ${q.header || q.question.slice(0, 100)}\nβœ… ${labels.join(', ')}`; + }); + return `**⏸️ Questions answered**\n\n${parts.join('\n\n')}`; +} + +export function buildQuestionComponents( + threadId: string, + request: QuestionRequest, + selections: Map, + showSubmit: boolean, +): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + const maxRows = 5; + + for (let qIdx = 0; qIdx < request.questions.length && rows.length < maxRows; qIdx++) { + const question = request.questions[qIdx]; + const selectedLabels = selections.get(qIdx) ?? []; + const options = question.options ?? []; + const isMulti = question.multiple === true; + + if (isMulti || options.length > 5) { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`qselect:${threadId}:${request.id}:${qIdx}`) + .setPlaceholder( + selectedLabels.length > 0 + ? `Selected: ${selectedLabels.join(', ').slice(0, 100)}` + : `Choose option(s) for Q${qIdx + 1}`, + ) + .setMinValues(0) + .setMaxValues(isMulti ? options.length : 1) + .addOptions( + options.map((opt, oIdx) => { + const optBuilder = new StringSelectMenuOptionBuilder() + .setLabel(opt.label.slice(0, 100)) + .setValue(String(oIdx)) + .setDefault(selectedLabels.includes(opt.label)); + if (opt.description) { + optBuilder.setDescription(opt.description.slice(0, 100)); + } + return optBuilder; + }), + ); + + rows.push(new ActionRowBuilder().addComponents(selectMenu)); + } else { + const buttons = options.map((opt, oIdx) => { + const isSelected = selectedLabels.includes(opt.label); + const customId = isMulti + ? `qtoggle:${threadId}:${request.id}:${qIdx}:${oIdx}` + : `qanswer:${threadId}:${request.id}:${qIdx}:${oIdx}`; + + return new ButtonBuilder() + .setCustomId(customId) + .setLabel(opt.label.slice(0, 80)) + .setStyle( + isSelected + ? isMulti + ? ButtonStyle.Success + : ButtonStyle.Primary + : ButtonStyle.Secondary, + ); + }); + + rows.push(new ActionRowBuilder().addComponents(...buttons)); + } + } + + if (rows.length >= maxRows) return rows; + + const hasMulti = request.questions.some((q) => q.multiple); + if (hasMulti && showSubmit) { + rows.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`qsubmit:${threadId}:${request.id}`) + .setLabel('Submit Answers') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`qreject:${threadId}:${request.id}`) + .setLabel('Reject') + .setStyle(ButtonStyle.Danger), + ), + ); + } else { + rows.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`qreject:${threadId}:${request.id}`) + .setLabel('Reject') + .setStyle(ButtonStyle.Danger), + ), + ); + } + + return rows; +} + +function buildAnsweredComponents( + threadId: string, + requestId: string, +): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`qreject:${threadId}:${requestId}`) + .setLabel('Reject') + .setStyle(ButtonStyle.Danger) + .setDisabled(true), + ), + ]; +} + export async function handleButton(interaction: ButtonInteraction) { const customId = interaction.customId; if (customId.startsWith('qanswer:')) { - const [, threadId, requestId, optionIndexRaw] = customId.split(':'); - await handleQuestionAnswer(interaction, threadId, requestId, optionIndexRaw); + const [, threadId, requestId, questionIndexRaw, optionIndexRaw] = customId.split(':'); + await handleQuestionAnswer(interaction, threadId, requestId, questionIndexRaw, optionIndexRaw); + return; + } + + if (customId.startsWith('qtoggle:')) { + const [, threadId, requestId, questionIndexRaw, optionIndexRaw] = customId.split(':'); + await handleQuestionToggle(interaction, threadId, requestId, questionIndexRaw, optionIndexRaw); + return; + } + + if (customId.startsWith('qsubmit:')) { + const [, threadId, requestId] = customId.split(':'); + await handleQuestionSubmit(interaction, threadId, requestId); return; } @@ -19,17 +184,17 @@ export async function handleButton(interaction: ButtonInteraction) { await handleQuestionReject(interaction, threadId, requestId); return; } - + const [action, threadId] = customId.split('_'); - + if (!threadId) { await interaction.reply({ content: '❌ Invalid button.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, }); return; } - + if (action === 'interrupt') { await handleInterrupt(interaction, threadId); } else if (action === 'delete') { @@ -39,59 +204,214 @@ export async function handleButton(interaction: ButtonInteraction) { } else { await interaction.reply({ content: '❌ Unknown action.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, }); } } -async function handleInterrupt(interaction: ButtonInteraction, threadId: string) { - const session = sessionManager.getSessionForThread(threadId); - - if (!session) { +export async function handleSelectMenu(interaction: StringSelectMenuInteraction) { + const customId = interaction.customId; + + if (customId.startsWith('qselect:')) { + const [, threadId, requestId, questionIndexRaw] = customId.split(':'); + const questionIndex = Number(questionIndexRaw); + + if (!threadId || !requestId || !Number.isInteger(questionIndex)) { + await interaction.reply({ + content: '❌ Invalid question selection.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const session = sessionManager.getSessionForThread(threadId); + if (!session) { + await interaction.reply({ + content: '⚠️ Session not found.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + try { + const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; + const request = questions.find((q) => q.id === requestId); + + if (!request) { + await interaction.reply({ + content: '⚠️ Pending question not found. It may have already been answered.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const question = request.questions[questionIndex]; + if (!question) { + await interaction.reply({ + content: '⚠️ Question not found.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const isMulti = question.multiple === true; + + const selectedLabels = interaction.values + .map((v) => Number(v)) + .filter((idx) => Number.isInteger(idx) && question.options?.[idx]) + .map((idx) => question.options![idx].label); + + const answerKey = `${requestId}:${threadId}`; + let selections = pendingAnswers.get(answerKey); + if (!selections) { + selections = new Map(); + pendingAnswers.set(answerKey, selections); + } + selections.set(questionIndex, selectedLabels); + + const allAnswered = request.questions.every( + (_, i) => (selections!.get(i) ?? []).length > 0, + ); + + if (allAnswered) { + await submitAllAnswers(interaction, session.port, request, selections, answerKey, threadId); + } else { + const maxRows = 5; + const hasMulti = request.questions.some((q) => q.multiple); + const showSubmit = hasMulti || request.questions.length > 1; + const text = buildQuestionText(request.questions, selections); + const components = buildQuestionComponents(threadId, request, selections, showSubmit); + + const safeComponents = components.slice(0, maxRows); + + await interaction.update({ content: text, components: safeComponents }); + await interaction.followUp({ + content: `βœ… Selected: ${selectedLabels.join(', ') || '(none)'}`, + flags: MessageFlags.Ephemeral, + }); + } + } catch (error) { + await interaction.followUp({ + content: `❌ Failed to process selection: ${(error as Error).message}`, + flags: MessageFlags.Ephemeral, + }); + } + } +} + +async function submitAllAnswers( + interaction: ButtonInteraction | StringSelectMenuInteraction, + port: number, + request: QuestionRequest, + selections: Map, + answerKey: string, + threadId: string, +) { + const answers = request.questions.map((_, i) => selections.get(i) ?? []); + await sessionManager.replyQuestion(port, request.id, answers); + clearPendingAnswers(answerKey); + + const answeredText = buildQuestionTextAnswered(request.questions, selections); + const answeredComponents = buildAnsweredComponents(threadId, request.id); + await interaction.update({ content: answeredText, components: answeredComponents }); + await interaction.followUp({ + content: 'βœ… All questions answered.', + flags: MessageFlags.Ephemeral, + }); +} + +async function handleQuestionAnswer( + interaction: ButtonInteraction, + threadId: string | undefined, + requestId: string | undefined, + questionIndexRaw: string | undefined, + optionIndexRaw: string | undefined, +) { + const questionIndex = Number(questionIndexRaw); + const optionIndex = Number(optionIndexRaw); + + if (!threadId || !requestId || !Number.isInteger(questionIndex) || !Number.isInteger(optionIndex)) { await interaction.reply({ - content: '⚠️ Session not found.', - flags: MessageFlags.Ephemeral + content: '❌ Invalid question response.', + flags: MessageFlags.Ephemeral, }); return; } - const channel = interaction.channel; - const parentChannelId = channel?.isThread() ? (channel as ThreadChannel).parentId! : channel?.id; - const preferredModel = parentChannelId ? dataStore.getChannelModel(parentChannelId) : undefined; - - const port = serveManager.getPort(session.projectPath, preferredModel); - - if (!port) { + const session = sessionManager.getSessionForThread(threadId); + if (!session) { await interaction.reply({ - content: '⚠️ Server is not running.', - flags: MessageFlags.Ephemeral + content: '⚠️ Session not found.', + flags: MessageFlags.Ephemeral, }); return; } - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const success = await sessionManager.abortSession(port, session.sessionId); - - if (success) { - await interaction.editReply({ content: '⏸️ Interrupt request sent.' }); - } else { - await interaction.editReply({ content: '⚠️ Failed to interrupt. Server may not be running or no active task.' }); + try { + const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; + const request = questions.find((q) => q.id === requestId); + + if (!request) { + await interaction.reply({ + content: '⚠️ Pending question not found. It may have already been answered.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const question = request.questions[questionIndex]; + const option = question?.options?.[optionIndex]; + + if (!option?.label) { + await interaction.reply({ + content: '⚠️ Pending question/option not found. It may have already been answered.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const answerKey = `${requestId}:${threadId}`; + let selections = pendingAnswers.get(answerKey); + if (!selections) { + selections = new Map(); + pendingAnswers.set(answerKey, selections); + } + + selections.set(questionIndex, [option.label]); + + const allAnswered = request.questions.every((_, i) => (selections!.get(i) ?? []).length > 0); + + if (allAnswered) { + await submitAllAnswers(interaction, session.port, request, selections, answerKey, threadId); + } else { + const text = buildQuestionText(request.questions, selections); + const components = buildQuestionComponents(threadId, request, selections, false); + await interaction.update({ content: text, components: components.slice(0, 5) }); + await interaction.followUp({ + content: `βœ… Q${questionIndex + 1} answered: ${option.label}`, + flags: MessageFlags.Ephemeral, + }); + } + } catch (error) { + await interaction.editReply({ + content: `❌ Failed to answer question: ${(error as Error).message}`, + }); } } - -async function handleQuestionAnswer( +async function handleQuestionToggle( interaction: ButtonInteraction, threadId: string | undefined, requestId: string | undefined, + questionIndexRaw: string | undefined, optionIndexRaw: string | undefined, ) { + const questionIndex = Number(questionIndexRaw); const optionIndex = Number(optionIndexRaw); - if (!threadId || !requestId || !Number.isInteger(optionIndex)) { + if (!threadId || !requestId || !Number.isInteger(questionIndex) || !Number.isInteger(optionIndex)) { await interaction.reply({ - content: '❌ Invalid question response.', + content: '❌ Invalid toggle.', flags: MessageFlags.Ephemeral, }); return; @@ -106,26 +426,96 @@ async function handleQuestionAnswer( return; } - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - try { const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; const request = questions.find((q) => q.id === requestId); - const question = request?.questions?.[0]; - const option = question?.options?.[optionIndex]; + if (!request) { + await interaction.reply({ + content: '⚠️ Pending question not found.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const option = request.questions[questionIndex]?.options?.[optionIndex]; if (!option?.label) { - await interaction.editReply({ - content: '⚠️ Pending question/option not found. It may have already been answered.', + await interaction.reply({ + content: '⚠️ Option not found.', + flags: MessageFlags.Ephemeral, }); return; } - await sessionManager.replyQuestion(session.port, requestId, [[option.label]]); - await interaction.editReply({ content: `βœ… Sent response: ${option.label}` }); + const answerKey = `${requestId}:${threadId}`; + let selections = pendingAnswers.get(answerKey); + if (!selections) { + selections = new Map(); + pendingAnswers.set(answerKey, selections); + } + + const current = selections.get(questionIndex) ?? []; + if (current.includes(option.label)) { + selections.set(questionIndex, current.filter((l) => l !== option.label)); + } else { + selections.set(questionIndex, [...current, option.label]); + } + + const text = buildQuestionText(request.questions, selections); + const components = buildQuestionComponents(threadId, request, selections, true); + await interaction.update({ content: text, components: components.slice(0, 5) }); + await interaction.followUp({ + content: `Toggled: ${option.label}`, + flags: MessageFlags.Ephemeral, + }); } catch (error) { await interaction.editReply({ - content: `❌ Failed to answer question: ${(error as Error).message}`, + content: `❌ Failed to toggle: ${(error as Error).message}`, + }); + } +} + +async function handleQuestionSubmit( + interaction: ButtonInteraction, + threadId: string | undefined, + requestId: string | undefined, +) { + if (!threadId || !requestId) { + await interaction.reply({ + content: '❌ Invalid submit.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const session = sessionManager.getSessionForThread(threadId); + if (!session) { + await interaction.reply({ + content: '⚠️ Session not found.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + try { + const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; + const request = questions.find((q) => q.id === requestId); + + if (!request) { + await interaction.reply({ + content: '⚠️ Pending question not found.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const answerKey = `${requestId}:${threadId}`; + const selections = pendingAnswers.get(answerKey) ?? new Map(); + + await submitAllAnswers(interaction, session.port, request, selections, answerKey, threadId); + } catch (error) { + await interaction.editReply({ + content: `❌ Failed to submit: ${(error as Error).message}`, }); } } @@ -152,11 +542,19 @@ async function handleQuestionReject( return; } - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - try { await sessionManager.rejectQuestion(session.port, requestId); - await interaction.editReply({ content: '🚫 Question rejected.' }); + const answerKey = `${requestId}:${threadId}`; + clearPendingAnswers(answerKey); + + await interaction.update({ + content: '🚫 Question rejected.', + components: buildAnsweredComponents(threadId, requestId), + }); + await interaction.followUp({ + content: '🚫 Question rejected.', + flags: MessageFlags.Ephemeral, + }); } catch (error) { await interaction.editReply({ content: `❌ Failed to reject question: ${(error as Error).message}`, @@ -164,6 +562,44 @@ async function handleQuestionReject( } } +async function handleInterrupt(interaction: ButtonInteraction, threadId: string) { + const session = sessionManager.getSessionForThread(threadId); + + if (!session) { + await interaction.reply({ + content: '⚠️ Session not found.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const channel = interaction.channel; + const parentChannelId = channel?.isThread() ? (channel as ThreadChannel).parentId! : channel?.id; + const preferredModel = parentChannelId ? dataStore.getChannelModel(parentChannelId) : undefined; + + const port = serveManager.getPort(session.projectPath, preferredModel); + + if (!port) { + await interaction.reply({ + content: '⚠️ Server is not running.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + const success = await sessionManager.abortSession(port, session.sessionId); + + if (success) { + await interaction.editReply({ content: '⏸️ Interrupt request sent.' }); + } else { + await interaction.editReply({ + content: '⚠️ Failed to interrupt. Server may not be running or no active task.', + }); + } +} + async function handleWorktreeDelete(interaction: ButtonInteraction, threadId: string) { const mapping = dataStore.getWorktreeMapping(threadId); if (!mapping) { @@ -208,7 +644,11 @@ async function handleWorktreePR(interaction: ButtonInteraction, threadId: string const port = await serveManager.spawnServe(mapping.worktreePath, preferredModel); await serveManager.waitForReady(port, 30000, mapping.worktreePath, preferredModel); - const sessionId = await sessionManager.ensureSessionForThread(threadId, mapping.worktreePath, port); + const sessionId = await sessionManager.ensureSessionForThread( + threadId, + mapping.worktreePath, + port, + ); const prPrompt = `Create a pull request for the current branch. Include a clear title and description summarizing all changes.`; await sessionManager.sendPrompt(port, sessionId, prPrompt, preferredModel); diff --git a/src/handlers/interactionHandler.ts b/src/handlers/interactionHandler.ts index def24d3..4faac65 100644 --- a/src/handlers/interactionHandler.ts +++ b/src/handlers/interactionHandler.ts @@ -1,6 +1,6 @@ import { Interaction, MessageFlags } from 'discord.js'; import { commands } from '../commands/index.js'; -import { handleButton } from './buttonHandler.js'; +import { handleButton, handleSelectMenu } from './buttonHandler.js'; import { isAuthorized } from '../services/configStore.js'; export async function handleInteraction(interaction: Interaction) { @@ -20,6 +20,22 @@ export async function handleInteraction(interaction: Interaction) { return; } + if (interaction.isStringSelectMenu()) { + if (!isAuthorized(interaction.user.id)) { + await interaction.reply({ + content: '🚫 You are not authorized to use this bot.', + flags: MessageFlags.Ephemeral, + }); + return; + } + try { + await handleSelectMenu(interaction); + } catch (error) { + console.error('Error handling select menu:', error); + } + return; + } + if (interaction.isAutocomplete()) { const command = commands.get(interaction.commandName); if (command?.autocomplete) { @@ -38,9 +54,9 @@ export async function handleInteraction(interaction: Interaction) { } return; } - + if (!interaction.isChatInputCommand()) return; - + if (!isAuthorized(interaction.user.id)) { await interaction.reply({ content: '🚫 You are not authorized to use this bot.', @@ -48,19 +64,19 @@ export async function handleInteraction(interaction: Interaction) { }); return; } - + const command = commands.get(interaction.commandName); - + if (!command) { return; } - + try { await command.execute(interaction); } catch (error) { console.error(`Error executing command ${interaction.commandName}:`, error); const content = '❌ An error occurred while executing the command.'; - + try { if (interaction.replied || interaction.deferred) { await interaction.followUp({ content, flags: MessageFlags.Ephemeral }); diff --git a/src/services/executionService.ts b/src/services/executionService.ts index 70f4033..42df6ef 100644 --- a/src/services/executionService.ts +++ b/src/services/executionService.ts @@ -14,6 +14,11 @@ import { SSEClient } from './sseClient.js'; import { formatOutput, formatOutputForMobile, buildContextHeader } from '../utils/messageFormatter.js'; import { processNextInQueue } from './queueManager.js'; import type { QuestionRequest } from '../types/index.js'; +import { + buildQuestionText, + buildQuestionComponents, + setPendingAnswers +} from '../handlers/buttonHandler.js'; export async function runPrompt( channel: TextBasedChannel, @@ -113,7 +118,7 @@ export async function runPrompt( let hasSessionError = false; const spinner = ['β ‹', 'β ™', 'β Ή', 'β Έ', 'β Ό', 'β ΄', 'β ¦', 'β §', 'β ‡', '⠏']; - const updateStreamMessage = async (content: string, components: ActionRowBuilder[]): Promise => { + const updateStreamMessage = async (content: string, components: ActionRowBuilder[]): Promise => { try { await streamMessage.edit({ content, components }); return true; @@ -278,30 +283,21 @@ export async function runPrompt( (async () => { try { - const question = request.questions?.[0]; - const header = question?.header ? `**${question.header}**` : '**OpenCode needs input**'; - const body = question?.question ?? 'OpenCode is waiting for a response.'; - const optionButtons = (question?.options ?? []).slice(0, 4).map((option, index) => - new ButtonBuilder() - .setCustomId(`qanswer:${threadId}:${request.id}:${index}`) - .setLabel((option.label ?? `Option ${index + 1}`).slice(0, 80)) - .setStyle(index === 0 ? ButtonStyle.Primary : ButtonStyle.Secondary) - ); - const rejectButton = new ButtonBuilder() - .setCustomId(`qreject:${threadId}:${request.id}`) - .setLabel('Reject') - .setStyle(ButtonStyle.Danger); - const questionButtons = new ActionRowBuilder().addComponents( - ...optionButtons, - rejectButton, - ); + const questions = request.questions ?? []; + if (questions.length === 0) return; - const edited = await updateStreamMessage( - `${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\n⏸️ **Waiting for OpenCode input**\n${header}\n\n${body.slice(0, 1500)}`, - [questionButtons], - ); + const answerKey = `${request.id}:${threadId}`; + const initialSelections = new Map(); + questions.forEach((_, i) => initialSelections.set(i, [])); + setPendingAnswers(answerKey, initialSelections); + + const questionText = buildQuestionText(questions, initialSelections); + const components = buildQuestionComponents(threadId, request, initialSelections, false); + + const content = `${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\n${questionText}`; + const edited = await updateStreamMessage(content, components); if (!edited) { - await safeSend(`⏸️ OpenCode is waiting for input: ${header}`); + await safeSend(`⏸️ OpenCode is waiting for input`); } } catch (error) { console.error('Error in onQuestionAsked:', error); From 8d3ac280d7347bf83da22fe59cbce198f1419bf9 Mon Sep 17 00:00:00 2001 From: fox3000foxy <40730498+fox3000foxy@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:34:54 +0200 Subject: [PATCH 3/6] review: fix min_values, session check, deferUpdate, submit validation, custom modal --- src/__tests__/buttonHandler.test.ts | 178 +++++++++++++---- src/handlers/buttonHandler.ts | 288 +++++++++++++++++++++------- src/handlers/interactionHandler.ts | 22 ++- 3 files changed, 385 insertions(+), 103 deletions(-) diff --git a/src/__tests__/buttonHandler.test.ts b/src/__tests__/buttonHandler.test.ts index c0c5ec1..f46eff4 100644 --- a/src/__tests__/buttonHandler.test.ts +++ b/src/__tests__/buttonHandler.test.ts @@ -26,16 +26,17 @@ vi.mock("../services/worktreeManager.js", () => ({ removeWorktree: vi.fn(), })); -import { handleButton, handleSelectMenu } from "../handlers/buttonHandler.js"; +import { handleButton, handleSelectMenu, handleModalSubmit } from "../handlers/buttonHandler.js"; function mockInteraction(customId: string) { return { customId, - reply: vi.fn(), - deferReply: vi.fn(), - editReply: vi.fn(), - update: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + deferUpdate: vi.fn().mockResolvedValue(undefined), followUp: vi.fn().mockResolvedValue(undefined), + showModal: vi.fn().mockResolvedValue(undefined), message: { id: "msg_1" }, channel: { id: "channel-1", isThread: () => false }, } as any; @@ -45,10 +46,24 @@ function mockSelectInteraction(customId: string, values: string[]) { return { customId, values, - reply: vi.fn(), - deferReply: vi.fn(), - editReply: vi.fn(), - update: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + deferUpdate: vi.fn().mockResolvedValue(undefined), + followUp: vi.fn().mockResolvedValue(undefined), + message: { id: "msg_1" }, + channel: { id: "channel-1", isThread: () => false }, + } as any; +} + +function mockModalInteraction(customId: string, answer: string) { + return { + customId, + fields: { getTextInputValue: vi.fn().mockReturnValue(answer) }, + reply: vi.fn().mockResolvedValue(undefined), + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + deferUpdate: vi.fn().mockResolvedValue(undefined), followUp: vi.fn().mockResolvedValue(undefined), message: { id: "msg_1" }, channel: { id: "channel-1", isThread: () => false }, @@ -83,7 +98,7 @@ describe("handleButton question responses", () => { const interaction = mockInteraction("qanswer:thread123:que_abc:0:0"); await handleButton(interaction); - expect(interaction.update).toHaveBeenCalled(); + expect(interaction.deferUpdate).toHaveBeenCalled(); expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_abc", [ ["Approve plan"], ]); @@ -104,12 +119,8 @@ describe("handleButton question responses", () => { const interaction = mockInteraction("qreject:thread123:que_abc"); await handleButton(interaction); + expect(interaction.deferUpdate).toHaveBeenCalled(); expect(sessionManagerMock.rejectQuestion).toHaveBeenCalledWith(14098, "que_abc"); - expect(interaction.update).toHaveBeenCalled(); - expect(interaction.followUp).toHaveBeenCalledWith({ - content: "🚫 Question rejected.", - flags: 64, - }); }); it("handles multi-question single-select: answers progressively, submits when done", async () => { @@ -139,9 +150,8 @@ describe("handleButton question responses", () => { const q1Interaction = mockInteraction("qanswer:thread123:que_xyz:0:1"); await handleButton(q1Interaction); - expect(sessionManagerMock.replyQuestion).not.toHaveBeenCalled(); - expect(q1Interaction.update).toHaveBeenCalled(); + expect(q1Interaction.deferUpdate).toHaveBeenCalled(); expect(q1Interaction.followUp).toHaveBeenCalledWith({ content: "βœ… Q1 answered: Vue", flags: 64, @@ -149,15 +159,10 @@ describe("handleButton question responses", () => { const q2Interaction = mockInteraction("qanswer:thread123:que_xyz:1:0"); await handleButton(q2Interaction); - expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_xyz", [ ["Vue"], ["CSS"], ]); - expect(q2Interaction.followUp).toHaveBeenCalledWith({ - content: "βœ… All questions answered.", - flags: 64, - }); }); it("handles multi-select toggle: toggle in, then submit", async () => { @@ -188,7 +193,7 @@ describe("handleButton question responses", () => { const toggle1 = mockInteraction("qtoggle:thread123:que_multi:0:0"); await handleButton(toggle1); expect(sessionManagerMock.replyQuestion).not.toHaveBeenCalled(); - expect(toggle1.update).toHaveBeenCalled(); + expect(toggle1.deferUpdate).toHaveBeenCalled(); const toggle2 = mockInteraction("qtoggle:thread123:que_multi:0:2"); await handleButton(toggle2); @@ -199,10 +204,6 @@ describe("handleButton question responses", () => { expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_multi", [ ["Cheese", "Mushrooms"], ]); - expect(submit.followUp).toHaveBeenCalledWith({ - content: "βœ… All questions answered.", - flags: 64, - }); }); it("handles select menu interaction for single-select", async () => { @@ -232,13 +233,10 @@ describe("handleButton question responses", () => { const interaction = mockSelectInteraction("qselect:thread123:que_select:0", ["1"]); await handleSelectMenu(interaction); + expect(interaction.deferUpdate).toHaveBeenCalled(); expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_select", [ ["Option B"], ]); - expect(interaction.followUp).toHaveBeenCalledWith({ - content: "βœ… All questions answered.", - flags: 64, - }); }); it("handles select menu interaction for multi-select", async () => { @@ -277,10 +275,122 @@ describe("handleButton question responses", () => { "que_multi_select", [["Alpha", "Gamma"]], ); - expect(interaction.followUp).toHaveBeenCalledWith({ - content: "βœ… All questions answered.", - flags: 64, + }); + + it("rejects cross-session question access", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_expected", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_wrong_ses", + sessionID: "ses_other", + questions: [ + { + question: "Should not reach?", + options: [{ label: "Yes" }, { label: "No" }], + }, + ], + }, + ]); + + const interaction = mockInteraction("qanswer:thread123:que_wrong_ses:0:0"); + await handleButton(interaction); + + expect(interaction.deferUpdate).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith({ + content: expect.stringContaining("belongs to another session"), + }); + expect(sessionManagerMock.replyQuestion).not.toHaveBeenCalled(); + }); + + it("prevents submit when not all questions have answers", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_two", + sessionID: "ses_123", + questions: [ + { + question: "Q1?", + options: [{ label: "A" }, { label: "B" }], + }, + { + question: "Q2?", + options: [{ label: "C" }, { label: "D" }], + }, + ], + }, + ]); + + const submit = mockInteraction("qsubmit:thread123:que_two"); + await handleButton(submit); + + expect(submit.deferUpdate).toHaveBeenCalled(); + expect(submit.editReply).toHaveBeenCalledWith({ + content: expect.stringContaining("Not all questions have an answer"), }); + expect(sessionManagerMock.replyQuestion).not.toHaveBeenCalled(); + }); + + it("handles custom answer via modal submit", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_custom", + sessionID: "ses_123", + questions: [ + { + question: "Type your answer?", + options: [{ label: "Option" }], + }, + ], + }, + ]); + sessionManagerMock.replyQuestion.mockResolvedValue(true); + + const interaction = mockModalInteraction("qcustomModal:thread123:que_custom:0", "My custom answer"); + await handleModalSubmit(interaction); + + expect(interaction.deferUpdate).toHaveBeenCalled(); + expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_custom", [ + ["My custom answer"], + ]); + }); + + it("handles custom answer button by showing a modal", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_custom_btn", + sessionID: "ses_123", + questions: [ + { + question: "Say something?", + options: [], + }, + ], + }, + ]); + + const interaction = mockInteraction("qcustom:thread123:que_custom_btn:0"); + await handleButton(interaction); + + expect(interaction.showModal).toHaveBeenCalled(); }); it("returns error for invalid button customId", async () => { diff --git a/src/handlers/buttonHandler.ts b/src/handlers/buttonHandler.ts index 115b50e..2276315 100644 --- a/src/handlers/buttonHandler.ts +++ b/src/handlers/buttonHandler.ts @@ -1,6 +1,7 @@ import { ButtonInteraction, StringSelectMenuInteraction, + ModalSubmitInteraction, ThreadChannel, MessageFlags, ActionRowBuilder, @@ -8,6 +9,9 @@ import { ButtonStyle, StringSelectMenuBuilder, StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, } from 'discord.js'; import type { QuestionItem, QuestionRequest } from '../types/index.js'; import * as sessionManager from '../services/sessionManager.js'; @@ -25,6 +29,27 @@ function clearPendingAnswers(key: string): void { pendingAnswers.delete(key); } +function findRequestForSession( + questions: QuestionRequest[], + requestId: string, + sessionId: string, +): QuestionRequest | undefined { + return questions.find((q) => q.id === requestId && q.sessionID === sessionId); +} + +function getOrCreateSelections( + requestId: string, + threadId: string, +): Map { + const key = `${requestId}:${threadId}`; + let selections = pendingAnswers.get(key); + if (!selections) { + selections = new Map(); + pendingAnswers.set(key, selections); + } + return selections; +} + export function buildQuestionText( questions: QuestionItem[], selections: Map, @@ -65,8 +90,18 @@ export function buildQuestionComponents( const selectedLabels = selections.get(qIdx) ?? []; const options = question.options ?? []; const isMulti = question.multiple === true; - - if (isMulti || options.length > 5) { + const hasCustom = question.custom === true; + + if (options.length === 0) { + rows.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`qcustom:${threadId}:${request.id}:${qIdx}`) + .setLabel('✏️ Type answer') + .setStyle(ButtonStyle.Secondary), + ), + ); + } else if (isMulti || options.length > 5) { const selectMenu = new StringSelectMenuBuilder() .setCustomId(`qselect:${threadId}:${request.id}:${qIdx}`) .setPlaceholder( @@ -74,10 +109,10 @@ export function buildQuestionComponents( ? `Selected: ${selectedLabels.join(', ').slice(0, 100)}` : `Choose option(s) for Q${qIdx + 1}`, ) - .setMinValues(0) - .setMaxValues(isMulti ? options.length : 1) + .setMinValues(isMulti ? 1 : 1) + .setMaxValues(Math.min(isMulti ? options.length : 1, 25)) .addOptions( - options.map((opt, oIdx) => { + options.slice(0, 25).map((opt, oIdx) => { const optBuilder = new StringSelectMenuOptionBuilder() .setLabel(opt.label.slice(0, 100)) .setValue(String(oIdx)) @@ -109,6 +144,15 @@ export function buildQuestionComponents( ); }); + if (hasCustom) { + buttons.push( + new ButtonBuilder() + .setCustomId(`qcustom:${threadId}:${request.id}:${qIdx}`) + .setLabel('✏️ Custom') + .setStyle(ButtonStyle.Secondary), + ); + } + rows.push(new ActionRowBuilder().addComponents(...buttons)); } } @@ -116,13 +160,18 @@ export function buildQuestionComponents( if (rows.length >= maxRows) return rows; const hasMulti = request.questions.some((q) => q.multiple); + const allAnswered = request.questions.every( + (_, i) => (selections.get(i) ?? []).length > 0, + ); + if (hasMulti && showSubmit) { rows.push( new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`qsubmit:${threadId}:${request.id}`) .setLabel('Submit Answers') - .setStyle(ButtonStyle.Success), + .setStyle(ButtonStyle.Success) + .setDisabled(!allAnswered), new ButtonBuilder() .setCustomId(`qreject:${threadId}:${request.id}`) .setLabel('Reject') @@ -185,6 +234,12 @@ export async function handleButton(interaction: ButtonInteraction) { return; } + if (customId.startsWith('qcustom:')) { + const [, threadId, requestId, questionIndexRaw] = customId.split(':'); + await handleQuestionCustomButton(interaction, threadId, requestId, questionIndexRaw); + return; + } + const [action, threadId] = customId.split('_'); if (!threadId) { @@ -233,74 +288,130 @@ export async function handleSelectMenu(interaction: StringSelectMenuInteraction) return; } + await interaction.deferUpdate(); + try { const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; - const request = questions.find((q) => q.id === requestId); + const request = findRequestForSession(questions, requestId, session.sessionId); if (!request) { - await interaction.reply({ - content: '⚠️ Pending question not found. It may have already been answered.', - flags: MessageFlags.Ephemeral, + await interaction.editReply({ + content: '⚠️ Pending question not found or belongs to another session.', }); return; } const question = request.questions[questionIndex]; if (!question) { - await interaction.reply({ + await interaction.editReply({ content: '⚠️ Question not found.', - flags: MessageFlags.Ephemeral, }); return; } - const isMulti = question.multiple === true; - const selectedLabels = interaction.values .map((v) => Number(v)) .filter((idx) => Number.isInteger(idx) && question.options?.[idx]) .map((idx) => question.options![idx].label); - const answerKey = `${requestId}:${threadId}`; - let selections = pendingAnswers.get(answerKey); - if (!selections) { - selections = new Map(); - pendingAnswers.set(answerKey, selections); - } + const selections = getOrCreateSelections(requestId, threadId); selections.set(questionIndex, selectedLabels); const allAnswered = request.questions.every( - (_, i) => (selections!.get(i) ?? []).length > 0, + (_, i) => (selections.get(i) ?? []).length > 0, ); if (allAnswered) { - await submitAllAnswers(interaction, session.port, request, selections, answerKey, threadId); + await submitAllAnswers(interaction, session.port, request, selections, `${requestId}:${threadId}`, threadId); } else { - const maxRows = 5; const hasMulti = request.questions.some((q) => q.multiple); const showSubmit = hasMulti || request.questions.length > 1; const text = buildQuestionText(request.questions, selections); const components = buildQuestionComponents(threadId, request, selections, showSubmit); - const safeComponents = components.slice(0, maxRows); + const safeComponents = components.slice(0, 5); - await interaction.update({ content: text, components: safeComponents }); + await interaction.editReply({ content: text, components: safeComponents }); await interaction.followUp({ content: `βœ… Selected: ${selectedLabels.join(', ') || '(none)'}`, flags: MessageFlags.Ephemeral, }); } } catch (error) { - await interaction.followUp({ + await interaction.editReply({ content: `❌ Failed to process selection: ${(error as Error).message}`, + }); + } + } +} + +export async function handleModalSubmit(interaction: ModalSubmitInteraction) { + const customId = interaction.customId; + + if (customId.startsWith('qcustomModal:')) { + const [, threadId, requestId, questionIndexRaw] = customId.split(':'); + const questionIndex = Number(questionIndexRaw); + + if (!threadId || !requestId || !Number.isInteger(questionIndex)) { + await interaction.reply({ + content: '❌ Invalid custom answer.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const session = sessionManager.getSessionForThread(threadId); + if (!session) { + await interaction.reply({ + content: '⚠️ Session not found.', flags: MessageFlags.Ephemeral, }); + return; + } + + await interaction.deferUpdate(); + + try { + const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; + const request = findRequestForSession(questions, requestId, session.sessionId); + + if (!request) { + await interaction.editReply({ + content: '⚠️ Pending question not found or belongs to another session.', + }); + return; + } + + const answer = interaction.fields.getTextInputValue('answer'); + + const selections = getOrCreateSelections(requestId, threadId); + selections.set(questionIndex, [answer]); + + const allAnswered = request.questions.every( + (_, i) => (selections.get(i) ?? []).length > 0, + ); + + if (allAnswered) { + await submitAllAnswers(interaction, session.port, request, selections, `${requestId}:${threadId}`, threadId); + } else { + const text = buildQuestionText(request.questions, selections); + const components = buildQuestionComponents(threadId, request, selections, false); + await interaction.editReply({ content: text, components: components.slice(0, 5) }); + await interaction.followUp({ + content: `βœ… Q${questionIndex + 1} answered: ${answer.slice(0, 100)}`, + flags: MessageFlags.Ephemeral, + }); + } + } catch (error) { + await interaction.editReply({ + content: `❌ Failed to process custom answer: ${(error as Error).message}`, + }); } } } async function submitAllAnswers( - interaction: ButtonInteraction | StringSelectMenuInteraction, + interaction: ButtonInteraction | StringSelectMenuInteraction | ModalSubmitInteraction, port: number, request: QuestionRequest, selections: Map, @@ -313,13 +424,56 @@ async function submitAllAnswers( const answeredText = buildQuestionTextAnswered(request.questions, selections); const answeredComponents = buildAnsweredComponents(threadId, request.id); - await interaction.update({ content: answeredText, components: answeredComponents }); + await interaction.editReply({ content: answeredText, components: answeredComponents }); await interaction.followUp({ content: 'βœ… All questions answered.', flags: MessageFlags.Ephemeral, }); } +async function handleQuestionCustomButton( + interaction: ButtonInteraction, + threadId: string | undefined, + requestId: string | undefined, + questionIndexRaw: string | undefined, +) { + const questionIndex = Number(questionIndexRaw); + + if (!threadId || !requestId || !Number.isInteger(questionIndex)) { + await interaction.reply({ + content: '❌ Invalid custom answer request.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const session = sessionManager.getSessionForThread(threadId); + if (!session) { + await interaction.reply({ + content: '⚠️ Session not found.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const modal = new ModalBuilder() + .setCustomId(`qcustomModal:${threadId}:${requestId}:${questionIndex}`) + .setTitle('Custom answer') + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('answer') + .setLabel('Your answer') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Type your answer...') + .setRequired(true) + .setMaxLength(1500), + ), + ); + + await interaction.showModal(modal); +} + async function handleQuestionAnswer( interaction: ButtonInteraction, threadId: string | undefined, @@ -347,14 +501,15 @@ async function handleQuestionAnswer( return; } + await interaction.deferUpdate(); + try { const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; - const request = questions.find((q) => q.id === requestId); + const request = findRequestForSession(questions, requestId, session.sessionId); if (!request) { - await interaction.reply({ - content: '⚠️ Pending question not found. It may have already been answered.', - flags: MessageFlags.Ephemeral, + await interaction.editReply({ + content: '⚠️ Pending question not found or belongs to another session.', }); return; } @@ -363,30 +518,23 @@ async function handleQuestionAnswer( const option = question?.options?.[optionIndex]; if (!option?.label) { - await interaction.reply({ - content: '⚠️ Pending question/option not found. It may have already been answered.', - flags: MessageFlags.Ephemeral, + await interaction.editReply({ + content: '⚠️ Option not found. It may have already been answered.', }); return; } - const answerKey = `${requestId}:${threadId}`; - let selections = pendingAnswers.get(answerKey); - if (!selections) { - selections = new Map(); - pendingAnswers.set(answerKey, selections); - } - + const selections = getOrCreateSelections(requestId, threadId); selections.set(questionIndex, [option.label]); - const allAnswered = request.questions.every((_, i) => (selections!.get(i) ?? []).length > 0); + const allAnswered = request.questions.every((_, i) => (selections.get(i) ?? []).length > 0); if (allAnswered) { - await submitAllAnswers(interaction, session.port, request, selections, answerKey, threadId); + await submitAllAnswers(interaction, session.port, request, selections, `${requestId}:${threadId}`, threadId); } else { const text = buildQuestionText(request.questions, selections); const components = buildQuestionComponents(threadId, request, selections, false); - await interaction.update({ content: text, components: components.slice(0, 5) }); + await interaction.editReply({ content: text, components: components.slice(0, 5) }); await interaction.followUp({ content: `βœ… Q${questionIndex + 1} answered: ${option.label}`, flags: MessageFlags.Ephemeral, @@ -426,33 +574,28 @@ async function handleQuestionToggle( return; } + await interaction.deferUpdate(); + try { const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; - const request = questions.find((q) => q.id === requestId); + const request = findRequestForSession(questions, requestId, session.sessionId); if (!request) { - await interaction.reply({ - content: '⚠️ Pending question not found.', - flags: MessageFlags.Ephemeral, + await interaction.editReply({ + content: '⚠️ Pending question not found or belongs to another session.', }); return; } const option = request.questions[questionIndex]?.options?.[optionIndex]; if (!option?.label) { - await interaction.reply({ + await interaction.editReply({ content: '⚠️ Option not found.', - flags: MessageFlags.Ephemeral, }); return; } - const answerKey = `${requestId}:${threadId}`; - let selections = pendingAnswers.get(answerKey); - if (!selections) { - selections = new Map(); - pendingAnswers.set(answerKey, selections); - } + const selections = getOrCreateSelections(requestId, threadId); const current = selections.get(questionIndex) ?? []; if (current.includes(option.label)) { @@ -463,7 +606,7 @@ async function handleQuestionToggle( const text = buildQuestionText(request.questions, selections); const components = buildQuestionComponents(threadId, request, selections, true); - await interaction.update({ content: text, components: components.slice(0, 5) }); + await interaction.editReply({ content: text, components: components.slice(0, 5) }); await interaction.followUp({ content: `Toggled: ${option.label}`, flags: MessageFlags.Ephemeral, @@ -497,22 +640,33 @@ async function handleQuestionSubmit( return; } + await interaction.deferUpdate(); + try { const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; - const request = questions.find((q) => q.id === requestId); + const request = findRequestForSession(questions, requestId, session.sessionId); if (!request) { - await interaction.reply({ - content: '⚠️ Pending question not found.', - flags: MessageFlags.Ephemeral, + await interaction.editReply({ + content: '⚠️ Pending question not found or belongs to another session.', }); return; } - const answerKey = `${requestId}:${threadId}`; - const selections = pendingAnswers.get(answerKey) ?? new Map(); + const selections = getOrCreateSelections(requestId, threadId); - await submitAllAnswers(interaction, session.port, request, selections, answerKey, threadId); + const allAnswered = request.questions.every( + (_, i) => (selections.get(i) ?? []).length > 0, + ); + + if (!allAnswered) { + await interaction.editReply({ + content: '❌ Not all questions have an answer yet. Please answer all questions before submitting.', + }); + return; + } + + await submitAllAnswers(interaction, session.port, request, selections, `${requestId}:${threadId}`, threadId); } catch (error) { await interaction.editReply({ content: `❌ Failed to submit: ${(error as Error).message}`, @@ -542,12 +696,14 @@ async function handleQuestionReject( return; } + await interaction.deferUpdate(); + try { await sessionManager.rejectQuestion(session.port, requestId); const answerKey = `${requestId}:${threadId}`; clearPendingAnswers(answerKey); - await interaction.update({ + await interaction.editReply({ content: '🚫 Question rejected.', components: buildAnsweredComponents(threadId, requestId), }); diff --git a/src/handlers/interactionHandler.ts b/src/handlers/interactionHandler.ts index 4faac65..450137a 100644 --- a/src/handlers/interactionHandler.ts +++ b/src/handlers/interactionHandler.ts @@ -1,6 +1,6 @@ import { Interaction, MessageFlags } from 'discord.js'; import { commands } from '../commands/index.js'; -import { handleButton, handleSelectMenu } from './buttonHandler.js'; +import { handleButton, handleSelectMenu, handleModalSubmit } from './buttonHandler.js'; import { isAuthorized } from '../services/configStore.js'; export async function handleInteraction(interaction: Interaction) { @@ -8,7 +8,7 @@ export async function handleInteraction(interaction: Interaction) { if (!isAuthorized(interaction.user.id)) { await interaction.reply({ content: '🚫 You are not authorized to use this bot.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, }); return; } @@ -36,6 +36,22 @@ export async function handleInteraction(interaction: Interaction) { return; } + if (interaction.isModalSubmit()) { + if (!isAuthorized(interaction.user.id)) { + await interaction.reply({ + content: '🚫 You are not authorized to use this bot.', + flags: MessageFlags.Ephemeral, + }); + return; + } + try { + await handleModalSubmit(interaction); + } catch (error) { + console.error('Error handling modal submit:', error); + } + return; + } + if (interaction.isAutocomplete()) { const command = commands.get(interaction.commandName); if (command?.autocomplete) { @@ -60,7 +76,7 @@ export async function handleInteraction(interaction: Interaction) { if (!isAuthorized(interaction.user.id)) { await interaction.reply({ content: '🚫 You are not authorized to use this bot.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, }); return; } From 745d68a2b91c8771356c4ac202e2e27064a27357 Mon Sep 17 00:00:00 2001 From: fox3000foxy <40730498+fox3000foxy@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:28:33 +0200 Subject: [PATCH 4/6] fix: address 4 remaining review issues - custom detection: treat custom as enabled unless explicitly disabled (question.custom !== false instead of === true) - 6-button row: when 5 options + custom would exceed limit, use select menu instead; add custom button in its own row for select menu questions - pagination: support 5+ questions by paginating maxRows-1 questions per page with Prev/Next nav in the bottom row - reject ownership: add findRequestForSession check before rejecting --- src/handlers/buttonHandler.ts | 288 +++++++++++++++++++++++-------- src/services/executionService.ts | 2 +- 2 files changed, 215 insertions(+), 75 deletions(-) diff --git a/src/handlers/buttonHandler.ts b/src/handlers/buttonHandler.ts index 2276315..c1e926e 100644 --- a/src/handlers/buttonHandler.ts +++ b/src/handlers/buttonHandler.ts @@ -20,13 +20,19 @@ import * as dataStore from '../services/dataStore.js'; import * as worktreeManager from '../services/worktreeManager.js'; const pendingAnswers = new Map>(); +const pendingPage = new Map(); export function setPendingAnswers(key: string, selections: Map): void { pendingAnswers.set(key, selections); } +export function setPendingPage(key: string, page: number): void { + pendingPage.set(key, page); +} + function clearPendingAnswers(key: string): void { pendingAnswers.delete(key); + pendingPage.delete(key); } function findRequestForSession( @@ -81,16 +87,25 @@ export function buildQuestionComponents( request: QuestionRequest, selections: Map, showSubmit: boolean, + page: number = 0, ): ActionRowBuilder[] { const rows: ActionRowBuilder[] = []; const maxRows = 5; - - for (let qIdx = 0; qIdx < request.questions.length && rows.length < maxRows; qIdx++) { + const questions = request.questions; + const totalQuestions = questions.length; + const questionsPerPage = maxRows - 1; + const maxPage = totalQuestions > questionsPerPage ? Math.ceil(totalQuestions / questionsPerPage) - 1 : 0; + const currentPage = Math.max(0, Math.min(page, maxPage)); + const pageStart = currentPage * questionsPerPage; + const pageEnd = Math.min(pageStart + questionsPerPage, totalQuestions); + const key = `${request.id}:${threadId}`; + + for (let qIdx = pageStart; qIdx < pageEnd && rows.length < maxRows - 1; qIdx++) { const question = request.questions[qIdx]; const selectedLabels = selections.get(qIdx) ?? []; const options = question.options ?? []; const isMulti = question.multiple === true; - const hasCustom = question.custom === true; + const hasCustom = question.custom !== false; if (options.length === 0) { rows.push( @@ -101,59 +116,75 @@ export function buildQuestionComponents( .setStyle(ButtonStyle.Secondary), ), ); - } else if (isMulti || options.length > 5) { - const selectMenu = new StringSelectMenuBuilder() - .setCustomId(`qselect:${threadId}:${request.id}:${qIdx}`) - .setPlaceholder( - selectedLabels.length > 0 - ? `Selected: ${selectedLabels.join(', ').slice(0, 100)}` - : `Choose option(s) for Q${qIdx + 1}`, - ) - .setMinValues(isMulti ? 1 : 1) - .setMaxValues(Math.min(isMulti ? options.length : 1, 25)) - .addOptions( - options.slice(0, 25).map((opt, oIdx) => { - const optBuilder = new StringSelectMenuOptionBuilder() - .setLabel(opt.label.slice(0, 100)) - .setValue(String(oIdx)) - .setDefault(selectedLabels.includes(opt.label)); - if (opt.description) { - optBuilder.setDescription(opt.description.slice(0, 100)); - } - return optBuilder; - }), - ); - - rows.push(new ActionRowBuilder().addComponents(selectMenu)); } else { - const buttons = options.map((opt, oIdx) => { - const isSelected = selectedLabels.includes(opt.label); - const customId = isMulti - ? `qtoggle:${threadId}:${request.id}:${qIdx}:${oIdx}` - : `qanswer:${threadId}:${request.id}:${qIdx}:${oIdx}`; - - return new ButtonBuilder() - .setCustomId(customId) - .setLabel(opt.label.slice(0, 80)) - .setStyle( - isSelected - ? isMulti - ? ButtonStyle.Success - : ButtonStyle.Primary - : ButtonStyle.Secondary, + const totalSlots = options.length + (hasCustom ? 1 : 0); + const useSelect = isMulti || options.length > 5 || totalSlots > 5; + + if (useSelect) { + const selectMenu = new StringSelectMenuBuilder() + .setCustomId(`qselect:${threadId}:${request.id}:${qIdx}`) + .setPlaceholder( + selectedLabels.length > 0 + ? `Selected: ${selectedLabels.join(', ').slice(0, 100)}` + : `Choose option(s) for Q${qIdx + 1}`, + ) + .setMinValues(1) + .setMaxValues(Math.min(isMulti ? options.length : 1, 25)) + .addOptions( + options.slice(0, 25).map((opt, oIdx) => { + const optBuilder = new StringSelectMenuOptionBuilder() + .setLabel(opt.label.slice(0, 100)) + .setValue(String(oIdx)) + .setDefault(selectedLabels.includes(opt.label)); + if (opt.description) { + optBuilder.setDescription(opt.description.slice(0, 100)); + } + return optBuilder; + }), ); - }); - if (hasCustom) { - buttons.push( - new ButtonBuilder() - .setCustomId(`qcustom:${threadId}:${request.id}:${qIdx}`) - .setLabel('✏️ Custom') - .setStyle(ButtonStyle.Secondary), - ); + rows.push(new ActionRowBuilder().addComponents(selectMenu)); + } else { + const buttons = options.map((opt, oIdx) => { + const isSelected = selectedLabels.includes(opt.label); + const customId = isMulti + ? `qtoggle:${threadId}:${request.id}:${qIdx}:${oIdx}` + : `qanswer:${threadId}:${request.id}:${qIdx}:${oIdx}`; + + return new ButtonBuilder() + .setCustomId(customId) + .setLabel(opt.label.slice(0, 80)) + .setStyle( + isSelected + ? isMulti + ? ButtonStyle.Success + : ButtonStyle.Primary + : ButtonStyle.Secondary, + ); + }); + + if (hasCustom) { + buttons.push( + new ButtonBuilder() + .setCustomId(`qcustom:${threadId}:${request.id}:${qIdx}`) + .setLabel('✏️ Custom') + .setStyle(ButtonStyle.Secondary), + ); + } + + rows.push(new ActionRowBuilder().addComponents(...buttons)); } - rows.push(new ActionRowBuilder().addComponents(...buttons)); + if (useSelect && hasCustom && rows.length < maxRows) { + rows.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`qcustom:${threadId}:${request.id}:${qIdx}`) + .setLabel('✏️ Custom') + .setStyle(ButtonStyle.Secondary), + ), + ); + } } } @@ -164,31 +195,53 @@ export function buildQuestionComponents( (_, i) => (selections.get(i) ?? []).length > 0, ); + const bottomButtons: ButtonBuilder[] = []; + if (hasMulti && showSubmit) { - rows.push( - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`qsubmit:${threadId}:${request.id}`) - .setLabel('Submit Answers') - .setStyle(ButtonStyle.Success) - .setDisabled(!allAnswered), - new ButtonBuilder() - .setCustomId(`qreject:${threadId}:${request.id}`) - .setLabel('Reject') - .setStyle(ButtonStyle.Danger), - ), + bottomButtons.push( + new ButtonBuilder() + .setCustomId(`qsubmit:${threadId}:${request.id}`) + .setLabel('Submit Answers') + .setStyle(ButtonStyle.Success) + .setDisabled(!allAnswered), ); - } else { - rows.push( - new ActionRowBuilder().addComponents( + } + + if (maxPage > 0) { + if (currentPage > 0) { + bottomButtons.push( new ButtonBuilder() - .setCustomId(`qreject:${threadId}:${request.id}`) - .setLabel('Reject') - .setStyle(ButtonStyle.Danger), - ), + .setCustomId(`qpage:${threadId}:${request.id}:${currentPage - 1}`) + .setLabel('β—€ Prev') + .setStyle(ButtonStyle.Secondary), + ); + } + bottomButtons.push( + new ButtonBuilder() + .setCustomId(`qpagelabel:${threadId}:${request.id}`) + .setLabel(`Page ${currentPage + 1}/${maxPage + 1}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), ); + if (currentPage < maxPage) { + bottomButtons.push( + new ButtonBuilder() + .setCustomId(`qpage:${threadId}:${request.id}:${currentPage + 1}`) + .setLabel('Next β–Ά') + .setStyle(ButtonStyle.Secondary), + ); + } } + bottomButtons.push( + new ButtonBuilder() + .setCustomId(`qreject:${threadId}:${request.id}`) + .setLabel('Reject') + .setStyle(ButtonStyle.Danger), + ); + + rows.push(new ActionRowBuilder().addComponents(...bottomButtons)); + return rows; } @@ -240,6 +293,17 @@ export async function handleButton(interaction: ButtonInteraction) { return; } + if (customId.startsWith('qpage:')) { + const [, threadId, requestId, pageRaw] = customId.split(':'); + await handleQuestionPage(interaction, threadId, requestId, pageRaw); + return; + } + + if (customId.startsWith('qpagelabel:')) { + await interaction.deferUpdate(); + return; + } + const [action, threadId] = customId.split('_'); if (!threadId) { @@ -327,7 +391,8 @@ export async function handleSelectMenu(interaction: StringSelectMenuInteraction) const hasMulti = request.questions.some((q) => q.multiple); const showSubmit = hasMulti || request.questions.length > 1; const text = buildQuestionText(request.questions, selections); - const components = buildQuestionComponents(threadId, request, selections, showSubmit); + const page = pendingPage.get(`${requestId}:${threadId}`) ?? 0; + const components = buildQuestionComponents(threadId, request, selections, showSubmit, page); const safeComponents = components.slice(0, 5); @@ -395,7 +460,8 @@ export async function handleModalSubmit(interaction: ModalSubmitInteraction) { await submitAllAnswers(interaction, session.port, request, selections, `${requestId}:${threadId}`, threadId); } else { const text = buildQuestionText(request.questions, selections); - const components = buildQuestionComponents(threadId, request, selections, false); + const page = pendingPage.get(`${requestId}:${threadId}`) ?? 0; + const components = buildQuestionComponents(threadId, request, selections, false, page); await interaction.editReply({ content: text, components: components.slice(0, 5) }); await interaction.followUp({ content: `βœ… Q${questionIndex + 1} answered: ${answer.slice(0, 100)}`, @@ -533,7 +599,8 @@ async function handleQuestionAnswer( await submitAllAnswers(interaction, session.port, request, selections, `${requestId}:${threadId}`, threadId); } else { const text = buildQuestionText(request.questions, selections); - const components = buildQuestionComponents(threadId, request, selections, false); + const page = pendingPage.get(`${requestId}:${threadId}`) ?? 0; + const components = buildQuestionComponents(threadId, request, selections, false, page); await interaction.editReply({ content: text, components: components.slice(0, 5) }); await interaction.followUp({ content: `βœ… Q${questionIndex + 1} answered: ${option.label}`, @@ -605,7 +672,8 @@ async function handleQuestionToggle( } const text = buildQuestionText(request.questions, selections); - const components = buildQuestionComponents(threadId, request, selections, true); + const page = pendingPage.get(`${requestId}:${threadId}`) ?? 0; + const components = buildQuestionComponents(threadId, request, selections, true, page); await interaction.editReply({ content: text, components: components.slice(0, 5) }); await interaction.followUp({ content: `Toggled: ${option.label}`, @@ -699,6 +767,16 @@ async function handleQuestionReject( await interaction.deferUpdate(); try { + const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; + const request = findRequestForSession(questions, requestId, session.sessionId); + + if (!request) { + await interaction.editReply({ + content: '⚠️ Pending question not found or belongs to another session.', + }); + return; + } + await sessionManager.rejectQuestion(session.port, requestId); const answerKey = `${requestId}:${threadId}`; clearPendingAnswers(answerKey); @@ -718,6 +796,68 @@ async function handleQuestionReject( } } +async function handleQuestionPage( + interaction: ButtonInteraction, + threadId: string | undefined, + requestId: string | undefined, + pageRaw: string | undefined, +) { + if (!threadId || !requestId || pageRaw === undefined) { + await interaction.reply({ + content: '❌ Invalid page navigation.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const newPage = Number(pageRaw); + if (!Number.isInteger(newPage)) { + await interaction.reply({ + content: '❌ Invalid page number.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const session = sessionManager.getSessionForThread(threadId); + if (!session) { + await interaction.reply({ + content: '⚠️ Session not found.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferUpdate(); + + try { + const questions = (await sessionManager.listQuestions(session.port)) as QuestionRequest[]; + const request = findRequestForSession(questions, requestId, session.sessionId); + + if (!request) { + await interaction.editReply({ + content: '⚠️ Pending question not found or belongs to another session.', + }); + return; + } + + const key = `${requestId}:${threadId}`; + pendingPage.set(key, newPage); + + const selections = getOrCreateSelections(requestId, threadId); + const text = buildQuestionText(request.questions, selections); + const hasMulti = request.questions.some((q) => q.multiple); + const showSubmit = hasMulti || request.questions.length > 1; + const components = buildQuestionComponents(threadId, request, selections, showSubmit, newPage); + + await interaction.editReply({ content: text, components: components.slice(0, 5) }); + } catch (error) { + await interaction.editReply({ + content: `❌ Failed to change page: ${(error as Error).message}`, + }); + } +} + async function handleInterrupt(interaction: ButtonInteraction, threadId: string) { const session = sessionManager.getSessionForThread(threadId); diff --git a/src/services/executionService.ts b/src/services/executionService.ts index 42df6ef..6d66d33 100644 --- a/src/services/executionService.ts +++ b/src/services/executionService.ts @@ -292,7 +292,7 @@ export async function runPrompt( setPendingAnswers(answerKey, initialSelections); const questionText = buildQuestionText(questions, initialSelections); - const components = buildQuestionComponents(threadId, request, initialSelections, false); + const components = buildQuestionComponents(threadId, request, initialSelections, false, 0); const content = `${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\n${questionText}`; const edited = await updateStreamMessage(content, components); From 74034dba007628365315368f5ea5da0be0457d42 Mon Sep 17 00:00:00 2001 From: fox3000foxy <40730498+fox3000foxy@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:58:19 +0200 Subject: [PATCH 5/6] fix: row-based pagination, paginated text, option overflow, test isolation - Pagination now counts by actual row consumption (select+custom=2 rows) - buildQuestionText only renders the current page's questions - Options >25 force enable custom input so overflow is reachable - Reject test now explicitly mocks listQuestions (no state leak) --- src/__tests__/buttonHandler.test.ts | 7 +++ src/handlers/buttonHandler.ts | 96 ++++++++++++++++++++++++----- src/services/executionService.ts | 2 +- 3 files changed, 88 insertions(+), 17 deletions(-) diff --git a/src/__tests__/buttonHandler.test.ts b/src/__tests__/buttonHandler.test.ts index f46eff4..64544b8 100644 --- a/src/__tests__/buttonHandler.test.ts +++ b/src/__tests__/buttonHandler.test.ts @@ -114,6 +114,13 @@ describe("handleButton question responses", () => { projectPath: "/repo", port: 14098, }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_abc", + sessionID: "ses_123", + questions: [], + }, + ]); sessionManagerMock.rejectQuestion.mockResolvedValue(true); const interaction = mockInteraction("qreject:thread123:que_abc"); diff --git a/src/handlers/buttonHandler.ts b/src/handlers/buttonHandler.ts index c1e926e..988b093 100644 --- a/src/handlers/buttonHandler.ts +++ b/src/handlers/buttonHandler.ts @@ -35,6 +35,62 @@ function clearPendingAnswers(key: string): void { pendingPage.delete(key); } +function countQuestionRows(question: QuestionItem): number { + const options = question.options ?? []; + const hasCustom = question.custom !== false; + if (options.length === 0) return 1; + const totalSlots = options.length + (hasCustom ? 1 : 0); + const useSelect = question.multiple === true || options.length > 5 || totalSlots > 5; + if (useSelect && hasCustom) return 2; + return 1; +} + +function computePageSlice( + questions: QuestionItem[], + page: number, + questionRows: number, +): { start: number; end: number; maxPage: number } { + let totalPages = 0; + { + let idx = 0; + while (idx < questions.length) { + let used = 0; + while (used < questionRows && idx < questions.length) { + const qRows = countQuestionRows(questions[idx]); + if (used + qRows > questionRows) break; + used += qRows; + idx++; + } + totalPages++; + } + } + + const maxPage = Math.max(0, totalPages - 1); + const requestedPage = Math.max(0, Math.min(page, maxPage)); + + let start = 0; + for (let p = 0; p < requestedPage; p++) { + let used = 0; + while (used < questionRows && start < questions.length) { + const qRows = countQuestionRows(questions[start]); + if (used + qRows > questionRows) break; + used += qRows; + start++; + } + } + + let end = start; + let used = 0; + while (used < questionRows && end < questions.length) { + const qRows = countQuestionRows(questions[end]); + if (used + qRows > questionRows) break; + used += qRows; + end++; + } + + return { start, end, maxPage }; +} + function findRequestForSession( questions: QuestionRequest[], requestId: string, @@ -59,14 +115,18 @@ function getOrCreateSelections( export function buildQuestionText( questions: QuestionItem[], selections: Map, + start: number = 0, + end?: number, ): string { - const parts = questions.map((q, i) => { - const labels = selections.get(i) ?? []; + const slice = end !== undefined ? questions.slice(start, end) : questions.slice(start); + const parts = slice.map((q, i) => { + const idx = start + i; + const labels = selections.get(idx) ?? []; const isAnswered = labels.length > 0; const status = isAnswered ? `βœ… ${labels.join(', ')}` : '⬜ Pending'; const header = q.header ? `**${q.header}**` : ''; const body = q.question.slice(0, 200); - return `**Q${i + 1}:** ${header} (${status})\n${body}`; + return `**Q${idx + 1}:** ${header} (${status})\n${body}`; }); return `⏸️ **Waiting for OpenCode input**\n\n${parts.join('\n\n')}`; } @@ -92,20 +152,19 @@ export function buildQuestionComponents( const rows: ActionRowBuilder[] = []; const maxRows = 5; const questions = request.questions; - const totalQuestions = questions.length; - const questionsPerPage = maxRows - 1; - const maxPage = totalQuestions > questionsPerPage ? Math.ceil(totalQuestions / questionsPerPage) - 1 : 0; + const questionRows = maxRows - 1; + const { start, end, maxPage } = computePageSlice(questions, page, questionRows); const currentPage = Math.max(0, Math.min(page, maxPage)); - const pageStart = currentPage * questionsPerPage; - const pageEnd = Math.min(pageStart + questionsPerPage, totalQuestions); - const key = `${request.id}:${threadId}`; - for (let qIdx = pageStart; qIdx < pageEnd && rows.length < maxRows - 1; qIdx++) { + for (let qIdx = start; qIdx < end && rows.length < questionRows; qIdx++) { const question = request.questions[qIdx]; const selectedLabels = selections.get(qIdx) ?? []; const options = question.options ?? []; const isMulti = question.multiple === true; - const hasCustom = question.custom !== false; + let hasCustom = question.custom !== false; + if (!hasCustom && options.length > 25) { + hasCustom = true; + } if (options.length === 0) { rows.push( @@ -390,8 +449,9 @@ export async function handleSelectMenu(interaction: StringSelectMenuInteraction) } else { const hasMulti = request.questions.some((q) => q.multiple); const showSubmit = hasMulti || request.questions.length > 1; - const text = buildQuestionText(request.questions, selections); const page = pendingPage.get(`${requestId}:${threadId}`) ?? 0; + const { start: ts, end: te } = computePageSlice(request.questions, page, 4); + const text = buildQuestionText(request.questions, selections, ts, te); const components = buildQuestionComponents(threadId, request, selections, showSubmit, page); const safeComponents = components.slice(0, 5); @@ -459,8 +519,9 @@ export async function handleModalSubmit(interaction: ModalSubmitInteraction) { if (allAnswered) { await submitAllAnswers(interaction, session.port, request, selections, `${requestId}:${threadId}`, threadId); } else { - const text = buildQuestionText(request.questions, selections); const page = pendingPage.get(`${requestId}:${threadId}`) ?? 0; + const { start: ts, end: te } = computePageSlice(request.questions, page, 4); + const text = buildQuestionText(request.questions, selections, ts, te); const components = buildQuestionComponents(threadId, request, selections, false, page); await interaction.editReply({ content: text, components: components.slice(0, 5) }); await interaction.followUp({ @@ -598,8 +659,9 @@ async function handleQuestionAnswer( if (allAnswered) { await submitAllAnswers(interaction, session.port, request, selections, `${requestId}:${threadId}`, threadId); } else { - const text = buildQuestionText(request.questions, selections); const page = pendingPage.get(`${requestId}:${threadId}`) ?? 0; + const { start: ts, end: te } = computePageSlice(request.questions, page, 4); + const text = buildQuestionText(request.questions, selections, ts, te); const components = buildQuestionComponents(threadId, request, selections, false, page); await interaction.editReply({ content: text, components: components.slice(0, 5) }); await interaction.followUp({ @@ -671,8 +733,9 @@ async function handleQuestionToggle( selections.set(questionIndex, [...current, option.label]); } - const text = buildQuestionText(request.questions, selections); const page = pendingPage.get(`${requestId}:${threadId}`) ?? 0; + const { start: ts, end: te } = computePageSlice(request.questions, page, 4); + const text = buildQuestionText(request.questions, selections, ts, te); const components = buildQuestionComponents(threadId, request, selections, true, page); await interaction.editReply({ content: text, components: components.slice(0, 5) }); await interaction.followUp({ @@ -845,9 +908,10 @@ async function handleQuestionPage( pendingPage.set(key, newPage); const selections = getOrCreateSelections(requestId, threadId); - const text = buildQuestionText(request.questions, selections); const hasMulti = request.questions.some((q) => q.multiple); const showSubmit = hasMulti || request.questions.length > 1; + const { start: ts, end: te } = computePageSlice(request.questions, newPage, 4); + const text = buildQuestionText(request.questions, selections, ts, te); const components = buildQuestionComponents(threadId, request, selections, showSubmit, newPage); await interaction.editReply({ content: text, components: components.slice(0, 5) }); diff --git a/src/services/executionService.ts b/src/services/executionService.ts index 6d66d33..6b8103b 100644 --- a/src/services/executionService.ts +++ b/src/services/executionService.ts @@ -291,7 +291,7 @@ export async function runPrompt( questions.forEach((_, i) => initialSelections.set(i, [])); setPendingAnswers(answerKey, initialSelections); - const questionText = buildQuestionText(questions, initialSelections); + const questionText = buildQuestionText(questions, initialSelections, 0, questions.length); const components = buildQuestionComponents(threadId, request, initialSelections, false, 0); const content = `${contextHeader}\nπŸ“Œ **Prompt**: ${prompt}\n\n${questionText}`; From 99009f18361585089c5f7dfc41b5f374592713f8 Mon Sep 17 00:00:00 2001 From: fox3000foxy <40730498+fox3000foxy@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:00:14 +0200 Subject: [PATCH 6/6] fix: isolate serveManager tests from OPENCODE_SERVER_PASSWORD env var --- src/__tests__/serveManager.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/__tests__/serveManager.test.ts b/src/__tests__/serveManager.test.ts index e29e17e..a281c69 100644 --- a/src/__tests__/serveManager.test.ts +++ b/src/__tests__/serveManager.test.ts @@ -313,6 +313,8 @@ describe("serveManager", () => { }); it("should resolve when fetch returns ok", async () => { + const origPwd = process.env.OPENCODE_SERVER_PASSWORD; + delete process.env.OPENCODE_SERVER_PASSWORD; vi.mocked(fetch).mockResolvedValue({ ok: true } as Response); const promise = serveManager.waitForReady(14097); @@ -323,6 +325,8 @@ describe("serveManager", () => { expect(fetch).toHaveBeenCalledWith("http://127.0.0.1:14097/session", { headers: {}, }); + if (origPwd === undefined) delete process.env.OPENCODE_SERVER_PASSWORD; + else process.env.OPENCODE_SERVER_PASSWORD = origPwd; }); it("should retry if fetch fails or returns not ok", async () => { @@ -440,6 +444,7 @@ describe("serveManager", () => { it("fails fast with a clear error when readiness probe returns 401 and password is unset", async () => { vi.useRealTimers(); + delete process.env.OPENCODE_SERVER_PASSWORD; vi.mocked(fetch).mockResolvedValue({ ok: false, status: 401,