From efb0172faa23c3610ffe1c3cb490e5462a041fb2 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 25 May 2026 13:49:36 -0500 Subject: [PATCH 01/15] fix(chat-workflow): persist the assistant message after a successful run (#609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(chat-workflow): persist the assistant message after a successful run Closes the silent-data-loss gap that the open-agents → recoup-api cutover introduced: the chat workflow streamed the final assistant message to the client over SSE but never wrote it to `chat_messages`, so a page refresh after a successful exchange wiped the reply. Changes: - New `lib/chat/persistAssistantMessage.ts` step (mirrors open-agents' `app/workflows/chat-post-finish.ts` helper of the same name). Fire-and-forget upsert + chat `updated_at` touch on fresh inserts; idempotent on workflow replay; never throws. - `runAgentStep` now wires an `onFinish` callback into `toUIMessageStream` to capture the assembled assistant message, and returns it alongside `finishReason` as part of the new `RunAgentStepResult` type. - `runAgentWorkflow` calls `persistAssistantMessage(chatId, responseMessage)` after a successful `runAgentStep` (in the try block, BEFORE the existing `clearChatActiveStream` + `closeChatStream` finally). On throw, no message is persisted (nothing was generated); cleanup still runs. Tests: - `persistAssistantMessage.test.ts` — 6 cases (insert + touch, duplicate skip, wrong-role guard, DB-error swallow, exception swallow, role assertion). - `runAgentStep.test.ts` — 3 new cases (onFinish wired, captured responseMessage returned, undefined when onFinish never fires). - `runAgentWorkflow.test.ts` — 3 new cases (persist called on success, not called when responseMessage undefined, not called on throw while cleanup still runs). Full suite: 3159 → 3171 passing. Tracking: #605 (Tier 1, item 1) Co-Authored-By: Claude Opus 4.7 (1M context) * fix(chat-workflow): loosen AssistantMessage type to accept UIMessage The over-strict `& Record` intersection on the outer shape required an index signature that `UIMessage` from `ai` doesn't carry, so wiring runAgentStep's UIMessage return into persistAssistantMessage failed the Vercel build with TS2345. Switched to a minimal duck-typed shape (id/role/parts) — matches both UIMessage and the in-test fixtures structurally. The `chat_messages.parts` column is jsonb so persistence doesn't care about the part subtypes. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(chat-workflow): mark persistAssistantMessage as a "use step" Vercel Workflow blocks `fetch()` in workflow-body code; the Supabase JS client uses fetch under the hood, so `upsertChatMessage` inside `persistAssistantMessage` failed at runtime with: Global "fetch" is unavailable in workflow functions. Use the "fetch" step function from "workflow" to make HTTP requests. `"use step"` directive moves the function into step-context where fetch is legal. Mirrors open-agents' `persistAssistantMessage` step in `app/workflows/chat-post-finish.ts` (which carries the same directive). Caught via runtime log inspection on the PR preview before merge. Co-Authored-By: Claude Opus 4.7 (1M context) * debug(chat-workflow): TEMP diagnostic logs in persistAssistantMessage Hard-refresh of a chat that ran on PR #609's preview showed the assistant message NOT in chat_messages — meaning silence in the existing error log is NOT the same as "row was written." Adding explicit logs at entry, after upsert, and after updateChat so the runtime tail can disambiguate: - "skip: not assistant role" branch - upsert result shape (ok / isDuplicate / rowPresent) - "persisted + touched chat" success line Will be reverted before merge. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(chat-workflow): pass generateMessageId to toUIMessageStream Diagnostic logs revealed every assistant message was arriving at persistAssistantMessage with `messageId: ''` — the AI SDK's default when `generateMessageId` isn't provided. Supabase's `chat_messages.id` PK then treated every workflow run after the first as a duplicate (`onConflict: "id", ignoreDuplicates: true` → isDuplicate: true, rowPresent: false) so no assistant row landed. Generating a stable id once per `runAgentStep` invocation via `generateId()` from `ai`, then plumbing it into `result.toUIMessageStream({ generateMessageId: () => ... })` so: - the streamed chunks carry the id (existing wire format), - `onFinish.responseMessage.id` carries the id, - `persistAssistantMessage` sees a real id and the upsert lands a fresh row. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(chat-workflow): move assistantMessageId generation to workflow body Match open-agents' structural pattern instead of generating the id inline inside runAgentStep. Rationale (which I should have applied the first time, per review feedback): 1. **Multi-step support** — when the Tier 2 outer loop lands, each runAgentStep call needs the SAME assistantMessageId so chunks accumulate under one chat_messages row instead of fragmenting per tool-call iteration. Generating inside the step gives every call a fresh id; generating in the workflow body and threading through makes the upgrade path one-line. 2. **Resume-after-tool-call** — open-agents reuses the latest message's id when `latestMessage.role === "assistant"` so the in-progress assistant turn re-attaches instead of starting a new row. Ported now to avoid a future surprise. 3. **Determinism** — `generateId()` is non-deterministic; the workflow body's WDK constraint forbids that. Wrapping it in a `"use step"` (`generateAssistantMessageId.ts`) makes the value durable across workflow replays. Changes: - New `app/lib/workflows/generateAssistantMessageId.ts` step (mirrors open-agents' local `generateId` step in `apps/web/app/workflows/chat.ts`). - `RunAgentStepInput` gains `assistantMessageId: string`. The inline `generateId()` call is removed. - `runAgentWorkflow` reads `latestMessage`; reuses its id when it's an assistant message, otherwise awaits the step. Threads the result into `runAgentStep`. - Tests: 2 new for the step, 1 new for runAgentStep forwarding, 2 new for the resume-aware branch in runAgentWorkflow. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(chat-workflow): revert temp diagnostic logs in persistAssistantMessage Logs served their purpose — surfaced the empty-messageId bug (fixed in 8974a37e by threading a workflow-generated id through toUIMessageStream's generateMessageId). UI verification on the PR preview confirmed the assistant row now persists. Reverting the debug logs so production runtime stays quiet. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(chat-workflow): bump last_assistant_message_at on persist (unread badge parity) Match open-agents' `updateChatAssistantActivity` which sets BOTH `updated_at` and `last_assistant_message_at` to the same timestamp. The recoup-api sidebar's `hasUnread` badge is computed in `lib/sessions/chats/getChatSummaries.ts` as `lastAssistantMessageAt > lastReadAt`, mirroring open-agents' identical query in `apps/web/lib/db/sessions.ts:201`. Without this column bump, an assistant message persisted by the workflow streams to the client, lands in `chat_messages`, but never lights up the unread badge for any other tabs/devices the user has open. The column already exists in `api`'s `chats` schema and `updateChat` already accepts it via `ChatMutableFields` — this is purely a "we forgot to set it" fix. Added two new unit tests: - bumps `last_assistant_message_at` on fresh insert - uses the same timestamp for both columns Co-Authored-By: Claude Opus 4.7 (1M context) * chore: format-fix workflow files (prettier --write) Resolves the format/lint CI failures on df312dbc — purely whitespace collapsing per the repo's prettier config (no behavior change). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../generateAssistantMessageId.test.ts | 20 +++ .../workflows/__tests__/runAgentStep.test.ts | 83 ++++++++++- .../__tests__/runAgentWorkflow.test.ts | 102 ++++++++++++- .../workflows/generateAssistantMessageId.ts | 19 +++ app/lib/workflows/runAgentStep.ts | 53 ++++++- app/lib/workflows/runAgentWorkflow.ts | 27 ++++ .../__tests__/persistAssistantMessage.test.ts | 140 ++++++++++++++++++ lib/chat/persistAssistantMessage.ts | 73 +++++++++ 8 files changed, 506 insertions(+), 11 deletions(-) create mode 100644 app/lib/workflows/__tests__/generateAssistantMessageId.test.ts create mode 100644 app/lib/workflows/generateAssistantMessageId.ts create mode 100644 lib/chat/__tests__/persistAssistantMessage.test.ts create mode 100644 lib/chat/persistAssistantMessage.ts diff --git a/app/lib/workflows/__tests__/generateAssistantMessageId.test.ts b/app/lib/workflows/__tests__/generateAssistantMessageId.test.ts new file mode 100644 index 000000000..c83cebfb1 --- /dev/null +++ b/app/lib/workflows/__tests__/generateAssistantMessageId.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from "vitest"; +import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; + +vi.mock("ai", async () => { + const actual = await vi.importActual("ai"); + return { ...actual, generateId: vi.fn(() => "generated-by-mock") }; +}); + +describe("generateAssistantMessageId", () => { + it("returns the value from ai's generateId()", async () => { + const id = await generateAssistantMessageId(); + expect(id).toBe("generated-by-mock"); + }); + + it("returns a string", async () => { + const id = await generateAssistantMessageId(); + expect(typeof id).toBe("string"); + expect(id.length).toBeGreaterThan(0); + }); +}); diff --git a/app/lib/workflows/__tests__/runAgentStep.test.ts b/app/lib/workflows/__tests__/runAgentStep.test.ts index b2e90475b..cfb101877 100644 --- a/app/lib/workflows/__tests__/runAgentStep.test.ts +++ b/app/lib/workflows/__tests__/runAgentStep.test.ts @@ -12,15 +12,28 @@ vi.mock("@ai-sdk/gateway", () => ({ gateway: vi.fn((modelId: string) => ({ modelId, __mock: "gateway" })), })); -function makeStreamResult(opts?: { metadataCalls?: Array }) { +function makeStreamResult(opts?: { + metadataCalls?: Array; + onFinishCalls?: Array; + emittedResponseMessage?: unknown; +}) { const calls = opts?.metadataCalls ?? []; + const onFinishCalls = opts?.onFinishCalls ?? []; return { - toUIMessageStream: vi.fn((streamOpts: { messageMetadata?: unknown }) => { - // Capture the callback so tests can inspect it + toUIMessageStream: vi.fn((streamOpts: { messageMetadata?: unknown; onFinish?: unknown }) => { + // Capture the callbacks so tests can inspect (and invoke) them calls.push(streamOpts.messageMetadata); + onFinishCalls.push(streamOpts.onFinish); return (async function* () { yield { type: "start" }; yield { type: "finish" }; + // Mirror the AI SDK contract: onFinish fires after the + // generator yields its last chunk with the assembled message. + if (typeof streamOpts.onFinish === "function" && opts?.emittedResponseMessage) { + (streamOpts.onFinish as (a: { responseMessage: unknown }) => void)({ + responseMessage: opts.emittedResponseMessage, + }); + } })(); }), finishReason: Promise.resolve("stop"), @@ -49,6 +62,7 @@ const baseInput = { agentContext: { sandbox: { state: { type: "vercel" }, workingDirectory: "/sandbox/mono" }, }, + assistantMessageId: "asst-test-id", }; describe("runAgentStep", () => { @@ -174,4 +188,67 @@ describe("runAgentStep", () => { expect(cb({ part: { type: "text-delta" } })).toBeUndefined(); expect(cb({ part: { type: "start" } })).toBeUndefined(); }); + + it("wires an onFinish callback into toUIMessageStream", async () => { + const onFinishCalls: unknown[] = []; + vi.mocked(streamText).mockReturnValue(makeStreamResult({ onFinishCalls }) as never); + const { stream } = makeWritable(); + + await runAgentStep({ ...baseInput, writable: stream } as never); + + expect(onFinishCalls).toHaveLength(1); + expect(typeof onFinishCalls[0]).toBe("function"); + }); + + it("returns the responseMessage captured from onFinish", async () => { + const emittedResponseMessage = { + id: "assistant-msg-1", + role: "assistant", + parts: [{ type: "text", text: "Hello" }], + }; + vi.mocked(streamText).mockReturnValue(makeStreamResult({ emittedResponseMessage }) as never); + const { stream } = makeWritable(); + + const result = await runAgentStep({ ...baseInput, writable: stream } as never); + + expect(result.responseMessage).toEqual(emittedResponseMessage); + expect(result.finishReason).toBe("stop"); + }); + + it("returns responseMessage: undefined when onFinish never fires", async () => { + // Default makeStreamResult — no emittedResponseMessage, so onFinish is wired but never invoked + vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); + const { stream } = makeWritable(); + + const result = await runAgentStep({ ...baseInput, writable: stream } as never); + + expect(result.responseMessage).toBeUndefined(); + expect(result.finishReason).toBe("stop"); + }); + + it("forwards input.assistantMessageId into toUIMessageStream's generateMessageId", async () => { + const generateMessageIdCalls: unknown[] = []; + const streamResult = makeStreamResult(); + // Spy on the options passed to toUIMessageStream to grab the generateMessageId fn. + const originalToUIMessageStream = streamResult.toUIMessageStream; + streamResult.toUIMessageStream = vi.fn((streamOpts: { generateMessageId?: unknown }) => { + generateMessageIdCalls.push(streamOpts.generateMessageId); + return (originalToUIMessageStream as unknown as (o: unknown) => AsyncGenerator)( + streamOpts, + ); + }) as never; + vi.mocked(streamText).mockReturnValue(streamResult as never); + const { stream } = makeWritable(); + + await runAgentStep({ + ...baseInput, + writable: stream, + assistantMessageId: "asst-from-workflow-xyz", + } as never); + + expect(generateMessageIdCalls).toHaveLength(1); + const gen = generateMessageIdCalls[0] as () => string; + expect(typeof gen).toBe("function"); + expect(gen()).toBe("asst-from-workflow-xyz"); + }); }); diff --git a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts index d061abbce..3e59ffc2d 100644 --- a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts +++ b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts @@ -3,6 +3,8 @@ import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; import { runAgentStep } from "@/app/lib/workflows/runAgentStep"; import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream"; import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; +import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; +import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; vi.mock("@/app/lib/workflows/runAgentStep", () => ({ runAgentStep: vi.fn(), @@ -13,6 +15,12 @@ vi.mock("@/lib/chat/clearChatActiveStream", () => ({ vi.mock("@/app/lib/workflows/closeChatStream", () => ({ closeChatStream: vi.fn(), })); +vi.mock("@/app/lib/workflows/generateAssistantMessageId", () => ({ + generateAssistantMessageId: vi.fn(), +})); +vi.mock("@/lib/chat/persistAssistantMessage", () => ({ + persistAssistantMessage: vi.fn(), +})); // Captured writable stub so tests can assert closeChatStream got the // same instance the workflow body holds. const writableStub = new WritableStream(); @@ -26,7 +34,10 @@ vi.mock("workflow", () => ({ })), })); -beforeEach(() => vi.clearAllMocks()); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(generateAssistantMessageId).mockResolvedValue("asst-fresh-id"); +}); const baseInput = { messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] } as never], @@ -40,7 +51,10 @@ const baseInput = { describe("runAgentWorkflow", () => { it("clears active_stream_id after a successful run, using the workflow's own runId", async () => { - vi.mocked(runAgentStep).mockResolvedValue({ finishReason: "stop" }); + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: undefined, + }); await runAgentWorkflow(baseInput); @@ -58,7 +72,10 @@ describe("runAgentWorkflow", () => { }); it("explicitly closes the chat writable after a successful run so SSE ends promptly", async () => { - vi.mocked(runAgentStep).mockResolvedValue({ finishReason: "stop" }); + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: undefined, + }); await runAgentWorkflow(baseInput); @@ -74,4 +91,83 @@ describe("runAgentWorkflow", () => { expect(closeChatStream).toHaveBeenCalledTimes(1); expect(closeChatStream).toHaveBeenCalledWith(writableStub); }); + + it("persists the assistant message when runAgentStep returns one", async () => { + const responseMessage = { + id: "assistant-msg-xyz", + role: "assistant", + parts: [{ type: "text", text: "Hello!" }], + }; + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: responseMessage as never, + }); + + await runAgentWorkflow(baseInput); + + expect(persistAssistantMessage).toHaveBeenCalledTimes(1); + expect(persistAssistantMessage).toHaveBeenCalledWith("chat-1", responseMessage); + }); + + it("does NOT call persistAssistantMessage when runAgentStep returns no responseMessage", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: undefined, + }); + + await runAgentWorkflow(baseInput); + + expect(persistAssistantMessage).not.toHaveBeenCalled(); + }); + + it("does NOT call persistAssistantMessage when runAgentStep throws (no message to persist)", async () => { + vi.mocked(runAgentStep).mockRejectedValue(new Error("model exploded")); + + await expect(runAgentWorkflow(baseInput)).rejects.toThrow("model exploded"); + + expect(persistAssistantMessage).not.toHaveBeenCalled(); + // But cleanup steps still run via the try/finally + expect(clearChatActiveStream).toHaveBeenCalledTimes(1); + expect(closeChatStream).toHaveBeenCalledTimes(1); + }); + + it("generates a fresh assistantMessageId via the step and forwards it to runAgentStep", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: undefined, + }); + + await runAgentWorkflow(baseInput); + + expect(generateAssistantMessageId).toHaveBeenCalledTimes(1); + expect(runAgentStep).toHaveBeenCalledWith( + expect.objectContaining({ assistantMessageId: "asst-fresh-id" }), + ); + }); + + it("reuses the latest assistant message id when resuming a tool-call turn (no fresh generation)", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: undefined, + }); + + const resumingInput = { + ...baseInput, + messages: [ + { id: "m1", role: "user", parts: [{ type: "text", text: "go" }] }, + { + id: "asst-in-progress", + role: "assistant", + parts: [{ type: "text", text: "thinking..." }], + }, + ] as never, + }; + + await runAgentWorkflow(resumingInput); + + expect(generateAssistantMessageId).not.toHaveBeenCalled(); + expect(runAgentStep).toHaveBeenCalledWith( + expect.objectContaining({ assistantMessageId: "asst-in-progress" }), + ); + }); }); diff --git a/app/lib/workflows/generateAssistantMessageId.ts b/app/lib/workflows/generateAssistantMessageId.ts new file mode 100644 index 000000000..d33d0ce65 --- /dev/null +++ b/app/lib/workflows/generateAssistantMessageId.ts @@ -0,0 +1,19 @@ +import { generateId } from "ai"; + +/** + * Vercel Workflow `"use step"` that returns a fresh message id. + * + * Wrapped as a step rather than inlined into the workflow body + * because `generateId()` is non-deterministic — calling it directly + * from workflow code would break the durable-replay contract (a + * replay would generate a *different* id and diverge from the + * captured event log). As a step, the result is captured in the + * event log once and reused on every replay. + * + * Mirrors open-agents' `generateId` step in + * `apps/web/app/workflows/chat.ts`. + */ +export async function generateAssistantMessageId(): Promise { + "use step"; + return generateId(); +} diff --git a/app/lib/workflows/runAgentStep.ts b/app/lib/workflows/runAgentStep.ts index 7ed847d5d..0520bb20a 100644 --- a/app/lib/workflows/runAgentStep.ts +++ b/app/lib/workflows/runAgentStep.ts @@ -22,6 +22,35 @@ export type RunAgentStepInput = { * is added to `experimental_context` right before each model call. */ agentContext: DurableAgentContext; + /** + * Stable id to assign to the assistant message produced by this + * step. Generated once in `runAgentWorkflow` so: + * + * - Every chunk in this step's `toUIMessageStream` carries the + * same id (the AI SDK threads it through). + * - Future multi-step iterations of the agent loop reuse the + * same id so a single conversational reply is one row in + * `chat_messages` rather than fragmenting per tool-call cycle. + * - Resume after tool-call interaction reattaches to the in- + * progress assistant message rather than spawning a new one. + * + * Mirrors open-agents' `runAgentStep(messages, originalMessages, + * messageId, ...)` signature in + * `apps/web/app/workflows/chat.ts`. + */ + assistantMessageId: string; +}; + +export type RunAgentStepResult = { + finishReason: string; + /** + * The assembled assistant message captured from + * `toUIMessageStream`'s `onFinish` callback. `undefined` if the + * stream finished without emitting one (e.g. an error path that + * short-circuits before any chunks land). Used by `runAgentWorkflow` + * to persist the final message to `chat_messages`. + */ + responseMessage: UIMessage | undefined; }; /** @@ -38,9 +67,11 @@ export type RunAgentStepInput = { * of the tool surface ports in a follow-up PR. * * @param input - Messages + selected model + writable stream + agent context. - * @returns finishReason from the model run. + * @returns `finishReason` from the model run plus the assembled + * `responseMessage` (when one was emitted) so the caller can + * persist it. */ -export async function runAgentStep(input: RunAgentStepInput): Promise<{ finishReason: string }> { +export async function runAgentStep(input: RunAgentStepInput): Promise { "use step"; console.log("[runAgentStep] start", { @@ -91,6 +122,12 @@ export async function runAgentStep(input: RunAgentStepInput): Promise<{ finishRe }), }); + // Capture the assembled assistant message via `onFinish` so the + // caller can persist it. Mirrors open-agents' `runAgentStep` which + // stashes `finishedResponseMessage` into a closure-scoped variable + // for the outer workflow body to forward into `persistAssistantMessage`. + let responseMessage: UIMessage | undefined; + // Acquire the writer once and release in `finally` so a thrown chunk // doesn't leak the lock. const writer = input.writable.getWriter(); @@ -100,7 +137,13 @@ export async function runAgentStep(input: RunAgentStepInput): Promise<{ finishRe // shape so sandbox.recoupable.com sees the same metadata when cut // over to api's /api/chat/workflow. const messageMetadata = buildMessageMetadataCallback({ modelId: input.modelId }); - for await (const part of result.toUIMessageStream({ messageMetadata })) { + for await (const part of result.toUIMessageStream({ + messageMetadata, + generateMessageId: () => input.assistantMessageId, + onFinish: ({ responseMessage: finishedResponseMessage }) => { + responseMessage = finishedResponseMessage; + }, + })) { await writer.write(part); } } finally { @@ -108,6 +151,6 @@ export async function runAgentStep(input: RunAgentStepInput): Promise<{ finishRe } const finishReason = await result.finishReason; - console.log("[runAgentStep] finish", { finishReason }); - return { finishReason }; + console.log("[runAgentStep] finish", { finishReason, hasResponseMessage: !!responseMessage }); + return { finishReason, responseMessage }; } diff --git a/app/lib/workflows/runAgentWorkflow.ts b/app/lib/workflows/runAgentWorkflow.ts index c9e5fdcd9..e4e628e96 100644 --- a/app/lib/workflows/runAgentWorkflow.ts +++ b/app/lib/workflows/runAgentWorkflow.ts @@ -1,8 +1,10 @@ import { getWorkflowMetadata, getWritable } from "workflow"; import type { UIMessage, UIMessageChunk } from "ai"; import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; +import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { runAgentStep } from "@/app/lib/workflows/runAgentStep"; import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream"; +import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; import type { DurableAgentContext } from "@/lib/agent/tools/AgentContext"; export type RunAgentWorkflowInput = { @@ -48,14 +50,39 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise(); + // Pick or generate a stable id for the assistant message. If the + // last message in the conversation is already an assistant message + // (we're resuming an in-progress turn after a tool-call interaction) + // reuse its id so chunks append to the same `chat_messages` row. + // Otherwise generate a fresh id once via a `"use step"` so the + // value is durable across workflow replays. Mirrors open-agents' + // pattern in `apps/web/app/workflows/chat.ts` where the id is + // generated in the workflow body and threaded into every + // `runAgentStep` call. + const latestMessage = input.messages.at(-1); + const assistantMessageId = + latestMessage?.role === "assistant" ? latestMessage.id : await generateAssistantMessageId(); + try { const result = await runAgentStep({ messages: input.messages, modelId: input.modelId, writable, agentContext: input.agentContext, + assistantMessageId, }); console.log("[runAgentWorkflow] finish", { finishReason: result.finishReason }); + + // Persist the final assistant message to `chat_messages` so a page + // refresh after the stream completes still shows the reply. Without + // this, the recoup-api cutover silently drops assistant responses — + // they stream to the client over SSE but never land in the DB. + // `persistAssistantMessage` is fire-and-forget by contract; it + // swallows its own errors so a transient DB failure here doesn't + // mark the workflow run failed. + if (result.responseMessage) { + await persistAssistantMessage(input.chatId, result.responseMessage); + } } finally { // Run two cleanup steps in parallel: // 1) `clearChatActiveStream` — CAS-gated DB clear of the chat's diff --git a/lib/chat/__tests__/persistAssistantMessage.test.ts b/lib/chat/__tests__/persistAssistantMessage.test.ts new file mode 100644 index 000000000..fc6def35e --- /dev/null +++ b/lib/chat/__tests__/persistAssistantMessage.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; +import { upsertChatMessage } from "@/lib/supabase/chat_messages/upsertChatMessage"; +import { updateChat } from "@/lib/supabase/chats/updateChat"; + +vi.mock("@/lib/supabase/chat_messages/upsertChatMessage", () => ({ + upsertChatMessage: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/updateChat", () => ({ + updateChat: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(updateChat).mockResolvedValue({ + ok: true, + rowsUpdated: 1, + row: null, + }); +}); + +const CHAT_ID = "11111111-1111-1111-1111-111111111111"; +const ASSISTANT_ID = "msg_abc"; + +function buildAssistantMessage(overrides: Record = {}) { + return { + id: ASSISTANT_ID, + role: "assistant", + parts: [{ type: "text", text: "Hello!" }], + ...overrides, + }; +} + +describe("persistAssistantMessage", () => { + it("upserts the assistant message row with role 'assistant'", async () => { + vi.mocked(upsertChatMessage).mockResolvedValue({ + ok: true, + row: { id: ASSISTANT_ID } as never, + isDuplicate: false, + }); + + await persistAssistantMessage(CHAT_ID, buildAssistantMessage()); + + expect(upsertChatMessage).toHaveBeenCalledWith( + expect.objectContaining({ + id: ASSISTANT_ID, + chat_id: CHAT_ID, + role: "assistant", + }), + ); + }); + + it("touches updated_at on the chat row on a fresh insert", async () => { + vi.mocked(upsertChatMessage).mockResolvedValue({ + ok: true, + row: { id: ASSISTANT_ID } as never, + isDuplicate: false, + }); + + await persistAssistantMessage(CHAT_ID, buildAssistantMessage()); + + expect(updateChat).toHaveBeenCalledWith( + { id: CHAT_ID }, + expect.objectContaining({ updated_at: expect.any(String) }), + ); + }); + + it("bumps last_assistant_message_at on a fresh insert (drives sidebar unread badge)", async () => { + vi.mocked(upsertChatMessage).mockResolvedValue({ + ok: true, + row: { id: ASSISTANT_ID } as never, + isDuplicate: false, + }); + + await persistAssistantMessage(CHAT_ID, buildAssistantMessage()); + + expect(updateChat).toHaveBeenCalledWith( + { id: CHAT_ID }, + expect.objectContaining({ + last_assistant_message_at: expect.any(String), + }), + ); + }); + + it("uses the same timestamp for updated_at and last_assistant_message_at (matches open-agents)", async () => { + vi.mocked(upsertChatMessage).mockResolvedValue({ + ok: true, + row: { id: ASSISTANT_ID } as never, + isDuplicate: false, + }); + + await persistAssistantMessage(CHAT_ID, buildAssistantMessage()); + + const updateArgs = vi.mocked(updateChat).mock.calls[0]?.[1] as { + updated_at?: string; + last_assistant_message_at?: string; + }; + expect(updateArgs.updated_at).toBeDefined(); + expect(updateArgs.last_assistant_message_at).toBe(updateArgs.updated_at); + }); + + it("does NOT touch updated_at on duplicate (workflow replay)", async () => { + vi.mocked(upsertChatMessage).mockResolvedValue({ + ok: true, + row: null, + isDuplicate: true, + }); + + await persistAssistantMessage(CHAT_ID, buildAssistantMessage()); + + expect(updateChat).not.toHaveBeenCalled(); + }); + + it("silently no-ops when the message role is not 'assistant' (guard against caller mistakes)", async () => { + await persistAssistantMessage(CHAT_ID, buildAssistantMessage({ role: "user" })); + + expect(upsertChatMessage).not.toHaveBeenCalled(); + expect(updateChat).not.toHaveBeenCalled(); + }); + + it("silently no-ops when the upsert reports a DB error (fire-and-forget contract)", async () => { + vi.mocked(upsertChatMessage).mockResolvedValue({ + ok: false, + error: "transient db error", + }); + + await expect( + persistAssistantMessage(CHAT_ID, buildAssistantMessage()), + ).resolves.toBeUndefined(); + expect(updateChat).not.toHaveBeenCalled(); + }); + + it("swallows unexpected exceptions (must not bubble up)", async () => { + vi.mocked(upsertChatMessage).mockRejectedValue(new Error("boom")); + + await expect( + persistAssistantMessage(CHAT_ID, buildAssistantMessage()), + ).resolves.toBeUndefined(); + }); +}); diff --git a/lib/chat/persistAssistantMessage.ts b/lib/chat/persistAssistantMessage.ts new file mode 100644 index 000000000..13f93c27b --- /dev/null +++ b/lib/chat/persistAssistantMessage.ts @@ -0,0 +1,73 @@ +import { upsertChatMessage } from "@/lib/supabase/chat_messages/upsertChatMessage"; +import { updateChat } from "@/lib/supabase/chats/updateChat"; + +/** + * Minimal duck-type shape we read off the assistant message. Both AI + * SDK's `UIMessage` and the in-test fixtures structurally satisfy it. + * Kept intentionally loose because the row we write to + * `chat_messages.parts` is `jsonb` — Supabase persists whatever the + * message looks like. + */ +type AssistantMessage = { + id: string; + role: string; + parts: ReadonlyArray; +}; + +/** + * Fire-and-forget persistence of the final assistant message at the + * end of a chat-workflow run. Mirrors open-agents' + * `persistAssistantMessage` step in + * `apps/web/app/workflows/chat-post-finish.ts` and closes the + * silent-data-loss gap the recoup-api cutover introduced — without + * this call the assistant response is streamed to the client but + * never written to `chat_messages`, so a page refresh after the + * stream completes wipes the message. + * + * Uses `upsertChatMessage(... { onConflict: "id", ignoreDuplicates })` + * so a workflow that's restarted (replay, recovery) doesn't + * double-insert. On a fresh insert we also bump + * `last_assistant_message_at` (drives the sidebar `hasUnread` badge + * in `getChatSummaries` — `lastAssistantMessageAt > lastReadAt`) and + * touch `updated_at` so the sidebar sort surfaces the chat. Matches + * open-agents' `updateChatAssistantActivity` which sets both columns + * to the same timestamp. + * + * Title generation lives in `persistLatestUserMessage` (the first + * user message is canonical for chat titles) — this function + * deliberately does NOT update the chat title. + * + * Errors are caught and logged. Contract is "schedule it and forget" + * — never block the workflow or surface failures to the UI. + * + * @param chatId - Target chat row. + * @param message - The assembled assistant message (typically from + * `toUIMessageStream`'s `onFinish.responseMessage`). + */ +export async function persistAssistantMessage( + chatId: string, + message: AssistantMessage, +): Promise { + "use step"; + try { + if (!message || message.role !== "assistant") return; + + const inserted = await upsertChatMessage({ + id: message.id, + chat_id: chatId, + role: "assistant", + parts: message as never, + }); + + if (!inserted.ok) return; + if (inserted.isDuplicate || inserted.row === null) return; + + const activityAt = new Date().toISOString(); + await updateChat( + { id: chatId }, + { updated_at: activityAt, last_assistant_message_at: activityAt }, + ); + } catch (error) { + console.error("[persistAssistantMessage] error:", error); + } +} From d56aa318158bd826e871e086019304269916793d Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 25 May 2026 16:57:35 -0500 Subject: [PATCH 02/15] feat(credits): charge credits per chat turn (atomic wallet debit + audit) (#612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(credits): port computeCreditsDeductedCents + estimateModelUsageCost from open-agents First piece of the chat-workflow billing path. Ports the per-turn cost math from open-agents' `apps/web/lib/credits/compute-credits-deducted-cents.ts` and `apps/web/lib/models.ts:estimateModelUsageCost` so the same billing logic runs on both sides during the cutover. Resolution order matches open-agents exactly: 1. gateway-reported cost on responseMessage.metadata.totalMessageCost (the same number the chat UI shows next to the response) 2. token-based estimate against the model catalog's cost entry 3. 1c floor when no pricing is available — so a successful turn never lands as a free run Three new files (per api's one-exported-function-per-file SRP): - AvailableModelCost.ts — shape mirroring open-agents' richer cost type (input, output, cache_read, context_over_200k) so the same estimator runs against either catalog - estimateModelUsageCost.ts — token-based USD estimator including the 200k+ context tier swap and cache_read pricing - computeCreditsDeductedCents.ts — top-level orchestrator (gateway cost → token estimate → 1c floor) using api's getAvailableModels directly (no HTTP self-fetch like open-agents does) Test coverage: 27 new unit tests across the two test files. All pricing edge cases covered (NaN/Infinity/negative gateway cost, cached-tokens- exceeding-input clamping, context_over_200k tier swap with partial overrides, catalog miss / fetch failure fallbacks). Unblocks step 3 (deductCreditsWithAudit TS wrapper) of the chat credits gap in recoupable/api#605. Full suite: 3191 → 3205 passing. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(credits): charge credits per chat turn (atomic wallet debit + audit) Closes the silent revenue-loss gap tracked in #605: every successful chat workflow turn now debits the account's wallet AND records a usage_events audit row, in a single atomic transaction. End-to-end flow: 1. runAgentStep's onFinish captures responseMessage.metadata ({totalMessageCost, totalMessageUsage}) — same number the chat UI shows next to the response. 2. runAgentWorkflow calls recordChatUsage(accountId, modelId, message) after persistAssistantMessage. 3. recordChatUsage → computeCreditsDeductedCents (gateway cost OR token estimate OR 1c floor) → deductCreditsWithAudit (supabase.rpc'deduct_credits_with_audit'). 4. The Postgres function (recoupable/database#26) runs the wallet UPDATE and the usage_events INSERT in one implicit transaction — either both land or neither does. Matches open-agents' db.transaction(...) atomicity guarantee. Threads accountId through RunAgentWorkflowInput from validateChatWorkflow (auth-derived; never trusted from the request body). New files: - lib/supabase/credits_usage/deductCreditsWithAudit.ts (+ tests) Thin supabase.rpc wrapper; fire-and-forget (returns ok/error instead of throwing). Lives in lib/supabase/ per CLAUDE.md SRP. - app/lib/workflows/recordChatUsage.ts (+ tests) "use step" function that ties the two together with entry/skip/ success/error logs and graceful handling of missing metadata, catalog failures, and RPC errors. Updated: - app/lib/workflows/runAgentWorkflow.ts + accountId field on RunAgentWorkflowInput + recordChatUsage call after successful persistAssistantMessage - lib/chat/handleChatWorkflowStream.ts + passes validated.accountId into start(runAgentWorkflow, ...) - app/lib/workflows/__tests__/runAgentWorkflow.test.ts + 3 new tests (records on success, skips when no responseMessage, skips when runAgentStep throws) TDD: each new file went red → minimal impl → green. Suite: 3205 → 3220 passing. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(credits): use flat interface for DeductCreditsWithAuditResult Next.js 16's type checker wasn't narrowing the discriminated union `{ ok: true } | { ok: false; error: string }` through `if (!result.ok)`, breaking the production build at `recordChatUsage.ts:90`. Vitest's own type config tolerated it, so this only surfaced on the preview deploy. Flat interface with optional `error?: string` avoids the narrowing requirement entirely — caller can read `result.error` directly when `result.ok` is false. Slight type-safety loss (compiler doesn't enforce that `error` is present when ok is false) is worth the build stability. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(credits): regenerate supabase RPC type for deduct_credits_with_audit The previous deploy failed because: 1. `types/database.types.ts` was stale — it didn't include the `deduct_credits_with_audit` RPC that landed in recoupable/database#26 (and was manually applied via the MCP after Supabase's GitHub App 502'd post-merge). Without that entry, `supabase.rpc("deduct_credits_with_audit", ...)` failed Next.js's stricter type check. 2. Even with the entry, the typed `Args.p_event: Json` couldn't accept our `DeductCreditsAuditEvent` interface directly — TS doesn't infer interface → index-signature assignment. Fixes: - Added the `deduct_credits_with_audit` entry to the Functions block of types/database.types.ts (matches the upstream regen via mcp__plugin_supabase_supabase__generate_typescript_types). - Cast `params.event as unknown as Json` at the supabase boundary in deductCreditsWithAudit.ts. The runtime payload is unchanged and the interface keeps its strong typing for callers. Verified locally: `pnpm exec tsc --noEmit` shows no errors in any file this PR touches. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(credits): consolidate chat-workflow billing into handleChatCredits (DRY) Addresses the user's PR review: my new files duplicated existing infrastructure. Consolidates everything into the existing pattern (handleChatCredits → getCreditUsage + recordCreditDeduction) so chat workflow billing uses the SAME orchestrator that the streaming chat path (handleChatStream) already uses. Changes: 1. lib/credits/getCreditUsage.ts - Added optional `gatewayCostUsd?: number` parameter - When positive, returns it directly (skips catalog lookup) - Otherwise existing token-math path is unchanged (backwards compat) 2. lib/credits/handleChatCredits.ts - Added `gatewayCostUsd?: number` (threaded to getCreditUsage) - Added `source?: "web" | "api"` (defaults to "web" for backwards compat; chat workflow passes "api" so admin dashboards can distinguish surface in spend rollups) 3. lib/credits/recordCreditDeduction.ts - Switched from `deductCredits + insertUsageEvent` (two separate Supabase calls, non-atomic — could leave wallet/meter drifted on partial failure) to the single `deduct_credits_with_audit` RPC. - Now atomic for ALL callers (chat workflow + research handlers), not just the new chat-workflow path. - Return shape simplified: `{ success: boolean }` instead of `{ success, newBalance }` (no caller was reading newBalance). 4. app/lib/workflows/runAgentWorkflow.ts - Imports handleChatCredits instead of recordChatUsage. - Reads gatewayCostUsd + token counts from responseMessage.metadata.{totalMessageCost, totalMessageUsage}. 5. Deleted (consolidated into existing infrastructure): - app/lib/workflows/recordChatUsage.ts - lib/credits/computeCreditsDeductedCents.ts - lib/credits/estimateModelUsageCost.ts - lib/credits/AvailableModelCost.ts - lib/credits/resolveCostTier.ts - All their test files Net delta: -7 files, +0 new orchestrator function. Plus the atomicity guarantee now applies to research handlers too. TDD: each change went RED → minimum impl → GREEN, with all 3195 tests passing at the end. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(credits): mark recordCreditDeduction as 'use step' for workflow runtime Vercel Workflow's build-time detector flagged `nanoid` as a Node.js module that can't run inside the workflow body. Marking recordCreditDeduction as 'use step' moves it into the step runtime where Node modules are allowed. Backwards compatible for the existing research-handler callers (regular API routes) — 'use step' functions execute immediately when called from non-workflow contexts. Also matches open-agents' pattern: their recordWorkflowUsage (which contains the equivalent nanoid call) is a 'use step' function. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(workflow): collapse inline metadata duck-type (KISS) PR review feedback: the 14-line inline type assertion for `result.responseMessage` was needless boilerplate. Replaced with: 1. Import the existing `AgentMessageMetadata` type (already used by `runAgentStep`'s `messageMetadata` callback — single source of truth for the shape). 2. Hoist a module-level `ZERO_USAGE` default so the fallback when metadata is missing is a named constant, not an inline literal. 3. Cast `result.responseMessage.metadata` once (`as AgentMessageMetadata | undefined`). Net delta: 14 lines → 5 lines inside the workflow body, no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/runAgentWorkflow.test.ts | 83 +++++++++++++++++ app/lib/workflows/runAgentWorkflow.ts | 35 +++++++- .../integration/chatEndToEnd.test.ts | 1 + lib/chat/handleChatWorkflowStream.ts | 1 + lib/credits/__tests__/getCreditUsage.test.ts | 76 ++++++++++++++++ .../__tests__/handleChatCredits.test.ts | 49 +++++++++- .../__tests__/recordCreditDeduction.test.ts | 82 ++++++++--------- lib/credits/getCreditUsage.ts | 17 ++++ lib/credits/handleChatCredits.ts | 33 +++++-- lib/credits/recordCreditDeduction.ts | 64 +++++++------ .../__tests__/deductCreditsWithAudit.test.ts | 89 +++++++++++++++++++ .../credits_usage/deductCreditsWithAudit.ts | 73 +++++++++++++++ types/database.types.ts | 9 ++ 13 files changed, 533 insertions(+), 79 deletions(-) create mode 100644 lib/supabase/credits_usage/__tests__/deductCreditsWithAudit.test.ts create mode 100644 lib/supabase/credits_usage/deductCreditsWithAudit.ts diff --git a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts index 3e59ffc2d..19cec3b78 100644 --- a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts +++ b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts @@ -5,6 +5,7 @@ import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream"; import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; +import { handleChatCredits } from "@/lib/credits/handleChatCredits"; vi.mock("@/app/lib/workflows/runAgentStep", () => ({ runAgentStep: vi.fn(), @@ -21,6 +22,9 @@ vi.mock("@/app/lib/workflows/generateAssistantMessageId", () => ({ vi.mock("@/lib/chat/persistAssistantMessage", () => ({ persistAssistantMessage: vi.fn(), })); +vi.mock("@/lib/credits/handleChatCredits", () => ({ + handleChatCredits: vi.fn(), +})); // Captured writable stub so tests can assert closeChatStream got the // same instance the workflow body holds. const writableStub = new WritableStream(); @@ -43,6 +47,7 @@ const baseInput = { messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] } as never], chatId: "chat-1", sessionId: "session-1", + accountId: "acc-1", modelId: "anthropic/claude-haiku-4.5", agentContext: { sandbox: { state: { type: "vercel" }, workingDirectory: "/sandbox/mono" }, @@ -170,4 +175,82 @@ describe("runAgentWorkflow", () => { expect.objectContaining({ assistantMessageId: "asst-in-progress" }), ); }); + + it("calls handleChatCredits with the gateway cost + token usage from responseMessage.metadata", async () => { + const responseMessage = { + id: "assistant-msg-xyz", + role: "assistant", + parts: [{ type: "text", text: "Hello!" }], + metadata: { + totalMessageCost: 0.07, + totalMessageUsage: { + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 20, + }, + }, + }; + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: responseMessage as never, + }); + + await runAgentWorkflow(baseInput); + + expect(handleChatCredits).toHaveBeenCalledTimes(1); + expect(handleChatCredits).toHaveBeenCalledWith({ + accountId: "acc-1", + model: "anthropic/claude-haiku-4.5", + source: "api", + gatewayCostUsd: 0.07, + usage: { + inputTokens: 100, + cachedInputTokens: 10, + outputTokens: 20, + }, + }); + }); + + it("calls handleChatCredits with zero usage when metadata is missing (lets the 1c floor apply)", async () => { + const responseMessage = { + id: "assistant-msg-xyz", + role: "assistant", + parts: [{ type: "text", text: "Hello!" }], + // no metadata + }; + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: responseMessage as never, + }); + + await runAgentWorkflow(baseInput); + + expect(handleChatCredits).toHaveBeenCalledTimes(1); + expect(handleChatCredits).toHaveBeenCalledWith({ + accountId: "acc-1", + model: "anthropic/claude-haiku-4.5", + source: "api", + gatewayCostUsd: undefined, + usage: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }, + }); + }); + + it("does NOT call handleChatCredits when runAgentStep returns no responseMessage", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: undefined, + }); + + await runAgentWorkflow(baseInput); + + expect(handleChatCredits).not.toHaveBeenCalled(); + }); + + it("does NOT call handleChatCredits when runAgentStep throws (no message to bill)", async () => { + vi.mocked(runAgentStep).mockRejectedValue(new Error("model exploded")); + + await expect(runAgentWorkflow(baseInput)).rejects.toThrow("model exploded"); + + expect(handleChatCredits).not.toHaveBeenCalled(); + }); }); diff --git a/app/lib/workflows/runAgentWorkflow.ts b/app/lib/workflows/runAgentWorkflow.ts index e4e628e96..67eb94b24 100644 --- a/app/lib/workflows/runAgentWorkflow.ts +++ b/app/lib/workflows/runAgentWorkflow.ts @@ -1,16 +1,31 @@ import { getWorkflowMetadata, getWritable } from "workflow"; -import type { UIMessage, UIMessageChunk } from "ai"; +import type { LanguageModelUsage, UIMessage, UIMessageChunk } from "ai"; import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { runAgentStep } from "@/app/lib/workflows/runAgentStep"; import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream"; import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; +import { handleChatCredits } from "@/lib/credits/handleChatCredits"; +import type { AgentMessageMetadata } from "@/lib/agent/messageMetadata/AgentMessageMetadata"; import type { DurableAgentContext } from "@/lib/agent/tools/AgentContext"; +const ZERO_USAGE: LanguageModelUsage = { + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, +} as LanguageModelUsage; + export type RunAgentWorkflowInput = { messages: UIMessage[]; chatId: string; sessionId: string; + /** + * Authenticated account whose wallet absorbs the turn's cost. Resolved by + * the route handler via `validateChatWorkflow` so we never trust a + * caller-supplied id. Threaded into `recordChatUsage` after the assistant + * message is persisted. + */ + accountId: string; modelId: string; /** * JSON-serializable subset of AgentContext that survives the durable @@ -82,6 +97,24 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise { expect(mockGetCreditUsage).toHaveBeenCalledWith( { promptTokens: 1000, completionTokens: 500 }, "gpt-4", + undefined, ); expect(mockRecordCreditDeduction).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/lib/chat/handleChatWorkflowStream.ts b/lib/chat/handleChatWorkflowStream.ts index 20f048a5c..27961b126 100644 --- a/lib/chat/handleChatWorkflowStream.ts +++ b/lib/chat/handleChatWorkflowStream.ts @@ -122,6 +122,7 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise { expect(cost).toBe(0); }); }); + + describe("gateway cost short-circuit", () => { + it("returns gatewayCostUsd directly when it is a positive number (skips catalog lookup)", async () => { + const cost = await getCreditUsage( + { inputTokens: 1000, outputTokens: 500 }, + "anthropic/claude-haiku-4.5", + 0.07, + ); + expect(cost).toBe(0.07); + expect(mockGetModel).not.toHaveBeenCalled(); + }); + + it("falls through to token math when gatewayCostUsd is undefined", async () => { + mockGetModel.mockResolvedValue({ + id: "gpt-4", + pricing: { input: "0.00003", output: "0.00006" }, + } as any); + + const cost = await getCreditUsage( + { inputTokens: 1000, outputTokens: 500 }, + "gpt-4", + undefined, + ); + // 1000 * 0.00003 + 500 * 0.00006 = 0.06 + expect(cost).toBeCloseTo(0.06); + expect(mockGetModel).toHaveBeenCalledWith("gpt-4"); + }); + + it("falls through to token math when gatewayCostUsd is 0", async () => { + mockGetModel.mockResolvedValue({ + id: "gpt-4", + pricing: { input: "0.00003", output: "0.00006" }, + } as any); + + const cost = await getCreditUsage({ inputTokens: 1000, outputTokens: 500 }, "gpt-4", 0); + expect(cost).toBeCloseTo(0.06); + }); + + it("falls through to token math when gatewayCostUsd is negative", async () => { + mockGetModel.mockResolvedValue({ + id: "gpt-4", + pricing: { input: "0.00003", output: "0.00006" }, + } as any); + + const cost = await getCreditUsage({ inputTokens: 1000, outputTokens: 500 }, "gpt-4", -1); + expect(cost).toBeCloseTo(0.06); + }); + + it("falls through to token math when gatewayCostUsd is NaN", async () => { + mockGetModel.mockResolvedValue({ + id: "gpt-4", + pricing: { input: "0.00003", output: "0.00006" }, + } as any); + + const cost = await getCreditUsage( + { inputTokens: 1000, outputTokens: 500 }, + "gpt-4", + Number.NaN, + ); + expect(cost).toBeCloseTo(0.06); + }); + + it("falls through to token math when gatewayCostUsd is Infinity", async () => { + mockGetModel.mockResolvedValue({ + id: "gpt-4", + pricing: { input: "0.00003", output: "0.00006" }, + } as any); + + const cost = await getCreditUsage( + { inputTokens: 1000, outputTokens: 500 }, + "gpt-4", + Number.POSITIVE_INFINITY, + ); + expect(cost).toBeCloseTo(0.06); + }); + }); }); diff --git a/lib/credits/__tests__/handleChatCredits.test.ts b/lib/credits/__tests__/handleChatCredits.test.ts index 0e92f352e..c1786fc8f 100644 --- a/lib/credits/__tests__/handleChatCredits.test.ts +++ b/lib/credits/__tests__/handleChatCredits.test.ts @@ -42,7 +42,7 @@ describe("handleChatCredits", () => { accountId: "account-123", }); - expect(mockGetCreditUsage).toHaveBeenCalledWith(USAGE, "gpt-4"); + expect(mockGetCreditUsage).toHaveBeenCalledWith(USAGE, "gpt-4", undefined); expect(mockRecordCreditDeduction).toHaveBeenCalledWith({ accountId: "account-123", creditsToDeduct: 5, @@ -154,4 +154,51 @@ describe("handleChatCredits", () => { expect(consoleSpy).toHaveBeenCalledWith("Failed to handle chat credits:", expect.any(Error)); }); }); + + describe("gateway cost + source extensions", () => { + it("forwards gatewayCostUsd to getCreditUsage when provided", async () => { + mockGetCreditUsage.mockResolvedValue(0.07); + mockRecordCreditDeduction.mockResolvedValue({ success: true, newBalance: 93 }); + + await handleChatCredits({ + usage: USAGE, + model: "anthropic/claude-haiku-4.5", + accountId: "account-123", + gatewayCostUsd: 0.07, + }); + + expect(mockGetCreditUsage).toHaveBeenCalledWith(USAGE, "anthropic/claude-haiku-4.5", 0.07); + }); + + it("defaults source to 'web' when not provided (backwards compatible)", async () => { + mockGetCreditUsage.mockResolvedValue(0.05); + mockRecordCreditDeduction.mockResolvedValue({ success: true, newBalance: 95 }); + + await handleChatCredits({ + usage: USAGE, + model: "gpt-4", + accountId: "account-123", + }); + + expect(mockRecordCreditDeduction).toHaveBeenCalledWith( + expect.objectContaining({ source: "web" }), + ); + }); + + it("propagates source='api' when caller is the chat workflow", async () => { + mockGetCreditUsage.mockResolvedValue(0.05); + mockRecordCreditDeduction.mockResolvedValue({ success: true, newBalance: 95 }); + + await handleChatCredits({ + usage: USAGE, + model: "anthropic/claude-haiku-4.5", + accountId: "account-123", + source: "api", + }); + + expect(mockRecordCreditDeduction).toHaveBeenCalledWith( + expect.objectContaining({ source: "api" }), + ); + }); + }); }); diff --git a/lib/credits/__tests__/recordCreditDeduction.test.ts b/lib/credits/__tests__/recordCreditDeduction.test.ts index 9cdfa4eba..95910af4e 100644 --- a/lib/credits/__tests__/recordCreditDeduction.test.ts +++ b/lib/credits/__tests__/recordCreditDeduction.test.ts @@ -1,17 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { recordCreditDeduction } from "@/lib/credits/recordCreditDeduction"; -const { deductCreditsMock, insertUsageEventMock } = vi.hoisted(() => ({ - deductCreditsMock: vi.fn(), - insertUsageEventMock: vi.fn(), +const { deductCreditsWithAuditMock } = vi.hoisted(() => ({ + deductCreditsWithAuditMock: vi.fn(), })); -vi.mock("@/lib/credits/deductCredits", () => ({ - deductCredits: deductCreditsMock, -})); - -vi.mock("@/lib/supabase/usage_events/insertUsageEvent", () => ({ - insertUsageEvent: insertUsageEventMock, +vi.mock("@/lib/supabase/credits_usage/deductCreditsWithAudit", () => ({ + deductCreditsWithAudit: deductCreditsWithAuditMock, })); const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; @@ -21,9 +16,8 @@ describe("recordCreditDeduction", () => { vi.clearAllMocks(); }); - it("deducts credits then inserts a usage_events row carrying token detail", async () => { - deductCreditsMock.mockResolvedValue({ success: true, newBalance: 100 }); - insertUsageEventMock.mockResolvedValue({ id: "abc" }); + it("calls the atomic RPC with cents + event payload carrying token detail", async () => { + deductCreditsWithAuditMock.mockResolvedValue({ ok: true }); const result = await recordCreditDeduction({ accountId: ACCOUNT, @@ -37,13 +31,13 @@ describe("recordCreditDeduction", () => { toolCallCount: 3, }); - expect(deductCreditsMock).toHaveBeenCalledWith({ - accountId: ACCOUNT, - creditsToDeduct: 250, - }); - expect(insertUsageEventMock).toHaveBeenCalledWith({ - account_id: ACCOUNT, - credits_deducted_cents: 250, + expect(deductCreditsWithAuditMock).toHaveBeenCalledTimes(1); + const args = deductCreditsWithAuditMock.mock.calls[0]?.[0]; + expect(args.accountId).toBe(ACCOUNT); + expect(args.cents).toBe(250); + expect(typeof args.eventId).toBe("string"); + expect(args.eventId.length).toBeGreaterThan(0); + expect(args.event).toEqual({ source: "web", agent_type: "main", provider: "anthropic", @@ -53,12 +47,11 @@ describe("recordCreditDeduction", () => { output_tokens: 567, tool_call_count: 3, }); - expect(result).toEqual({ success: true, newBalance: 100 }); + expect(result).toEqual({ success: true }); }); - it("applies defaults (agent_type='main', zero tokens, null model/provider) when token detail is omitted", async () => { - deductCreditsMock.mockResolvedValue({ success: true, newBalance: 95 }); - insertUsageEventMock.mockResolvedValue({ id: "abc" }); + it("applies defaults (agent_type='main', zero tokens, undefined model/provider) when token detail is omitted", async () => { + deductCreditsWithAuditMock.mockResolvedValue({ ok: true }); await recordCreditDeduction({ accountId: ACCOUNT, @@ -66,13 +59,12 @@ describe("recordCreditDeduction", () => { source: "api", }); - expect(insertUsageEventMock).toHaveBeenCalledWith({ - account_id: ACCOUNT, - credits_deducted_cents: 5, + const args = deductCreditsWithAuditMock.mock.calls[0]?.[0]; + expect(args.event).toEqual({ source: "api", agent_type: "main", - provider: null, - model_id: null, + provider: undefined, + model_id: undefined, input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, @@ -80,33 +72,35 @@ describe("recordCreditDeduction", () => { }); }); - it("does not insert the audit row when the wallet deduction fails", async () => { - deductCreditsMock.mockRejectedValue(new Error("Insufficient credits")); + it("returns { success: false } and logs when the RPC reports an error (does NOT throw)", async () => { + const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + deductCreditsWithAuditMock.mockResolvedValue({ + ok: false, + error: "credits_usage row not found", + }); - await expect( - recordCreditDeduction({ - accountId: ACCOUNT, - creditsToDeduct: 50, - source: "web", - }), - ).rejects.toThrow(/Insufficient credits/); + const result = await recordCreditDeduction({ + accountId: ACCOUNT, + creditsToDeduct: 50, + source: "web", + }); - expect(insertUsageEventMock).not.toHaveBeenCalled(); + expect(result).toEqual({ success: false }); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); }); - it("returns the deduction result even if the audit insert fails (logs and swallows)", async () => { + it("does not throw when the RPC wrapper itself rejects (defense in depth)", async () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - deductCreditsMock.mockResolvedValue({ success: true, newBalance: 100 }); - insertUsageEventMock.mockRejectedValue(new Error("db down")); + deductCreditsWithAuditMock.mockRejectedValue(new Error("network blip")); const result = await recordCreditDeduction({ accountId: ACCOUNT, creditsToDeduct: 5, - source: "api", + source: "web", }); - expect(result).toEqual({ success: true, newBalance: 100 }); - expect(consoleSpy).toHaveBeenCalled(); + expect(result).toEqual({ success: false }); consoleSpy.mockRestore(); }); }); diff --git a/lib/credits/getCreditUsage.ts b/lib/credits/getCreditUsage.ts index 4e1dc5b28..26eb9180b 100644 --- a/lib/credits/getCreditUsage.ts +++ b/lib/credits/getCreditUsage.ts @@ -3,14 +3,31 @@ import { LanguageModelUsage } from "ai"; /** * Calculates the total spend in USD for a given language model usage. + * + * Resolution order: + * 1. `gatewayCostUsd` — gateway-reported actual cost from + * `responseMessage.metadata.totalMessageCost`. Used directly when + * present and positive so the wallet debit converges with the cost + * label the chat UI shows next to the assistant response. + * 2. Token-based estimate using `model.pricing.input/output` from + * the gateway catalog (`getModel`). Authoritative for token cost. + * 3. `0` when nothing prices the turn (caller floors to the 1c + * minimum via `Math.max(1, Math.round(usd * 100))`). + * * @param usage - The language model usage data * @param modelId - The ID of the model used + * @param gatewayCostUsd - Optional gateway-reported USD cost (preferred over token math) * @returns The total spend in USD or 0 if calculation fails */ export const getCreditUsage = async ( usage: LanguageModelUsage, modelId: string, + gatewayCostUsd?: number, ): Promise => { + if (typeof gatewayCostUsd === "number" && Number.isFinite(gatewayCostUsd) && gatewayCostUsd > 0) { + return gatewayCostUsd; + } + try { const model = await getModel(modelId); if (!model) { diff --git a/lib/credits/handleChatCredits.ts b/lib/credits/handleChatCredits.ts index bc2ecff9f..061043420 100644 --- a/lib/credits/handleChatCredits.ts +++ b/lib/credits/handleChatCredits.ts @@ -6,19 +6,42 @@ interface HandleChatCreditsParams { usage: LanguageModelUsage; model: string; accountId?: string; + /** + * Gateway-reported total USD cost for this turn (from + * `responseMessage.metadata.totalMessageCost`). When present and + * positive, used directly instead of the token-based estimate so + * the wallet debit converges with the cost label the chat UI shows. + */ + gatewayCostUsd?: number; + /** + * Which surface generated the turn — used to label the + * `usage_events` audit row. Defaults to `"web"` to preserve the + * existing `handleChatStream` contract; the chat-workflow path + * (`/api/chat/workflow`) passes `"api"` so admin dashboards can + * distinguish surface in spend rollups. + */ + source?: "web" | "api"; } /** * Handles credit deduction after chat completion. * Always deducts at least 1 credit when accountId is present (round up from usage cost). - * @param usage - The language model usage data - * @param model - The model ID used for the chat - * @param accountId - The account ID to deduct credits from (optional) + * + * Resolution order for the per-turn cost: + * 1. `gatewayCostUsd` — gateway-reported actual USD (preferred) + * 2. Token-based estimate via `getCreditUsage` against the gateway catalog + * 3. 1c floor — every successful turn debits at least 1 cent + * + * Wallet debit + audit row insert are atomic via + * `recordCreditDeduction` (backed by the `deduct_credits_with_audit` + * Postgres function). */ export const handleChatCredits = async ({ usage, model, accountId, + gatewayCostUsd, + source = "web", }: HandleChatCreditsParams): Promise => { if (!accountId) { console.error("No account ID provided, skipping credit deduction"); @@ -26,13 +49,13 @@ export const handleChatCredits = async ({ } try { - const usageCost = await getCreditUsage(usage, model); + const usageCost = await getCreditUsage(usage, model, gatewayCostUsd); const creditsToDeduct = Math.max(1, Math.round(usageCost * 100)); await recordCreditDeduction({ accountId, creditsToDeduct, - source: "web", + source, modelId: model, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, diff --git a/lib/credits/recordCreditDeduction.ts b/lib/credits/recordCreditDeduction.ts index b2db46803..3bf61ecce 100644 --- a/lib/credits/recordCreditDeduction.ts +++ b/lib/credits/recordCreditDeduction.ts @@ -1,5 +1,5 @@ -import { deductCredits } from "./deductCredits"; -import { insertUsageEvent } from "@/lib/supabase/usage_events/insertUsageEvent"; +import { nanoid } from "nanoid"; +import { deductCreditsWithAudit } from "@/lib/supabase/credits_usage/deductCreditsWithAudit"; interface RecordCreditDeductionParams { accountId: string; @@ -16,42 +16,50 @@ interface RecordCreditDeductionParams { interface RecordCreditDeductionResult { success: boolean; - newBalance?: number; } /** - * Wallet + meter wrapper. Debits the credits_usage balance via deductCredits, - * then writes a usage_events row recording the wallet impact alongside any - * token detail the caller has. + * Wallet + meter atomic wrapper. Calls the `deduct_credits_with_audit` + * Postgres function (recoupable/database#26) which runs the + * `credits_usage` debit and the `usage_events` insert inside a single + * transaction — either both writes commit or neither does. Eliminates + * the wallet/meter drift risk the previous (two separate Supabase + * calls) implementation had. * - * If the audit insert fails, the deduction is preserved (already committed) - * and the error is logged but not surfaced — the wallet stays authoritative - * and a reconciliation job can recover the missing audit row later. + * Errors are caught and surfaced via `{ success: false }` so the + * caller (the chat workflow or any research handler) never aborts on + * a credit-accounting hiccup. The wallet stays authoritative — if the + * RPC rejects, neither write happened, and the caller can decide + * whether to retry or move on. */ export const recordCreditDeduction = async ( params: RecordCreditDeductionParams, ): Promise => { - const result = await deductCredits({ - accountId: params.accountId, - creditsToDeduct: params.creditsToDeduct, - }); - + "use step"; try { - await insertUsageEvent({ - account_id: params.accountId, - credits_deducted_cents: params.creditsToDeduct, - source: params.source, - agent_type: params.agentType ?? "main", - provider: params.provider ?? null, - model_id: params.modelId ?? null, - input_tokens: params.inputTokens ?? 0, - cached_input_tokens: params.cachedInputTokens ?? 0, - output_tokens: params.outputTokens ?? 0, - tool_call_count: params.toolCallCount ?? 0, + const result = await deductCreditsWithAudit({ + accountId: params.accountId, + cents: params.creditsToDeduct, + eventId: nanoid(), + event: { + source: params.source, + agent_type: params.agentType ?? "main", + provider: params.provider, + model_id: params.modelId, + input_tokens: params.inputTokens ?? 0, + cached_input_tokens: params.cachedInputTokens ?? 0, + output_tokens: params.outputTokens ?? 0, + tool_call_count: params.toolCallCount ?? 0, + }, }); + + if (!result.ok) { + console.error("[recordCreditDeduction] atomic debit failed:", result.error); + return { success: false }; + } + return { success: true }; } catch (error) { - console.error("Failed to insert usage_events row (wallet was still debited):", error); + console.error("[recordCreditDeduction] unexpected error:", error); + return { success: false }; } - - return result; }; diff --git a/lib/supabase/credits_usage/__tests__/deductCreditsWithAudit.test.ts b/lib/supabase/credits_usage/__tests__/deductCreditsWithAudit.test.ts new file mode 100644 index 000000000..c2157bd88 --- /dev/null +++ b/lib/supabase/credits_usage/__tests__/deductCreditsWithAudit.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { deductCreditsWithAudit } from "@/lib/supabase/credits_usage/deductCreditsWithAudit"; +import supabase from "@/lib/supabase/serverClient"; + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { rpc: vi.fn() }, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const ACCOUNT = "11111111-1111-1111-1111-111111111111"; +const validEvent = { + source: "api" as const, + agent_type: "main" as const, + provider: "anthropic", + model_id: "anthropic/claude-haiku-4.5", + input_tokens: 42, + cached_input_tokens: 10, + output_tokens: 13, + tool_call_count: 1, +}; + +describe("deductCreditsWithAudit", () => { + it("calls the deduct_credits_with_audit RPC with the right param names", async () => { + vi.mocked(supabase.rpc).mockResolvedValue({ data: null, error: null } as never); + + await deductCreditsWithAudit({ + accountId: ACCOUNT, + cents: 7, + eventId: "evt_abc", + event: validEvent, + }); + + expect(supabase.rpc).toHaveBeenCalledTimes(1); + expect(supabase.rpc).toHaveBeenCalledWith("deduct_credits_with_audit", { + p_account_id: ACCOUNT, + p_amount: 7, + p_event_id: "evt_abc", + p_event: validEvent, + }); + }); + + it("returns { ok: true } when the RPC succeeds", async () => { + vi.mocked(supabase.rpc).mockResolvedValue({ data: null, error: null } as never); + + const result = await deductCreditsWithAudit({ + accountId: ACCOUNT, + cents: 7, + eventId: "evt_abc", + event: validEvent, + }); + + expect(result).toEqual({ ok: true }); + }); + + it("returns { ok: false, error } when the RPC returns an error (does NOT throw)", async () => { + vi.mocked(supabase.rpc).mockResolvedValue({ + data: null, + error: { message: "credits_usage row not found" }, + } as never); + + const result = await deductCreditsWithAudit({ + accountId: ACCOUNT, + cents: 7, + eventId: "evt_abc", + event: validEvent, + }); + + expect(result).toEqual({ ok: false, error: "credits_usage row not found" }); + }); + + it("returns { ok: false, error } when the rpc call throws (network failure)", async () => { + vi.mocked(supabase.rpc).mockRejectedValue(new Error("network blip")); + + const result = await deductCreditsWithAudit({ + accountId: ACCOUNT, + cents: 7, + eventId: "evt_abc", + event: validEvent, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("network blip"); + } + }); +}); diff --git a/lib/supabase/credits_usage/deductCreditsWithAudit.ts b/lib/supabase/credits_usage/deductCreditsWithAudit.ts new file mode 100644 index 000000000..871800f73 --- /dev/null +++ b/lib/supabase/credits_usage/deductCreditsWithAudit.ts @@ -0,0 +1,73 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Json } from "@/types/database.types"; + +/** + * JSON payload populating the `usage_events` audit row. Optional + * fields fall back to column defaults (`source='api'`, + * `agent_type='main'`, provider/model_id NULL, counts 0) inside the + * SQL function — see `database/supabase/migrations/20260525000000_deduct_credits_with_audit.sql`. + */ +export interface DeductCreditsAuditEvent { + source?: "api" | "web"; + agent_type?: "main" | "subagent"; + provider?: string; + model_id?: string; + input_tokens?: number; + cached_input_tokens?: number; + output_tokens?: number; + tool_call_count?: number; +} + +export interface DeductCreditsWithAuditResult { + ok: boolean; + /** + * Present only when `ok === false`. Kept optional rather than as a + * discriminated union so callers can read `result.error` without + * tripping a narrowing edge case in the Next.js 16 type checker. + */ + error?: string; +} + +/** + * Atomically debits `credits_usage.remaining_credits` and inserts the + * corresponding `usage_events` audit row via the + * `deduct_credits_with_audit` Postgres function. The SQL function + * runs both writes inside an implicit transaction so wallet/meter + * can never drift on partial failure — matching open-agents' + * `recordUsage` `db.transaction(...)` guarantee. + * + * Caller convention: `cents` is the amount to debit (integer ≥ 1). + * `eventId` should match `lib/supabase/usage_events/insertUsageEvent.ts`'s + * nanoid convention so the audit trail is consistent. + * + * Errors are never thrown — returns `{ ok: false, error }` instead so + * the caller (`recordChatUsage`) can swallow without aborting the + * chat workflow on a credit accounting hiccup. + */ +export async function deductCreditsWithAudit(params: { + accountId: string; + cents: number; + eventId: string; + event: DeductCreditsAuditEvent; +}): Promise { + try { + const { error } = await supabase.rpc("deduct_credits_with_audit", { + p_account_id: params.accountId, + p_amount: params.cents, + p_event_id: params.eventId, + // DeductCreditsAuditEvent is structurally JSON-safe, but TS can't + // infer an interface → index-signature assignment automatically. + // Cast once at this boundary; the runtime payload is unchanged. + p_event: params.event as unknown as Json, + }); + if (error) { + return { ok: false, error: error.message }; + } + return { ok: true }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/types/database.types.ts b/types/database.types.ts index 20287a6a9..cc7d03e1e 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -3961,6 +3961,15 @@ export type Database = { Args: { account_id: string; amount: number }; Returns: undefined; }; + deduct_credits_with_audit: { + Args: { + p_account_id: string; + p_amount: number; + p_event: Json; + p_event_id: string; + }; + Returns: undefined; + }; extract_domain: { Args: { email: string }; Returns: string }; get_account_invitations: { Args: { account_slug: string }; From 86295b8bb29fa5e1569342f2ea5bae26d0a5b7a3 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Mon, 25 May 2026 18:58:27 -0500 Subject: [PATCH 03/15] feat(chat-workflow): auto-commit + push after natural finish (#614) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chat-workflow): auto-commit + push after natural finish (#605) Ports open-agents' auto-commit flow to the chat workflow. When a turn finishes naturally (not `tool-calls`) and the session has both `repo_owner` and `repo_name`, the workflow: 1. `git status --porcelain` to check for changes (hasAutoCommitChanges) 2. Emits `data-commit { status: "pending" }` to the SSE stream so the UI can show a spinner 3. Runs the commit + push (runAutoCommit → performAutoCommit): - `git remote set-url origin` with x-access-token URL when GITHUB_TOKEN is set - `git add -A` - LLM-generated commit message via `generateText` on `git diff --cached` (falls back to "chore: update repository changes" if the gateway is down or diff is empty) - `git commit -m ''` - `git rev-parse HEAD` + `git symbolic-ref --short HEAD` - `GIT_TERMINAL_PROMPT=0 git push -u origin ` 4. Emits `data-commit { status: "success"/"error", url? }` with the resolved commit URL when both committed AND pushed New files (TDD, 33 tests): - `lib/chat/auto-commit/performAutoCommit.ts` (14 tests) — the sandbox.exec orchestration, with granular failure modes so the caller can distinguish "couldn't commit" from "committed but push failed". - `lib/chat/auto-commit/hasAutoCommitChanges.ts` (5 tests) — fast pre-flight, fail-open on errors so runAutoCommit reports the real issue. - `lib/chat/auto-commit/runAutoCommit.ts` (4 tests) — workflow step wrapping performAutoCommit with global error handling. - `lib/chat/auto-commit/buildCommitData.ts` (7 tests) — pure helper shaping the AutoCommitResult into the UIMessageChunk payload (status, commit url with proper URL encoding). - `lib/chat/auto-commit/sendCommitChunk.ts` (3 tests) — workflow step that writes the data-commit chunk into the workflow writable (acquires writer / releases lock). Updated: - `app/lib/workflows/runAgentWorkflow.ts` — auto-commit branch after persistAssistantMessage; wraps VercelState with the `{type: "vercel"}` discriminator before passing to SandboxState consumers. New input fields: `sessionTitle?`, `repoOwner?`, `repoName?`. - `app/lib/workflows/__tests__/runAgentWorkflow.test.ts` — 5 new cases covering the happy path, no-changes skip, missing repo identifiers, finish reason 'tool-calls' (no auto-commit on intermediate turns), and the error path. - `lib/chat/handleChatWorkflowStream.ts` — threads `session.title`, `session.repo_owner`, `session.repo_name` into the workflow input. KNOWN LIMITATION (separate follow-up): the data-commit chunks are emitted live to the SSE stream but are NOT re-persisted onto the assistant message's `parts`. The chunk disappears on page refresh. The commit itself is permanent on GitHub. Re-persistence requires either: (a) a follow-up persistAssistantMessage call with the updated message, or (b) an updateChatMessageParts helper. TDD discipline: each new file went RED → minimum impl → GREEN. Suite: 3195 → 3233 passing. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(auto-commit): derive repoOwner/repoName from session.clone_url Auto-commit needs the owner + repo name to build the GitHub commit URL and to set the remote auth URL on push. The session table has `repo_owner` / `repo_name` columns but they were never populated, so the workflow was always skipping auto-commit silently. Rather than denormalize the data (populate the columns at write time), treat `clone_url` as canonical and parse it at read time. Single source of truth, no drift risk between columns and the URL. New helper `lib/github/parseGitHubRepoIdentifiers.ts` (8 tests) handles https + ssh shapes, .git suffix, trailing slashes, and the null / non-github cases. `handleChatWorkflowStream` parses `session.clone_url` once and threads `repoOwner` / `repoName` into the workflow input. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(auto-commit): persist data-commit chunk onto assistant message Closes the "data-commit chunk disappears on refresh" limitation flagged in the initial PR description. The chunk is now merged into the assistant message's `parts` and re-persisted via a UPDATE-only helper, so the `GitDataPartCard` UI in open-agents renders the "Committed at " affordance on page load — not just during the live SSE stream. New files: - `lib/chat/upsertAssistantDataPart.ts` (5 tests) — pure helper ported from open-agents `apps/web/app/workflows/chat.ts`. Merges a data-part into a message's `parts` by `{type, id}` (replace if matched, append otherwise). Immutable; doesn't mutate the input. - `lib/supabase/chat_messages/updateChatMessageParts.ts` (3 tests) — UPDATE-only helper that bypasses the `upsertChatMessage(onConflict: "id", ignoreDuplicates: true)` no-op-on-second-call semantics. Keeps the first-insert path's replay-idempotency for `persistAssistantMessage`; this helper is specifically for "the row exists, replace its `parts`". Wired into `runAgentWorkflow`: After the resolved data-commit chunk is emitted to the writable, the chunk is merged into `result.responseMessage.parts` via `upsertAssistantDataPart`, then `updateChatMessageParts` writes the updated `parts` to the DB. Mirrors open-agents' two-persist pattern in `apps/web/app/workflows/chat.ts:didUpdateGitData`. Tests: - 2 new in `runAgentWorkflow.test.ts`: - success path now asserts `updateChatMessageParts` was called with the resolved data-commit part merged into the parts array - no-changes path asserts `updateChatMessageParts` is NOT called - +8 helper tests across the two new files Co-Authored-By: Claude Opus 4.7 (1M context) * fix(auto-commit): persist whole message + use step for workflow runtime Two bugs from the first persistence attempt, both caught when the data-commit chunk failed to appear on the open-agents UI after refresh: 1. `updateChatMessageParts` wasn't marked `"use step"`. The function calls `supabase.from(...).update(...)` which uses fetch under the hood — forbidden in the workflow body. The call ran silently with no effect. Same failure mode I hit on `recordCreditDeduction.ts`. 2. The workflow was passing `messageWithCommit.parts` (the inner parts array) when `chat_messages.parts` actually stores the WHOLE message object — matching `persistAssistantMessage`'s `parts: message as never` storage convention. Pass the merged message object now. Confirmed via direct DB query that the first attempt didn't write anything (the row still had only the original message-shape from `persistAssistantMessage`'s first call). With the step boundary + correct payload shape, the persistence path now executes and stores the data-commit chunk so the open-agents `GitDataPartCard` can render after refresh. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(auto-commit): address SRP / KISS / OCP review feedback Per @sweetmantech's PR review comments on #614: KISS — Renamed updateChatMessageParts → updateChatMessage and moved the "use step" boundary out of the supabase layer. Supabase wrappers now stay pure; step-bound wrappers live in lib/chat/. - lib/supabase/chat_messages/updateChatMessage.ts (no "use step") - lib/chat/persistAssistantDataPart.ts (new) — "use step" wrapper that internally calls upsertAssistantDataPart (merge) then updateChatMessage (write). Single durable step boundary at the chat-domain layer. SRP — Extracted generateCommitMessage to its own file. Was a private helper inside performAutoCommit.ts; now reusable for alternative commit-message strategies and individually testable. - lib/chat/auto-commit/generateCommitMessage.ts (+6 tests) - performAutoCommit.ts imports it instead of defining inline. OCP — Extracted the ~50-line auto-commit block from runAgentWorkflow into its own file. Workflow body shrinks to a single function call; the auto-commit flow can evolve without touching workflow code. - lib/chat/auto-commit/autoCommitChatTurn.ts (+9 tests covering every gate, the no-changes path, the happy path including pending → resolved chunks + persistence, and the error path). - runAgentWorkflow.ts: ~50 lines → 11-line invocation. - Workflow test pruned: 6 sub-step assertions → 4 wiring assertions (the flow itself is exhaustively tested in autoCommitChatTurn.test.ts). Also flattened UpdateChatMessageResult from a discriminated union to a single interface — same Next.js 16 narrowing issue I hit on DeductCreditsWithAuditResult in #612. Net file count: +5 new files, -0 deletions (renames don't count). Lines moved out of runAgentWorkflow.ts: ~50. Tests: 3233 → 3266 passing (+33). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(auto-commit): spread input/result + use gpt-5.4-nano for commit messages Per @sweetmantech's PR review: KISS — `runAgentWorkflow` was enumerating 8 fields when constructing the autoCommitChatTurn call. Switched to `{ ...input, ...result, writable, sandboxState }`. Any future fields added to input or result get forwarded automatically; the workflow body stays tight. Updated the workflow test assertion to `expect.objectContaining(...)` since extra fields from input/result are now passed through. Model — Updated `generateCommitMessage` to use `openai/gpt-5.4-nano` instead of `anthropic/claude-haiku-4.5`. Newer, cheaper, and a better fit for the short-output commit-message task. The prompt is unchanged so behavior should be near-identical. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/runAgentWorkflow.test.ts | 89 ++++++ app/lib/workflows/runAgentWorkflow.ts | 32 +++ .../persistAssistantDataPart.test.ts | 77 ++++++ .../__tests__/upsertAssistantDataPart.test.ts | 81 ++++++ .../__tests__/autoCommitChatTurn.test.ts | 162 +++++++++++ .../__tests__/buildCommitData.test.ts | 113 ++++++++ .../__tests__/generateCommitMessage.test.ts | 79 ++++++ .../__tests__/hasAutoCommitChanges.test.ts | 58 ++++ .../__tests__/performAutoCommit.test.ts | 259 ++++++++++++++++++ .../__tests__/runAutoCommit.test.ts | 86 ++++++ .../__tests__/sendCommitChunk.test.ts | 51 ++++ lib/chat/auto-commit/autoCommitChatTurn.ts | 95 +++++++ lib/chat/auto-commit/buildCommitData.ts | 85 ++++++ lib/chat/auto-commit/generateCommitMessage.ts | 62 +++++ lib/chat/auto-commit/hasAutoCommitChanges.ts | 47 ++++ lib/chat/auto-commit/performAutoCommit.ts | 128 +++++++++ lib/chat/auto-commit/runAutoCommit.ts | 50 ++++ lib/chat/auto-commit/sendCommitChunk.ts | 25 ++ lib/chat/handleChatWorkflowStream.ts | 11 + lib/chat/persistAssistantDataPart.ts | 55 ++++ lib/chat/upsertAssistantDataPart.ts | 44 +++ .../parseGitHubRepoIdentifiers.test.ts | 49 ++++ lib/github/parseGitHubRepoIdentifiers.ts | 32 +++ .../__tests__/updateChatMessage.test.ts | 49 ++++ .../chat_messages/updateChatMessage.ts | 58 ++++ 25 files changed, 1877 insertions(+) create mode 100644 lib/chat/__tests__/persistAssistantDataPart.test.ts create mode 100644 lib/chat/__tests__/upsertAssistantDataPart.test.ts create mode 100644 lib/chat/auto-commit/__tests__/autoCommitChatTurn.test.ts create mode 100644 lib/chat/auto-commit/__tests__/buildCommitData.test.ts create mode 100644 lib/chat/auto-commit/__tests__/generateCommitMessage.test.ts create mode 100644 lib/chat/auto-commit/__tests__/hasAutoCommitChanges.test.ts create mode 100644 lib/chat/auto-commit/__tests__/performAutoCommit.test.ts create mode 100644 lib/chat/auto-commit/__tests__/runAutoCommit.test.ts create mode 100644 lib/chat/auto-commit/__tests__/sendCommitChunk.test.ts create mode 100644 lib/chat/auto-commit/autoCommitChatTurn.ts create mode 100644 lib/chat/auto-commit/buildCommitData.ts create mode 100644 lib/chat/auto-commit/generateCommitMessage.ts create mode 100644 lib/chat/auto-commit/hasAutoCommitChanges.ts create mode 100644 lib/chat/auto-commit/performAutoCommit.ts create mode 100644 lib/chat/auto-commit/runAutoCommit.ts create mode 100644 lib/chat/auto-commit/sendCommitChunk.ts create mode 100644 lib/chat/persistAssistantDataPart.ts create mode 100644 lib/chat/upsertAssistantDataPart.ts create mode 100644 lib/github/__tests__/parseGitHubRepoIdentifiers.test.ts create mode 100644 lib/github/parseGitHubRepoIdentifiers.ts create mode 100644 lib/supabase/chat_messages/__tests__/updateChatMessage.test.ts create mode 100644 lib/supabase/chat_messages/updateChatMessage.ts diff --git a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts index 19cec3b78..3697c84a9 100644 --- a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts +++ b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts @@ -6,6 +6,7 @@ import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; import { handleChatCredits } from "@/lib/credits/handleChatCredits"; +import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; vi.mock("@/app/lib/workflows/runAgentStep", () => ({ runAgentStep: vi.fn(), @@ -25,6 +26,9 @@ vi.mock("@/lib/chat/persistAssistantMessage", () => ({ vi.mock("@/lib/credits/handleChatCredits", () => ({ handleChatCredits: vi.fn(), })); +vi.mock("@/lib/chat/auto-commit/autoCommitChatTurn", () => ({ + autoCommitChatTurn: vi.fn(), +})); // Captured writable stub so tests can assert closeChatStream got the // same instance the workflow body holds. const writableStub = new WritableStream(); @@ -49,11 +53,24 @@ const baseInput = { sessionId: "session-1", accountId: "acc-1", modelId: "anthropic/claude-haiku-4.5", + sessionTitle: "test session", + repoOwner: "recoupable", + repoName: "api", agentContext: { sandbox: { state: { type: "vercel" }, workingDirectory: "/sandbox/mono" }, } as never, }; +const responseMessageWithMetadata = { + id: "asst-msg-1", + role: "assistant", + parts: [{ type: "text", text: "Hello!" }], + metadata: { + totalMessageCost: 0.07, + totalMessageUsage: { inputTokens: 100, cachedInputTokens: 10, outputTokens: 20 }, + }, +} as never; + describe("runAgentWorkflow", () => { it("clears active_stream_id after a successful run, using the workflow's own runId", async () => { vi.mocked(runAgentStep).mockResolvedValue({ @@ -253,4 +270,76 @@ describe("runAgentWorkflow", () => { expect(handleChatCredits).not.toHaveBeenCalled(); }); + + describe("auto-commit", () => { + // The auto-commit flow itself is exhaustively covered in + // `lib/chat/auto-commit/__tests__/autoCommitChatTurn.test.ts`. + // These tests only verify the workflow body wires the orchestrator + // up with the right inputs. + + it("calls autoCommitChatTurn with workflow context after persistAssistantMessage", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: responseMessageWithMetadata, + }); + + await runAgentWorkflow(baseInput); + + expect(autoCommitChatTurn).toHaveBeenCalledTimes(1); + // Workflow spreads `...input, ...result` into the call so any + // future fields are forwarded automatically. Only assert on the + // fields autoCommitChatTurn actually consumes — extra fields + // from input/result are fine to pass through. + expect(autoCommitChatTurn).toHaveBeenCalledWith( + expect.objectContaining({ + writable: writableStub, + responseMessage: responseMessageWithMetadata, + finishReason: "stop", + sessionId: "session-1", + sessionTitle: "test session", + repoOwner: "recoupable", + repoName: "api", + sandboxState: { type: "vercel", ...baseInput.agentContext.sandbox.state }, + }), + ); + }); + + it("wraps the raw VercelState with `type: 'vercel'` before forwarding", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: responseMessageWithMetadata, + }); + + await runAgentWorkflow(baseInput); + + const call = vi.mocked(autoCommitChatTurn).mock.calls[0]?.[0]; + expect(call?.sandboxState).toMatchObject({ type: "vercel" }); + }); + + it("forwards undefined sandboxState when agentContext.sandbox is missing", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: responseMessageWithMetadata, + }); + + await runAgentWorkflow({ + ...baseInput, + agentContext: {} as never, + }); + + const call = vi.mocked(autoCommitChatTurn).mock.calls[0]?.[0]; + expect(call?.sandboxState).toBeUndefined(); + }); + + it("does NOT call autoCommitChatTurn when runAgentStep returns no responseMessage", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + responseMessage: undefined, + }); + + await runAgentWorkflow(baseInput); + + expect(autoCommitChatTurn).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/lib/workflows/runAgentWorkflow.ts b/app/lib/workflows/runAgentWorkflow.ts index 67eb94b24..58431094c 100644 --- a/app/lib/workflows/runAgentWorkflow.ts +++ b/app/lib/workflows/runAgentWorkflow.ts @@ -6,6 +6,7 @@ import { runAgentStep } from "@/app/lib/workflows/runAgentStep"; import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream"; import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; import { handleChatCredits } from "@/lib/credits/handleChatCredits"; +import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; import type { AgentMessageMetadata } from "@/lib/agent/messageMetadata/AgentMessageMetadata"; import type { DurableAgentContext } from "@/lib/agent/tools/AgentContext"; @@ -27,6 +28,20 @@ export type RunAgentWorkflowInput = { */ accountId: string; modelId: string; + /** + * Optional chat title — used as context for the auto-commit + * message-generation LLM call. + */ + sessionTitle?: string; + /** + * Repo identifiers from `sessions.repo_owner` / `sessions.repo_name`. + * When BOTH are present and the sandbox is reachable, the workflow + * runs auto-commit after a successful turn (git add → LLM-generated + * commit message → git commit → git push). Either being absent + * skips auto-commit silently. + */ + repoOwner?: string; + repoName?: string; /** * JSON-serializable subset of AgentContext that survives the durable * workflow input. `runAgentStep` attaches the constructed `model` @@ -115,6 +130,23 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise ({ + updateChatMessage: vi.fn(), +})); + +const baseMessage = { + id: "msg_1", + role: "assistant" as const, + parts: [{ type: "text", text: "Hello" }] as never[], +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(updateChatMessage).mockResolvedValue({ ok: true }); +}); + +describe("persistAssistantDataPart", () => { + it("merges the part into the message and writes the WHOLE merged message via updateChatMessage", async () => { + const part = { + type: "data-commit" as const, + id: "msg_1:commit", + data: { status: "success" as const }, + }; + + await persistAssistantDataPart(baseMessage, part); + + expect(updateChatMessage).toHaveBeenCalledTimes(1); + const [id, written] = vi.mocked(updateChatMessage).mock.calls[0]!; + expect(id).toBe("msg_1"); + const message = written as typeof baseMessage; + expect(message.id).toBe("msg_1"); + expect(message.role).toBe("assistant"); + expect(message.parts).toHaveLength(2); + expect(message.parts[1]).toEqual(part); + }); + + it("replaces an existing data-part with matching {type, id} (pending → success transition)", async () => { + const messageWithPending = { + ...baseMessage, + parts: [ + ...baseMessage.parts, + { + type: "data-commit", + id: "msg_1:commit", + data: { status: "pending" }, + }, + ], + }; + const success = { + type: "data-commit" as const, + id: "msg_1:commit", + data: { status: "success" as const, commitSha: "abc" }, + }; + + await persistAssistantDataPart(messageWithPending, success); + + const [, written] = vi.mocked(updateChatMessage).mock.calls[0]!; + const message = written as typeof messageWithPending; + // Still 2 parts (text + commit), not 3 + expect(message.parts).toHaveLength(2); + expect(message.parts[1]).toEqual(success); + }); + + it("does not throw when updateChatMessage rejects (caller decides what to do)", async () => { + vi.mocked(updateChatMessage).mockResolvedValue({ ok: false, error: "boom" }); + await expect( + persistAssistantDataPart(baseMessage, { + type: "data-commit", + id: "msg_1:commit", + data: { status: "success" }, + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/lib/chat/__tests__/upsertAssistantDataPart.test.ts b/lib/chat/__tests__/upsertAssistantDataPart.test.ts new file mode 100644 index 000000000..28a832407 --- /dev/null +++ b/lib/chat/__tests__/upsertAssistantDataPart.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { upsertAssistantDataPart } from "@/lib/chat/upsertAssistantDataPart"; + +const baseMessage = { + id: "msg_1", + role: "assistant" as const, + parts: [{ type: "text", text: "Hello" }, { type: "step-finish" }] as never[], +}; + +describe("upsertAssistantDataPart", () => { + it("appends a new part when no part with the same {type, id} exists", () => { + const part = { + type: "data-commit" as const, + id: "msg_1:commit", + data: { status: "pending" as const, committed: false, pushed: false }, + }; + const result = upsertAssistantDataPart(baseMessage, part); + expect(result.parts).toHaveLength(3); + expect(result.parts[2]).toEqual(part); + }); + + it("replaces the existing part when {type, id} matches (pending → success)", () => { + const pendingPart = { + type: "data-commit" as const, + id: "msg_1:commit", + data: { status: "pending" as const, committed: false, pushed: false }, + }; + const successPart = { + type: "data-commit" as const, + id: "msg_1:commit", + data: { + status: "success" as const, + committed: true, + pushed: true, + commitSha: "abc123", + url: "https://github.com/owner/repo/commit/abc123", + }, + }; + const afterPending = upsertAssistantDataPart(baseMessage, pendingPart); + const afterSuccess = upsertAssistantDataPart(afterPending, successPart); + expect(afterSuccess.parts).toHaveLength(3); + expect(afterSuccess.parts[2]).toEqual(successPart); + }); + + it("does NOT mutate the input message", () => { + const part = { + type: "data-commit" as const, + id: "msg_1:commit", + data: { status: "pending" as const, committed: false, pushed: false }, + }; + const result = upsertAssistantDataPart(baseMessage, part); + expect(baseMessage.parts).toHaveLength(2); + expect(result.parts).not.toBe(baseMessage.parts); + }); + + it("preserves the other message fields (id, role, etc.)", () => { + const part = { + type: "data-commit" as const, + id: "msg_1:commit", + data: { status: "pending" as const, committed: false, pushed: false }, + }; + const result = upsertAssistantDataPart(baseMessage, part); + expect(result.id).toBe(baseMessage.id); + expect(result.role).toBe(baseMessage.role); + }); + + it("treats different ids as different parts (appends a second one)", () => { + const partA = { + type: "data-commit" as const, + id: "msg_1:commit-a", + data: { status: "pending" as const, committed: false, pushed: false }, + }; + const partB = { + type: "data-commit" as const, + id: "msg_1:commit-b", + data: { status: "pending" as const, committed: false, pushed: false }, + }; + const result = upsertAssistantDataPart(upsertAssistantDataPart(baseMessage, partA), partB); + expect(result.parts).toHaveLength(4); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/autoCommitChatTurn.test.ts b/lib/chat/auto-commit/__tests__/autoCommitChatTurn.test.ts new file mode 100644 index 000000000..d0ab5ec96 --- /dev/null +++ b/lib/chat/auto-commit/__tests__/autoCommitChatTurn.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; +import { hasAutoCommitChanges } from "@/lib/chat/auto-commit/hasAutoCommitChanges"; +import { runAutoCommit } from "@/lib/chat/auto-commit/runAutoCommit"; +import { sendCommitChunk } from "@/lib/chat/auto-commit/sendCommitChunk"; +import { persistAssistantDataPart } from "@/lib/chat/persistAssistantDataPart"; +import type { UIMessageChunk } from "ai"; + +vi.mock("@/lib/chat/auto-commit/hasAutoCommitChanges", () => ({ + hasAutoCommitChanges: vi.fn(), +})); +vi.mock("@/lib/chat/auto-commit/runAutoCommit", () => ({ + runAutoCommit: vi.fn(), +})); +vi.mock("@/lib/chat/auto-commit/sendCommitChunk", () => ({ + sendCommitChunk: vi.fn(), +})); +vi.mock("@/lib/chat/persistAssistantDataPart", () => ({ + persistAssistantDataPart: vi.fn(), +})); + +const writable = new WritableStream(); +const baseMessage = { + id: "asst-msg-1", + role: "assistant" as const, + parts: [{ type: "text", text: "ok" }], +}; + +const baseInput = { + writable, + responseMessage: baseMessage as never, + finishReason: "stop", + sessionId: "session-1", + sessionTitle: "test", + repoOwner: "recoupable", + repoName: "api", + sandboxState: { type: "vercel" as const, sandboxId: "sb_123" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("autoCommitChatTurn", () => { + describe("gating (canAutoCommit)", () => { + it("skips entirely when finishReason is 'tool-calls' (intermediate turn)", async () => { + await autoCommitChatTurn({ ...baseInput, finishReason: "tool-calls" }); + expect(hasAutoCommitChanges).not.toHaveBeenCalled(); + expect(runAutoCommit).not.toHaveBeenCalled(); + expect(sendCommitChunk).not.toHaveBeenCalled(); + expect(persistAssistantDataPart).not.toHaveBeenCalled(); + }); + + it("skips entirely when repoOwner is undefined", async () => { + await autoCommitChatTurn({ ...baseInput, repoOwner: undefined }); + expect(hasAutoCommitChanges).not.toHaveBeenCalled(); + }); + + it("skips entirely when repoName is undefined", async () => { + await autoCommitChatTurn({ ...baseInput, repoName: undefined }); + expect(hasAutoCommitChanges).not.toHaveBeenCalled(); + }); + + it("skips entirely when sandboxState is undefined", async () => { + await autoCommitChatTurn({ ...baseInput, sandboxState: undefined }); + expect(hasAutoCommitChanges).not.toHaveBeenCalled(); + }); + }); + + describe("no-changes path", () => { + it("checks for changes but emits no chunks and does not persist", async () => { + vi.mocked(hasAutoCommitChanges).mockResolvedValue(false); + await autoCommitChatTurn(baseInput); + expect(hasAutoCommitChanges).toHaveBeenCalledTimes(1); + expect(runAutoCommit).not.toHaveBeenCalled(); + expect(sendCommitChunk).not.toHaveBeenCalled(); + expect(persistAssistantDataPart).not.toHaveBeenCalled(); + }); + }); + + describe("happy path", () => { + beforeEach(() => { + vi.mocked(hasAutoCommitChanges).mockResolvedValue(true); + vi.mocked(runAutoCommit).mockResolvedValue({ + committed: true, + pushed: true, + commitSha: "abc123", + commitMessage: "feat: thing", + }); + }); + + it("emits pending → resolved chunks and persists the resolved data-commit part", async () => { + await autoCommitChatTurn(baseInput); + + expect(sendCommitChunk).toHaveBeenCalledTimes(2); + expect(sendCommitChunk).toHaveBeenNthCalledWith( + 1, + writable, + "asst-msg-1:commit", + expect.objectContaining({ status: "pending" }), + ); + expect(sendCommitChunk).toHaveBeenNthCalledWith( + 2, + writable, + "asst-msg-1:commit", + expect.objectContaining({ + status: "success", + commitSha: "abc123", + url: "https://github.com/recoupable/api/commit/abc123", + }), + ); + + expect(persistAssistantDataPart).toHaveBeenCalledTimes(1); + const [calledMessage, calledPart] = vi.mocked(persistAssistantDataPart).mock.calls[0]!; + expect((calledMessage as { id: string }).id).toBe("asst-msg-1"); + expect(calledPart).toMatchObject({ + type: "data-commit", + id: "asst-msg-1:commit", + data: { status: "success", commitSha: "abc123" }, + }); + }); + + it("forwards sessionId, sessionTitle, repos, sandboxState to runAutoCommit", async () => { + await autoCommitChatTurn(baseInput); + expect(runAutoCommit).toHaveBeenCalledWith({ + sessionId: "session-1", + sessionTitle: "test", + repoOwner: "recoupable", + repoName: "api", + sandboxState: baseInput.sandboxState, + }); + }); + + it("defaults sessionTitle to empty string when undefined", async () => { + await autoCommitChatTurn({ ...baseInput, sessionTitle: undefined }); + expect(runAutoCommit).toHaveBeenCalledWith(expect.objectContaining({ sessionTitle: "" })); + }); + }); + + describe("error path (commit failed)", () => { + it("emits resolved chunk with status='error' but does NOT skip persistence", async () => { + vi.mocked(hasAutoCommitChanges).mockResolvedValue(true); + vi.mocked(runAutoCommit).mockResolvedValue({ + committed: false, + pushed: false, + error: "Failed to stage changes", + }); + + await autoCommitChatTurn(baseInput); + + expect(sendCommitChunk).toHaveBeenNthCalledWith( + 2, + writable, + "asst-msg-1:commit", + expect.objectContaining({ status: "error", error: "Failed to stage changes" }), + ); + // We still persist the error state so the GitDataPartCard + // renders "Commit failed" on refresh. + expect(persistAssistantDataPart).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/buildCommitData.test.ts b/lib/chat/auto-commit/__tests__/buildCommitData.test.ts new file mode 100644 index 000000000..e7577497d --- /dev/null +++ b/lib/chat/auto-commit/__tests__/buildCommitData.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from "vitest"; +import { buildCommitData } from "@/lib/chat/auto-commit/buildCommitData"; + +describe("buildCommitData", () => { + describe("success path", () => { + it("returns status='success' with the commit url when the commit was pushed", () => { + const data = buildCommitData( + { + committed: true, + pushed: true, + commitSha: "abc123", + commitMessage: "feat: thing", + }, + "recoupable", + "api", + ); + expect(data).toEqual({ + status: "success", + committed: true, + pushed: true, + commitMessage: "feat: thing", + commitSha: "abc123", + url: "https://github.com/recoupable/api/commit/abc123", + }); + }); + + it("omits the url when the commit landed locally but wasn't pushed", () => { + const data = buildCommitData( + { + committed: true, + pushed: false, + commitSha: "abc123", + commitMessage: "feat: thing", + }, + "recoupable", + "api", + ); + expect(data.url).toBeUndefined(); + expect(data.status).toBe("success"); + }); + + it("omits the url when commitSha is missing (paranoia)", () => { + const data = buildCommitData( + { committed: true, pushed: true, commitMessage: "feat: thing" }, + "recoupable", + "api", + ); + expect(data.url).toBeUndefined(); + }); + }); + + describe("error path", () => { + it("returns status='error' with the error message", () => { + const data = buildCommitData( + { + committed: false, + pushed: false, + error: "Failed to stage changes", + }, + "recoupable", + "api", + ); + expect(data).toEqual({ + status: "error", + committed: false, + pushed: false, + commitMessage: undefined, + commitSha: undefined, + url: undefined, + error: "Failed to stage changes", + }); + }); + + it("still includes the url when commit pushed but result was marked error (partial success edge)", () => { + // hypothetical: commit pushed, then a later step set error + const data = buildCommitData( + { + committed: true, + pushed: true, + commitSha: "abc123", + commitMessage: "feat: thing", + error: "post-push hook failed", + }, + "recoupable", + "api", + ); + expect(data.status).toBe("error"); + expect(data.url).toBe("https://github.com/recoupable/api/commit/abc123"); + }); + }); + + describe("skipped path", () => { + it("returns status='skipped' when nothing was committed (no changes)", () => { + const data = buildCommitData({ committed: false, pushed: false }, "recoupable", "api"); + expect(data).toEqual({ + status: "skipped", + committed: false, + pushed: false, + }); + }); + }); + + describe("url encoding", () => { + it("encodes owner / repo / sha in the URL path", () => { + const data = buildCommitData( + { committed: true, pushed: true, commitSha: "abc/def" }, + "owner with space", + "repo+name", + ); + expect(data.url).toBe("https://github.com/owner%20with%20space/repo%2Bname/commit/abc%2Fdef"); + }); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/generateCommitMessage.test.ts b/lib/chat/auto-commit/__tests__/generateCommitMessage.test.ts new file mode 100644 index 000000000..34e9942d9 --- /dev/null +++ b/lib/chat/auto-commit/__tests__/generateCommitMessage.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { generateCommitMessage } from "@/lib/chat/auto-commit/generateCommitMessage"; +import generateText from "@/lib/ai/generateText"; +import type { ExecResult, Sandbox } from "@/lib/sandbox/abstraction"; + +vi.mock("@/lib/ai/generateText", () => ({ default: vi.fn() })); + +const ok = (stdout = "", stderr = ""): ExecResult => ({ + success: true, + exitCode: 0, + stdout, + stderr, + truncated: false, +}); + +function makeSandbox(handlers: Record = {}) { + const exec = vi.fn((cmd: string) => { + for (const [pattern, result] of Object.entries(handlers)) { + if (cmd.includes(pattern)) return Promise.resolve(result); + } + return Promise.resolve(ok()); + }); + return { exec } as unknown as Sandbox; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("generateCommitMessage", () => { + it("returns the LLM-generated message when the gateway succeeds", async () => { + vi.mocked(generateText).mockResolvedValue({ + text: "feat: add hello world endpoint", + } as never); + const sandbox = makeSandbox({ + "git diff --cached": ok("some diff content"), + }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "test session"); + expect(msg).toBe("feat: add hello world endpoint"); + }); + + it("falls back to default message when staged diff is empty", async () => { + const sandbox = makeSandbox({ "git diff --cached": ok("") }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "session"); + expect(msg).toBe("chore: update repository changes"); + expect(generateText).not.toHaveBeenCalled(); + }); + + it("falls back to default message when generateText rejects", async () => { + vi.mocked(generateText).mockRejectedValue(new Error("gateway down")); + const sandbox = makeSandbox({ "git diff --cached": ok("diff") }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "session"); + expect(msg).toBe("chore: update repository changes"); + }); + + it("truncates the LLM-generated message to 72 chars", async () => { + const longMessage = "feat: " + "x".repeat(200); + vi.mocked(generateText).mockResolvedValue({ text: longMessage } as never); + const sandbox = makeSandbox({ "git diff --cached": ok("diff") }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "session"); + expect(msg.length).toBeLessThanOrEqual(72); + }); + + it("takes only the first line of the LLM output (strips trailing prose)", async () => { + vi.mocked(generateText).mockResolvedValue({ + text: "feat: clean header\n\nMore details about the change.", + } as never); + const sandbox = makeSandbox({ "git diff --cached": ok("diff") }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "session"); + expect(msg).toBe("feat: clean header"); + }); + + it("falls back when LLM returns an empty string", async () => { + vi.mocked(generateText).mockResolvedValue({ text: "" } as never); + const sandbox = makeSandbox({ "git diff --cached": ok("diff") }); + const msg = await generateCommitMessage(sandbox, "/sandbox", "session"); + expect(msg).toBe("chore: update repository changes"); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/hasAutoCommitChanges.test.ts b/lib/chat/auto-commit/__tests__/hasAutoCommitChanges.test.ts new file mode 100644 index 000000000..d0e075eb8 --- /dev/null +++ b/lib/chat/auto-commit/__tests__/hasAutoCommitChanges.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { hasAutoCommitChanges } from "@/lib/chat/auto-commit/hasAutoCommitChanges"; +import { connectSandbox } from "@/lib/sandbox/factory"; + +vi.mock("@/lib/sandbox/factory", () => ({ + connectSandbox: vi.fn(), +})); + +function buildSandbox(execResult: { success: boolean; stdout: string; stderr?: string }) { + return { + type: "vercel" as const, + workingDirectory: "/sandbox/repo", + exec: vi.fn().mockResolvedValue({ + success: execResult.success, + stdout: execResult.stdout, + stderr: execResult.stderr ?? "", + exitCode: execResult.success ? 0 : 1, + truncated: false, + }), + } as never; +} + +const SANDBOX_STATE = { type: "vercel" as const, sandboxId: "sb_123" } as never; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("hasAutoCommitChanges", () => { + it("returns true when git status --porcelain has output (changes present)", async () => { + vi.mocked(connectSandbox).mockResolvedValue( + buildSandbox({ success: true, stdout: "M file.txt\n" }), + ); + expect(await hasAutoCommitChanges({ sandboxState: SANDBOX_STATE })).toBe(true); + }); + + it("returns false when git status --porcelain has empty output (no changes)", async () => { + vi.mocked(connectSandbox).mockResolvedValue(buildSandbox({ success: true, stdout: "" })); + expect(await hasAutoCommitChanges({ sandboxState: SANDBOX_STATE })).toBe(false); + }); + + it("returns false when git status --porcelain has only whitespace", async () => { + vi.mocked(connectSandbox).mockResolvedValue(buildSandbox({ success: true, stdout: " \n\n" })); + expect(await hasAutoCommitChanges({ sandboxState: SANDBOX_STATE })).toBe(false); + }); + + it("returns true (fail-open) when git status itself fails — lets runAutoCommit surface the real error", async () => { + vi.mocked(connectSandbox).mockResolvedValue( + buildSandbox({ success: false, stdout: "", stderr: "not a git repo" }), + ); + expect(await hasAutoCommitChanges({ sandboxState: SANDBOX_STATE })).toBe(true); + }); + + it("returns true (fail-open) when the sandbox connect rejects — same rationale", async () => { + vi.mocked(connectSandbox).mockRejectedValue(new Error("sandbox gone")); + expect(await hasAutoCommitChanges({ sandboxState: SANDBOX_STATE })).toBe(true); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/performAutoCommit.test.ts b/lib/chat/auto-commit/__tests__/performAutoCommit.test.ts new file mode 100644 index 000000000..8290430c0 --- /dev/null +++ b/lib/chat/auto-commit/__tests__/performAutoCommit.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { performAutoCommit } from "@/lib/chat/auto-commit/performAutoCommit"; +import generateText from "@/lib/ai/generateText"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; +import type { ExecResult, Sandbox } from "@/lib/sandbox/abstraction"; + +vi.mock("@/lib/ai/generateText", () => ({ default: vi.fn() })); +vi.mock("@/lib/github/getServiceGithubToken", () => ({ + getServiceGithubToken: vi.fn(), +})); + +const ok = (stdout = "", stderr = ""): ExecResult => ({ + success: true, + exitCode: 0, + stdout, + stderr, + truncated: false, +}); + +const fail = (stdout = "", stderr = ""): ExecResult => ({ + success: false, + exitCode: 1, + stdout, + stderr, + truncated: false, +}); + +function makeSandbox(handlers: Record = {}) { + const exec = vi.fn((cmd: string) => { + for (const [pattern, result] of Object.entries(handlers)) { + if (cmd.includes(pattern)) return Promise.resolve(result); + } + return Promise.resolve(ok()); + }); + const sandbox = { + type: "vercel" as const, + workingDirectory: "/sandbox/repo", + exec, + } as unknown as Sandbox; + return { sandbox, exec }; +} + +const baseParams = { + sessionId: "session-1", + sessionTitle: "test session", + repoOwner: "recoupable", + repoName: "api", +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getServiceGithubToken).mockReturnValue(undefined); + vi.mocked(generateText).mockResolvedValue({ + text: "feat: add example file", + } as never); +}); + +describe("performAutoCommit", () => { + describe("no changes path", () => { + it("returns { committed:false, pushed:false } when git status is empty", async () => { + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok(""), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result).toEqual({ committed: false, pushed: false }); + // Should not have called add/commit/push when nothing to stage + expect(exec).not.toHaveBeenCalledWith( + expect.stringContaining("git add -A"), + expect.any(String), + expect.any(Number), + ); + }); + + it("returns { committed:false, pushed:false } when git status itself fails", async () => { + const { sandbox } = makeSandbox({ + "git status --porcelain": fail("not a git repo"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result).toEqual({ committed: false, pushed: false }); + }); + }); + + describe("happy path", () => { + it("commits AND pushes when changes are present", async () => { + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff content"), + "git rev-parse HEAD": ok("abc123def456"), + "git symbolic-ref --short HEAD": ok("feat/branch"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + + expect(result.committed).toBe(true); + expect(result.pushed).toBe(true); + expect(result.commitMessage).toBe("feat: add example file"); + expect(result.commitSha).toBe("abc123def456"); + expect(result.error).toBeUndefined(); + + // Verify command sequence by call ordering + const calls = exec.mock.calls.map(c => c[0]); + const addIdx = calls.findIndex(c => c.includes("git add -A")); + const commitIdx = calls.findIndex(c => c.startsWith("git commit")); + const pushIdx = calls.findIndex(c => c.includes("git push")); + expect(addIdx).toBeGreaterThanOrEqual(0); + expect(commitIdx).toBeGreaterThan(addIdx); + expect(pushIdx).toBeGreaterThan(commitIdx); + }); + + it("pushes the current branch via `git push -u origin `", async () => { + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("custom-branch"), + }); + await performAutoCommit({ sandbox, ...baseParams }); + expect(exec).toHaveBeenCalledWith( + expect.stringContaining("git push -u origin custom-branch"), + expect.any(String), + expect.any(Number), + ); + }); + + it("disables interactive prompts on push (GIT_TERMINAL_PROMPT=0)", async () => { + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + await performAutoCommit({ sandbox, ...baseParams }); + const pushCall = exec.mock.calls.find(c => c[0].includes("git push")); + expect(pushCall?.[0]).toContain("GIT_TERMINAL_PROMPT=0"); + }); + }); + + describe("error paths", () => { + it("returns { committed:false, pushed:false, error } when git add fails", async () => { + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git add -A": fail("permission denied"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.committed).toBe(false); + expect(result.pushed).toBe(false); + expect(result.error).toMatch(/stage|add/i); + }); + + it("returns { committed:false, pushed:false, error } when git commit fails", async () => { + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git commit": fail("nothing to commit"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.committed).toBe(false); + expect(result.pushed).toBe(false); + expect(result.error).toBeDefined(); + }); + + it("returns { committed:true, pushed:false, error } when push fails after commit succeeds", async () => { + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("commitsha"), + "git symbolic-ref --short HEAD": ok("main"), + "git push": fail("network unreachable"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.committed).toBe(true); + expect(result.pushed).toBe(false); + expect(result.commitSha).toBe("commitsha"); + expect(result.error).toMatch(/push/i); + }); + }); + + describe("commit message generation", () => { + it("uses the LLM-generated commit message on success", async () => { + vi.mocked(generateText).mockResolvedValue({ + text: "fix: update header logic", + } as never); + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.commitMessage).toBe("fix: update header logic"); + }); + + it("falls back to default message when generateText rejects", async () => { + vi.mocked(generateText).mockRejectedValue(new Error("gateway down")); + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.committed).toBe(true); + expect(result.commitMessage).toBe("chore: update repository changes"); + }); + + it("falls back to default message when staged diff is empty (rare race)", async () => { + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok(""), // empty diff + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.commitMessage).toBe("chore: update repository changes"); + expect(generateText).not.toHaveBeenCalled(); + }); + + it("truncates the LLM-generated message to 72 chars", async () => { + const longMessage = "feat: " + "x".repeat(200); + vi.mocked(generateText).mockResolvedValue({ text: longMessage } as never); + const { sandbox } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + const result = await performAutoCommit({ sandbox, ...baseParams }); + expect(result.commitMessage!.length).toBeLessThanOrEqual(72); + }); + }); + + describe("github auth url", () => { + it("sets `git remote set-url origin` with x-access-token URL when GITHUB_TOKEN is set", async () => { + vi.mocked(getServiceGithubToken).mockReturnValue("ghp_test_token_value"); + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + await performAutoCommit({ sandbox, ...baseParams }); + const remoteCall = exec.mock.calls.find(c => c[0].includes("git remote set-url")); + expect(remoteCall?.[0]).toContain("x-access-token:ghp_test_token_value"); + expect(remoteCall?.[0]).toContain("github.com/recoupable/api"); + }); + + it("does NOT touch the remote when GITHUB_TOKEN is missing", async () => { + vi.mocked(getServiceGithubToken).mockReturnValue(undefined); + const { sandbox, exec } = makeSandbox({ + "git status --porcelain": ok("M file.txt"), + "git diff --cached": ok("diff"), + "git rev-parse HEAD": ok("sha"), + "git symbolic-ref --short HEAD": ok("main"), + }); + await performAutoCommit({ sandbox, ...baseParams }); + const remoteCall = exec.mock.calls.find(c => c[0].includes("git remote set-url")); + expect(remoteCall).toBeUndefined(); + }); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/runAutoCommit.test.ts b/lib/chat/auto-commit/__tests__/runAutoCommit.test.ts new file mode 100644 index 000000000..16a9dac5b --- /dev/null +++ b/lib/chat/auto-commit/__tests__/runAutoCommit.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { runAutoCommit } from "@/lib/chat/auto-commit/runAutoCommit"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { performAutoCommit } from "@/lib/chat/auto-commit/performAutoCommit"; + +vi.mock("@/lib/sandbox/factory", () => ({ connectSandbox: vi.fn() })); +vi.mock("@/lib/chat/auto-commit/performAutoCommit", () => ({ + performAutoCommit: vi.fn(), +})); + +const SANDBOX_STATE = { type: "vercel", sandboxId: "sb_123" } as never; + +const baseParams = { + sessionId: "session-1", + sessionTitle: "test", + repoOwner: "recoupable", + repoName: "api", + sandboxState: SANDBOX_STATE, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("runAutoCommit", () => { + it("forwards sandbox + caller fields to performAutoCommit", async () => { + const sandboxStub = { + type: "vercel" as const, + workingDirectory: "/sandbox/repo", + exec: vi.fn(), + } as never; + vi.mocked(connectSandbox).mockResolvedValue(sandboxStub); + vi.mocked(performAutoCommit).mockResolvedValue({ + committed: true, + pushed: true, + commitSha: "abc", + commitMessage: "feat: thing", + }); + + const result = await runAutoCommit(baseParams); + + expect(connectSandbox).toHaveBeenCalledWith(SANDBOX_STATE); + expect(performAutoCommit).toHaveBeenCalledWith({ + sandbox: sandboxStub, + sessionId: "session-1", + sessionTitle: "test", + repoOwner: "recoupable", + repoName: "api", + }); + expect(result).toEqual({ + committed: true, + pushed: true, + commitSha: "abc", + commitMessage: "feat: thing", + }); + }); + + it("returns the AutoCommitResult unchanged on success", async () => { + vi.mocked(connectSandbox).mockResolvedValue({} as never); + vi.mocked(performAutoCommit).mockResolvedValue({ + committed: false, + pushed: false, + }); + expect(await runAutoCommit(baseParams)).toEqual({ + committed: false, + pushed: false, + }); + }); + + it("returns { committed:false, pushed:false, error } when connectSandbox rejects (does NOT throw)", async () => { + vi.mocked(connectSandbox).mockRejectedValue(new Error("sandbox unreachable")); + const result = await runAutoCommit(baseParams); + expect(result.committed).toBe(false); + expect(result.pushed).toBe(false); + expect(result.error).toMatch(/sandbox unreachable|auto-commit/i); + }); + + it("returns { committed:false, pushed:false, error } when performAutoCommit rejects", async () => { + vi.mocked(connectSandbox).mockResolvedValue({} as never); + vi.mocked(performAutoCommit).mockRejectedValue(new Error("boom")); + const result = await runAutoCommit(baseParams); + expect(result.committed).toBe(false); + expect(result.pushed).toBe(false); + expect(result.error).toBeDefined(); + }); +}); diff --git a/lib/chat/auto-commit/__tests__/sendCommitChunk.test.ts b/lib/chat/auto-commit/__tests__/sendCommitChunk.test.ts new file mode 100644 index 000000000..e42b9b59d --- /dev/null +++ b/lib/chat/auto-commit/__tests__/sendCommitChunk.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { sendCommitChunk } from "@/lib/chat/auto-commit/sendCommitChunk"; +import type { CommitData } from "@/lib/chat/auto-commit/buildCommitData"; + +const PENDING: CommitData = { + status: "skipped", + committed: false, + pushed: false, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("sendCommitChunk", () => { + it("writes the `data-commit` chunk into the writable with id + data", async () => { + const written: unknown[] = []; + const writable = new WritableStream({ + write(chunk) { + written.push(chunk); + }, + }); + + await sendCommitChunk(writable, "msg_abc:commit", PENDING); + + expect(written).toEqual([{ type: "data-commit", id: "msg_abc:commit", data: PENDING }]); + }); + + it("releases the writer lock after writing so subsequent writes can acquire one", async () => { + const writable = new WritableStream({ write() {} }); + await sendCommitChunk(writable, "id1", PENDING); + // If lock weren't released, this getWriter() would throw + const writer = writable.getWriter(); + expect(writer).toBeDefined(); + writer.releaseLock(); + }); + + it("releases the writer lock even when the underlying write rejects", async () => { + const writable = new WritableStream({ + write() { + throw new Error("sink boom"); + }, + }); + await expect(sendCommitChunk(writable, "id1", PENDING)).rejects.toThrow(/sink boom/); + // Lock should still be released + expect(() => { + const writer = writable.getWriter(); + writer.releaseLock(); + }).not.toThrow(); + }); +}); diff --git a/lib/chat/auto-commit/autoCommitChatTurn.ts b/lib/chat/auto-commit/autoCommitChatTurn.ts new file mode 100644 index 000000000..73c22293d --- /dev/null +++ b/lib/chat/auto-commit/autoCommitChatTurn.ts @@ -0,0 +1,95 @@ +import type { UIMessageChunk } from "ai"; +import { hasAutoCommitChanges } from "@/lib/chat/auto-commit/hasAutoCommitChanges"; +import { runAutoCommit } from "@/lib/chat/auto-commit/runAutoCommit"; +import { sendCommitChunk } from "@/lib/chat/auto-commit/sendCommitChunk"; +import { buildCommitData } from "@/lib/chat/auto-commit/buildCommitData"; +import { persistAssistantDataPart } from "@/lib/chat/persistAssistantDataPart"; +import type { SandboxState } from "@/lib/sandbox/factory"; + +interface AssistantMessage { + id: string; + role: string; + parts: ReadonlyArray; +} + +export interface AutoCommitChatTurnInput { + writable: WritableStream; + responseMessage: AssistantMessage; + finishReason: string | undefined; + sessionId: string; + sessionTitle?: string; + repoOwner?: string; + repoName?: string; + /** + * Discriminated SandboxState (already wrapped with the `type` tag + * by the caller — DurableAgentContext carries the raw VercelState + * so the workflow body composes the SandboxState before calling + * here). + */ + sandboxState?: SandboxState; +} + +/** + * Runs the full auto-commit flow at the tail of a successful chat + * turn: gates on `canAutoCommit`, checks for sandbox changes, emits + * the `data-commit` chunks (pending → resolved), runs the + * sandbox-side commit + push, and persists the resolved part onto + * the assistant message so the `GitDataPartCard` UI renders on + * page refresh. + * + * Extracted from `runAgentWorkflow` so the workflow body stays a + * thin orchestrator. Mirrors open-agents' + * `apps/web/app/workflows/chat.ts:canAutoCommit` block. + * + * Skips silently when: + * - finishReason is `"tool-calls"` (intermediate turn, not natural finish) + * - either `repoOwner` or `repoName` is missing (no GitHub link to make) + * - `sandboxState` is missing (no sandbox to commit in) + * - `hasAutoCommitChanges` returns false (`git status --porcelain` empty) + * + * Never throws — the inner steps swallow their errors and surface + * the result via `AutoCommitResult.error`, which `buildCommitData` + * shapes into a `status: "error"` chunk that's still persisted so + * the UI shows "Commit failed" on refresh. + */ +export async function autoCommitChatTurn(input: AutoCommitChatTurnInput): Promise { + const canAutoCommit = + input.finishReason !== "tool-calls" && + input.repoOwner !== undefined && + input.repoName !== undefined && + input.sandboxState !== undefined; + if (!canAutoCommit) return; + + const hasChanges = await hasAutoCommitChanges({ + sandboxState: input.sandboxState!, + }); + if (!hasChanges) return; + + const commitPartId = `${input.responseMessage.id}:commit`; + + // Emit the pending chunk BEFORE the commit step so the UI can + // show a spinner while git add/commit/push run. + await sendCommitChunk(input.writable, commitPartId, { + status: "pending", + committed: false, + pushed: false, + }); + + const commitResult = await runAutoCommit({ + sessionId: input.sessionId, + sessionTitle: input.sessionTitle ?? "", + repoOwner: input.repoOwner!, + repoName: input.repoName!, + sandboxState: input.sandboxState!, + }); + const resolvedData = buildCommitData(commitResult, input.repoOwner!, input.repoName!); + await sendCommitChunk(input.writable, commitPartId, resolvedData); + + // Persist the resolved data-commit part onto the assistant + // message so the GitDataPartCard renders on page refresh. + await persistAssistantDataPart(input.responseMessage, { + type: "data-commit", + id: commitPartId, + data: resolvedData, + }); +} diff --git a/lib/chat/auto-commit/buildCommitData.ts b/lib/chat/auto-commit/buildCommitData.ts new file mode 100644 index 000000000..982522132 --- /dev/null +++ b/lib/chat/auto-commit/buildCommitData.ts @@ -0,0 +1,85 @@ +import type { AutoCommitResult } from "@/lib/chat/auto-commit/performAutoCommit"; + +/** + * Data shape carried by the `data-commit` UI chunk emitted from + * `runAgentWorkflow` after auto-commit runs. Mirrors open-agents' + * `WebAgentCommitData` byte-for-byte so the shared chat UI can render + * it without conditional logic on the source surface. + */ +export interface CommitData { + /** + * `"pending"` is set when the workflow emits the initial chunk + * before the commit step runs (so the UI can show a spinner). + * `buildCommitData` itself only ever produces the terminal three + * statuses; pending is constructed inline in `runAgentWorkflow`. + */ + status: "pending" | "success" | "error" | "skipped"; + committed: boolean; + pushed: boolean; + commitMessage?: string; + commitSha?: string; + /** + * GitHub commit URL. Set only when the commit was both committed + * AND pushed AND has a SHA — i.e., the link will actually resolve + * on GitHub. + */ + url?: string; + error?: string; +} + +function buildGitHubCommitUrl(repoOwner: string, repoName: string, commitSha: string): string { + return `https://github.com/${encodeURIComponent(repoOwner)}/${encodeURIComponent(repoName)}/commit/${encodeURIComponent(commitSha)}`; +} + +/** + * Shapes an `AutoCommitResult` into the UI chunk payload. + * + * Resolution order: + * - `result.error` set → `status: "error"` (preserves any commit/push + * metadata that landed so the UI can still link to the partial + * result) + * - `result.committed` → `status: "success"` + * - otherwise → `status: "skipped"` + * + * Mirrors open-agents' `apps/web/app/workflows/chat.ts:buildCommitData`. + */ +export function buildCommitData( + result: AutoCommitResult, + repoOwner: string, + repoName: string, +): CommitData { + if (result.error) { + return { + status: "error", + committed: result.committed, + pushed: result.pushed, + commitMessage: result.commitMessage, + commitSha: result.commitSha, + url: + result.pushed && result.commitSha + ? buildGitHubCommitUrl(repoOwner, repoName, result.commitSha) + : undefined, + error: result.error, + }; + } + + if (result.committed) { + return { + status: "success", + committed: result.committed, + pushed: result.pushed, + commitMessage: result.commitMessage, + commitSha: result.commitSha, + url: + result.pushed && result.commitSha + ? buildGitHubCommitUrl(repoOwner, repoName, result.commitSha) + : undefined, + }; + } + + return { + status: "skipped", + committed: false, + pushed: false, + }; +} diff --git a/lib/chat/auto-commit/generateCommitMessage.ts b/lib/chat/auto-commit/generateCommitMessage.ts new file mode 100644 index 000000000..93c3af1d1 --- /dev/null +++ b/lib/chat/auto-commit/generateCommitMessage.ts @@ -0,0 +1,62 @@ +import type { Sandbox } from "@/lib/sandbox/abstraction"; +import generateText from "@/lib/ai/generateText"; + +const FALLBACK_COMMIT_MESSAGE = "chore: update repository changes"; +const COMMIT_MESSAGE_MAX_LENGTH = 72; +const DIFF_PROMPT_TRUNCATE_CHARS = 8000; +const TIMEOUT_DIFF_MS = 30_000; + +/** + * Asks the gateway to produce a conventional-commit-formatted + * message describing the staged diff. Falls back to a sane default + * when: + * - the staged diff is empty (rare race; the caller has already + * verified there's something to commit, but `git diff --cached` + * can race with the actual stage) + * - the gateway call throws + * - the gateway returns an empty/whitespace string + * + * Truncates to 72 chars to fit standard commit-message-line width. + * Only the first line of the LLM output is used — the prompt asks + * for a single line but models sometimes follow with body text. + * + * Ports the inline `generateCommitMessage` helper that previously + * lived inside `performAutoCommit.ts`. Extracting it lets callers + * compose alternative commit message strategies without re-running + * the full performAutoCommit sandbox pipeline. + */ +export async function generateCommitMessage( + sandbox: Sandbox, + cwd: string, + sessionTitle: string, +): Promise { + try { + const stagedDiffResult = await sandbox.exec("git diff --cached", cwd, TIMEOUT_DIFF_MS); + const diffForCommit = stagedDiffResult.stdout; + + if (!diffForCommit.trim()) { + return FALLBACK_COMMIT_MESSAGE; + } + + const result = await generateText({ + model: "openai/gpt-5.4-nano", + prompt: `Generate a concise git commit message for these changes. Use conventional commit format (e.g., "feat:", "fix:", "refactor:"). One line only, max ${COMMIT_MESSAGE_MAX_LENGTH} characters. + +Session context: ${sessionTitle} + +Diff: +${diffForCommit.slice(0, DIFF_PROMPT_TRUNCATE_CHARS)} + +Respond with ONLY the commit message, nothing else.`, + }); + + const generated = result.text.trim().split("\n")[0]?.trim(); + if (generated && generated.length > 0) { + return generated.slice(0, COMMIT_MESSAGE_MAX_LENGTH); + } + } catch (error) { + console.warn("[auto-commit] Failed to generate commit message:", error); + } + + return FALLBACK_COMMIT_MESSAGE; +} diff --git a/lib/chat/auto-commit/hasAutoCommitChanges.ts b/lib/chat/auto-commit/hasAutoCommitChanges.ts new file mode 100644 index 000000000..bc2b86e05 --- /dev/null +++ b/lib/chat/auto-commit/hasAutoCommitChanges.ts @@ -0,0 +1,47 @@ +import { connectSandbox, type SandboxState } from "@/lib/sandbox/factory"; + +const STATUS_TIMEOUT_MS = 10_000; + +/** + * Quick pre-flight: does the sandbox have anything staged-or-unstaged + * that auto-commit should pick up? Returns true when `git status + * --porcelain` reports any output. + * + * Failure mode: fail-open. If the sandbox connect or the git command + * itself fails, return `true` and let `runAutoCommit` try anyway — + * it will surface the real error in its own `AutoCommitResult`. The + * alternative (returning false on error) would silently skip + * auto-commit on a transient sandbox blip, which we don't want. + * + * Mirrors open-agents' + * `apps/web/app/workflows/chat-post-finish.ts:hasAutoCommitChangesStep`. + */ +export async function hasAutoCommitChanges(params: { + sandboxState: SandboxState; +}): Promise { + "use step"; + console.log("[hasAutoCommitChanges] enter"); + try { + const sandbox = await connectSandbox(params.sandboxState); + const statusResult = await sandbox.exec( + "git status --porcelain", + sandbox.workingDirectory, + STATUS_TIMEOUT_MS, + ); + + if (!statusResult.success) { + console.warn( + "[hasAutoCommitChanges] git status failed; assuming changes present (fail-open)", + { stderr: statusResult.stderr }, + ); + return true; + } + + const hasChanges = statusResult.stdout.trim().length > 0; + console.log("[hasAutoCommitChanges] done", { hasChanges }); + return hasChanges; + } catch (error) { + console.error("[hasAutoCommitChanges] unexpected error (failing open):", error); + return true; + } +} diff --git a/lib/chat/auto-commit/performAutoCommit.ts b/lib/chat/auto-commit/performAutoCommit.ts new file mode 100644 index 000000000..94c918feb --- /dev/null +++ b/lib/chat/auto-commit/performAutoCommit.ts @@ -0,0 +1,128 @@ +import type { Sandbox } from "@/lib/sandbox/abstraction"; +import { generateCommitMessage } from "@/lib/chat/auto-commit/generateCommitMessage"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; + +export interface AutoCommitParams { + sandbox: Sandbox; + sessionId: string; + sessionTitle: string; + repoOwner: string; + repoName: string; +} + +export interface AutoCommitResult { + committed: boolean; + pushed: boolean; + commitMessage?: string; + commitSha?: string; + error?: string; +} + +const TIMEOUT_QUICK_MS = 10_000; +const TIMEOUT_PUSH_MS = 60_000; +const TIMEOUT_RESOLVE_MS = 5_000; + +/** + * Stages all changes in the sandbox, generates a commit message via + * the gateway, commits, and pushes. Ports + * `open-agents/apps/web/lib/chat/auto-commit-direct.ts:performAutoCommit`. + * + * Failure modes are deliberately granular so the caller can surface + * the right UI state: + * - `{ committed: false, pushed: false }` when nothing to commit + * (git status empty, or git status itself failed) + * - `{ committed: false, pushed: false, error }` when stage/commit + * fails + * - `{ committed: true, pushed: false, error }` when the commit + * landed locally but the push failed (caller should retry or + * surface the local sha so the user knows the changes aren't + * remote yet) + * + * Auth: if `GITHUB_TOKEN` is set (the service-account token used to + * clone), the function rewrites `origin` to an x-access-token URL so + * the subsequent push authenticates. When the token is absent the + * remote URL is left alone (public repos / pre-authed remotes still + * work). + */ +export async function performAutoCommit(params: AutoCommitParams): Promise { + const { sandbox, sessionTitle, repoOwner, repoName } = params; + const cwd = sandbox.workingDirectory; + + // 1. Check for uncommitted changes + const statusResult = await sandbox.exec("git status --porcelain", cwd, TIMEOUT_QUICK_MS); + if (!statusResult.success || !statusResult.stdout.trim()) { + return { committed: false, pushed: false }; + } + + // 2. Configure auth on origin so the push can authenticate. + const token = getServiceGithubToken(); + if (token) { + const authUrl = `https://x-access-token:${token}@github.com/${repoOwner}/${repoName}.git`; + await sandbox.exec(`git remote set-url origin "${authUrl}"`, cwd, TIMEOUT_QUICK_MS); + } + + // 3. Stage all changes + const addResult = await sandbox.exec("git add -A", cwd, TIMEOUT_QUICK_MS); + if (!addResult.success) { + return { + committed: false, + pushed: false, + error: "Failed to stage changes", + }; + } + + // 4. Generate commit message (LLM-generated from staged diff, with + // a sane fallback when the gateway fails or the diff is empty). + const commitMessage = await generateCommitMessage(sandbox, cwd, sessionTitle); + + // 5. Commit. Single-quote escaping mirrors open-agents' + // auto-commit-direct so messages containing apostrophes don't + // break the shell. + const escapedMessage = commitMessage.replace(/'/g, "'\\''"); + const commitResult = await sandbox.exec( + `git commit -m '${escapedMessage}'`, + cwd, + TIMEOUT_QUICK_MS, + ); + if (!commitResult.success) { + return { + committed: false, + pushed: false, + error: `Failed to commit: ${commitResult.stdout || commitResult.stderr}`, + }; + } + + const headResult = await sandbox.exec("git rev-parse HEAD", cwd, TIMEOUT_RESOLVE_MS); + const commitSha = headResult.stdout.trim() || undefined; + + // 6. Push. GIT_TERMINAL_PROMPT=0 so a missing/expired token fails + // fast instead of hanging on a credential prompt the workflow + // runtime can't answer. + const branchResult = await sandbox.exec("git symbolic-ref --short HEAD", cwd, TIMEOUT_RESOLVE_MS); + const currentBranch = branchResult.stdout.trim() || "HEAD"; + + const pushResult = await sandbox.exec( + `GIT_TERMINAL_PROMPT=0 git push -u origin ${currentBranch}`, + cwd, + TIMEOUT_PUSH_MS, + ); + if (!pushResult.success) { + console.warn( + `[auto-commit] Push failed for session ${params.sessionId}: ${pushResult.stderr || pushResult.stdout}`, + ); + return { + committed: true, + pushed: false, + commitMessage, + commitSha, + error: "Commit succeeded but push failed", + }; + } + + return { + committed: true, + pushed: true, + commitMessage, + commitSha, + }; +} diff --git a/lib/chat/auto-commit/runAutoCommit.ts b/lib/chat/auto-commit/runAutoCommit.ts new file mode 100644 index 000000000..ca2b73975 --- /dev/null +++ b/lib/chat/auto-commit/runAutoCommit.ts @@ -0,0 +1,50 @@ +import { connectSandbox, type SandboxState } from "@/lib/sandbox/factory"; +import { performAutoCommit, type AutoCommitResult } from "@/lib/chat/auto-commit/performAutoCommit"; + +/** + * Workflow step that runs auto-commit + push in the sandbox. Wraps + * `performAutoCommit` with sandbox connect + global error handling so + * the chat workflow never aborts on a commit-time hiccup — failures + * land in `AutoCommitResult.error`, which the caller surfaces via the + * `data-commit` UI chunk. + * + * Mirrors open-agents' + * `apps/web/app/workflows/chat-post-finish.ts:runAutoCommitStep`. + */ +export async function runAutoCommit(params: { + sessionId: string; + sessionTitle: string; + repoOwner: string; + repoName: string; + sandboxState: SandboxState; +}): Promise { + "use step"; + console.log("[runAutoCommit] enter", { + sessionId: params.sessionId, + repoOwner: params.repoOwner, + repoName: params.repoName, + }); + try { + const sandbox = await connectSandbox(params.sandboxState); + const result = await performAutoCommit({ + sandbox, + sessionId: params.sessionId, + sessionTitle: params.sessionTitle, + repoOwner: params.repoOwner, + repoName: params.repoName, + }); + console.log("[runAutoCommit] done", { + committed: result.committed, + pushed: result.pushed, + hasError: result.error !== undefined, + }); + return result; + } catch (error) { + console.error("[runAutoCommit] unexpected error:", error); + return { + committed: false, + pushed: false, + error: error instanceof Error ? error.message : "Auto-commit failed", + }; + } +} diff --git a/lib/chat/auto-commit/sendCommitChunk.ts b/lib/chat/auto-commit/sendCommitChunk.ts new file mode 100644 index 000000000..b0b60e954 --- /dev/null +++ b/lib/chat/auto-commit/sendCommitChunk.ts @@ -0,0 +1,25 @@ +import type { UIMessageChunk } from "ai"; +import type { CommitData } from "@/lib/chat/auto-commit/buildCommitData"; + +/** + * Emits a `data-commit` UIMessageChunk into the chat workflow's + * writable. Wrapped as a `"use step"` so the chunk flushes durably to + * the SSE client before the workflow moves on (e.g., the "pending" + * status appears in the UI before `runAutoCommit` starts). + * + * Mirrors open-agents' + * `apps/web/app/workflows/chat.ts:sendDataPart`. + */ +export async function sendCommitChunk( + writable: WritableStream, + id: string, + data: CommitData, +): Promise { + "use step"; + const writer = writable.getWriter(); + try { + await writer.write({ type: "data-commit", id, data } as UIMessageChunk); + } finally { + writer.releaseLock(); + } +} diff --git a/lib/chat/handleChatWorkflowStream.ts b/lib/chat/handleChatWorkflowStream.ts index 27961b126..0e7af2f3e 100644 --- a/lib/chat/handleChatWorkflowStream.ts +++ b/lib/chat/handleChatWorkflowStream.ts @@ -14,6 +14,7 @@ import { errorResponse } from "@/lib/networking/errorResponse"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; import { extractOrgId } from "@/lib/recoupable/extractOrgId"; +import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; import type { VercelState } from "@/lib/sandbox/vercel/state"; @@ -117,6 +118,13 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise; +} + +/** + * Workflow-step wrapper that merges a data-part into an assistant + * message and persists the merged message to `chat_messages.parts`. + * + * Carries the `"use step"` directive — the underlying + * `updateChatMessage` Supabase helper is intentionally kept pure so + * the step boundary lives in the chat-domain layer (this file) + * rather than the supabase layer. Mirrors the pattern used by + * `persistAssistantMessage` + `upsertChatMessage`. + * + * Use this when a `data-*` chunk lands AFTER the initial + * `persistAssistantMessage` call already wrote the message — e.g., + * the auto-commit flow's resolved `data-commit` chunk that needs to + * survive page refresh. + * + * Errors from the underlying supabase call are surfaced as logs; + * this function never throws so a transient DB blip can't mark the + * chat workflow run failed. + */ +export async function persistAssistantDataPart( + message: AssistantMessage, + part: DataPart, +): Promise { + "use step"; + console.log("[persistAssistantDataPart] enter", { + messageId: message.id, + partType: part.type, + partId: part.id, + }); + const merged = upsertAssistantDataPart(message, part); + const result = await updateChatMessage(merged.id, merged); + if (!result.ok) { + console.error("[persistAssistantDataPart] update failed:", result.error); + return; + } + console.log("[persistAssistantDataPart] persisted", { + messageId: merged.id, + partCount: merged.parts.length, + }); +} diff --git a/lib/chat/upsertAssistantDataPart.ts b/lib/chat/upsertAssistantDataPart.ts new file mode 100644 index 000000000..4516ba458 --- /dev/null +++ b/lib/chat/upsertAssistantDataPart.ts @@ -0,0 +1,44 @@ +interface DataPart { + type: string; + id: string; + data: unknown; +} + +interface AssistantMessage { + id: string; + role: string; + parts: ReadonlyArray; +} + +/** + * Returns a new assistant message with `part` merged into `parts`: + * - replaces the existing part when one with the same `{type, id}` + * is already present (e.g. pending → success transition for the + * same `data-commit` part) + * - appends otherwise + * + * Pure helper — the input message is not mutated. Mirrors + * open-agents' `upsertAssistantDataPart` in + * `apps/web/app/workflows/chat.ts`. + * + * Used by the auto-commit branch in `runAgentWorkflow` to persist the + * resolved data-commit chunk onto the assistant message so the + * `GitDataPartCard` UI renders on page refresh (not just during the + * live SSE stream). + */ +export function upsertAssistantDataPart( + message: TMessage, + part: DataPart, +): TMessage { + const nextParts = [...message.parts]; + const existingIndex = nextParts.findIndex(p => { + const candidate = p as { type?: string; id?: string }; + return candidate.type === part.type && candidate.id === part.id; + }); + if (existingIndex >= 0) { + nextParts[existingIndex] = part; + } else { + nextParts.push(part); + } + return { ...message, parts: nextParts }; +} diff --git a/lib/github/__tests__/parseGitHubRepoIdentifiers.test.ts b/lib/github/__tests__/parseGitHubRepoIdentifiers.test.ts new file mode 100644 index 000000000..72d6ded0d --- /dev/null +++ b/lib/github/__tests__/parseGitHubRepoIdentifiers.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; + +describe("parseGitHubRepoIdentifiers", () => { + it("parses owner + repo from a plain https URL", () => { + expect(parseGitHubRepoIdentifiers("https://github.com/Myco-WTF/Sweetman")).toEqual({ + owner: "Myco-WTF", + repo: "Sweetman", + }); + }); + + it("strips a trailing .git suffix", () => { + expect(parseGitHubRepoIdentifiers("https://github.com/recoupable/api.git")).toEqual({ + owner: "recoupable", + repo: "api", + }); + }); + + it("strips a trailing slash", () => { + expect(parseGitHubRepoIdentifiers("https://github.com/recoupable/api/")).toEqual({ + owner: "recoupable", + repo: "api", + }); + }); + + it("accepts ssh-style URLs (git@github.com:owner/repo.git)", () => { + expect(parseGitHubRepoIdentifiers("git@github.com:Myco-WTF/Sweetman.git")).toEqual({ + owner: "Myco-WTF", + repo: "Sweetman", + }); + }); + + it("returns null for non-github URLs", () => { + expect(parseGitHubRepoIdentifiers("https://gitlab.com/owner/repo")).toBeNull(); + }); + + it("returns null for the empty string", () => { + expect(parseGitHubRepoIdentifiers("")).toBeNull(); + }); + + it("returns null for an https URL missing the repo segment", () => { + expect(parseGitHubRepoIdentifiers("https://github.com/owner")).toBeNull(); + }); + + it("returns null when input is null/undefined", () => { + expect(parseGitHubRepoIdentifiers(null)).toBeNull(); + expect(parseGitHubRepoIdentifiers(undefined)).toBeNull(); + }); +}); diff --git a/lib/github/parseGitHubRepoIdentifiers.ts b/lib/github/parseGitHubRepoIdentifiers.ts new file mode 100644 index 000000000..eb05bf925 --- /dev/null +++ b/lib/github/parseGitHubRepoIdentifiers.ts @@ -0,0 +1,32 @@ +/** + * Parses a clone URL into `{ owner, repo }`. Used as a fallback when + * a session row was created before the api started persisting + * `repo_owner` / `repo_name` directly — auto-commit needs both to + * compose the GitHub commit URL and to set the remote auth URL on + * push. Returns `null` for non-GitHub URLs and for malformed inputs. + * + * Recognized shapes: + * - `https://github.com//` + * - `https://github.com//.git` + * - `git@github.com:/.git` + * - trailing slashes are tolerated + */ +export function parseGitHubRepoIdentifiers( + cloneUrl: string | null | undefined, +): { owner: string; repo: string } | null { + if (!cloneUrl) return null; + + // ssh form: git@github.com:owner/repo[.git] + const sshMatch = cloneUrl.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (sshMatch) { + return { owner: sshMatch[1]!, repo: sshMatch[2]! }; + } + + // https form: https://github.com/owner/repo[.git][/] + const httpsMatch = cloneUrl.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (httpsMatch) { + return { owner: httpsMatch[1]!, repo: httpsMatch[2]! }; + } + + return null; +} diff --git a/lib/supabase/chat_messages/__tests__/updateChatMessage.test.ts b/lib/supabase/chat_messages/__tests__/updateChatMessage.test.ts new file mode 100644 index 000000000..95735ec18 --- /dev/null +++ b/lib/supabase/chat_messages/__tests__/updateChatMessage.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { updateChatMessage } from "@/lib/supabase/chat_messages/updateChatMessage"; + +const { fromMock, updateMock, eqMock } = vi.hoisted(() => { + const updateMock = vi.fn(); + const eqMock = vi.fn(); + const fromMock = vi.fn(); + return { fromMock, updateMock, eqMock }; +}); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { from: fromMock }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + // chained builder: from(...).update(...).eq(...) → Promise<{error}> + eqMock.mockResolvedValue({ error: null }); + updateMock.mockReturnValue({ eq: eqMock }); + fromMock.mockReturnValue({ update: updateMock }); +}); + +describe("updateChatMessage", () => { + it("UPDATEs the `parts` column on chat_messages keyed by id", async () => { + const parts = [ + { type: "text", text: "hi" }, + { type: "data-commit", id: "x", data: { status: "success" } }, + ]; + const result = await updateChatMessage("msg_abc", parts); + + expect(fromMock).toHaveBeenCalledWith("chat_messages"); + expect(updateMock).toHaveBeenCalledWith({ parts }); + expect(eqMock).toHaveBeenCalledWith("id", "msg_abc"); + expect(result).toEqual({ ok: true }); + }); + + it("returns { ok: false, error } when supabase reports an error (does NOT throw)", async () => { + eqMock.mockResolvedValue({ error: { message: "boom" } }); + const result = await updateChatMessage("msg_abc", []); + expect(result).toEqual({ ok: false, error: "boom" }); + }); + + it("returns { ok: false, error } when the supabase client itself rejects", async () => { + eqMock.mockRejectedValue(new Error("network blip")); + const result = await updateChatMessage("msg_abc", []); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("network blip"); + }); +}); diff --git a/lib/supabase/chat_messages/updateChatMessage.ts b/lib/supabase/chat_messages/updateChatMessage.ts new file mode 100644 index 000000000..8f201db10 --- /dev/null +++ b/lib/supabase/chat_messages/updateChatMessage.ts @@ -0,0 +1,58 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Json } from "@/types/database.types"; + +export interface UpdateChatMessageResult { + ok: boolean; + /** + * Present only when `ok === false`. Kept optional rather than as a + * discriminated union so callers can read `result.error` without + * tripping a narrowing edge case in the Next.js 16 type checker + * (same pattern as `DeductCreditsWithAuditResult`). + */ + error?: string; +} + +/** + * Replaces the `parts` jsonb column on an existing `chat_messages` + * row. UPDATE-only — does NOT insert if the row is missing, so the + * caller must have already persisted the message via + * `upsertChatMessage` first. + * + * Pure supabase wrapper — kept free of any workflow concerns + * (no `"use step"` directive here). Callers that need durable step + * semantics wrap this in a `"use step"` function in `lib/chat/` + * (e.g. `persistAssistantDataPart`). Keeping the step boundary out + * of the supabase layer matches the rest of the codebase + * (`upsertChatMessage`, `updateChat`, etc.) — only domain wrappers + * are step-bound, supabase wrappers stay pure. + * + * Use this when a chunk lands AFTER the initial assistant message + * was already persisted (auto-commit's `data-commit`, future PR data + * parts, etc.) and you need the chunk to survive page refresh. The + * existing `upsertChatMessage` uses `onConflict: "id", + * ignoreDuplicates: true` so a second call would be a no-op — this + * helper exists specifically to bypass that. + * + * Mirrors the UPDATE branch in open-agents' + * `apps/web/lib/db/sessions.ts:upsertChatMessageScoped` (which uses + * a single INSERT-then-UPDATE atomic helper; api keeps the two paths + * separate so the first-insert path remains replay-idempotent). + */ +export async function updateChatMessage( + id: string, + parts: unknown, +): Promise { + try { + const { error } = await supabase + .from("chat_messages") + .update({ parts: parts as Json }) + .eq("id", id); + if (error) return { ok: false, error: error.message }; + return { ok: true }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} From 9562e6b222e3757377225e02199e4e7ae5791a5f Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 26 May 2026 05:46:56 +0530 Subject: [PATCH 04/15] feat(api): migrate /api/sessions/[sessionId]/chats/[chatId] from open-agents (#562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): migrate GET /api/sessions/[sessionId]/chats/[chatId] from open-agents Returns the chat's persisted UI message stream plus its current streaming state so callers can hydrate / refresh a chat view: { chat: { id, modelId, activeStreamId }, isStreaming, messages } `messages` is the raw `parts` JSON for each `chat_messages` row, ordered by `created_at` then `id`. `isStreaming` is derived from `active_stream_id`. Auth via `validateAuthContext` (Privy Bearer / x-api-key); 404 when the session or chat is missing (or when the chat lives in a different session); 403 when the session is owned by a different account. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(api): also migrate PATCH + DELETE /api/sessions/[sessionId]/chats/[chatId] PATCH `{ title?, modelId? }` returns `{ chat }`. At least one field required; both must be non-empty after trim. modelId is stored as-is (no model-variant sanitization until user-preferences are migrated). DELETE returns `{ success: true }`. Refuses with 400 if the chat is the only one in its session. Both reuse the same auth + session-ownership + chat-belongs-to-session gating as the GET. New supabase helpers `lib/supabase/chats/{updateChat,deleteChat}.ts`. 22 new vitest cases. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(chats): drop redundant updated_at stamp in updateChat The `chats` table has a `set_updated_at` Postgres trigger (added in database `20260501000000_open_agents_sessions_and_chats.sql`) that auto-refreshes `updated_at` on every row update. Matches the convention of the other 6 update helpers in `lib/supabase/`. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(chats): drop conditional spread in patch handler Supabase strips undefined values during JSON serialization, so columns with undefined patch values are simply omitted from the PostgREST UPDATE — no need to guard the spread. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(chats): drop unused payload from delete validator The delete handler only needs to know whether validation passed — it doesn't read the auth/session/chat/sibling rows the validator was previously returning. Switch to `NextResponse | null`. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(chats): slim get/patch validators to just what handlers use Get handler reads only the chat row; patch handler reads only the parsed body. Drop the unused auth/session payload from both validator returns. Matches the simplification just made to the delete validator. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(chats): patch handler returns camelCase Chat shape The patch handler was returning the raw Supabase row (snake_case session_id, model_id, etc.) instead of the camelCase wire format documented under the Chat schema. Wrap with toChatResponse so it matches the create endpoint and the docs. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(chats): GET handler returns full chat row via toChatResponse Expands `SessionChatResponse.chat` from `{ id, modelId, activeStreamId }` to the full camelCase wire row (sessionId, title, lastAssistantMessageAt, createdAt, updatedAt). Lets a single helper cover both initial render and in-tab refresh on the open-agents side. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(chats): reject unknown fields on PATCH session chat (.strict()) Honors the documented `additionalProperties: false` contract for `UpdateSessionChatRequest` (docs#209). The zod object previously stripped unknown keys silently; `.strict()` now returns a 400 when the body carries any field other than `title` / `modelId`. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(chats): drop unsupported message arg to zod .strict() The installed zod version's `.strict()` takes no arguments — the custom-message overload broke the production `tsc` build (passed lint + vitest, which don't typecheck the same way). Unknown keys still reject with zod's default "Unrecognized key(s)" 400. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../[sessionId]/chats/[chatId]/route.ts | 81 +++++++ .../deleteSessionChatHandler.test.ts | 60 ++++++ .../__tests__/getSessionChatHandler.test.ts | 116 ++++++++++ .../__tests__/patchSessionChatHandler.test.ts | 102 +++++++++ .../validateDeleteSessionChatRequest.test.ts | 141 ++++++++++++ .../validateGetSessionChatRequest.test.ts | 152 +++++++++++++ .../validatePatchSessionChatRequest.test.ts | 200 ++++++++++++++++++ .../chats/deleteSessionChatHandler.ts | 35 +++ lib/sessions/chats/getSessionChatHandler.ts | 48 +++++ lib/sessions/chats/patchSessionChatHandler.ts | 40 ++++ .../chats/validateDeleteSessionChatRequest.ts | 66 ++++++ .../chats/validateGetSessionChatRequest.ts | 61 ++++++ .../chats/validatePatchSessionChatRequest.ts | 91 ++++++++ lib/supabase/chats/deleteChat.ts | 19 ++ 14 files changed, 1212 insertions(+) create mode 100644 app/api/sessions/[sessionId]/chats/[chatId]/route.ts create mode 100644 lib/sessions/chats/__tests__/deleteSessionChatHandler.test.ts create mode 100644 lib/sessions/chats/__tests__/getSessionChatHandler.test.ts create mode 100644 lib/sessions/chats/__tests__/patchSessionChatHandler.test.ts create mode 100644 lib/sessions/chats/__tests__/validateDeleteSessionChatRequest.test.ts create mode 100644 lib/sessions/chats/__tests__/validateGetSessionChatRequest.test.ts create mode 100644 lib/sessions/chats/__tests__/validatePatchSessionChatRequest.test.ts create mode 100644 lib/sessions/chats/deleteSessionChatHandler.ts create mode 100644 lib/sessions/chats/getSessionChatHandler.ts create mode 100644 lib/sessions/chats/patchSessionChatHandler.ts create mode 100644 lib/sessions/chats/validateDeleteSessionChatRequest.ts create mode 100644 lib/sessions/chats/validateGetSessionChatRequest.ts create mode 100644 lib/sessions/chats/validatePatchSessionChatRequest.ts create mode 100644 lib/supabase/chats/deleteChat.ts diff --git a/app/api/sessions/[sessionId]/chats/[chatId]/route.ts b/app/api/sessions/[sessionId]/chats/[chatId]/route.ts new file mode 100644 index 000000000..082b2ad7b --- /dev/null +++ b/app/api/sessions/[sessionId]/chats/[chatId]/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSessionChatHandler } from "@/lib/sessions/chats/getSessionChatHandler"; +import { patchSessionChatHandler } from "@/lib/sessions/chats/patchSessionChatHandler"; +import { deleteSessionChatHandler } from "@/lib/sessions/chats/deleteSessionChatHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/sessions/{sessionId}/chats/{chatId} + * + * Returns the chat's persisted UI message stream plus its current + * streaming state. Authenticates via Privy Bearer token or + * `x-api-key`; 404s when the session or chat is missing (or the chat + * lives in a different session) and 403s when the session is owned by + * a different account. + * + * @param request - The incoming request. + * @param options - Route options containing the async params. + * @param options.params - Route params containing the session id and chat id. + * @returns A NextResponse with `{ chat, isStreaming, messages }` on 200, or an error. + */ +export async function GET( + request: NextRequest, + options: { params: Promise<{ sessionId: string; chatId: string }> }, +) { + const { sessionId, chatId } = await options.params; + return getSessionChatHandler(request, sessionId, chatId); +} + +/** + * PATCH /api/sessions/{sessionId}/chats/{chatId} + * + * Applies a partial update to the chat (`title` and/or `modelId`). + * Body must include at least one of those non-empty fields. + * + * @param request - The incoming request. + * @param options - Route options containing the async params. + * @param options.params - Route params containing the session id and chat id. + * @returns A NextResponse with `{ chat }` on 200, or an error. + */ +export async function PATCH( + request: NextRequest, + options: { params: Promise<{ sessionId: string; chatId: string }> }, +) { + const { sessionId, chatId } = await options.params; + return patchSessionChatHandler(request, sessionId, chatId); +} + +/** + * DELETE /api/sessions/{sessionId}/chats/{chatId} + * + * Removes the chat (cascade clears `chat_messages` / `chat_reads`). + * Refuses with 400 if the chat is the only one in its session. + * + * @param request - The incoming request. + * @param options - Route options containing the async params. + * @param options.params - Route params containing the session id and chat id. + * @returns A NextResponse with `{ success: true }` on 200, or an error. + */ +export async function DELETE( + request: NextRequest, + options: { params: Promise<{ sessionId: string; chatId: string }> }, +) { + const { sessionId, chatId } = await options.params; + return deleteSessionChatHandler(request, sessionId, chatId); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/sessions/chats/__tests__/deleteSessionChatHandler.test.ts b/lib/sessions/chats/__tests__/deleteSessionChatHandler.test.ts new file mode 100644 index 000000000..f3d8fae65 --- /dev/null +++ b/lib/sessions/chats/__tests__/deleteSessionChatHandler.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/chats/validateDeleteSessionChatRequest", () => ({ + validateDeleteSessionChatRequest: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/deleteChat", () => ({ + deleteChat: vi.fn(), +})); + +const { validateDeleteSessionChatRequest } = await import( + "@/lib/sessions/chats/validateDeleteSessionChatRequest" +); +const { deleteChat } = await import("@/lib/supabase/chats/deleteChat"); +const { deleteSessionChatHandler } = await import("@/lib/sessions/chats/deleteSessionChatHandler"); + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1", { + method: "DELETE", + }); +} + +describe("deleteSessionChatHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the NextResponse from the validator as-is", async () => { + const failure = NextResponse.json( + { error: "Cannot delete the only chat in a session" }, + { status: 400 }, + ); + vi.mocked(validateDeleteSessionChatRequest).mockResolvedValue(failure); + + const res = await deleteSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(res).toBe(failure); + expect(deleteChat).not.toHaveBeenCalled(); + }); + + it("returns { success: true } on the happy path", async () => { + vi.mocked(validateDeleteSessionChatRequest).mockResolvedValue(null); + vi.mocked(deleteChat).mockResolvedValue(true); + + const res = await deleteSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(200); + expect(deleteChat).toHaveBeenCalledWith("chat_1"); + expect(await res.json()).toEqual({ success: true }); + }); + + it("returns 500 when deleteChat reports failure", async () => { + vi.mocked(validateDeleteSessionChatRequest).mockResolvedValue(null); + vi.mocked(deleteChat).mockResolvedValue(false); + + const res = await deleteSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(500); + }); +}); diff --git a/lib/sessions/chats/__tests__/getSessionChatHandler.test.ts b/lib/sessions/chats/__tests__/getSessionChatHandler.test.ts new file mode 100644 index 000000000..b6c4c2ee3 --- /dev/null +++ b/lib/sessions/chats/__tests__/getSessionChatHandler.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/chats/validateGetSessionChatRequest", () => ({ + validateGetSessionChatRequest: vi.fn(), +})); +vi.mock("@/lib/supabase/chat_messages/selectChatMessages", () => ({ + selectChatMessages: vi.fn(), +})); + +const { validateGetSessionChatRequest } = await import( + "@/lib/sessions/chats/validateGetSessionChatRequest" +); +const { selectChatMessages } = await import("@/lib/supabase/chat_messages/selectChatMessages"); +const { getSessionChatHandler } = await import("@/lib/sessions/chats/getSessionChatHandler"); + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1"); +} + +function mockValidated(chatOverrides: Parameters[0] = {}) { + vi.mocked(validateGetSessionChatRequest).mockResolvedValue( + baseChatRow({ id: "chat_1", session_id: "sess_1", ...chatOverrides }), + ); +} + +describe("getSessionChatHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the NextResponse from validateGetSessionChatRequest as-is", async () => { + const failure = NextResponse.json({ error: "Forbidden" }, { status: 403 }); + vi.mocked(validateGetSessionChatRequest).mockResolvedValue(failure); + + const res = await getSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(res).toBe(failure); + expect(selectChatMessages).not.toHaveBeenCalled(); + }); + + it("returns 200 with chat, isStreaming=false, and message parts", async () => { + mockValidated({ active_stream_id: null, model_id: "openai/gpt-5-mini" }); + vi.mocked(selectChatMessages).mockResolvedValue([ + { + id: "msg_1", + chat_id: "chat_1", + role: "user", + parts: [{ type: "text", text: "hi" }], + created_at: "2026-05-01T00:00:00.000Z", + }, + { + id: "msg_2", + chat_id: "chat_1", + role: "assistant", + parts: [{ type: "text", text: "hello" }], + created_at: "2026-05-01T00:00:01.000Z", + }, + ]); + + const res = await getSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + chat: { + id: string; + sessionId: string; + title: string; + modelId: string | null; + activeStreamId: string | null; + lastAssistantMessageAt: string | null; + createdAt: string; + updatedAt: string; + }; + isStreaming: boolean; + messages: unknown[]; + }; + expect(body.chat).toEqual({ + id: "chat_1", + sessionId: "sess_1", + title: "New chat", + modelId: "openai/gpt-5-mini", + activeStreamId: null, + lastAssistantMessageAt: null, + createdAt: "2026-05-04T00:00:00.000Z", + updatedAt: "2026-05-04T00:00:00.000Z", + }); + expect(body.isStreaming).toBe(false); + expect(body.messages).toEqual([ + [{ type: "text", text: "hi" }], + [{ type: "text", text: "hello" }], + ]); + expect(selectChatMessages).toHaveBeenCalledWith({ + chatId: "chat_1", + orderBy: { createdAt: "asc" }, + }); + }); + + it("derives isStreaming=true from active_stream_id", async () => { + mockValidated({ active_stream_id: "stream-xyz" }); + vi.mocked(selectChatMessages).mockResolvedValue([]); + + const res = await getSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + chat: { activeStreamId: string | null }; + isStreaming: boolean; + messages: unknown[]; + }; + expect(body.isStreaming).toBe(true); + expect(body.chat.activeStreamId).toBe("stream-xyz"); + expect(body.messages).toEqual([]); + }); +}); diff --git a/lib/sessions/chats/__tests__/patchSessionChatHandler.test.ts b/lib/sessions/chats/__tests__/patchSessionChatHandler.test.ts new file mode 100644 index 000000000..b819ea188 --- /dev/null +++ b/lib/sessions/chats/__tests__/patchSessionChatHandler.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/chats/validatePatchSessionChatRequest", () => ({ + validatePatchSessionChatRequest: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/updateChat", () => ({ + updateChat: vi.fn(), +})); + +const { validatePatchSessionChatRequest } = await import( + "@/lib/sessions/chats/validatePatchSessionChatRequest" +); +const { updateChat } = await import("@/lib/supabase/chats/updateChat"); +const { patchSessionChatHandler } = await import("@/lib/sessions/chats/patchSessionChatHandler"); + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1", { + method: "PATCH", + }); +} + +function mockValidated(patch: { title?: string; modelId?: string }) { + vi.mocked(validatePatchSessionChatRequest).mockResolvedValue(patch); +} + +describe("patchSessionChatHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the NextResponse from the validator as-is", async () => { + const failure = NextResponse.json({ error: "Forbidden" }, { status: 403 }); + vi.mocked(validatePatchSessionChatRequest).mockResolvedValue(failure); + + const res = await patchSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(res).toBe(failure); + expect(updateChat).not.toHaveBeenCalled(); + }); + + it("maps modelId to model_id and stores title verbatim", async () => { + mockValidated({ title: "Renamed", modelId: "openai/gpt-5-mini" }); + vi.mocked(updateChat).mockResolvedValue({ + ok: true, + rowsUpdated: 1, + row: baseChatRow({ + id: "chat_1", + title: "Renamed", + model_id: "openai/gpt-5-mini", + }), + }); + + const res = await patchSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(200); + expect(updateChat).toHaveBeenCalledWith( + { id: "chat_1" }, + { title: "Renamed", model_id: "openai/gpt-5-mini" }, + ); + const body = (await res.json()) as { chat: { title: string; modelId: string } }; + expect(body.chat.title).toBe("Renamed"); + expect(body.chat.modelId).toBe("openai/gpt-5-mini"); + }); + + it("only patches the field provided", async () => { + mockValidated({ title: "Just a title" }); + vi.mocked(updateChat).mockResolvedValue({ + ok: true, + rowsUpdated: 1, + row: baseChatRow({ id: "chat_1", title: "Just a title" }), + }); + + await patchSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(updateChat).toHaveBeenCalledWith( + { id: "chat_1" }, + { title: "Just a title", model_id: undefined }, + ); + }); + + it("returns 500 when updateChat fails", async () => { + mockValidated({ title: "Renamed" }); + vi.mocked(updateChat).mockResolvedValue({ ok: false, error: "db down" }); + + const res = await patchSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(500); + }); + + it("returns 500 when updateChat matches no row", async () => { + mockValidated({ title: "Renamed" }); + vi.mocked(updateChat).mockResolvedValue({ + ok: true, + rowsUpdated: 0, + row: null, + }); + + const res = await patchSessionChatHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(500); + }); +}); diff --git a/lib/sessions/chats/__tests__/validateDeleteSessionChatRequest.test.ts b/lib/sessions/chats/__tests__/validateDeleteSessionChatRequest.test.ts new file mode 100644 index 000000000..86a32a873 --- /dev/null +++ b/lib/sessions/chats/__tests__/validateDeleteSessionChatRequest.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/selectChats", () => ({ + selectChats: vi.fn(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { selectChats } = await import("@/lib/supabase/chats/selectChats"); +const { validateDeleteSessionChatRequest } = await import( + "@/lib/sessions/chats/validateDeleteSessionChatRequest" +); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1", { + method: "DELETE", + }); +} + +describe("validateDeleteSessionChatRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the auth NextResponse when validateAuthContext rejects", async () => { + const failure = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(failure); + + const res = await validateDeleteSessionChatRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBe(failure); + expect(selectSessions).not.toHaveBeenCalled(); + }); + + it("returns 404 when the session is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await validateDeleteSessionChatRequest(makeReq(), "sess_missing", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + } + }); + + it("returns 403 when the session belongs to a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), + ]); + + const res = await validateDeleteSessionChatRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(403); + } + }); + + it("returns 404 when the chat is not in this session", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([ + baseChatRow({ id: "chat_OTHER", session_id: "sess_1" }), + baseChatRow({ id: "chat_OTHER_2", session_id: "sess_1" }), + ]); + + const res = await validateDeleteSessionChatRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + } + }); + + it("returns 400 when the chat is the only one in the session", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]); + + const res = await validateDeleteSessionChatRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + status: "error", + error: "Cannot delete the only chat in a session", + }); + } + }); + + it("returns null on the happy path", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([ + baseChatRow({ id: "chat_1", session_id: "sess_1" }), + baseChatRow({ id: "chat_2", session_id: "sess_1" }), + ]); + + const res = await validateDeleteSessionChatRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBeNull(); + expect(selectChats).toHaveBeenCalledWith({ sessionId: "sess_1" }); + }); +}); diff --git a/lib/sessions/chats/__tests__/validateGetSessionChatRequest.test.ts b/lib/sessions/chats/__tests__/validateGetSessionChatRequest.test.ts new file mode 100644 index 000000000..e3f4bc595 --- /dev/null +++ b/lib/sessions/chats/__tests__/validateGetSessionChatRequest.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/selectChats", () => ({ + selectChats: vi.fn(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { selectChats } = await import("@/lib/supabase/chats/selectChats"); +const { validateGetSessionChatRequest } = await import( + "@/lib/sessions/chats/validateGetSessionChatRequest" +); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1"); +} + +describe("validateGetSessionChatRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the auth NextResponse when validateAuthContext rejects", async () => { + const failure = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(failure); + + const res = await validateGetSessionChatRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBe(failure); + expect(selectSessions).not.toHaveBeenCalled(); + expect(selectChats).not.toHaveBeenCalled(); + }); + + it("returns 404 when the session is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await validateGetSessionChatRequest(makeReq(), "sess_missing", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Session not found", + }); + } + expect(selectChats).not.toHaveBeenCalled(); + }); + + it("returns 403 when the session belongs to a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), + ]); + + const res = await validateGetSessionChatRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ status: "error", error: "Forbidden" }); + } + expect(selectChats).not.toHaveBeenCalled(); + }); + + it("returns 404 when the chat does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([]); + + const res = await validateGetSessionChatRequest(makeReq(), "sess_1", "chat_missing"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Chat not found", + }); + } + }); + + it("returns 404 when the chat belongs to a different session", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([ + baseChatRow({ id: "chat_1", session_id: "sess_OTHER" }), + ]); + + const res = await validateGetSessionChatRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Chat not found", + }); + } + }); + + it("returns the chat row on the happy path", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]); + + const res = await validateGetSessionChatRequest(makeReq(), "sess_1", "chat_1"); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.id).toBe("chat_1"); + expect(res.session_id).toBe("sess_1"); + } + expect(selectSessions).toHaveBeenCalledWith({ id: "sess_1" }); + expect(selectChats).toHaveBeenCalledWith({ id: "chat_1" }); + }); +}); diff --git a/lib/sessions/chats/__tests__/validatePatchSessionChatRequest.test.ts b/lib/sessions/chats/__tests__/validatePatchSessionChatRequest.test.ts new file mode 100644 index 000000000..4696880bf --- /dev/null +++ b/lib/sessions/chats/__tests__/validatePatchSessionChatRequest.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/selectChats", () => ({ + selectChats: vi.fn(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { selectChats } = await import("@/lib/supabase/chats/selectChats"); +const { validatePatchSessionChatRequest } = await import( + "@/lib/sessions/chats/validatePatchSessionChatRequest" +); + +const accountId = "acc-uuid-1"; + +function makeReq(body: unknown): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function makeRawReq(rawBody: string): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: rawBody, + }); +} + +function happyPathMocks() { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]); +} + +describe("validatePatchSessionChatRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the auth NextResponse when validateAuthContext rejects", async () => { + const failure = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(failure); + + const res = await validatePatchSessionChatRequest(makeReq({ title: "x" }), "sess_1", "chat_1"); + expect(res).toBe(failure); + }); + + it("returns 404 when the session is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + + const res = await validatePatchSessionChatRequest( + makeReq({ title: "x" }), + "sess_missing", + "chat_1", + ); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + } + }); + + it("returns 403 when the session belongs to a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), + ]); + + const res = await validatePatchSessionChatRequest(makeReq({ title: "x" }), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(403); + } + }); + + it("returns 404 when the chat does not belong to the session", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([ + baseChatRow({ id: "chat_1", session_id: "sess_OTHER" }), + ]); + + const res = await validatePatchSessionChatRequest(makeReq({ title: "x" }), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + } + }); + + it("returns 400 on invalid JSON", async () => { + happyPathMocks(); + const res = await validatePatchSessionChatRequest(makeRawReq("not json"), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + status: "error", + error: "Invalid JSON body", + }); + } + }); + + it("returns 400 when neither title nor modelId is provided", async () => { + happyPathMocks(); + const res = await validatePatchSessionChatRequest(makeReq({}), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe("At least one field is required"); + } + }); + + it("returns 400 when the body contains unknown fields", async () => { + happyPathMocks(); + const res = await validatePatchSessionChatRequest( + makeReq({ title: "Renamed", foo: "bar" }), + "sess_1", + "chat_1", + ); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(400); + } + }); + + it("returns 400 when title is whitespace-only", async () => { + happyPathMocks(); + const res = await validatePatchSessionChatRequest( + makeReq({ title: " " }), + "sess_1", + "chat_1", + ); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(400); + } + }); + + it("returns the validated patch with title trimmed", async () => { + happyPathMocks(); + const res = await validatePatchSessionChatRequest( + makeReq({ title: " Renamed " }), + "sess_1", + "chat_1", + ); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res).toEqual({ title: "Renamed" }); + } + }); + + it("returns the validated patch when modelId is provided", async () => { + happyPathMocks(); + const res = await validatePatchSessionChatRequest( + makeReq({ modelId: "openai/gpt-5-mini" }), + "sess_1", + "chat_1", + ); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res).toEqual({ modelId: "openai/gpt-5-mini" }); + } + }); +}); diff --git a/lib/sessions/chats/deleteSessionChatHandler.ts b/lib/sessions/chats/deleteSessionChatHandler.ts new file mode 100644 index 000000000..b245c9284 --- /dev/null +++ b/lib/sessions/chats/deleteSessionChatHandler.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateDeleteSessionChatRequest } from "@/lib/sessions/chats/validateDeleteSessionChatRequest"; +import { deleteChat } from "@/lib/supabase/chats/deleteChat"; + +/** + * Handles `DELETE /api/sessions/{sessionId}/chats/{chatId}`. Removes + * the chat row (cascade clears `chat_messages` / `chat_reads`). + * Refuses with 400 if the chat is the only one in its session. + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @param chatId - The id of the chat being deleted. + * @returns A NextResponse with `{ success: true }` on 200, or an error. + */ +export async function deleteSessionChatHandler( + request: NextRequest, + sessionId: string, + chatId: string, +): Promise { + const failure = await validateDeleteSessionChatRequest(request, sessionId, chatId); + if (failure) { + return failure; + } + + const ok = await deleteChat(chatId); + if (!ok) { + return NextResponse.json( + { status: "error", error: "Failed to delete chat" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json({ success: true }, { status: 200, headers: getCorsHeaders() }); +} diff --git a/lib/sessions/chats/getSessionChatHandler.ts b/lib/sessions/chats/getSessionChatHandler.ts new file mode 100644 index 000000000..84661945e --- /dev/null +++ b/lib/sessions/chats/getSessionChatHandler.ts @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { toChatResponse } from "@/lib/sessions/toChatResponse"; +import { validateGetSessionChatRequest } from "@/lib/sessions/chats/validateGetSessionChatRequest"; +import { selectChatMessages } from "@/lib/supabase/chat_messages/selectChatMessages"; + +export interface SessionChatResponse { + chat: ReturnType; + isStreaming: boolean; + messages: unknown[]; +} + +/** + * Handles `GET /api/sessions/{sessionId}/chats/{chatId}`. Returns the + * chat row (camelCase wire format), its streaming state, and the + * persisted UI message stream — enough for both initial render and + * in-tab refresh. + * + * `messages` is the raw `parts` JSON for each row (chat messages are + * stored as the full UIMessage; the caller deserializes back into + * its UIMessage type). + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @param chatId - The id of the chat being fetched. + * @returns A NextResponse with `{ chat, isStreaming, messages }` on 200, or an error. + */ +export async function getSessionChatHandler( + request: NextRequest, + sessionId: string, + chatId: string, +): Promise { + const chat = await validateGetSessionChatRequest(request, sessionId, chatId); + if (chat instanceof NextResponse) { + return chat; + } + + const messages = (await selectChatMessages({ chatId, orderBy: { createdAt: "asc" } })) ?? []; + + return NextResponse.json( + { + chat: toChatResponse(chat), + isStreaming: chat.active_stream_id !== null, + messages: messages.map(row => row.parts), + } satisfies SessionChatResponse, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/chats/patchSessionChatHandler.ts b/lib/sessions/chats/patchSessionChatHandler.ts new file mode 100644 index 000000000..d23ea4016 --- /dev/null +++ b/lib/sessions/chats/patchSessionChatHandler.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validatePatchSessionChatRequest } from "@/lib/sessions/chats/validatePatchSessionChatRequest"; +import { toChatResponse } from "@/lib/sessions/toChatResponse"; +import { updateChat } from "@/lib/supabase/chats/updateChat"; + +/** + * Handles `PATCH /api/sessions/{sessionId}/chats/{chatId}`. Applies a + * partial update to the chat (`title` and/or `modelId`) and returns + * the updated row under `{ chat }`. + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @param chatId - The id of the chat being updated. + * @returns A NextResponse with `{ chat }` on 200, or an error. + */ +export async function patchSessionChatHandler( + request: NextRequest, + sessionId: string, + chatId: string, +): Promise { + const patch = await validatePatchSessionChatRequest(request, sessionId, chatId); + if (patch instanceof NextResponse) { + return patch; + } + + const result = await updateChat({ id: chatId }, { title: patch.title, model_id: patch.modelId }); + + if (!result.ok || !result.row) { + return NextResponse.json( + { status: "error", error: "Failed to update chat" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { chat: toChatResponse(result.row) }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/sessions/chats/validateDeleteSessionChatRequest.ts b/lib/sessions/chats/validateDeleteSessionChatRequest.ts new file mode 100644 index 000000000..6d9972891 --- /dev/null +++ b/lib/sessions/chats/validateDeleteSessionChatRequest.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { selectChats } from "@/lib/supabase/chats/selectChats"; + +/** + * Validates a `DELETE /api/sessions/{sessionId}/chats/{chatId}` + * request end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Loads the session and confirms the caller owns it + * 3. Loads every chat in the session, confirms the target chat is + * one of them, and refuses 400 if it's the only one (sessions + * must always retain at least one chat) + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @param chatId - The id of the chat being deleted. + * @returns A NextResponse on failure, or `null` when validation passes. + */ +export async function validateDeleteSessionChatRequest( + request: NextRequest, + sessionId: string, + chatId: string, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const sessionRows = await selectSessions({ id: sessionId }); + const session = sessionRows[0] ?? null; + + if (!session) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (session.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + const siblingChats = await selectChats({ sessionId }); + const chat = siblingChats.find(row => row.id === chatId) ?? null; + + if (!chat) { + return NextResponse.json( + { status: "error", error: "Chat not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (siblingChats.length <= 1) { + return NextResponse.json( + { status: "error", error: "Cannot delete the only chat in a session" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return null; +} diff --git a/lib/sessions/chats/validateGetSessionChatRequest.ts b/lib/sessions/chats/validateGetSessionChatRequest.ts new file mode 100644 index 000000000..f453b100d --- /dev/null +++ b/lib/sessions/chats/validateGetSessionChatRequest.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { selectChats } from "@/lib/supabase/chats/selectChats"; +import type { Tables } from "@/types/database.types"; + +/** + * Validates a `GET /api/sessions/{sessionId}/chats/{chatId}` request + * end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Loads the session and confirms the caller owns it + * 3. Loads the chat and confirms it belongs to the session + * + * Returns either a 401/403/404 NextResponse describing the first + * failure, or the resolved chat row for the handler to render. + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @param chatId - The id of the chat being fetched. + * @returns A NextResponse on failure, or the chat row on success. + */ +export async function validateGetSessionChatRequest( + request: NextRequest, + sessionId: string, + chatId: string, +): Promise> { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const sessionRows = await selectSessions({ id: sessionId }); + const session = sessionRows[0] ?? null; + + if (!session) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (session.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + const chatRows = await selectChats({ id: chatId }); + const chat = chatRows[0] ?? null; + + if (!chat || chat.session_id !== sessionId) { + return NextResponse.json( + { status: "error", error: "Chat not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + return chat; +} diff --git a/lib/sessions/chats/validatePatchSessionChatRequest.ts b/lib/sessions/chats/validatePatchSessionChatRequest.ts new file mode 100644 index 000000000..77470c799 --- /dev/null +++ b/lib/sessions/chats/validatePatchSessionChatRequest.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { selectChats } from "@/lib/supabase/chats/selectChats"; + +const patchSessionChatBodySchema = z + .object({ + title: z.string().trim().min(1, "title cannot be empty").optional(), + modelId: z.string().trim().min(1, "modelId cannot be empty").optional(), + }) + .strict() + .refine(value => value.title !== undefined || value.modelId !== undefined, { + message: "At least one field is required", + }); + +export type PatchSessionChatBody = z.infer; + +/** + * Validates a `PATCH /api/sessions/{sessionId}/chats/{chatId}` request + * end-to-end: + * 1. Authenticates the caller via Privy Bearer / x-api-key + * 2. Loads the session and confirms the caller owns it + * 3. Loads the chat and confirms it belongs to the session + * 4. Parses the JSON body and ensures at least one updatable field + * is present and non-empty + * + * @param request - The incoming request. + * @param sessionId - The id of the parent session. + * @param chatId - The id of the chat being updated. + * @returns A NextResponse on failure, or the validated patch body on success. + */ +export async function validatePatchSessionChatRequest( + request: NextRequest, + sessionId: string, + chatId: string, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const sessionRows = await selectSessions({ id: sessionId }); + const session = sessionRows[0] ?? null; + + if (!session) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (session.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + const chatRows = await selectChats({ id: chatId }); + const chat = chatRows[0] ?? null; + + if (!chat || chat.session_id !== sessionId) { + return NextResponse.json( + { status: "error", error: "Chat not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const parsed = patchSessionChatBodySchema.safeParse(rawBody); + if (!parsed.success) { + const firstError = parsed.error.issues[0]; + return NextResponse.json( + { status: "error", error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return parsed.data; +} diff --git a/lib/supabase/chats/deleteChat.ts b/lib/supabase/chats/deleteChat.ts new file mode 100644 index 000000000..3bc0d663e --- /dev/null +++ b/lib/supabase/chats/deleteChat.ts @@ -0,0 +1,19 @@ +import supabase from "@/lib/supabase/serverClient"; + +/** + * Deletes the `chats` row identified by `chatId`. Returns `true` on + * success and `false` on Supabase error after logging. + * + * @param chatId - The id of the chat to delete. + * @returns `true` on success, `false` on error. + */ +export async function deleteChat(chatId: string): Promise { + const { error } = await supabase.from("chats").delete().eq("id", chatId); + + if (error) { + console.error("Error deleting chat:", error); + return false; + } + + return true; +} From 977ef2c4bf4307ac2e9e043bcee8f9c5979a3f05 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Tue, 26 May 2026 06:28:23 +0530 Subject: [PATCH 05/15] feat(chat-workflow): persist assistant message per step (#603) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(chat-workflow): persist assistant message per step Upgrade the success-only persist (#609) to per-step persistence so a stopped or crashed turn keeps the partial reply instead of dropping it. - runAgentStep streams through createUIMessageStream and persists on every onStepFinish/onFinish (toUIMessageStream exposes only onFinish); it still returns the final responseMessage so the credits path (#612) keeps billing from its metadata. - persistAssistantMessage now overwrites the row as it grows (DO UPDATE via the restored upsertChatMessage `update` flag) and bumps last_assistant_message_at/updated_at on every persist, so a partial reply still surfaces as unread. - runAgentWorkflow drops its own persist call (now per-step) and keeps the #612 credit charge. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(workflow): spread input into runAgentStep (KISS) Per review feedback on PR #603 — drop the manual field-by-field destructure and forward the workflow input object directly. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Sweets Sweetman --- .../workflows/__tests__/runAgentStep.test.ts | 186 ++++++++++++------ .../__tests__/runAgentWorkflow.test.ts | 36 +--- app/lib/workflows/runAgentStep.ts | 79 ++++---- app/lib/workflows/runAgentWorkflow.ts | 29 +-- .../__tests__/persistAssistantMessage.test.ts | 106 ++++------ lib/chat/persistAssistantMessage.ts | 77 +++----- .../__tests__/upsertChatMessage.test.ts | 7 + .../chat_messages/upsertChatMessage.ts | 14 +- 8 files changed, 251 insertions(+), 283 deletions(-) diff --git a/app/lib/workflows/__tests__/runAgentStep.test.ts b/app/lib/workflows/__tests__/runAgentStep.test.ts index cfb101877..d8371afda 100644 --- a/app/lib/workflows/__tests__/runAgentStep.test.ts +++ b/app/lib/workflows/__tests__/runAgentStep.test.ts @@ -1,10 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { streamText } from "ai"; +import { streamText, createUIMessageStream } from "ai"; import { runAgentStep } from "@/app/lib/workflows/runAgentStep"; +import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; vi.mock("ai", async () => { const actual = await vi.importActual("ai"); - return { ...actual, streamText: vi.fn() }; + return { ...actual, streamText: vi.fn(), createUIMessageStream: vi.fn() }; }); // Avoid pulling in real gateway / fetch surface. @@ -12,30 +13,38 @@ vi.mock("@ai-sdk/gateway", () => ({ gateway: vi.fn((modelId: string) => ({ modelId, __mock: "gateway" })), })); +vi.mock("@/lib/chat/persistAssistantMessage", () => ({ + persistAssistantMessage: vi.fn(), +})); + +// Captures the options runAgentStep passes to createUIMessageStream so +// tests can drive its onStepFinish / onFinish callbacks directly. +type CreateOpts = { + generateId?: () => string; + onStepFinish?: (e: { responseMessage: unknown }) => unknown; + onFinish?: (e: { responseMessage: unknown }) => unknown; + execute?: (a: { writer: { write: () => void; merge: () => void; onError: undefined } }) => void; +}; +let capturedCreateOpts: CreateOpts; + function makeStreamResult(opts?: { metadataCalls?: Array; - onFinishCalls?: Array; - emittedResponseMessage?: unknown; + generateIdCalls?: Array; }) { const calls = opts?.metadataCalls ?? []; - const onFinishCalls = opts?.onFinishCalls ?? []; + const genCalls = opts?.generateIdCalls ?? []; return { - toUIMessageStream: vi.fn((streamOpts: { messageMetadata?: unknown; onFinish?: unknown }) => { - // Capture the callbacks so tests can inspect (and invoke) them - calls.push(streamOpts.messageMetadata); - onFinishCalls.push(streamOpts.onFinish); - return (async function* () { - yield { type: "start" }; - yield { type: "finish" }; - // Mirror the AI SDK contract: onFinish fires after the - // generator yields its last chunk with the assembled message. - if (typeof streamOpts.onFinish === "function" && opts?.emittedResponseMessage) { - (streamOpts.onFinish as (a: { responseMessage: unknown }) => void)({ - responseMessage: opts.emittedResponseMessage, - }); - } - })(); - }), + toUIMessageStream: vi.fn( + (streamOpts: { messageMetadata?: unknown; generateMessageId?: unknown }) => { + // Capture the callbacks so tests can inspect them. + calls.push(streamOpts.messageMetadata); + genCalls.push(streamOpts.generateMessageId); + return (async function* () { + yield { type: "start" }; + yield { type: "finish" }; + })(); + }, + ), finishReason: Promise.resolve("stop"), }; } @@ -59,6 +68,7 @@ const baseInput = { }, ], modelId: "anthropic/claude-haiku-4.5", + chatId: "chat-1", agentContext: { sandbox: { state: { type: "vercel" }, workingDirectory: "/sandbox/mono" }, }, @@ -68,6 +78,20 @@ const baseInput = { describe("runAgentStep", () => { beforeEach(() => { vi.clearAllMocks(); + // Default: capture the options, run execute (so toUIMessageStream — and + // its messageMetadata callback — is exercised), and return an empty + // stream that closes immediately so pipeTo resolves. + vi.mocked(createUIMessageStream).mockImplementation((opts: never) => { + capturedCreateOpts = opts as CreateOpts; + capturedCreateOpts.execute?.({ + writer: { write: () => {}, merge: () => {}, onError: undefined }, + }); + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }) as never; + }); }); it("wires a messageMetadata callback into toUIMessageStream", async () => { @@ -103,8 +127,7 @@ describe("runAgentStep", () => { }); it("includes cwd from agentContext.sandbox in the system prompt", async () => { - const captured: unknown[] = []; - vi.mocked(streamText).mockReturnValue(makeStreamResult({ metadataCalls: captured }) as never); + vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); const { stream } = makeWritable(); await runAgentStep({ @@ -125,8 +148,7 @@ describe("runAgentStep", () => { }); it("wraps tools with anthropic cacheControl on the last tool before passing to streamText", async () => { - const captured: unknown[] = []; - vi.mocked(streamText).mockReturnValue(makeStreamResult({ metadataCalls: captured }) as never); + vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); const { stream } = makeWritable(); await runAgentStep({ ...baseInput, writable: stream } as never); @@ -148,8 +170,7 @@ describe("runAgentStep", () => { }); it("wires a prepareStep callback that marks the last message with cacheControl", async () => { - const captured: unknown[] = []; - vi.mocked(streamText).mockReturnValue(makeStreamResult({ metadataCalls: captured }) as never); + vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); const { stream } = makeWritable(); await runAgentStep({ ...baseInput, writable: stream } as never); @@ -189,55 +210,49 @@ describe("runAgentStep", () => { expect(cb({ part: { type: "start" } })).toBeUndefined(); }); - it("wires an onFinish callback into toUIMessageStream", async () => { - const onFinishCalls: unknown[] = []; - vi.mocked(streamText).mockReturnValue(makeStreamResult({ onFinishCalls }) as never); + it("persists the assistant message on each step via onStepFinish", async () => { + vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); const { stream } = makeWritable(); await runAgentStep({ ...baseInput, writable: stream } as never); - expect(onFinishCalls).toHaveLength(1); - expect(typeof onFinishCalls[0]).toBe("function"); + const msg = { id: "a1", role: "assistant", parts: [{ type: "text", text: "partial" }] }; + await capturedCreateOpts.onStepFinish?.({ responseMessage: msg }); + + expect(persistAssistantMessage).toHaveBeenCalledWith("chat-1", msg); }); - it("returns the responseMessage captured from onFinish", async () => { - const emittedResponseMessage = { - id: "assistant-msg-1", - role: "assistant", - parts: [{ type: "text", text: "Hello" }], - }; - vi.mocked(streamText).mockReturnValue(makeStreamResult({ emittedResponseMessage }) as never); + it("persists the final assistant message via onFinish", async () => { + vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); const { stream } = makeWritable(); - const result = await runAgentStep({ ...baseInput, writable: stream } as never); + await runAgentStep({ ...baseInput, writable: stream } as never); - expect(result.responseMessage).toEqual(emittedResponseMessage); - expect(result.finishReason).toBe("stop"); + const msg = { id: "a1", role: "assistant", parts: [{ type: "text", text: "done" }] }; + await capturedCreateOpts.onFinish?.({ responseMessage: msg }); + + expect(persistAssistantMessage).toHaveBeenCalledWith("chat-1", msg); }); - it("returns responseMessage: undefined when onFinish never fires", async () => { - // Default makeStreamResult — no emittedResponseMessage, so onFinish is wired but never invoked - vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); + it("forwards assistantMessageId into toUIMessageStream's generateMessageId (stable row id)", async () => { + const generateIdCalls: unknown[] = []; + vi.mocked(streamText).mockReturnValue(makeStreamResult({ generateIdCalls }) as never); const { stream } = makeWritable(); - const result = await runAgentStep({ ...baseInput, writable: stream } as never); + await runAgentStep({ + ...baseInput, + writable: stream, + assistantMessageId: "asst-from-workflow-xyz", + } as never); - expect(result.responseMessage).toBeUndefined(); - expect(result.finishReason).toBe("stop"); + expect(generateIdCalls).toHaveLength(1); + const gen = generateIdCalls[0] as () => string; + expect(typeof gen).toBe("function"); + expect(gen()).toBe("asst-from-workflow-xyz"); }); - it("forwards input.assistantMessageId into toUIMessageStream's generateMessageId", async () => { - const generateMessageIdCalls: unknown[] = []; - const streamResult = makeStreamResult(); - // Spy on the options passed to toUIMessageStream to grab the generateMessageId fn. - const originalToUIMessageStream = streamResult.toUIMessageStream; - streamResult.toUIMessageStream = vi.fn((streamOpts: { generateMessageId?: unknown }) => { - generateMessageIdCalls.push(streamOpts.generateMessageId); - return (originalToUIMessageStream as unknown as (o: unknown) => AsyncGenerator)( - streamOpts, - ); - }) as never; - vi.mocked(streamText).mockReturnValue(streamResult as never); + it("sets a stable generateId on the createUIMessageStream", async () => { + vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); const { stream } = makeWritable(); await runAgentStep({ @@ -246,9 +261,52 @@ describe("runAgentStep", () => { assistantMessageId: "asst-from-workflow-xyz", } as never); - expect(generateMessageIdCalls).toHaveLength(1); - const gen = generateMessageIdCalls[0] as () => string; - expect(typeof gen).toBe("function"); - expect(gen()).toBe("asst-from-workflow-xyz"); + expect(typeof capturedCreateOpts.generateId).toBe("function"); + expect(capturedCreateOpts.generateId!()).toBe("asst-from-workflow-xyz"); + }); + + it("returns the finishReason from the model result", async () => { + vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); + const { stream } = makeWritable(); + + const result = await runAgentStep({ ...baseInput, writable: stream } as never); + + expect(result.finishReason).toBe("stop"); + }); + + it("returns the responseMessage captured from onFinish (so the workflow can charge credits)", async () => { + const emitted = { + id: "asst-test-id", + role: "assistant", + parts: [{ type: "text", text: "Hello" }], + metadata: { totalMessageCost: 0.05 }, + }; + vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); + vi.mocked(createUIMessageStream).mockImplementation((opts: never) => { + const o = opts as CreateOpts; + o.execute?.({ writer: { write: () => {}, merge: () => {}, onError: undefined } }); + // Drive onFinish so runAgentStep captures the final message. + void o.onFinish?.({ responseMessage: emitted }); + return new ReadableStream({ + start(controller) { + controller.close(); + }, + }) as never; + }); + const { stream } = makeWritable(); + + const result = await runAgentStep({ ...baseInput, writable: stream } as never); + + expect(result.responseMessage).toEqual(emitted); + }); + + it("returns responseMessage: undefined when onFinish never fires", async () => { + // Default mock never invokes onFinish. + vi.mocked(streamText).mockReturnValue(makeStreamResult() as never); + const { stream } = makeWritable(); + + const result = await runAgentStep({ ...baseInput, writable: stream } as never); + + expect(result.responseMessage).toBeUndefined(); }); }); diff --git a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts index 3697c84a9..d033a06c7 100644 --- a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts +++ b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts @@ -4,7 +4,6 @@ import { runAgentStep } from "@/app/lib/workflows/runAgentStep"; import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream"; import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; -import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; import { handleChatCredits } from "@/lib/credits/handleChatCredits"; import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; @@ -20,9 +19,6 @@ vi.mock("@/app/lib/workflows/closeChatStream", () => ({ vi.mock("@/app/lib/workflows/generateAssistantMessageId", () => ({ generateAssistantMessageId: vi.fn(), })); -vi.mock("@/lib/chat/persistAssistantMessage", () => ({ - persistAssistantMessage: vi.fn(), -})); vi.mock("@/lib/credits/handleChatCredits", () => ({ handleChatCredits: vi.fn(), })); @@ -114,24 +110,7 @@ describe("runAgentWorkflow", () => { expect(closeChatStream).toHaveBeenCalledWith(writableStub); }); - it("persists the assistant message when runAgentStep returns one", async () => { - const responseMessage = { - id: "assistant-msg-xyz", - role: "assistant", - parts: [{ type: "text", text: "Hello!" }], - }; - vi.mocked(runAgentStep).mockResolvedValue({ - finishReason: "stop", - responseMessage: responseMessage as never, - }); - - await runAgentWorkflow(baseInput); - - expect(persistAssistantMessage).toHaveBeenCalledTimes(1); - expect(persistAssistantMessage).toHaveBeenCalledWith("chat-1", responseMessage); - }); - - it("does NOT call persistAssistantMessage when runAgentStep returns no responseMessage", async () => { + it("forwards chatId to runAgentStep so it can persist the assistant message per step", async () => { vi.mocked(runAgentStep).mockResolvedValue({ finishReason: "stop", responseMessage: undefined, @@ -139,18 +118,7 @@ describe("runAgentWorkflow", () => { await runAgentWorkflow(baseInput); - expect(persistAssistantMessage).not.toHaveBeenCalled(); - }); - - it("does NOT call persistAssistantMessage when runAgentStep throws (no message to persist)", async () => { - vi.mocked(runAgentStep).mockRejectedValue(new Error("model exploded")); - - await expect(runAgentWorkflow(baseInput)).rejects.toThrow("model exploded"); - - expect(persistAssistantMessage).not.toHaveBeenCalled(); - // But cleanup steps still run via the try/finally - expect(clearChatActiveStream).toHaveBeenCalledTimes(1); - expect(closeChatStream).toHaveBeenCalledTimes(1); + expect(runAgentStep).toHaveBeenCalledWith(expect.objectContaining({ chatId: "chat-1" })); }); it("generates a fresh assistantMessageId via the step and forwards it to runAgentStep", async () => { diff --git a/app/lib/workflows/runAgentStep.ts b/app/lib/workflows/runAgentStep.ts index 0520bb20a..66f73f58f 100644 --- a/app/lib/workflows/runAgentStep.ts +++ b/app/lib/workflows/runAgentStep.ts @@ -1,4 +1,10 @@ -import { streamText, convertToModelMessages, type UIMessage, type UIMessageChunk } from "ai"; +import { + streamText, + convertToModelMessages, + createUIMessageStream, + type UIMessage, + type UIMessageChunk, +} from "ai"; import { gateway } from "@ai-sdk/gateway"; import { agentCustomInstructions } from "@/lib/chat/agentCustomInstructions"; import { buildAgentSystemPrompt } from "@/lib/chat/buildAgentSystemPrompt"; @@ -8,11 +14,14 @@ import type { AgentContext, DurableAgentContext } from "@/lib/agent/tools/AgentC import { buildMessageMetadataCallback } from "@/lib/agent/messageMetadata/buildMessageMetadataCallback"; import { addCacheControlToTools } from "@/lib/agent/contextManagement/addCacheControlToTools"; import { addCacheControlToMessages } from "@/lib/agent/contextManagement/addCacheControlToMessages"; +import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; export type RunAgentStepInput = { messages: UIMessage[]; modelId: string; writable: WritableStream; + /** Target chat for persisting the assistant message as it streams. */ + chatId: string; /** * The JSON-serializable agent context that survives the durable * workflow input. `runAgentStep` widens it into a full `AgentContext` @@ -44,11 +53,10 @@ export type RunAgentStepInput = { export type RunAgentStepResult = { finishReason: string; /** - * The assembled assistant message captured from - * `toUIMessageStream`'s `onFinish` callback. `undefined` if the - * stream finished without emitting one (e.g. an error path that - * short-circuits before any chunks land). Used by `runAgentWorkflow` - * to persist the final message to `chat_messages`. + * The assembled assistant message captured from the stream's `onFinish`. + * `undefined` if the stream finished without emitting one. Per-step + * persistence happens inside this function; this is returned so + * `runAgentWorkflow` can charge credits from `responseMessage.metadata`. */ responseMessage: UIMessage | undefined; }; @@ -67,9 +75,7 @@ export type RunAgentStepResult = { * of the tool surface ports in a follow-up PR. * * @param input - Messages + selected model + writable stream + agent context. - * @returns `finishReason` from the model run plus the assembled - * `responseMessage` (when one was emitted) so the caller can - * persist it. + * @returns finishReason plus the assembled assistant message. */ export async function runAgentStep(input: RunAgentStepInput): Promise { "use step"; @@ -122,33 +128,38 @@ export async function runAgentStep(input: RunAgentStepInput): Promise({ + generateId: () => input.assistantMessageId, + onStepFinish: ({ responseMessage: stepMessage }) => + persistAssistantMessage(input.chatId, stepMessage), + onFinish: ({ responseMessage: finalMessage }) => { + responseMessage = finalMessage; + return persistAssistantMessage(input.chatId, finalMessage); + }, + execute: ({ writer }) => { + writer.merge( + result.toUIMessageStream({ + messageMetadata, + generateMessageId: () => input.assistantMessageId, + }), + ); + }, + }); - // Acquire the writer once and release in `finally` so a thrown chunk - // doesn't leak the lock. - const writer = input.writable.getWriter(); - try { - // `messageMetadata` emits {modelId, usage, cost} chunks the UI - // renders as model/cost badges. Mirrors open-agents' chat workflow - // shape so sandbox.recoupable.com sees the same metadata when cut - // over to api's /api/chat/workflow. - const messageMetadata = buildMessageMetadataCallback({ modelId: input.modelId }); - for await (const part of result.toUIMessageStream({ - messageMetadata, - generateMessageId: () => input.assistantMessageId, - onFinish: ({ responseMessage: finishedResponseMessage }) => { - responseMessage = finishedResponseMessage; - }, - })) { - await writer.write(part); - } - } finally { - writer.releaseLock(); - } + // preventClose/preventAbort: runAgentWorkflow's finally owns the writable's + // lifecycle (closeChatStream), so don't close or abort it here. + await uiStream.pipeTo(input.writable, { preventClose: true, preventAbort: true }); const finishReason = await result.finishReason; console.log("[runAgentStep] finish", { finishReason, hasResponseMessage: !!responseMessage }); diff --git a/app/lib/workflows/runAgentWorkflow.ts b/app/lib/workflows/runAgentWorkflow.ts index 58431094c..11e22dd18 100644 --- a/app/lib/workflows/runAgentWorkflow.ts +++ b/app/lib/workflows/runAgentWorkflow.ts @@ -4,7 +4,6 @@ import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { runAgentStep } from "@/app/lib/workflows/runAgentStep"; import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream"; -import { persistAssistantMessage } from "@/lib/chat/persistAssistantMessage"; import { handleChatCredits } from "@/lib/credits/handleChatCredits"; import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; import type { AgentMessageMetadata } from "@/lib/agent/messageMetadata/AgentMessageMetadata"; @@ -95,33 +94,19 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise ({ beforeEach(() => { vi.clearAllMocks(); - vi.mocked(updateChat).mockResolvedValue({ - ok: true, - rowsUpdated: 1, - row: null, - }); + vi.mocked(updateChat).mockResolvedValue({ ok: true, rowsUpdated: 1, row: null }); }); const CHAT_ID = "11111111-1111-1111-1111-111111111111"; @@ -31,110 +27,82 @@ function buildAssistantMessage(overrides: Record = {}) { }; } -describe("persistAssistantMessage", () => { - it("upserts the assistant message row with role 'assistant'", async () => { - vi.mocked(upsertChatMessage).mockResolvedValue({ - ok: true, - row: { id: ASSISTANT_ID } as never, - isDuplicate: false, - }); - - await persistAssistantMessage(CHAT_ID, buildAssistantMessage()); - - expect(upsertChatMessage).toHaveBeenCalledWith( - expect.objectContaining({ - id: ASSISTANT_ID, - chat_id: CHAT_ID, - role: "assistant", - }), - ); +function okUpsert() { + vi.mocked(upsertChatMessage).mockResolvedValue({ + ok: true, + row: { id: ASSISTANT_ID } as never, + isDuplicate: false, }); +} - it("touches updated_at on the chat row on a fresh insert", async () => { - vi.mocked(upsertChatMessage).mockResolvedValue({ - ok: true, - row: { id: ASSISTANT_ID } as never, - isDuplicate: false, - }); +describe("persistAssistantMessage", () => { + it("upserts the assistant row with update:true (DO UPDATE — overwrite as it grows)", async () => { + okUpsert(); + const message = buildAssistantMessage(); - await persistAssistantMessage(CHAT_ID, buildAssistantMessage()); + await persistAssistantMessage(CHAT_ID, message as never); - expect(updateChat).toHaveBeenCalledWith( - { id: CHAT_ID }, - expect.objectContaining({ updated_at: expect.any(String) }), + expect(upsertChatMessage).toHaveBeenCalledWith( + { id: ASSISTANT_ID, chat_id: CHAT_ID, role: "assistant", parts: message }, + { update: true }, ); }); - it("bumps last_assistant_message_at on a fresh insert (drives sidebar unread badge)", async () => { - vi.mocked(upsertChatMessage).mockResolvedValue({ - ok: true, - row: { id: ASSISTANT_ID } as never, - isDuplicate: false, - }); + it("bumps updated_at and last_assistant_message_at to the same timestamp on a successful persist", async () => { + okUpsert(); - await persistAssistantMessage(CHAT_ID, buildAssistantMessage()); + await persistAssistantMessage(CHAT_ID, buildAssistantMessage() as never); expect(updateChat).toHaveBeenCalledWith( { id: CHAT_ID }, expect.objectContaining({ + updated_at: expect.any(String), last_assistant_message_at: expect.any(String), }), ); - }); - - it("uses the same timestamp for updated_at and last_assistant_message_at (matches open-agents)", async () => { - vi.mocked(upsertChatMessage).mockResolvedValue({ - ok: true, - row: { id: ASSISTANT_ID } as never, - isDuplicate: false, - }); - - await persistAssistantMessage(CHAT_ID, buildAssistantMessage()); - const updateArgs = vi.mocked(updateChat).mock.calls[0]?.[1] as { updated_at?: string; last_assistant_message_at?: string; }; - expect(updateArgs.updated_at).toBeDefined(); expect(updateArgs.last_assistant_message_at).toBe(updateArgs.updated_at); }); - it("does NOT touch updated_at on duplicate (workflow replay)", async () => { - vi.mocked(upsertChatMessage).mockResolvedValue({ - ok: true, - row: null, - isDuplicate: true, - }); + it("bumps activity on every persist so a partial/stopped reply still surfaces as unread", async () => { + // Under DO UPDATE the upsert returns a row on both insert and update, so + // a per-step partial message must keep marking the chat active — gating + // the bump on "fresh insert only" would lose unread for stopped replies. + okUpsert(); - await persistAssistantMessage(CHAT_ID, buildAssistantMessage()); + await persistAssistantMessage(CHAT_ID, buildAssistantMessage() as never); + await persistAssistantMessage( + CHAT_ID, + buildAssistantMessage({ parts: [{ type: "text", text: "Hello there!" }] }) as never, + ); - expect(updateChat).not.toHaveBeenCalled(); + expect(updateChat).toHaveBeenCalledTimes(2); }); - it("silently no-ops when the message role is not 'assistant' (guard against caller mistakes)", async () => { - await persistAssistantMessage(CHAT_ID, buildAssistantMessage({ role: "user" })); + it("no-ops when the message role is not 'assistant' (guard against caller mistakes)", async () => { + await persistAssistantMessage(CHAT_ID, buildAssistantMessage({ role: "user" }) as never); expect(upsertChatMessage).not.toHaveBeenCalled(); expect(updateChat).not.toHaveBeenCalled(); }); - it("silently no-ops when the upsert reports a DB error (fire-and-forget contract)", async () => { - vi.mocked(upsertChatMessage).mockResolvedValue({ - ok: false, - error: "transient db error", - }); + it("does not bump activity when the upsert reports a DB error (fire-and-forget contract)", async () => { + vi.mocked(upsertChatMessage).mockResolvedValue({ ok: false, error: "transient db error" }); await expect( - persistAssistantMessage(CHAT_ID, buildAssistantMessage()), + persistAssistantMessage(CHAT_ID, buildAssistantMessage() as never), ).resolves.toBeUndefined(); expect(updateChat).not.toHaveBeenCalled(); }); - it("swallows unexpected exceptions (must not bubble up)", async () => { + it("swallows unexpected exceptions (must not bubble up and tear down the stream)", async () => { vi.mocked(upsertChatMessage).mockRejectedValue(new Error("boom")); await expect( - persistAssistantMessage(CHAT_ID, buildAssistantMessage()), + persistAssistantMessage(CHAT_ID, buildAssistantMessage() as never), ).resolves.toBeUndefined(); }); }); diff --git a/lib/chat/persistAssistantMessage.ts b/lib/chat/persistAssistantMessage.ts index 13f93c27b..5217950f9 100644 --- a/lib/chat/persistAssistantMessage.ts +++ b/lib/chat/persistAssistantMessage.ts @@ -1,66 +1,35 @@ +import type { UIMessage } from "ai"; import { upsertChatMessage } from "@/lib/supabase/chat_messages/upsertChatMessage"; import { updateChat } from "@/lib/supabase/chats/updateChat"; /** - * Minimal duck-type shape we read off the assistant message. Both AI - * SDK's `UIMessage` and the in-test fixtures structurally satisfy it. - * Kept intentionally loose because the row we write to - * `chat_messages.parts` is `jsonb` — Supabase persists whatever the - * message looks like. - */ -type AssistantMessage = { - id: string; - role: string; - parts: ReadonlyArray; -}; - -/** - * Fire-and-forget persistence of the final assistant message at the - * end of a chat-workflow run. Mirrors open-agents' - * `persistAssistantMessage` step in - * `apps/web/app/workflows/chat-post-finish.ts` and closes the - * silent-data-loss gap the recoup-api cutover introduced — without - * this call the assistant response is streamed to the client but - * never written to `chat_messages`, so a page refresh after the - * stream completes wipes the message. - * - * Uses `upsertChatMessage(... { onConflict: "id", ignoreDuplicates })` - * so a workflow that's restarted (replay, recovery) doesn't - * double-insert. On a fresh insert we also bump - * `last_assistant_message_at` (drives the sidebar `hasUnread` badge - * in `getChatSummaries` — `lastAssistantMessageAt > lastReadAt`) and - * touch `updated_at` so the sidebar sort surfaces the chat. Matches - * open-agents' `updateChatAssistantActivity` which sets both columns - * to the same timestamp. + * Persist the streaming assistant message, overwriting its row as it grows + * (DO UPDATE on a stable id), and bump the chat's assistant-activity + * timestamps. Called per step from `runAgentStep`, so a stopped or crashed + * turn keeps the partial reply it produced and still surfaces as unread + * (`getChatSummaries` derives `hasUnread` from + * `last_assistant_message_at > last_read_at`); `updated_at` re-sorts the + * chat to the top. Bumped on every successful persist — under DO UPDATE the + * upsert returns a row each time, so an interrupted reply isn't dropped. * - * Title generation lives in `persistLatestUserMessage` (the first - * user message is canonical for chat titles) — this function - * deliberately does NOT update the chat title. - * - * Errors are caught and logged. Contract is "schedule it and forget" - * — never block the workflow or surface failures to the UI. - * - * @param chatId - Target chat row. - * @param message - The assembled assistant message (typically from - * `toUIMessageStream`'s `onFinish.responseMessage`). + * Never throws: the AI SDK awaits stream callbacks un-guarded, so an + * escaping error would tear down the client stream. Errors are logged. */ -export async function persistAssistantMessage( - chatId: string, - message: AssistantMessage, -): Promise { - "use step"; +export async function persistAssistantMessage(chatId: string, message: UIMessage): Promise { try { - if (!message || message.role !== "assistant") return; + if (message?.role !== "assistant") return; - const inserted = await upsertChatMessage({ - id: message.id, - chat_id: chatId, - role: "assistant", - parts: message as never, - }); + const upserted = await upsertChatMessage( + { + id: message.id, + chat_id: chatId, + role: "assistant", + parts: message as never, + }, + { update: true }, + ); - if (!inserted.ok) return; - if (inserted.isDuplicate || inserted.row === null) return; + if (!upserted.ok) return; const activityAt = new Date().toISOString(); await updateChat( diff --git a/lib/supabase/chat_messages/__tests__/upsertChatMessage.test.ts b/lib/supabase/chat_messages/__tests__/upsertChatMessage.test.ts index 0ea559058..cd770f7c6 100644 --- a/lib/supabase/chat_messages/__tests__/upsertChatMessage.test.ts +++ b/lib/supabase/chat_messages/__tests__/upsertChatMessage.test.ts @@ -38,6 +38,13 @@ describe("upsertChatMessage", () => { expect(result).toEqual({ ok: true, row: null, isDuplicate: true }); }); + it("passes ignoreDuplicates:false when update:true (DO UPDATE — overwrite as it grows)", async () => { + maybeSingleChain.mockResolvedValue({ data, error: null }); + const result = await upsertChatMessage(data, { update: true }); + expect(result).toEqual({ ok: true, row: data, isDuplicate: false }); + expect(upsertChain).toHaveBeenCalledWith(data, { onConflict: "id", ignoreDuplicates: false }); + }); + it("returns ok:false with error on Supabase failure (distinct from duplicate)", async () => { maybeSingleChain.mockResolvedValue({ data: null, error: { message: "down" } }); const result = await upsertChatMessage(data); diff --git a/lib/supabase/chat_messages/upsertChatMessage.ts b/lib/supabase/chat_messages/upsertChatMessage.ts index d98b9b343..82e029e89 100644 --- a/lib/supabase/chat_messages/upsertChatMessage.ts +++ b/lib/supabase/chat_messages/upsertChatMessage.ts @@ -13,18 +13,20 @@ export type UpsertChatMessageResult = | { ok: false; error: string }; /** - * Insert-or-skip a single chat message row. Wraps Supabase upsert with - * `ignoreDuplicates: true` on the `id` primary key, but returns a - * discriminated result so callers can tell "duplicate skipped" apart from - * "DB error" — the previous helper returned `null` for both, which made - * callers silently swallow operational failures. + * Upsert a single chat message on the `id` primary key. `update: false` + * (default) → DO NOTHING (write-once, e.g. the user message); `update: true` + * → DO UPDATE (overwrite, e.g. the assistant message as it grows per step). + * Returns a discriminated result so callers can tell "duplicate skipped" + * apart from "DB error" — the previous helper returned `null` for both, + * which made callers silently swallow operational failures. */ export async function upsertChatMessage( data: TablesInsert<"chat_messages">, + { update = false }: { update?: boolean } = {}, ): Promise { const { data: row, error } = await supabase .from("chat_messages") - .upsert(data, { onConflict: "id", ignoreDuplicates: true }) + .upsert(data, { onConflict: "id", ignoreDuplicates: !update }) .select() .maybeSingle(); From 61ebc51efb72de4b508061699add9b1ebe922d64 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Tue, 26 May 2026 14:27:00 -0500 Subject: [PATCH 06/15] feat(sessions): ensure personal repo on POST /api/sessions (#618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sessions): ensure personal repo on POST /api/sessions When a caller hits POST /api/sessions without a cloneUrl and without an org bound to their auth context, the handler now provisions (or reuses) their personal Recoupable workspace repo at recoupable/- before returning the session row. This unblocks chat.recoupable.com's Path C cutover (recoupable/chat#1748) — previously the chat-side bootstrap had to construct the personal cloneUrl from a client-side display name (e.g. "sweetman.eth" → "sweetman-eth"), which diverged from open-agents' canonical name source (account_info.name with email-local-part fallback) and 502'd at the clone step. Ported from open-agents: - buildPersonalRepoIdentifier, buildPersonalRepoUrl, githubOwner - repositoryExists, createRepository (plain fetch, no Octokit, to match recoup-api's existing lib/github/* style) - ensurePersonalRepo (idempotent check-then-create) - toKebabCase New session-side helper: - resolveSessionCloneUrl picks bodyCloneUrl > org-no-op > ensurePersonalRepo - buildSessionInsertRow takes the resolved cloneUrl as input - createSessionHandler returns 502 if cloneUrl resolution fails Co-Authored-By: Claude Opus 4.7 * fix(sessions): flatten ResolveSessionCloneUrlResult to single interface Next.js 16's next build doesn't narrow the discriminated union { ok: true; cloneUrl } | { ok: false; error } through `if (!result.ok)`, breaking the Vercel preview build with: Type error: Property 'error' does not exist on type 'ResolveSessionCloneUrlResult'. Same compile-only divergence we hit on PR #603 — vitest's tsc is more permissive than next build's. Flatten to a single interface with `cloneUrl: string | null` and `error?: string` so callers read both fields directly after checking `ok`. Co-Authored-By: Claude Opus 4.7 * refactor(repo-naming): unify workspace repos as recoupable/ Drop the - slug — account names are mutable, so any URL that embeds the name eventually drifts. The repo URL is now just the account UUID: stable across renames, identical shape for personal and org workspaces, trivial to parse (`recoupable/`). Provisioning flow in ensurePersonalRepo: 1. recoupable/ exists -> return URL (idempotent) 2. legacy - exists (via GitHub search) -> rename to . GitHub auto-redirects the old URL forever, so any sessions.clone_url rows that still reference the old name keep working without a DB backfill. 3. nothing exists -> create fresh recoupable/ extractOrgId regex now accepts both legacy `-` and bare `` shapes so old + new clone URLs both parse. scripts/migrate-workspace-repo-names.ts: one-time backfill. Lists all recoupable org repos, finds those matching ^.+-$, renames each to just . Defaults to dry-run; --apply commits. Idempotent. resolveSessionCloneUrl no longer needs to look up the account row to derive a slug — ensurePersonalRepo only needs the accountId from auth. Deleted unused lib/string/toKebabCase. Co-Authored-By: Claude Opus 4.7 * review(PR #618): drop owner/token/description/isPrivate params Per Sweet's review comments on createRepository.ts, hard-code the constants that only have one sensible value: - owner = "recoupable" (RECOUPABLE_GITHUB_OWNER) - token = read from env via getServiceGithubToken (no param plumbing) - description = dropped (GitHub doesn't render anything meaningful) - private = false (workspace repos are public) Applied the same simplification consistently to the other three new github helpers so the surface stays symmetric: - repositoryExists, renameRepository, findLegacyAccountRepo all drop owner + token params; each reads the token via getServiceGithubToken and short-circuits when missing. - ensurePersonalRepo no longer threads token/owner through; its call sites simplified to e.g. createRepository({ name }) and repositoryExists({ repo }). - migrate-workspace-repo-names.ts script likewise calls renameRepository({ repo, newName }) — token plumbing removed. Tests updated to mock getServiceGithubToken instead of passing a fake token through the call. Co-Authored-By: Claude Opus 4.7 * prune: drop runtime legacy-rename branch (script-once is enough) The migration script renames every legacy - repo once before merge, after which no legacy repo can exist for ensurePersonalRepo to find. The runtime self-healing branch was pure YAGNI. Removed: - lib/github/findLegacyAccountRepo.ts (only caller was the runtime-rename branch) - lib/github/renameRepository.ts (sole consumer is the migration script; PATCH-rename inlined there) - the legacy-rename branch + its tests in ensurePersonalRepo ensurePersonalRepo is now just exists -> create. Co-Authored-By: Claude Opus 4.7 * prune: inline repo-name + URL into ensurePersonalRepo Per Sweet's review on buildPersonalRepoIdentifier.ts: post-refactor the helpers hide nothing — the repo name IS the accountId, the URL is one string concat. ensurePersonalRepo was their only runtime caller. Removed: - lib/recoupable/buildPersonalRepoIdentifier.ts (+ test) - lib/recoupable/buildPersonalRepoUrl.ts ensurePersonalRepo now derives the two values inline at the top of the function. Co-Authored-By: Claude Opus 4.7 * prune: ensurePersonalRepo now returns just the clone URL string Per Sweet's review on EnsurePersonalRepoResult: the only caller (resolveSessionCloneUrl) reads `cloneUrl` and nothing else. The other three fields (repoUrl, owner, repoName) were written into the response but never consumed. Drop the interface; return Promise. Co-Authored-By: Claude Opus 4.7 * unify: provision org workspace repo too (cloneUrl: null branch fix) Per Sweet's review on resolveSessionCloneUrl.ts:45 — the cloneUrl: null early-return for auth.orgId was leftover hedging that contradicts the unified recoupable/ design. Organizations ARE accounts in the data model (account_organization_ids.organization joins the accounts table), so auth.orgId is itself an account_id. The fix: drop the null-return branch and always call ensurePersonalRepo, keyed on auth.orgId ?? auth.accountId. Personal and org sessions now provision the same way — at recoupable/, where accountId is either the user's or the org's. Error message updated from "personal repository" to "workspace repository" to reflect the unified naming. Co-Authored-By: Claude Opus 4.7 * fix(migrate): match empty-slug - repos too The regex used .+ for the slug, requiring at least 1 char before the dash. Accounts that had no display name at repo-creation time produced - names (literal leading dash, empty kebab), and the first migration run skipped those as "non-workspace". 6 leading-dash repos turned up in the recoupable org after the first apply pass — 5 of them collided with already-renamed siblings (their losers were deleted manually); 1 was free and renamed. Changed .+ to .* so future runs of this script catch empty-slug names. Bare names still don't match (no separator before the UUID). Co-Authored-By: Claude Opus 4.7 * revert: createRepository back to private: true Smoke test of the api PR's preview surfaced an inconsistency — the 153 legacy workspace repos in the recoupable org are all private (created by old open-agents code with private: true), but my earlier review-feedback change set new repos to public. Per Sweet's follow-up, flip back to private so the entire fleet stays uniform. Co-Authored-By: Claude Opus 4.7 * chore: drop migration script (already applied to prod GitHub) scripts/migrate-workspace-repo-names.ts was a one-time backfill — ran against the recoupable org on 2026-05-26, renamed every legacy - workspace repo to bare , then verified zero pending via final dry-run. Keeping it in the codebase forever would be dead weight (per the same KISS principle we applied to findLegacyAccountRepo + renameRepository). Updated the ensurePersonalRepo docstring to reflect that the migration is historical, not an ongoing reference. Co-Authored-By: Claude Opus 4.7 * prune: slim CreateRepositoryResult to {success, repoUrl, error} Per Sweet's review — cloneUrl, owner, repoName are all derivable from repoUrl (cloneUrl = repoUrl + ".git" which git also accepts as repoUrl; owner = "recoupable"; repoName = trailing path segment). Dropped them from the interface and the parse + return shape. ensurePersonalRepo now consumes created.repoUrl directly. The existing-repo branch already returned repoUrl, so the function is now fully consistent on the no-".git" URL form. Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- lib/github/__tests__/createRepository.test.ts | 88 +++++++++++++++++ lib/github/__tests__/repositoryExists.test.ts | 61 ++++++++++++ lib/github/createRepository.ts | 95 +++++++++++++++++++ lib/github/repositoryExists.ts | 46 +++++++++ .../__tests__/ensurePersonalRepo.test.ts | 58 +++++++++++ lib/recoupable/__tests__/extractOrgId.test.ts | 12 +++ lib/recoupable/ensurePersonalRepo.ts | 48 ++++++++++ lib/recoupable/extractOrgId.ts | 11 ++- lib/recoupable/githubOwner.ts | 13 +++ .../__tests__/buildSessionInsertRow.test.ts | 16 ++-- .../createSessionHandler.persistence.test.ts | 32 +++++++ .../__tests__/createSessionHandler.test.ts | 3 + .../__tests__/resolveSessionCloneUrl.test.ts | 78 +++++++++++++++ lib/sessions/buildSessionInsertRow.ts | 11 ++- lib/sessions/createSessionHandler.ts | 19 +++- lib/sessions/resolveSessionCloneUrl.ts | 53 +++++++++++ 16 files changed, 634 insertions(+), 10 deletions(-) create mode 100644 lib/github/__tests__/createRepository.test.ts create mode 100644 lib/github/__tests__/repositoryExists.test.ts create mode 100644 lib/github/createRepository.ts create mode 100644 lib/github/repositoryExists.ts create mode 100644 lib/recoupable/__tests__/ensurePersonalRepo.test.ts create mode 100644 lib/recoupable/ensurePersonalRepo.ts create mode 100644 lib/recoupable/githubOwner.ts create mode 100644 lib/sessions/__tests__/resolveSessionCloneUrl.test.ts create mode 100644 lib/sessions/resolveSessionCloneUrl.ts diff --git a/lib/github/__tests__/createRepository.test.ts b/lib/github/__tests__/createRepository.test.ts new file mode 100644 index 000000000..ee9e013ac --- /dev/null +++ b/lib/github/__tests__/createRepository.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { createRepository } from "@/lib/github/createRepository"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; + +vi.mock("@/lib/github/getServiceGithubToken", () => ({ + getServiceGithubToken: vi.fn(() => "tok"), +})); + +describe("createRepository", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getServiceGithubToken).mockReturnValue("tok"); + }); + + it("returns failure when GITHUB_TOKEN is missing", async () => { + vi.mocked(getServiceGithubToken).mockReturnValue(undefined); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await createRepository({ name: "id-1" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/GITHUB_TOKEN/i); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("rejects invalid names without hitting the network", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await createRepository({ name: "bad name with spaces" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/invalid/i); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("POSTs to /orgs/recoupable/repos with hard-coded private=true + auto_init=true", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + html_url: "https://github.com/recoupable/id-1", + }), + { status: 201, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await createRepository({ name: "id-1" }); + + expect(result).toEqual({ + success: true, + repoUrl: "https://github.com/recoupable/id-1", + }); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://api.github.com/orgs/recoupable/repos"); + expect(init.method).toBe("POST"); + expect(JSON.parse(init.body as string)).toEqual({ + name: "id-1", + private: true, + auto_init: true, + }); + }); + + it("returns name-conflict error on 422", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 422 })); + + const result = await createRepository({ name: "id-1" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/already exists/i); + }); + + it("returns permission-denied error on 403", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 403 })); + + const result = await createRepository({ name: "id-1" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/permission/i); + }); + + it("returns network-error on fetch rejection", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("ECONNRESET")); + + const result = await createRepository({ name: "id-1" }); + + expect(result.success).toBe(false); + expect(result.error).toMatch(/network/i); + }); +}); diff --git a/lib/github/__tests__/repositoryExists.test.ts b/lib/github/__tests__/repositoryExists.test.ts new file mode 100644 index 000000000..66853591a --- /dev/null +++ b/lib/github/__tests__/repositoryExists.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { repositoryExists } from "@/lib/github/repositoryExists"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; + +vi.mock("@/lib/github/getServiceGithubToken", () => ({ + getServiceGithubToken: vi.fn(() => "tok"), +})); + +describe("repositoryExists", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getServiceGithubToken).mockReturnValue("tok"); + }); + + it("returns null when GITHUB_TOKEN is missing", async () => { + vi.mocked(getServiceGithubToken).mockReturnValue(undefined); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + const result = await repositoryExists({ repo: "id-1" }); + + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns true on 200 and calls GET /repos/recoupable/", async () => { + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response(null, { status: 200 })); + + const result = await repositoryExists({ repo: "id-1" }); + + expect(result).toBe(true); + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.github.com/repos/recoupable/id-1", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Authorization: "Bearer tok", + }), + }), + ); + }); + + it("returns false on 404", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 404 })); + + expect(await repositoryExists({ repo: "missing" })).toBe(false); + }); + + it("returns null on other statuses (auth, rate limit, etc.)", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 403 })); + + expect(await repositoryExists({ repo: "rate-limited" })).toBeNull(); + }); + + it("returns null on network failure", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("ECONNRESET")); + + expect(await repositoryExists({ repo: "anything" })).toBeNull(); + }); +}); diff --git a/lib/github/createRepository.ts b/lib/github/createRepository.ts new file mode 100644 index 000000000..c6eebaf03 --- /dev/null +++ b/lib/github/createRepository.ts @@ -0,0 +1,95 @@ +import { RECOUPABLE_GITHUB_OWNER } from "@/lib/recoupable/githubOwner"; +import { getServiceGithubToken } from "./getServiceGithubToken"; + +export interface CreateRepositoryResult { + success: boolean; + /** GitHub UI URL (`html_url`). */ + repoUrl?: string; + /** Human-readable error message; only set when `success` is false. */ + error?: string; +} + +/** + * Create a workspace repository under the Recoupable GitHub org. + * + * Hard-coded conventions (per PR #618 review — KISS / YAGNI): + * - owner = `recoupable` (no other owner makes sense; see + * `RECOUPABLE_GITHUB_OWNER`). + * - private = true (matches the 153 legacy workspace repos that + * pre-date this code path — keeps the fleet uniform; clones from + * sandboxes auth via the GITHUB_TOKEN service token). + * - description = none (GitHub doesn't render anything meaningful + * for these per-account repos). + * - token = read once from `GITHUB_TOKEN` via + * `getServiceGithubToken` (single source of truth — callers no + * longer thread the token through). + * + * `auto_init: true` so the repo has an initial `main` branch the + * sandbox can `git clone`. Without it, cloning a 0-commit repo fails. + * + * Uses plain `fetch` to match recoup-api's existing `lib/github/*` + * style (no Octokit dependency). + */ +export async function createRepository(params: { name: string }): Promise { + const { name } = params; + + const token = getServiceGithubToken(); + if (!token) { + console.error("[createRepository] GITHUB_TOKEN missing"); + return { success: false, error: "GITHUB_TOKEN missing" }; + } + + if (!/^[\w.-]+$/.test(name)) { + return { + success: false, + error: + "Invalid repository name. Use only letters, numbers, hyphens, underscores, and periods.", + }; + } + + try { + const response = await fetch(`https://api.github.com/orgs/${RECOUPABLE_GITHUB_OWNER}/repos`, { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ + name, + private: true, + auto_init: true, + }), + }); + + if (response.status === 201) { + const data = (await response.json()) as { html_url: string }; + return { success: true, repoUrl: data.html_url }; + } + + if (response.status === 422) { + return { + success: false, + error: "Repository name already exists or is invalid", + }; + } + if (response.status === 403) { + return { success: false, error: "Permission denied" }; + } + + let body = ""; + try { + body = await response.text(); + } catch { + body = ""; + } + console.error( + `[createRepository] unexpected status ${response.status} for ${RECOUPABLE_GITHUB_OWNER}/${name}: ${body}`, + ); + return { success: false, error: `GitHub returned ${response.status}` }; + } catch (error) { + console.error("[createRepository] network error:", error); + return { success: false, error: "Network error talking to GitHub" }; + } +} diff --git a/lib/github/repositoryExists.ts b/lib/github/repositoryExists.ts new file mode 100644 index 000000000..62e4e5f16 --- /dev/null +++ b/lib/github/repositoryExists.ts @@ -0,0 +1,46 @@ +import { RECOUPABLE_GITHUB_OWNER } from "@/lib/recoupable/githubOwner"; +import { getServiceGithubToken } from "./getServiceGithubToken"; + +/** + * Returns `true` if `recoupable/` exists, `false` if 404, `null` + * on any other failure (auth, rate limit, network, missing token). + * Lets callers distinguish "doesn't exist yet" from "couldn't reach + * GitHub" before attempting destructive ops like create. + * + * Owner is hard-coded to `recoupable` and the GitHub token is read + * from the environment (per PR #618 review — single source of truth). + */ +export async function repositoryExists(params: { repo: string }): Promise { + const { repo } = params; + + const token = getServiceGithubToken(); + if (!token) { + console.error("[repositoryExists] GITHUB_TOKEN missing"); + return null; + } + + try { + const response = await fetch( + `https://api.github.com/repos/${RECOUPABLE_GITHUB_OWNER}/${repo}`, + { + method: "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }, + ); + + if (response.status === 200) return true; + if (response.status === 404) return false; + + console.error( + `[repositoryExists] unexpected status ${response.status} for ${RECOUPABLE_GITHUB_OWNER}/${repo}`, + ); + return null; + } catch (error) { + console.error("[repositoryExists] network error:", error); + return null; + } +} diff --git a/lib/recoupable/__tests__/ensurePersonalRepo.test.ts b/lib/recoupable/__tests__/ensurePersonalRepo.test.ts new file mode 100644 index 000000000..5ea6ca1f8 --- /dev/null +++ b/lib/recoupable/__tests__/ensurePersonalRepo.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; +import { repositoryExists } from "@/lib/github/repositoryExists"; +import { createRepository } from "@/lib/github/createRepository"; + +vi.mock("@/lib/github/repositoryExists", () => ({ + repositoryExists: vi.fn(), +})); +vi.mock("@/lib/github/createRepository", () => ({ + createRepository: vi.fn(), +})); + +const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; + +describe("ensurePersonalRepo", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the existing repo URL when recoupable/ already exists", async () => { + vi.mocked(repositoryExists).mockResolvedValue(true); + + const result = await ensurePersonalRepo({ accountId }); + + expect(result).toBe(`https://github.com/recoupable/${accountId}`); + expect(createRepository).not.toHaveBeenCalled(); + }); + + it("returns null when the existence check fails for non-404 reasons", async () => { + vi.mocked(repositoryExists).mockResolvedValue(null); + + expect(await ensurePersonalRepo({ accountId })).toBeNull(); + expect(createRepository).not.toHaveBeenCalled(); + }); + + it("creates a fresh repo when none exists and returns its URL", async () => { + vi.mocked(repositoryExists).mockResolvedValue(false); + vi.mocked(createRepository).mockResolvedValue({ + success: true, + repoUrl: `https://github.com/recoupable/${accountId}`, + }); + + const result = await ensurePersonalRepo({ accountId }); + + expect(createRepository).toHaveBeenCalledWith({ name: accountId }); + expect(result).toBe(`https://github.com/recoupable/${accountId}`); + }); + + it("returns null when creation outright fails", async () => { + vi.mocked(repositoryExists).mockResolvedValue(false); + vi.mocked(createRepository).mockResolvedValue({ + success: false, + error: "Permission denied", + }); + + expect(await ensurePersonalRepo({ accountId })).toBeNull(); + }); +}); diff --git a/lib/recoupable/__tests__/extractOrgId.test.ts b/lib/recoupable/__tests__/extractOrgId.test.ts index c38232c4c..dc161cc52 100644 --- a/lib/recoupable/__tests__/extractOrgId.test.ts +++ b/lib/recoupable/__tests__/extractOrgId.test.ts @@ -38,6 +38,18 @@ describe("extractOrgId", () => { ); }); + it("extracts the UUID from a bare new-naming repo (recoupable/)", () => { + expect(extractOrgId("https://github.com/recoupable/fb678396-a68f-4294-ae50-b8cacf9ce77b")).toBe( + "fb678396-a68f-4294-ae50-b8cacf9ce77b", + ); + }); + + it("accepts a bare new-naming repo name", () => { + expect(extractOrgId("fb678396-a68f-4294-ae50-b8cacf9ce77b")).toBe( + "fb678396-a68f-4294-ae50-b8cacf9ce77b", + ); + }); + it("returns null for non-Recoupable clone URLs", () => { expect( extractOrgId( diff --git a/lib/recoupable/ensurePersonalRepo.ts b/lib/recoupable/ensurePersonalRepo.ts new file mode 100644 index 000000000..0b60f3bcc --- /dev/null +++ b/lib/recoupable/ensurePersonalRepo.ts @@ -0,0 +1,48 @@ +import { createRepository } from "@/lib/github/createRepository"; +import { repositoryExists } from "@/lib/github/repositoryExists"; +import { RECOUPABLE_GITHUB_OWNER } from "./githubOwner"; + +/** + * Idempotently ensure an account has a workspace repo at the + * canonical `recoupable/` location. + * + * 1. If `recoupable/` already exists → return its URL. + * 2. Otherwise create it with `auto_init: true` so the sandbox has + * a `main` branch to clone, and return the new clone URL. + * + * Returns `null` only when the GitHub helpers can't get a service + * token or repo creation outright fails — the caller surfaces that + * as a 502. The single-string return matches what callers actually + * consume (the clone URL); owner / repo name are trivially + * recoverable from the URL if ever needed. + * + * Legacy `-` repos were renamed to the canonical + * `` shape in a one-time backfill (see PR #618), so the + * runtime never needs to look for a legacy name — every workspace + * either already lives at `recoupable/` or doesn't exist + * yet. + */ +export async function ensurePersonalRepo(params: { accountId: string }): Promise { + const repoName = params.accountId; + const repoUrl = `https://github.com/${RECOUPABLE_GITHUB_OWNER}/${repoName}`; + + const existing = await repositoryExists({ repo: repoName }); + + if (existing === null) { + console.error(`[ensurePersonalRepo] failed to check ${RECOUPABLE_GITHUB_OWNER}/${repoName}`); + return null; + } + + if (existing) { + return repoUrl; + } + + const created = await createRepository({ name: repoName }); + + if (!created.success || !created.repoUrl) { + console.error(`[ensurePersonalRepo] createRepository failed: ${created.error ?? "unknown"}`); + return null; + } + + return created.repoUrl; +} diff --git a/lib/recoupable/extractOrgId.ts b/lib/recoupable/extractOrgId.ts index ac30985c5..174fb63c9 100644 --- a/lib/recoupable/extractOrgId.ts +++ b/lib/recoupable/extractOrgId.ts @@ -1,6 +1,15 @@ import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName"; -const UUID_TAIL_PATTERN = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i; +const UUID_RAW = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +/** + * Match the trailing UUID either bare (new naming: ``) or after + * a slug + dash (legacy: `-` / `org--`). Both + * shapes coexist until the migration script renames every legacy repo + * — and even after, `sessions.clone_url` rows from before the rename + * still resolve via GitHub's redirect, so this parser keeps working + * for old rows forever. + */ +const UUID_TAIL_PATTERN = new RegExp(`(?:^|-)(${UUID_RAW})$`, "i"); /** * Extracts the organization UUID from a Recoupable org clone URL or diff --git a/lib/recoupable/githubOwner.ts b/lib/recoupable/githubOwner.ts new file mode 100644 index 000000000..a87ee71a2 --- /dev/null +++ b/lib/recoupable/githubOwner.ts @@ -0,0 +1,13 @@ +/** + * The GitHub organization that owns every Recoupable workspace repo, + * both per-Recoupable-org repos (`recoupable/org--`) and + * per-account personal repos (`recoupable/-`). + * + * Single source of truth so renames or alternate-environment overrides + * stay in lockstep across builders, parsers, and GitHub API callers. + * Mirrors open-agents `apps/web/lib/recoupable/github-owner.ts` and + * chat `lib/recoupable/githubOwner.ts` — all three surfaces must + * agree on this value, otherwise sandboxes try to clone from + * different orgs depending on which surface created them. + */ +export const RECOUPABLE_GITHUB_OWNER = "recoupable"; diff --git a/lib/sessions/__tests__/buildSessionInsertRow.test.ts b/lib/sessions/__tests__/buildSessionInsertRow.test.ts index a786871f5..6d54d0e3c 100644 --- a/lib/sessions/__tests__/buildSessionInsertRow.test.ts +++ b/lib/sessions/__tests__/buildSessionInsertRow.test.ts @@ -3,7 +3,12 @@ import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; describe("buildSessionInsertRow", () => { it("returns sane defaults for an empty body", () => { - const row = buildSessionInsertRow({ body: {}, accountId: "acc-1", title: "Berlin" }); + const row = buildSessionInsertRow({ + body: {}, + accountId: "acc-1", + title: "Berlin", + cloneUrl: null, + }); expect(row.account_id).toBe("acc-1"); expect(row.title).toBe("Berlin"); expect(row.status).toBe("running"); @@ -15,14 +20,12 @@ describe("buildSessionInsertRow", () => { expect(row.id).toMatch(/^[0-9a-f-]{36}$/i); }); - it("forwards branch + clone fields verbatim", () => { + it("writes the resolved cloneUrl onto clone_url", () => { const row = buildSessionInsertRow({ - body: { - branch: "main", - cloneUrl: "https://github.com/recoupable/ai.git", - }, + body: { branch: "main" }, accountId: "acc-1", title: "Berlin", + cloneUrl: "https://github.com/recoupable/ai.git", }); expect(row.branch).toBe("main"); expect(row.clone_url).toBe("https://github.com/recoupable/ai.git"); @@ -33,6 +36,7 @@ describe("buildSessionInsertRow", () => { body: { sandboxType: "vercel" }, accountId: "acc-1", title: "Berlin", + cloneUrl: null, }); expect(row.sandbox_state).toEqual({ type: "vercel" }); }); diff --git a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts index 038889a3d..66e52963d 100644 --- a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts +++ b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts @@ -5,6 +5,7 @@ import { insertSession } from "@/lib/supabase/sessions/insertSession"; import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; import { insertChat } from "@/lib/supabase/chats/insertChat"; import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; +import { resolveSessionCloneUrl } from "@/lib/sessions/resolveSessionCloneUrl"; import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; @@ -22,6 +23,9 @@ vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ resolveSessionTitle: vi.fn(async () => "Anchorage"), })); +vi.mock("@/lib/sessions/resolveSessionCloneUrl", () => ({ + resolveSessionCloneUrl: vi.fn(async () => ({ ok: true, cloneUrl: null })), +})); const okValidated = (overrides: { body?: object; accountId?: string } = {}) => ({ body: overrides.body ?? {}, @@ -95,6 +99,34 @@ describe("createSessionHandler — persistence", () => { expect(deleteSessionById).toHaveBeenCalledWith("sess_rollback"); }); + it("returns 502 when resolveSessionCloneUrl fails (e.g. personal repo provisioning blew up)", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(resolveSessionCloneUrl).mockResolvedValueOnce({ + ok: false, + error: "Failed to provision personal repository", + }); + + const res = await createSessionHandler(makeCreateSessionReq({})); + expect(res.status).toBe(502); + expect(insertSession).not.toHaveBeenCalled(); + }); + + it("forwards the resolved cloneUrl onto the inserted session row", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(resolveSessionCloneUrl).mockResolvedValueOnce({ + ok: true, + cloneUrl: "https://github.com/recoupable/sweetman-acc-uuid-1", + }); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow()); + vi.mocked(insertChat).mockResolvedValue(baseChatRow()); + + await createSessionHandler(makeCreateSessionReq({})); + + expect(vi.mocked(insertSession).mock.calls[0][0].clone_url).toBe( + "https://github.com/recoupable/sweetman-acc-uuid-1", + ); + }); + it("logs an orphan-session error when rollback also fails", async () => { vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_orphan" })); diff --git a/lib/sessions/__tests__/createSessionHandler.test.ts b/lib/sessions/__tests__/createSessionHandler.test.ts index a239ccd82..cada68e71 100644 --- a/lib/sessions/__tests__/createSessionHandler.test.ts +++ b/lib/sessions/__tests__/createSessionHandler.test.ts @@ -18,6 +18,9 @@ vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ resolveSessionTitle: vi.fn(async () => "Anchorage"), })); +vi.mock("@/lib/sessions/resolveSessionCloneUrl", () => ({ + resolveSessionCloneUrl: vi.fn(async () => ({ ok: true, cloneUrl: null })), +})); describe("createSessionHandler — short-circuits on validation failure", () => { beforeEach(() => vi.clearAllMocks()); diff --git a/lib/sessions/__tests__/resolveSessionCloneUrl.test.ts b/lib/sessions/__tests__/resolveSessionCloneUrl.test.ts new file mode 100644 index 000000000..ed2104789 --- /dev/null +++ b/lib/sessions/__tests__/resolveSessionCloneUrl.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { resolveSessionCloneUrl } from "@/lib/sessions/resolveSessionCloneUrl"; +import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/recoupable/ensurePersonalRepo", () => ({ + ensurePersonalRepo: vi.fn(), +})); + +const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; +const orgId = "0a0a0a0a-a0a0-4a0a-8a0a-aaaaaaaaaaaa"; +const baseAuth: AuthContext = { + accountId, + orgId: null, + authToken: "tok", +}; + +describe("resolveSessionCloneUrl", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("uses the body cloneUrl when provided, regardless of org state", async () => { + const result = await resolveSessionCloneUrl({ + bodyCloneUrl: "https://github.com/recoupable/org-foo-id-1", + auth: { ...baseAuth, orgId }, + }); + + expect(result).toEqual({ + ok: true, + cloneUrl: "https://github.com/recoupable/org-foo-id-1", + }); + expect(ensurePersonalRepo).not.toHaveBeenCalled(); + }); + + it("provisions the user's workspace when no body cloneUrl and no org", async () => { + vi.mocked(ensurePersonalRepo).mockResolvedValue(`https://github.com/recoupable/${accountId}`); + + const result = await resolveSessionCloneUrl({ + bodyCloneUrl: undefined, + auth: baseAuth, + }); + + expect(result).toEqual({ + ok: true, + cloneUrl: `https://github.com/recoupable/${accountId}`, + }); + expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId }); + }); + + it("provisions the ORG's workspace when no body cloneUrl and an org is bound", async () => { + vi.mocked(ensurePersonalRepo).mockResolvedValue(`https://github.com/recoupable/${orgId}`); + + const result = await resolveSessionCloneUrl({ + bodyCloneUrl: undefined, + auth: { ...baseAuth, orgId }, + }); + + expect(result).toEqual({ + ok: true, + cloneUrl: `https://github.com/recoupable/${orgId}`, + }); + // Keyed on orgId (organizations are accounts), not the caller's accountId. + expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId: orgId }); + }); + + it("returns an error when ensurePersonalRepo fails", async () => { + vi.mocked(ensurePersonalRepo).mockResolvedValue(null); + + const result = await resolveSessionCloneUrl({ + bodyCloneUrl: undefined, + auth: baseAuth, + }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); +}); diff --git a/lib/sessions/buildSessionInsertRow.ts b/lib/sessions/buildSessionInsertRow.ts index 8c718f57f..de6dfbce3 100644 --- a/lib/sessions/buildSessionInsertRow.ts +++ b/lib/sessions/buildSessionInsertRow.ts @@ -6,6 +6,13 @@ interface BuildSessionInsertRowInput { body: CreateSessionBody; accountId: string; title: string; + /** + * Final clone URL resolved by `resolveSessionCloneUrl`. When `null`, + * the session row stores no `clone_url` — matches the prior + * `body.cloneUrl ?? null` behavior for callers that don't (yet) + * trigger personal-repo provisioning. + */ + cloneUrl: string | null; } /** @@ -21,14 +28,14 @@ interface BuildSessionInsertRowInput { * @returns A row ready to pass to `insertSession`. */ export function buildSessionInsertRow(input: BuildSessionInsertRowInput): TablesInsert<"sessions"> { - const { body, accountId, title } = input; + const { body, accountId, title, cloneUrl } = input; return { id: generateUUID(), account_id: accountId, title, status: "running", branch: body.branch ?? null, - clone_url: body.cloneUrl ?? null, + clone_url: cloneUrl, global_skill_refs: [], sandbox_state: { type: body.sandboxType ?? "vercel" }, lifecycle_state: "provisioning", diff --git a/lib/sessions/createSessionHandler.ts b/lib/sessions/createSessionHandler.ts index d27b0b9e1..0dc416634 100644 --- a/lib/sessions/createSessionHandler.ts +++ b/lib/sessions/createSessionHandler.ts @@ -3,6 +3,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; +import { resolveSessionCloneUrl } from "@/lib/sessions/resolveSessionCloneUrl"; import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; import { failedToCreateSession } from "@/lib/sessions/failedToCreateSession"; import { insertSession } from "@/lib/supabase/sessions/insertSession"; @@ -37,8 +38,24 @@ export async function createSessionHandler(request: NextRequest): Promise` workspace exists + * and use that URL. The provisioning is the same for personal + * sessions and org-bound sessions because organizations are + * themselves accounts (`auth.orgId` IS an account_id — see + * `account_organization_ids.organization` joining `accounts`). + * When an org is bound, the workspace is keyed on `auth.orgId`; + * otherwise on the user's own `auth.accountId`. + */ +export async function resolveSessionCloneUrl(params: { + bodyCloneUrl: string | undefined; + auth: AuthContext; +}): Promise { + const { bodyCloneUrl, auth } = params; + + if (bodyCloneUrl) { + return { ok: true, cloneUrl: bodyCloneUrl }; + } + + const workspaceAccountId = auth.orgId ?? auth.accountId; + const cloneUrl = await ensurePersonalRepo({ + accountId: workspaceAccountId, + }); + + if (!cloneUrl) { + return { + ok: false, + cloneUrl: null, + error: "Failed to provision workspace repository", + }; + } + + return { ok: true, cloneUrl }; +} From f05b050e582f784c5a3d1d8be1c0679aecc94521 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Tue, 26 May 2026 15:48:12 -0500 Subject: [PATCH 07/15] feat(sessions): replace body cloneUrl+branch with organizationId (#620) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sessions): replace body cloneUrl+branch with organizationId POST /api/sessions's request schema is now {title?, organizationId?, sandboxType?}. Removed: - cloneUrl: api derives the workspace repo URL itself via ensurePersonalRepo (recoupable/ for personal, recoupable/ for org sessions) - branch: was only ever piped to sessions.branch column; sandbox handler already falls back to repo default when null - repoOwner / repoName / isNewBranch: never accepted (Zod silently dropped them); pure docs drift, removed from the OpenAPI spec in recoupable/docs#226 Added: - organizationId: optional uuid; when present, validated by validateAuthContext's existing input.organizationId path and sets auth.orgId. resolveSessionCloneUrl's old `auth.orgId ?? auth.accountId` logic now derives the workspace owner from the request alone. Inlined resolveSessionCloneUrl into createSessionHandler — it was a thin wrapper around ensurePersonalRepo once bodyCloneUrl support was removed. Deleted the file and its test. Tests: - buildSessionInsertRow takes a non-null cloneUrl now (always set on session create); branch column is hard-coded null in the row. - createSessionHandler.persistence covers both personal and org branches (ensurePersonalRepo called with auth.accountId vs auth.orgId respectively) plus the 502 path when ensure fails. - validateCreateSessionBody covers the new organizationId uuid validation + forwarding to validateAuthContext. 3,322 / 3,322 tests pass; `next build` TS phase clean. Co-Authored-By: Claude Opus 4.7 * feat(sessions): drop sandboxType too — only vercel is supported z.literal("vercel") with hard-coded SandboxState as { type: "vercel" } & VercelState meant the body field had exactly one acceptable value and the column always got the same value regardless. Pure YAGNI. Also drops `body: CreateSessionBody` from buildSessionInsertRow's input — nothing in the body shape is read there anymore (title is resolved upstream, cloneUrl is passed in, sandboxType is gone). Final POST /api/sessions request shape: { title?, organizationId? }. Pairs with recoupable/docs#226 (also amended to drop sandboxType). Co-Authored-By: Claude Opus 4.7 --------- Co-authored-by: Claude Opus 4.7 --- .../__tests__/buildSessionInsertRow.test.ts | 29 ++++--- .../createSessionHandler.persistence.test.ts | 63 ++++++++------- .../__tests__/createSessionHandler.test.ts | 11 ++- .../__tests__/resolveSessionCloneUrl.test.ts | 78 ------------------- .../validateCreateSessionBody.test.ts | 40 ++++++---- lib/sessions/buildSessionInsertRow.ts | 28 +++---- lib/sessions/createSessionHandler.ts | 29 +++---- lib/sessions/resolveSessionCloneUrl.ts | 53 ------------- lib/sessions/validateCreateSessionBody.ts | 37 ++++++--- 9 files changed, 140 insertions(+), 228 deletions(-) delete mode 100644 lib/sessions/__tests__/resolveSessionCloneUrl.test.ts delete mode 100644 lib/sessions/resolveSessionCloneUrl.ts diff --git a/lib/sessions/__tests__/buildSessionInsertRow.test.ts b/lib/sessions/__tests__/buildSessionInsertRow.test.ts index 6d54d0e3c..2b75c679a 100644 --- a/lib/sessions/__tests__/buildSessionInsertRow.test.ts +++ b/lib/sessions/__tests__/buildSessionInsertRow.test.ts @@ -1,13 +1,14 @@ import { describe, it, expect } from "vitest"; import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; +const DEFAULT_CLONE = "https://github.com/recoupable/acc-1"; + describe("buildSessionInsertRow", () => { - it("returns sane defaults for an empty body", () => { + it("returns sane defaults", () => { const row = buildSessionInsertRow({ - body: {}, accountId: "acc-1", title: "Berlin", - cloneUrl: null, + cloneUrl: DEFAULT_CLONE, }); expect(row.account_id).toBe("acc-1"); expect(row.title).toBe("Berlin"); @@ -16,27 +17,33 @@ describe("buildSessionInsertRow", () => { expect(row.lifecycle_version).toBe(0); expect(row.sandbox_state).toEqual({ type: "vercel" }); expect(row.branch).toBeNull(); - expect(row.clone_url).toBeNull(); + expect(row.clone_url).toBe(DEFAULT_CLONE); expect(row.id).toMatch(/^[0-9a-f-]{36}$/i); }); it("writes the resolved cloneUrl onto clone_url", () => { const row = buildSessionInsertRow({ - body: { branch: "main" }, accountId: "acc-1", title: "Berlin", - cloneUrl: "https://github.com/recoupable/ai.git", + cloneUrl: "https://github.com/recoupable/org-uuid-9", + }); + expect(row.clone_url).toBe("https://github.com/recoupable/org-uuid-9"); + }); + + it("always sets branch to null (no longer sourced from body)", () => { + const row = buildSessionInsertRow({ + accountId: "acc-1", + title: "Berlin", + cloneUrl: DEFAULT_CLONE, }); - expect(row.branch).toBe("main"); - expect(row.clone_url).toBe("https://github.com/recoupable/ai.git"); + expect(row.branch).toBeNull(); }); - it("uses the provided sandboxType when set", () => { + it("hard-codes sandbox_state.type to vercel (only provider supported)", () => { const row = buildSessionInsertRow({ - body: { sandboxType: "vercel" }, accountId: "acc-1", title: "Berlin", - cloneUrl: null, + cloneUrl: DEFAULT_CLONE, }); expect(row.sandbox_state).toEqual({ type: "vercel" }); }); diff --git a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts index 66e52963d..cf54c9eff 100644 --- a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts +++ b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts @@ -5,7 +5,7 @@ import { insertSession } from "@/lib/supabase/sessions/insertSession"; import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; import { insertChat } from "@/lib/supabase/chats/insertChat"; import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; -import { resolveSessionCloneUrl } from "@/lib/sessions/resolveSessionCloneUrl"; +import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; @@ -23,21 +23,28 @@ vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ resolveSessionTitle: vi.fn(async () => "Anchorage"), })); -vi.mock("@/lib/sessions/resolveSessionCloneUrl", () => ({ - resolveSessionCloneUrl: vi.fn(async () => ({ ok: true, cloneUrl: null })), +vi.mock("@/lib/recoupable/ensurePersonalRepo", () => ({ + ensurePersonalRepo: vi.fn(), })); -const okValidated = (overrides: { body?: object; accountId?: string } = {}) => ({ +const DEFAULT_CLONE_URL = "https://github.com/recoupable/acc-uuid-1"; + +const okValidated = ( + overrides: { body?: object; accountId?: string; orgId?: string | null } = {}, +) => ({ body: overrides.body ?? {}, auth: { accountId: overrides.accountId ?? "acc-uuid-1", - orgId: null, + orgId: overrides.orgId ?? null, authToken: "key_test", }, }); describe("createSessionHandler — persistence", () => { - beforeEach(() => vi.clearAllMocks()); + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ensurePersonalRepo).mockResolvedValue(DEFAULT_CLONE_URL); + }); it("creates session and chat with defaults on empty body", async () => { vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); @@ -56,12 +63,33 @@ describe("createSessionHandler — persistence", () => { expect(insertArgs.status).toBe("running"); expect(insertArgs.lifecycle_state).toBe("provisioning"); expect(insertArgs.sandbox_state).toEqual({ type: "vercel" }); + expect(insertArgs.clone_url).toBe(DEFAULT_CLONE_URL); const chatArgs = vi.mocked(insertChat).mock.calls[0][0]; expect(chatArgs.session_id).toBe("sess_1"); expect(chatArgs.title).toBe("New chat"); }); + it("uses auth.accountId for personal sessions", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow()); + vi.mocked(insertChat).mockResolvedValue(baseChatRow()); + + await createSessionHandler(makeCreateSessionReq({})); + + expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId: "acc-uuid-1" }); + }); + + it("uses auth.orgId when an org is bound (org session)", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated({ orgId: "org-uuid-9" })); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow()); + vi.mocked(insertChat).mockResolvedValue(baseChatRow()); + + await createSessionHandler(makeCreateSessionReq({})); + + expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId: "org-uuid-9" }); + }); + it("forwards body title to resolveSessionTitle and writes the resolved title", async () => { vi.mocked(validateCreateSessionBody).mockResolvedValue( okValidated({ body: { title: "Hello world" } }), @@ -99,34 +127,15 @@ describe("createSessionHandler — persistence", () => { expect(deleteSessionById).toHaveBeenCalledWith("sess_rollback"); }); - it("returns 502 when resolveSessionCloneUrl fails (e.g. personal repo provisioning blew up)", async () => { + it("returns 502 when ensurePersonalRepo fails", async () => { vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); - vi.mocked(resolveSessionCloneUrl).mockResolvedValueOnce({ - ok: false, - error: "Failed to provision personal repository", - }); + vi.mocked(ensurePersonalRepo).mockResolvedValueOnce(null); const res = await createSessionHandler(makeCreateSessionReq({})); expect(res.status).toBe(502); expect(insertSession).not.toHaveBeenCalled(); }); - it("forwards the resolved cloneUrl onto the inserted session row", async () => { - vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); - vi.mocked(resolveSessionCloneUrl).mockResolvedValueOnce({ - ok: true, - cloneUrl: "https://github.com/recoupable/sweetman-acc-uuid-1", - }); - vi.mocked(insertSession).mockResolvedValue(baseSessionRow()); - vi.mocked(insertChat).mockResolvedValue(baseChatRow()); - - await createSessionHandler(makeCreateSessionReq({})); - - expect(vi.mocked(insertSession).mock.calls[0][0].clone_url).toBe( - "https://github.com/recoupable/sweetman-acc-uuid-1", - ); - }); - it("logs an orphan-session error when rollback also fails", async () => { vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_orphan" })); diff --git a/lib/sessions/__tests__/createSessionHandler.test.ts b/lib/sessions/__tests__/createSessionHandler.test.ts index cada68e71..0e20d7938 100644 --- a/lib/sessions/__tests__/createSessionHandler.test.ts +++ b/lib/sessions/__tests__/createSessionHandler.test.ts @@ -18,8 +18,8 @@ vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ resolveSessionTitle: vi.fn(async () => "Anchorage"), })); -vi.mock("@/lib/sessions/resolveSessionCloneUrl", () => ({ - resolveSessionCloneUrl: vi.fn(async () => ({ ok: true, cloneUrl: null })), +vi.mock("@/lib/recoupable/ensurePersonalRepo", () => ({ + ensurePersonalRepo: vi.fn(async () => "https://github.com/recoupable/acc-uuid-1"), })); describe("createSessionHandler — short-circuits on validation failure", () => { @@ -36,10 +36,13 @@ describe("createSessionHandler — short-circuits on validation failure", () => it("returns 400 when validateCreateSessionBody rejects with 400", async () => { vi.mocked(validateCreateSessionBody).mockResolvedValue( - NextResponse.json({ status: "error", error: "Invalid sandbox type" }, { status: 400 }), + NextResponse.json( + { status: "error", error: "organizationId must be a valid UUID" }, + { status: 400 }, + ), ); - const res = await createSessionHandler(makeCreateSessionReq({ sandboxType: "wrong" })); + const res = await createSessionHandler(makeCreateSessionReq({ organizationId: "not-a-uuid" })); expect(res.status).toBe(400); expect(insertSession).not.toHaveBeenCalled(); }); diff --git a/lib/sessions/__tests__/resolveSessionCloneUrl.test.ts b/lib/sessions/__tests__/resolveSessionCloneUrl.test.ts deleted file mode 100644 index ed2104789..000000000 --- a/lib/sessions/__tests__/resolveSessionCloneUrl.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { resolveSessionCloneUrl } from "@/lib/sessions/resolveSessionCloneUrl"; -import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; -import type { AuthContext } from "@/lib/auth/validateAuthContext"; - -vi.mock("@/lib/recoupable/ensurePersonalRepo", () => ({ - ensurePersonalRepo: vi.fn(), -})); - -const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; -const orgId = "0a0a0a0a-a0a0-4a0a-8a0a-aaaaaaaaaaaa"; -const baseAuth: AuthContext = { - accountId, - orgId: null, - authToken: "tok", -}; - -describe("resolveSessionCloneUrl", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("uses the body cloneUrl when provided, regardless of org state", async () => { - const result = await resolveSessionCloneUrl({ - bodyCloneUrl: "https://github.com/recoupable/org-foo-id-1", - auth: { ...baseAuth, orgId }, - }); - - expect(result).toEqual({ - ok: true, - cloneUrl: "https://github.com/recoupable/org-foo-id-1", - }); - expect(ensurePersonalRepo).not.toHaveBeenCalled(); - }); - - it("provisions the user's workspace when no body cloneUrl and no org", async () => { - vi.mocked(ensurePersonalRepo).mockResolvedValue(`https://github.com/recoupable/${accountId}`); - - const result = await resolveSessionCloneUrl({ - bodyCloneUrl: undefined, - auth: baseAuth, - }); - - expect(result).toEqual({ - ok: true, - cloneUrl: `https://github.com/recoupable/${accountId}`, - }); - expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId }); - }); - - it("provisions the ORG's workspace when no body cloneUrl and an org is bound", async () => { - vi.mocked(ensurePersonalRepo).mockResolvedValue(`https://github.com/recoupable/${orgId}`); - - const result = await resolveSessionCloneUrl({ - bodyCloneUrl: undefined, - auth: { ...baseAuth, orgId }, - }); - - expect(result).toEqual({ - ok: true, - cloneUrl: `https://github.com/recoupable/${orgId}`, - }); - // Keyed on orgId (organizations are accounts), not the caller's accountId. - expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId: orgId }); - }); - - it("returns an error when ensurePersonalRepo fails", async () => { - vi.mocked(ensurePersonalRepo).mockResolvedValue(null); - - const result = await resolveSessionCloneUrl({ - bodyCloneUrl: undefined, - auth: baseAuth, - }); - - expect(result.ok).toBe(false); - expect(result.error).toBeDefined(); - }); -}); diff --git a/lib/sessions/__tests__/validateCreateSessionBody.test.ts b/lib/sessions/__tests__/validateCreateSessionBody.test.ts index e5e863b73..85491c44d 100644 --- a/lib/sessions/__tests__/validateCreateSessionBody.test.ts +++ b/lib/sessions/__tests__/validateCreateSessionBody.test.ts @@ -30,25 +30,13 @@ describe("validateCreateSessionBody", () => { expect(result).toBe(failure); }); - it("returns 400 when sandboxType is not 'vercel'", async () => { - vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - - const result = await validateCreateSessionBody(req({ sandboxType: "wrong" })); - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - const body = (await result.json()) as { status: string; error: string }; - expect(body.error).toBe("Invalid sandbox type"); - } - }); - it("returns body + auth on success", async () => { vi.mocked(validateAuthContext).mockResolvedValue(okAuth); - const result = await validateCreateSessionBody(req({ title: "Hello", sandboxType: "vercel" })); + const result = await validateCreateSessionBody(req({ title: "Hello" })); expect(result).not.toBeInstanceOf(NextResponse); if (!(result instanceof NextResponse)) { - expect(result.body).toEqual({ title: "Hello", sandboxType: "vercel" }); + expect(result.body).toEqual({ title: "Hello" }); expect(result.auth).toBe(okAuth); } }); @@ -62,4 +50,28 @@ describe("validateCreateSessionBody", () => { expect(result.body).toEqual({}); } }); + + it("rejects a non-UUID organizationId with 400", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + + const result = await validateCreateSessionBody(req({ organizationId: "not-a-uuid" })); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = (await result.json()) as { status: string; error: string }; + expect(body.error).toMatch(/UUID/i); + } + }); + + it("forwards organizationId to validateAuthContext for org-access validation", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + + const orgId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; + await validateCreateSessionBody(req({ organizationId: orgId })); + + expect(validateAuthContext).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ organizationId: orgId }), + ); + }); }); diff --git a/lib/sessions/buildSessionInsertRow.ts b/lib/sessions/buildSessionInsertRow.ts index de6dfbce3..57402034a 100644 --- a/lib/sessions/buildSessionInsertRow.ts +++ b/lib/sessions/buildSessionInsertRow.ts @@ -1,43 +1,37 @@ import type { TablesInsert } from "@/types/database.types"; -import type { CreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; import { generateUUID } from "@/lib/uuid/generateUUID"; interface BuildSessionInsertRowInput { - body: CreateSessionBody; accountId: string; title: string; /** - * Final clone URL resolved by `resolveSessionCloneUrl`. When `null`, - * the session row stores no `clone_url` — matches the prior - * `body.cloneUrl ?? null` behavior for callers that don't (yet) - * trigger personal-repo provisioning. + * Final clone URL resolved by the handler via `ensurePersonalRepo` — + * always set on session create now that the body no longer carries + * `cloneUrl`. */ - cloneUrl: string | null; + cloneUrl: string; } /** * Normalizes a validated `POST /api/sessions` body plus a resolved - * title into a `sessions` insert row. Centralizes the default / - * null-coalescing rules so the handler can stay focused on HTTP and - * persistence flow. + * title + ensured clone URL into a `sessions` insert row. Centralizes + * the default / null-coalescing rules so the handler can stay focused + * on HTTP and persistence flow. * - * Title resolution is intentionally not done here — that lives in - * `resolveSessionTitle` so this function stays synchronous and pure. - * - * @param input - The validated body, owning account id, and resolved title. + * @param input - The validated body, owning account id, resolved title, and resolved clone URL. * @returns A row ready to pass to `insertSession`. */ export function buildSessionInsertRow(input: BuildSessionInsertRowInput): TablesInsert<"sessions"> { - const { body, accountId, title, cloneUrl } = input; + const { accountId, title, cloneUrl } = input; return { id: generateUUID(), account_id: accountId, title, status: "running", - branch: body.branch ?? null, + branch: null, clone_url: cloneUrl, global_skill_refs: [], - sandbox_state: { type: body.sandboxType ?? "vercel" }, + sandbox_state: { type: "vercel" }, lifecycle_state: "provisioning", lifecycle_version: 0, }; diff --git a/lib/sessions/createSessionHandler.ts b/lib/sessions/createSessionHandler.ts index 0dc416634..87e81066b 100644 --- a/lib/sessions/createSessionHandler.ts +++ b/lib/sessions/createSessionHandler.ts @@ -3,7 +3,7 @@ import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; -import { resolveSessionCloneUrl } from "@/lib/sessions/resolveSessionCloneUrl"; +import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; import { failedToCreateSession } from "@/lib/sessions/failedToCreateSession"; import { insertSession } from "@/lib/supabase/sessions/insertSession"; @@ -18,10 +18,16 @@ const INITIAL_CHAT_TITLE = "New chat"; * Handles `POST /api/sessions`. * * Authenticates, validates the request, resolves a final session - * title (provided > random city fallback), then creates a session - * row and an initial chat row. If the chat insert fails after the - * session row is persisted, the session is rolled back so callers - * never observe an orphaned session. + * title (provided > random city fallback), ensures the workspace repo + * exists at `recoupable/`, then creates + * a session row and an initial chat row. If the chat insert fails + * after the session row is persisted, the session is rolled back so + * callers never observe an orphaned session. + * + * The clone URL is derived server-side — callers never construct + * GitHub URLs. Personal sessions (no `organizationId` in body) use + * `auth.accountId`; org sessions (with `organizationId`) use + * `auth.orgId` after `validateAuthContext` confirms org access. * * @param request - The incoming request. * @returns A NextResponse with `{ session, chat }` on 200, or an error. @@ -38,23 +44,20 @@ export async function createSessionHandler(request: NextRequest): Promise` workspace exists - * and use that URL. The provisioning is the same for personal - * sessions and org-bound sessions because organizations are - * themselves accounts (`auth.orgId` IS an account_id — see - * `account_organization_ids.organization` joining `accounts`). - * When an org is bound, the workspace is keyed on `auth.orgId`; - * otherwise on the user's own `auth.accountId`. - */ -export async function resolveSessionCloneUrl(params: { - bodyCloneUrl: string | undefined; - auth: AuthContext; -}): Promise { - const { bodyCloneUrl, auth } = params; - - if (bodyCloneUrl) { - return { ok: true, cloneUrl: bodyCloneUrl }; - } - - const workspaceAccountId = auth.orgId ?? auth.accountId; - const cloneUrl = await ensurePersonalRepo({ - accountId: workspaceAccountId, - }); - - if (!cloneUrl) { - return { - ok: false, - cloneUrl: null, - error: "Failed to provision workspace repository", - }; - } - - return { ok: true, cloneUrl }; -} diff --git a/lib/sessions/validateCreateSessionBody.ts b/lib/sessions/validateCreateSessionBody.ts index f794dfb07..d9aa07175 100644 --- a/lib/sessions/validateCreateSessionBody.ts +++ b/lib/sessions/validateCreateSessionBody.ts @@ -5,11 +5,22 @@ import { safeParseJson } from "@/lib/networking/safeParseJson"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import type { AuthContext } from "@/lib/auth/validateAuthContext"; +/** + * Body schema for `POST /api/sessions`. All fields optional — an empty + * body creates a personal session against the caller's own workspace + * repo (`recoupable/`); passing `organizationId` creates an + * org session against `recoupable/` (and validates org + * access via `validateAuthContext`). + * + * `cloneUrl` / `branch` / `sandboxType` were removed — the api derives + * the URL itself via `ensurePersonalRepo` and only `vercel` sandboxes + * are supported (hard-coded in `SandboxState`). + * `repoOwner`/`repoName`/`isNewBranch` were never accepted (Zod + * silently dropped them); cleaned up alongside in recoupable/docs#226. + */ export const createSessionBodySchema = z.object({ title: z.string().optional(), - branch: z.string().optional(), - cloneUrl: z.string().optional(), - sandboxType: z.literal("vercel", { error: "Invalid sandbox type" }).optional(), + organizationId: z.string().uuid("organizationId must be a valid UUID").optional(), }); export type CreateSessionBody = z.infer; @@ -21,9 +32,11 @@ export interface ValidatedCreateSessionRequest { /** * Validates a `POST /api/sessions` request end-to-end: - * 1. Authenticates the caller via Privy Bearer / x-api-key - * 2. Parses the JSON body (treating malformed JSON as an empty body) - * 3. Validates the body against the Zod schema + * 1. Parses the JSON body (treating malformed JSON as an empty body) + * 2. Validates the body against the Zod schema + * 3. Authenticates the caller via Privy Bearer / x-api-key, forwarding + * any body `organizationId` so `validateAuthContext` checks org + * access and sets `auth.orgId`. * * Returns either a 4xx NextResponse describing the first failure, or * the validated `{ body, auth }` ready for the handler to consume. @@ -34,11 +47,6 @@ export interface ValidatedCreateSessionRequest { export async function validateCreateSessionBody( request: NextRequest, ): Promise { - const auth = await validateAuthContext(request); - if (auth instanceof NextResponse) { - return auth; - } - const rawBody = await safeParseJson(request); const result = createSessionBodySchema.safeParse(rawBody); if (!result.success) { @@ -56,5 +64,12 @@ export async function validateCreateSessionBody( ); } + const auth = await validateAuthContext(request, { + organizationId: result.data.organizationId ?? null, + }); + if (auth instanceof NextResponse) { + return auth; + } + return { body: result.data, auth }; } From 88bc87007af0b96f422f1cec07525c66e61c0e21 Mon Sep 17 00:00:00 2001 From: ahmednahima0-beep Date: Thu, 28 May 2026 05:46:19 +0700 Subject: [PATCH 08/15] feat(sessions): enhance session patching with sandbox state management (#578) * feat(sessions): enhance session patching with sandbox state management - Added logic to handle archiving and unarchiving of sessions, ensuring proper sandbox state checks. - Introduced `stopSandboxOnArchive` function to manage sandbox teardown for archived sessions. - Updated `patchSessionByIdHandler` to include conditions for unarchiving sessions based on sandbox state. - Implemented CORS headers in response for error handling during sandbox operations. * test(sessions): enhance session route tests with sandbox management mocks - Added mocks for `next/server` and `stopSandboxOnArchive` to improve test isolation and control over sandbox state during session route tests. - Updated session test suite to utilize these mocks for better reliability and clarity in testing session behavior. * refactor(tests): simplify mock implementation for session route tests - Updated the mock for `next/server` in the session route tests to use a more concise syntax, improving readability and maintainability of the test code. * fix(sessions): improve error handling in stopSandboxOnArchive function - Added error logging for sandbox stop failures, providing clearer insights into issues during session archiving. - Updated error message for state clearing failures to enhance clarity in debugging. * fix(sessions): enhance error handling in stopSandboxOnArchive function - Added a return statement in the error handling block to prevent further execution after a sandbox stop failure, improving robustness during session archiving. * fix(sessions): refine unarchive condition for sandbox state management - Updated the unarchive condition in `patchSessionByIdHandler` to include a check for the sandbox's lifecycle state, ensuring that the sandbox is not in a hibernated state before allowing unarchiving. This enhances error handling during session management. * fix(sessions): further refine unarchive condition for sandbox state management - Enhanced the unarchive condition in `patchSessionByIdHandler` to ensure the sandbox's lifecycle state is not "archived" in addition to not being "hibernated". This improves the accuracy of session management during state transitions. * fix(sessions): add session status check before stopping sandbox on archive - Introduced a check in the `stopSandboxOnArchive` function to ensure the session's status is not "archived" before proceeding with sandbox state clearing. This enhances session management accuracy during archiving operations. * fix(sessions): optimize session state update in stopSandboxOnArchive - Refined the logic in the `stopSandboxOnArchive` function to check if the session is still archived before updating its lifecycle state. This change improves the accuracy of session state management during archiving operations. * Enhance session patching and sandbox state management - Updated the PATCH /api/sessions/[sessionId] handler to include additional lifecycle properties when archiving a session, ensuring proper state management. - Refined the hasRuntimeSandboxState function to improve checks for sandbox state validity, including handling of expiresAt and sandboxName. - Enhanced unit tests for hasRuntimeSandboxState to cover new scenarios, ensuring accurate state validation. - Improved the stopSandboxOnArchive function to handle errors more gracefully and ensure that sandbox state is cleared appropriately when archiving sessions. All changes are accompanied by passing tests, maintaining code integrity and functionality. * Refactor file path resolution in agent tools - Replaced `path` module usage with `resolveSandboxPath` and `joinSandboxPath` in multiple tools (bashTool, editFileTool, globTool, grepTool, readFileTool, skillTool, writeFileTool) to streamline path handling within the sandbox environment. - Updated `toDisplayPath` to utilize `resolveSandboxPath` and `isPathWithinSandboxDirectory` for improved path validation. - Enhanced `discoverSkills` and `findSkillFile` to use `joinSandboxPath` for directory handling, ensuring consistency across skill discovery processes. - Adjusted `recordCreditDeduction` to use `randomUUID` instead of `nanoid` for generating unique event IDs, aligning with modern practices. These changes improve code maintainability and ensure consistent path resolution across the application. * Refactor credit deduction and path handling - Replaced `randomUUID` with `nanoid` in `recordCreditDeduction` for generating unique event IDs, aligning with modern practices. - Enhanced `isPathWithinSandboxDirectory` to avoid false negatives when checking paths, ensuring accurate path validation. - Updated `discoverSkills` to normalize file paths by replacing backslashes with forward slashes, improving cross-platform compatibility. These changes improve code consistency and maintainability across the application. * Refactor sandbox path handling in agent tools - Updated imports in multiple tools (bashTool, editFileTool, globTool, grepTool, readFileTool, skillTool, writeFileTool) to use the new `resolveSandboxPath` and `joinSandboxPath` functions, enhancing path resolution consistency. - Removed the deprecated `sandboxPaths` module, consolidating path-related functions into dedicated files for better organization. - Introduced new utility functions: `dirnameSandboxPath`, `isPathWithinSandboxDirectory`, and `relativeSandboxPath` to streamline path operations and improve maintainability. These changes enhance the clarity and efficiency of path management within the sandbox environment. * Enhance path comparison in isPathWithinSandboxDirectory for Windows compatibility - Updated the isPathWithinSandboxDirectory function to convert both the resolved file path and directory to lowercase before comparison, ensuring accurate path validation on Windows systems, which are case-insensitive. - This change prevents false negatives when checking if a file path is within a specified sandbox directory. These modifications improve the reliability of path handling in the sandbox environment. * refactor(agent/tools): revert sandbox path helpers to native path module Per KISS: path resolution in agent tools is unrelated to PATCH /api/sessions/{sessionId}. Restored path.isAbsolute/path.resolve/path.join in all 8 tools, removing resolveSandboxPath, joinSandboxPath, and dirnameSandboxPath imports that were scope creep. * chore(pr-578): remove all scope creep PR now only modifies the 3 files directly tied to PATCH /api/sessions/{sessionId}: - lib/sessions/patchSessionByIdHandler.ts - lib/sessions/stopSandboxOnArchive.ts - app/api/sessions/[sessionId]/__tests__/route.test.ts Deleted: dirnameSandboxPath, isPathWithinSandboxDirectory, isPosixSandboxPath, joinSandboxPath, relativeSandboxPath, resolveSandboxPath, toPosixSegment. Reverted: hasRuntimeSandboxState.ts, discoverSkills.ts, findSkillFile.ts, and all sandbox test files to origin/test. * refactor(sessions): extract isSandboxPausing into lib/sandbox OCP fix: move isSandboxPausing predicate out of patchSessionByIdHandler into its own lib so the handler only calls lib functions, not inline logic. * fix(sessions): preserve snapshot as fallback until stop succeeds; clear stale lifecycle_error on archive - Move snapshot_url/snapshot_created_at null-out from synchronous PATCH update into stopSandboxOnArchive success branch, matching open-agents behavior. Snapshot now remains as a fallback if stop() fails. - Add lifecycle_error: null to synchronous archive update so stale errors from a prior failed run are cleared on re-archive (open-agents parity). * refactor(sessions): extract isUnarchiveConflict predicate into lib OCP: move the 409-guard predicate out of patchSessionByIdHandler and into lib/sessions/isUnarchiveConflict.ts. Handler now calls isUnarchiveConflict(row). * refactor(sessions): simplify lifecycle state updates in patchSessionByIdHandler Replaced inline lifecycle state updates with constants ARCHIVE_LIFECYCLE_PATCH and UNARCHIVE_LIFECYCLE_PATCH for better readability and maintainability. This change streamlines the handling of session lifecycle states during patch operations. * refactor(sessions): format imports for lifecycle state patches in patchSessionByIdHandler Updated the import statements for ARCHIVE_LIFECYCLE_PATCH and UNARCHIVE_LIFECYCLE_PATCH to improve readability and maintain consistency in the code structure. This change enhances the clarity of the lifecycle state management within the session patching process. --- .../[sessionId]/__tests__/route.test.ts | 14 ++++ lib/sandbox/isSandboxPausing.ts | 21 ++++++ lib/sessions/isUnarchiveConflict.ts | 16 +++++ lib/sessions/lifecycleStatePatches.ts | 21 ++++++ lib/sessions/patchSessionByIdHandler.ts | 23 +++++++ lib/sessions/stopSandboxOnArchive.ts | 68 +++++++++++++++++++ 6 files changed, 163 insertions(+) create mode 100644 lib/sandbox/isSandboxPausing.ts create mode 100644 lib/sessions/isUnarchiveConflict.ts create mode 100644 lib/sessions/lifecycleStatePatches.ts create mode 100644 lib/sessions/stopSandboxOnArchive.ts diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 87bbe4bcd..17afe983e 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -5,6 +5,15 @@ import type { Tables } from "@/types/database.types"; type SessionRow = Tables<"sessions">; +vi.mock("next/server", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, after: vi.fn() }; +}); + +vi.mock("@/lib/sessions/stopSandboxOnArchive", () => ({ + stopSandboxOnArchive: vi.fn(), +})); + vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ selectSessions: vi.fn(), })); @@ -352,6 +361,11 @@ describe("PATCH /api/sessions/[sessionId]", () => { expect(updateSession).toHaveBeenCalledWith("sess_1", { title: "Renamed session", status: "archived", + lifecycle_state: "archived", + lifecycle_error: null, + lifecycle_run_id: null, + sandbox_expires_at: null, + hibernate_after: null, }); }); diff --git a/lib/sandbox/isSandboxPausing.ts b/lib/sandbox/isSandboxPausing.ts new file mode 100644 index 000000000..46eb195ff --- /dev/null +++ b/lib/sandbox/isSandboxPausing.ts @@ -0,0 +1,21 @@ +import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; + +/** + * Returns true when a sandbox is actively being paused — i.e. it has + * live runtime state but has not yet reached a terminal lifecycle state + * (`hibernated` or `archived`). + * + * Used by `PATCH /api/sessions/{sessionId}` to guard unarchive requests: + * if the sandbox is still pausing the request returns 409 so the caller + * can retry once the sandbox has settled. + */ +export function isSandboxPausing(row: { + sandbox_state: unknown; + lifecycle_state: string | null; +}): boolean { + return ( + hasRuntimeSandboxState(row.sandbox_state) && + row.lifecycle_state !== "hibernated" && + row.lifecycle_state !== "archived" + ); +} diff --git a/lib/sessions/isUnarchiveConflict.ts b/lib/sessions/isUnarchiveConflict.ts new file mode 100644 index 000000000..78a8b4513 --- /dev/null +++ b/lib/sessions/isUnarchiveConflict.ts @@ -0,0 +1,16 @@ +import { isSandboxPausing } from "@/lib/sandbox/isSandboxPausing"; + +/** + * Returns true when an unarchive request should be rejected with 409: + * the sandbox has no snapshot to restore from and is still actively pausing. + * + * Without a snapshot, the sandbox cannot be unarchived until the pause + * completes; callers should retry after the sandbox settles. + */ +export function isUnarchiveConflict(row: { + sandbox_state: unknown; + lifecycle_state: string | null; + snapshot_url: string | null; +}): boolean { + return !row.snapshot_url && isSandboxPausing(row); +} diff --git a/lib/sessions/lifecycleStatePatches.ts b/lib/sessions/lifecycleStatePatches.ts new file mode 100644 index 000000000..9388e849b --- /dev/null +++ b/lib/sessions/lifecycleStatePatches.ts @@ -0,0 +1,21 @@ +/** + * Lifecycle fields applied synchronously when a session is archived via + * `PATCH /api/sessions/{sessionId}`. Sandbox stop and snapshot clearing + * run afterward via `stopSandboxOnArchive`. + */ +export const ARCHIVE_LIFECYCLE_PATCH = { + lifecycle_state: "archived", + lifecycle_error: null, + lifecycle_run_id: null, + sandbox_expires_at: null, + hibernate_after: null, +} as const; + +/** + * Lifecycle fields applied synchronously when a session is unarchived via + * `PATCH /api/sessions/{sessionId}`. + */ +export const UNARCHIVE_LIFECYCLE_PATCH = { + lifecycle_state: null, + lifecycle_error: null, +} as const; diff --git a/lib/sessions/patchSessionByIdHandler.ts b/lib/sessions/patchSessionByIdHandler.ts index c93913667..16010c908 100644 --- a/lib/sessions/patchSessionByIdHandler.ts +++ b/lib/sessions/patchSessionByIdHandler.ts @@ -1,6 +1,13 @@ +import { after } from "next/server"; import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { isUnarchiveConflict } from "@/lib/sessions/isUnarchiveConflict"; +import { + ARCHIVE_LIFECYCLE_PATCH, + UNARCHIVE_LIFECYCLE_PATCH, +} from "@/lib/sessions/lifecycleStatePatches"; import { validatePatchSessionBody } from "@/lib/sessions/validatePatchSessionBody"; +import { stopSandboxOnArchive } from "@/lib/sessions/stopSandboxOnArchive"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; import { updateSession } from "@/lib/supabase/sessions/updateSession"; import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; @@ -56,11 +63,23 @@ export async function patchSessionByIdHandler( ); } + const shouldArchive = body.status === "archived" && row.status !== "archived"; + const shouldUnarchive = body.status === "running" && row.status === "archived"; + + if (shouldUnarchive && isUnarchiveConflict(row)) { + return NextResponse.json( + { status: "error", error: "Sandbox is still being paused, try again in a few seconds." }, + { status: 409, headers: getCorsHeaders() }, + ); + } + const updates = { ...(body.title !== undefined && { title: body.title }), ...(body.status !== undefined && { status: body.status }), ...(body.linesAdded !== undefined && { lines_added: body.linesAdded }), ...(body.linesRemoved !== undefined && { lines_removed: body.linesRemoved }), + ...(shouldArchive && ARCHIVE_LIFECYCLE_PATCH), + ...(shouldUnarchive && UNARCHIVE_LIFECYCLE_PATCH), }; if (Object.keys(updates).length === 0) { @@ -79,6 +98,10 @@ export async function patchSessionByIdHandler( ); } + if (shouldArchive) { + after(() => stopSandboxOnArchive(row)); + } + return NextResponse.json( { session: toSessionResponse(updated) }, { status: 200, headers: getCorsHeaders() }, diff --git a/lib/sessions/stopSandboxOnArchive.ts b/lib/sessions/stopSandboxOnArchive.ts new file mode 100644 index 000000000..6559aa892 --- /dev/null +++ b/lib/sessions/stopSandboxOnArchive.ts @@ -0,0 +1,68 @@ +import { connectSandbox, type SandboxState } from "@/lib/sandbox/factory"; +import { clearSandboxState } from "@/lib/sandbox/clearSandboxState"; +import { hasRuntimeSandboxState } from "@/lib/sandbox/hasRuntimeSandboxState"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; +import type { Tables } from "@/types/database.types"; +import type { Json } from "@/types/database.types"; + +/** + * Fire-and-forget sandbox teardown for newly-archived sessions. + * + * Stops the running sandbox then clears its runtime state so the + * auto-hibernate workflow ignores the row. When the stop fails, persists + * a `lifecycle_error` and — if the row has no snapshot to fall back to — + * clears the runtime sandbox state so future unarchive attempts are not + * blocked forever by the 409 guard. + * + * Must be scheduled via `after()` so the HTTP response is not blocked. + * No-ops immediately when the session has no runtime sandbox. + * + * @param session - The session row as it existed before archiving. + */ +export async function stopSandboxOnArchive(session: Tables<"sessions">): Promise { + if (!hasRuntimeSandboxState(session.sandbox_state)) return; + + let stopError: unknown; + + try { + const sandbox = await connectSandbox(session.sandbox_state as unknown as SandboxState); + await sandbox.stop(); + } catch (error) { + stopError = error; + console.error(`[stopSandboxOnArchive] stop failed for session ${session.id}:`, error); + } + + try { + const rows = await selectSessions({ id: session.id }); + const current = rows?.[0] ?? null; + + if (!current || current.status !== "archived") return; + + if (stopError !== undefined) { + const message = stopError instanceof Error ? stopError.message : String(stopError); + const shouldClearState = + !current.snapshot_url && hasRuntimeSandboxState(current.sandbox_state); + + await updateSession(session.id, { + lifecycle_error: `Archive finalization failed: ${message}`, + lifecycle_state: "archived", + lifecycle_run_id: null, + sandbox_expires_at: null, + hibernate_after: null, + ...(shouldClearState && { + sandbox_state: clearSandboxState(current.sandbox_state) as unknown as Json, + }), + }); + return; + } + + await updateSession(session.id, { + snapshot_url: null, + snapshot_created_at: null, + sandbox_state: clearSandboxState(session.sandbox_state) as unknown as Json, + }); + } catch (error) { + console.error(`[stopSandboxOnArchive] state update failed for session ${session.id}:`, error); + } +} From ed3d42534481a1c8cf25e856deb3b4631d347fec Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Thu, 28 May 2026 19:52:38 -0500 Subject: [PATCH 09/15] =?UTF-8?q?feat(backfill):=20Phase=202=20rooms?= =?UTF-8?q?=E2=86=92sessions/chats/chat=5Fmessages=20script=20(#623)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(backfill): Phase 2 rooms→sessions/chats/chat_messages script Ports the chat-side Phase 2 backfill (recoupable/chat#1750) to api, where the sessions/chats/chat_messages model lives — so it reuses existing typed helpers instead of recreating them and needs no type regeneration. - scripts/backfillRoomsToSessions.ts + scripts/backfill/migrateRoom.ts: orchestration only, no raw supabase. Reuses insertSession / insertChat / upsertChatMessage, guarded by selectSessions / selectChats for idempotent re-runs; deterministic uuidv5 session ids; preserves room.id as chat.id. - lib/supabase/rooms/selectAllRooms.ts: paginated all-rooms read (the existing selectRooms caps at 1k); reuses selectMemories per room. - Adds uuid dep + `backfill:rooms-to-sessions` script. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(backfill): add dry-run mode + paginated memories read - Dry run (`DRY_RUN=1` or `--dry-run`): runs all reads + existence checks but skips every insert/upsert, then prints a preview — rooms would-migrate vs skipped, sessions/chats new vs already-exist, messages to write, malformed (null-parts) messages that can't migrate, and rooms ≥1000 memories. Surfaces the two failure modes before touching prod. - Paginated selectMemoriesByRoomId replaces the 1k-capped selectMemories so rooms with >1,000 messages aren't truncated (parity with the original chat script's pagination). - Malformed memories (null/missing content.parts) are now detected and skipped+counted rather than aborting the room. - Tests: selectMemoriesByRoomId pagination + migrateRoom dry-run (asserts no writes), real-run writes, malformed-skip, and idempotent re-run. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(backfill): add --limit and --room flags for sampled/targeted runs Lets the dry run exercise the script's real code path on a small sample (--limit=N) or a single room (--room=) without a full ~70k-read pass. --room also serves to re-migrate one room. Validated against real data: pagination (3,671-memory room), malformed-skip (all-malformed room), and the happy path all match DB ground truth. Co-Authored-By: Claude Opus 4.7 (1M context) * perf(backfill): concurrent room pool + batched message writes Two changes to cut the full run from ~8-9h to ~30-60 min: - Process rooms in concurrent batches (--concurrency=N, default 20) via Promise.allSettled. Safe because distinct rooms never share row ids, so there's no contention and idempotency holds; failures are isolated per room. - Batch chat_messages writes: new upsertChatMessages does one round-trip per room (write-once, per-row retry fallback) instead of one upsert per message — rescues message-heavy rooms (e.g. the 3,671-message room: 3,671 round-trips → ~1). Tests: upsertChatMessages (batch + fallback + throw), migrateRoom updated to the batch helper. 14 backfill tests green; tsc clean. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(backfill): lint — imports-first in tests, drop unused eslint-disables - migrateRoom.test.ts: move imports above the vi.mock calls (import/first; vitest hoists vi.mock regardless of position). - Remove unused @typescript-eslint/no-explicit-any disable directives (the test `any` casts don't trip the rule in this config). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(backfill): address KISS/DRY review — separate pagination from supabase libs - KISS: move the read-all-pages for-loop out of lib/supabase. New scripts/backfill/paginate.ts (generic) + scripts/backfill/selectAllRooms.ts compose it with the thin single-query selectRooms. Delete lib/supabase/rooms/selectAllRooms.ts. - KISS: drop lib/supabase/memories/selectMemoriesByRoomId.ts; reuse the existing selectMemories with an additive optional `range` page param. - DRY: upsertChatMessages per-row fallback now reuses the single-row upsertChatMessage helper instead of re-defining the upsert query. - selectRooms/selectMemories extended additively (optional `range`), so existing live-API callers are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(backfill): upsertChatMessages defines no query — pure delegation Drop the direct batch upsert; loop the single-row upsertChatMessage helper so this file holds zero supabase queries. Used only by the one-time backfill, so the lost batch round-trip doesn't affect any live path. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../__tests__/upsertChatMessages.test.ts | 59 ++++++++ .../chat_messages/upsertChatMessages.ts | 29 ++++ lib/supabase/memories/selectMemories.ts | 9 ++ lib/supabase/rooms/selectRooms.ts | 10 +- package.json | 3 + pnpm-lock.yaml | 20 +++ .../backfill/__tests__/migrateRoom.test.ts | 110 ++++++++++++++ scripts/backfill/__tests__/paginate.test.ts | 38 +++++ .../backfill/__tests__/selectAllRooms.test.ts | 43 ++++++ scripts/backfill/migrateRoom.ts | 139 ++++++++++++++++++ scripts/backfill/paginate.ts | 19 +++ scripts/backfill/selectAllRooms.ts | 12 ++ scripts/backfillRoomsToSessions.ts | 112 ++++++++++++++ 13 files changed, 602 insertions(+), 1 deletion(-) create mode 100644 lib/supabase/chat_messages/__tests__/upsertChatMessages.test.ts create mode 100644 lib/supabase/chat_messages/upsertChatMessages.ts create mode 100644 scripts/backfill/__tests__/migrateRoom.test.ts create mode 100644 scripts/backfill/__tests__/paginate.test.ts create mode 100644 scripts/backfill/__tests__/selectAllRooms.test.ts create mode 100644 scripts/backfill/migrateRoom.ts create mode 100644 scripts/backfill/paginate.ts create mode 100644 scripts/backfill/selectAllRooms.ts create mode 100644 scripts/backfillRoomsToSessions.ts diff --git a/lib/supabase/chat_messages/__tests__/upsertChatMessages.test.ts b/lib/supabase/chat_messages/__tests__/upsertChatMessages.test.ts new file mode 100644 index 000000000..8c995b26a --- /dev/null +++ b/lib/supabase/chat_messages/__tests__/upsertChatMessages.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { upsertChatMessages } from "@/lib/supabase/chat_messages/upsertChatMessages"; +import { upsertChatMessage } from "@/lib/supabase/chat_messages/upsertChatMessage"; + +vi.mock("@/lib/supabase/chat_messages/upsertChatMessage", () => ({ + upsertChatMessage: vi.fn(), +})); + +const rows: any[] = [ + { id: "m1", chat_id: "c1", role: "user", parts: [], created_at: "t" }, + { id: "m2", chat_id: "c1", role: "assistant", parts: [], created_at: "t" }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("upsertChatMessages", () => { + it("returns 0 without calling the helper for an empty batch", async () => { + const n = await upsertChatMessages([]); + expect(n).toBe(0); + expect(upsertChatMessage).not.toHaveBeenCalled(); + }); + + it("delegates each row write-once to the single-row helper", async () => { + vi.mocked(upsertChatMessage).mockResolvedValue({ + ok: true, + row: null, + isDuplicate: false, + } as any); + + const n = await upsertChatMessages(rows); + + expect(n).toBe(2); + expect(upsertChatMessage).toHaveBeenCalledTimes(2); + expect(upsertChatMessage).toHaveBeenCalledWith(rows[0], { update: false }); + }); + + it("counts duplicates (write-once skips) as success", async () => { + vi.mocked(upsertChatMessage).mockResolvedValue({ + ok: true, + row: null, + isDuplicate: true, + } as any); + + const n = await upsertChatMessages(rows); + + expect(n).toBe(2); + }); + + it("logs and keeps going on a row failure, then throws at the end", async () => { + vi.mocked(upsertChatMessage) + .mockResolvedValueOnce({ ok: true, row: null, isDuplicate: false } as any) + .mockResolvedValueOnce({ ok: false, error: "row boom" } as any); + + await expect(upsertChatMessages(rows)).rejects.toThrow("Failed to upsert 1 of 2 chat_messages"); + expect(upsertChatMessage).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lib/supabase/chat_messages/upsertChatMessages.ts b/lib/supabase/chat_messages/upsertChatMessages.ts new file mode 100644 index 000000000..9004fd022 --- /dev/null +++ b/lib/supabase/chat_messages/upsertChatMessages.ts @@ -0,0 +1,29 @@ +import type { TablesInsert } from "@/types/database.types"; +import { upsertChatMessage } from "./upsertChatMessage"; + +/** + * Upsert many chat messages by delegating each row to the single-row + * `upsertChatMessage` helper — no query is defined here. Write-once + * (`update: false` → DO NOTHING on conflict), so re-runs never overwrite + * already-migrated messages. A single bad row doesn't block the rest; + * throws if any row ultimately fails. Returns the number of rows attempted + * (duplicates count as success). + * + * Used by the Phase 2 backfill. + */ +export async function upsertChatMessages(rows: TablesInsert<"chat_messages">[]): Promise { + let succeeded = 0; + for (const row of rows) { + const result = await upsertChatMessage(row, { update: false }); + if ("error" in result) { + console.error(` ❌ Skipping message ${row.id}:`, result.error); + } else { + succeeded++; + } + } + + if (succeeded !== rows.length) { + throw new Error(`Failed to upsert ${rows.length - succeeded} of ${rows.length} chat_messages`); + } + return succeeded; +} diff --git a/lib/supabase/memories/selectMemories.ts b/lib/supabase/memories/selectMemories.ts index f1168162f..ed9ffc92d 100644 --- a/lib/supabase/memories/selectMemories.ts +++ b/lib/supabase/memories/selectMemories.ts @@ -17,11 +17,16 @@ export default async function selectMemories( ascending?: boolean; limit?: number; memoryId?: string; + /** Zero-based inclusive row range for a single page (PostgREST `.range`). + * When set, also adds `id` as a stable secondary sort so paginated reads + * don't skip/duplicate rows at page boundaries. */ + range?: { from: number; to: number }; }, ): Promise[] | null> { const ascending = options?.ascending ?? false; const limit = options?.limit; const memoryId = options?.memoryId; + const range = options?.range; let query = supabase .from("memories") @@ -37,6 +42,10 @@ export default async function selectMemories( query = query.limit(limit); } + if (range) { + query = query.order("id", { ascending }).range(range.from, range.to); + } + const { data, error } = await query; if (error) { diff --git a/lib/supabase/rooms/selectRooms.ts b/lib/supabase/rooms/selectRooms.ts index 33e1debef..b6e0b389e 100644 --- a/lib/supabase/rooms/selectRooms.ts +++ b/lib/supabase/rooms/selectRooms.ts @@ -8,6 +8,10 @@ export interface SelectRoomsParams { account_ids?: string[]; /** Filter by artist ID */ artist_id?: string; + /** Zero-based inclusive row range for a single page (PostgREST `.range`). + * When set, also adds `id` as a stable secondary sort so paginated reads + * don't skip/duplicate rows at page boundaries. */ + range?: { from: number; to: number }; } /** @@ -21,7 +25,7 @@ export interface SelectRoomsParams { * @returns Array of rooms or null if error */ export async function selectRooms(params: SelectRoomsParams = {}): Promise { - const { account_ids, artist_id } = params; + const { account_ids, artist_id, range } = params; // If account_ids is an empty array, return empty (no accounts to look up) if (account_ids !== undefined && account_ids.length === 0) { @@ -41,6 +45,10 @@ export async function selectRooms(params: SelectRoomsParams = {}): Promise types/database.types.ts", + "backfill:rooms-to-sessions": "npx tsx scripts/backfillRoomsToSessions.ts", "eval": "braintrust eval --external-packages playwright playwright-core chromium-bidi @browserbasehq/stagehand @composio/core @composio/vercel" }, "dependencies": { @@ -57,6 +58,7 @@ "resend": "^6.6.0", "sharp": "^0.34.5", "stripe": "^17.4.0", + "uuid": "^14.0.0", "viem": "^2.21.26", "workflow": "^4.2.4", "x402-fetch": "^0.7.3", @@ -70,6 +72,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^11.0.0", "@typescript-eslint/eslint-plugin": "^8.29.1", "@typescript-eslint/parser": "^8.29.1", "eslint": "^9.24.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b4a331ad..e953e3bcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: stripe: specifier: ^17.4.0 version: 17.7.0 + uuid: + specifier: ^14.0.0 + version: 14.0.0 viem: specifier: ^2.21.26 version: 2.40.3(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) @@ -162,6 +165,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.7) + '@types/uuid': + specifier: ^11.0.0 + version: 11.0.0 '@typescript-eslint/eslint-plugin': specifier: ^8.29.1 version: 8.48.0(@typescript-eslint/parser@8.48.0(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) @@ -3018,6 +3024,10 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@11.0.0': + resolution: {integrity: sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==} + deprecated: This is a stub types definition. uuid provides its own type definitions, so you do not need this installed. + '@types/uuid@8.3.4': resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} @@ -7667,6 +7677,10 @@ packages: resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} hasBin: true + uuid@14.0.0: + resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==} + hasBin: true + uuid@3.4.0: resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -11709,6 +11723,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/uuid@11.0.0': + dependencies: + uuid: 14.0.0 + '@types/uuid@8.3.4': {} '@types/ws@7.4.7': @@ -17808,6 +17826,8 @@ snapshots: uuid@13.0.0: {} + uuid@14.0.0: {} + uuid@3.4.0: {} uuid@8.3.2: {} diff --git a/scripts/backfill/__tests__/migrateRoom.test.ts b/scripts/backfill/__tests__/migrateRoom.test.ts new file mode 100644 index 000000000..7ee86fdb8 --- /dev/null +++ b/scripts/backfill/__tests__/migrateRoom.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { migrateRoom } from "@/scripts/backfill/migrateRoom"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { selectChats } from "@/lib/supabase/chats/selectChats"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; +import { upsertChatMessages } from "@/lib/supabase/chat_messages/upsertChatMessages"; + +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ selectSessions: vi.fn() })); +vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() })); +vi.mock("@/lib/supabase/chats/selectChats", () => ({ selectChats: vi.fn() })); +vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); +vi.mock("@/lib/supabase/memories/selectMemories", () => ({ default: vi.fn() })); +vi.mock("@/lib/supabase/chat_messages/upsertChatMessages", () => ({ + upsertChatMessages: vi.fn(), +})); + +const room: any = { + id: "room-1", + account_id: "acc-1", + artist_id: null, + topic: "Hello", + updated_at: "2026-01-01T00:00:00Z", +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(selectSessions).mockResolvedValue([]); + vi.mocked(selectChats).mockResolvedValue([]); + vi.mocked(selectMemories).mockResolvedValue([ + { + id: "m1", + room_id: "room-1", + content: { role: "user", parts: [{ type: "text", text: "hi" }] }, + updated_at: "2026-01-01T00:00:00Z", + } as any, + { + id: "m2", + room_id: "room-1", + content: { role: "assistant", parts: null }, // malformed — null parts + updated_at: "2026-01-01T00:00:01Z", + } as any, + ]); + + vi.mocked(insertSession).mockResolvedValue({ id: "s1" } as any); + + vi.mocked(insertChat).mockResolvedValue({ id: "room-1" } as any); + vi.mocked(upsertChatMessages).mockResolvedValue(1); +}); + +describe("migrateRoom", () => { + it("performs NO writes in dry-run mode but still reads + reports stats", async () => { + const stats = await migrateRoom(room, { dryRun: true }); + + expect(insertSession).not.toHaveBeenCalled(); + expect(insertChat).not.toHaveBeenCalled(); + expect(upsertChatMessages).not.toHaveBeenCalled(); + + expect(selectSessions).toHaveBeenCalled(); + expect(selectChats).toHaveBeenCalled(); + expect(selectMemories).toHaveBeenCalledWith( + "room-1", + expect.objectContaining({ range: { from: 0, to: 999 } }), + ); + + expect(stats).toMatchObject({ + status: "migrated", + messagesWritten: 1, // only the well-formed memory + messagesMalformed: 1, // the null-parts memory + memoryCount: 2, + }); + }); + + it("writes session, chat, and only well-formed messages in a real run", async () => { + const stats = await migrateRoom(room, { dryRun: false }); + + expect(insertSession).toHaveBeenCalledTimes(1); + expect(insertChat).toHaveBeenCalledTimes(1); + // one batch call containing only the well-formed message + expect(upsertChatMessages).toHaveBeenCalledTimes(1); + expect(upsertChatMessages).toHaveBeenCalledWith([ + expect.objectContaining({ id: "m1", chat_id: "room-1", role: "user" }), + ]); + expect(stats.messagesWritten).toBe(1); + expect(stats.messagesMalformed).toBe(1); + }); + + it("skips rooms with no account_id", async () => { + const stats = await migrateRoom({ ...room, account_id: null }, { dryRun: false }); + + expect(stats.status).toBe("skipped"); + expect(insertSession).not.toHaveBeenCalled(); + expect(insertChat).not.toHaveBeenCalled(); + }); + + it("skips session/chat inserts when they already exist (idempotent re-run)", async () => { + vi.mocked(selectSessions).mockResolvedValue([{ id: "s1" } as any]); + + vi.mocked(selectChats).mockResolvedValue([{ id: "room-1" } as any]); + + const stats = await migrateRoom(room, { dryRun: false }); + + expect(insertSession).not.toHaveBeenCalled(); + expect(insertChat).not.toHaveBeenCalled(); + expect(stats.sessionExisted).toBe(true); + expect(stats.chatExisted).toBe(true); + }); +}); diff --git a/scripts/backfill/__tests__/paginate.test.ts b/scripts/backfill/__tests__/paginate.test.ts new file mode 100644 index 000000000..691a31ec5 --- /dev/null +++ b/scripts/backfill/__tests__/paginate.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { paginate } from "@/scripts/backfill/paginate"; + +describe("paginate", () => { + it("stops after a single page when it is not full (< PAGE_SIZE)", async () => { + const fetchPage = vi.fn().mockResolvedValueOnce([1, 2, 3]); + + const rows = await paginate(fetchPage); + + expect(rows).toEqual([1, 2, 3]); + expect(fetchPage).toHaveBeenCalledTimes(1); + expect(fetchPage).toHaveBeenCalledWith(0, 999); + }); + + it("keeps paging while pages are full, advancing the range each time", async () => { + const full = Array.from({ length: 1000 }, (_, i) => i); + const fetchPage = vi + .fn() + .mockResolvedValueOnce(full) // page 1 — full, keep going + .mockResolvedValueOnce([1000, 1001]); // page 2 — short, stop + + const rows = await paginate(fetchPage); + + expect(rows).toHaveLength(1002); + expect(fetchPage).toHaveBeenCalledTimes(2); + expect(fetchPage).toHaveBeenNthCalledWith(1, 0, 999); + expect(fetchPage).toHaveBeenNthCalledWith(2, 1000, 1999); + }); + + it("returns an empty array and queries once when the first page is empty", async () => { + const fetchPage = vi.fn().mockResolvedValueOnce([]); + + const rows = await paginate(fetchPage); + + expect(rows).toEqual([]); + expect(fetchPage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/scripts/backfill/__tests__/selectAllRooms.test.ts b/scripts/backfill/__tests__/selectAllRooms.test.ts new file mode 100644 index 000000000..ed999e29b --- /dev/null +++ b/scripts/backfill/__tests__/selectAllRooms.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectAllRooms } from "@/scripts/backfill/selectAllRooms"; +import { selectRooms } from "@/lib/supabase/rooms/selectRooms"; + +vi.mock("@/lib/supabase/rooms/selectRooms", () => ({ selectRooms: vi.fn() })); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("selectAllRooms", () => { + it("returns a single short page without a second query", async () => { + vi.mocked(selectRooms).mockResolvedValueOnce([{ id: "r1" }, { id: "r2" }] as any); + + const rooms = await selectAllRooms(); + + expect(rooms).toHaveLength(2); + expect(selectRooms).toHaveBeenCalledTimes(1); + expect(selectRooms).toHaveBeenCalledWith({ range: { from: 0, to: 999 } }); + }); + + it("pages through the PostgREST 1,000-row cap until a short page", async () => { + const fullPage = Array.from({ length: 1000 }, (_, i) => ({ id: `r${i}` })); + vi.mocked(selectRooms) + .mockResolvedValueOnce(fullPage as any) + .mockResolvedValueOnce([{ id: "r1000" }] as any); + + const rooms = await selectAllRooms(); + + expect(rooms).toHaveLength(1001); + expect(selectRooms).toHaveBeenCalledTimes(2); + expect(selectRooms).toHaveBeenNthCalledWith(2, { range: { from: 1000, to: 1999 } }); + }); + + it("treats a null result as an empty page", async () => { + vi.mocked(selectRooms).mockResolvedValueOnce(null as any); + + const rooms = await selectAllRooms(); + + expect(rooms).toEqual([]); + expect(selectRooms).toHaveBeenCalledTimes(1); + }); +}); diff --git a/scripts/backfill/migrateRoom.ts b/scripts/backfill/migrateRoom.ts new file mode 100644 index 000000000..c95209ec2 --- /dev/null +++ b/scripts/backfill/migrateRoom.ts @@ -0,0 +1,139 @@ +import { v5 as uuidv5 } from "uuid"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { selectChats } from "@/lib/supabase/chats/selectChats"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; +import selectMemories from "@/lib/supabase/memories/selectMemories"; +import { upsertChatMessages } from "@/lib/supabase/chat_messages/upsertChatMessages"; +import type { Json, Tables, TablesInsert } from "@/types/database.types"; +import { paginate } from "./paginate"; + +// Fixed namespace so uuidv5(room.id) yields the same sessionId every run. +// A re-run after partial failure then finds the existing session via the +// guard below instead of minting an orphan. +const SESSION_NAMESPACE = "f47ac10b-58cc-4372-a567-0e02b2c3d479"; + +/** Rooms at/above this many memories are flagged in the run summary. */ +export const OVERSIZED_THRESHOLD = 1000; + +export interface RoomStats { + /** "skipped" = no account_id; "migrated" = processed (real or dry). */ + status: "migrated" | "skipped"; + /** Session/chat already existed → insert was a no-op (idempotent re-run). */ + sessionExisted: boolean; + chatExisted: boolean; + /** Messages written (real run) or that would be written (dry run). */ + messagesWritten: number; + /** Memories whose content lacks a usable { role, parts } shape. */ + messagesMalformed: number; + /** Total memories read for the room. */ + memoryCount: number; +} + +interface MigrateOptions { + dryRun?: boolean; +} + +/** Legacy memories store content as { role, parts, content }. */ +function isWellFormedContent(content: unknown): content is { role: string; parts: Json } { + if (!content || typeof content !== "object") return false; + const candidate = content as { role?: unknown; parts?: unknown }; + return typeof candidate.role === "string" && candidate.parts != null; +} + +async function migrateMessages( + roomId: string, + dryRun: boolean, +): Promise<{ written: number; malformed: number; memoryCount: number }> { + const memories = await paginate((from, to) => + selectMemories(roomId, { ascending: true, range: { from, to } }).then(m => m ?? []), + ); + const rows: TablesInsert<"chat_messages">[] = []; + let malformed = 0; + + for (const memory of memories) { + const content = memory.content; + if (!isWellFormedContent(content)) { + malformed++; + continue; + } + rows.push({ + id: memory.id, + chat_id: roomId, + role: content.role, + parts: content.parts, + created_at: memory.updated_at, + }); + } + + // Write-once per message, so re-runs never overwrite already-migrated rows. + const written = dryRun ? rows.length : await upsertChatMessages(rows); + + return { written, malformed, memoryCount: memories.length }; +} + +export async function migrateRoom( + room: Tables<"rooms">, + { dryRun = false }: MigrateOptions = {}, +): Promise { + if (!room.account_id) { + console.warn(`⚠️ Skipping room ${room.id} — no account_id`); + return { + status: "skipped", + sessionExisted: false, + chatExisted: false, + messagesWritten: 0, + messagesMalformed: 0, + memoryCount: 0, + }; + } + + const sessionId = uuidv5(room.id, SESSION_NAMESPACE); + const title = room.topic ?? "Untitled"; + + // insertSession / insertChat are plain inserts; guard with a select so + // re-runs are idempotent and don't error on the existing primary key. + const existingSession = await selectSessions({ id: sessionId }); + const sessionExisted = Boolean(existingSession?.length); + if (!sessionExisted && !dryRun) { + const session = await insertSession({ + id: sessionId, + account_id: room.account_id, + title, + created_at: room.updated_at, + updated_at: room.updated_at, + }); + if (!session) throw new Error(`Failed to insert session for room ${room.id}`); + } + + // Preserve room.id as chat.id so /chat/[roomId] URLs keep working. + const existingChat = await selectChats({ id: room.id }); + const chatExisted = existingChat.length > 0; + if (!chatExisted && !dryRun) { + const chat = await insertChat({ + id: room.id, + session_id: sessionId, + title, + created_at: room.updated_at, + updated_at: room.updated_at, + }); + if (!chat) throw new Error(`Failed to insert chat for room ${room.id}`); + } + + const { written, malformed, memoryCount } = await migrateMessages(room.id, dryRun); + + const verb = dryRun ? "Would migrate" : "Migrated"; + const malformedNote = malformed ? `, ${malformed} malformed skipped` : ""; + console.log( + `✅ ${verb} room ${room.id} → session ${sessionId} (${written} messages${malformedNote})`, + ); + + return { + status: "migrated", + sessionExisted, + chatExisted, + messagesWritten: written, + messagesMalformed: malformed, + memoryCount, + }; +} diff --git a/scripts/backfill/paginate.ts b/scripts/backfill/paginate.ts new file mode 100644 index 000000000..5da32159b --- /dev/null +++ b/scripts/backfill/paginate.ts @@ -0,0 +1,19 @@ +const PAGE_SIZE = 1000; + +/** + * Collects every row from a paged query by calling `fetchPage(from, to)` until + * it returns a short page (< PAGE_SIZE). Keeps the pagination loop OUT of the + * thin `lib/supabase` query helpers — they stay single-query; this composes + * them. Used by the Phase 2 backfill to read past the PostgREST 1,000-row cap. + */ +export async function paginate( + fetchPage: (from: number, to: number) => Promise, +): Promise { + const rows: T[] = []; + for (let from = 0; ; from += PAGE_SIZE) { + const page = await fetchPage(from, from + PAGE_SIZE - 1); + rows.push(...page); + if (page.length < PAGE_SIZE) break; + } + return rows; +} diff --git a/scripts/backfill/selectAllRooms.ts b/scripts/backfill/selectAllRooms.ts new file mode 100644 index 000000000..0e8a74e7e --- /dev/null +++ b/scripts/backfill/selectAllRooms.ts @@ -0,0 +1,12 @@ +import { selectRooms } from "@/lib/supabase/rooms/selectRooms"; +import type { Tables } from "@/types/database.types"; +import { paginate } from "./paginate"; + +/** + * Every room, read past the PostgREST 1,000-row cap. The for-loop lives here + * (outside `lib/supabase`); `selectRooms` stays a thin single-query helper — + * `paginate` just calls it one page at a time. + */ +export async function selectAllRooms(): Promise[]> { + return paginate((from, to) => selectRooms({ range: { from, to } }).then(rows => rows ?? [])); +} diff --git a/scripts/backfillRoomsToSessions.ts b/scripts/backfillRoomsToSessions.ts new file mode 100644 index 000000000..b1dea52b5 --- /dev/null +++ b/scripts/backfillRoomsToSessions.ts @@ -0,0 +1,112 @@ +/** + * Phase 2 backfill: migrate rooms + memories → sessions + chats + chat_messages. + * + * Real run: + * SUPABASE_URL=… SUPABASE_KEY=… pnpm backfill:rooms-to-sessions + * + * Dry run (reads only, no writes — prints what would happen): + * SUPABASE_URL=… SUPABASE_KEY=… DRY_RUN=1 pnpm backfill:rooms-to-sessions + * (or pass `--dry-run`) + * + * Idempotent: deterministic session ids + existence guards + write-once + * message upserts make re-runs safe after a partial failure. + */ + +import selectRoom from "@/lib/supabase/rooms/selectRoom"; +import { migrateRoom, OVERSIZED_THRESHOLD, type RoomStats } from "./backfill/migrateRoom"; +import { selectAllRooms } from "./backfill/selectAllRooms"; +import type { Tables } from "@/types/database.types"; + +const dryRun = process.argv.includes("--dry-run") || process.env.DRY_RUN === "1"; + +// `--limit=N` processes only the first N rooms; `--room=` processes a +// single room. Both are for validating the script's code path (or +// re-migrating one room) before/without an unbounded run. +const limitFlag = process.argv.find(a => a.startsWith("--limit=")); +const limit = limitFlag ? Number.parseInt(limitFlag.split("=")[1], 10) : undefined; +const roomFlag = process.argv.find(a => a.startsWith("--room=")); +const roomId = roomFlag ? roomFlag.split("=")[1] : undefined; + +// Rooms are processed in concurrent batches of this size. Each room's +// session/chat/message ids are independent (distinct rooms never touch the +// same row), so cross-room concurrency is safe and idempotency holds. +const concurrencyFlag = process.argv.find(a => a.startsWith("--concurrency=")); +const CONCURRENCY = concurrencyFlag + ? Math.max(1, Number.parseInt(concurrencyFlag.split("=")[1], 10)) + : 20; + +async function main() { + console.log( + `🚀 Phase 2 backfill: rooms → sessions/chats/chat_messages${dryRun ? " [DRY RUN — no writes]" : ""}${roomId ? ` [room ${roomId}]` : limit ? ` [limit ${limit}]` : ""}\n`, + ); + + let rooms: Tables<"rooms">[]; + if (roomId) { + const room = await selectRoom(roomId); + rooms = room ? [room] : []; + console.log(room ? `Targeting room ${roomId}\n` : `Room ${roomId} not found\n`); + } else { + const allRooms = await selectAllRooms(); + rooms = limit ? allRooms.slice(0, limit) : allRooms; + console.log( + `Found ${allRooms.length} rooms${limit ? `, processing first ${rooms.length}` : ""}\n`, + ); + } + + let migrated = 0; + let skipped = 0; + let failed = 0; + let sessionsNew = 0; + let sessionsExisting = 0; + let chatsNew = 0; + let chatsExisting = 0; + let messages = 0; + let malformed = 0; + let oversized = 0; + + const tally = (stats: RoomStats) => { + if (stats.status === "skipped") { + skipped++; + return; + } + migrated++; + if (stats.sessionExisted) sessionsExisting++; + else sessionsNew++; + if (stats.chatExisted) chatsExisting++; + else chatsNew++; + messages += stats.messagesWritten; + malformed += stats.messagesMalformed; + if (stats.memoryCount >= OVERSIZED_THRESHOLD) oversized++; + }; + + // Process rooms in concurrent batches. `allSettled` isolates failures so + // one bad room doesn't sink the rest of its batch. + for (let i = 0; i < rooms.length; i += CONCURRENCY) { + const batch = rooms.slice(i, i + CONCURRENCY); + const results = await Promise.allSettled(batch.map(room => migrateRoom(room, { dryRun }))); + results.forEach((result, j) => { + if (result.status === "fulfilled") { + tally(result.value); + } else { + console.error(`❌ Failed to migrate room ${batch[j].id}:`, result.reason); + failed++; + } + }); + } + + console.log(`\n📊 ${dryRun ? "DRY RUN — no writes performed" : "Done"}`); + console.log( + `Rooms: ${migrated} ${dryRun ? "would migrate" : "migrated"}, ${skipped} skipped (no account_id), ${failed} failed`, + ); + console.log(`Sessions: ${sessionsNew} new, ${sessionsExisting} already exist`); + console.log(`Chats: ${chatsNew} new, ${chatsExisting} already exist`); + console.log( + `Messages: ${messages} ${dryRun ? "would write" : "written"}` + + (malformed ? ` | ${malformed} malformed (null parts) skipped` : "") + + (oversized ? ` | ${oversized} room(s) ≥${OVERSIZED_THRESHOLD} memories` : ""), + ); + + if (failed > 0) process.exit(1); +} + +main(); From 49c327fb317f810c1e7f21715d69175558b1bad3 Mon Sep 17 00:00:00 2001 From: "sweetman.eth" Date: Fri, 29 May 2026 13:06:53 -0500 Subject: [PATCH 10/15] fix(backfill): store full UIMessage in chat_messages.parts (#627) The workflow persists the entire UIMessage in the parts column (persistLatestUserMessage / persistAssistantMessage), and the read path (getSessionChatHandler) returns parts verbatim as a UIMessage. The Phase 2 backfill wrote only the bare parts array, so migrated history came back without id/role and wouldn't render through the workflow read path. Store { id, role, parts } to match the native shape. The already-migrated ~46k rows were corrected in place via a one-time SQL reconstruction (jsonb_build_object from the id/role/parts columns); this fix keeps the owed straggler re-run consistent. Co-authored-by: Claude Opus 4.7 (1M context) --- scripts/backfill/__tests__/migrateRoom.test.ts | 9 ++++++++- scripts/backfill/migrateRoom.ts | 7 ++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/backfill/__tests__/migrateRoom.test.ts b/scripts/backfill/__tests__/migrateRoom.test.ts index 7ee86fdb8..40bd58eac 100644 --- a/scripts/backfill/__tests__/migrateRoom.test.ts +++ b/scripts/backfill/__tests__/migrateRoom.test.ts @@ -80,8 +80,15 @@ describe("migrateRoom", () => { expect(insertChat).toHaveBeenCalledTimes(1); // one batch call containing only the well-formed message expect(upsertChatMessages).toHaveBeenCalledTimes(1); + // `parts` stores the full UIMessage (matching the workflow's native + // persist path), not the bare parts array. expect(upsertChatMessages).toHaveBeenCalledWith([ - expect.objectContaining({ id: "m1", chat_id: "room-1", role: "user" }), + expect.objectContaining({ + id: "m1", + chat_id: "room-1", + role: "user", + parts: { id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }, + }), ]); expect(stats.messagesWritten).toBe(1); expect(stats.messagesMalformed).toBe(1); diff --git a/scripts/backfill/migrateRoom.ts b/scripts/backfill/migrateRoom.ts index c95209ec2..79f8c192f 100644 --- a/scripts/backfill/migrateRoom.ts +++ b/scripts/backfill/migrateRoom.ts @@ -57,11 +57,16 @@ async function migrateMessages( malformed++; continue; } + // The workflow persists the FULL UIMessage in the `parts` column + // (see lib/chat/persistLatestUserMessage / persistAssistantMessage), + // and the read path (getSessionChatHandler) returns `parts` verbatim + // expecting a UIMessage. Store the same shape so migrated history + // deserializes identically to natively-written chats. rows.push({ id: memory.id, chat_id: roomId, role: content.role, - parts: content.parts, + parts: { id: memory.id, role: content.role, parts: content.parts }, created_at: memory.updated_at, }); } From 290783cbbd4fce60badba804abbdc4a12e36463b Mon Sep 17 00:00:00 2001 From: ahmednahima0-beep Date: Sat, 30 May 2026 02:09:24 +0700 Subject: [PATCH 11/15] Feat(api) - migrate post chat read (#624) * feat(chat-workflow): forward Privy JWT as RECOUP_ACCESS_TOKEN This commit introduces the handling of a short-lived Privy JWT in the workflow request body as `recoupAccessToken`. The implementation ensures that the token is validated and injected into the sandbox environment, allowing the `recoup-api` skill to authenticate successfully. Key changes include: - Updated schema in `lib/chat/validateChatWorkflow.ts` to accept `recoupAccessToken`. - Enhanced `AgentContext` to include the new token field. - Adjusted `handleChatWorkflowStream` to conditionally spread the token into the context. - Modified `buildRecoupExecEnv` to inject the token into the sandbox environment when present. Tests have been added to verify the new functionality, ensuring that the full suite passes successfully. Co-authored-by: Claude Opus 4.7 (1M context) * refactor(chat-workflow): update response format for chat read endpoints This commit modifies the response structure for the chat read endpoints to enhance consistency. The return value for successful requests has been changed from `{ status: "ok" }` to `{ success: true }` across the relevant API routes and handler functions. Key changes include: - Updated JSDoc comments to reflect the new response format. - Adjusted the implementation in `markChatReadHandler` and the corresponding test cases to ensure they validate the new response structure. All tests have been updated accordingly and pass successfully. * refactor(tests): streamline request creation in chat read tests This commit refactors the request creation in the `markChatReadHandler` and `validateMarkChatReadRequest` test files for improved readability. The `makeReq` function has been simplified to return a single line for the `NextRequest` instantiation, enhancing code clarity without altering functionality. All tests remain intact and pass successfully. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: sweetman.eth --- .../[sessionId]/chats/[chatId]/read/route.ts | 38 +++++ .../__tests__/markChatReadHandler.test.ts | 79 +++++++++ .../validateMarkChatReadRequest.test.ts | 152 ++++++++++++++++++ lib/sessions/chats/markChatReadHandler.ts | 35 ++++ .../chats/validateMarkChatReadRequest.ts | 66 ++++++++ lib/supabase/chat_reads/upsertChatRead.ts | 38 +++++ 6 files changed, 408 insertions(+) create mode 100644 app/api/sessions/[sessionId]/chats/[chatId]/read/route.ts create mode 100644 lib/sessions/chats/__tests__/markChatReadHandler.test.ts create mode 100644 lib/sessions/chats/__tests__/validateMarkChatReadRequest.test.ts create mode 100644 lib/sessions/chats/markChatReadHandler.ts create mode 100644 lib/sessions/chats/validateMarkChatReadRequest.ts create mode 100644 lib/supabase/chat_reads/upsertChatRead.ts diff --git a/app/api/sessions/[sessionId]/chats/[chatId]/read/route.ts b/app/api/sessions/[sessionId]/chats/[chatId]/read/route.ts new file mode 100644 index 000000000..e67d3fe06 --- /dev/null +++ b/app/api/sessions/[sessionId]/chats/[chatId]/read/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { markChatReadHandler } from "@/lib/sessions/chats/markChatReadHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/sessions/{sessionId}/chats/{chatId}/read + * + * Marks a chat as read for the authenticated account. Authenticates via + * Privy Bearer token or x-api-key header. + * + * @param request - The incoming request. + * @param options - Route options containing the async params. + * @param options.params - Route params containing sessionId and chatId. + * @returns A NextResponse with `{ success: true }` on 200, or an error. + */ +export async function POST( + request: NextRequest, + options: { params: Promise<{ sessionId: string; chatId: string }> }, +) { + const { sessionId, chatId } = await options.params; + return markChatReadHandler(request, sessionId, chatId); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/sessions/chats/__tests__/markChatReadHandler.test.ts b/lib/sessions/chats/__tests__/markChatReadHandler.test.ts new file mode 100644 index 000000000..457fb95df --- /dev/null +++ b/lib/sessions/chats/__tests__/markChatReadHandler.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/sessions/chats/validateMarkChatReadRequest", () => ({ + validateMarkChatReadRequest: vi.fn(), +})); +vi.mock("@/lib/supabase/chat_reads/upsertChatRead", () => ({ + upsertChatRead: vi.fn(), +})); + +const { validateMarkChatReadRequest } = await import( + "@/lib/sessions/chats/validateMarkChatReadRequest" +); +const { upsertChatRead } = await import("@/lib/supabase/chat_reads/upsertChatRead"); +const { markChatReadHandler } = await import("@/lib/sessions/chats/markChatReadHandler"); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1/read", { + method: "POST", + }); +} + +function mockValidated() { + vi.mocked(validateMarkChatReadRequest).mockResolvedValue({ + auth: { accountId, orgId: null, authToken: "tok" }, + session: baseSessionRow({ id: "sess_1", account_id: accountId }), + chat: baseChatRow({ id: "chat_1", session_id: "sess_1" }), + }); +} + +describe("markChatReadHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the NextResponse from validateMarkChatReadRequest as-is", async () => { + const failure = NextResponse.json({ status: "error", error: "Forbidden" }, { status: 403 }); + vi.mocked(validateMarkChatReadRequest).mockResolvedValue(failure); + + const res = await markChatReadHandler(makeReq(), "sess_1", "chat_1"); + expect(res).toBe(failure); + expect(upsertChatRead).not.toHaveBeenCalled(); + }); + + it("returns { success: true } when upsert succeeds", async () => { + mockValidated(); + vi.mocked(upsertChatRead).mockResolvedValue({ + account_id: accountId, + chat_id: "chat_1", + last_read_at: "2026-05-21T00:00:00.000Z", + created_at: "2026-05-21T00:00:00.000Z", + updated_at: "2026-05-21T00:00:00.000Z", + }); + + const res = await markChatReadHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true }); + expect(upsertChatRead).toHaveBeenCalledWith(accountId, "chat_1"); + }); + + it("returns 500 when upsertChatRead fails", async () => { + mockValidated(); + vi.mocked(upsertChatRead).mockResolvedValue(null); + + const res = await markChatReadHandler(makeReq(), "sess_1", "chat_1"); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ + status: "error", + error: "Failed to mark chat as read", + }); + }); +}); diff --git a/lib/sessions/chats/__tests__/validateMarkChatReadRequest.test.ts b/lib/sessions/chats/__tests__/validateMarkChatReadRequest.test.ts new file mode 100644 index 000000000..3149e6e24 --- /dev/null +++ b/lib/sessions/chats/__tests__/validateMarkChatReadRequest.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; +import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("@/lib/supabase/sessions/selectSessions", () => ({ + selectSessions: vi.fn(), +})); +vi.mock("@/lib/supabase/chats/selectChats", () => ({ + selectChats: vi.fn(), +})); + +const { validateAuthContext } = await import("@/lib/auth/validateAuthContext"); +const { selectSessions } = await import("@/lib/supabase/sessions/selectSessions"); +const { selectChats } = await import("@/lib/supabase/chats/selectChats"); +const { validateMarkChatReadRequest } = await import( + "@/lib/sessions/chats/validateMarkChatReadRequest" +); + +const accountId = "acc-uuid-1"; + +function makeReq(): NextRequest { + return new NextRequest("https://example.com/api/sessions/sess_1/chats/chat_1/read", { + method: "POST", + }); +} + +describe("validateMarkChatReadRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("forwards the auth NextResponse when validateAuthContext rejects", async () => { + const failure = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(failure); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBe(failure); + expect(selectSessions).not.toHaveBeenCalled(); + }); + + it("returns 404 when the session is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([]); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1" })]); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_missing", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Session not found", + }); + } + }); + + it("returns 403 when the session belongs to a different account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: "acc-OTHER" }), + ]); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(403); + expect(await res.json()).toEqual({ status: "error", error: "Forbidden" }); + } + }); + + it("returns 404 when the chat is missing", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([]); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_1", "chat_missing"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Chat not found", + }); + } + }); + + it("returns 404 when the chat belongs to a different session", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([ + baseChatRow({ id: "chat_1", session_id: "sess_OTHER" }), + ]); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_1", "chat_1"); + expect(res).toBeInstanceOf(NextResponse); + if (res instanceof NextResponse) { + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ + status: "error", + error: "Chat not found", + }); + } + }); + + it("returns { auth, session, chat } on the happy path", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "tok", + }); + vi.mocked(selectSessions).mockResolvedValue([ + baseSessionRow({ id: "sess_1", account_id: accountId }), + ]); + vi.mocked(selectChats).mockResolvedValue([baseChatRow({ id: "chat_1", session_id: "sess_1" })]); + + const res = await validateMarkChatReadRequest(makeReq(), "sess_1", "chat_1"); + expect(res).not.toBeInstanceOf(NextResponse); + if (!(res instanceof NextResponse)) { + expect(res.auth.accountId).toBe(accountId); + expect(res.session.id).toBe("sess_1"); + expect(res.chat.id).toBe("chat_1"); + } + }); +}); diff --git a/lib/sessions/chats/markChatReadHandler.ts b/lib/sessions/chats/markChatReadHandler.ts new file mode 100644 index 000000000..39223bc89 --- /dev/null +++ b/lib/sessions/chats/markChatReadHandler.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateMarkChatReadRequest } from "@/lib/sessions/chats/validateMarkChatReadRequest"; +import { upsertChatRead } from "@/lib/supabase/chat_reads/upsertChatRead"; + +/** + * Handles `POST /api/sessions/{sessionId}/chats/{chatId}/read`. + * Upserts `chat_reads.last_read_at` for the authenticated account so + * subsequent list-chats calls report `hasUnread: false` for this chat. + * + * @param request - The incoming request. + * @param sessionId - The parent session id. + * @param chatId - The chat id to mark as read. + * @returns A NextResponse with `{ success: true }` on 200, or an error. + */ +export async function markChatReadHandler( + request: NextRequest, + sessionId: string, + chatId: string, +): Promise { + const validated = await validateMarkChatReadRequest(request, sessionId, chatId); + if (validated instanceof NextResponse) { + return validated; + } + + const row = await upsertChatRead(validated.auth.accountId, chatId); + if (!row) { + return NextResponse.json( + { status: "error", error: "Failed to mark chat as read" }, + { status: 500, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json({ success: true }, { status: 200, headers: getCorsHeaders() }); +} diff --git a/lib/sessions/chats/validateMarkChatReadRequest.ts b/lib/sessions/chats/validateMarkChatReadRequest.ts new file mode 100644 index 000000000..46ff53c61 --- /dev/null +++ b/lib/sessions/chats/validateMarkChatReadRequest.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import type { AuthContext } from "@/lib/auth/validateAuthContext"; +import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; +import { selectChats } from "@/lib/supabase/chats/selectChats"; +import type { Tables } from "@/types/database.types"; + +export interface ValidatedMarkChatReadRequest { + auth: AuthContext; + session: Tables<"sessions">; + chat: Tables<"chats">; +} + +/** + * Validates `POST /api/sessions/{sessionId}/chats/{chatId}/read`: + * 1. Authenticates the caller + * 2. Loads the session and chat + * 3. Confirms the account owns the session and the chat belongs to it + * + * @param request - The incoming request. + * @param sessionId - The parent session id. + * @param chatId - The chat id to mark as read. + * @returns A NextResponse on failure, or the validated auth + session + chat. + */ +export async function validateMarkChatReadRequest( + request: NextRequest, + sessionId: string, + chatId: string, +): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) { + return auth; + } + + const [sessionRows, chatRows] = await Promise.all([ + selectSessions({ id: sessionId }), + selectChats({ id: chatId }), + ]); + + const session = sessionRows[0] ?? null; + const chat = chatRows[0] ?? null; + + if (!session) { + return NextResponse.json( + { status: "error", error: "Session not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + if (session.account_id !== auth.accountId) { + return NextResponse.json( + { status: "error", error: "Forbidden" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + + if (!chat || chat.session_id !== sessionId) { + return NextResponse.json( + { status: "error", error: "Chat not found" }, + { status: 404, headers: getCorsHeaders() }, + ); + } + + return { auth, session, chat }; +} diff --git a/lib/supabase/chat_reads/upsertChatRead.ts b/lib/supabase/chat_reads/upsertChatRead.ts new file mode 100644 index 000000000..a36121ab7 --- /dev/null +++ b/lib/supabase/chat_reads/upsertChatRead.ts @@ -0,0 +1,38 @@ +import supabase from "@/lib/supabase/serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Upserts a `chat_reads` row for the given account and chat, setting + * `last_read_at` to the current time. Idempotent — safe to call repeatedly. + * + * @param accountId - The owning account id. + * @param chatId - The chat id to mark as read. + * @returns The upserted row, or `null` if the write failed. + */ +export async function upsertChatRead( + accountId: string, + chatId: string, +): Promise | null> { + const now = new Date().toISOString(); + + const { data, error } = await supabase + .from("chat_reads") + .upsert( + { + account_id: accountId, + chat_id: chatId, + last_read_at: now, + updated_at: now, + }, + { onConflict: "account_id,chat_id" }, + ) + .select() + .maybeSingle(); + + if (error) { + console.error("[upsertChatRead] error:", error); + return null; + } + + return data; +} From 743b0663e6489983fe596d030a08c13df4215a07 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sat, 30 May 2026 07:52:13 +0530 Subject: [PATCH 12/15] feat(sessions): accept artistId on POST /api/sessions (#628) * feat(sessions): accept artistId on POST /api/sessions Threads optional artistId through the session-create flow and exposes it on session reads. - validateCreateSessionBody accepts artistId (UUID, optional). - buildSessionInsertRow writes artist_id from artistId (defaults to null). - createSessionHandler forwards the validated artistId into the insert. - toSessionResponse exposes artistId on the camelCase response. - types/database.types.ts: add artist_id to sessions Row/Insert/Update + sessions_artist_id_fkey relationship (mirrors what supabase gen would emit after database#27 applies). Anyone who regenerates types post-migration should see no diff. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(test): include artistId in GET /api/sessions/{sid} happy-path shape The existing test used toEqual against the full camelCase session shape; adding artistId to toSessionResponse required the expected object to carry it too. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../[sessionId]/__tests__/route.test.ts | 2 ++ lib/sessions/__tests__/baseSessionRow.ts | 1 + .../__tests__/buildSessionInsertRow.test.ts | 19 ++++++++++++++ .../createSessionHandler.persistence.test.ts | 25 +++++++++++++++++++ .../validateCreateSessionBody.test.ts | 23 +++++++++++++++++ lib/sessions/buildSessionInsertRow.ts | 11 ++++++-- lib/sessions/createSessionHandler.ts | 1 + lib/sessions/toSessionResponse.ts | 1 + lib/sessions/validateCreateSessionBody.ts | 1 + types/database.types.ts | 10 ++++++++ 10 files changed, 92 insertions(+), 2 deletions(-) diff --git a/app/api/sessions/[sessionId]/__tests__/route.test.ts b/app/api/sessions/[sessionId]/__tests__/route.test.ts index 17afe983e..e75ba6961 100644 --- a/app/api/sessions/[sessionId]/__tests__/route.test.ts +++ b/app/api/sessions/[sessionId]/__tests__/route.test.ts @@ -63,6 +63,7 @@ function makePatchReqRaw( const mockRow: SessionRow = { id: "sess_1", account_id: "acc-uuid-1", + artist_id: null, title: "Test session", status: "running", repo_owner: "acme", @@ -186,6 +187,7 @@ describe("GET /api/sessions/[sessionId]", () => { session: { id: "sess_1", userId: "acc-uuid-1", + artistId: null, title: "Test session", status: "running", repoOwner: "acme", diff --git a/lib/sessions/__tests__/baseSessionRow.ts b/lib/sessions/__tests__/baseSessionRow.ts index 8c5fe8095..0f40d0152 100644 --- a/lib/sessions/__tests__/baseSessionRow.ts +++ b/lib/sessions/__tests__/baseSessionRow.ts @@ -9,6 +9,7 @@ export function baseSessionRow(overrides: Partial> = {}): Tab return { id: "sess_1", account_id: "acc-uuid-1", + artist_id: null, title: "Test session", status: "running", repo_owner: null, diff --git a/lib/sessions/__tests__/buildSessionInsertRow.test.ts b/lib/sessions/__tests__/buildSessionInsertRow.test.ts index 2b75c679a..0e2ac881b 100644 --- a/lib/sessions/__tests__/buildSessionInsertRow.test.ts +++ b/lib/sessions/__tests__/buildSessionInsertRow.test.ts @@ -47,4 +47,23 @@ describe("buildSessionInsertRow", () => { }); expect(row.sandbox_state).toEqual({ type: "vercel" }); }); + + it("writes artist_id when artistId is provided", () => { + const row = buildSessionInsertRow({ + accountId: "acc-1", + title: "Berlin", + cloneUrl: DEFAULT_CLONE, + artistId: "artist-uuid-1", + }); + expect(row.artist_id).toBe("artist-uuid-1"); + }); + + it("sets artist_id to null when artistId is omitted", () => { + const row = buildSessionInsertRow({ + accountId: "acc-1", + title: "Berlin", + cloneUrl: DEFAULT_CLONE, + }); + expect(row.artist_id).toBeNull(); + }); }); diff --git a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts index cf54c9eff..043b65e6e 100644 --- a/lib/sessions/__tests__/createSessionHandler.persistence.test.ts +++ b/lib/sessions/__tests__/createSessionHandler.persistence.test.ts @@ -107,6 +107,31 @@ describe("createSessionHandler — persistence", () => { expect(vi.mocked(insertSession).mock.calls[0][0].title).toBe("Hello world"); }); + it("writes artist_id when body carries artistId, and exposes it on the response", async () => { + const artistId = "a25c5dc5-3eb2-4fff-9a5e-39e90c9d4f02"; + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated({ body: { artistId } })); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ artist_id: artistId })); + vi.mocked(insertChat).mockResolvedValue(baseChatRow()); + + const res = await createSessionHandler(makeCreateSessionReq({ artistId })); + expect(res.status).toBe(200); + + expect(vi.mocked(insertSession).mock.calls[0][0].artist_id).toBe(artistId); + + const body = (await res.json()) as { session: { artistId: string | null } }; + expect(body.session.artistId).toBe(artistId); + }); + + it("writes artist_id as null when artistId is omitted", async () => { + vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); + vi.mocked(insertSession).mockResolvedValue(baseSessionRow()); + vi.mocked(insertChat).mockResolvedValue(baseChatRow()); + + await createSessionHandler(makeCreateSessionReq({})); + + expect(vi.mocked(insertSession).mock.calls[0][0].artist_id).toBeNull(); + }); + it("returns 500 when insertSession fails", async () => { vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); vi.mocked(insertSession).mockResolvedValue(null); diff --git a/lib/sessions/__tests__/validateCreateSessionBody.test.ts b/lib/sessions/__tests__/validateCreateSessionBody.test.ts index 85491c44d..39fc1c42b 100644 --- a/lib/sessions/__tests__/validateCreateSessionBody.test.ts +++ b/lib/sessions/__tests__/validateCreateSessionBody.test.ts @@ -74,4 +74,27 @@ describe("validateCreateSessionBody", () => { expect.objectContaining({ organizationId: orgId }), ); }); + + it("accepts a valid artistId and surfaces it on the validated body", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + + const artistId = "a25c5dc5-3eb2-4fff-9a5e-39e90c9d4f02"; + const result = await validateCreateSessionBody(req({ artistId })); + expect(result).not.toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) { + expect(result.body.artistId).toBe(artistId); + } + }); + + it("rejects a non-UUID artistId with 400", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + + const result = await validateCreateSessionBody(req({ artistId: "not-a-uuid" })); + expect(result).toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) { + expect(result.status).toBe(400); + const body = (await result.json()) as { status: string; error: string }; + expect(body.error).toMatch(/UUID/i); + } + }); }); diff --git a/lib/sessions/buildSessionInsertRow.ts b/lib/sessions/buildSessionInsertRow.ts index 57402034a..c37f84722 100644 --- a/lib/sessions/buildSessionInsertRow.ts +++ b/lib/sessions/buildSessionInsertRow.ts @@ -10,6 +10,12 @@ interface BuildSessionInsertRowInput { * `cloneUrl`. */ cloneUrl: string; + /** + * Optional artist account this session belongs to. Backs the chat + * sidebar's artist filter; omitted for sessions not tied to an + * artist context. + */ + artistId?: string; } /** @@ -18,14 +24,15 @@ interface BuildSessionInsertRowInput { * the default / null-coalescing rules so the handler can stay focused * on HTTP and persistence flow. * - * @param input - The validated body, owning account id, resolved title, and resolved clone URL. + * @param input - The validated body, owning account id, resolved title, resolved clone URL, and optional artist id. * @returns A row ready to pass to `insertSession`. */ export function buildSessionInsertRow(input: BuildSessionInsertRowInput): TablesInsert<"sessions"> { - const { accountId, title, cloneUrl } = input; + const { accountId, title, cloneUrl, artistId } = input; return { id: generateUUID(), account_id: accountId, + artist_id: artistId ?? null, title, status: "running", branch: null, diff --git a/lib/sessions/createSessionHandler.ts b/lib/sessions/createSessionHandler.ts index 87e81066b..e48020537 100644 --- a/lib/sessions/createSessionHandler.ts +++ b/lib/sessions/createSessionHandler.ts @@ -58,6 +58,7 @@ export async function createSessionHandler(request: NextRequest): Promise) { return { id: row.id, userId: row.account_id, + artistId: row.artist_id, title: row.title, status: row.status, repoOwner: row.repo_owner, diff --git a/lib/sessions/validateCreateSessionBody.ts b/lib/sessions/validateCreateSessionBody.ts index d9aa07175..0b6059b68 100644 --- a/lib/sessions/validateCreateSessionBody.ts +++ b/lib/sessions/validateCreateSessionBody.ts @@ -21,6 +21,7 @@ import type { AuthContext } from "@/lib/auth/validateAuthContext"; export const createSessionBodySchema = z.object({ title: z.string().optional(), organizationId: z.string().uuid("organizationId must be a valid UUID").optional(), + artistId: z.string().uuid("artistId must be a valid UUID").optional(), }); export type CreateSessionBody = z.infer; diff --git a/types/database.types.ts b/types/database.types.ts index cc7d03e1e..191472132 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -2878,6 +2878,7 @@ export type Database = { sessions: { Row: { account_id: string; + artist_id: string | null; branch: string | null; cached_diff: Json | null; cached_diff_updated_at: string | null; @@ -2907,6 +2908,7 @@ export type Database = { }; Insert: { account_id: string; + artist_id?: string | null; branch?: string | null; cached_diff?: Json | null; cached_diff_updated_at?: string | null; @@ -2936,6 +2938,7 @@ export type Database = { }; Update: { account_id?: string; + artist_id?: string | null; branch?: string | null; cached_diff?: Json | null; cached_diff_updated_at?: string | null; @@ -2971,6 +2974,13 @@ export type Database = { referencedRelation: "accounts"; referencedColumns: ["id"]; }, + { + foreignKeyName: "sessions_artist_id_fkey"; + columns: ["artist_id"]; + isOneToOne: false; + referencedRelation: "accounts"; + referencedColumns: ["id"]; + }, ]; }; social_fans: { From 94821f69f5c4fdd1693a46e4183a1efbc8b3af19 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Sun, 31 May 2026 08:54:51 +0530 Subject: [PATCH 13/15] feat(api): migrate GET /api/chats to session-scoped shape (#626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(api): migrate GET /api/chats to session-scoped shape Reads chats joined to sessions via a new selectChatsWithSessions helper and projects rows to { id, title, accountId, sessionId, updatedAt } so clients can render canonical /sessions/{sid}/chats/{cid} URLs. Recoup admin → all chats; personal/org → caller's account (or target with canAccessAccount). artist_account_id is accepted but ignored for backward compatibility. The MCP get_chats tool gets the same treatment. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): drop explicit Tables<> type from selectChatsWithSessions The typed supabase-js client infers the join row shape from .from("chats").select("*, session:sessions!inner(id, account_id)") directly. The ChatWithSession alias built from Tables<"chats"> & Pick, ...> plus the trailing `as ChatWithSession[]` cast were just paying tax to reach a shape the SDK was already giving us. Let inference carry it through to the handler. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(api): describe artist_account_id as dormant, not deprecated The artist filter on GET /api/chats / get_chats is staying. It just has no rows to match until sessions.artist_id is populated. Reword the MCP tool schema, route handler JSDoc, and validator comment so callers know the filter is first-class and will start working once the database column lands. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(ci): prettier-format selectChatsWithSessions.ts * chore(types): regenerate types/database.types.ts from current supabase CLI `pnpm update-types` produces this exact output today. The previously committed file was hand-edited (per api#628) which drifted from the CLI's current style (semicolons vs trailing-comma multi-line). No schema deltas — purely formatting normalization plus a few JSON-path keys the newer CLI infers on JSONB columns. Going forward this file should always be CLI-generated, never hand-edited, so future regens are no-ops. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(chats): project artistId + apply artist filter + fix cubic P1s Matches the docs#227 contract that GET /api/chats responses include `artistId` and the `artist_account_id` query param is a real filter. - selectChatsWithSessions: project `session.artist_id` in SELECT; accept `artistAccountId` param; `.eq("session.artist_id", id)` when set. - validateGetChatsRequest: return `artistAccountId` on the scope. Fix the unreachable Recoup-admin branch (cubic P1) — check `account_organization_ids` membership instead of `auth.orgId`, which is always null for Bearer tokens. Bearer-authed admins now get the same admin scope as x-api-key org admins. - getChatsHandler: pass `artistAccountId` to the select; include `artistId` in the per-row projection. 500 catch block now returns hardcoded "Internal server error" instead of echoing the raw exception message (cubic P1 — no leaking internals on 500). - registerGetChatsTool (MCP): same projection/filter changes; same admin-via-membership fix. - Tests: updated mocks for getAccountOrganizations, refreshed admin scope cases, added composed filter + artistId-null cases, and updated the 500 expectation to "Internal server error" (cubic). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(organizations): extract isRecoupAdmin helper for DRY admin check Two callers were inlining the same `getAccountOrganizations(...).some(m => m.organization_id === RECOUP_ORG_ID)` pattern to detect Recoup-org membership. Extract into a shared helper so the admin-scope decision lives in one place. - New `lib/organizations/isRecoupAdmin.ts` (+ unit tests). - `validateGetChatsRequest` and `registerGetChatsTool` now call it. - `canAccessAccount` deliberately keeps its inline check — it reuses the same org-list query for the subsequent shared-org check, so calling this helper there would double the DB round-trip. - Tests now mock `isRecoupAdmin` directly (cleaner than mocking the Supabase fetch for every admin-scope case). Addresses sweetmantech's review comment on #626. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(format): prettier-format types/database.types.ts The supabase CLI emits the file without semicolons (it has its own inline formatter), but the project's prettier config requires them (`semi: true`), so `pnpm format:check` was failing in CI. This is mechanical — diff is purely whitespace + trailing semicolons. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: sweetman.eth --- app/api/chats/route.ts | 9 +- lib/chats/__tests__/getChatsHandler.test.ts | 333 +++++++++--------- .../__tests__/validateGetChatsRequest.test.ts | 229 ++++-------- lib/chats/getChatsHandler.ts | 76 ++-- lib/chats/validateGetChatsRequest.ts | 90 ++--- .../__tests__/registerGetChatsTool.test.ts | 129 ++++--- lib/mcp/tools/chats/registerGetChatsTool.ts | 67 +++- .../__tests__/isRecoupAdmin.test.ts | 47 +++ lib/organizations/isRecoupAdmin.ts | 26 ++ .../__tests__/selectChatsWithSessions.test.ts | 123 +++++++ lib/supabase/chats/selectChatsWithSessions.ts | 57 +++ 11 files changed, 704 insertions(+), 482 deletions(-) create mode 100644 lib/organizations/__tests__/isRecoupAdmin.test.ts create mode 100644 lib/organizations/isRecoupAdmin.ts create mode 100644 lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts create mode 100644 lib/supabase/chats/selectChatsWithSessions.ts diff --git a/app/api/chats/route.ts b/app/api/chats/route.ts index 3a3070950..453fd1b51 100644 --- a/app/api/chats/route.ts +++ b/app/api/chats/route.ts @@ -21,13 +21,16 @@ export async function OPTIONS() { /** * GET /api/chats * - * Retrieves chat rooms for an account. + * Retrieves chats joined with their owning session. Each row carries + * `sessionId` and the owning `accountId` so clients can render canonical + * `/sessions/{sessionId}/chats/{chatId}` URLs. * * Authentication: x-api-key header or Authorization Bearer token required. * * Query parameters: - * - account_id (required): UUID of the account whose chats to retrieve - * - artist_account_id (optional): Filter to chats for a specific artist (UUID) + * - account_id (optional): UUID of the target account (validated for access). + * - artist_account_id (optional): Accepted for backward compatibility but + * ignored — reserved for the artist-surface migration. * * @param request - The request object containing query parameters * @returns A NextResponse with chats data or an error diff --git a/lib/chats/__tests__/getChatsHandler.test.ts b/lib/chats/__tests__/getChatsHandler.test.ts index 45c80c769..7ed6478d4 100644 --- a/lib/chats/__tests__/getChatsHandler.test.ts +++ b/lib/chats/__tests__/getChatsHandler.test.ts @@ -4,7 +4,8 @@ import { getChatsHandler } from "../getChatsHandler"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; -import { selectRooms } from "@/lib/supabase/rooms/selectRooms"; +import { isRecoupAdmin } from "@/lib/organizations/isRecoupAdmin"; +import { selectChatsWithSessions } from "@/lib/supabase/chats/selectChatsWithSessions"; vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), @@ -14,28 +15,20 @@ vi.mock("@/lib/organizations/canAccessAccount", () => ({ canAccessAccount: vi.fn(), })); -vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ - getAccountOrganizations: vi.fn(), +vi.mock("@/lib/organizations/isRecoupAdmin", () => ({ + isRecoupAdmin: vi.fn(), })); -vi.mock("@/lib/supabase/rooms/selectRooms", () => ({ - selectRooms: vi.fn(), +vi.mock("@/lib/supabase/chats/selectChatsWithSessions", () => ({ + selectChatsWithSessions: vi.fn(), })); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); -vi.mock("@/lib/const", () => ({ - RECOUP_ORG_ID: "recoup-org-id", -})); - /** * Creates a mock NextRequest with the given URL and headers. - * - * @param url - The URL for the request - * @param apiKey - The API key header value - * @returns A mock NextRequest */ function createMockRequest(url: string, apiKey = "test-api-key"): NextRequest { return { @@ -46,9 +39,16 @@ function createMockRequest(url: string, apiKey = "test-api-key"): NextRequest { } as unknown as NextRequest; } +/** Fixture helper — a session embed with optional artist id. */ +function sessionEmbed(accountId: string, artistId: string | null = null) { + return { id: "sess-1", account_id: accountId, artist_id: artistId }; +} + describe("getChatsHandler", () => { beforeEach(() => { vi.clearAllMocks(); + // Default: caller is NOT a Recoup admin — admin tests override. + vi.mocked(isRecoupAdmin).mockResolvedValue(false); }); describe("authentication", () => { @@ -63,41 +63,81 @@ describe("getChatsHandler", () => { expect(response.status).toBe(401); expect(json.status).toBe("error"); - expect(selectRooms).not.toHaveBeenCalled(); + expect(selectChatsWithSessions).not.toHaveBeenCalled(); }); }); describe("personal key behavior", () => { - it("returns chats for personal key owner without account_id param", async () => { + it("returns chats projected to the new shape for personal key", async () => { const accountId = "123e4567-e89b-12d3-a456-426614174000"; - const mockChats = [ + const artistId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "test-token", + }); + vi.mocked(selectChatsWithSessions).mockResolvedValue([ + { + id: "chat-1", + title: "Hello", + session_id: "sess-1", + updated_at: "2024-01-02T00:00:00Z", + created_at: "2024-01-01T00:00:00Z", + active_stream_id: null, + last_assistant_message_at: null, + model_id: null, + session: sessionEmbed(accountId, artistId), + }, + ]); + + const request = createMockRequest("http://localhost/api/chats"); + const response = await getChatsHandler(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.status).toBe("success"); + expect(json.chats).toEqual([ { id: "chat-1", - account_id: accountId, - artist_id: null, - topic: "Chat 1", - updated_at: "2024-01-01T00:00:00Z", + title: "Hello", + accountId, + sessionId: "sess-1", + updatedAt: "2024-01-02T00:00:00Z", + artistId, }, - ]; + ]); + expect(selectChatsWithSessions).toHaveBeenCalledWith({ + accountIds: [accountId], + artistAccountId: undefined, + }); + }); + it("surfaces artistId as null when session has no artist", async () => { + const accountId = "123e4567-e89b-12d3-a456-426614174000"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId, - orgId: null, // Personal key + orgId: null, authToken: "test-token", }); - vi.mocked(selectRooms).mockResolvedValue(mockChats); + vi.mocked(selectChatsWithSessions).mockResolvedValue([ + { + id: "chat-1", + title: "Hello", + session_id: "sess-1", + updated_at: "2024-01-02T00:00:00Z", + created_at: "2024-01-01T00:00:00Z", + active_stream_id: null, + last_assistant_message_at: null, + model_id: null, + session: sessionEmbed(accountId, null), + }, + ]); const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); const json = await response.json(); - expect(response.status).toBe(200); - expect(json.status).toBe("success"); - expect(json.chats).toEqual(mockChats); - expect(selectRooms).toHaveBeenCalledWith({ - account_ids: [accountId], - artist_id: undefined, - }); + expect(json.chats[0].artistId).toBeNull(); }); it("returns 403 when personal key tries to filter by account_id", async () => { @@ -106,7 +146,7 @@ describe("getChatsHandler", () => { vi.mocked(validateAuthContext).mockResolvedValue({ accountId, - orgId: null, // Personal key + orgId: null, authToken: "test-token", }); vi.mocked(canAccessAccount).mockResolvedValue(false); @@ -117,128 +157,74 @@ describe("getChatsHandler", () => { expect(response.status).toBe(403); expect(json.status).toBe("error"); - expect(json.error).toBe("Access denied to specified account_id"); - expect(selectRooms).not.toHaveBeenCalled(); + expect(selectChatsWithSessions).not.toHaveBeenCalled(); }); }); - describe("org key behavior", () => { - it("returns chats for org key owner without account_id param", async () => { - const orgId = "123e4567-e89b-12d3-a456-426614174000"; - const mockChats = [ - { - id: "chat-1", - account_id: orgId, - artist_id: null, - topic: "Chat 1", - updated_at: "2024-01-01T00:00:00Z", - }, - ]; + describe("artist filter", () => { + it("passes artist_account_id through to the select", async () => { + const accountId = "123e4567-e89b-12d3-a456-426614174000"; + const artistAccountId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: orgId, - orgId, + accountId, + orgId: null, authToken: "test-token", }); - vi.mocked(selectRooms).mockResolvedValue(mockChats); + vi.mocked(selectChatsWithSessions).mockResolvedValue([]); - const request = createMockRequest("http://localhost/api/chats"); + const request = createMockRequest( + `http://localhost/api/chats?artist_account_id=${artistAccountId}`, + ); const response = await getChatsHandler(request); - const json = await response.json(); expect(response.status).toBe(200); - expect(json.status).toBe("success"); - expect(json.chats).toEqual(mockChats); - expect(selectRooms).toHaveBeenCalledWith({ - account_ids: [orgId], - artist_id: undefined, + expect(selectChatsWithSessions).toHaveBeenCalledWith({ + accountIds: [accountId], + artistAccountId, }); }); + }); - it("allows org key to filter by account_id for org member", async () => { - const orgId = "123e4567-e89b-12d3-a456-426614174000"; - const memberId = "223e4567-e89b-12d3-a456-426614174000"; - const mockChats = [ - { - id: "chat-1", - account_id: memberId, - artist_id: null, - topic: "Chat 1", - updated_at: "2024-01-01T00:00:00Z", - }, - ]; - + describe("admin behavior", () => { + it("passes undefined accountIds when caller is in Recoup org (membership-based)", async () => { + const adminAccountId = "admin-account-123"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: orgId, - orgId, + accountId: adminAccountId, + orgId: null, authToken: "test-token", }); - vi.mocked(canAccessAccount).mockResolvedValue(true); - vi.mocked(selectRooms).mockResolvedValue(mockChats); + vi.mocked(isRecoupAdmin).mockResolvedValue(true); + vi.mocked(selectChatsWithSessions).mockResolvedValue([]); - const request = createMockRequest(`http://localhost/api/chats?account_id=${memberId}`); + const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); - const json = await response.json(); expect(response.status).toBe(200); - expect(json.status).toBe("success"); - expect(selectRooms).toHaveBeenCalledWith({ - account_ids: [memberId], - artist_id: undefined, - }); - }); - - it("returns 403 when org key tries to access non-member account", async () => { - const orgId = "123e4567-e89b-12d3-a456-426614174000"; - const nonMemberId = "323e4567-e89b-12d3-a456-426614174000"; - - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: orgId, - orgId, - authToken: "test-token", + expect(selectChatsWithSessions).toHaveBeenCalledWith({ + accountIds: undefined, + artistAccountId: undefined, }); - vi.mocked(canAccessAccount).mockResolvedValue(false); - - const request = createMockRequest(`http://localhost/api/chats?account_id=${nonMemberId}`); - const response = await getChatsHandler(request); - const json = await response.json(); - - expect(response.status).toBe(403); - expect(json.status).toBe("error"); - expect(json.error).toBe("Access denied to specified account_id"); - expect(selectRooms).not.toHaveBeenCalled(); }); - }); - - describe("Recoup admin key behavior", () => { - it("returns chats for admin account without account_id param", async () => { - const recoupOrgId = "recoup-org-id"; - const mockChats = [ - { - id: "chat-1", - account_id: recoupOrgId, - artist_id: null, - topic: "Chat 1", - updated_at: "2024-01-01T00:00:00Z", - }, - ]; + it("scopes admin to the target account when account_id is provided", async () => { + const adminAccountId = "admin-account-123"; + const target = "323e4567-e89b-12d3-a456-426614174000"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: recoupOrgId, - orgId: recoupOrgId, + accountId: adminAccountId, + orgId: null, authToken: "test-token", }); - vi.mocked(selectRooms).mockResolvedValue(mockChats); + vi.mocked(canAccessAccount).mockResolvedValue(true); + vi.mocked(selectChatsWithSessions).mockResolvedValue([]); - const request = createMockRequest("http://localhost/api/chats"); + const request = createMockRequest(`http://localhost/api/chats?account_id=${target}`); const response = await getChatsHandler(request); - const json = await response.json(); expect(response.status).toBe(200); - expect(json.status).toBe("success"); - expect(selectRooms).toHaveBeenCalledWith({ - account_ids: [recoupOrgId], - artist_id: undefined, + expect(selectChatsWithSessions).toHaveBeenCalledWith({ + accountIds: [target], + artistAccountId: undefined, }); }); }); @@ -257,7 +243,7 @@ describe("getChatsHandler", () => { expect(response.status).toBe(400); expect(json.status).toBe("error"); - expect(selectRooms).not.toHaveBeenCalled(); + expect(selectChatsWithSessions).not.toHaveBeenCalled(); }); it("returns 400 when artist_account_id is invalid UUID", async () => { @@ -273,45 +259,12 @@ describe("getChatsHandler", () => { expect(response.status).toBe(400); expect(json.status).toBe("error"); - expect(selectRooms).not.toHaveBeenCalled(); + expect(selectChatsWithSessions).not.toHaveBeenCalled(); }); }); - describe("successful responses", () => { - it("returns filtered chats when artist_account_id is provided", async () => { - const accountId = "123e4567-e89b-12d3-a456-426614174000"; - const artistId = "223e4567-e89b-12d3-a456-426614174001"; - const mockChats = [ - { - id: "chat-1", - account_id: accountId, - artist_id: artistId, - topic: "Artist Chat", - updated_at: "2024-01-01T00:00:00Z", - }, - ]; - - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId, - orgId: null, // Personal key - authToken: "test-token", - }); - vi.mocked(selectRooms).mockResolvedValue(mockChats); - - const request = createMockRequest(`http://localhost/api/chats?artist_account_id=${artistId}`); - const response = await getChatsHandler(request); - const json = await response.json(); - - expect(response.status).toBe(200); - expect(json.status).toBe("success"); - expect(json.chats).toEqual(mockChats); - expect(selectRooms).toHaveBeenCalledWith({ - account_ids: [accountId], - artist_id: artistId, - }); - }); - - it("returns empty array when no chats found", async () => { + describe("error handling", () => { + it("returns 500 when selectChatsWithSessions returns null", async () => { const accountId = "123e4567-e89b-12d3-a456-426614174000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -319,20 +272,18 @@ describe("getChatsHandler", () => { orgId: null, authToken: "test-token", }); - vi.mocked(selectRooms).mockResolvedValue([]); + vi.mocked(selectChatsWithSessions).mockResolvedValue(null); const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); const json = await response.json(); - expect(response.status).toBe(200); - expect(json.status).toBe("success"); - expect(json.chats).toEqual([]); + expect(response.status).toBe(500); + expect(json.status).toBe("error"); + expect(json.error).toBe("Failed to retrieve chats"); }); - }); - describe("error handling", () => { - it("returns 500 when selectRooms returns null", async () => { + it("returns 500 with a generic message when an exception is thrown (no leak of raw message)", async () => { const accountId = "123e4567-e89b-12d3-a456-426614174000"; vi.mocked(validateAuthContext).mockResolvedValue({ @@ -340,7 +291,7 @@ describe("getChatsHandler", () => { orgId: null, authToken: "test-token", }); - vi.mocked(selectRooms).mockResolvedValue(null); + vi.mocked(selectChatsWithSessions).mockRejectedValue(new Error("Database error")); const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); @@ -348,26 +299,60 @@ describe("getChatsHandler", () => { expect(response.status).toBe(500); expect(json.status).toBe("error"); - expect(json.error).toBe("Failed to retrieve chats"); + expect(json.error).toBe("Internal server error"); + // Raw exception message must not leak into the response body. + expect(json.error).not.toContain("Database"); }); + }); - it("returns 500 when an exception is thrown", async () => { + describe("response projection", () => { + it("skips rows whose session embed is missing", async () => { const accountId = "123e4567-e89b-12d3-a456-426614174000"; - + const artistId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "test-token", }); - vi.mocked(selectRooms).mockRejectedValue(new Error("Database error")); + vi.mocked(selectChatsWithSessions).mockResolvedValue([ + { + id: "chat-keep", + title: "Keep", + session_id: "sess-1", + updated_at: "2024-01-02T00:00:00Z", + created_at: "2024-01-01T00:00:00Z", + active_stream_id: null, + last_assistant_message_at: null, + model_id: null, + session: sessionEmbed(accountId, artistId), + }, + { + id: "chat-orphan", + title: "Orphan", + session_id: "sess-2", + updated_at: "2024-01-03T00:00:00Z", + created_at: "2024-01-01T00:00:00Z", + active_stream_id: null, + last_assistant_message_at: null, + model_id: null, + session: null, + }, + ]); const request = createMockRequest("http://localhost/api/chats"); const response = await getChatsHandler(request); const json = await response.json(); - expect(response.status).toBe(500); - expect(json.status).toBe("error"); - expect(json.error).toBe("Database error"); + expect(json.chats).toEqual([ + { + id: "chat-keep", + title: "Keep", + accountId, + sessionId: "sess-1", + updatedAt: "2024-01-02T00:00:00Z", + artistId, + }, + ]); }); }); }); diff --git a/lib/chats/__tests__/validateGetChatsRequest.test.ts b/lib/chats/__tests__/validateGetChatsRequest.test.ts index b28f253c1..fc0d1a2f3 100644 --- a/lib/chats/__tests__/validateGetChatsRequest.test.ts +++ b/lib/chats/__tests__/validateGetChatsRequest.test.ts @@ -4,8 +4,8 @@ import { validateGetChatsRequest } from "../validateGetChatsRequest"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { isRecoupAdmin } from "@/lib/organizations/isRecoupAdmin"; -// Mock dependencies vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn(), })); @@ -14,24 +14,22 @@ vi.mock("@/lib/organizations/canAccessAccount", () => ({ canAccessAccount: vi.fn(), })); -vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ - getAccountOrganizations: vi.fn(), +vi.mock("@/lib/organizations/isRecoupAdmin", () => ({ + isRecoupAdmin: vi.fn(), })); vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => new Headers()), })); -vi.mock("@/lib/const", () => ({ - RECOUP_ORG_ID: "recoup-org-id", -})); - describe("validateGetChatsRequest", () => { beforeEach(() => { vi.clearAllMocks(); + // Default: caller is NOT a Recoup admin. Admin tests override. + vi.mocked(isRecoupAdmin).mockResolvedValue(false); }); - it("should return error if auth fails", async () => { + it("returns auth error if validateAuthContext fails", async () => { vi.mocked(validateAuthContext).mockResolvedValue( NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), ); @@ -40,226 +38,141 @@ describe("validateGetChatsRequest", () => { const result = await validateGetChatsRequest(request); expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(401); + expect((result as NextResponse).status).toBe(401); }); - it("should return single account ID for personal key", async () => { - const mockAccountId = "personal-account-123"; + it("scopes personal Bearer to the caller's account", async () => { + const accountId = "personal-account-123"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: mockAccountId, - orgId: null, // Personal key + accountId, + orgId: null, authToken: "test-token", }); - const request = new NextRequest("http://localhost/api/chats", { - headers: { "x-api-key": "test-api-key" }, - }); + const request = new NextRequest("http://localhost/api/chats"); const result = await validateGetChatsRequest(request); expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - account_ids: [mockAccountId], - artist_id: undefined, - }); + expect(result).toEqual({ accountIds: [accountId], artistAccountId: undefined }); }); - it("should return account_ids for org key", async () => { - const mockOrgId = "org-123"; + it("scopes org key to the caller's org account (target unspecified)", async () => { + const orgId = "org-123"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: mockOrgId, - orgId: mockOrgId, + accountId: orgId, + orgId, authToken: "test-token", }); - const request = new NextRequest("http://localhost/api/chats", { - headers: { "x-api-key": "test-api-key" }, - }); + const request = new NextRequest("http://localhost/api/chats"); const result = await validateGetChatsRequest(request); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - account_ids: [mockOrgId], - artist_id: undefined, - }); + expect(result).toEqual({ accountIds: [orgId], artistAccountId: undefined }); }); - it("should return account_ids for Recoup admin key", async () => { - const recoupOrgId = "recoup-org-id"; + it("returns undefined accountIds for Recoup admin (membership-based check)", async () => { + // Bearer-authed caller whose accountId is a member of RECOUP_ORG. + // orgId stays null (Bearer never sets it) — the admin check goes + // through isRecoupAdmin → account_organization_ids instead. + const adminAccountId = "admin-account-123"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: recoupOrgId, - orgId: recoupOrgId, + accountId: adminAccountId, + orgId: null, authToken: "test-token", }); + vi.mocked(isRecoupAdmin).mockResolvedValue(true); - const request = new NextRequest("http://localhost/api/chats", { - headers: { "x-api-key": "recoup-admin-key" }, - }); + const request = new NextRequest("http://localhost/api/chats"); const result = await validateGetChatsRequest(request); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - account_ids: [recoupOrgId], - artist_id: undefined, - }); + expect(isRecoupAdmin).toHaveBeenCalledWith(adminAccountId); + expect(result).toEqual({ accountIds: undefined, artistAccountId: undefined }); }); - it("should parse artist_account_id query parameter correctly", async () => { - const mockAccountId = "account-123"; - const artistId = "a1111111-1111-4111-8111-111111111111"; + it("scopes admin to a specific account when account_id is supplied", async () => { + const adminAccountId = "admin-account-123"; + const target = "a1111111-1111-4111-8111-111111111111"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: mockAccountId, + accountId: adminAccountId, orgId: null, authToken: "test-token", }); + vi.mocked(canAccessAccount).mockResolvedValue(true); - const request = new NextRequest(`http://localhost/api/chats?artist_account_id=${artistId}`, { - headers: { "x-api-key": "test-api-key" }, - }); + const request = new NextRequest(`http://localhost/api/chats?account_id=${target}`); const result = await validateGetChatsRequest(request); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - account_ids: [mockAccountId], - artist_id: artistId, + expect(canAccessAccount).toHaveBeenCalledWith({ + targetAccountId: target, + currentAccountId: adminAccountId, }); + expect(result).toEqual({ accountIds: [target], artistAccountId: undefined }); }); - it("should reject invalid artist_account_id query parameter", async () => { + it("rejects personal key trying to filter by account_id they can't access", async () => { + const accountId = "a1111111-1111-4111-8111-111111111111"; + const other = "b2222222-2222-4222-8222-222222222222"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: "account-123", + accountId, orgId: null, authToken: "test-token", }); + vi.mocked(canAccessAccount).mockResolvedValue(false); - const request = new NextRequest("http://localhost/api/chats?artist_account_id=invalid", { - headers: { "x-api-key": "test-api-key" }, - }); + const request = new NextRequest(`http://localhost/api/chats?account_id=${other}`); const result = await validateGetChatsRequest(request); expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(400); + expect((result as NextResponse).status).toBe(403); }); - it("should reject personal key trying to filter by account_id", async () => { - const mockAccountId = "a1111111-1111-4111-8111-111111111111"; - const otherAccountId = "b2222222-2222-4222-8222-222222222222"; + it("passes artist_account_id through to the scope", async () => { + const accountId = "personal-account-123"; + const artistAccountId = "a1111111-1111-4111-8111-111111111111"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: mockAccountId, - orgId: null, // Personal key + accountId, + orgId: null, authToken: "test-token", }); - const request = new NextRequest(`http://localhost/api/chats?account_id=${otherAccountId}`, { - headers: { "x-api-key": "test-api-key" }, - }); + const request = new NextRequest( + `http://localhost/api/chats?artist_account_id=${artistAccountId}`, + ); const result = await validateGetChatsRequest(request); - expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(403); + expect(result).toEqual({ accountIds: [accountId], artistAccountId }); }); - it("should allow org key to filter by account_id within org", async () => { - const mockOrgId = "c3333333-3333-4333-8333-333333333333"; - const targetAccountId = "d4444444-4444-4444-8444-444444444444"; + it("composes account_id + artist_account_id when both supplied", async () => { + const callerAccountId = "caller-account-123"; + const target = "a1111111-1111-4111-8111-111111111111"; + const artistAccountId = "c3333333-3333-4333-8333-333333333333"; vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: mockOrgId, - orgId: mockOrgId, + accountId: callerAccountId, + orgId: null, authToken: "test-token", }); vi.mocked(canAccessAccount).mockResolvedValue(true); - const request = new NextRequest(`http://localhost/api/chats?account_id=${targetAccountId}`, { - headers: { "x-api-key": "test-api-key" }, - }); + const request = new NextRequest( + `http://localhost/api/chats?account_id=${target}&artist_account_id=${artistAccountId}`, + ); const result = await validateGetChatsRequest(request); - expect(canAccessAccount).toHaveBeenCalledWith({ - targetAccountId, - currentAccountId: mockOrgId, - }); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - account_ids: [targetAccountId], - artist_id: undefined, - }); + expect(result).toEqual({ accountIds: [target], artistAccountId }); }); - it("should reject org key filtering by account_id not in org", async () => { - const mockOrgId = "f6666666-6666-4666-8666-666666666666"; - const notInOrgId = "b8888888-8888-4888-8888-888888888888"; + it("returns 400 for invalid artist_account_id UUID", async () => { vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: mockOrgId, - orgId: mockOrgId, + accountId: "account-123", + orgId: null, authToken: "test-token", }); - vi.mocked(canAccessAccount).mockResolvedValue(false); - const request = new NextRequest(`http://localhost/api/chats?account_id=${notInOrgId}`, { - headers: { "x-api-key": "test-api-key" }, - }); + const request = new NextRequest("http://localhost/api/chats?artist_account_id=invalid"); const result = await validateGetChatsRequest(request); - expect(canAccessAccount).toHaveBeenCalledWith({ - targetAccountId: notInOrgId, - currentAccountId: mockOrgId, - }); expect(result).toBeInstanceOf(NextResponse); - const response = result as NextResponse; - expect(response.status).toBe(403); - }); - - it("should allow Recoup admin to filter by any account_id", async () => { - const recoupOrgId = "recoup-org-id"; - const anyAccountId = "a1111111-1111-4111-8111-111111111111"; - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: recoupOrgId, - orgId: recoupOrgId, - authToken: "test-token", - }); - vi.mocked(canAccessAccount).mockResolvedValue(true); // Admin always has access - - const request = new NextRequest(`http://localhost/api/chats?account_id=${anyAccountId}`, { - headers: { "x-api-key": "recoup-admin-key" }, - }); - const result = await validateGetChatsRequest(request); - - expect(canAccessAccount).toHaveBeenCalledWith({ - targetAccountId: anyAccountId, - currentAccountId: recoupOrgId, - }); - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - account_ids: [anyAccountId], - artist_id: undefined, - }); - }); - - it("should allow combining account_id and artist_account_id filters", async () => { - const mockOrgId = "c3333333-3333-4333-8333-333333333333"; - const targetAccountId = "d4444444-4444-4444-8444-444444444444"; - const artistId = "e5555555-5555-4555-8555-555555555555"; - vi.mocked(validateAuthContext).mockResolvedValue({ - accountId: mockOrgId, - orgId: mockOrgId, - authToken: "test-token", - }); - vi.mocked(canAccessAccount).mockResolvedValue(true); - - const request = new NextRequest( - `http://localhost/api/chats?account_id=${targetAccountId}&artist_account_id=${artistId}`, - { - headers: { "x-api-key": "test-api-key" }, - }, - ); - const result = await validateGetChatsRequest(request); - - expect(result).not.toBeInstanceOf(NextResponse); - expect(result).toEqual({ - account_ids: [targetAccountId], - artist_id: artistId, - }); + expect((result as NextResponse).status).toBe(400); }); }); diff --git a/lib/chats/getChatsHandler.ts b/lib/chats/getChatsHandler.ts index da180ddfa..0f7b254a7 100644 --- a/lib/chats/getChatsHandler.ts +++ b/lib/chats/getChatsHandler.ts @@ -1,19 +1,28 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateGetChatsRequest } from "@/lib/chats/validateGetChatsRequest"; -import { selectRooms } from "@/lib/supabase/rooms/selectRooms"; +import { selectChatsWithSessions } from "@/lib/supabase/chats/selectChatsWithSessions"; /** * Handler for retrieving chats. + * * Requires authentication via x-api-key header or Authorization bearer token. * - * For personal keys: Returns array of chats for the account (if exists). - * For org keys: Returns array of chats for accounts in the organization. - * For Recoup admin key: Returns array of ALL chat records. + * Returns chats joined with their owning session so the response carries the + * `sessionId`, owning `accountId`, and `artistId` per row, enabling clients + * to render canonical `/sessions/{sid}/chats/{cid}` URLs and filter by + * artist context. + * + * Scope: + * - Personal/org key: chats belonging to the caller's account. + * - Personal/org key with `account_id`: chats for that account (when the + * caller can access it). + * - Recoup admin: all chats (or a specific account when `account_id` is set). * * Optional query parameters: - * - account_id: Filter to a specific account (validated against org membership) - * - artist_account_id: Filter to chats for a specific artist (UUID) + * - `account_id`: target account override (validated against access). + * - `artist_account_id`: scope chats to those whose owning session has the + * given artist context (`sessions.artist_id`). Composes with `account_id`. * * @param request - The request object containing query parameters * @returns A NextResponse with chats data @@ -25,43 +34,44 @@ export async function getChatsHandler(request: NextRequest): Promise { + if (!row.session) return []; + return [ + { + id: row.id, + title: row.title, + accountId: row.session.account_id, + sessionId: row.session_id, + updatedAt: row.updated_at, + artistId: row.session.artist_id, + }, + ]; + }); + return NextResponse.json( - { - status: "success", - chats, - }, - { - status: 200, - headers: getCorsHeaders(), - }, + { status: "success", chats }, + { status: 200, headers: getCorsHeaders() }, ); } catch (error) { + // Never leak raw exception messages on 500 — they can expose internal + // structure or DB errors. Caller gets a fixed string; the real cause + // stays in server logs. console.error("[ERROR] getChatsHandler:", error); return NextResponse.json( - { - status: "error", - error: error instanceof Error ? error.message : "Internal server error", - }, - { - status: 500, - headers: getCorsHeaders(), - }, + { status: "error", error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, ); } } diff --git a/lib/chats/validateGetChatsRequest.ts b/lib/chats/validateGetChatsRequest.ts index e32fa127e..522249aa3 100644 --- a/lib/chats/validateGetChatsRequest.ts +++ b/lib/chats/validateGetChatsRequest.ts @@ -1,79 +1,87 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +import { z } from "zod"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import type { SelectRoomsParams } from "@/lib/supabase/rooms/selectRooms"; -import { buildGetChatsParams } from "./buildGetChatsParams"; -import { z } from "zod"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { isRecoupAdmin } from "@/lib/organizations/isRecoupAdmin"; const getChatsQuerySchema = z.object({ account_id: z.string().uuid("account_id must be a valid UUID").optional(), artist_account_id: z.string().uuid("artist_account_id must be a valid UUID").optional(), }); +/** + * Validated scope params for the chats listing. + * `accountIds === undefined` signals admin scope (no account filter). + * `artistAccountId` is a separate, composable filter on `sessions.artist_id`. + */ +export interface GetChatsScope { + accountIds?: string[]; + artistAccountId?: string; +} + /** * Validates GET /api/chats request. - * Handles authentication via x-api-key or Authorization bearer token. * - * For personal keys: Returns accountIds with the key owner's account - * For org keys: Returns orgId for filtering by org membership in database - * For Recoup admin key: Returns empty params to indicate ALL chat records + * Auth via x-api-key or Authorization Bearer token. Returns the account scope: + * - Recoup admin (no target) → `{ accountIds: undefined }` (all chats). + * - Recoup admin with `account_id` → `{ accountIds: [target] }`. + * - Org/personal key with `account_id` → `{ accountIds: [target] }` if + * `canAccessAccount` allows, else 403. + * - Personal/org key without target → `{ accountIds: [callerAccountId] }`. * - * Query parameters: - * - account_id: Filter to a specific account (validated against org membership) - * - artist_account_id: Filter by artist ID + * Recoup-admin status is derived from `account_organization_ids` membership + * (not `auth.orgId`) because Bearer-authed callers never set `auth.orgId` — + * the previous `orgId === RECOUP_ORG_ID` branch was unreachable for them. * - * @param request - The NextRequest object - * @returns A NextResponse with an error if validation fails, or SelectRoomsParams + * `artistAccountId` (from `?artist_account_id=`) passes through verbatim + * and composes with the account scope at the SQL layer. */ export async function validateGetChatsRequest( request: NextRequest, -): Promise { - // Parse query parameters first +): Promise { const { searchParams } = new URL(request.url); - const queryParams = { + const queryResult = getChatsQuerySchema.safeParse({ account_id: searchParams.get("account_id") ?? undefined, artist_account_id: searchParams.get("artist_account_id") ?? undefined, - }; - - const queryResult = getChatsQuerySchema.safeParse(queryParams); + }); if (!queryResult.success) { const firstError = queryResult.error.issues[0]; return NextResponse.json( - { - status: "error", - error: firstError.message, - }, + { status: "error", error: firstError.message }, { status: 400, headers: getCorsHeaders() }, ); } - const { account_id: target_account_id, artist_account_id: artist_id } = queryResult.data; + const { account_id: targetAccountId, artist_account_id: artistAccountId } = queryResult.data; - // Use validateAuthContext for authentication const authResult = await validateAuthContext(request); if (authResult instanceof NextResponse) { return authResult; } + const { accountId } = authResult; - const { accountId: account_id } = authResult; - - // Use shared function to build params - const { params, error } = await buildGetChatsParams({ - account_id, - target_account_id, - artist_id, - }); + if (targetAccountId) { + const hasAccess = await canAccessAccount({ + targetAccountId, + currentAccountId: accountId, + }); + if (!hasAccess) { + return NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403, headers: getCorsHeaders() }, + ); + } + return { accountIds: [targetAccountId], artistAccountId }; + } - if (error) { - return NextResponse.json( - { - status: "error", - error, - }, - { status: 403, headers: getCorsHeaders() }, - ); + // Recoup admin → all chats. Membership-based so Bearer-authed admins + // get the same scope as x-api-key admins (auth.orgId is null for + // Bearer regardless of the caller's org memberships). + if (await isRecoupAdmin(accountId)) { + return { accountIds: undefined, artistAccountId }; } - return params; + return { accountIds: [accountId], artistAccountId }; } diff --git a/lib/mcp/tools/chats/__tests__/registerGetChatsTool.test.ts b/lib/mcp/tools/chats/__tests__/registerGetChatsTool.test.ts index 4cceb5de3..f2c7e0fc9 100644 --- a/lib/mcp/tools/chats/__tests__/registerGetChatsTool.test.ts +++ b/lib/mcp/tools/chats/__tests__/registerGetChatsTool.test.ts @@ -5,27 +5,31 @@ import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sd import { registerGetChatsTool } from "../registerGetChatsTool"; -const mockSelectRooms = vi.fn(); +const mockSelectChatsWithSessions = vi.fn(); const mockCanAccessAccount = vi.fn(); +const mockIsRecoupAdmin = vi.fn(); -vi.mock("@/lib/supabase/rooms/selectRooms", () => ({ - selectRooms: (...args: unknown[]) => mockSelectRooms(...args), +vi.mock("@/lib/supabase/chats/selectChatsWithSessions", () => ({ + selectChatsWithSessions: (...args: unknown[]) => mockSelectChatsWithSessions(...args), })); vi.mock("@/lib/organizations/canAccessAccount", () => ({ canAccessAccount: (...args: unknown[]) => mockCanAccessAccount(...args), })); +vi.mock("@/lib/organizations/isRecoupAdmin", () => ({ + isRecoupAdmin: (...args: unknown[]) => mockIsRecoupAdmin(...args), +})); + type ServerRequestHandlerExtra = RequestHandlerExtra; /** * Creates a mock extra object with optional authInfo. - * - * @param authInfo - Optional auth info object containing account ID. - * @param authInfo.accountId - The account ID for authentication. - * @returns A mock ServerRequestHandlerExtra object. */ -function createMockExtra(authInfo?: { accountId?: string }): ServerRequestHandlerExtra { +function createMockExtra(authInfo?: { + accountId?: string; + orgId?: string | null; +}): ServerRequestHandlerExtra { return { authInfo: authInfo ? { @@ -34,6 +38,7 @@ function createMockExtra(authInfo?: { accountId?: string }): ServerRequestHandle clientId: authInfo.accountId, extra: { accountId: authInfo.accountId, + orgId: authInfo.orgId ?? null, }, } : undefined, @@ -46,9 +51,11 @@ describe("registerGetChatsTool", () => { beforeEach(() => { vi.clearAllMocks(); + // Default: caller is NOT a Recoup admin. Admin tests override. + mockIsRecoupAdmin.mockResolvedValue(false); mockServer = { - registerTool: vi.fn((name, config, handler) => { + registerTool: vi.fn((_name, _config, handler) => { registeredHandler = handler; }), } as unknown as McpServer; @@ -60,20 +67,20 @@ describe("registerGetChatsTool", () => { expect(mockServer.registerTool).toHaveBeenCalledWith( "get_chats", expect.objectContaining({ - description: "Get chat conversations for accounts.", + description: expect.stringContaining("chat"), }), expect.any(Function), ); }); it("returns empty chats array when no records exist", async () => { - mockSelectRooms.mockResolvedValue([]); + mockSelectChatsWithSessions.mockResolvedValue([]); const result = await registeredHandler({}, createMockExtra({ accountId: "account-123" })); - expect(mockSelectRooms).toHaveBeenCalledWith({ - account_ids: ["account-123"], - artist_id: undefined, + expect(mockSelectChatsWithSessions).toHaveBeenCalledWith({ + accountIds: ["account-123"], + artistAccountId: undefined, }); expect(result).toEqual({ content: [ @@ -85,49 +92,53 @@ describe("registerGetChatsTool", () => { }); }); - it("returns chats array with records when they exist", async () => { - mockSelectRooms.mockResolvedValue([ + it("returns chats projected to the new wire shape including artistId", async () => { + mockSelectChatsWithSessions.mockResolvedValue([ { id: "chat-456", - account_id: "account-123", - artist_id: null, - topic: "Test Chat", + title: "Test Chat", + session_id: "sess-1", updated_at: "2024-01-01T00:00:00Z", + created_at: "2024-01-01T00:00:00Z", + active_stream_id: null, + last_assistant_message_at: null, + model_id: null, + session: { id: "sess-1", account_id: "account-123", artist_id: "artist-789" }, }, ]); const result = await registeredHandler({}, createMockExtra({ accountId: "account-123" })); - expect(result).toEqual({ - content: [ - { - type: "text", - text: expect.stringContaining('"chats":['), - }, - ], - }); - expect(result).toEqual({ - content: [ - { - type: "text", - text: expect.stringContaining('"id":"chat-456"'), - }, - ], - }); + const textNode = (result as { content: { type: string; text: string }[] }).content[0]; + expect(textNode.text).toContain('"sessionId":"sess-1"'); + expect(textNode.text).toContain('"accountId":"account-123"'); + expect(textNode.text).toContain('"artistId":"artist-789"'); }); - it("allows account_id override with access", async () => { - mockCanAccessAccount.mockResolvedValue(true); - mockSelectRooms.mockResolvedValue([ + it("surfaces artistId as null when session has no artist", async () => { + mockSelectChatsWithSessions.mockResolvedValue([ { id: "chat-456", - account_id: "target-account-789", - artist_id: null, - topic: "Test Chat", + title: "Test Chat", + session_id: "sess-1", updated_at: "2024-01-01T00:00:00Z", + created_at: "2024-01-01T00:00:00Z", + active_stream_id: null, + last_assistant_message_at: null, + model_id: null, + session: { id: "sess-1", account_id: "account-123", artist_id: null }, }, ]); + const result = await registeredHandler({}, createMockExtra({ accountId: "account-123" })); + const textNode = (result as { content: { type: string; text: string }[] }).content[0]; + expect(textNode.text).toContain('"artistId":null'); + }); + + it("allows account_id override with access", async () => { + mockCanAccessAccount.mockResolvedValue(true); + mockSelectChatsWithSessions.mockResolvedValue([]); + await registeredHandler( { account_id: "target-account-789" }, createMockExtra({ accountId: "org-account-id" }), @@ -137,9 +148,9 @@ describe("registerGetChatsTool", () => { targetAccountId: "target-account-789", currentAccountId: "org-account-id", }); - expect(mockSelectRooms).toHaveBeenCalledWith({ - account_ids: ["target-account-789"], - artist_id: undefined, + expect(mockSelectChatsWithSessions).toHaveBeenCalledWith({ + accountIds: ["target-account-789"], + artistAccountId: undefined, }); }); @@ -174,25 +185,35 @@ describe("registerGetChatsTool", () => { }); }); - it("filters by artist_account_id when provided", async () => { - const artistChats = [ - { id: "chat-1", account_id: "account-123", artist_id: "artist-456", topic: "Artist Chat" }, - ]; - mockSelectRooms.mockResolvedValue(artistChats); + it("passes artist_account_id through to the select", async () => { + mockSelectChatsWithSessions.mockResolvedValue([]); await registeredHandler( { artist_account_id: "artist-456" }, createMockExtra({ accountId: "account-123" }), ); - expect(mockSelectRooms).toHaveBeenCalledWith({ - account_ids: ["account-123"], - artist_id: "artist-456", + expect(mockSelectChatsWithSessions).toHaveBeenCalledWith({ + accountIds: ["account-123"], + artistAccountId: "artist-456", + }); + }); + + it("passes undefined accountIds for Recoup admin (membership-based)", async () => { + mockIsRecoupAdmin.mockResolvedValue(true); + mockSelectChatsWithSessions.mockResolvedValue([]); + + await registeredHandler({}, createMockExtra({ accountId: "admin-account-123" })); + + expect(mockIsRecoupAdmin).toHaveBeenCalledWith("admin-account-123"); + expect(mockSelectChatsWithSessions).toHaveBeenCalledWith({ + accountIds: undefined, + artistAccountId: undefined, }); }); - it("returns error when selectRooms fails", async () => { - mockSelectRooms.mockResolvedValue(null); + it("returns error when selectChatsWithSessions fails", async () => { + mockSelectChatsWithSessions.mockResolvedValue(null); const result = await registeredHandler({}, createMockExtra({ accountId: "account-123" })); diff --git a/lib/mcp/tools/chats/registerGetChatsTool.ts b/lib/mcp/tools/chats/registerGetChatsTool.ts index 7f999b4eb..d667964ba 100644 --- a/lib/mcp/tools/chats/registerGetChatsTool.ts +++ b/lib/mcp/tools/chats/registerGetChatsTool.ts @@ -3,25 +3,37 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto import type { ServerRequest, ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import type { McpAuthInfo } from "@/lib/mcp/verifyApiKey"; -import { selectRooms } from "@/lib/supabase/rooms/selectRooms"; import { getToolResultSuccess } from "@/lib/mcp/getToolResultSuccess"; import { getToolResultError } from "@/lib/mcp/getToolResultError"; -import { buildGetChatsParams } from "@/lib/chats/buildGetChatsParams"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; +import { isRecoupAdmin } from "@/lib/organizations/isRecoupAdmin"; +import { selectChatsWithSessions } from "@/lib/supabase/chats/selectChatsWithSessions"; const getChatsSchema = z.object({ account_id: z.string().optional().describe("The account ID to filter chats for."), - artist_account_id: z.string().optional().describe("The artist account ID to filter chats for."), + artist_account_id: z + .string() + .optional() + .describe( + "Filter chats to those whose owning session is in the specified artist context " + + "(matches `sessions.artist_id`). Composes with `account_id`.", + ), }); export type GetChatsArgs = z.infer; /** * Registers the "get_chats" tool on the MCP server. - * Retrieves chat conversations (rooms) for accounts. * - * For personal keys: Returns chats for the key owner's account. - * For org keys: Returns chats for all accounts in the organization. - * For Recoup admin key: Returns ALL chat records. + * Returns chats joined with their owning session so each row carries + * `sessionId`, owning `accountId`, and `artistId`. Scope mirrors + * GET /api/chats: personal/org → caller's account; Recoup admin → all; + * or a specific account when `account_id` is supplied and the caller + * can access it. `artist_account_id` further scopes by artist context. + * + * Admin status is derived from `account_organization_ids` membership so + * that Bearer-authed callers get the same admin scope as x-api-key + * callers with an org-bound key. * * @param server - The MCP server instance to register the tool on. */ @@ -33,7 +45,7 @@ export function registerGetChatsTool(server: McpServer): void { inputSchema: getChatsSchema, }, async (args: GetChatsArgs, extra: RequestHandlerExtra) => { - const { account_id, artist_account_id } = args; + const { account_id: targetAccountId, artist_account_id: artistAccountId } = args; const authInfo = extra.authInfo as McpAuthInfo | undefined; const accountId = authInfo?.extra?.accountId; @@ -44,22 +56,39 @@ export function registerGetChatsTool(server: McpServer): void { ); } - const { params, error } = await buildGetChatsParams({ - account_id: accountId, - target_account_id: account_id, - artist_id: artist_account_id, - }); - - if (error) { - return getToolResultError(error); + let accountIds: string[] | undefined; + if (targetAccountId) { + const hasAccess = await canAccessAccount({ + targetAccountId, + currentAccountId: accountId, + }); + if (!hasAccess) { + return getToolResultError("Access denied to specified account_id"); + } + accountIds = [targetAccountId]; + } else { + accountIds = (await isRecoupAdmin(accountId)) ? undefined : [accountId]; } - const chats = await selectRooms(params); - - if (chats === null) { + const rows = await selectChatsWithSessions({ accountIds, artistAccountId }); + if (rows === null) { return getToolResultError("Failed to retrieve chats"); } + const chats = rows.flatMap(row => { + if (!row.session) return []; + return [ + { + id: row.id, + title: row.title, + accountId: row.session.account_id, + sessionId: row.session_id, + updatedAt: row.updated_at, + artistId: row.session.artist_id, + }, + ]; + }); + return getToolResultSuccess({ chats }); }, ); diff --git a/lib/organizations/__tests__/isRecoupAdmin.test.ts b/lib/organizations/__tests__/isRecoupAdmin.test.ts new file mode 100644 index 000000000..3a5d96df2 --- /dev/null +++ b/lib/organizations/__tests__/isRecoupAdmin.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isRecoupAdmin } from "@/lib/organizations/isRecoupAdmin"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +vi.mock("@/lib/supabase/account_organization_ids/getAccountOrganizations", () => ({ + getAccountOrganizations: vi.fn(), +})); + +vi.mock("@/lib/const", () => ({ + RECOUP_ORG_ID: "recoup-org-id", +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("isRecoupAdmin", () => { + it("returns false for an empty/missing accountId without querying", async () => { + expect(await isRecoupAdmin("")).toBe(false); + expect(getAccountOrganizations).not.toHaveBeenCalled(); + }); + + it("returns true when the account is a member of the Recoup org", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { organization_id: "some-other-org" } as never, + { organization_id: "recoup-org-id" } as never, + ]); + + expect(await isRecoupAdmin("acc-1")).toBe(true); + expect(getAccountOrganizations).toHaveBeenCalledWith({ accountId: "acc-1" }); + }); + + it("returns false when the account is in orgs but none is Recoup", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([ + { organization_id: "org-a" } as never, + { organization_id: "org-b" } as never, + ]); + + expect(await isRecoupAdmin("acc-1")).toBe(false); + }); + + it("returns false when the account is in no orgs", async () => { + vi.mocked(getAccountOrganizations).mockResolvedValue([]); + + expect(await isRecoupAdmin("acc-1")).toBe(false); + }); +}); diff --git a/lib/organizations/isRecoupAdmin.ts b/lib/organizations/isRecoupAdmin.ts new file mode 100644 index 000000000..af8cfb55a --- /dev/null +++ b/lib/organizations/isRecoupAdmin.ts @@ -0,0 +1,26 @@ +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; +import { RECOUP_ORG_ID } from "@/lib/const"; + +/** + * Returns `true` iff `accountId` is a member of the Recoup organization + * (`RECOUP_ORG_ID`). Used to grant admin-level scope (read/write across + * all accounts) to Recoup team members regardless of which auth method + * they used. + * + * Membership is read via `account_organization_ids` so this works for + * Bearer-authed callers too — `auth.orgId` is only populated by + * x-api-key org keys, leaving Bearer admins unrecognized if you check + * `auth.orgId === RECOUP_ORG_ID` alone. + * + * `canAccessAccount` deliberately inlines an equivalent check because + * it reuses the same org-list query for the subsequent shared-org check + * — calling this helper there would double the DB query for no benefit. + * + * @param accountId - The account to check. + * @returns `true` if the account is in the Recoup org; `false` otherwise. + */ +export async function isRecoupAdmin(accountId: string): Promise { + if (!accountId) return false; + const orgs = await getAccountOrganizations({ accountId }); + return orgs.some(m => m.organization_id === RECOUP_ORG_ID); +} diff --git a/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts b/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts new file mode 100644 index 000000000..2d9de9afe --- /dev/null +++ b/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectChatsWithSessions } from "@/lib/supabase/chats/selectChatsWithSessions"; + +const fromMock = vi.fn(); +const selectMock = vi.fn(); +const inMock = vi.fn(); +const eqMock = vi.fn(); +const orderMock = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: (...args: unknown[]) => fromMock(...args), + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + // Default chain: from().select().in().eq().order() -> resolves to { data, error } + const builder = { + select: selectMock, + in: inMock, + eq: eqMock, + order: orderMock, + }; + fromMock.mockReturnValue(builder); + selectMock.mockReturnValue(builder); + inMock.mockReturnValue(builder); + eqMock.mockReturnValue(builder); + orderMock.mockResolvedValue({ data: [], error: null }); +}); + +describe("selectChatsWithSessions", () => { + it("queries chats joined with sessions, filtered by account_ids, ordered by updated_at desc", async () => { + const rows = [ + { + id: "chat-1", + title: "Hello", + session_id: "sess-1", + updated_at: "2024-01-02T00:00:00Z", + session: { id: "sess-1", account_id: "acc-1" }, + }, + ]; + orderMock.mockResolvedValueOnce({ data: rows, error: null }); + + const result = await selectChatsWithSessions({ accountIds: ["acc-1", "acc-2"] }); + + expect(fromMock).toHaveBeenCalledWith("chats"); + expect(selectMock).toHaveBeenCalledTimes(1); + const selectArg = selectMock.mock.calls[0]?.[0]; + expect(typeof selectArg).toBe("string"); + expect(String(selectArg)).toContain("session:sessions!inner"); + expect(String(selectArg)).toContain("account_id"); + expect(String(selectArg)).toContain("artist_id"); + + expect(inMock).toHaveBeenCalledWith("session.account_id", ["acc-1", "acc-2"]); + expect(eqMock).not.toHaveBeenCalled(); + expect(orderMock).toHaveBeenCalledWith("updated_at", { ascending: false }); + expect(result).toEqual(rows); + }); + + it("composes accountIds + artistAccountId — both filters applied", async () => { + orderMock.mockResolvedValueOnce({ data: [], error: null }); + + await selectChatsWithSessions({ + accountIds: ["acc-1"], + artistAccountId: "artist-9", + }); + + expect(inMock).toHaveBeenCalledWith("session.account_id", ["acc-1"]); + expect(eqMock).toHaveBeenCalledWith("session.artist_id", "artist-9"); + }); + + it("applies the artist filter alone (admin scope + artist)", async () => { + orderMock.mockResolvedValueOnce({ data: [], error: null }); + + await selectChatsWithSessions({ artistAccountId: "artist-9" }); + + expect(inMock).not.toHaveBeenCalled(); + expect(eqMock).toHaveBeenCalledWith("session.artist_id", "artist-9"); + }); + + it("returns [] without calling .in() when accountIds is undefined (admin: all)", async () => { + const rows = [ + { + id: "chat-1", + title: "Admin", + session_id: "sess-1", + updated_at: "2024-01-02T00:00:00Z", + session: { id: "sess-1", account_id: "acc-1" }, + }, + ]; + orderMock.mockResolvedValueOnce({ data: rows, error: null }); + + const result = await selectChatsWithSessions({}); + + expect(inMock).not.toHaveBeenCalled(); + expect(orderMock).toHaveBeenCalledWith("updated_at", { ascending: false }); + expect(result).toEqual(rows); + }); + + it("short-circuits to [] when accountIds is an empty array", async () => { + const result = await selectChatsWithSessions({ accountIds: [] }); + + expect(result).toEqual([]); + expect(fromMock).not.toHaveBeenCalled(); + }); + + it("returns null on supabase error", async () => { + orderMock.mockResolvedValueOnce({ data: null, error: { message: "boom" } }); + + const result = await selectChatsWithSessions({ accountIds: ["acc-1"] }); + + expect(result).toBeNull(); + }); + + it("returns [] when supabase returns no data and no error", async () => { + orderMock.mockResolvedValueOnce({ data: null, error: null }); + + const result = await selectChatsWithSessions({ accountIds: ["acc-1"] }); + + expect(result).toEqual([]); + }); +}); diff --git a/lib/supabase/chats/selectChatsWithSessions.ts b/lib/supabase/chats/selectChatsWithSessions.ts new file mode 100644 index 000000000..932341ff0 --- /dev/null +++ b/lib/supabase/chats/selectChatsWithSessions.ts @@ -0,0 +1,57 @@ +import supabase from "@/lib/supabase/serverClient"; + +export interface SelectChatsWithSessionsParams { + /** + * Owning account IDs to filter by (via `sessions.account_id`). + * - `undefined` returns chats across all accounts (admin scope). + * - `[]` short-circuits to an empty result. + */ + accountIds?: string[]; + /** + * Optional artist account id to filter by (via `sessions.artist_id`). + * Composes with `accountIds`: scopes the result to chats whose owning + * session both belongs to one of the accountIds AND is in the given + * artist context. + */ + artistAccountId?: string; +} + +const SELECT = ` + *, + session:sessions!inner ( id, account_id, artist_id ) +` as const; + +/** + * Reads chats joined to their owning session, optionally scoped to a set of + * account IDs through `sessions.account_id` and/or an artist context through + * `sessions.artist_id`. Ordered by `chats.updated_at` descending so newest + * activity surfaces first. + * + * Returns `null` when Supabase reports an error so callers can distinguish a + * transient failure from an empty result. Row shape is inferred from the + * typed supabase-js client — both `chats.*` and the embedded `session` + * projection surface to callers without an explicit type alias. + */ +export async function selectChatsWithSessions(params: SelectChatsWithSessionsParams = {}) { + const { accountIds, artistAccountId } = params; + + if (accountIds !== undefined && accountIds.length === 0) { + return []; + } + + let query = supabase.from("chats").select(SELECT); + if (accountIds !== undefined) { + query = query.in("session.account_id", accountIds); + } + if (artistAccountId) { + query = query.eq("session.artist_id", artistAccountId); + } + const { data, error } = await query.order("updated_at", { ascending: false }); + + if (error) { + console.error("[selectChatsWithSessions] error:", error); + return null; + } + + return data ?? []; +} From a8c91b0eb28ebdbcff028f000caa484483a54c55 Mon Sep 17 00:00:00 2001 From: ahmednahima0-beep Date: Mon, 1 Jun 2026 09:21:15 +0700 Subject: [PATCH 14/15] feat(backfill): include artist_id in session migration (#629) * feat(backfill): include artist_id in session migration This update modifies the `migrateRoom` function to include the `artist_id` when inserting a session. Additionally, the test suite for `migrateRoom` has been enhanced to verify that the `artist_id` is correctly passed to the `insertSession` function, ensuring that the migration process captures this important data point for each room. All related tests have been updated to reflect these changes. * refactor(tests): streamline migrateRoom test assertions This commit refactors the `migrateRoom.test.ts` file by consolidating the assertion for the `insertSession` call to a single line, enhancing readability. The test now verifies that the `artist_id` is correctly passed to the `insertSession` function without altering the test's functionality. All related tests remain intact and pass successfully. --------- Co-authored-by: sweetman.eth --- scripts/backfill/__tests__/migrateRoom.test.ts | 12 ++++++++++++ scripts/backfill/migrateRoom.ts | 1 + 2 files changed, 13 insertions(+) diff --git a/scripts/backfill/__tests__/migrateRoom.test.ts b/scripts/backfill/__tests__/migrateRoom.test.ts index 40bd58eac..5da4d8c9e 100644 --- a/scripts/backfill/__tests__/migrateRoom.test.ts +++ b/scripts/backfill/__tests__/migrateRoom.test.ts @@ -77,6 +77,12 @@ describe("migrateRoom", () => { const stats = await migrateRoom(room, { dryRun: false }); expect(insertSession).toHaveBeenCalledTimes(1); + expect(insertSession).toHaveBeenCalledWith( + expect.objectContaining({ + account_id: "acc-1", + artist_id: null, + }), + ); expect(insertChat).toHaveBeenCalledTimes(1); // one batch call containing only the well-formed message expect(upsertChatMessages).toHaveBeenCalledTimes(1); @@ -102,6 +108,12 @@ describe("migrateRoom", () => { expect(insertChat).not.toHaveBeenCalled(); }); + it("passes room.artist_id into insertSession for straggler rooms", async () => { + await migrateRoom({ ...room, artist_id: "artist-1" }, { dryRun: false }); + + expect(insertSession).toHaveBeenCalledWith(expect.objectContaining({ artist_id: "artist-1" })); + }); + it("skips session/chat inserts when they already exist (idempotent re-run)", async () => { vi.mocked(selectSessions).mockResolvedValue([{ id: "s1" } as any]); diff --git a/scripts/backfill/migrateRoom.ts b/scripts/backfill/migrateRoom.ts index 79f8c192f..66fa13d8e 100644 --- a/scripts/backfill/migrateRoom.ts +++ b/scripts/backfill/migrateRoom.ts @@ -104,6 +104,7 @@ export async function migrateRoom( const session = await insertSession({ id: sessionId, account_id: room.account_id, + artist_id: room.artist_id, title, created_at: room.updated_at, updated_at: room.updated_at, From 0f8bdf19672a9bb0b705ba5bd159a3053a8f54e5 Mon Sep 17 00:00:00 2001 From: Arpit Gupta Date: Mon, 1 Jun 2026 22:34:00 +0530 Subject: [PATCH 15/15] feat(chats): exclude archived sessions from GET /api/chats (#630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archive is the soft-delete path for sessions (`PATCH /api/sessions/{id} { status: "archived" }` — already wired up to `stopSandboxOnArchive` and the lifecycle state machine). The chats list was still surfacing chats from archived sessions, which made archive feel like a no-op from the user's perspective and blocks the chat sidebar from using archive as the "delete a chat thread" action. - `selectChatsWithSessions`: add `.neq("session.status", "archived")` and project `session.status` so the filter has something to gate on. - `getChatsHandler` + `get_chats` MCP tool JSDoc: note the exclusion so callers know archived rows won't appear. - Tests: assert the filter is always applied (with or without account / artist scoping) and that the SELECT now projects `status`. Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: sweetman.eth --- lib/chats/getChatsHandler.ts | 4 +++- lib/mcp/tools/chats/registerGetChatsTool.ts | 1 + .../chats/__tests__/selectChatsWithSessions.test.ts | 13 ++++++++++++- lib/supabase/chats/selectChatsWithSessions.ts | 9 ++++++--- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/lib/chats/getChatsHandler.ts b/lib/chats/getChatsHandler.ts index 0f7b254a7..1a0c31cd8 100644 --- a/lib/chats/getChatsHandler.ts +++ b/lib/chats/getChatsHandler.ts @@ -11,7 +11,9 @@ import { selectChatsWithSessions } from "@/lib/supabase/chats/selectChatsWithSes * Returns chats joined with their owning session so the response carries the * `sessionId`, owning `accountId`, and `artistId` per row, enabling clients * to render canonical `/sessions/{sid}/chats/{cid}` URLs and filter by - * artist context. + * artist context. Chats whose owning session is archived + * (`sessions.status === "archived"`) are excluded — archive is the + * soft-delete path for sessions. * * Scope: * - Personal/org key: chats belonging to the caller's account. diff --git a/lib/mcp/tools/chats/registerGetChatsTool.ts b/lib/mcp/tools/chats/registerGetChatsTool.ts index d667964ba..0230d5c86 100644 --- a/lib/mcp/tools/chats/registerGetChatsTool.ts +++ b/lib/mcp/tools/chats/registerGetChatsTool.ts @@ -30,6 +30,7 @@ export type GetChatsArgs = z.infer; * GET /api/chats: personal/org → caller's account; Recoup admin → all; * or a specific account when `account_id` is supplied and the caller * can access it. `artist_account_id` further scopes by artist context. + * Chats whose owning session is archived are excluded. * * Admin status is derived from `account_organization_ids` membership so * that Bearer-authed callers get the same admin scope as x-api-key diff --git a/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts b/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts index 2d9de9afe..0c88d4115 100644 --- a/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts +++ b/lib/supabase/chats/__tests__/selectChatsWithSessions.test.ts @@ -3,6 +3,7 @@ import { selectChatsWithSessions } from "@/lib/supabase/chats/selectChatsWithSes const fromMock = vi.fn(); const selectMock = vi.fn(); +const neqMock = vi.fn(); const inMock = vi.fn(); const eqMock = vi.fn(); const orderMock = vi.fn(); @@ -15,15 +16,17 @@ vi.mock("@/lib/supabase/serverClient", () => ({ beforeEach(() => { vi.clearAllMocks(); - // Default chain: from().select().in().eq().order() -> resolves to { data, error } + // Default chain: from().select().neq().in().eq().order() -> resolves to { data, error } const builder = { select: selectMock, + neq: neqMock, in: inMock, eq: eqMock, order: orderMock, }; fromMock.mockReturnValue(builder); selectMock.mockReturnValue(builder); + neqMock.mockReturnValue(builder); inMock.mockReturnValue(builder); eqMock.mockReturnValue(builder); orderMock.mockResolvedValue({ data: [], error: null }); @@ -51,13 +54,21 @@ describe("selectChatsWithSessions", () => { expect(String(selectArg)).toContain("session:sessions!inner"); expect(String(selectArg)).toContain("account_id"); expect(String(selectArg)).toContain("artist_id"); + expect(String(selectArg)).toContain("status"); + expect(neqMock).toHaveBeenCalledWith("session.status", "archived"); expect(inMock).toHaveBeenCalledWith("session.account_id", ["acc-1", "acc-2"]); expect(eqMock).not.toHaveBeenCalled(); expect(orderMock).toHaveBeenCalledWith("updated_at", { ascending: false }); expect(result).toEqual(rows); }); + it("always excludes archived sessions, even with no other filters", async () => { + await selectChatsWithSessions({}); + + expect(neqMock).toHaveBeenCalledWith("session.status", "archived"); + }); + it("composes accountIds + artistAccountId — both filters applied", async () => { orderMock.mockResolvedValueOnce({ data: [], error: null }); diff --git a/lib/supabase/chats/selectChatsWithSessions.ts b/lib/supabase/chats/selectChatsWithSessions.ts index 932341ff0..ee3d204db 100644 --- a/lib/supabase/chats/selectChatsWithSessions.ts +++ b/lib/supabase/chats/selectChatsWithSessions.ts @@ -18,13 +18,16 @@ export interface SelectChatsWithSessionsParams { const SELECT = ` *, - session:sessions!inner ( id, account_id, artist_id ) + session:sessions!inner ( id, account_id, artist_id, status ) ` as const; /** * Reads chats joined to their owning session, optionally scoped to a set of * account IDs through `sessions.account_id` and/or an artist context through - * `sessions.artist_id`. Ordered by `chats.updated_at` descending so newest + * `sessions.artist_id`. Chats whose session is archived + * (`sessions.status === "archived"`) are excluded — archive is the + * soft-delete path, and archived sessions should not surface in chat + * listings. Results are ordered by `chats.updated_at` descending so newest * activity surfaces first. * * Returns `null` when Supabase reports an error so callers can distinguish a @@ -39,7 +42,7 @@ export async function selectChatsWithSessions(params: SelectChatsWithSessionsPar return []; } - let query = supabase.from("chats").select(SELECT); + let query = supabase.from("chats").select(SELECT).neq("session.status", "archived"); if (accountIds !== undefined) { query = query.in("session.account_id", accountIds); }