[beadboard] Add agent-dispatch and agent-status API routes
## 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) <noreply@anthropic.com>
This commit is contained in:
parent
394a771b67
commit
a7b4f7ba32
5 changed files with 311 additions and 1 deletions
|
|
@ -13,7 +13,7 @@
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"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": "remotion preview src/video/index.ts",
|
||||||
"video:render": "remotion render src/video/index.ts Main out/video.mp4",
|
"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"
|
"video:thumbnail": "remotion still src/video/index.ts Main out/thumbnail.png --frame=60"
|
||||||
|
|
|
||||||
98
src/app/api/agent-dispatch/route.ts
Normal file
98
src/app/api/agent-dispatch/route.ts
Normal file
|
|
@ -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<BeadIssue | null> {
|
||||||
|
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<Response> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/app/api/agent-status/route.ts
Normal file
66
src/app/api/agent-status/route.ts
Normal file
|
|
@ -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<StatusSnapshot> | null = null;
|
||||||
|
|
||||||
|
async function fetchRemoteStatus(): Promise<StatusSnapshot> {
|
||||||
|
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<StatusSnapshot> {
|
||||||
|
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<Response> {
|
||||||
|
const snapshot = await getStatus();
|
||||||
|
return NextResponse.json({ busy: snapshot.busy });
|
||||||
|
}
|
||||||
62
tests/api/agent-dispatch-route.test.ts
Normal file
62
tests/api/agent-dispatch-route.test.ts
Normal file
|
|
@ -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<string, string | undefined>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
84
tests/api/agent-status-route.test.ts
Normal file
84
tests/api/agent-status-route.test.ts
Normal file
|
|
@ -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<T>(stub: FetchFn, work: () => Promise<T>): Promise<T> {
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
globalThis.fetch = stub;
|
||||||
|
return work().finally(() => {
|
||||||
|
globalThis.fetch = original;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureEnv(keys: string[]): () => void {
|
||||||
|
const saved = new Map<string, string | undefined>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue