diff --git a/src/__tests__/buttonHandler.test.ts b/src/__tests__/buttonHandler.test.ts new file mode 100644 index 0000000..64544b8 --- /dev/null +++ b/src/__tests__/buttonHandler.test.ts @@ -0,0 +1,421 @@ +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, handleSelectMenu, handleModalSubmit } from "../handlers/buttonHandler.js"; + +function mockInteraction(customId: string) { + return { + customId, + 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; +} + +function mockSelectInteraction(customId: string, values: string[]) { + return { + customId, + values, + 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 }, + } as any; +} + +describe("handleButton question responses", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("answers a single-select question with one question (auto-submit)", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + projectPath: "/repo", + port: 14098, + }); + sessionManagerMock.listQuestions.mockResolvedValue([ + { + id: "que_abc", + 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_abc:0:0"); + await handleButton(interaction); + + expect(interaction.deferUpdate).toHaveBeenCalled(); + expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_abc", [ + ["Approve plan"], + ]); + expect(interaction.followUp).toHaveBeenCalledWith({ + content: "✅ All questions answered.", + flags: 64, + }); + }); + + it("rejects OpenCode questions", async () => { + sessionManagerMock.getSessionForThread.mockReturnValue({ + sessionId: "ses_123", + 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"); + await handleButton(interaction); + + expect(interaction.deferUpdate).toHaveBeenCalled(); + expect(sessionManagerMock.rejectQuestion).toHaveBeenCalledWith(14098, "que_abc"); + }); + + 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.deferUpdate).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"], + ]); + }); + + 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.deferUpdate).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"], + ]); + }); + + 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(interaction.deferUpdate).toHaveBeenCalled(); + expect(sessionManagerMock.replyQuestion).toHaveBeenCalledWith(14098, "que_select", [ + ["Option B"], + ]); + }); + + 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_multi_select", + [["Alpha", "Gamma"]], + ); + }); + + 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 () => { + 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/__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, 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..988b093 100644 --- a/src/handlers/buttonHandler.ts +++ b/src/handlers/buttonHandler.ts @@ -1,22 +1,378 @@ -import { ButtonInteraction, ThreadChannel, MessageFlags } from 'discord.js'; +import { + ButtonInteraction, + StringSelectMenuInteraction, + ModalSubmitInteraction, + ThreadChannel, + MessageFlags, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} 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>(); +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 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, + 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, + start: number = 0, + end?: number, +): string { + 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${idx + 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, + page: number = 0, +): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + const maxRows = 5; + const questions = request.questions; + const questionRows = maxRows - 1; + const { start, end, maxPage } = computePageSlice(questions, page, questionRows); + const currentPage = Math.max(0, Math.min(page, maxPage)); + + 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; + let hasCustom = question.custom !== false; + if (!hasCustom && options.length > 25) { + hasCustom = 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 { + 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; + }), + ); + + 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)); + } + + if (useSelect && hasCustom && rows.length < maxRows) { + rows.push( + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`qcustom:${threadId}:${request.id}:${qIdx}`) + .setLabel('✏️ Custom') + .setStyle(ButtonStyle.Secondary), + ), + ); + } + } + } + + 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, + ); + + const bottomButtons: ButtonBuilder[] = []; + + if (hasMulti && showSubmit) { + bottomButtons.push( + new ButtonBuilder() + .setCustomId(`qsubmit:${threadId}:${request.id}`) + .setLabel('Submit Answers') + .setStyle(ButtonStyle.Success) + .setDisabled(!allAnswered), + ); + } + + if (maxPage > 0) { + if (currentPage > 0) { + bottomButtons.push( + new ButtonBuilder() + .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; +} + +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, 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; + } + + if (customId.startsWith('qreject:')) { + const [, threadId, requestId] = customId.split(':'); + await handleQuestionReject(interaction, threadId, requestId); + return; + } + + if (customId.startsWith('qcustom:')) { + const [, threadId, requestId, questionIndexRaw] = customId.split(':'); + await handleQuestionCustomButton(interaction, threadId, requestId, questionIndexRaw); + 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) { await interaction.reply({ content: '❌ Invalid button.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, }); return; } - + if (action === 'interrupt') { await handleInterrupt(interaction, threadId); } else if (action === 'delete') { @@ -26,18 +382,553 @@ export async function handleButton(interaction: ButtonInteraction) { } else { await interaction.reply({ content: '❌ Unknown action.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, + }); + } +} + +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; + } + + 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 question = request.questions[questionIndex]; + if (!question) { + await interaction.editReply({ + content: '⚠️ Question not found.', + }); + return; + } + + const selectedLabels = interaction.values + .map((v) => Number(v)) + .filter((idx) => Number.isInteger(idx) && question.options?.[idx]) + .map((idx) => question.options![idx].label); + + const selections = getOrCreateSelections(requestId, threadId); + selections.set(questionIndex, selectedLabels); + + 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 hasMulti = request.questions.some((q) => q.multiple); + const showSubmit = hasMulti || request.questions.length > 1; + 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); + + await interaction.editReply({ content: text, components: safeComponents }); + await interaction.followUp({ + content: `✅ Selected: ${selectedLabels.join(', ') || '(none)'}`, + flags: MessageFlags.Ephemeral, + }); + } + } catch (error) { + 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 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({ + 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 | ModalSubmitInteraction, + 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.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, + 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: '❌ 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.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 question = request.questions[questionIndex]; + const option = question?.options?.[optionIndex]; + + if (!option?.label) { + await interaction.editReply({ + content: '⚠️ Option not found. It may have already been answered.', + }); + return; + } + + const selections = getOrCreateSelections(requestId, threadId); + 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, `${requestId}:${threadId}`, threadId); + } else { + 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({ + 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 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(questionIndex) || !Number.isInteger(optionIndex)) { + await interaction.reply({ + content: '❌ Invalid toggle.', + 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 option = request.questions[questionIndex]?.options?.[optionIndex]; + if (!option?.label) { + await interaction.editReply({ + content: '⚠️ Option not found.', + }); + return; + } + + const selections = getOrCreateSelections(requestId, threadId); + + 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 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({ + content: `Toggled: ${option.label}`, + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + await interaction.editReply({ + 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; + } + + 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 selections = getOrCreateSelections(requestId, 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}`, + }); + } +} + +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.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); + + await interaction.editReply({ + 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}`, + }); + } +} + +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 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) }); + } 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); - + if (!session) { await interaction.reply({ content: '⚠️ Session not found.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, }); return; } @@ -47,23 +938,25 @@ async function handleInterrupt(interaction: ButtonInteraction, threadId: string) 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 + 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.' }); + await interaction.editReply({ + content: '⚠️ Failed to interrupt. Server may not be running or no active task.', + }); } } @@ -111,7 +1004,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..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 } 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; } @@ -20,6 +20,38 @@ 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.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) { @@ -38,29 +70,29 @@ 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.', - flags: MessageFlags.Ephemeral + flags: MessageFlags.Ephemeral, }); 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 25d9c41..6b8103b 100644 --- a/src/services/executionService.ts +++ b/src/services/executionService.ts @@ -13,6 +13,12 @@ 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'; +import { + buildQuestionText, + buildQuestionComponents, + setPendingAnswers +} from '../handlers/buttonHandler.js'; export async function runPrompt( channel: TextBasedChannel, @@ -112,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; @@ -267,6 +273,39 @@ export async function runPrompt( })(); }); + sseClient.onQuestionAsked((request: QuestionRequest) => { + if (request.sessionID !== sessionId) return; + + if (updateInterval) { + clearInterval(updateInterval); + updateInterval = null; + } + + (async () => { + try { + const questions = request.questions ?? []; + if (questions.length === 0) return; + + const answerKey = `${request.id}:${threadId}`; + const initialSelections = new Map(); + questions.forEach((_, i) => initialSelections.set(i, [])); + setPendingAnswers(answerKey, 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}`; + const edited = await updateStreamMessage(content, components); + if (!edited) { + await safeSend(`⏸️ OpenCode is waiting for input`); + } + } 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;