Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions packages/server/decision-endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* PR D: Tests for /api/decision poll + SSE endpoints
*
* For remote clients (separate process from server), the only way to get
* the decision result is via HTTP. These endpoints provide both polling
* and real-time SSE options.
*/
import { describe, test, expect, afterAll, beforeAll } from "bun:test";

describe("/api/decision endpoints", () => {
const controllers: AbortController[] = [];
let savedPort: string | undefined;
let savedRemote: string | undefined;

beforeAll(() => {
savedPort = process.env.PLANNOTATOR_PORT;
savedRemote = process.env.PLANNOTATOR_REMOTE;
delete process.env.PLANNOTATOR_PORT;
delete process.env.PLANNOTATOR_REMOTE;
delete process.env.PLANNOTATOR_SERVER_URL;
});

afterAll(() => {
for (const c of controllers) c.abort();
if (savedPort) process.env.PLANNOTATOR_PORT = savedPort;
if (savedRemote) process.env.PLANNOTATOR_REMOTE = savedRemote;
});

test("GET /api/decision returns pending before user decides", async () => {
const { startPlannotatorServer } = await import("./index");

const controller = new AbortController();
controllers.push(controller);
const server = await startPlannotatorServer({
plan: "# Test Plan",
signal: controller.signal,
});

const response = await fetch(`${server.url}/api/decision`);
expect(response.status).toBe(200);
const body = await response.json() as any;
expect(body.pending).toBe(true);
});

test("GET /api/decision returns result after approve", async () => {
const { startPlannotatorServer } = await import("./index");

const controller = new AbortController();
controllers.push(controller);
const server = await startPlannotatorServer({
plan: "# Test Plan",
signal: controller.signal,
});

// Approve the plan
const approveResponse = await fetch(`${server.url}/api/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(approveResponse.status).toBe(200);

// Now check decision
const response = await fetch(`${server.url}/api/decision`);
expect(response.status).toBe(200);
const body = await response.json() as any;
expect(body.pending).toBeUndefined();
expect(body.approved).toBe(true);
});

test("GET /api/decision/stream returns SSE with decision result", async () => {
const { startPlannotatorServer } = await import("./index");

const controller = new AbortController();
controllers.push(controller);
const server = await startPlannotatorServer({
plan: "# Test Plan",
signal: controller.signal,
});

// Approve immediately, then start SSE — should get result immediately
const approveResp = await fetch(`${server.url}/api/approve`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(approveResp.status).toBe(200);

// Now connect SSE — should immediately send the result
const sseResponse = await fetch(`${server.url}/api/decision/stream`);
expect(sseResponse.status).toBe(200);
expect(sseResponse.headers.get("content-type")).toContain("text/event-stream");

const reader = sseResponse.body!.getReader();
const decoder = new TextDecoder();
let result = "";
// Read at least one SSE event
for (let i = 0; i < 10; i++) {
const { done, value } = await reader.read();
if (done) break;
result += decoder.decode(value, { stream: true });
if (result.includes("approved")) break;
}
reader.cancel();

expect(result).toContain("data:");
expect(result).toContain("approved");
});
});
70 changes: 68 additions & 2 deletions packages/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@ export async function startPlannotatorServer(
agentSwitch?: string;
permissionMode?: string;
}) => void;
let decisionResult: {
approved: boolean;
feedback?: string;
savedPath?: string;
agentSwitch?: string;
permissionMode?: string;
} | null = null;
let decisionPromise: Promise<{
approved: boolean;
feedback?: string;
Expand Down Expand Up @@ -528,7 +535,9 @@ export async function startPlannotatorServer(

// Use permission mode from client request if provided, otherwise fall back to hook input
const effectivePermissionMode = requestedPermissionMode || permissionMode;
resolveDecision({ approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode });
const result = { approved: true, feedback, savedPath, agentSwitch, permissionMode: effectivePermissionMode };
decisionResult = result;
resolveDecision(result);
return Response.json({ ok: true, savedPath });
}

Expand Down Expand Up @@ -561,10 +570,67 @@ export async function startPlannotatorServer(
}

deleteDraft(draftKey);
resolveDecision({ approved: false, feedback, savedPath });
const result = { approved: false, feedback, savedPath };
decisionResult = result;
resolveDecision(result);
return Response.json({ ok: true, savedPath });
}

// --- Decision polling endpoint ---
// For remote clients that can't await waitForDecision() directly.
if (url.pathname === "/api/decision" && req.method === "GET") {
// Check if the decision has been made by looking at the stored result
if (decisionResult) {
return Response.json(decisionResult);
}
return Response.json({ pending: true });
}

// --- Decision SSE stream ---
// Real-time notification for remote clients.
if (url.pathname === "/api/decision/stream" && req.method === "GET") {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder();
const send = (data: unknown) => {
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch { /* stream already closed */ }
};

// If already decided, send immediately
if (decisionResult) {
send(decisionResult);
controller.close();
return;
}

// Poll until decided (simpler than promise-based for single-session)
const interval = setInterval(() => {
if (decisionResult) {
clearInterval(interval);
send(decisionResult);
try { controller.close(); } catch { /* already closed */ }
}
}, 200);

// Abort cleanup
req.signal.addEventListener("abort", () => {
clearInterval(interval);
try { controller.close(); } catch { /* already closed */ }
});
},
});

return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}

// Favicon
if (url.pathname === "/favicon.svg") return handleFavicon();

Expand Down