Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions src/__tests__/buttonHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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.",
});
});
});
51 changes: 51 additions & 0 deletions src/__tests__/sessionManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ import {
getSessionInfo,
listSessions,
abortSession,
listQuestions,
replyQuestion,
rejectQuestion,
ensureSessionForThread,
getSessionForThread,
setSessionForThread,
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 54 additions & 0 deletions src/__tests__/sseClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
97 changes: 97 additions & 0 deletions src/handlers/buttonHandler.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
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';
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('_');

Expand Down Expand Up @@ -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) {
Expand Down
Loading