From 0d8300f07dfabb006e07277b99414a274ce81fa1 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 15:59:01 -0500 Subject: [PATCH 01/22] feat: add GitHub adapter for coding agent bot - Add GitHubAdapter to bot with token, webhook secret, and username config - Add onMergeAction handler to squash-merge PRs via GitHub API - Add updated callback status for PR feedback flow - Add merged status to thread state - Add GITHUB_TOKEN, GITHUB_WEBHOOK_SECRET, GITHUB_BOT_USERNAME env vars - 35 tests across 10 test files MYC-4431 Co-Authored-By: Claude Opus 4.6 --- app/api/coding-agent/[platform]/route.ts | 2 +- lib/coding-agent/__tests__/bot.test.ts | 27 +++++++- .../handleCodingAgentCallback.test.ts | 14 ++++ .../__tests__/onMergeAction.test.ts | 68 +++++++++++++++++++ .../validateCodingAgentCallback.test.ts | 11 ++- .../__tests__/validateEnv.test.ts | 3 + lib/coding-agent/bot.ts | 14 +++- lib/coding-agent/handleCodingAgentCallback.ts | 5 ++ lib/coding-agent/handlers/onMergeAction.ts | 54 +++++++++++++++ lib/coding-agent/handlers/registerHandlers.ts | 2 + lib/coding-agent/types.ts | 2 +- .../validateCodingAgentCallback.ts | 2 +- lib/coding-agent/validateEnv.ts | 3 + 13 files changed, 199 insertions(+), 8 deletions(-) create mode 100644 lib/coding-agent/__tests__/onMergeAction.test.ts create mode 100644 lib/coding-agent/handlers/onMergeAction.ts diff --git a/app/api/coding-agent/[platform]/route.ts b/app/api/coding-agent/[platform]/route.ts index 5e95bff8..813ad36a 100644 --- a/app/api/coding-agent/[platform]/route.ts +++ b/app/api/coding-agent/[platform]/route.ts @@ -7,7 +7,7 @@ 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. + * Handles Slack and GitHub webhooks via dynamic [platform] segment. * * @param request - The incoming webhook request * @param params.params diff --git a/lib/coding-agent/__tests__/bot.test.ts b/lib/coding-agent/__tests__/bot.test.ts index 3d320f1b..fcba1ada 100644 --- a/lib/coding-agent/__tests__/bot.test.ts +++ b/lib/coding-agent/__tests__/bot.test.ts @@ -6,6 +6,12 @@ vi.mock("@chat-adapter/slack", () => ({ })), })); +vi.mock("@chat-adapter/github", () => ({ + GitHubAdapter: vi.fn().mockImplementation(() => ({ + name: "github", + })), +})); + vi.mock("@chat-adapter/state-ioredis", () => ({ createIoRedisState: vi.fn().mockReturnValue({ connect: vi.fn(), @@ -38,11 +44,14 @@ 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.GITHUB_WEBHOOK_SECRET = "test-webhook-secret"; + process.env.GITHUB_BOT_USERNAME = "recoup-bot"; 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 () => { + it("creates a Chat instance with slack and github adapters", async () => { const { Chat } = await import("chat"); const { createCodingAgentBot } = await import("../bot"); @@ -52,6 +61,22 @@ describe("createCodingAgentBot", () => { const lastCall = vi.mocked(Chat).mock.calls.at(-1)!; const config = lastCall[0]; expect(config.adapters).toHaveProperty("slack"); + expect(config.adapters).toHaveProperty("github"); + }); + + it("creates a GitHubAdapter with correct config", async () => { + const { GitHubAdapter } = await import("@chat-adapter/github"); + const { createCodingAgentBot } = await import("../bot"); + + createCodingAgentBot(); + + expect(GitHubAdapter).toHaveBeenCalledWith( + expect.objectContaining({ + token: "ghp_test", + webhookSecret: "test-webhook-secret", + userName: "recoup-bot", + }), + ); }); it("creates a SlackAdapter with correct config", async () => { diff --git a/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts b/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts index b0173c9e..820e6998 100644 --- a/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts +++ b/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts @@ -121,4 +121,18 @@ describe("handleCodingAgentCallback", () => { expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("Sandbox timed out")); }); + it("posts updated confirmation for updated status", async () => { + 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({ snapshotId: "snap_new" })); + expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("updated")); + }); }); diff --git a/lib/coding-agent/__tests__/onMergeAction.test.ts b/lib/coding-agent/__tests__/onMergeAction.test.ts new file mode 100644 index 00000000..2b249cf5 --- /dev/null +++ b/lib/coding-agent/__tests__/onMergeAction.test.ts @@ -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(); + }); +}); diff --git a/lib/coding-agent/__tests__/validateCodingAgentCallback.test.ts b/lib/coding-agent/__tests__/validateCodingAgentCallback.test.ts index eccff62e..525aa4ff 100644 --- a/lib/coding-agent/__tests__/validateCodingAgentCallback.test.ts +++ b/lib/coding-agent/__tests__/validateCodingAgentCallback.test.ts @@ -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", () => { diff --git a/lib/coding-agent/__tests__/validateEnv.test.ts b/lib/coding-agent/__tests__/validateEnv.test.ts index 27391354..9b21a828 100644 --- a/lib/coding-agent/__tests__/validateEnv.test.ts +++ b/lib/coding-agent/__tests__/validateEnv.test.ts @@ -3,6 +3,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; const REQUIRED_VARS = [ "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", + "GITHUB_TOKEN", + "GITHUB_WEBHOOK_SECRET", + "GITHUB_BOT_USERNAME", "REDIS_URL", "CODING_AGENT_CALLBACK_SECRET", ]; diff --git a/lib/coding-agent/bot.ts b/lib/coding-agent/bot.ts index 7ed49712..425bcdba 100644 --- a/lib/coding-agent/bot.ts +++ b/lib/coding-agent/bot.ts @@ -1,5 +1,6 @@ import { Chat, ConsoleLogger } from "chat"; import { SlackAdapter } from "@chat-adapter/slack"; +import { GitHubAdapter } from "@chat-adapter/github"; import { createIoRedisState } from "@chat-adapter/state-ioredis"; import redis from "@/lib/redis/connection"; import type { CodingAgentThreadState } from "./types"; @@ -8,7 +9,7 @@ import { validateCodingAgentEnv } from "./validateEnv"; const logger = new ConsoleLogger(); /** - * Creates a new Chat bot instance configured with the Slack adapter. + * Creates a new Chat bot instance configured with Slack and GitHub adapters. */ export function createCodingAgentBot() { validateCodingAgentEnv(); @@ -32,9 +33,16 @@ export function createCodingAgentBot() { logger, }); - return new Chat<{ slack: SlackAdapter }, CodingAgentThreadState>({ + const github = new GitHubAdapter({ + token: process.env.GITHUB_TOKEN!, + webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!, + userName: process.env.GITHUB_BOT_USERNAME!, + logger, + }); + + return new Chat<{ slack: SlackAdapter; github: GitHubAdapter }, CodingAgentThreadState>({ userName: "Recoup Agent", - adapters: { slack }, + adapters: { slack, github }, state, }); } diff --git a/lib/coding-agent/handleCodingAgentCallback.ts b/lib/coding-agent/handleCodingAgentCallback.ts index 3796780d..992369a9 100644 --- a/lib/coding-agent/handleCodingAgentCallback.ts +++ b/lib/coding-agent/handleCodingAgentCallback.ts @@ -52,6 +52,11 @@ export async function handleCodingAgentCallback(request: Request): Promise { + const thread = event.thread; + const state = (await thread.state) as CodingAgentThreadState | null; + + if (!state?.prs?.length) { + await thread.post("No PRs to merge."); + return; + } + + const token = process.env.GITHUB_TOKEN; + if (!token) { + await thread.post("Missing GITHUB_TOKEN — cannot merge PRs."); + return; + } + + const results: string[] = []; + + for (const pr of state.prs) { + const [owner, repo] = pr.repo.split("/"); + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/pulls/${pr.number}/merge`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ merge_method: "squash" }), + }, + ); + + if (response.ok) { + results.push(`${pr.repo}#${pr.number} merged`); + } else { + const error = await response.json(); + results.push(`${pr.repo}#${pr.number} failed: ${error.message}`); + } + } + + await thread.setState({ status: "merged" }); + await thread.post(`Merge results:\n${results.map(r => `- ${r}`).join("\n")}`); + }); +} diff --git a/lib/coding-agent/handlers/registerHandlers.ts b/lib/coding-agent/handlers/registerHandlers.ts index 788e4e46..96f24748 100644 --- a/lib/coding-agent/handlers/registerHandlers.ts +++ b/lib/coding-agent/handlers/registerHandlers.ts @@ -1,6 +1,7 @@ import { codingAgentBot } from "../bot"; import { registerOnNewMention } from "./onNewMention"; import { registerOnSubscribedMessage } from "./onSubscribedMessage"; +import { registerOnMergeAction } from "./onMergeAction"; /** * Registers all coding agent event handlers on the bot singleton. @@ -8,3 +9,4 @@ import { registerOnSubscribedMessage } from "./onSubscribedMessage"; */ registerOnNewMention(codingAgentBot); registerOnSubscribedMessage(codingAgentBot); +registerOnMergeAction(codingAgentBot); diff --git a/lib/coding-agent/types.ts b/lib/coding-agent/types.ts index 28a583fe..45fe24e7 100644 --- a/lib/coding-agent/types.ts +++ b/lib/coding-agent/types.ts @@ -3,7 +3,7 @@ * Stored in Redis via Chat SDK's state adapter. */ export interface CodingAgentThreadState { - status: "running" | "pr_created" | "updating" | "failed"; + status: "running" | "pr_created" | "updating" | "merged" | "failed"; prompt: string; runId?: string; slackThreadId?: string; diff --git a/lib/coding-agent/validateCodingAgentCallback.ts b/lib/coding-agent/validateCodingAgentCallback.ts index eae9097e..57989f04 100644 --- a/lib/coding-agent/validateCodingAgentCallback.ts +++ b/lib/coding-agent/validateCodingAgentCallback.ts @@ -11,7 +11,7 @@ const codingAgentPRSchema = z.object({ export const codingAgentCallbackSchema = z.object({ threadId: z.string({ message: "threadId is required" }).min(1, "threadId cannot be empty"), - status: z.enum(["pr_created", "no_changes", "failed"]), + status: z.enum(["pr_created", "no_changes", "failed", "updated"]), branch: z.string().optional(), snapshotId: z.string().optional(), prs: z.array(codingAgentPRSchema).optional(), diff --git a/lib/coding-agent/validateEnv.ts b/lib/coding-agent/validateEnv.ts index 9e9a5af6..d5c67662 100644 --- a/lib/coding-agent/validateEnv.ts +++ b/lib/coding-agent/validateEnv.ts @@ -1,6 +1,9 @@ const REQUIRED_ENV_VARS = [ "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", + "GITHUB_TOKEN", + "GITHUB_WEBHOOK_SECRET", + "GITHUB_BOT_USERNAME", "REDIS_URL", "CODING_AGENT_CALLBACK_SECRET", ] as const; From adfcde463189aa72a084af65bc52bf7fdf425935 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 16:39:25 -0500 Subject: [PATCH 02/22] chore: trigger redeploy with updated env vars Co-Authored-By: Claude Opus 4.6 From 0b5d8395a236f6498ab4e45f2d9f601026e13107 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 16:53:59 -0500 Subject: [PATCH 03/22] fix: route mentions to update-pr when thread already has PRs On GitHub, follow-up comments with @mention are routed to onNewMention instead of onSubscribedMessage. Now onNewMention checks thread state: - If running/updating: tells user to wait - If pr_created with snapshot: triggers update-pr with feedback - Otherwise: starts new coding agent task Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/__tests__/handlers.test.ts | 72 ++++++++++++++++++++- lib/coding-agent/handlers/onNewMention.ts | 28 ++++++-- 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/lib/coding-agent/__tests__/handlers.test.ts b/lib/coding-agent/__tests__/handlers.test.ts index e750d149..29d9e287 100644 --- a/lib/coding-agent/__tests__/handlers.test.ts +++ b/lib/coding-agent/__tests__/handlers.test.ts @@ -1,7 +1,14 @@ 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, })); const { registerOnNewMention } = await import("../handlers/onNewMention"); @@ -26,13 +33,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(), @@ -46,6 +54,7 @@ describe("registerOnNewMention", () => { expect(mockThread.subscribe).toHaveBeenCalledOnce(); expect(mockThread.post).toHaveBeenCalledWith(expect.stringContaining("Starting work")); + expect(mockTriggerCodingAgent).toHaveBeenCalled(); expect(mockThread.setState).toHaveBeenCalledWith( expect.objectContaining({ status: "running", @@ -53,4 +62,63 @@ describe("registerOnNewMention", () => { }), ); }); + + 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.stringContaining("feedback")); + 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")); + }); }); diff --git a/lib/coding-agent/handlers/onNewMention.ts b/lib/coding-agent/handlers/onNewMention.ts index 20ecd15e..1c87e2a0 100644 --- a/lib/coding-agent/handlers/onNewMention.ts +++ b/lib/coding-agent/handlers/onNewMention.ts @@ -1,19 +1,39 @@ import type { CodingAgentBot } from "../bot"; import { triggerCodingAgent } from "@/lib/trigger/triggerCodingAgent"; +import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; /** * Registers the onNewMention handler on the bot. - * Subscribes to the thread and triggers the coding agent Trigger.dev task. + * If the thread already has PRs, treats the mention as feedback and + * triggers the update-pr task. Otherwise, starts a new coding agent task. * * @param bot */ export function registerOnNewMention(bot: CodingAgentBot) { bot.onNewMention(async (thread, message) => { - const prompt = message.text; - try { - await thread.subscribe(); + const state = await thread.state; + + if (state?.status === "running" || state?.status === "updating") { + await thread.post("I'm still working on this. I'll let you know when I'm done."); + return; + } + if (state?.status === "pr_created" && state.snapshotId && state.branch && state.prs?.length) { + await thread.post("Got your feedback. Updating the PRs..."); + await thread.setState({ status: "updating" }); + await triggerUpdatePR({ + feedback: message.text, + snapshotId: state.snapshotId, + branch: state.branch, + repo: state.prs[0].repo, + callbackThreadId: thread.id, + }); + return; + } + + const prompt = message.text; + await thread.subscribe(); await thread.post(`Starting work on: "${prompt}"\n\nI'll reply here when done.`); const handle = await triggerCodingAgent({ From f20c67bde529819395f2421102775348f71204aa Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 17:01:01 -0500 Subject: [PATCH 04/22] fix: reset thread state on no_changes and failed callbacks Without this, the thread stays in "running" state after a no_changes or failed callback, blocking all future mentions with "still working." Co-Authored-By: Claude Opus 4.6 --- .../__tests__/handleCodingAgentCallback.test.ts | 6 ++++-- lib/coding-agent/handleCodingAgentCallback.ts | 2 ++ lib/coding-agent/types.ts | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts b/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts index 820e6998..67ba3eba 100644 --- a/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts +++ b/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts @@ -93,7 +93,7 @@ describe("handleCodingAgentCallback", () => { 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", @@ -104,10 +104,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", @@ -118,6 +119,7 @@ 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")); }); diff --git a/lib/coding-agent/handleCodingAgentCallback.ts b/lib/coding-agent/handleCodingAgentCallback.ts index 992369a9..0a8ebd06 100644 --- a/lib/coding-agent/handleCodingAgentCallback.ts +++ b/lib/coding-agent/handleCodingAgentCallback.ts @@ -46,10 +46,12 @@ export async function handleCodingAgentCallback(request: Request): Promise Date: Fri, 6 Mar 2026 17:04:41 -0500 Subject: [PATCH 05/22] fix: import bot singleton in callback route The callback route uses ThreadImpl which requires the Chat singleton to be registered. Without importing the bot, setState/post calls fail with "No Chat singleton registered." Co-Authored-By: Claude Opus 4.6 --- app/api/coding-agent/callback/route.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/api/coding-agent/callback/route.ts b/app/api/coding-agent/callback/route.ts index 46349018..dca0c974 100644 --- a/app/api/coding-agent/callback/route.ts +++ b/app/api/coding-agent/callback/route.ts @@ -1,4 +1,5 @@ import type { NextRequest } from "next/server"; +import "@/lib/coding-agent/bot"; import { handleCodingAgentCallback } from "@/lib/coding-agent/handleCodingAgentCallback"; /** From 9e65b58fe02072306688b63f55a9fd2b6e255735 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 17:09:49 -0500 Subject: [PATCH 06/22] fix: ensure Redis is connected before handling callback On cold starts, the callback route may execute before the lazy Redis connection is established. Explicitly await redis.connect() if not ready. Co-Authored-By: Claude Opus 4.6 --- app/api/coding-agent/callback/route.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/api/coding-agent/callback/route.ts b/app/api/coding-agent/callback/route.ts index dca0c974..91d2fee2 100644 --- a/app/api/coding-agent/callback/route.ts +++ b/app/api/coding-agent/callback/route.ts @@ -1,4 +1,5 @@ import type { NextRequest } from "next/server"; +import redis from "@/lib/redis/connection"; import "@/lib/coding-agent/bot"; import { handleCodingAgentCallback } from "@/lib/coding-agent/handleCodingAgentCallback"; @@ -11,5 +12,8 @@ import { handleCodingAgentCallback } from "@/lib/coding-agent/handleCodingAgentC * @param request - The incoming callback request */ export async function POST(request: NextRequest) { + if (redis.status !== "ready") { + await redis.connect(); + } return handleCodingAgentCallback(request); } From 3622d17388584d64d2b707dddd3bd5cde31e8321 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 17:13:40 -0500 Subject: [PATCH 07/22] fix: handle Redis connecting state in callback route Check for "wait" (not yet started) and "connecting" (in progress) states separately to avoid "Redis is already connected" error. Co-Authored-By: Claude Opus 4.6 --- app/api/coding-agent/callback/route.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/api/coding-agent/callback/route.ts b/app/api/coding-agent/callback/route.ts index 91d2fee2..6e5e5eec 100644 --- a/app/api/coding-agent/callback/route.ts +++ b/app/api/coding-agent/callback/route.ts @@ -12,8 +12,10 @@ 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 if (redis.status === "connecting") { + await new Promise((resolve) => redis.once("ready", resolve)); } return handleCodingAgentCallback(request); } From fb8d5d7fdf990a6c5cba94c8b5e1f6e0b9926be3 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 17:55:44 -0500 Subject: [PATCH 08/22] feat: add Merge All PRs button to pr_created message Use Chat SDK Card with Actions to post a rich message with: - Link buttons to review each PR - "Merge All PRs" button that triggers the onMergeAction handler Co-Authored-By: Claude Opus 4.6 --- .../handleCodingAgentCallback.test.ts | 7 ++++++- .../__tests__/handlePRCreated.test.ts | 18 ++++++++++++++++-- lib/coding-agent/handlePRCreated.ts | 18 +++++++++++++++--- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts b/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts index 67ba3eba..97d2c96c 100644 --- a/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts +++ b/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts @@ -18,6 +18,11 @@ vi.mock("chat", () => { const parts = threadId.split(":"); return `${parts[0]}:${parts[1]}`; }), + Card: vi.fn((opts) => ({ type: "card", ...opts })), + Text: 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 })), }; }); @@ -89,7 +94,7 @@ 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" })); }); diff --git a/lib/coding-agent/__tests__/handlePRCreated.test.ts b/lib/coding-agent/__tests__/handlePRCreated.test.ts index 8208c8bb..7cbf33d1 100644 --- a/lib/coding-agent/__tests__/handlePRCreated.test.ts +++ b/lib/coding-agent/__tests__/handlePRCreated.test.ts @@ -9,8 +9,16 @@ vi.mock("../getThread", () => ({ getThread: vi.fn(() => mockThread), })); +vi.mock("chat", () => ({ + Card: vi.fn((opts) => ({ type: "card", ...opts })), + Text: 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", { @@ -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", diff --git a/lib/coding-agent/handlePRCreated.ts b/lib/coding-agent/handlePRCreated.ts index 7b62f096..787ef8fd 100644 --- a/lib/coding-agent/handlePRCreated.ts +++ b/lib/coding-agent/handlePRCreated.ts @@ -1,3 +1,4 @@ +import { Card, Text, Actions, Button, LinkButton } from "chat"; import { getThread } from "./getThread"; import type { CodingAgentCallbackBody } from "./validateCodingAgentCallback"; @@ -13,9 +14,20 @@ export async function handlePRCreated(threadId: string, body: CodingAgentCallbac .map(pr => `- [${pr.repo}#${pr.number}](${pr.url}) → \`${pr.baseBranch}\``) .join("\n"); - await thread.post( - `PRs created:\n${prLinks}\n\nReply in this thread to give feedback, or click Merge when ready.`, - ); + const card = Card({ + title: "PRs Created", + children: [ + Text(`${prLinks}\n\nReply in this thread to give feedback.`), + Actions([ + ...((body.prs ?? []).map(pr => + LinkButton({ url: pr.url, label: `Review ${pr.repo}#${pr.number}` }), + )), + Button({ id: "merge_all_prs", label: "Merge All PRs", style: "primary" }), + ]), + ], + }); + + await thread.post({ card }); await thread.setState({ status: "pr_created", From f7c99411745e36f0091129ca78df94a542db8163 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 17:57:35 -0500 Subject: [PATCH 09/22] feat: add View Task button to initial mention reply Posts a Card with a LinkButton to https://chat.recoupable.com/tasks/{runId} so users can monitor task progress directly from the Slack thread. Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/__tests__/handlers.test.ts | 9 ++++++++- lib/coding-agent/handlers/onNewMention.ts | 14 +++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/coding-agent/__tests__/handlers.test.ts b/lib/coding-agent/__tests__/handlers.test.ts index 29d9e287..0b51e8b8 100644 --- a/lib/coding-agent/__tests__/handlers.test.ts +++ b/lib/coding-agent/__tests__/handlers.test.ts @@ -11,6 +11,13 @@ vi.mock("@/lib/trigger/triggerUpdatePR", () => ({ triggerUpdatePR: mockTriggerUpdatePR, })); +vi.mock("chat", () => ({ + Card: vi.fn((opts) => ({ type: "card", ...opts })), + Text: 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"); beforeEach(() => { @@ -53,8 +60,8 @@ 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", diff --git a/lib/coding-agent/handlers/onNewMention.ts b/lib/coding-agent/handlers/onNewMention.ts index 1c87e2a0..9ad96f21 100644 --- a/lib/coding-agent/handlers/onNewMention.ts +++ b/lib/coding-agent/handlers/onNewMention.ts @@ -1,3 +1,4 @@ +import { Card, Text, Actions, LinkButton } from "chat"; import type { CodingAgentBot } from "../bot"; import { triggerCodingAgent } from "@/lib/trigger/triggerCodingAgent"; import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; @@ -34,13 +35,24 @@ export function registerOnNewMention(bot: CodingAgentBot) { const prompt = message.text; await thread.subscribe(); - await thread.post(`Starting work on: "${prompt}"\n\nI'll reply here when done.`); const handle = await triggerCodingAgent({ prompt, callbackThreadId: thread.id, }); + const card = Card({ + title: "Task Started", + children: [ + Text(`Starting work on: "${prompt}"\n\nI'll reply here when done.`), + Actions([ + LinkButton({ url: `https://chat.recoupable.com/tasks/${handle.id}`, label: "View Task" }), + ]), + ], + }); + + await thread.post({ card }); + await thread.setState({ status: "running", prompt, From ded955368ae434c29be57a11f6bf725ff1b2e0f0 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 17:59:44 -0500 Subject: [PATCH 10/22] fix: use CardText instead of Text (not exported from chat SDK) Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts | 2 +- lib/coding-agent/__tests__/handlePRCreated.test.ts | 2 +- lib/coding-agent/__tests__/handlers.test.ts | 2 +- lib/coding-agent/handlePRCreated.ts | 4 ++-- lib/coding-agent/handlers/onNewMention.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts b/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts index 97d2c96c..17750c67 100644 --- a/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts +++ b/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts @@ -19,7 +19,7 @@ vi.mock("chat", () => { return `${parts[0]}:${parts[1]}`; }), Card: vi.fn((opts) => ({ type: "card", ...opts })), - Text: vi.fn((text) => ({ type: "text", text })), + 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 })), diff --git a/lib/coding-agent/__tests__/handlePRCreated.test.ts b/lib/coding-agent/__tests__/handlePRCreated.test.ts index 7cbf33d1..6f80972a 100644 --- a/lib/coding-agent/__tests__/handlePRCreated.test.ts +++ b/lib/coding-agent/__tests__/handlePRCreated.test.ts @@ -11,7 +11,7 @@ vi.mock("../getThread", () => ({ vi.mock("chat", () => ({ Card: vi.fn((opts) => ({ type: "card", ...opts })), - Text: vi.fn((text) => ({ type: "text", text })), + 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 })), diff --git a/lib/coding-agent/__tests__/handlers.test.ts b/lib/coding-agent/__tests__/handlers.test.ts index 0b51e8b8..15d198ad 100644 --- a/lib/coding-agent/__tests__/handlers.test.ts +++ b/lib/coding-agent/__tests__/handlers.test.ts @@ -13,7 +13,7 @@ vi.mock("@/lib/trigger/triggerUpdatePR", () => ({ vi.mock("chat", () => ({ Card: vi.fn((opts) => ({ type: "card", ...opts })), - Text: vi.fn((text) => ({ type: "text", text })), + CardText: vi.fn((text) => ({ type: "text", text })), Actions: vi.fn((children) => ({ type: "actions", children })), LinkButton: vi.fn((opts) => ({ type: "link-button", ...opts })), })); diff --git a/lib/coding-agent/handlePRCreated.ts b/lib/coding-agent/handlePRCreated.ts index 787ef8fd..d132864c 100644 --- a/lib/coding-agent/handlePRCreated.ts +++ b/lib/coding-agent/handlePRCreated.ts @@ -1,4 +1,4 @@ -import { Card, Text, Actions, Button, LinkButton } from "chat"; +import { Card, CardText, Actions, Button, LinkButton } from "chat"; import { getThread } from "./getThread"; import type { CodingAgentCallbackBody } from "./validateCodingAgentCallback"; @@ -17,7 +17,7 @@ export async function handlePRCreated(threadId: string, body: CodingAgentCallbac const card = Card({ title: "PRs Created", children: [ - Text(`${prLinks}\n\nReply in this thread to give feedback.`), + CardText(`${prLinks}\n\nReply in this thread to give feedback.`), Actions([ ...((body.prs ?? []).map(pr => LinkButton({ url: pr.url, label: `Review ${pr.repo}#${pr.number}` }), diff --git a/lib/coding-agent/handlers/onNewMention.ts b/lib/coding-agent/handlers/onNewMention.ts index 9ad96f21..6073c7cb 100644 --- a/lib/coding-agent/handlers/onNewMention.ts +++ b/lib/coding-agent/handlers/onNewMention.ts @@ -1,4 +1,4 @@ -import { Card, Text, Actions, LinkButton } from "chat"; +import { Card, CardText, Actions, LinkButton } from "chat"; import type { CodingAgentBot } from "../bot"; import { triggerCodingAgent } from "@/lib/trigger/triggerCodingAgent"; import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; @@ -44,7 +44,7 @@ export function registerOnNewMention(bot: CodingAgentBot) { const card = Card({ title: "Task Started", children: [ - Text(`Starting work on: "${prompt}"\n\nI'll reply here when done.`), + CardText(`Starting work on: "${prompt}"\n\nI'll reply here when done.`), Actions([ LinkButton({ url: `https://chat.recoupable.com/tasks/${handle.id}`, label: "View Task" }), ]), From cf51eb305c376aaa6546c6c10d4155758b321ec5 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:15:37 -0500 Subject: [PATCH 11/22] refactor: remove GitHub adapter, keep Slack-only with merge button Strip GitHubAdapter and GitHub webhook env vars from coding agent bot. GITHUB_TOKEN retained for PR merge functionality via Slack button. Co-Authored-By: Claude Opus 4.6 --- app/api/coding-agent/[platform]/route.ts | 4 +-- app/api/coding-agent/__tests__/route.test.ts | 1 - lib/coding-agent/__tests__/bot.test.ts | 26 +------------------ .../__tests__/validateEnv.test.ts | 2 -- lib/coding-agent/bot.ts | 14 +++------- lib/coding-agent/validateEnv.ts | 2 -- 6 files changed, 6 insertions(+), 43 deletions(-) diff --git a/app/api/coding-agent/[platform]/route.ts b/app/api/coding-agent/[platform]/route.ts index 813ad36a..dd79629b 100644 --- a/app/api/coding-agent/[platform]/route.ts +++ b/app/api/coding-agent/[platform]/route.ts @@ -7,11 +7,11 @@ import "@/lib/coding-agent/handlers/registerHandlers"; * POST /api/coding-agent/[platform] * * Webhook endpoint for the coding agent bot. - * Handles Slack and GitHub 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, diff --git a/app/api/coding-agent/__tests__/route.test.ts b/app/api/coding-agent/__tests__/route.test.ts index d23eb468..bc34ced2 100644 --- a/app/api/coding-agent/__tests__/route.test.ts +++ b/app/api/coding-agent/__tests__/route.test.ts @@ -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 })), }, }, })); diff --git a/lib/coding-agent/__tests__/bot.test.ts b/lib/coding-agent/__tests__/bot.test.ts index fcba1ada..ede2020d 100644 --- a/lib/coding-agent/__tests__/bot.test.ts +++ b/lib/coding-agent/__tests__/bot.test.ts @@ -6,12 +6,6 @@ vi.mock("@chat-adapter/slack", () => ({ })), })); -vi.mock("@chat-adapter/github", () => ({ - GitHubAdapter: vi.fn().mockImplementation(() => ({ - name: "github", - })), -})); - vi.mock("@chat-adapter/state-ioredis", () => ({ createIoRedisState: vi.fn().mockReturnValue({ connect: vi.fn(), @@ -45,13 +39,11 @@ describe("createCodingAgentBot", () => { process.env.SLACK_BOT_TOKEN = "xoxb-test"; process.env.SLACK_SIGNING_SECRET = "test-signing-secret"; process.env.GITHUB_TOKEN = "ghp_test"; - process.env.GITHUB_WEBHOOK_SECRET = "test-webhook-secret"; - process.env.GITHUB_BOT_USERNAME = "recoup-bot"; process.env.REDIS_URL = "redis://localhost:6379"; process.env.CODING_AGENT_CALLBACK_SECRET = "test-callback-secret"; }); - it("creates a Chat instance with slack and github adapters", async () => { + it("creates a Chat instance with slack adapter", async () => { const { Chat } = await import("chat"); const { createCodingAgentBot } = await import("../bot"); @@ -61,22 +53,6 @@ describe("createCodingAgentBot", () => { const lastCall = vi.mocked(Chat).mock.calls.at(-1)!; const config = lastCall[0]; expect(config.adapters).toHaveProperty("slack"); - expect(config.adapters).toHaveProperty("github"); - }); - - it("creates a GitHubAdapter with correct config", async () => { - const { GitHubAdapter } = await import("@chat-adapter/github"); - const { createCodingAgentBot } = await import("../bot"); - - createCodingAgentBot(); - - expect(GitHubAdapter).toHaveBeenCalledWith( - expect.objectContaining({ - token: "ghp_test", - webhookSecret: "test-webhook-secret", - userName: "recoup-bot", - }), - ); }); it("creates a SlackAdapter with correct config", async () => { diff --git a/lib/coding-agent/__tests__/validateEnv.test.ts b/lib/coding-agent/__tests__/validateEnv.test.ts index 9b21a828..37278c74 100644 --- a/lib/coding-agent/__tests__/validateEnv.test.ts +++ b/lib/coding-agent/__tests__/validateEnv.test.ts @@ -4,8 +4,6 @@ const REQUIRED_VARS = [ "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "GITHUB_TOKEN", - "GITHUB_WEBHOOK_SECRET", - "GITHUB_BOT_USERNAME", "REDIS_URL", "CODING_AGENT_CALLBACK_SECRET", ]; diff --git a/lib/coding-agent/bot.ts b/lib/coding-agent/bot.ts index 425bcdba..7ed49712 100644 --- a/lib/coding-agent/bot.ts +++ b/lib/coding-agent/bot.ts @@ -1,6 +1,5 @@ import { Chat, ConsoleLogger } from "chat"; import { SlackAdapter } from "@chat-adapter/slack"; -import { GitHubAdapter } from "@chat-adapter/github"; import { createIoRedisState } from "@chat-adapter/state-ioredis"; import redis from "@/lib/redis/connection"; import type { CodingAgentThreadState } from "./types"; @@ -9,7 +8,7 @@ import { validateCodingAgentEnv } from "./validateEnv"; const logger = new ConsoleLogger(); /** - * Creates a new Chat bot instance configured with Slack and GitHub adapters. + * Creates a new Chat bot instance configured with the Slack adapter. */ export function createCodingAgentBot() { validateCodingAgentEnv(); @@ -33,16 +32,9 @@ export function createCodingAgentBot() { logger, }); - const github = new GitHubAdapter({ - token: process.env.GITHUB_TOKEN!, - webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!, - userName: process.env.GITHUB_BOT_USERNAME!, - logger, - }); - - return new Chat<{ slack: SlackAdapter; github: GitHubAdapter }, CodingAgentThreadState>({ + return new Chat<{ slack: SlackAdapter }, CodingAgentThreadState>({ userName: "Recoup Agent", - adapters: { slack, github }, + adapters: { slack }, state, }); } diff --git a/lib/coding-agent/validateEnv.ts b/lib/coding-agent/validateEnv.ts index d5c67662..51e0a36c 100644 --- a/lib/coding-agent/validateEnv.ts +++ b/lib/coding-agent/validateEnv.ts @@ -2,8 +2,6 @@ const REQUIRED_ENV_VARS = [ "SLACK_BOT_TOKEN", "SLACK_SIGNING_SECRET", "GITHUB_TOKEN", - "GITHUB_WEBHOOK_SECRET", - "GITHUB_BOT_USERNAME", "REDIS_URL", "CODING_AGENT_CALLBACK_SECRET", ] as const; From cb6e8f7b2d2547e8b1b0f65dafa380068266b22d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:19:26 -0500 Subject: [PATCH 12/22] feat: add View Task button to PR update feedback message Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/__tests__/handlers.test.ts | 2 +- lib/coding-agent/handlers/onNewMention.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/coding-agent/__tests__/handlers.test.ts b/lib/coding-agent/__tests__/handlers.test.ts index 15d198ad..5cd30f50 100644 --- a/lib/coding-agent/__tests__/handlers.test.ts +++ b/lib/coding-agent/__tests__/handlers.test.ts @@ -104,7 +104,7 @@ describe("registerOnNewMention", () => { repo: "recoupable/tasks", }), ); - expect(mockThread.post).toHaveBeenCalledWith(expect.stringContaining("feedback")); + expect(mockThread.post).toHaveBeenCalledWith(expect.objectContaining({ card: expect.anything() })); expect(mockThread.setState).toHaveBeenCalledWith(expect.objectContaining({ status: "updating" })); }); diff --git a/lib/coding-agent/handlers/onNewMention.ts b/lib/coding-agent/handlers/onNewMention.ts index 6073c7cb..1f3ab223 100644 --- a/lib/coding-agent/handlers/onNewMention.ts +++ b/lib/coding-agent/handlers/onNewMention.ts @@ -21,15 +21,26 @@ export function registerOnNewMention(bot: CodingAgentBot) { } if (state?.status === "pr_created" && state.snapshotId && state.branch && state.prs?.length) { - await thread.post("Got your feedback. Updating the PRs..."); await thread.setState({ status: "updating" }); - await triggerUpdatePR({ + const handle = await triggerUpdatePR({ feedback: message.text, snapshotId: state.snapshotId, branch: state.branch, repo: state.prs[0].repo, callbackThreadId: thread.id, }); + + const card = Card({ + title: "Updating PRs", + children: [ + CardText(`Got your feedback. Updating the PRs...`), + Actions([ + LinkButton({ url: `https://chat.recoupable.com/tasks/${handle.id}`, label: "View Task" }), + ]), + ], + }); + + await thread.post({ card }); return; } From bbb3f36f380e17fc666427f1b8995479030c495e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:23:20 -0500 Subject: [PATCH 13/22] feat: add PR buttons to updated callback, DRY with buildPRCard Extract buildPRCard to share PR review links + Merge button between pr_created and updated callbacks. Updated status now resets to pr_created and shows the same card. Co-Authored-By: Claude Opus 4.6 --- .../handleCodingAgentCallback.test.ts | 16 ++++++++--- lib/coding-agent/buildPRCard.ts | 27 +++++++++++++++++++ lib/coding-agent/handleCodingAgentCallback.ts | 12 ++++++--- lib/coding-agent/handlePRCreated.ts | 19 ++----------- 4 files changed, 51 insertions(+), 23 deletions(-) create mode 100644 lib/coding-agent/buildPRCard.ts diff --git a/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts b/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts index 17750c67..64e30c7b 100644 --- a/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts +++ b/lib/coding-agent/__tests__/handleCodingAgentCallback.test.ts @@ -6,11 +6,15 @@ 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, @@ -34,6 +38,7 @@ const { handleCodingAgentCallback } = await import("../handleCodingAgentCallback beforeEach(() => { vi.clearAllMocks(); + mockState = null; process.env.CODING_AGENT_CALLBACK_SECRET = "test-secret"; }); @@ -128,7 +133,12 @@ describe("handleCodingAgentCallback", () => { expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("Sandbox timed out")); }); - it("posts updated confirmation for updated status", async () => { + 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", @@ -139,7 +149,7 @@ describe("handleCodingAgentCallback", () => { const response = await handleCodingAgentCallback(request); expect(response.status).toBe(200); - expect(mockSetState).toHaveBeenCalledWith(expect.objectContaining({ snapshotId: "snap_new" })); - expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("updated")); + expect(mockSetState).toHaveBeenCalledWith(expect.objectContaining({ status: "pr_created", snapshotId: "snap_new" })); + expect(mockPost).toHaveBeenCalledWith(expect.objectContaining({ card: expect.anything() })); }); }); diff --git a/lib/coding-agent/buildPRCard.ts b/lib/coding-agent/buildPRCard.ts new file mode 100644 index 00000000..c7cce941 --- /dev/null +++ b/lib/coding-agent/buildPRCard.ts @@ -0,0 +1,27 @@ +import { Card, CardText, Actions, Button, LinkButton } from "chat"; +import type { CodingAgentPR } from "./types"; + +/** + * Builds a Card with PR review links and a Merge All PRs button. + * + * @param title - Card title (e.g. "PRs Created", "PRs Updated") + * @param prs - Array of PRs to build review links for + */ +export function buildPRCard(title: string, prs: CodingAgentPR[]) { + const prLinks = prs + .map(pr => `- [${pr.repo}#${pr.number}](${pr.url}) → \`${pr.baseBranch}\``) + .join("\n"); + + return Card({ + title, + children: [ + CardText(`${prLinks}\n\nReply in this thread to give feedback.`), + Actions([ + ...prs.map(pr => + LinkButton({ url: pr.url, label: `Review ${pr.repo}#${pr.number}` }), + ), + Button({ id: "merge_all_prs", label: "Merge All PRs", style: "primary" }), + ]), + ], + }); +} diff --git a/lib/coding-agent/handleCodingAgentCallback.ts b/lib/coding-agent/handleCodingAgentCallback.ts index 0a8ebd06..5397419a 100644 --- a/lib/coding-agent/handleCodingAgentCallback.ts +++ b/lib/coding-agent/handleCodingAgentCallback.ts @@ -3,6 +3,8 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCodingAgentCallback } from "./validateCodingAgentCallback"; import { getThread } from "./getThread"; import { handlePRCreated } from "./handlePRCreated"; +import { buildPRCard } from "./buildPRCard"; +import type { CodingAgentThreadState } from "./types"; /** * Handles coding agent task callback from Trigger.dev. @@ -55,10 +57,14 @@ export async function handleCodingAgentCallback(request: Request): Promise `- [${pr.repo}#${pr.number}](${pr.url}) → \`${pr.baseBranch}\``) - .join("\n"); - - const card = Card({ - title: "PRs Created", - children: [ - CardText(`${prLinks}\n\nReply in this thread to give feedback.`), - Actions([ - ...((body.prs ?? []).map(pr => - LinkButton({ url: pr.url, label: `Review ${pr.repo}#${pr.number}` }), - )), - Button({ id: "merge_all_prs", label: "Merge All PRs", style: "primary" }), - ]), - ], - }); + const card = buildPRCard("PRs Created", body.prs ?? []); await thread.post({ card }); From b2eca9d58c575254765fa74b742ada02881fc5df Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:25:25 -0500 Subject: [PATCH 14/22] refactor: DRY View Task button into buildTaskCard Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/buildTaskCard.ts | 20 +++++++++++++++++++ lib/coding-agent/handlers/onNewMention.ts | 24 +++-------------------- 2 files changed, 23 insertions(+), 21 deletions(-) create mode 100644 lib/coding-agent/buildTaskCard.ts diff --git a/lib/coding-agent/buildTaskCard.ts b/lib/coding-agent/buildTaskCard.ts new file mode 100644 index 00000000..f0b16548 --- /dev/null +++ b/lib/coding-agent/buildTaskCard.ts @@ -0,0 +1,20 @@ +import { Card, CardText, Actions, LinkButton } from "chat"; + +/** + * Builds a Card with a message and a View Task button. + * + * @param title - Card title (e.g. "Task Started", "Updating PRs") + * @param message - Body text + * @param runId - Trigger.dev run ID for the View Task link + */ +export function buildTaskCard(title: string, message: string, runId: string) { + return Card({ + title, + children: [ + CardText(message), + Actions([ + LinkButton({ url: `https://chat.recoupable.com/tasks/${runId}`, label: "View Task" }), + ]), + ], + }); +} diff --git a/lib/coding-agent/handlers/onNewMention.ts b/lib/coding-agent/handlers/onNewMention.ts index 1f3ab223..6b887abe 100644 --- a/lib/coding-agent/handlers/onNewMention.ts +++ b/lib/coding-agent/handlers/onNewMention.ts @@ -1,5 +1,5 @@ -import { Card, CardText, Actions, LinkButton } from "chat"; import type { CodingAgentBot } from "../bot"; +import { buildTaskCard } from "../buildTaskCard"; import { triggerCodingAgent } from "@/lib/trigger/triggerCodingAgent"; import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; @@ -30,16 +30,7 @@ export function registerOnNewMention(bot: CodingAgentBot) { callbackThreadId: thread.id, }); - const card = Card({ - title: "Updating PRs", - children: [ - CardText(`Got your feedback. Updating the PRs...`), - Actions([ - LinkButton({ url: `https://chat.recoupable.com/tasks/${handle.id}`, label: "View Task" }), - ]), - ], - }); - + const card = buildTaskCard("Updating PRs", "Got your feedback. Updating the PRs...", handle.id); await thread.post({ card }); return; } @@ -52,16 +43,7 @@ export function registerOnNewMention(bot: CodingAgentBot) { callbackThreadId: thread.id, }); - const card = Card({ - title: "Task Started", - children: [ - CardText(`Starting work on: "${prompt}"\n\nI'll reply here when done.`), - Actions([ - LinkButton({ url: `https://chat.recoupable.com/tasks/${handle.id}`, label: "View Task" }), - ]), - ], - }); - + const card = buildTaskCard("Task Started", `Starting work on: "${prompt}"\n\nI'll reply here when done.`, handle.id); await thread.post({ card }); await thread.setState({ From fcc512f485042f86afe8ee648dc2f954cbeb284c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:29:51 -0500 Subject: [PATCH 15/22] refactor: remove redundant text PR links from buildPRCard The review buttons already link to each PR. Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/buildPRCard.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/lib/coding-agent/buildPRCard.ts b/lib/coding-agent/buildPRCard.ts index c7cce941..4ae3129a 100644 --- a/lib/coding-agent/buildPRCard.ts +++ b/lib/coding-agent/buildPRCard.ts @@ -8,14 +8,10 @@ import type { CodingAgentPR } from "./types"; * @param prs - Array of PRs to build review links for */ export function buildPRCard(title: string, prs: CodingAgentPR[]) { - const prLinks = prs - .map(pr => `- [${pr.repo}#${pr.number}](${pr.url}) → \`${pr.baseBranch}\``) - .join("\n"); - return Card({ title, children: [ - CardText(`${prLinks}\n\nReply in this thread to give feedback.`), + CardText("Reply in this thread to give feedback."), Actions([ ...prs.map(pr => LinkButton({ url: pr.url, label: `Review ${pr.repo}#${pr.number}` }), From 413cb5583f79f6d4cec10e32bf6b2093faca689c Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:31:16 -0500 Subject: [PATCH 16/22] fix: restore PR summary text without links in buildPRCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show "repo#number → baseBranch" as plain text, buttons handle linking. Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/buildPRCard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/coding-agent/buildPRCard.ts b/lib/coding-agent/buildPRCard.ts index 4ae3129a..9ca6343d 100644 --- a/lib/coding-agent/buildPRCard.ts +++ b/lib/coding-agent/buildPRCard.ts @@ -11,7 +11,7 @@ export function buildPRCard(title: string, prs: CodingAgentPR[]) { return Card({ title, children: [ - CardText("Reply in this thread to give feedback."), + CardText(`${prs.map(pr => `- ${pr.repo}#${pr.number} → \`${pr.baseBranch}\``).join("\n")}\n\nReply in this thread to give feedback.`), Actions([ ...prs.map(pr => LinkButton({ url: pr.url, label: `Review ${pr.repo}#${pr.number}` }), From 5f5a9ca5f8ce31753736f9b0f1f0ac53d429b5cf Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:42:08 -0500 Subject: [PATCH 17/22] debug: log triggerUpdatePR handle and card payload Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/handlers/onNewMention.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/coding-agent/handlers/onNewMention.ts b/lib/coding-agent/handlers/onNewMention.ts index 6b887abe..daa2a9ad 100644 --- a/lib/coding-agent/handlers/onNewMention.ts +++ b/lib/coding-agent/handlers/onNewMention.ts @@ -30,7 +30,9 @@ export function registerOnNewMention(bot: CodingAgentBot) { callbackThreadId: thread.id, }); + console.log("[coding-agent] triggerUpdatePR handle:", JSON.stringify(handle)); const card = buildTaskCard("Updating PRs", "Got your feedback. Updating the PRs...", handle.id); + console.log("[coding-agent] posting card:", JSON.stringify({ card })); await thread.post({ card }); return; } From 26a7c75d8d1873cb994f6009b2d84036f46f741d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:50:30 -0500 Subject: [PATCH 18/22] fix: add View Task button to onSubscribedMessage feedback reply The follow-up messages in a thread hit onSubscribedMessage, not onNewMention. This was still posting plain text instead of a card with the View Task button. Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/__tests__/onSubscribedMessage.test.ts | 2 +- lib/coding-agent/handlers/onNewMention.ts | 2 -- lib/coding-agent/handlers/onSubscribedMessage.ts | 8 +++++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/coding-agent/__tests__/onSubscribedMessage.test.ts b/lib/coding-agent/__tests__/onSubscribedMessage.test.ts index f4647420..8ba6b565 100644 --- a/lib/coding-agent/__tests__/onSubscribedMessage.test.ts +++ b/lib/coding-agent/__tests__/onSubscribedMessage.test.ts @@ -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({ diff --git a/lib/coding-agent/handlers/onNewMention.ts b/lib/coding-agent/handlers/onNewMention.ts index daa2a9ad..6b887abe 100644 --- a/lib/coding-agent/handlers/onNewMention.ts +++ b/lib/coding-agent/handlers/onNewMention.ts @@ -30,9 +30,7 @@ export function registerOnNewMention(bot: CodingAgentBot) { callbackThreadId: thread.id, }); - console.log("[coding-agent] triggerUpdatePR handle:", JSON.stringify(handle)); const card = buildTaskCard("Updating PRs", "Got your feedback. Updating the PRs...", handle.id); - console.log("[coding-agent] posting card:", JSON.stringify({ card })); await thread.post({ card }); return; } diff --git a/lib/coding-agent/handlers/onSubscribedMessage.ts b/lib/coding-agent/handlers/onSubscribedMessage.ts index 1846c541..c0aba328 100644 --- a/lib/coding-agent/handlers/onSubscribedMessage.ts +++ b/lib/coding-agent/handlers/onSubscribedMessage.ts @@ -1,4 +1,5 @@ import type { CodingAgentBot } from "../bot"; +import { buildTaskCard } from "../buildTaskCard"; import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; /** @@ -21,17 +22,18 @@ export function registerOnSubscribedMessage(bot: CodingAgentBot) { } if (state.status === "pr_created" && state.snapshotId && state.branch && state.prs?.length) { - await thread.post("Got your feedback. Updating the PRs..."); - await thread.setState({ status: "updating" }); - await triggerUpdatePR({ + const handle = await triggerUpdatePR({ feedback: message.text, snapshotId: state.snapshotId, branch: state.branch, repo: state.prs[0].repo, callbackThreadId: thread.id, }); + + const card = buildTaskCard("Updating PRs", "Got your feedback. Updating the PRs...", handle.id); + await thread.post({ card }); } }); } From 5f71242c51b97209d5600ae294c0adf0daec3ecd Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:52:24 -0500 Subject: [PATCH 19/22] refactor: DRY feedback handling into shared handleFeedback Extract busy check and update-pr trigger logic into handleFeedback, used by both onNewMention and onSubscribedMessage. Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/handlers/handleFeedback.ts | 39 +++++++++++++++++++ lib/coding-agent/handlers/onNewMention.ts | 22 +---------- .../handlers/onSubscribedMessage.ts | 29 ++------------ 3 files changed, 44 insertions(+), 46 deletions(-) create mode 100644 lib/coding-agent/handlers/handleFeedback.ts diff --git a/lib/coding-agent/handlers/handleFeedback.ts b/lib/coding-agent/handlers/handleFeedback.ts new file mode 100644 index 00000000..a2c6197f --- /dev/null +++ b/lib/coding-agent/handlers/handleFeedback.ts @@ -0,0 +1,39 @@ +import { buildTaskCard } from "../buildTaskCard"; +import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; +import type { CodingAgentThreadState } from "../types"; + +/** + * Handles a message in a thread that already has state. + * Returns true if the message was handled (busy or feedback), false otherwise. + * + * @param thread - The chat thread + * @param messageText - The user's message text + * @param state - The current thread state + */ +export async function handleFeedback( + thread: { id: string; post: (msg: unknown) => Promise; setState: (s: Partial) => Promise }, + messageText: string, + state: CodingAgentThreadState | null, +): Promise { + if (state?.status === "running" || state?.status === "updating") { + await thread.post("I'm still working on this. I'll let you know when I'm done."); + return true; + } + + if (state?.status === "pr_created" && state.snapshotId && state.branch && state.prs?.length) { + await thread.setState({ status: "updating" }); + const handle = await triggerUpdatePR({ + feedback: messageText, + snapshotId: state.snapshotId, + branch: state.branch, + repo: state.prs[0].repo, + callbackThreadId: thread.id, + }); + + const card = buildTaskCard("Updating PRs", "Got your feedback. Updating the PRs...", handle.id); + await thread.post({ card }); + return true; + } + + return false; +} diff --git a/lib/coding-agent/handlers/onNewMention.ts b/lib/coding-agent/handlers/onNewMention.ts index 6b887abe..ea020146 100644 --- a/lib/coding-agent/handlers/onNewMention.ts +++ b/lib/coding-agent/handlers/onNewMention.ts @@ -1,7 +1,7 @@ import type { CodingAgentBot } from "../bot"; import { buildTaskCard } from "../buildTaskCard"; import { triggerCodingAgent } from "@/lib/trigger/triggerCodingAgent"; -import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; +import { handleFeedback } from "./handleFeedback"; /** * Registers the onNewMention handler on the bot. @@ -15,25 +15,7 @@ export function registerOnNewMention(bot: CodingAgentBot) { try { const state = await thread.state; - if (state?.status === "running" || state?.status === "updating") { - await thread.post("I'm still working on this. I'll let you know when I'm done."); - return; - } - - if (state?.status === "pr_created" && state.snapshotId && state.branch && state.prs?.length) { - await thread.setState({ status: "updating" }); - const handle = await triggerUpdatePR({ - feedback: message.text, - snapshotId: state.snapshotId, - branch: state.branch, - repo: state.prs[0].repo, - callbackThreadId: thread.id, - }); - - const card = buildTaskCard("Updating PRs", "Got your feedback. Updating the PRs...", handle.id); - await thread.post({ card }); - return; - } + if (await handleFeedback(thread, message.text, state)) return; const prompt = message.text; await thread.subscribe(); diff --git a/lib/coding-agent/handlers/onSubscribedMessage.ts b/lib/coding-agent/handlers/onSubscribedMessage.ts index c0aba328..7b769706 100644 --- a/lib/coding-agent/handlers/onSubscribedMessage.ts +++ b/lib/coding-agent/handlers/onSubscribedMessage.ts @@ -1,39 +1,16 @@ import type { CodingAgentBot } from "../bot"; -import { buildTaskCard } from "../buildTaskCard"; -import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; +import { handleFeedback } from "./handleFeedback"; /** * Registers the onSubscribedMessage handler on the bot. - * If the agent has created PRs, treats the message as feedback and - * triggers the update-pr task. If the agent is currently working, - * tells the user to wait. + * Delegates to handleFeedback for busy/update-pr logic. * * @param bot */ export function registerOnSubscribedMessage(bot: CodingAgentBot) { bot.onSubscribedMessage(async (thread, message) => { const state = await thread.state; - if (!state) return; - - if (state.status === "running" || state.status === "updating") { - await thread.post("I'm still working on this. I'll let you know when I'm done."); - return; - } - - if (state.status === "pr_created" && state.snapshotId && state.branch && state.prs?.length) { - await thread.setState({ status: "updating" }); - - const handle = await triggerUpdatePR({ - feedback: message.text, - snapshotId: state.snapshotId, - branch: state.branch, - repo: state.prs[0].repo, - callbackThreadId: thread.id, - }); - - const card = buildTaskCard("Updating PRs", "Got your feedback. Updating the PRs...", handle.id); - await thread.post({ card }); - } + await handleFeedback(thread, message.text, state); }); } From 78ab30bdedcbf96b11e800fff177593df8946613 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:54:33 -0500 Subject: [PATCH 20/22] fix: widen handleFeedback thread type to match Thread signature Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/handlers/handleFeedback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/coding-agent/handlers/handleFeedback.ts b/lib/coding-agent/handlers/handleFeedback.ts index a2c6197f..75331426 100644 --- a/lib/coding-agent/handlers/handleFeedback.ts +++ b/lib/coding-agent/handlers/handleFeedback.ts @@ -11,7 +11,7 @@ import type { CodingAgentThreadState } from "../types"; * @param state - The current thread state */ export async function handleFeedback( - thread: { id: string; post: (msg: unknown) => Promise; setState: (s: Partial) => Promise }, + thread: { id: string; post: (msg: unknown) => Promise; setState: (s: Partial) => Promise }, messageText: string, state: CodingAgentThreadState | null, ): Promise { From b809db4abf648a2c30d40b9e2c304634020844f4 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 18:56:41 -0500 Subject: [PATCH 21/22] refactor: use Chat SDK Thread type in handleFeedback Co-Authored-By: Claude Opus 4.6 --- lib/coding-agent/handlers/handleFeedback.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/coding-agent/handlers/handleFeedback.ts b/lib/coding-agent/handlers/handleFeedback.ts index 75331426..4c1c554d 100644 --- a/lib/coding-agent/handlers/handleFeedback.ts +++ b/lib/coding-agent/handlers/handleFeedback.ts @@ -1,3 +1,4 @@ +import type { Thread } from "chat"; import { buildTaskCard } from "../buildTaskCard"; import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; import type { CodingAgentThreadState } from "../types"; @@ -11,7 +12,7 @@ import type { CodingAgentThreadState } from "../types"; * @param state - The current thread state */ export async function handleFeedback( - thread: { id: string; post: (msg: unknown) => Promise; setState: (s: Partial) => Promise }, + thread: Thread, messageText: string, state: CodingAgentThreadState | null, ): Promise { From 5abd81c315e7d9876cc1faa7575f5c87522c91ad Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Fri, 6 Mar 2026 19:03:17 -0500 Subject: [PATCH 22/22] fix: wait for Redis ready state in callback route Wait until Redis status is "ready" before handling callbacks. Handles all intermediate states (wait, connecting, reconnecting) to prevent IoRedisStateAdapter errors on cold starts. Co-Authored-By: Claude Opus 4.6 --- app/api/coding-agent/callback/route.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/api/coding-agent/callback/route.ts b/app/api/coding-agent/callback/route.ts index 6e5e5eec..bb029f13 100644 --- a/app/api/coding-agent/callback/route.ts +++ b/app/api/coding-agent/callback/route.ts @@ -12,10 +12,15 @@ import { handleCodingAgentCallback } from "@/lib/coding-agent/handleCodingAgentC * @param request - The incoming callback request */ export async function POST(request: NextRequest) { - if (redis.status === "wait") { - await redis.connect(); - } else if (redis.status === "connecting") { - await new Promise((resolve) => redis.once("ready", resolve)); + if (redis.status !== "ready") { + if (redis.status === "wait") { + await redis.connect(); + } else { + await new Promise((resolve, reject) => { + redis.once("ready", resolve); + redis.once("error", reject); + }); + } } return handleCodingAgentCallback(request); }