diff --git a/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc b/.agents/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc similarity index 100% rename from .cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc rename to .agents/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc diff --git a/apps/web/src/app/api/opencode/[port]/[[...path]]/route.test.ts b/apps/web/src/app/api/opencode/[port]/[[...path]]/route.test.ts index 1a64ee7..7457beb 100644 --- a/apps/web/src/app/api/opencode/[port]/[[...path]]/route.test.ts +++ b/apps/web/src/app/api/opencode/[port]/[[...path]]/route.test.ts @@ -281,11 +281,11 @@ describe("API Proxy Route /api/opencode/[port]/[[...path]]", () => { const fetchOptions = mockFetch.mock.calls[0]?.[1] as { method: string body: unknown - duplex?: string } expect(fetchOptions.method).toBe("POST") - expect(fetchOptions.body).toBeDefined() - expect(fetchOptions.duplex).toBe("half") + // Body is read as text string, not streamed + expect(typeof fetchOptions.body).toBe("string") + expect(JSON.parse(fetchOptions.body as string)).toEqual({ name: "Test Session" }) }) it("handles PUT requests with body", async () => { @@ -313,11 +313,10 @@ describe("API Proxy Route /api/opencode/[port]/[[...path]]", () => { const fetchOptions = mockFetch.mock.calls[0]?.[1] as { method: string body: unknown - duplex?: string } expect(fetchOptions.method).toBe("PUT") - expect(fetchOptions.body).toBeDefined() - expect(fetchOptions.duplex).toBe("half") + expect(typeof fetchOptions.body).toBe("string") + expect(JSON.parse(fetchOptions.body as string)).toEqual({ name: "Updated Session" }) }) it("handles PATCH requests with body", async () => { @@ -345,21 +344,16 @@ describe("API Proxy Route /api/opencode/[port]/[[...path]]", () => { const fetchOptions = mockFetch.mock.calls[0]?.[1] as { method: string body: unknown - duplex?: string } expect(fetchOptions.method).toBe("PATCH") - expect(fetchOptions.body).toBeDefined() - expect(fetchOptions.duplex).toBe("half") + expect(typeof fetchOptions.body).toBe("string") + expect(JSON.parse(fetchOptions.body as string)).toEqual({ status: "active" }) }) it("handles DELETE requests", async () => { - // TODO: BUG - Route crashes on 204 responses - // Current implementation calls response.text() for ALL responses - // then creates NextResponse(body, { status }), which fails for 204 - // See message to coordinator - needs fix in route.ts const mockFetch = vi.fn().mockResolvedValue({ ok: true, - status: 200, // Use 200 instead of 204 to avoid crash + status: 200, headers: new Headers({ "content-type": "application/json" }), text: vi.fn().mockResolvedValue('{"deleted": true}'), }) @@ -375,7 +369,7 @@ describe("API Proxy Route /api/opencode/[port]/[[...path]]", () => { expect(response.status).toBe(200) const fetchOptions = mockFetch.mock.calls[0]?.[1] as { method: string; body: unknown } expect(fetchOptions.method).toBe("DELETE") - expect(fetchOptions.body).toBeNull() + expect(fetchOptions.body).toBeUndefined() }) it("handles OPTIONS requests", async () => { @@ -572,27 +566,25 @@ describe("API Proxy Route /api/opencode/[port]/[[...path]]", () => { expect(response.headers.get("Content-Type")).toBe("application/json") }) - // TODO: BUG - Route crashes on 204 responses, skipping this test - // Once route.ts is fixed to handle 204 properly, uncomment this test: - // it("returns empty response for 204 No Content", async () => { - // global.fetch = vi.fn().mockResolvedValue({ - // ok: true, - // status: 204, - // headers: new Headers(), - // text: vi.fn().mockResolvedValue(""), - // }) as unknown as typeof fetch - // - // const request = new NextRequest("http://localhost:3000/api/opencode/4056/sessions/123", { - // method: "DELETE", - // }) - // const params = Promise.resolve({ port: "4056", path: ["sessions", "123"] }) - // - // const response = await DELETE(request, { params }) - // - // expect(response.status).toBe(204) - // const body = await response.text() - // expect(body).toBe("") - // }) + it("returns empty response for 204 No Content", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 204, + headers: new Headers(), + text: vi.fn().mockResolvedValue(""), + }) as unknown as typeof fetch + + const request = new NextRequest("http://localhost:3000/api/opencode/4056/sessions/123", { + method: "DELETE", + }) + const params = Promise.resolve({ port: "4056", path: ["sessions", "123"] }) + + const response = await DELETE(request, { params }) + + expect(response.status).toBe(204) + const body = await response.text() + expect(body).toBe("") + }) it("handles large response bodies", async () => { const largeBody = JSON.stringify({ data: "x".repeat(10000) }) diff --git a/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts b/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts index f5654e2..7a78d04 100644 --- a/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts +++ b/apps/web/src/app/api/opencode/[port]/[[...path]]/route.ts @@ -119,18 +119,20 @@ async function proxyRequest( } // Copy body for POST/PUT/PATCH - let body: ReadableStream | null = null + // Read as string instead of streaming ReadableStream because: + // 1. Bun's fetch doesn't need duplex:"half" (Node.js-only option) + // 2. request.body can be null in Next.js 16 if body is already consumed + // 3. Most proxy payloads are small JSON - memory isn't a concern + let body: string | undefined if (["POST", "PUT", "PATCH"].includes(request.method)) { - body = request.body + body = await request.text() } // Proxy request to OpenCode server const response = await fetch(targetUrl, { method: request.method, headers, - body, - // @ts-expect-error - duplex mode needed for streaming request bodies - duplex: body ? "half" : undefined, + body: body || undefined, }) // Handle non-2xx responses @@ -144,6 +146,11 @@ async function proxyRequest( ) } + // Handle 204 No Content (no body to read) + if (response.status === 204) { + return new NextResponse(null, { status: 204 }) + } + // Return proxied response const responseBody = await response.text() return new NextResponse(responseBody, { diff --git a/apps/web/src/app/session/[id]/debug-panel.tsx b/apps/web/src/app/session/[id]/debug-panel.tsx index 62e2f48..5a645b3 100644 --- a/apps/web/src/app/session/[id]/debug-panel.tsx +++ b/apps/web/src/app/session/[id]/debug-panel.tsx @@ -45,7 +45,8 @@ export function DebugPanel({ sessionId, isOpen }: DebugPanelProps) { const [fetchError, setFetchError] = useState(null) // Config and store hooks - const { directory } = getOpencodeConfig() + const { directory: rawDirectory } = getOpencodeConfig() + const directory = rawDirectory ?? "" const messagesWithParts = useMessagesWithParts(sessionId) const storeMessages = useMessages(sessionId) diff --git a/apps/web/src/app/session/[id]/session-layout.tsx b/apps/web/src/app/session/[id]/session-layout.tsx index faf7dc7..a54a575 100644 --- a/apps/web/src/app/session/[id]/session-layout.tsx +++ b/apps/web/src/app/session/[id]/session-layout.tsx @@ -266,15 +266,15 @@ export function SessionLayout({ }: SessionLayoutProps) { return ( <> - {/* Inject OpenCode config for factory hooks - must have directory from URL */} - {directory && ( - - )} + {/* Inject OpenCode config for factory hooks - always render to override ServerStatus default */} + {/* ServerStatus sets directory:"" which breaks store lookups - session page must override */} + {/* Always pass directory even if empty string - OpencodeConfig requires it */} + diff --git a/packages/react/src/next-ssr-plugin.tsx b/packages/react/src/next-ssr-plugin.tsx index c209bbd..bfda882 100644 --- a/packages/react/src/next-ssr-plugin.tsx +++ b/packages/react/src/next-ssr-plugin.tsx @@ -15,7 +15,7 @@ import { useServerInsertedHTML } from "next/navigation" export interface OpencodeConfig { baseUrl: string - directory: string + directory?: string } export interface OpencodeSSRPluginProps {