beadboard/tests/lib/dispatch-prompt.test.ts
Viktor Barzin 394a771b67 [beadboard] Add Dispatch-to-Agent button and prompt template
## Context

We want a one-click path from the right-panel task detail into the
claude-agent-service runner, without the user copy-pasting the bead id
into a CLI or another tab. The runner expects a self-contained prompt
that restates the bead id, title, description, acceptance criteria, and
the guard rails the agent must operate under (no push, no file edits,
no terraform/kubectl/helm). The prompt template lives in `src/lib/` so
it can be tested and reused from the server-side dispatch route.

The right-panel button needs to:
- Only appear when the bead is actionable (`open` or `in_progress`).
- Disable itself while the claude-agent-service is already busy (the
  service has a global `asyncio.Lock` — parallel dispatches 409).
- Disable itself when the bead lacks acceptance criteria. An agent that
  doesn't know what "done" looks like burns budget and closes nothing.
- Surface the resulting `job_id` or any 409/error back to the user.

The project has no toast library (no `sonner`, no `react-hot-toast`), so
we render status inline under the button rather than pulling in a new
dependency for this single surface.

## This change

- `src/lib/dispatch-prompt.ts` exports `buildDispatchPrompt(bead)` which
  produces the exact prompt the agent runner expects. Bead id, priority
  (`P<n>`), issue type, description, and acceptance criteria are
  interpolated; `<job_id>` stays a literal placeholder because the agent
  only learns its own id at runtime (env var).
- `src/components/shared/dispatch-button.tsx` is a focused client
  component with three responsibilities:
    1. Poll `GET /api/agent-status` every 5 s while the panel is open
       (plus an initial fetch on mount), mirror `busy` into local state.
    2. On click, `POST /api/agent-dispatch` with `{taskId}`; branch on
       200 / 409 / other.
    3. Render an inline status line under the button (`text-xs`, tone
       driven by `ok | info | error`) — no toast dep required.
  The poll interval self-clears on unmount so closing the panel stops
  network traffic.
- `src/components/shared/thread-drawer.tsx` renders `<DispatchButton>`
  alongside the existing "Edit task" button in the summary section,
  wrapped in a `flex-wrap` so the two controls reflow on narrow panes.
- Registers two new tests in `package.json`'s enumerated test script.

## What is NOT in this change

- The `/api/agent-dispatch` and `/api/agent-status` routes themselves —
  those land in the next commit. The button calls them but the server
  side is intentionally a separate step so each commit can be reviewed
  in isolation.
- No real toast system is introduced; inline status is sufficient.
- No change to how task state transitions on dispatch. The agent itself
  is expected to run `bd update --claim` / `bd close` via the prompt's
  operating rules.

## Test Plan

### Automated

```
$ node --import tsx --test tests/lib/dispatch-prompt.test.ts \
    tests/components/shared/dispatch-button.test.tsx
# tests 7 pass 7 fail 0
```

Covers:
- Bead id appears in opening paragraph and in both `bd note` / `bd close`
  commands.
- Priority rendered as `P<n>`, issue type echoed.
- Description and acceptance criteria quoted verbatim when present.
- `(no description)` / `(no acceptance criteria)` fallbacks when null.
- Guard rails block present (no terraform/kubectl/helm, workspace bd
  path, `bd update … --status blocked` fallback).
- DispatchButton module loads and exports both named and default.

`npm run typecheck` shows only the pre-existing `OrchestratorChatMessage`
type gap in `left-panel.tsx` that reproduces on untouched `main`.

### Manual Verification

1. `npm install`
2. `npm run dev`
3. Open `http://localhost:3000/?task=<some-open-bead-id>`
4. Expected: "Dispatch to Agent" button next to "Edit task" in the
   right-panel summary section.
5. Button disabled on beads with `status in {closed, blocked, deferred}`
   (they don't render the button at all).
6. Button disabled on beads missing acceptance criteria, with tooltip
   "Task is missing acceptance criteria — cannot dispatch.".
7. Click: UI flips to "Dispatching…"; once the next commit is merged,
   the agent-dispatch route will surface a `job_id` (today it returns
   404 which renders as "Dispatch failed (HTTP 404)").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 14:04:34 +00:00

76 lines
2.5 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDispatchPrompt } from '../../src/lib/dispatch-prompt';
import type { BeadIssue } from '../../src/lib/types';
function makeIssue(overrides: Partial<BeadIssue> = {}): BeadIssue {
return {
id: 'beadboard-abc.1',
title: 'Sample task',
description: 'Describe the work in detail.',
status: 'open',
priority: 1,
issue_type: 'task',
assignee: null,
templateId: null,
owner: null,
labels: [],
dependencies: [],
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
closed_at: null,
close_reason: null,
closed_by_session: null,
created_by: null,
due_at: null,
estimated_minutes: null,
external_ref: null,
acceptance_criteria: 'Checkbox: done when X.',
metadata: {},
...overrides,
};
}
test('buildDispatchPrompt includes bead id in opening and close commands', () => {
const prompt = buildDispatchPrompt(makeIssue({ id: 'beadboard-77.3' }));
assert.match(prompt, /beads-task-runner/);
assert.match(prompt, /`beadboard-77\.3`/);
assert.match(prompt, /bd note beadboard-77\.3 "claimed by agent <job_id>"/);
assert.match(prompt, /bd close beadboard-77\.3 -r "completed by agent <job_id>"/);
});
test('buildDispatchPrompt renders priority as P<n> and includes issue type', () => {
const prompt = buildDispatchPrompt(makeIssue({ priority: 2, issue_type: 'bug' }));
assert.match(prompt, /\(P2, type=bug\)/);
});
test('buildDispatchPrompt includes description and acceptance criteria verbatim', () => {
const prompt = buildDispatchPrompt(makeIssue({
description: 'Concrete description here.',
acceptance_criteria: 'Concrete acceptance here.',
}));
assert.ok(prompt.includes('Concrete description here.'));
assert.ok(prompt.includes('Concrete acceptance here.'));
});
test('buildDispatchPrompt falls back when description or acceptance are missing', () => {
const prompt = buildDispatchPrompt(makeIssue({
description: null,
acceptance_criteria: null,
}));
assert.ok(prompt.includes('(no description)'));
assert.ok(prompt.includes('(no acceptance criteria)'));
});
test('buildDispatchPrompt includes operating guard rails', () => {
const prompt = buildDispatchPrompt(makeIssue());
assert.match(prompt, /bd --db \/workspace\/\.beads/);
assert.match(prompt, /Do NOT push, do NOT edit files, do NOT run terraform\/kubectl\/helm\./);
assert.match(prompt, /bd update beadboard-abc\.1 --status blocked/);
});