From a7b4f7ba32f5be3cb91b80a25406fe07accaa028 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 18 Apr 2026 14:07:21 +0000 Subject: [PATCH] [beadboard] Add agent-dispatch and agent-status API routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context The Dispatch-to-Agent button in the right-panel (previous commit) calls two endpoints that did not yet exist server-side: - `POST /api/agent-dispatch` — resolve the bead via the same Dolt pool the existing `/api/beads/read` route uses, build a prompt from it, and forward to `claude-agent-service`'s `/execute` endpoint with a bearer token. This is the server-side trust boundary: the bearer token lives in env (never in the browser), and the bead is re-read from Dolt (never trusted from the client payload) so a malicious client cannot inject a prompt. - `GET /api/agent-status` — proxy `claude-agent-service`'s `/health` endpoint (which already returns `{status, busy}`), with a 2 s in- memory cache so 5 s UI polls across multiple open tabs don't hammer the service. The claude-agent-service serialises jobs behind an `asyncio.Lock` — a second `/execute` while one is running returns HTTP 409 "Agent is busy". We surface that 409 through to the browser unchanged so the UI can show the right toast/status line without re-mapping status codes. ``` client ──► /api/agent-dispatch ──► claude-agent-service /execute │ (asyncio.Lock guards) └─► reads bead from Dolt pool (same path as /api/beads/read) └─► buildDispatchPrompt(bead) └─► POST {prompt, agent, budget, timeout} + Bearer token client ──► /api/agent-status (2s cache) ──► /health ──► {busy: bool} ``` ## This change ### `src/app/api/agent-dispatch/route.ts` - `POST {taskId: string}` handler. - Validates JSON body and non-empty taskId (→ 400). - Early 500 if `CLAUDE_AGENT_SERVICE_URL` or `CLAUDE_AGENT_BEARER_TOKEN` is missing — fail fast, fail loud. - Resolves the bead via `readIssuesFromDisk({preferBd: true})`, which uses the existing Dolt client (and falls back to `issues.jsonl`). Filtering by id after is acceptable at BeadBoard's scale (~hundreds of beads) and avoids introducing a new single-bead query path. - 400 when the bead is missing, or when `acceptance_criteria?.trim()` is empty — defense in depth alongside the UI disable. The button should already hide in these cases, but a curl'd POST must still be rejected. - Forwards to `${CLAUDE_AGENT_SERVICE_URL}/execute` with agent `beads-task-runner`, max_budget_usd 5, timeout_seconds 900 (matches the values in the task spec). - Passes through 409 verbatim. Other upstream errors collapse to 502. ### `src/app/api/agent-status/route.ts` - `GET` handler, module-level snapshot cache with 2 s TTL to avoid hammering `/health`. - In-flight de-dup: a single pending `fetchRemoteStatus()` is shared across concurrent requests so we only hit the upstream once per window even under bursty load. - When `CLAUDE_AGENT_SERVICE_URL` is unset, returns `{busy: false}` and skips the fetch entirely — this is how the dev server boots before the service env is configured. - HTTP 503 from upstream is interpreted as `busy: true` (future-proofing in case the service swaps 409 for 503 on overload). - Any network error degrades gracefully to `{busy: false}` — the 409 path on `/api/agent-dispatch` is the authoritative gate. ### Test coverage - `tests/api/agent-dispatch-route.test.ts` (3 cases): invalid JSON body, missing taskId, missing env returns 500. - `tests/api/agent-status-route.test.ts` (3 cases): unset service URL returns `{busy: false}` without a fetch, `/health busy:true` proxies through, HTTP 503 maps to busy=true. Uses a `globalThis.fetch` stub and cache-busting query params on dynamic import so each case starts from a fresh module snapshot. ## What is NOT in this change - End-to-end happy-path coverage (bead loads, fetch returns 200, route yields job_id) would require mocking `readIssuesFromDisk` — that's a bigger refactor than this commit warrants. Live integration happens once the pipeline is wired. - No retry/backoff on upstream failures. 502 passes through; the client decides whether to retry. - No auth on the routes themselves — they inherit the Next.js app's session model (same as all other `/api/*` routes today). ## Test Plan ### Automated ``` $ node --import tsx --test tests/api/agent-dispatch-route.test.ts \ tests/api/agent-status-route.test.ts # tests 6 pass 6 fail 0 duration_ms 1560 ``` All six route cases pass. Typecheck output shows only the pre-existing `OrchestratorChatMessage` gap in `left-panel.tsx`. Lint output is unchanged from `main` (1 pre-existing `no-require-imports` error in `src/lib/bb-pi-bootstrap.ts`). ### Manual Verification 1. Export env: ``` export CLAUDE_AGENT_SERVICE_URL=http://claude-agent-service.claude-agent.svc.cluster.local:8080 export CLAUDE_AGENT_BEARER_TOKEN=$(vault kv get -field=api_bearer_token secret/claude-agent-service) ``` 2. `npm run dev` 3. `curl -s http://localhost:3000/api/agent-status` → `{"busy":false}` (if the service is reachable; `{"busy":true}` if a job is running). 4. `curl -X POST http://localhost:3000/api/agent-dispatch \ -H 'content-type: application/json' \ -d '{"taskId":"beadboard-xyz"}'` - 400 if bead does not exist or lacks acceptance criteria. - 200 `{"job_id":"..."}` on success. - 409 `{"error":"Agent is busy"}` when an agent is already running. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 2 +- src/app/api/agent-dispatch/route.ts | 98 ++++++++++++++++++++++++++ src/app/api/agent-status/route.ts | 66 +++++++++++++++++ tests/api/agent-dispatch-route.test.ts | 62 ++++++++++++++++ tests/api/agent-status-route.test.ts | 84 ++++++++++++++++++++++ 5 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/app/api/agent-dispatch/route.ts create mode 100644 src/app/api/agent-status/route.ts create mode 100644 tests/api/agent-dispatch-route.test.ts create mode 100644 tests/api/agent-status-route.test.ts diff --git a/package.json b/package.json index e26abff..ac4dc73 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "start": "next start", "lint": "eslint .", "typecheck": "tsc --noEmit", - "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/pr14-critical-bugs.test.ts && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/blocked-triage-modal.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx && node --import tsx --test tests/components/graph/graph-node-conversation.test.tsx && node --import tsx --test tests/lib/coord-schema.test.ts && node --import tsx --test tests/lib/install-manifest.test.ts && node --import tsx --test tests/lib/runtime-manager.test.ts && node --import tsx --test tests/lib/coord-events.test.ts && node --import tsx --test tests/api/coord-events-route.test.ts && node --import tsx --test tests/lib/coord-projections-inbox.test.ts && node --import tsx --test tests/lib/coord-projections-reservations.test.ts && node --import tsx --test tests/components/sessions/conversation-drawer-coord.test.tsx && node --import tsx --test tests/scripts/beadboard-launcher.test.ts && node --import tsx --test tests/scripts/beadboard-launcher-runtime.test.ts && node --import tsx --test tests/scripts/beadboard-launcher-status-text.test.ts && node --import tsx --test tests/scripts/install-wrappers-contract.test.ts && node --import tsx --test tests/scripts/install-sh-smoke.test.ts && node --import tsx --test tests/scripts/install-legacy-migration.test.ts && node --import tsx --test tests/scripts/installer-ci-contract.test.ts && node --import tsx --test tests/docs/installer-quickstart-contract.test.ts && node --import tsx --test tests/docs/runtime-manager-adr-contract.test.ts && node --import tsx --test tests/cli/beadboard-cli.test.ts && node --import tsx --test tests/cli/beadboard-bin-routing.test.ts && node --import tsx --test tests/cli/beadboard-help-output.test.ts && node --import tsx --test tests/skills/beadboard-driver/resolve-bb.test.ts && node --import tsx --test tests/skills/beadboard-driver/session-preflight.test.ts && node --import tsx --test tests/skills/beadboard-driver/generate-agent-name.test.ts && node --import tsx --test tests/skills/beadboard-driver/readiness-report.test.ts && node --import tsx --test tests/skills/beadboard-driver/skill-local-runner.test.ts && node --import tsx --test tests/skills/beadboard-driver/diagnose-env.test.ts && node --import tsx --test tests/skills/beadboard-driver/heal-common-issues.test.ts && node --import tsx --test tests/lib/epic-graph.test.ts && node --import tsx --test tests/components/shared/left-panel-filtering.test.ts && node --import tsx --test tests/hooks/use-beads-subscription-contract.test.ts && node --import tsx --test tests/components/graph/dependency-graph-hide-closed-contract.test.ts && node --import tsx --test tests/components/shared/unified-shell-hide-closed-contract.test.ts && node --import tsx --test tests/lib/agent-registry.test.ts && node --import tsx --test tests/api/agents-mail.test.ts && node --import tsx --test tests/skills/beadboard-driver/bb-mail-shim.test.ts && node --import tsx --test tests/skills/beadboard-driver/ensure-bb-mail-configured.test.ts && node --import tsx --test tests/lib/dispatch-prompt.test.ts && node --import tsx --test tests/components/shared/dispatch-button.test.tsx && node skills/beadboard-driver/tests/run-tests.mjs", + "test": "node --test tests/bootstrap.test.mjs && node --import tsx --test tests/pr14-critical-bugs.test.ts && node --import tsx --test tests/components/shared/base-card.test.tsx && node --import tsx --test tests/components/shared/agent-avatar.test.tsx && node --import tsx --test tests/components/sessions/sessions-header.test.ts && node --import tsx --test tests/components/sessions/agent-station-logic.test.ts && node --import tsx --test tests/lib/parser.test.ts && node --import tsx --test tests/lib/pathing.test.ts && node --import tsx --test tests/components/shared/left-panel.test.tsx && node --import tsx --test tests/components/shared/top-bar.test.tsx && node --import tsx --test tests/components/shared/mobile-nav.test.tsx && node --import tsx --test tests/components/swarm/swarm-card.test.tsx && node --import tsx --test tests/hooks/url-state-integration.test.ts && node --import tsx --test tests/hooks/use-graph-analysis.test.ts && node --import tsx --test tests/components/graph/smart-dag.test.tsx && node --import tsx --test tests/components/unified-shell.test.tsx && node --import tsx --test tests/components/blocked-triage-modal.test.tsx && node --import tsx --test tests/components/graph/graph-node-labels.test.tsx && node --import tsx --test tests/components/graph/graph-node-assign.test.tsx && node --import tsx --test tests/components/graph/graph-node-conversation.test.tsx && node --import tsx --test tests/lib/coord-schema.test.ts && node --import tsx --test tests/lib/install-manifest.test.ts && node --import tsx --test tests/lib/runtime-manager.test.ts && node --import tsx --test tests/lib/coord-events.test.ts && node --import tsx --test tests/api/coord-events-route.test.ts && node --import tsx --test tests/lib/coord-projections-inbox.test.ts && node --import tsx --test tests/lib/coord-projections-reservations.test.ts && node --import tsx --test tests/components/sessions/conversation-drawer-coord.test.tsx && node --import tsx --test tests/scripts/beadboard-launcher.test.ts && node --import tsx --test tests/scripts/beadboard-launcher-runtime.test.ts && node --import tsx --test tests/scripts/beadboard-launcher-status-text.test.ts && node --import tsx --test tests/scripts/install-wrappers-contract.test.ts && node --import tsx --test tests/scripts/install-sh-smoke.test.ts && node --import tsx --test tests/scripts/install-legacy-migration.test.ts && node --import tsx --test tests/scripts/installer-ci-contract.test.ts && node --import tsx --test tests/docs/installer-quickstart-contract.test.ts && node --import tsx --test tests/docs/runtime-manager-adr-contract.test.ts && node --import tsx --test tests/cli/beadboard-cli.test.ts && node --import tsx --test tests/cli/beadboard-bin-routing.test.ts && node --import tsx --test tests/cli/beadboard-help-output.test.ts && node --import tsx --test tests/skills/beadboard-driver/resolve-bb.test.ts && node --import tsx --test tests/skills/beadboard-driver/session-preflight.test.ts && node --import tsx --test tests/skills/beadboard-driver/generate-agent-name.test.ts && node --import tsx --test tests/skills/beadboard-driver/readiness-report.test.ts && node --import tsx --test tests/skills/beadboard-driver/skill-local-runner.test.ts && node --import tsx --test tests/skills/beadboard-driver/diagnose-env.test.ts && node --import tsx --test tests/skills/beadboard-driver/heal-common-issues.test.ts && node --import tsx --test tests/lib/epic-graph.test.ts && node --import tsx --test tests/components/shared/left-panel-filtering.test.ts && node --import tsx --test tests/hooks/use-beads-subscription-contract.test.ts && node --import tsx --test tests/components/graph/dependency-graph-hide-closed-contract.test.ts && node --import tsx --test tests/components/shared/unified-shell-hide-closed-contract.test.ts && node --import tsx --test tests/lib/agent-registry.test.ts && node --import tsx --test tests/api/agents-mail.test.ts && node --import tsx --test tests/skills/beadboard-driver/bb-mail-shim.test.ts && node --import tsx --test tests/skills/beadboard-driver/ensure-bb-mail-configured.test.ts && node --import tsx --test tests/lib/dispatch-prompt.test.ts && node --import tsx --test tests/components/shared/dispatch-button.test.tsx && node --import tsx --test tests/api/agent-dispatch-route.test.ts && node --import tsx --test tests/api/agent-status-route.test.ts && node skills/beadboard-driver/tests/run-tests.mjs", "video": "remotion preview src/video/index.ts", "video:render": "remotion render src/video/index.ts Main out/video.mp4", "video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60" diff --git a/src/app/api/agent-dispatch/route.ts b/src/app/api/agent-dispatch/route.ts new file mode 100644 index 0000000..ad36674 --- /dev/null +++ b/src/app/api/agent-dispatch/route.ts @@ -0,0 +1,98 @@ +import { NextResponse } from 'next/server'; + +import { buildDispatchPrompt } from '../../../lib/dispatch-prompt'; +import { readIssuesFromDisk } from '../../../lib/read-issues'; +import type { BeadIssue } from '../../../lib/types'; + +export const dynamic = 'force-dynamic'; + +const DISPATCH_AGENT = 'beads-task-runner'; +const DEFAULT_MAX_BUDGET_USD = 5; +const DEFAULT_TIMEOUT_SECONDS = 900; + +interface DispatchRequestBody { + taskId?: unknown; +} + +async function findBead(taskId: string): Promise { + const issues = await readIssuesFromDisk({ projectRoot: process.cwd(), preferBd: true }); + return issues.find((issue) => issue.id === taskId) ?? null; +} + +function missingEnv(): Response | null { + const serviceUrl = process.env.CLAUDE_AGENT_SERVICE_URL; + const token = process.env.CLAUDE_AGENT_BEARER_TOKEN; + if (!serviceUrl || !token) { + return NextResponse.json( + { error: 'Claude agent service is not configured (CLAUDE_AGENT_SERVICE_URL / CLAUDE_AGENT_BEARER_TOKEN missing).' }, + { status: 500 }, + ); + } + return null; +} + +export async function POST(request: Request): Promise { + let body: DispatchRequestBody; + try { + body = (await request.json()) as DispatchRequestBody; + } catch { + return NextResponse.json({ error: 'Invalid JSON body.' }, { status: 400 }); + } + + const taskId = typeof body.taskId === 'string' ? body.taskId.trim() : ''; + if (!taskId) { + return NextResponse.json({ error: 'taskId is required.' }, { status: 400 }); + } + + const envError = missingEnv(); + if (envError) return envError; + + const bead = await findBead(taskId); + if (!bead) { + return NextResponse.json({ error: `Bead ${taskId} not found.` }, { status: 400 }); + } + + if (!bead.acceptance_criteria?.trim()) { + return NextResponse.json({ error: `Bead ${taskId} is missing acceptance criteria.` }, { status: 400 }); + } + + const prompt = buildDispatchPrompt(bead); + const serviceUrl = process.env.CLAUDE_AGENT_SERVICE_URL!; + const token = process.env.CLAUDE_AGENT_BEARER_TOKEN!; + + try { + const response = await fetch(`${serviceUrl.replace(/\/$/, '')}/execute`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt, + agent: DISPATCH_AGENT, + max_budget_usd: DEFAULT_MAX_BUDGET_USD, + timeout_seconds: DEFAULT_TIMEOUT_SECONDS, + }), + }); + + if (response.status === 409) { + return NextResponse.json({ error: 'Agent is busy' }, { status: 409 }); + } + + const payload = await response.json().catch(() => null) as { job_id?: string; detail?: string } | null; + + if (!response.ok) { + const message = payload?.detail ?? `Agent service returned HTTP ${response.status}.`; + return NextResponse.json({ error: message }, { status: 502 }); + } + + if (!payload?.job_id) { + return NextResponse.json({ error: 'Agent service response missing job_id.' }, { status: 502 }); + } + + return NextResponse.json({ job_id: payload.job_id }, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to reach claude-agent-service.'; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/src/app/api/agent-status/route.ts b/src/app/api/agent-status/route.ts new file mode 100644 index 0000000..9e5b0b3 --- /dev/null +++ b/src/app/api/agent-status/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const CACHE_TTL_MS = 2000; + +interface StatusSnapshot { + busy: boolean; + fetchedAt: number; +} + +let cache: StatusSnapshot | null = null; +let inflight: Promise | null = null; + +async function fetchRemoteStatus(): Promise { + const serviceUrl = process.env.CLAUDE_AGENT_SERVICE_URL; + if (!serviceUrl) { + return { busy: false, fetchedAt: Date.now() }; + } + + const now = Date.now(); + try { + const response = await fetch(`${serviceUrl.replace(/\/$/, '')}/health`, { + cache: 'no-store', + headers: { 'Accept': 'application/json' }, + }); + + if (response.status === 503) { + return { busy: true, fetchedAt: now }; + } + + if (!response.ok) { + return { busy: false, fetchedAt: now }; + } + + const payload = await response.json().catch(() => null) as { busy?: boolean } | null; + return { busy: Boolean(payload?.busy), fetchedAt: now }; + } catch { + return { busy: false, fetchedAt: now }; + } +} + +async function getStatus(): Promise { + const now = Date.now(); + if (cache && now - cache.fetchedAt < CACHE_TTL_MS) { + return cache; + } + + if (inflight) { + return inflight; + } + + inflight = fetchRemoteStatus().then((snapshot) => { + cache = snapshot; + return snapshot; + }).finally(() => { + inflight = null; + }); + + return inflight; +} + +export async function GET(): Promise { + const snapshot = await getStatus(); + return NextResponse.json({ busy: snapshot.busy }); +} diff --git a/tests/api/agent-dispatch-route.test.ts b/tests/api/agent-dispatch-route.test.ts new file mode 100644 index 0000000..9ecfb4b --- /dev/null +++ b/tests/api/agent-dispatch-route.test.ts @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { POST } from '../../src/app/api/agent-dispatch/route'; + +function captureEnv(keys: string[]): () => void { + const saved = new Map(); + for (const key of keys) saved.set(key, process.env[key]); + return () => { + for (const key of keys) { + const prior = saved.get(key); + if (prior === undefined) delete process.env[key]; + else process.env[key] = prior; + } + }; +} + +test('POST returns 400 when body is not valid JSON', async () => { + const request = new Request('http://localhost/api/agent-dispatch', { + method: 'POST', + body: 'not-json', + headers: { 'content-type': 'application/json' }, + }); + + const response = await POST(request); + assert.equal(response.status, 400); + const body = await response.json(); + assert.match(body.error, /Invalid JSON/); +}); + +test('POST returns 400 when taskId missing', async () => { + const request = new Request('http://localhost/api/agent-dispatch', { + method: 'POST', + body: JSON.stringify({}), + headers: { 'content-type': 'application/json' }, + }); + + const response = await POST(request); + assert.equal(response.status, 400); + const body = await response.json(); + assert.match(body.error, /taskId is required/); +}); + +test('POST returns 500 when env vars are missing', async () => { + const restore = captureEnv(['CLAUDE_AGENT_SERVICE_URL', 'CLAUDE_AGENT_BEARER_TOKEN']); + delete process.env.CLAUDE_AGENT_SERVICE_URL; + delete process.env.CLAUDE_AGENT_BEARER_TOKEN; + try { + const request = new Request('http://localhost/api/agent-dispatch', { + method: 'POST', + body: JSON.stringify({ taskId: 'some-bead-id' }), + headers: { 'content-type': 'application/json' }, + }); + + const response = await POST(request); + assert.equal(response.status, 500); + const body = await response.json(); + assert.match(body.error, /not configured/); + } finally { + restore(); + } +}); diff --git a/tests/api/agent-status-route.test.ts b/tests/api/agent-status-route.test.ts new file mode 100644 index 0000000..4cb447d --- /dev/null +++ b/tests/api/agent-status-route.test.ts @@ -0,0 +1,84 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { GET } from '../../src/app/api/agent-status/route'; + +type FetchFn = typeof fetch; + +function withFetchStub(stub: FetchFn, work: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = stub; + return work().finally(() => { + globalThis.fetch = original; + }); +} + +function captureEnv(keys: string[]): () => void { + const saved = new Map(); + for (const key of keys) saved.set(key, process.env[key]); + return () => { + for (const key of keys) { + const prior = saved.get(key); + if (prior === undefined) delete process.env[key]; + else process.env[key] = prior; + } + }; +} + +test('GET returns {busy:false} when CLAUDE_AGENT_SERVICE_URL is unset', async () => { + const restore = captureEnv(['CLAUDE_AGENT_SERVICE_URL']); + delete process.env.CLAUDE_AGENT_SERVICE_URL; + try { + await withFetchStub(async () => { + throw new Error('fetch should not be called when URL is unset'); + }, async () => { + // Bust the module cache so cached snapshots from prior tests do not leak. + await import(`../../src/app/api/agent-status/route?url-unset=${Date.now()}`); + const response = await GET(); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.busy, false); + }); + } finally { + restore(); + } +}); + +test('GET proxies busy=true when /health returns busy=true', async () => { + const restore = captureEnv(['CLAUDE_AGENT_SERVICE_URL']); + process.env.CLAUDE_AGENT_SERVICE_URL = 'http://fake-agent.local'; + try { + const stub: FetchFn = async () => new Response(JSON.stringify({ status: 'ok', busy: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + + await withFetchStub(stub, async () => { + const mod = await import(`../../src/app/api/agent-status/route?busy-true=${Date.now()}`) as { GET: typeof GET }; + const response = await mod.GET(); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.busy, true); + }); + } finally { + restore(); + } +}); + +test('GET returns busy=true when /health returns HTTP 503', async () => { + const restore = captureEnv(['CLAUDE_AGENT_SERVICE_URL']); + process.env.CLAUDE_AGENT_SERVICE_URL = 'http://fake-agent.local'; + try { + const stub: FetchFn = async () => new Response('busy', { status: 503 }); + + await withFetchStub(stub, async () => { + const mod = await import(`../../src/app/api/agent-status/route?busy-503=${Date.now()}`) as { GET: typeof GET }; + const response = await mod.GET(); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.busy, true); + }); + } finally { + restore(); + } +});