From 00f8b9c9fa9e0992bd5ef59ec2536098a4450414 Mon Sep 17 00:00:00 2001 From: fayekelmith Date: Sat, 15 Aug 2026 05:44:56 +0000 Subject: [PATCH] Generated with Hive: Add retry support to feature chat endpoint and complete attention mock fixtures --- scripts/backfill-attention-showcase.ts | 142 ++++++ .../api/features/feature-chat-retry.test.ts | 403 ++++++++++++++++++ .../api/features/[featureId]/chat/helpers.ts | 23 + .../api/features/[featureId]/chat/route.ts | 94 +++- 4 files changed, 661 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/unit/api/features/feature-chat-retry.test.ts create mode 100644 src/app/api/features/[featureId]/chat/helpers.ts diff --git a/scripts/backfill-attention-showcase.ts b/scripts/backfill-attention-showcase.ts index a3052d2433..ef396e9526 100644 --- a/scripts/backfill-attention-showcase.ts +++ b/scripts/backfill-attention-showcase.ts @@ -105,6 +105,7 @@ interface ShowcaseVariant { formTitle: string; fields: Array<{ name: string; type: string; required: boolean; label: string; options?: string[] }>; }; + /** Awaiting-reply Feature: last message is ASSISTANT, no tasks exist. */ feature: { title: string; brief: string; requirements: string; assistantMessage: string }; /** * Live Now fixtures (see file header): planner-running feature, @@ -124,6 +125,10 @@ interface ShowcaseVariant { taskTitle: string; taskDescription: string; }; + /** Halted Feature: workflowStatus=HALTED so "Retry Run" is exercisable. */ + haltedFeature: { title: string; brief: string; requirements: string; userMessage: string }; + /** Ready-to-review Feature: workflowStatus=COMPLETED so "Jump to Review" is exercisable. */ + reviewFeature: { title: string; brief: string; requirements: string; userMessage: string; assistantMessage: string }; } const VARIANTS: ShowcaseVariant[] = [ @@ -193,6 +198,20 @@ const VARIANTS: ShowcaseVariant[] = [ taskDescription: "Several templates render without preview thumbnails; agent halted awaiting asset decisions.", }, + haltedFeature: { + title: "Migrate auth to passkeys", + brief: "Replace password-based login with WebAuthn passkeys across all surfaces.", + requirements: "FIDO2 registration, authentication, fallback to existing OAuth.", + userMessage: "Please design and implement passkey registration flow for the web app.", + }, + reviewFeature: { + title: "Dark mode for settings pages", + brief: "Extend the existing dark theme to cover all settings pages and modals.", + requirements: "Full CSS variable coverage, no hardcoded colour values, persisted user preference.", + userMessage: "Extend dark mode styling to the settings section.", + assistantMessage: + "All settings pages now fully support dark mode. I've opened a PR and recorded a before/after screen recording — have a look and let me know if any section needs adjustment before merge.", + }, }, { halted: { @@ -260,6 +279,20 @@ const VARIANTS: ShowcaseVariant[] = [ taskDescription: "Filter state resets on refresh; agent halted awaiting confirmation of expected persistence scope.", }, + haltedFeature: { + title: "Unify API error response schema", + brief: "Standardise all API error payloads to { code, message, details } across every route.", + requirements: "Consistent error codes, backward-compatible wrapper, auto-docs update.", + userMessage: "Please audit and unify the API error response schema across all routes.", + }, + reviewFeature: { + title: "Granular notification preferences", + brief: "Give users per-channel, per-event notification toggles (email / push / in-app).", + requirements: "Per-event toggles, workspace-scoped defaults, immediate Pusher broadcast on save.", + userMessage: "Build the granular notification preferences UI and backend.", + assistantMessage: + "Notification preferences are live — email/push/in-app toggles are all wired up. I've opened a PR with a before/after test suite; looks good to me. Ready for your sign-off.", + }, }, { halted: { @@ -327,6 +360,20 @@ const VARIANTS: ShowcaseVariant[] = [ taskDescription: "Migration halts on presets with custom backgrounds; needs a fallback mapping.", }, + haltedFeature: { + title: "Multi-region failover routing", + brief: "Route traffic to the nearest healthy region and fail over automatically on health-check failures.", + requirements: "Latency-based routing, health probes every 30s, zero-downtime cutover.", + userMessage: "Design and scaffold the multi-region failover routing layer.", + }, + reviewFeature: { + title: "AI-powered release notes generator", + brief: "Auto-draft release notes from merged PRs and resolved tickets each sprint.", + requirements: "PR title/body parsing, Jira link resolution, markdown output, one-click publish to Notion.", + userMessage: "Build the release notes generator using merged PRs and closed tickets.", + assistantMessage: + "Release notes generator is ready — it picks up all merged PRs since the last tag, groups them by label, and exports to Notion. PR open and green. Ready for your review before we ship.", + }, }, ]; @@ -992,6 +1039,101 @@ async function backfillWorkspace( }); console.log(` + loose-feature HALTED task: ${variant.loose.taskTitle}`); } + + // ── HALTED feature (workflowStatus=HALTED, has a USER msg to retry) ── + // Provides the "Retry Run" menu-item fixture. Has one USER-authored + // message so the server-side retry branch has something to resend. + let haltedFeat = await db.feature.findFirst({ + where: { workspaceId, title: variant.haltedFeature.title, deleted: false }, + select: { id: true }, + }); + if (!haltedFeat) { + haltedFeat = await db.feature.create({ + data: { + title: variant.haltedFeature.title, + brief: variant.haltedFeature.brief, + status: FeatureStatus.IN_PROGRESS, + priority: FeaturePriority.HIGH, + requirements: variant.haltedFeature.requirements, + workspaceId, + createdById: userId, + updatedById: userId, + assigneeId: userId, + workflowStatus: WorkflowStatus.HALTED, + }, + select: { id: true }, + }); + console.log(` + halted feature: ${variant.haltedFeature.title}`); + } + const existingHaltedMsg = await db.chatMessage.findFirst({ + where: { featureId: haltedFeat.id, role: "USER" }, + select: { id: true }, + }); + if (!existingHaltedMsg) { + await db.chatMessage.create({ + data: { + featureId: haltedFeat.id, + message: variant.haltedFeature.userMessage, + role: "USER", + }, + }); + console.log(` + USER msg on halted feature`); + } + + // ── READY-TO-REVIEW feature (workflowStatus=COMPLETED, last-msg ASSISTANT) ── + // Provides the "Jump to Review" menu-item fixture. + let reviewFeat = await db.feature.findFirst({ + where: { workspaceId, title: variant.reviewFeature.title, deleted: false }, + select: { id: true }, + }); + if (!reviewFeat) { + reviewFeat = await db.feature.create({ + data: { + title: variant.reviewFeature.title, + brief: variant.reviewFeature.brief, + status: FeatureStatus.IN_PROGRESS, + priority: FeaturePriority.MEDIUM, + requirements: variant.reviewFeature.requirements, + workspaceId, + createdById: userId, + updatedById: userId, + assigneeId: userId, + workflowStatus: WorkflowStatus.COMPLETED, + }, + select: { id: true }, + }); + console.log(` + ready-to-review feature: ${variant.reviewFeature.title}`); + } + const existingReviewMsg = await db.chatMessage.findFirst({ + where: { featureId: reviewFeat.id }, + select: { id: true, role: true }, + orderBy: { timestamp: "desc" }, + }); + if (!existingReviewMsg || existingReviewMsg.role !== "ASSISTANT") { + // Seed both a USER message and a closing ASSISTANT message so the + // feature resembles a completed conversation ready for sign-off. + const hasUser = await db.chatMessage.findFirst({ + where: { featureId: reviewFeat.id, role: "USER" }, + select: { id: true }, + }); + if (!hasUser) { + await db.chatMessage.create({ + data: { + featureId: reviewFeat.id, + message: variant.reviewFeature.userMessage, + role: "USER", + }, + }); + } + await db.chatMessage.create({ + data: { + featureId: reviewFeat.id, + message: variant.reviewFeature.assistantMessage, + role: "ASSISTANT", + }, + }); + console.log(` + ASSISTANT msg on ready-to-review feature`); + } } async function main() { diff --git a/src/__tests__/unit/api/features/feature-chat-retry.test.ts b/src/__tests__/unit/api/features/feature-chat-retry.test.ts new file mode 100644 index 0000000000..eeba58cda0 --- /dev/null +++ b/src/__tests__/unit/api/features/feature-chat-retry.test.ts @@ -0,0 +1,403 @@ +/** + * Unit tests for the retry branch of POST /api/features/[featureId]/chat + * + * Tests cover: + * - resolveRetryMessage helper (message-selection logic) + * - strict retry === true handling via the POST handler + * - 400 when both retry and message are present + * - 400 on empty / no-USER-message history + * - 429 on rate-limit (updatedAt throttle) + * - auth-ordering: retry only runs AFTER workspace access check + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "@/app/api/features/[featureId]/chat/route"; +import { resolveRetryMessage } from "@/app/api/features/[featureId]/chat/helpers"; +import { NextRequest } from "next/server"; +import { db } from "@/lib/db"; +import { ChatRole, ChatStatus } from "@/lib/chat"; + +// ── Mocks ────────────────────────────────────────────────────────────────── + +vi.mock("@/lib/auth/api-token", () => ({ + requireAuthOrApiToken: vi.fn().mockResolvedValue({ + id: "user-123", + email: "test@example.com", + }), +})); + +vi.mock("@/lib/auth/workspace-access", async () => { + const actual = await vi.importActual( + "@/lib/auth/workspace-access", + ); + return { + ...actual, + resolveWorkspaceAccess: vi.fn().mockResolvedValue({ + kind: "member", + userId: "user-123", + workspaceId: "workspace-123", + slug: "ws", + role: "DEVELOPER", + }), + }; +}); + +vi.mock("@/lib/db", () => ({ + db: { + feature: { + findUnique: vi.fn(), + }, + chatMessage: { + create: vi.fn(), + findMany: vi.fn(), + }, + artifact: { + findFirst: vi.fn(), + }, + }, +})); + +// Mock sendFeatureChatMessage so tests don't need a full workspace/swarm setup. +vi.mock("@/services/roadmap/feature-chat", () => ({ + sendFeatureChatMessage: vi.fn(), +})); + +vi.mock("@/services/canvas-planner-forms", () => ({ + appendAnswerRow: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, +})); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function makeFeature(overrides: Record = {}) { + return { + workspaceId: "workspace-123", + parentCanvasConversationId: null, + updatedAt: new Date(Date.now() - 60_000), // 60 s ago — outside throttle window + ...overrides, + }; +} + +function makeChatMessage(overrides: Record = {}) { + return { + id: "msg-id", + featureId: "feature-123", + message: "Test message", + role: ChatRole.USER, + userId: "user-123", + contextTags: "[]", + status: ChatStatus.SENT, + sourceWebsocketID: null, + replyId: null, + createdAt: new Date(), + updatedAt: new Date(), + artifacts: [], + attachments: [], + createdBy: { + id: "user-123", + name: "Test User", + email: "test@example.com", + image: null, + }, + ...overrides, + }; +} + +function makeSendResult(messageText = "Test message") { + return { + chatMessage: makeChatMessage({ message: messageText }), + stakworkData: null, + }; +} + +function makeRetryRequest( + featureId: string, + body: Record = { retry: true }, +) { + return new NextRequest( + `http://localhost:3000/api/features/${featureId}/chat`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +// ── Unit tests: resolveRetryMessage ───────────────────────────────────────── + +describe("resolveRetryMessage()", () => { + it("returns null for empty history", () => { + expect(resolveRetryMessage([])).toBeNull(); + }); + + it("returns the first message when no ASSISTANT message exists (only USER)", () => { + const msgs = [ + { role: ChatRole.USER, message: "first user message" }, + { role: ChatRole.USER, message: "second user message" }, + ]; + expect(resolveRetryMessage(msgs)).toBe("first user message"); + }); + + it("returns the most recent USER message when an ASSISTANT message exists", () => { + const msgs = [ + { role: ChatRole.USER, message: "first user message" }, + { role: ChatRole.ASSISTANT, message: "assistant reply" }, + { role: ChatRole.USER, message: "latest user message" }, + ]; + expect(resolveRetryMessage(msgs)).toBe("latest user message"); + }); + + it("returns null when assistant is present but no USER message at all", () => { + const msgs = [ + { role: ChatRole.ASSISTANT, message: "assistant-only" }, + ]; + // hasAssistant = true, but no USER message — reverse.find returns undefined + expect(resolveRetryMessage(msgs)).toBeNull(); + }); + + it("handles interleaved messages correctly — picks the latest USER", () => { + const msgs = [ + { role: ChatRole.USER, message: "first" }, + { role: ChatRole.ASSISTANT, message: "a1" }, + { role: ChatRole.USER, message: "second" }, + { role: ChatRole.ASSISTANT, message: "a2" }, + { role: ChatRole.USER, message: "third" }, + ]; + expect(resolveRetryMessage(msgs)).toBe("third"); + }); + + it("single USER message with no ASSISTANT → returns that message (first message branch)", () => { + const msgs = [{ role: ChatRole.USER, message: "only message" }]; + expect(resolveRetryMessage(msgs)).toBe("only message"); + }); + + it("ignores non-USER/ASSISTANT roles when looking for resendable message", () => { + // A message with role SYSTEM only — no USER message at all. + // No assistant → first message branch fires, returning the SYSTEM message + // (which is a string, so non-null). This is the current implementation's + // behaviour for an edge case that can't arise in practice today. + const msgs = [{ role: "SYSTEM", message: "sys" }]; + // hasAssistant=false → returns msgs[0].message which is "sys" + expect(resolveRetryMessage(msgs)).toBe("sys"); + }); +}); + +// ── Integration-style unit tests: POST handler retry branch ───────────────── + +describe("POST /api/features/[featureId]/chat — retry branch", () => { + let sendFeatureChatMessage: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Import the mocked service after vi.clearAllMocks + const mod = await import("@/services/roadmap/feature-chat"); + sendFeatureChatMessage = vi.mocked(mod.sendFeatureChatMessage); + sendFeatureChatMessage.mockResolvedValue(makeSendResult("Please redesign the onboarding flow")); + + // Default: feature exists and was updated 60 s ago (outside throttle) + vi.mocked(db.feature.findUnique).mockResolvedValue(makeFeature() as never); + + // Default: single USER message in history (resendable) + vi.mocked(db.chatMessage.findMany).mockResolvedValue([ + makeChatMessage({ message: "Please redesign the onboarding flow" }), + ] as never); + + vi.mocked(db.artifact.findFirst).mockResolvedValue(null); + }); + + it("returns 201 with { retry: true } body — no message required", async () => { + const req = makeRetryRequest("feature-123", { retry: true }); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(201); + const data = await res.json(); + expect(data.success).toBe(true); + }); + + it("returns 400 when both retry:true and a non-empty message are present", async () => { + const req = makeRetryRequest("feature-123", { + retry: true, + message: "some message", + }); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/cannot combine retry/i); + // sendFeatureChatMessage must never be called + expect(sendFeatureChatMessage).not.toHaveBeenCalled(); + }); + + it("does NOT treat retry:1 (non-boolean-true) as a retry — falls through to normal send", async () => { + // Only strict boolean true should trigger the retry branch; + // retry:1 falls through to the normal send path. + const req = makeRetryRequest("feature-123", { + retry: 1, + message: "normal message", + }); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(201); + }); + + it("does NOT treat retry:'true' (string) as a retry — falls through to normal send", async () => { + const req = makeRetryRequest("feature-123", { + retry: "true", + message: "normal message", + }); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(201); + }); + + it("returns 400 with 'Nothing to retry' when history is empty", async () => { + vi.mocked(db.chatMessage.findMany).mockResolvedValue([] as never); + const req = makeRetryRequest("feature-123", { retry: true }); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/nothing to retry/i); + expect(sendFeatureChatMessage).not.toHaveBeenCalled(); + }); + + it("returns 400 when history has no USER-authored message (only ASSISTANT)", async () => { + vi.mocked(db.chatMessage.findMany).mockResolvedValue([ + makeChatMessage({ role: ChatRole.ASSISTANT, message: "assistant only" }), + ] as never); + const req = makeRetryRequest("feature-123", { retry: true }); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/nothing to retry/i); + }); + + it("returns 429 when feature was updated within the throttle window", async () => { + vi.mocked(db.feature.findUnique).mockResolvedValue( + makeFeature({ updatedAt: new Date(Date.now() - 2_000) }) as never, // 2s ago + ); + const req = makeRetryRequest("feature-123", { retry: true }); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(429); + const data = await res.json(); + expect(data.error).toMatch(/retry too soon/i); + expect(sendFeatureChatMessage).not.toHaveBeenCalled(); + }); + + it("allows retry when feature was updated outside the throttle window", async () => { + vi.mocked(db.feature.findUnique).mockResolvedValue( + makeFeature({ updatedAt: new Date(Date.now() - 60_000) }) as never, + ); + const req = makeRetryRequest("feature-123", { retry: true }); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(201); + expect(sendFeatureChatMessage).toHaveBeenCalledOnce(); + }); + + it("uses the first message when no ASSISTANT message exists in history", async () => { + const firstMsg = "First ever user message"; + vi.mocked(db.chatMessage.findMany).mockResolvedValue([ + makeChatMessage({ role: ChatRole.USER, message: firstMsg }), + ] as never); + sendFeatureChatMessage.mockResolvedValue(makeSendResult(firstMsg)); + + const req = makeRetryRequest("feature-123", { retry: true }); + await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + + expect(sendFeatureChatMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: firstMsg }), + ); + }); + + it("uses the most recent USER message when an ASSISTANT message exists", async () => { + const latestUserMsg = "Latest user question"; + vi.mocked(db.chatMessage.findMany).mockResolvedValue([ + makeChatMessage({ role: ChatRole.USER, message: "first user message" }), + makeChatMessage({ role: ChatRole.ASSISTANT, message: "assistant reply" }), + makeChatMessage({ role: ChatRole.USER, message: latestUserMsg }), + ] as never); + sendFeatureChatMessage.mockResolvedValue(makeSendResult(latestUserMsg)); + + const req = makeRetryRequest("feature-123", { retry: true }); + await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + + expect(sendFeatureChatMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: latestUserMsg }), + ); + }); + + it("returns 404 when feature does not exist", async () => { + vi.mocked(db.feature.findUnique).mockResolvedValue(null); + const req = makeRetryRequest("nonexistent-feature", { retry: true }); + const res = await POST(req, { + params: Promise.resolve({ featureId: "nonexistent-feature" }), + }); + expect(res.status).toBe(404); + }); + + it("IDOR guard: chatMessage.findMany is scoped to the target featureId only", async () => { + const req = makeRetryRequest("feature-123", { retry: true }); + await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + + // All findMany calls for the retry history resolution must be scoped to { featureId } + const findManyCalls = vi.mocked(db.chatMessage.findMany).mock.calls; + expect(findManyCalls.length).toBeGreaterThan(0); + for (const call of findManyCalls) { + // The retry history query must scope to the requested featureId + expect(call[0]?.where?.featureId).toBe("feature-123"); + } + }); + + it("auth: 403 for non-members returned before chat history is read", async () => { + const { resolveWorkspaceAccess } = await import("@/lib/auth/workspace-access"); + vi.mocked(resolveWorkspaceAccess).mockResolvedValueOnce({ + kind: "forbidden", + } as never); + + const req = makeRetryRequest("feature-123", { retry: true }); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(403); + + // The retry history findMany must NOT have been called before auth completes + const historyCall = vi.mocked(db.chatMessage.findMany).mock.calls.find( + (call) => call[0]?.where?.featureId !== undefined, + ); + expect(historyCall).toBeUndefined(); + expect(sendFeatureChatMessage).not.toHaveBeenCalled(); + }); + + it("normal send path still works after adding retry branch", async () => { + const req = new NextRequest( + "http://localhost:3000/api/features/feature-123/chat", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "Normal message" }), + }, + ); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(201); + expect(sendFeatureChatMessage).toHaveBeenCalledWith( + expect.objectContaining({ message: "Normal message" }), + ); + }); + + it("normal send path still returns 400 when no message and no attachments", async () => { + const req = new NextRequest( + "http://localhost:3000/api/features/feature-123/chat", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }, + ); + const res = await POST(req, { params: Promise.resolve({ featureId: "feature-123" }) }); + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toMatch(/message is required/i); + }); +}); diff --git a/src/app/api/features/[featureId]/chat/helpers.ts b/src/app/api/features/[featureId]/chat/helpers.ts new file mode 100644 index 0000000000..0bdd06e8fe --- /dev/null +++ b/src/app/api/features/[featureId]/chat/helpers.ts @@ -0,0 +1,23 @@ +import { ChatRole } from "@/lib/chat"; + +/** + * Resolve which message to resend on retry, mirroring the client-side + * branching in PlanChatView.handleRetry: + * - If any ASSISTANT message exists → resend the most recent USER message. + * - If no ASSISTANT message exists yet → resend the very first message. + * Returns null when there is nothing resendable. + */ +export function resolveRetryMessage( + messages: { role: string; message: string }[], +): string | null { + if (messages.length === 0) return null; + + const hasAssistant = messages.some((m) => m.role === ChatRole.ASSISTANT); + if (hasAssistant) { + // Most recent USER message (history is asc, so reverse search) + const userMsg = [...messages].reverse().find((m) => m.role === ChatRole.USER); + return userMsg?.message ?? null; + } + // No assistant reply yet — resend the very first message + return messages[0].message ?? null; +} diff --git a/src/app/api/features/[featureId]/chat/route.ts b/src/app/api/features/[featureId]/chat/route.ts index d3ad6d03ae..6468e885b7 100644 --- a/src/app/api/features/[featureId]/chat/route.ts +++ b/src/app/api/features/[featureId]/chat/route.ts @@ -11,6 +11,11 @@ import { } from "@/lib/auth/workspace-access"; import { toPublicUser, redactArtifactContentForPublic } from "@/lib/auth/public-redact"; import { appendAnswerRow } from "@/services/canvas-planner-forms"; +import { logger } from "@/lib/logger"; +import { resolveRetryMessage } from "./helpers"; + +/** Minimum seconds between retries on the same feature to prevent abuse. */ +const RETRY_THROTTLE_SECONDS = 10; export const runtime = "nodejs"; export const fetchCache = "force-no-store"; @@ -155,8 +160,95 @@ export async function POST( } const body = await request.json(); - const { message, contextTags = [], sourceWebsocketID, webhook, replyId, history: bodyHistory, isPrototype, attachments = [] as AttachmentRequest[], model, selectedRepositoryIds } = body; + const { + retry, + message, + contextTags = [], + sourceWebsocketID, + webhook, + replyId, + history: bodyHistory, + isPrototype, + attachments = [] as AttachmentRequest[], + model, + selectedRepositoryIds, + } = body; + + // ── Retry branch ──────────────────────────────────────────────────────── + // Checked BEFORE the message/attachments guard so a bare { retry: true } + // body isn't rejected by that guard. + if (retry === true) { + // Reject conflated requests: retry + message in same body is ambiguous. + if (message && message.length > 0) { + return NextResponse.json( + { error: "Cannot combine retry with a message" }, + { status: 400 }, + ); + } + + // Abuse guard: reject rapid retries using the feature's updatedAt as a + // proxy for the last status transition (no new column needed). Each + // retry re-runs the full planning pipeline, so we throttle independently + // of the existing IN_PROGRESS check (which doesn't fire once HALTED). + const featureForRetry = await db.feature.findUnique({ + where: { id: featureId }, + select: { updatedAt: true }, + }); + if (featureForRetry) { + const secondsSinceUpdate = + (Date.now() - featureForRetry.updatedAt.getTime()) / 1000; + if (secondsSinceUpdate < RETRY_THROTTLE_SECONDS) { + logger.warn( + `[features/chat] retry rate-limited: featureId=${featureId} secondsSinceUpdate=${secondsSinceUpdate.toFixed(1)}`, + ); + return NextResponse.json( + { error: "Retry too soon — please wait a moment before retrying" }, + { status: 429 }, + ); + } + } + + // Resolve message from chat history scoped strictly to this feature. + const history = await db.chatMessage.findMany({ + where: { featureId }, + orderBy: { createdAt: "asc" }, + select: { role: true, message: true }, + }); + + const retryMessage = resolveRetryMessage(history); + if (!retryMessage) { + logger.warn( + `[features/chat] retry found nothing resendable: featureId=${featureId}`, + ); + return NextResponse.json( + { error: "Nothing to retry" }, + { status: 400 }, + ); + } + + const { chatMessage, stakworkData } = await sendFeatureChatMessage({ + featureId, + userId: userOrResponse.id, + message: retryMessage, + }); + + const clientMessage = { + ...chatMessage, + createdBy: chatMessage.createdBy || undefined, + contextTags: JSON.parse(chatMessage.contextTags as string) as ContextTag[], + artifacts: chatMessage.artifacts.map((artifact) => ({ + ...artifact, + content: artifact.content as unknown, + })) as Artifact[], + }; + + return NextResponse.json( + { success: true, message: clientMessage, workflow: stakworkData?.data }, + { status: 201 }, + ); + } + // ── Normal send branch ────────────────────────────────────────────────── if (!message && attachments.length === 0) { return NextResponse.json({ error: "Message is required" }, { status: 400 }); }