feat(module2): chat with your agent — three canned prompts, then Layers 2/3

Module 2 is now a chat straight to the agent instead of Build & flash.
Removed the "Open your node" links and the Live board feed card.

New AgentChat component: three imperative canned prompts —
- List the I2C devices on the bus
- Count to 100 and print the value once a second in the LED matrix
- Scroll GO CLAWS on the LED matrix
Each sends to the agent (fire-and-forget, cloud) and streams its
activity (tools/flash/reply) back into the transcript. A prompt is marked
done on its first terminal step (flash/response); an error resets it to
retry. Prompts run one at a time.

Once all three have run successfully, Module 2 reveals "What's next" —
the ADD Layer 2 (Skills) + Layer 3 (Policies & failure) capture — and
Proceed gates on all-three-tried AND L2 + L3 filled.

Note: BuildFlash / LiveBoardFeed / ActorMap are now orphaned (kept for
possible reuse).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-22 01:39:25 -07:00
co-authored by Claude Opus 4.8
parent c2ef338888
commit 7780903278
4 changed files with 342 additions and 85 deletions
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, act, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AgentChat } from './AgentChat'
import { useSession } from '@/store/session'
import { sendPrompt } from '@/lib/api'
import type { WsEvent } from '@/types'
let emit: (e: WsEvent) => void = () => {}
vi.mock('@/lib/api', () => ({
sendPrompt: vi.fn().mockResolvedValue(undefined),
openTeamActivity: (_t: string, on: (e: WsEvent) => void) => {
emit = on
return () => {}
},
}))
const mockSend = vi.mocked(sendPrompt)
describe('AgentChat', () => {
beforeEach(() => {
useSession.getState().reset()
sessionStorage.clear()
mockSend.mockClear()
mockSend.mockResolvedValue(undefined)
})
it('sends the canned prompt and shows it in the transcript', async () => {
const user = userEvent.setup()
render(<AgentChat />)
await user.click(screen.getByTestId('prompt-i2c'))
expect(mockSend).toHaveBeenCalledWith(expect.any(String), 'List the I2C devices on the bus', 'cloud')
expect(screen.getByTestId('chat-transcript')).toHaveTextContent(/list the i2c devices/i)
expect(screen.getByTestId('prompt-i2c')).toHaveAttribute('data-state', 'running')
})
it('marks a prompt done on a terminal activity and reports progress', async () => {
const onProgress = vi.fn()
const user = userEvent.setup()
render(<AgentChat onProgress={onProgress} />)
await user.click(screen.getByTestId('prompt-scroll'))
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'flash', label: 'Flashed to 0x80F0000', ts: '' }))
await waitFor(() => expect(screen.getByTestId('prompt-scroll')).toHaveAttribute('data-state', 'done'))
expect(onProgress).toHaveBeenLastCalledWith(1, 3)
})
it('an error resets the prompt so it can be retried', async () => {
const user = userEvent.setup()
render(<AgentChat />)
await user.click(screen.getByTestId('prompt-count'))
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'error', label: 'boom', ts: '' }))
await waitFor(() => expect(screen.getByTestId('prompt-count')).toHaveAttribute('data-state', 'idle'))
expect(screen.getByTestId('prompt-count')).toBeEnabled()
})
})
+184
View File
@@ -0,0 +1,184 @@
import { useEffect, useRef, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity } from '@/lib/api'
import type { NodeActivityKind, WsEvent } from '@/types'
/**
* The three canned prompts, in order. Each drives the agent to use its skills +
* tools on the real board: enumerate the I2C bus, then two LED-matrix sketches.
* KEEP THESE IMPERATIVE — the small on-board model reliably calls tools when told
* to *do* something but stalls when *asked a question* (measured on the board).
*/
const PROMPTS: { id: string; text: string }[] = [
{ id: 'i2c', text: 'List the I2C devices on the bus' },
{ id: 'count', text: 'Count to 100 and print the value once a second in the LED matrix' },
{ id: 'scroll', text: 'Scroll GO CLAWS on the LED matrix' },
]
type Status = 'idle' | 'running' | 'done'
type Line =
| { who: 'you'; text: string }
| { who: 'agent'; kind: NodeActivityKind; text: string }
const KIND_DOT: Record<NodeActivityKind, string> = {
thinking: 'bg-muted-foreground',
tool: 'bg-amber',
flash: 'bg-primary',
error: 'bg-destructive',
response: 'bg-teal',
fallback: 'bg-rose',
}
// A prompt is "done" once the agent reaches a terminal step for it.
const isSuccess = (k: NodeActivityKind) => k === 'flash' || k === 'response'
export interface AgentChatProps {
/** Called whenever the set of successfully-tried prompts changes. */
onProgress?: (doneCount: number, total: number) => void
}
/**
* A chat window straight to the team's agent. Click a canned prompt and watch
* the agent use its skills/tools on the real board — its activity streams back
* as the reply. Tracks which of the three prompts have completed successfully.
*/
export function AgentChat({ onProgress }: AgentChatProps) {
const teamId = useSession((s) => s.teamId)
const [lines, setLines] = useState<Line[]>([])
const [status, setStatus] = useState<Record<string, Status>>({ i2c: 'idle', count: 'idle', scroll: 'idle' })
const running = useRef<string | null>(null)
const scroller = useRef<HTMLDivElement>(null)
const doneCount = Object.values(status).filter((s) => s === 'done').length
const busy = running.current !== null || Object.values(status).some((s) => s === 'running')
// Report progress without making the parent's inline callback a dependency
// (that would re-fire every render and loop with the parent's setState).
const onProgressRef = useRef(onProgress)
onProgressRef.current = onProgress
useEffect(() => {
onProgressRef.current?.(doneCount, PROMPTS.length)
}, [doneCount])
useEffect(() => {
if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight
}, [lines])
// Stream this team's own board activity as the agent's replies.
useEffect(() => {
return openTeamActivity(teamId, (ev: WsEvent) => {
if (ev.type !== 'node:activity') return
setLines((prev) => [...prev, { who: 'agent', kind: ev.kind, text: ev.label }])
const active = running.current
if (!active) return
if (ev.kind === 'error') {
running.current = null
setStatus((s) => ({ ...s, [active]: 'idle' })) // let them retry
} else if (isSuccess(ev.kind)) {
running.current = null
setStatus((s) => ({ ...s, [active]: 'done' }))
}
})
}, [teamId])
const run = async (id: string, text: string) => {
if (busy) return
running.current = id
setStatus((s) => ({ ...s, [id]: 'running' }))
setLines((prev) => [...prev, { who: 'you', text }])
try {
await sendPrompt(teamId, text, 'cloud')
} catch {
running.current = null
setStatus((s) => ({ ...s, [id]: 'idle' }))
setLines((prev) => [
...prev,
{ who: 'agent', kind: 'error', text: 'Could not reach your agent — is the board online?' },
])
}
}
return (
<Card data-testid="agent-chat">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="text-base">Chat with your agent</CardTitle>
<Badge variant="default" className="font-mono text-[10px] uppercase tracking-widest">
{doneCount}/{PROMPTS.length} tried
</Badge>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed">
Send one of these to your agent and watch it use its skills and tools on the real board
its activity streams back here. Try all three to see what it can already do.
</p>
{/* canned prompts */}
<div className="flex flex-col gap-2">
{PROMPTS.map((p) => {
const st = status[p.id]
return (
<button
key={p.id}
type="button"
data-testid={`prompt-${p.id}`}
data-state={st}
disabled={busy && st !== 'running'}
onClick={() => void run(p.id, p.text)}
className={cn(
'flex items-center gap-2.5 text-left rounded-md border px-3 py-2 text-sm transition-colors disabled:opacity-50',
st === 'done'
? 'border-teal/40 bg-teal/10'
: st === 'running'
? 'border-amber/40 bg-amber/5'
: 'border-border hover:border-primary/40 hover:bg-muted',
)}
>
<span
className={cn(
'w-2 h-2 rounded-full shrink-0',
st === 'done' ? 'bg-teal' : st === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground/50',
)}
/>
<span className="flex-1">{p.text}</span>
<span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground shrink-0">
{st === 'done' ? 'done ✓' : st === 'running' ? 'running…' : 'send →'}
</span>
</button>
)
})}
</div>
{/* transcript */}
<div
ref={scroller}
data-testid="chat-transcript"
className="rounded-md border border-border bg-background/50 p-3 h-56 overflow-y-auto space-y-2"
>
{lines.length === 0 ? (
<p className="font-mono text-[11px] text-muted-foreground">
Pick a prompt above your agent&rsquo;s work (tools, flashes, replies) shows up here.
</p>
) : (
lines.map((l, i) =>
l.who === 'you' ? (
<div key={i} className="flex justify-end">
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[85%]">{l.text}</span>
</div>
) : (
<div key={i} className="flex items-start gap-2 font-mono text-[11px]">
<span className={cn('w-1.5 h-1.5 rounded-full shrink-0 mt-1.5', KIND_DOT[l.kind])} />
<span className={cn(l.kind === 'error' && 'text-destructive', l.kind === 'flash' && 'text-foreground font-medium')}>
{l.text}
</span>
</div>
),
)
)}
</div>
</CardContent>
</Card>
)
}
+41 -12
View File
@@ -13,7 +13,6 @@ vi.mock('@/lib/api', async (orig) => ({
emit = on emit = on
return () => {} return () => {}
}, },
getNodeStatus: vi.fn().mockResolvedValue({ teamId: 't', online: false }),
sendPrompt: vi.fn().mockResolvedValue(undefined), sendPrompt: vi.fn().mockResolvedValue(undefined),
})) }))
@@ -25,6 +24,13 @@ function renderPage() {
) )
} }
/** Click a canned prompt and let the agent reach a terminal (success) step. */
async function completePrompt(user: ReturnType<typeof userEvent.setup>, id: string) {
await user.click(screen.getByTestId(`prompt-${id}`))
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'response', label: 'Agent finished', ts: '' }))
await waitFor(() => expect(screen.getByTestId(`prompt-${id}`)).toHaveAttribute('data-state', 'done'))
}
describe('Module2', () => { describe('Module2', () => {
beforeEach(() => { beforeEach(() => {
useSession.getState().reset() useSession.getState().reset()
@@ -39,31 +45,54 @@ describe('Module2', () => {
).toHaveAttribute('data-state', 'active') ).toHaveAttribute('data-state', 'active')
}) })
it('shows the live board feed and the build & flash panel', () => { it('is a chat with the three canned prompts — no live feed / build & flash', () => {
renderPage() renderPage()
expect(screen.getByTestId('live-board-feed')).toBeInTheDocument() expect(screen.getByTestId('agent-chat')).toBeInTheDocument()
expect(screen.getByTestId('activity-log')).toBeInTheDocument() expect(screen.getByTestId('prompt-i2c')).toBeInTheDocument()
expect(screen.getByTestId('prompt-count')).toBeInTheDocument()
expect(screen.getByTestId('prompt-scroll')).toBeInTheDocument()
expect(screen.queryByTestId('live-board-feed')).toBeNull()
expect(screen.queryByTestId('activity-log')).toBeNull()
}) })
it('gates Proceed until the board acted and L2 + L3 are filled', async () => { it('reveals "what\'s next" (the ADD layers) only after all three prompts succeed', async () => {
const user = userEvent.setup()
renderPage()
expect(screen.queryByTestId('whats-next')).toBeNull()
expect(screen.queryByLabelText(/layer 2/i)).toBeNull()
await completePrompt(user, 'i2c')
await completePrompt(user, 'count')
expect(screen.queryByTestId('whats-next')).toBeNull() // still one to go
await completePrompt(user, 'scroll')
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
expect(screen.getByLabelText(/layer 2/i)).toBeInTheDocument()
})
it('gates Proceed until all prompts ran AND L2 + L3 are filled', async () => {
const user = userEvent.setup()
useSession.getState().setAddLayer('L2', 'escalate on critical') useSession.getState().setAddLayer('L2', 'escalate on critical')
useSession.getState().setAddLayer('L3', 'drive damper on critical') useSession.getState().setAddLayer('L3', 'drive damper on critical')
renderPage() renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeDisabled()
// the board acts (via Build & flash) → activity streams from the node await completePrompt(user, 'i2c')
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'response', label: 'Agent finished', ts: '' })) await completePrompt(user, 'count')
await waitFor(() => expect(proceed).toBeEnabled()) await completePrompt(user, 'scroll')
const proceed = screen.getByRole('button', { name: /proceed/i })
expect(proceed).toBeEnabled()
}) })
it('marks m2 complete on Proceed', async () => { it('marks m2 complete on Proceed', async () => {
const user = userEvent.setup() const user = userEvent.setup()
renderPage() renderPage()
await completePrompt(user, 'i2c')
await completePrompt(user, 'count')
await completePrompt(user, 'scroll')
await user.type(screen.getByLabelText(/layer 2/i), 'L2 text') await user.type(screen.getByLabelText(/layer 2/i), 'L2 text')
await user.type(screen.getByLabelText(/layer 3/i), 'L3 text') await user.type(screen.getByLabelText(/layer 3/i), 'L3 text')
act(() => emit({ type: 'node:activity', teamId: 'x', kind: 'response', label: 'Agent finished', ts: '' })) await user.click(screen.getByRole('button', { name: /proceed/i }))
await user.click(await screen.findByRole('button', { name: /proceed/i }))
expect(useSession.getState().phases.m2).toBe(true) expect(useSession.getState().phases.m2).toBe(true)
}) })
}) })
+36 -46
View File
@@ -1,27 +1,22 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { PhaseStrip } from '@/components/PhaseStrip' import { PhaseStrip } from '@/components/PhaseStrip'
import { LiveBoardFeed } from '@/components/LiveBoardFeed' import { AgentChat } from '@/components/AgentChat'
import { BuildFlash } from '@/components/BuildFlash'
import { AddLayerForm } from '@/components/AddLayerForm' import { AddLayerForm } from '@/components/AddLayerForm'
import { OpenYourNode } from '@/components/OpenYourNode'
import { useNodeFeed } from '@/lib/useNodeFeed'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
export function Module2() { export function Module2() {
const navigate = useNavigate() const navigate = useNavigate()
const teamId = useSession((s) => s.teamId)
const feed = useNodeFeed(teamId, true)
const l2 = useSession((s) => s.add.L2) const l2 = useSession((s) => s.add.L2)
const l3 = useSession((s) => s.add.L3) const l3 = useSession((s) => s.add.L3)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
const [tried, setTried] = useState(0)
// The board acting on a prompt (via Build & flash below) shows up in its const allTried = tried >= 3
// activity feed — that's proof the loop was exercised. const ready = allTried && l2.trim().length > 0 && l3.trim().length > 0
const exercised = feed.activity.length > 0
const ready = exercised && l2.trim().length > 0 && l3.trim().length > 0
const onProceed = () => { const onProceed = () => {
completePhase('m2') completePhase('m2')
@@ -42,55 +37,37 @@ export function Module2() {
<PhaseStrip active="m2" /> <PhaseStrip active="m2" />
<section className="px-8 py-10 max-w-5xl mx-auto space-y-6"> <section className="px-8 py-10 max-w-3xl mx-auto space-y-6">
<div> <div>
<Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2"> <Badge variant="secondary" className="font-mono text-[10px] tracking-widest uppercase mb-2">
Phase 4 of 5 · ~90 min Phase 4 of 5 · ~90 min
</Badge> </Badge>
<h1 className="text-3xl font-bold tracking-tight">Module 2 · Skills &amp; policies</h1> <h1 className="text-3xl font-bold tracking-tight">Module 2 · Skills &amp; policies</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-xl"> <p className="text-sm text-muted-foreground mt-2 max-w-xl">
Your node ships with expert skills already. Decide which domain skills it needs, try them in the Your agent already ships with expert skills hardware, the MCU bridge, the LED matrix,
node dashboard, then capture Layers 2 and 3 the skills it can invoke and the actuation gate flashing, and more. Try them from the chat below: each prompt makes the agent use its
that governs them. skills and tools on your real board.
</p> </p>
<div className="mt-3">
<OpenYourNode variant="inline" />
</div>
</div> </div>
<Card> <AgentChat onProgress={(done) => setTried(done)} />
<CardHeader>
<CardTitle className="text-base">Skills reference</CardTitle> {/* What's next — revealed once all three prompts have run successfully. */}
</CardHeader> {allTried ? (
<CardContent className="space-y-3"> <div className="space-y-6" data-testid="whats-next">
<p className="text-sm text-muted-foreground leading-relaxed"> <div className="pt-2">
The node already ships with expert skills <span className="font-mono text-xs text-foreground">uno-q-hardware</span>,{' '} <h2 className="text-lg font-semibold tracking-tight">What&rsquo;s next</h2>
<span className="font-mono text-xs text-foreground">bridge</span>,{' '} <p className="text-sm text-muted-foreground mt-1 max-w-xl">
<span className="font-mono text-xs text-foreground">led-matrix</span>,{' '} You just watched the agent enumerate a bus and drive the matrix using its built-in
<span className="font-mono text-xs text-foreground">flashing</span>, and more. Layer 2 is not skills. Now capture <span className="font-medium text-foreground">your domain&rsquo;s</span>{' '}
about re-implementing those: it&rsquo;s deciding which <em>domain</em> skills your node needs, then skills and the policy that governs them Layers 2 and 3 of your Agent Design Document.
trying them live in the dashboard.
</p> </p>
<OpenYourNode variant="inline" /> </div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Live board feed</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<LiveBoardFeed feed={feed} />
</CardContent>
</Card>
<BuildFlash />
<div className="grid lg:grid-cols-2 gap-6">
<AddLayerForm <AddLayerForm
layer="L2" layer="L2"
title="ADD · Layer 2 — Skills" title="ADD · Layer 2 — Skills"
description="What skills can the node invoke to act on its domain?" description="What skills can the agent invoke to act on its domain?"
placeholder="Flash a sketch to the MCU; scroll a message; drive the damper; sample the IMU." placeholder="Flash a sketch to the MCU; scroll a message; drive the damper; sample the IMU."
/> />
<AddLayerForm <AddLayerForm
@@ -106,13 +83,26 @@ export function Module2() {
'• agent unsure → escalate to a human, do not actuate' '• agent unsure → escalate to a human, do not actuate'
} }
/> />
</div>
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
<Button size="lg" disabled={!ready} onClick={onProceed}> <Button size="lg" disabled={!ready} onClick={onProceed}>
Proceed to ADD builder Proceed to ADD builder
</Button> </Button>
</div> </div>
</div>
) : (
<Card>
<CardHeader>
<CardTitle className="text-base text-muted-foreground">What&rsquo;s next</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground leading-relaxed">
Try all three prompts above. Once your agent has run each one successfully, we&rsquo;ll
capture your domain&rsquo;s skills and policies here.
</p>
</CardContent>
</Card>
)}
</section> </section>
</main> </main>
) )