From 394a771b675cbe487217748800f6029d63abd04a Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 18 Apr 2026 14:04:34 +0000 Subject: [PATCH] [beadboard] Add Dispatch-to-Agent button and prompt template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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`), issue type, description, and acceptance criteria are interpolated; `` 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 `` 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`, 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=` 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) --- package.json | 2 +- src/components/shared/dispatch-button.tsx | 126 ++++++++++++++++++ src/components/shared/thread-drawer.tsx | 4 +- src/lib/dispatch-prompt.ts | 25 ++++ .../shared/dispatch-button.test.tsx | 14 ++ tests/lib/dispatch-prompt.test.ts | 76 +++++++++++ 6 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/components/shared/dispatch-button.tsx create mode 100644 src/lib/dispatch-prompt.ts create mode 100644 tests/components/shared/dispatch-button.test.tsx create mode 100644 tests/lib/dispatch-prompt.test.ts diff --git a/package.json b/package.json index a2cd088..e26abff 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 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 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/components/shared/dispatch-button.tsx b/src/components/shared/dispatch-button.tsx new file mode 100644 index 0000000..abf2524 --- /dev/null +++ b/src/components/shared/dispatch-button.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Rocket } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; + +import type { BeadIssue } from '../../lib/types'; + +interface DispatchButtonProps { + bead: BeadIssue; +} + +type StatusMessage = { kind: 'info' | 'ok' | 'error'; text: string }; + +const DISPATCHABLE_STATUSES = new Set(['open', 'in_progress']); +const POLL_INTERVAL_MS = 5000; + +export function DispatchButton({ bead }: DispatchButtonProps) { + const [agentBusy, setAgentBusy] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [status, setStatus] = useState(null); + const mountedRef = useRef(true); + + const isDispatchable = DISPATCHABLE_STATUSES.has(bead.status); + const hasAcceptance = Boolean(bead.acceptance_criteria?.trim()); + + const fetchAgentStatus = useCallback(async () => { + try { + const response = await fetch('/api/agent-status', { cache: 'no-store' }); + if (!response.ok) return; + const payload = await response.json() as { busy?: boolean }; + if (mountedRef.current) { + setAgentBusy(Boolean(payload.busy)); + } + } catch { + // Leave previous state; fail closed is not required for a heartbeat. + } + }, []); + + useEffect(() => { + mountedRef.current = true; + if (!isDispatchable) { + return () => { + mountedRef.current = false; + }; + } + + void fetchAgentStatus(); + const handle = window.setInterval(() => { void fetchAgentStatus(); }, POLL_INTERVAL_MS); + return () => { + mountedRef.current = false; + window.clearInterval(handle); + }; + }, [isDispatchable, fetchAgentStatus]); + + const handleDispatch = useCallback(async () => { + setSubmitting(true); + setStatus({ kind: 'info', text: 'Submitting…' }); + try { + const response = await fetch('/api/agent-dispatch', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ taskId: bead.id }), + }); + const payload = await response.json().catch(() => ({} as Record)); + if (response.status === 409) { + setStatus({ kind: 'error', text: 'Agent busy — try again shortly' }); + setAgentBusy(true); + return; + } + if (!response.ok) { + const message = typeof payload.error === 'string' ? payload.error : `Dispatch failed (HTTP ${response.status})`; + setStatus({ kind: 'error', text: message }); + return; + } + const jobId = typeof payload.job_id === 'string' ? payload.job_id : 'unknown'; + setStatus({ kind: 'ok', text: `Job \`${jobId}\` submitted` }); + setAgentBusy(true); + } catch (error) { + const message = error instanceof Error ? error.message : 'Network error'; + setStatus({ kind: 'error', text: message }); + } finally { + setSubmitting(false); + } + }, [bead.id]); + + if (!isDispatchable) { + return null; + } + + const disabled = submitting || agentBusy || !hasAcceptance; + const hint = !hasAcceptance + ? 'Task is missing acceptance criteria — cannot dispatch.' + : agentBusy + ? 'Agent is currently busy.' + : 'Hand this task to the claude-agent-service runner.'; + + const statusToneClass = status?.kind === 'ok' + ? 'text-[var(--ui-accent-ready)]' + : status?.kind === 'error' + ? 'text-[#EAA7A0]' + : 'text-[var(--ui-text-muted)]'; + + return ( +
+ + {status ? ( +

+ {status.text} +

+ ) : null} +
+ ); +} + +export default DispatchButton; diff --git a/src/components/shared/thread-drawer.tsx b/src/components/shared/thread-drawer.tsx index e3e38f3..273fae4 100644 --- a/src/components/shared/thread-drawer.tsx +++ b/src/components/shared/thread-drawer.tsx @@ -13,6 +13,7 @@ import type { UpdateMutationPayload } from '../../lib/mutations'; import type { BeadIssue } from '../../lib/types'; import { ThreadView, type ThreadItem } from './thread-view'; import { useResponsive } from '../../hooks/use-responsive'; +import { DispatchButton } from './dispatch-button'; interface ThreadDrawerProps { isOpen: boolean; @@ -284,7 +285,7 @@ export function ThreadDrawer({ {issue.issue_type} {issue.assignee ? @{issue.assignee} : null} -
+
+
) : ( diff --git a/src/lib/dispatch-prompt.ts b/src/lib/dispatch-prompt.ts new file mode 100644 index 0000000..4b59679 --- /dev/null +++ b/src/lib/dispatch-prompt.ts @@ -0,0 +1,25 @@ +import type { BeadIssue } from './types'; + +export function buildDispatchPrompt(bead: BeadIssue): string { + const priority = `P${bead.priority}`; + const description = bead.description?.trim() ?? '(no description)'; + const acceptance = bead.acceptance_criteria?.trim() ?? '(no acceptance criteria)'; + + return [ + `You are the "beads-task-runner" agent picking up beads task \`${bead.id}\`.`, + ``, + `Task: ${bead.title} (${priority}, type=${bead.issue_type})`, + `Description:`, + description, + ``, + `Acceptance criteria:`, + acceptance, + ``, + `Operating rules:`, + `- Always use \`bd --db /workspace/.beads …\` for every bd call.`, + `- First action: \`bd note ${bead.id} "claimed by agent "\`.`, + `- Do NOT push, do NOT edit files, do NOT run terraform/kubectl/helm.`, + `- If the task is outside those rails, run \`bd update ${bead.id} --status blocked\` with a note and stop.`, + `- On success: \`bd close ${bead.id} -r "completed by agent "\`.`, + ].join('\n'); +} diff --git a/tests/components/shared/dispatch-button.test.tsx b/tests/components/shared/dispatch-button.test.tsx new file mode 100644 index 0000000..511f0c6 --- /dev/null +++ b/tests/components/shared/dispatch-button.test.tsx @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +test('DispatchButton module exports component', async () => { + const mod = await import('../../../src/components/shared/dispatch-button'); + assert.ok(mod.DispatchButton, 'DispatchButton should be exported'); + assert.equal(typeof mod.DispatchButton, 'function', 'DispatchButton should be a function'); +}); + +test('DispatchButton module exports default', async () => { + const mod = await import('../../../src/components/shared/dispatch-button'); + assert.ok(mod.default, 'default export should exist'); + assert.equal(mod.default, mod.DispatchButton, 'default should match named export'); +}); diff --git a/tests/lib/dispatch-prompt.test.ts b/tests/lib/dispatch-prompt.test.ts new file mode 100644 index 0000000..44948a3 --- /dev/null +++ b/tests/lib/dispatch-prompt.test.ts @@ -0,0 +1,76 @@ +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 { + 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 "/); + assert.match(prompt, /bd close beadboard-77\.3 -r "completed by agent "/); +}); + +test('buildDispatchPrompt renders priority as P 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/); +});