-
Notifications
You must be signed in to change notification settings - Fork 9
promote: test → main (POST sessions + sandbox cleanup) #516
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
06f0822
refactor(sandbox): callers use open-agents abstraction (Phase 2.2) (#…
sweetmantech e2c3167
Merge remote-tracking branch 'origin/main' into test
sweetmantech e1ac582
chore(sandbox): delete dead Claude Code helpers (Phase 2.3) (#512)
sweetmantech 97724c4
Merge remote-tracking branch 'origin/main' into test
sweetmantech 4c17ee7
feat(sessions): port POST /api/sessions from open-agents (#515)
sweetmantech File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { describe, it, expect, vi } from "vitest"; | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
|
|
||
| import { POST, OPTIONS } from "@/app/api/sessions/route"; | ||
| import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; | ||
|
|
||
| vi.mock("@/lib/sessions/createSessionHandler", () => ({ | ||
| createSessionHandler: vi.fn(), | ||
| })); | ||
|
|
||
| describe("POST /api/sessions", () => { | ||
| it("delegates to createSessionHandler", async () => { | ||
| const expected = NextResponse.json({ ok: true }, { status: 200 }); | ||
| vi.mocked(createSessionHandler).mockResolvedValue(expected); | ||
|
|
||
| const req = new NextRequest("http://localhost/api/sessions", { method: "POST" }); | ||
| const res = await POST(req); | ||
|
|
||
| expect(createSessionHandler).toHaveBeenCalledWith(req); | ||
| expect(res).toBe(expected); | ||
| }); | ||
| }); | ||
|
|
||
| describe("OPTIONS /api/sessions", () => { | ||
| it("returns 200 for CORS preflight", async () => { | ||
| const res = await OPTIONS(); | ||
| expect(res.status).toBe(200); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; | ||
|
|
||
| /** | ||
| * 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` — create a session and an initial chat. | ||
| * | ||
| * @param request - The incoming request. | ||
| * @returns A NextResponse with `{ session, chat }` on 200, or an error. | ||
| */ | ||
| export async function POST(request: NextRequest) { | ||
| return createSessionHandler(request); | ||
| } | ||
|
|
||
| export const dynamic = "force-dynamic"; | ||
| export const fetchCache = "force-no-store"; | ||
| export const revalidate = 0; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import type { Tables } from "@/types/database.types"; | ||
|
|
||
| /** | ||
| * Returns a fully-populated `chats` row suitable for mocking | ||
| * `insertChat` in tests. Pass `overrides` to customize fields per case. | ||
| */ | ||
| export function baseChatRow(overrides: Partial<Tables<"chats">> = {}): Tables<"chats"> { | ||
| return { | ||
| id: "chat_1", | ||
| session_id: "sess_1", | ||
| title: "New chat", | ||
| model_id: null, | ||
| active_stream_id: null, | ||
| last_assistant_message_at: null, | ||
| created_at: "2026-05-04T00:00:00.000Z", | ||
| updated_at: "2026-05-04T00:00:00.000Z", | ||
| ...overrides, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import type { Tables } from "@/types/database.types"; | ||
|
|
||
| /** | ||
| * Returns a fully-populated `sessions` row suitable for mocking | ||
| * `insertSession` / `selectSession` in tests. Pass `overrides` to | ||
| * customize fields per case. | ||
| */ | ||
| export function baseSessionRow(overrides: Partial<Tables<"sessions">> = {}): Tables<"sessions"> { | ||
| return { | ||
| id: "sess_1", | ||
| account_id: "acc-uuid-1", | ||
| title: "Test session", | ||
| status: "running", | ||
| repo_owner: null, | ||
| repo_name: null, | ||
| branch: null, | ||
| clone_url: null, | ||
| is_new_branch: false, | ||
| global_skill_refs: [], | ||
| sandbox_state: { type: "vercel" }, | ||
| lifecycle_state: "provisioning", | ||
| lifecycle_version: 0, | ||
| last_activity_at: null, | ||
| sandbox_expires_at: null, | ||
| hibernate_after: null, | ||
| lifecycle_run_id: null, | ||
| lifecycle_error: null, | ||
| lines_added: 0, | ||
| lines_removed: 0, | ||
| snapshot_url: null, | ||
| snapshot_created_at: null, | ||
| snapshot_size_bytes: null, | ||
| cached_diff: null, | ||
| cached_diff_updated_at: null, | ||
| created_at: "2026-05-04T00:00:00.000Z", | ||
| updated_at: "2026-05-04T00:00:00.000Z", | ||
| ...overrides, | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| 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" }); | ||
| expect(row.account_id).toBe("acc-1"); | ||
| expect(row.title).toBe("Berlin"); | ||
| expect(row.status).toBe("running"); | ||
| expect(row.lifecycle_state).toBe("provisioning"); | ||
| expect(row.lifecycle_version).toBe(0); | ||
| expect(row.sandbox_state).toEqual({ type: "vercel" }); | ||
| expect(row.branch).toBeNull(); | ||
| expect(row.clone_url).toBeNull(); | ||
| expect(row.id).toMatch(/^[0-9a-f-]{36}$/i); | ||
| }); | ||
|
|
||
| it("forwards branch + clone fields verbatim", () => { | ||
| const row = buildSessionInsertRow({ | ||
| body: { | ||
| branch: "main", | ||
| cloneUrl: "https://github.com/recoupable/ai.git", | ||
| }, | ||
| accountId: "acc-1", | ||
| title: "Berlin", | ||
| }); | ||
| expect(row.branch).toBe("main"); | ||
| expect(row.clone_url).toBe("https://github.com/recoupable/ai.git"); | ||
| }); | ||
|
|
||
| it("uses the provided sandboxType when set", () => { | ||
| const row = buildSessionInsertRow({ | ||
| body: { sandboxType: "vercel" }, | ||
| accountId: "acc-1", | ||
| title: "Berlin", | ||
| }); | ||
| expect(row.sandbox_state).toEqual({ type: "vercel" }); | ||
| }); | ||
| }); |
110 changes: 110 additions & 0 deletions
110
lib/sessions/__tests__/createSessionHandler.persistence.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Custom agent: Enforce Clear Code Style and Maintainability Practices Test file exceeds the repository’s 100-line maintainability limit. Prompt for AI agents |
||
|
|
||
| import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; | ||
| 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 { createSessionHandler } from "@/lib/sessions/createSessionHandler"; | ||
| import { baseSessionRow } from "@/lib/sessions/__tests__/baseSessionRow"; | ||
| import { baseChatRow } from "@/lib/sessions/__tests__/baseChatRow"; | ||
| import { makeCreateSessionReq } from "@/lib/sessions/__tests__/makeCreateSessionReq"; | ||
|
|
||
| vi.mock("@/lib/networking/getCorsHeaders", () => ({ | ||
| getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), | ||
| })); | ||
| vi.mock("@/lib/sessions/validateCreateSessionBody", () => ({ | ||
| validateCreateSessionBody: vi.fn(), | ||
| })); | ||
| vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() })); | ||
| vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() })); | ||
| vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); | ||
| vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ | ||
| resolveSessionTitle: vi.fn(async () => "Anchorage"), | ||
| })); | ||
|
|
||
| const okValidated = (overrides: { body?: object; accountId?: string } = {}) => ({ | ||
| body: overrides.body ?? {}, | ||
| auth: { | ||
| accountId: overrides.accountId ?? "acc-uuid-1", | ||
| orgId: null, | ||
| authToken: "key_test", | ||
| }, | ||
| }); | ||
|
|
||
| describe("createSessionHandler — persistence", () => { | ||
| beforeEach(() => vi.clearAllMocks()); | ||
|
|
||
| it("creates session and chat with defaults on empty body", async () => { | ||
| vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); | ||
| vi.mocked(insertSession).mockResolvedValue(baseSessionRow()); | ||
| vi.mocked(insertChat).mockResolvedValue(baseChatRow()); | ||
|
|
||
| const res = await createSessionHandler(makeCreateSessionReq({})); | ||
| expect(res.status).toBe(200); | ||
|
|
||
| const body = (await res.json()) as { session: { userId: string }; chat: { sessionId: string } }; | ||
| expect(body.session.userId).toBe("acc-uuid-1"); | ||
| expect(body.chat.sessionId).toBe("sess_1"); | ||
|
|
||
| const insertArgs = vi.mocked(insertSession).mock.calls[0][0]; | ||
| expect(insertArgs.account_id).toBe("acc-uuid-1"); | ||
| expect(insertArgs.status).toBe("running"); | ||
| expect(insertArgs.lifecycle_state).toBe("provisioning"); | ||
| expect(insertArgs.sandbox_state).toEqual({ type: "vercel" }); | ||
|
|
||
| const chatArgs = vi.mocked(insertChat).mock.calls[0][0]; | ||
| expect(chatArgs.session_id).toBe("sess_1"); | ||
| expect(chatArgs.title).toBe("New chat"); | ||
| }); | ||
|
|
||
| it("forwards body title to resolveSessionTitle and writes the resolved title", async () => { | ||
| vi.mocked(validateCreateSessionBody).mockResolvedValue( | ||
| okValidated({ body: { title: "Hello world" } }), | ||
| ); | ||
| vi.mocked(resolveSessionTitle).mockResolvedValueOnce("Hello world"); | ||
| vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ title: "Hello world" })); | ||
| vi.mocked(insertChat).mockResolvedValue(baseChatRow()); | ||
|
|
||
| await createSessionHandler(makeCreateSessionReq({ title: "Hello world" })); | ||
|
|
||
| expect(resolveSessionTitle).toHaveBeenCalledWith({ | ||
| providedTitle: "Hello world", | ||
| accountId: "acc-uuid-1", | ||
| }); | ||
| expect(vi.mocked(insertSession).mock.calls[0][0].title).toBe("Hello world"); | ||
| }); | ||
|
|
||
| it("returns 500 when insertSession fails", async () => { | ||
| vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); | ||
| vi.mocked(insertSession).mockResolvedValue(null); | ||
|
|
||
| const res = await createSessionHandler(makeCreateSessionReq({})); | ||
| expect(res.status).toBe(500); | ||
| expect(insertChat).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("rolls back the session and returns 500 when insertChat fails", async () => { | ||
| vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); | ||
| vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_rollback" })); | ||
| vi.mocked(insertChat).mockResolvedValue(null); | ||
| vi.mocked(deleteSessionById).mockResolvedValue(true); | ||
|
|
||
| const res = await createSessionHandler(makeCreateSessionReq({})); | ||
| expect(res.status).toBe(500); | ||
| expect(deleteSessionById).toHaveBeenCalledWith("sess_rollback"); | ||
| }); | ||
|
|
||
| it("logs an orphan-session error when rollback also fails", async () => { | ||
| vi.mocked(validateCreateSessionBody).mockResolvedValue(okValidated()); | ||
| vi.mocked(insertSession).mockResolvedValue(baseSessionRow({ id: "sess_orphan" })); | ||
| vi.mocked(insertChat).mockResolvedValue(null); | ||
| vi.mocked(deleteSessionById).mockResolvedValue(false); | ||
| const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); | ||
|
|
||
| const res = await createSessionHandler(makeCreateSessionReq({})); | ||
| expect(res.status).toBe(500); | ||
| expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("orphaned session"), "sess_orphan"); | ||
| errSpy.mockRestore(); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { NextResponse } from "next/server"; | ||
|
|
||
| import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; | ||
| import { insertSession } from "@/lib/supabase/sessions/insertSession"; | ||
| import { createSessionHandler } from "@/lib/sessions/createSessionHandler"; | ||
| import { makeCreateSessionReq } from "@/lib/sessions/__tests__/makeCreateSessionReq"; | ||
|
|
||
| vi.mock("@/lib/networking/getCorsHeaders", () => ({ | ||
| getCorsHeaders: () => ({ "Access-Control-Allow-Origin": "*" }), | ||
| })); | ||
| vi.mock("@/lib/sessions/validateCreateSessionBody", () => ({ | ||
| validateCreateSessionBody: vi.fn(), | ||
| })); | ||
| vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() })); | ||
| vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() })); | ||
| vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); | ||
| vi.mock("@/lib/sessions/resolveSessionTitle", () => ({ | ||
| resolveSessionTitle: vi.fn(async () => "Anchorage"), | ||
| })); | ||
|
|
||
| describe("createSessionHandler — short-circuits on validation failure", () => { | ||
| beforeEach(() => vi.clearAllMocks()); | ||
|
|
||
| it("returns the NextResponse from validateCreateSessionBody as-is", async () => { | ||
| const failure = NextResponse.json({ status: "error", error: "bad" }, { status: 401 }); | ||
| vi.mocked(validateCreateSessionBody).mockResolvedValue(failure); | ||
|
|
||
| const res = await createSessionHandler(makeCreateSessionReq({})); | ||
| expect(res).toBe(failure); | ||
| expect(insertSession).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("returns 400 when validateCreateSessionBody rejects with 400", async () => { | ||
| vi.mocked(validateCreateSessionBody).mockResolvedValue( | ||
| NextResponse.json({ status: "error", error: "Invalid sandbox type" }, { status: 400 }), | ||
| ); | ||
|
|
||
| const res = await createSessionHandler(makeCreateSessionReq({ sandboxType: "wrong" })); | ||
| expect(res.status).toBe(400); | ||
| expect(insertSession).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| import { describe, it, expect, vi, afterEach } from "vitest"; | ||
| import { getRandomCityName } from "@/lib/sessions/getRandomCityName"; | ||
| import { cityNames } from "@/lib/sessions/cityNames"; | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe("getRandomCityName", () => { | ||
| it("returns a city from the curated list when none are used", () => { | ||
| const result = getRandomCityName(new Set()); | ||
| expect(cityNames).toContain(result); | ||
| }); | ||
|
|
||
| it("avoids cities that are already in use", () => { | ||
| const used = new Set(cityNames.slice(0, cityNames.length - 1)); | ||
| const result = getRandomCityName(used); | ||
| expect(result).toBe(cityNames[cityNames.length - 1]); | ||
| }); | ||
|
|
||
| it("appends a numeric suffix once every city is used", () => { | ||
| const used = new Set(cityNames); | ||
| const result = getRandomCityName(used); | ||
| expect(result).toMatch(/ \d+$/); | ||
| const base = result.replace(/ \d+$/, ""); | ||
| expect(cityNames).toContain(base); | ||
| }); | ||
|
|
||
| it("increments the suffix when the next number is also used", () => { | ||
| const baseCity = cityNames[0]; | ||
| const used = new Set([...cityNames, `${baseCity} 2`, `${baseCity} 3`]); | ||
| // Pin Math.random so the fallback always picks index 0 (= baseCity), | ||
| // making the suffix-increment behavior deterministic to assert. | ||
| vi.spyOn(Math, "random").mockReturnValue(0); | ||
|
|
||
| const result = getRandomCityName(used); | ||
|
|
||
| expect(result).toBe(`${baseCity} 4`); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { NextRequest } from "next/server"; | ||
|
|
||
| /** | ||
| * Builds a NextRequest pointing at `/api/sessions` with a JSON body | ||
| * and the standard test API key already attached. | ||
| * | ||
| * @param body - The body to send (object literal or raw string). | ||
| */ | ||
| export function makeCreateSessionReq(body: unknown): NextRequest { | ||
| const headers = new Headers({ | ||
| "Content-Type": "application/json", | ||
| "x-api-key": "key_test", | ||
| }); | ||
| return new NextRequest("http://localhost/api/sessions", { | ||
| method: "POST", | ||
| headers, | ||
| body: typeof body === "string" ? body : JSON.stringify(body), | ||
| }); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Custom agent: Module should export a single primary function whose name matches the filename
Module exports multiple top-level functions instead of a single primary export matching the file basename.
Prompt for AI agents