Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0d8300f
feat: add GitHub adapter for coding agent bot
sweetmantech Mar 6, 2026
adfcde4
chore: trigger redeploy with updated env vars
sweetmantech Mar 6, 2026
0b5d839
fix: route mentions to update-pr when thread already has PRs
sweetmantech Mar 6, 2026
f20c67b
fix: reset thread state on no_changes and failed callbacks
sweetmantech Mar 6, 2026
68a5f85
fix: import bot singleton in callback route
sweetmantech Mar 6, 2026
9e65b58
fix: ensure Redis is connected before handling callback
sweetmantech Mar 6, 2026
3622d17
fix: handle Redis connecting state in callback route
sweetmantech Mar 6, 2026
fb8d5d7
feat: add Merge All PRs button to pr_created message
sweetmantech Mar 6, 2026
f7c9941
feat: add View Task button to initial mention reply
sweetmantech Mar 6, 2026
ded9553
fix: use CardText instead of Text (not exported from chat SDK)
sweetmantech Mar 6, 2026
cf51eb3
refactor: remove GitHub adapter, keep Slack-only with merge button
sweetmantech Mar 6, 2026
cb6e8f7
feat: add View Task button to PR update feedback message
sweetmantech Mar 6, 2026
bbb3f36
feat: add PR buttons to updated callback, DRY with buildPRCard
sweetmantech Mar 6, 2026
b2eca9d
refactor: DRY View Task button into buildTaskCard
sweetmantech Mar 6, 2026
fcc512f
refactor: remove redundant text PR links from buildPRCard
sweetmantech Mar 6, 2026
413cb55
fix: restore PR summary text without links in buildPRCard
sweetmantech Mar 6, 2026
5f5a9ca
debug: log triggerUpdatePR handle and card payload
sweetmantech Mar 6, 2026
26a7c75
fix: add View Task button to onSubscribedMessage feedback reply
sweetmantech Mar 6, 2026
5f71242
refactor: DRY feedback handling into shared handleFeedback
sweetmantech Mar 6, 2026
78ab30b
fix: widen handleFeedback thread type to match Thread signature
sweetmantech Mar 6, 2026
b809db4
refactor: use Chat SDK Thread type in handleFeedback
sweetmantech Mar 6, 2026
5abd81c
fix: wait for Redis ready state in callback route
sweetmantech Mar 7, 2026
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
4 changes: 2 additions & 2 deletions app/api/coding-agent/[platform]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import "@/lib/coding-agent/handlers/registerHandlers";
* POST /api/coding-agent/[platform]
*
* Webhook endpoint for the coding agent bot.
* Handles Slack webhooks via dynamic [platform] segment.
* Currently handles Slack webhooks via dynamic [platform] segment.
*
* @param request - The incoming webhook request
* @param params.params
* @param params - Route params containing the platform name (slack or github)
* @param params - Route params containing the platform name
*/
export async function POST(
request: NextRequest,
Expand Down
1 change: 0 additions & 1 deletion app/api/coding-agent/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ vi.mock("@/lib/coding-agent/bot", () => ({
codingAgentBot: {
webhooks: {
slack: vi.fn().mockResolvedValue(new Response("ok", { status: 200 })),
github: vi.fn().mockResolvedValue(new Response("ok", { status: 200 })),
},
},
}));
Expand Down
12 changes: 12 additions & 0 deletions app/api/coding-agent/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { NextRequest } from "next/server";
import redis from "@/lib/redis/connection";
import "@/lib/coding-agent/bot";
import { handleCodingAgentCallback } from "@/lib/coding-agent/handleCodingAgentCallback";

/**
Expand All @@ -10,5 +12,15 @@ import { handleCodingAgentCallback } from "@/lib/coding-agent/handleCodingAgentC
* @param request - The incoming callback request
*/
export async function POST(request: NextRequest) {
if (redis.status !== "ready") {
if (redis.status === "wait") {
await redis.connect();
} else {
await new Promise<void>((resolve, reject) => {
redis.once("ready", resolve);
redis.once("error", reject);
});
}
}
return handleCodingAgentCallback(request);
}
1 change: 1 addition & 0 deletions lib/coding-agent/__tests__/bot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe("createCodingAgentBot", () => {
vi.clearAllMocks();
process.env.SLACK_BOT_TOKEN = "xoxb-test";
process.env.SLACK_SIGNING_SECRET = "test-signing-secret";
process.env.GITHUB_TOKEN = "ghp_test";
process.env.REDIS_URL = "redis://localhost:6379";
process.env.CODING_AGENT_CALLBACK_SECRET = "test-callback-secret";
});
Expand Down
37 changes: 34 additions & 3 deletions lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,27 @@ vi.mock("@/lib/networking/getCorsHeaders", () => ({

const mockPost = vi.fn();
const mockSetState = vi.fn();
let mockState: unknown = null;

vi.mock("chat", () => {
const ThreadImpl = vi.fn().mockImplementation(() => ({
post: mockPost,
setState: mockSetState,
get state() {
return Promise.resolve(mockState);
},
}));
return {
ThreadImpl,
deriveChannelId: vi.fn((_, threadId: string) => {
const parts = threadId.split(":");
return `${parts[0]}:${parts[1]}`;
}),
Card: vi.fn((opts) => ({ type: "card", ...opts })),
CardText: vi.fn((text) => ({ type: "text", text })),
Actions: vi.fn((children) => ({ type: "actions", children })),
Button: vi.fn((opts) => ({ type: "button", ...opts })),
LinkButton: vi.fn((opts) => ({ type: "link-button", ...opts })),
};
});

Expand All @@ -29,6 +38,7 @@ const { handleCodingAgentCallback } = await import("../handleCodingAgentCallback

beforeEach(() => {
vi.clearAllMocks();
mockState = null;
process.env.CODING_AGENT_CALLBACK_SECRET = "test-secret";
});

Expand Down Expand Up @@ -89,11 +99,11 @@ describe("handleCodingAgentCallback", () => {
const response = await handleCodingAgentCallback(request);

expect(response.status).toBe(200);
expect(mockPost).toHaveBeenCalled();
expect(mockPost).toHaveBeenCalledWith(expect.objectContaining({ card: expect.anything() }));
expect(mockSetState).toHaveBeenCalledWith(expect.objectContaining({ status: "pr_created" }));
});

it("posts no-changes message for no_changes status", async () => {
it("posts no-changes message and resets state for no_changes status", async () => {
const body = {
threadId: "slack:C123:1234567890.123456",
status: "no_changes",
Expand All @@ -104,10 +114,11 @@ describe("handleCodingAgentCallback", () => {
const response = await handleCodingAgentCallback(request);

expect(response.status).toBe(200);
expect(mockSetState).toHaveBeenCalledWith(expect.objectContaining({ status: "no_changes" }));
expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("No changes"));
});

it("posts error message for failed status", async () => {
it("posts error message and resets state for failed status", async () => {
const body = {
threadId: "slack:C123:1234567890.123456",
status: "failed",
Expand All @@ -118,7 +129,27 @@ describe("handleCodingAgentCallback", () => {
const response = await handleCodingAgentCallback(request);

expect(response.status).toBe(200);
expect(mockSetState).toHaveBeenCalledWith(expect.objectContaining({ status: "failed" }));
expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("Sandbox timed out"));
});

it("posts updated card with PR buttons for updated status", async () => {
mockState = {
status: "updating",
prs: [{ repo: "recoupable/api", number: 42, url: "https://github.com/recoupable/api/pull/42", baseBranch: "test" }],
};

const body = {
threadId: "slack:C123:1234567890.123456",
status: "updated",
snapshotId: "snap_new",
};
const request = makeRequest(body);

const response = await handleCodingAgentCallback(request);

expect(response.status).toBe(200);
expect(mockSetState).toHaveBeenCalledWith(expect.objectContaining({ status: "pr_created", snapshotId: "snap_new" }));
expect(mockPost).toHaveBeenCalledWith(expect.objectContaining({ card: expect.anything() }));
});
});
18 changes: 16 additions & 2 deletions lib/coding-agent/__tests__/handlePRCreated.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ vi.mock("../getThread", () => ({
getThread: vi.fn(() => mockThread),
}));

vi.mock("chat", () => ({
Card: vi.fn((opts) => ({ type: "card", ...opts })),
CardText: vi.fn((text) => ({ type: "text", text })),
Actions: vi.fn((children) => ({ type: "actions", children })),
Button: vi.fn((opts) => ({ type: "button", ...opts })),
LinkButton: vi.fn((opts) => ({ type: "link-button", ...opts })),
}));

describe("handlePRCreated", () => {
it("posts PR links and updates thread state", async () => {
it("posts a card with PR links and merge button", async () => {
const { handlePRCreated } = await import("../handlePRCreated");

await handlePRCreated("slack:C123:ts", {
Expand All @@ -21,7 +29,13 @@ describe("handlePRCreated", () => {
prs: [{ repo: "recoupable/api", number: 42, url: "https://github.com/recoupable/api/pull/42", baseBranch: "test" }],
});

expect(mockThread.post).toHaveBeenCalledWith(expect.stringContaining("recoupable/api#42"));
expect(mockThread.post).toHaveBeenCalledWith(expect.objectContaining({ card: expect.anything() }));

const { Button } = await import("chat");
expect(Button).toHaveBeenCalledWith(
expect.objectContaining({ id: "merge_all_prs", label: "Merge All PRs" }),
);

expect(mockThread.setState).toHaveBeenCalledWith(
expect.objectContaining({
status: "pr_created",
Expand Down
81 changes: 78 additions & 3 deletions lib/coding-agent/__tests__/handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,21 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const mockTriggerCodingAgent = vi.fn().mockResolvedValue({ id: "run_123" });
const mockTriggerUpdatePR = vi.fn().mockResolvedValue({ id: "run_456" });

vi.mock("@/lib/trigger/triggerCodingAgent", () => ({
triggerCodingAgent: vi.fn().mockResolvedValue({ id: "run_123" }),
triggerCodingAgent: mockTriggerCodingAgent,
}));

vi.mock("@/lib/trigger/triggerUpdatePR", () => ({
triggerUpdatePR: mockTriggerUpdatePR,
}));

vi.mock("chat", () => ({
Card: vi.fn((opts) => ({ type: "card", ...opts })),
CardText: vi.fn((text) => ({ type: "text", text })),
Actions: vi.fn((children) => ({ type: "actions", children })),
LinkButton: vi.fn((opts) => ({ type: "link-button", ...opts })),
}));

const { registerOnNewMention } = await import("../handlers/onNewMention");
Expand All @@ -26,13 +40,14 @@ describe("registerOnNewMention", () => {
expect(bot.onNewMention).toHaveBeenCalledOnce();
});

it("posts acknowledgment and triggers coding agent task", async () => {
it("posts acknowledgment and triggers coding agent task when no existing state", async () => {
const bot = createMockBot();
registerOnNewMention(bot);
const handler = bot.onNewMention.mock.calls[0][0];

const mockThread = {
id: "slack:C123:1234567890.123456",
state: Promise.resolve(null),
subscribe: vi.fn(),
post: vi.fn(),
setState: vi.fn(),
Expand All @@ -45,12 +60,72 @@ describe("registerOnNewMention", () => {
await handler(mockThread, mockMessage);

expect(mockThread.subscribe).toHaveBeenCalledOnce();
expect(mockThread.post).toHaveBeenCalledWith(expect.stringContaining("Starting work"));
expect(mockTriggerCodingAgent).toHaveBeenCalled();
expect(mockThread.post).toHaveBeenCalledWith(expect.objectContaining({ card: expect.anything() }));
expect(mockThread.setState).toHaveBeenCalledWith(
expect.objectContaining({
status: "running",
prompt: "fix the login bug in the api",
}),
);
});

it("triggers update-pr instead of coding-agent when thread has pr_created state", async () => {
const bot = createMockBot();
registerOnNewMention(bot);
const handler = bot.onNewMention.mock.calls[0][0];

const mockThread = {
id: "github:recoupable/tasks:56",
state: Promise.resolve({
status: "pr_created",
prompt: "original prompt",
snapshotId: "snap_abc",
branch: "agent/fix-bug",
prs: [{ repo: "recoupable/tasks", number: 56, url: "https://github.com/recoupable/tasks/pull/56", baseBranch: "main" }],
}),
subscribe: vi.fn(),
post: vi.fn(),
setState: vi.fn(),
};
const mockMessage = {
text: "remove the Project Structure changes",
author: { id: "sweetmantech" },
};

await handler(mockThread, mockMessage);

expect(mockTriggerCodingAgent).not.toHaveBeenCalled();
expect(mockTriggerUpdatePR).toHaveBeenCalledWith(
expect.objectContaining({
feedback: "remove the Project Structure changes",
snapshotId: "snap_abc",
branch: "agent/fix-bug",
repo: "recoupable/tasks",
}),
);
expect(mockThread.post).toHaveBeenCalledWith(expect.objectContaining({ card: expect.anything() }));
expect(mockThread.setState).toHaveBeenCalledWith(expect.objectContaining({ status: "updating" }));
});

it("tells user to wait when thread is already running", async () => {
const bot = createMockBot();
registerOnNewMention(bot);
const handler = bot.onNewMention.mock.calls[0][0];

const mockThread = {
id: "github:recoupable/tasks:56",
state: Promise.resolve({ status: "running", prompt: "original" }),
subscribe: vi.fn(),
post: vi.fn(),
setState: vi.fn(),
};
const mockMessage = { text: "any update?", author: { id: "sweetmantech" } };

await handler(mockThread, mockMessage);

expect(mockTriggerCodingAgent).not.toHaveBeenCalled();
expect(mockTriggerUpdatePR).not.toHaveBeenCalled();
expect(mockThread.post).toHaveBeenCalledWith(expect.stringContaining("still working"));
});
});
68 changes: 68 additions & 0 deletions lib/coding-agent/__tests__/onMergeAction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

global.fetch = vi.fn();

const { registerOnMergeAction } = await import("../handlers/onMergeAction");

beforeEach(() => {
vi.clearAllMocks();
process.env.GITHUB_TOKEN = "ghp_test";
});

function createMockBot() {
return {
onAction: vi.fn(),
} as any;
}

describe("registerOnMergeAction", () => {
it("registers merge_all_prs action handler", () => {
const bot = createMockBot();
registerOnMergeAction(bot);
expect(bot.onAction).toHaveBeenCalledWith("merge_all_prs", expect.any(Function));
});

it("squash-merges PRs and posts results", async () => {
vi.mocked(fetch).mockResolvedValue({ ok: true } as Response);

const bot = createMockBot();
registerOnMergeAction(bot);
const handler = bot.onAction.mock.calls[0][1];

const mockThread = {
state: Promise.resolve({
status: "pr_created",
prompt: "fix bug",
prs: [{ repo: "recoupable/api", number: 42, url: "url", baseBranch: "test" }],
}),
post: vi.fn(),
setState: vi.fn(),
};

await handler({ thread: mockThread });

expect(fetch).toHaveBeenCalledWith(
"https://api.github.com/repos/recoupable/api/pulls/42/merge",
expect.objectContaining({ method: "PUT" }),
);
expect(mockThread.setState).toHaveBeenCalledWith({ status: "merged" });
expect(mockThread.post).toHaveBeenCalledWith(expect.stringContaining("merged"));
});

it("posts no PRs message when state has no PRs", async () => {
const bot = createMockBot();
registerOnMergeAction(bot);
const handler = bot.onAction.mock.calls[0][1];

const mockThread = {
state: Promise.resolve({ status: "pr_created", prompt: "fix bug" }),
post: vi.fn(),
setState: vi.fn(),
};

await handler({ thread: mockThread });

expect(mockThread.post).toHaveBeenCalledWith("No PRs to merge.");
expect(fetch).not.toHaveBeenCalled();
});
});
2 changes: 1 addition & 1 deletion lib/coding-agent/__tests__/onSubscribedMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe("registerOnSubscribedMessage", () => {

await handler(mockThread, { text: "make the button blue", author: { userId: "U111" } });

expect(mockThread.post).toHaveBeenCalledWith(expect.stringContaining("feedback"));
expect(mockThread.post).toHaveBeenCalledWith(expect.objectContaining({ card: expect.anything() }));
expect(mockThread.setState).toHaveBeenCalledWith(expect.objectContaining({ status: "updating" }));
expect(triggerUpdatePR).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down
11 changes: 10 additions & 1 deletion lib/coding-agent/__tests__/validateCodingAgentCallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,16 @@ describe("validateCodingAgentCallback", () => {
expect(result).not.toBeInstanceOf(NextResponse);
});

});
it("accepts updated status with new snapshotId", () => {
const body = {
threadId: "slack:C123:1234567890.123456",
status: "updated",
snapshotId: "snap_new456",
};
const result = validateCodingAgentCallback(body);
expect(result).not.toBeInstanceOf(NextResponse);
});
});

describe("invalid payloads", () => {
it("rejects missing threadId", () => {
Expand Down
1 change: 1 addition & 0 deletions lib/coding-agent/__tests__/validateEnv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const REQUIRED_VARS = [
"SLACK_BOT_TOKEN",
"SLACK_SIGNING_SECRET",
"GITHUB_TOKEN",
"REDIS_URL",
"CODING_AGENT_CALLBACK_SECRET",
];
Expand Down
Loading