Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4ee7ae7
feat: include email attachments in agent.generate and tool calls
sweetmantech Feb 28, 2026
a957496
fix: resolve TypeScript type error in generateEmailResponse
sweetmantech Feb 28, 2026
e091fba
refactor: remove image parts, rely on download URLs for all file types
sweetmantech Feb 28, 2026
92a537a
feat: add Slack-driven coding agent with Chat SDK
sweetmantech Feb 28, 2026
a207b93
debug: add logging to respondToInboundEmail flow
sweetmantech Feb 28, 2026
a2a8180
fix: resolve build errors in coding agent Slack adapter
sweetmantech Mar 6, 2026
c810896
fix: connect Redis before passing to Chat SDK state adapter
sweetmantech Mar 6, 2026
488afd3
fix: handle Slack url_verification before bot initialization
sweetmantech Mar 6, 2026
5ad50e2
test: add route tests for coding-agent webhook endpoint
sweetmantech Mar 6, 2026
9e795eb
chore: redeploy with updated env vars
sweetmantech Mar 6, 2026
fc7c5af
debug: add logging to onNewMention handler
sweetmantech Mar 6, 2026
c94de57
feat: validate required env vars for coding agent bot
sweetmantech Mar 6, 2026
f599676
chore: redeploy with all required env vars
sweetmantech Mar 6, 2026
586d884
refactor: remove GitHub adapter code, scope PR to Slack-only
sweetmantech Mar 6, 2026
e929fb5
chore: remove debug logging from coding agent and email handlers
sweetmantech Mar 6, 2026
16fa074
refactor: use static imports instead of lazy-load in webhook route
sweetmantech Mar 6, 2026
757dafc
refactor: remove channel/user allowlist filtering
sweetmantech Mar 6, 2026
9e7c898
refactor: extract getThread and handlePRCreated into separate files (…
sweetmantech Mar 6, 2026
56ad22a
fix: restore pr_created callback handling, remove updated status
sweetmantech Mar 6, 2026
4c12355
fix: add JSON parse error handling and fail-fast Redis connect
sweetmantech Mar 6, 2026
1406a93
feat: add onSubscribedMessage handler for feedback loop
sweetmantech Mar 6, 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
38 changes: 38 additions & 0 deletions app/api/coding-agent/[platform]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { NextRequest } from "next/server";
import { after } from "next/server";
import { codingAgentBot } from "@/lib/coding-agent/bot";
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.
*
* @param request - The incoming webhook request
* @param params.params
* @param params - Route params containing the platform name (slack or github)
*/
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ platform: string }> },
) {
const { platform } = await params;

// Handle Slack url_verification challenge before loading the bot.
// This avoids blocking on Redis/adapter initialization during setup.
if (platform === "slack") {
const body = await request.clone().json().catch(() => null);
if (body?.type === "url_verification" && body?.challenge) {
return Response.json({ challenge: body.challenge });
}
}

const handler = codingAgentBot.webhooks[platform as keyof typeof codingAgentBot.webhooks];

if (!handler) {
return new Response("Unknown platform", { status: 404 });
}

return handler(request, { waitUntil: p => after(() => p) });
}
86 changes: 86 additions & 0 deletions app/api/coding-agent/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";

vi.mock("next/server", async () => {
const actual = await vi.importActual("next/server");
return {
...actual,
after: vi.fn((fn: () => void) => fn()),
};
});

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 })),
},
},
}));

vi.mock("@/lib/coding-agent/handlers/registerHandlers", () => ({}));

const { POST } = await import("../[platform]/route");

describe("POST /api/coding-agent/[platform]", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("responds to Slack url_verification challenge", async () => {
const body = JSON.stringify({
type: "url_verification",
challenge: "test_challenge_value",
});

const request = new NextRequest("https://example.com/api/coding-agent/slack", {
method: "POST",
body,
headers: { "content-type": "application/json" },
});

const response = await POST(request, {
params: Promise.resolve({ platform: "slack" }),
});

expect(response.status).toBe(200);
const json = await response.json();
expect(json.challenge).toBe("test_challenge_value");
});

it("returns 404 for unknown platforms", async () => {
const request = new NextRequest("https://example.com/api/coding-agent/unknown", {
method: "POST",
body: JSON.stringify({}),
headers: { "content-type": "application/json" },
});

const response = await POST(request, {
params: Promise.resolve({ platform: "unknown" }),
});

expect(response.status).toBe(404);
});

it("delegates non-challenge Slack requests to bot webhook handler", async () => {
const { codingAgentBot } = await import("@/lib/coding-agent/bot");

const body = JSON.stringify({
type: "event_callback",
event: { type: "app_mention", text: "hello" },
});

const request = new NextRequest("https://example.com/api/coding-agent/slack", {
method: "POST",
body,
headers: { "content-type": "application/json" },
});

const response = await POST(request, {
params: Promise.resolve({ platform: "slack" }),
});

expect(response.status).toBe(200);
expect(codingAgentBot.webhooks.slack).toHaveBeenCalled();
});
});
14 changes: 14 additions & 0 deletions app/api/coding-agent/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { NextRequest } from "next/server";
import { handleCodingAgentCallback } from "@/lib/coding-agent/handleCodingAgentCallback";

/**
* POST /api/coding-agent/callback
*
* Callback endpoint for the coding agent Trigger.dev task.
* Receives task results and posts them back to the Slack thread.
*
* @param request - The incoming callback request
*/
export async function POST(request: NextRequest) {
return handleCodingAgentCallback(request);
}
95 changes: 95 additions & 0 deletions lib/coding-agent/__tests__/bot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@chat-adapter/slack", () => ({
SlackAdapter: vi.fn().mockImplementation(() => ({
name: "slack",
})),
}));

vi.mock("@chat-adapter/state-ioredis", () => ({
createIoRedisState: vi.fn().mockReturnValue({
connect: vi.fn(),
disconnect: vi.fn(),
}),
}));

vi.mock("@/lib/redis/connection", () => ({
default: {},
}));

vi.mock("chat", () => ({
Chat: vi.fn().mockImplementation((config: Record<string, unknown>) => {
const instance = {
...config,
webhooks: {},
onNewMention: vi.fn(),
onSubscribedMessage: vi.fn(),
onAction: vi.fn(),
registerSingleton: vi.fn().mockReturnThis(),
};
instance.registerSingleton = vi.fn().mockReturnValue(instance);
return instance;
}),
ConsoleLogger: vi.fn(),
}));

describe("createCodingAgentBot", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.SLACK_BOT_TOKEN = "xoxb-test";
process.env.SLACK_SIGNING_SECRET = "test-signing-secret";
process.env.REDIS_URL = "redis://localhost:6379";
process.env.CODING_AGENT_CALLBACK_SECRET = "test-callback-secret";
});

it("creates a Chat instance with slack adapter", async () => {
const { Chat } = await import("chat");
const { createCodingAgentBot } = await import("../bot");

createCodingAgentBot();

expect(Chat).toHaveBeenCalled();
const lastCall = vi.mocked(Chat).mock.calls.at(-1)!;
const config = lastCall[0];
expect(config.adapters).toHaveProperty("slack");
});

it("creates a SlackAdapter with correct config", async () => {
const { SlackAdapter } = await import("@chat-adapter/slack");
const { createCodingAgentBot } = await import("../bot");

createCodingAgentBot();

expect(SlackAdapter).toHaveBeenCalledWith(
expect.objectContaining({
botToken: "xoxb-test",
signingSecret: "test-signing-secret",
}),
);
});

it("uses ioredis state adapter with existing Redis client", async () => {
const { createIoRedisState } = await import("@chat-adapter/state-ioredis");
const redis = (await import("@/lib/redis/connection")).default;
const { createCodingAgentBot } = await import("../bot");

createCodingAgentBot();

expect(createIoRedisState).toHaveBeenCalledWith(
expect.objectContaining({
client: redis,
keyPrefix: "coding-agent",
}),
);
});

it("sets userName to Recoup Agent", async () => {
const { Chat } = await import("chat");
const { createCodingAgentBot } = await import("../bot");

createCodingAgentBot();

const lastCall = vi.mocked(Chat).mock.calls.at(-1)!;
expect(lastCall[0].userName).toBe("Recoup Agent");
});
});
20 changes: 20 additions & 0 deletions lib/coding-agent/__tests__/getThread.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, it, expect, vi } from "vitest";

vi.mock("chat", () => ({
ThreadImpl: vi.fn().mockImplementation((config: Record<string, unknown>) => config),
}));

describe("getThread", () => {
it("parses adapter name and channel ID from thread ID", async () => {
const { getThread } = await import("../getThread");
const { ThreadImpl } = await import("chat");

getThread("slack:C123:1234567890.123456");

expect(ThreadImpl).toHaveBeenCalledWith({
adapterName: "slack",
id: "slack:C123:1234567890.123456",
channelId: "slack:C123",
});
});
});
124 changes: 124 additions & 0 deletions lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

const mockPost = vi.fn();
const mockSetState = vi.fn();

vi.mock("chat", () => {
const ThreadImpl = vi.fn().mockImplementation(() => ({
post: mockPost,
setState: mockSetState,
}));
return {
ThreadImpl,
deriveChannelId: vi.fn((_, threadId: string) => {
const parts = threadId.split(":");
return `${parts[0]}:${parts[1]}`;
}),
};
});

vi.mock("../bot", () => ({
codingAgentBot: {},
}));

const { handleCodingAgentCallback } = await import("../handleCodingAgentCallback");

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

describe("handleCodingAgentCallback", () => {
/**
*
* @param body
* @param secret
*/
function makeRequest(body: unknown, secret = "test-secret") {
return {
json: () => Promise.resolve(body),
headers: new Headers({
"x-callback-secret": secret,
}),
} as unknown as Request;
}

it("returns 401 when secret header is missing", async () => {
const request = {
json: () => Promise.resolve({}),
headers: new Headers(),
} as unknown as Request;

const response = await handleCodingAgentCallback(request);
expect(response.status).toBe(401);
});

it("returns 401 when secret header is wrong", async () => {
const request = makeRequest({}, "wrong-secret");
const response = await handleCodingAgentCallback(request);
expect(response.status).toBe(401);
});

it("returns 400 for invalid body", async () => {
const request = makeRequest({ invalid: true });
const response = await handleCodingAgentCallback(request);
expect(response.status).toBe(400);
});

it("posts PR links for pr_created status", async () => {
const body = {
threadId: "slack:C123:1234567890.123456",
status: "pr_created",
branch: "agent/fix-bug-1234",
snapshotId: "snap_abc",
prs: [
{
repo: "recoupable/recoup-api",
number: 42,
url: "https://github.com/recoupable/recoup-api/pull/42",
baseBranch: "test",
},
],
};
const request = makeRequest(body);

const response = await handleCodingAgentCallback(request);

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

it("posts no-changes message for no_changes status", async () => {
const body = {
threadId: "slack:C123:1234567890.123456",
status: "no_changes",
message: "No files modified",
};
const request = makeRequest(body);

const response = await handleCodingAgentCallback(request);

expect(response.status).toBe(200);
expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("No changes"));
});

it("posts error message for failed status", async () => {
const body = {
threadId: "slack:C123:1234567890.123456",
status: "failed",
message: "Sandbox timed out",
};
const request = makeRequest(body);

const response = await handleCodingAgentCallback(request);

expect(response.status).toBe(200);
expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("Sandbox timed out"));
});

});
Loading