feat(phase2): real say-hi chat + Connected node state on Meet-your-node

- Open your node: shows a 'Connected' badge (+ board name) once the board is claimed.
- Say hi: replaces the liveness-poll self-test with an actual round-trip to the
  agent on the team's own board — sends a greeting, shows the agent's reply, and
  only unlocks once it answers. New blocking /nodes/:teamId/say-hi route (greeting
  never flashes, so no hang risk) + bridge.sayHi + client sayHi(). The /prompt route
  stays fire-and-forget for the flash path.
- Pick your domain already captures to the store and syncs to the server DB via
  useCollectiveSync -> pushTeam, available to later steps/judging.
- Proceed gates on connected + agent-replied + domain. Tests updated + say-hi
  coverage (route + page).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-21 09:26:50 -07:00
co-authored by Claude Opus 4.8
parent fc5f66021f
commit cefddb245c
7 changed files with 183 additions and 84 deletions
+16
View File
@@ -248,6 +248,22 @@ export function createApp(opts: AppOptions): Express {
res.status(202).json({ accepted: true }) res.status(202).json({ accepted: true })
}) })
// Onboarding "say hi": prompt the node and WAIT for its reply (blocking) so the
// participant sees their agent answer. Greeting only — never a flash turn.
app.post('/nodes/:teamId/say-hi', async (req, res) => {
if (!nodes) return res.status(503).json({ error: 'node bridge unavailable' })
const b = req.body ?? {}
const message = typeof b.message === 'string' && b.message.trim() ? b.message : 'Hi! Introduce yourself in one sentence.'
const agent = typeof b.agent === 'string' ? b.agent : undefined
try {
const reply = await nodes.sayHi(String(req.params.teamId), message, agent)
if (reply === null) return res.status(404).json({ error: 'no node registered for team' })
res.json({ reply })
} catch {
res.status(502).json({ error: 'your node did not answer — is it online?' })
}
})
// Public liveness for a team's board — the wizard/self-test polls this after // Public liveness for a team's board — the wizard/self-test polls this after
// a claim. Online reflects the bridge's live /health + SSE view. // a claim. Online reflects the bridge's live /health + SSE view.
app.get('/nodes/:teamId/status', (req, res) => { app.get('/nodes/:teamId/status', (req, res) => {
+18
View File
@@ -27,6 +27,10 @@ describe('node bridge + /nodes routes', () => {
send: async (node, message, agent) => { send: async (node, message, agent) => {
sent.push({ node, message, agent }) sent.push({ node, message, agent })
}, },
sendAndWait: async (node, message, agent) => {
sent.push({ node, message, agent })
return `echo: ${message}`
},
subscribe: () => () => {}, // no live SSE in the unit test subscribe: () => () => {}, // no live SSE in the unit test
}) })
app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes }) app = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE, nodes })
@@ -78,6 +82,20 @@ describe('node bridge + /nodes routes', () => {
await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400) await request(app).post('/nodes/t1/prompt').send({ message: ' ' }).expect(400)
}) })
it('say-hi waits for the node reply; 404s an unregistered team', async () => {
await request(app).post('/nodes/ghost/say-hi').send({}).expect(404)
await request(app)
.post('/nodes')
.set('x-access-code', ADMIN)
.send({ teamId: 't1', url: 'http://n', token: 'zc_secret' })
.expect(201)
const res = await request(app).post('/nodes/t1/say-hi').send({ agent: 'cloud' }).expect(200)
expect(res.body.reply).toMatch(/^echo: /)
// a default greeting is sent when no message is supplied
expect(sent.at(-1)?.message).toMatch(/introduce yourself/i)
expect(sent.at(-1)?.agent).toBe('cloud')
})
it('503s when no bridge is configured', async () => { it('503s when no bridge is configured', async () => {
const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE }) const bare = createApp({ store, broadcast: () => {}, adminCode: ADMIN, judgeCode: JUDGE })
await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503) await request(bare).get('/nodes').set('x-access-code', ADMIN).expect(503)
+26
View File
@@ -123,6 +123,22 @@ export async function sendPrompt(node: NodeRef, message: string, agent = 'defaul
}) })
} }
/**
* Prompt a node and WAIT for its reply text (the blocking `/webhook` response).
* Only for non-flashing turns (e.g. the onboarding greeting) — a flash turn must
* stay fire-and-forget via {@link sendPrompt} or it hangs the agent's task.
*/
export async function promptAndWait(node: NodeRef, message: string, agent = 'default'): Promise<string> {
const res = await fetch(`${node.url}/webhook?agent=${encodeURIComponent(agent)}`, {
method: 'POST',
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
body: JSON.stringify({ message }),
})
if (!res.ok) throw new Error(`webhook ${res.status}`)
const body = (await res.json().catch(() => ({}))) as { response?: string }
return (body.response ?? '').trim()
}
export interface SubscribeOptions { export interface SubscribeOptions {
/** Aborts the whole reconnect loop when fired. */ /** Aborts the whole reconnect loop when fired. */
signal?: AbortSignal signal?: AbortSignal
@@ -219,6 +235,9 @@ export interface NodeBridge {
remove(teamId: string): void remove(teamId: string): void
list(): NodeView[] list(): NodeView[]
prompt(teamId: string, message: string, agent?: string): Promise<boolean> prompt(teamId: string, message: string, agent?: string): Promise<boolean>
/** Say-hi: prompt the node and return its reply text (blocking). `null` if the
* team has no registered node. Non-flash use only (the greeting). */
sayHi(teamId: string, message: string, agent?: string): Promise<string | null>
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */ /** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
stopAll(): void stopAll(): void
@@ -230,6 +249,7 @@ export interface NodeBridgeDeps {
/** Injectable for tests. */ /** Injectable for tests. */
ping?: (n: NodeRef) => Promise<boolean> ping?: (n: NodeRef) => Promise<boolean>
send?: (n: NodeRef, m: string, agent?: string) => Promise<void> send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
sendAndWait?: (n: NodeRef, m: string, agent?: string) => Promise<string>
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
} }
@@ -243,6 +263,7 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
const registry = deps.registry ?? createNodeRegistry() const registry = deps.registry ?? createNodeRegistry()
const ping = deps.ping ?? pingNode const ping = deps.ping ?? pingNode
const send = deps.send ?? sendPrompt const send = deps.send ?? sendPrompt
const sendAndWait = deps.sendAndWait ?? promptAndWait
const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus })) const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
const online = new Map<string, boolean>() const online = new Map<string, boolean>()
const stops = new Map<string, () => void>() const stops = new Map<string, () => void>()
@@ -287,6 +308,11 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
await send(node, message, agent) await send(node, message, agent)
return true return true
}, },
async sayHi(teamId, message, agent) {
const node = registry.get(teamId)
if (!node) return null
return sendAndWait(node, message, agent)
},
onTeamActivity(teamId, listener) { onTeamActivity(teamId, listener) {
let set = teamListeners.get(teamId) let set = teamListeners.get(teamId)
if (!set) { if (!set) {
+7 -2
View File
@@ -58,9 +58,14 @@ export function OpenYourNode({ variant = 'hero', className }: OpenYourNodeProps)
)} )}
> >
<div> <div>
<div className="text-xl font-bold tracking-tight">Open your node →</div> <div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight">Open your node →</span>
<span className="font-mono text-[9px] uppercase tracking-widest text-teal border border-teal/40 bg-teal/10 rounded px-1.5 py-0.5">
Connected
</span>
</div>
<div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs"> <div className="font-mono text-[10px] text-muted-foreground mt-1 truncate max-w-xs">
{device.nodeUrl} {device.port ? `${device.port} · ` : ''}{device.nodeUrl}
</div> </div>
</div> </div>
<span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden /> <span className="w-2.5 h-2.5 rounded-full bg-teal animate-pulse shrink-0" aria-hidden />
+14
View File
@@ -161,6 +161,20 @@ export async function releaseBoard(kit: string, code: string): Promise<{ release
// --- ZeroClaw node (participant) ------------------------------------------ // --- ZeroClaw node (participant) ------------------------------------------
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */ /** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
/** Onboarding "say hi": prompt the team's node and wait for its reply text. */
export async function sayHi(teamId: string, agent?: string, message?: string): Promise<string> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/say-hi`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ agent, message }),
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error ?? `sayHi ${res.status}`)
}
return ((await res.json()) as { reply: string }).reply
}
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> { export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, { const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
method: 'POST', method: 'POST',
+45 -49
View File
@@ -1,15 +1,15 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react' import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom' import { MemoryRouter } from 'react-router-dom'
import { EnvSetup } from './EnvSetup' import { EnvSetup } from './EnvSetup'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { getNodeStatus } from '@/lib/api' import { sayHi } from '@/lib/api'
vi.mock('@/lib/api', async (orig) => ({ vi.mock('@/lib/api', async (orig) => ({
...(await orig<typeof import('@/lib/api')>()), ...(await orig<typeof import('@/lib/api')>()),
getNodeStatus: vi.fn(), sayHi: vi.fn(),
})) }))
const mockNodeStatus = vi.mocked(getNodeStatus) const mockSayHi = vi.mocked(sayHi)
function renderPage() { function renderPage() {
return render( return render(
@@ -21,13 +21,14 @@ function renderPage() {
/** Connect a board with a live node URL — the common precondition. */ /** Connect a board with a live node URL — the common precondition. */
function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') { function connect(nodeUrl: string | null = 'http://192.168.1.7:8080') {
useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0, nodeUrl }) useSession.getState().setDevice({ connected: true, port: 'board · crimson-otter', uptimeS: 0, nodeUrl })
} }
describe('EnvSetup — Meet your node', () => { describe('EnvSetup — Meet your node', () => {
beforeEach(() => { beforeEach(() => {
useSession.getState().reset() useSession.getState().reset()
sessionStorage.clear() sessionStorage.clear()
mockSayHi.mockReset()
}) })
it('renders the phase strip set to setup and the heading', () => { it('renders the phase strip set to setup and the heading', () => {
@@ -42,19 +43,17 @@ describe('EnvSetup — Meet your node', () => {
it('prompts to claim a board first when not connected', () => { it('prompts to claim a board first when not connected', () => {
renderPage() renderPage()
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument() expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /open your node/i })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: /say hi to your agent/i })).toBeDisabled()
// say-hi is disabled with no device
expect(screen.getByRole('button', { name: /say hi \/ confirm online/i })).toBeDisabled()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
}) })
it('renders the Open-your-node CTA to the board url when connected with a nodeUrl', () => { it('shows the node as Connected with the board url when claimed', () => {
connect('http://192.168.1.7:8080') connect('http://192.168.1.7:8080')
renderPage() renderPage()
const link = screen.getByRole('link', { name: /open your node/i }) const link = screen.getByRole('link', { name: /open your node/i })
expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080') expect(link).toHaveAttribute('href', 'http://192.168.1.7:8080')
expect(link).toHaveAttribute('target', '_blank') expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer') expect(screen.getByText(/^connected$/i)).toBeInTheDocument()
}) })
it('falls back to the claim prompt when connected but there is no nodeUrl', () => { it('falls back to the claim prompt when connected but there is no nodeUrl', () => {
@@ -64,55 +63,52 @@ describe('EnvSetup — Meet your node', () => {
expect(screen.getByText(/claim your board first/i)).toBeInTheDocument() expect(screen.getByText(/claim your board first/i)).toBeInTheDocument()
}) })
it('requires a domain even after the board is online', async () => { it('says hi to the agent and shows its reply', async () => {
vi.useFakeTimers() mockSayHi.mockResolvedValue("Hi! I'm your node — I can read your sensor and drive the matrix.")
try { connect()
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true }) renderPage()
connect()
renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi \/ confirm online/i })) fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
await act(async () => { expect(await screen.findByTestId('node-reply')).toHaveTextContent(/read your sensor/i)
await vi.advanceTimersByTimeAsync(2000) expect(screen.getByRole('button', { name: /your node replied/i })).toBeInTheDocument()
})
expect(screen.getByText(/online ✓/i)).toBeInTheDocument()
// online but no domain → still gated
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
} finally {
vi.useRealTimers()
}
}) })
it('keeps Proceed gated when a domain is named but the board is not confirmed online', () => { it('requires a domain even after the agent replies', async () => {
mockSayHi.mockResolvedValue('hello there')
connect()
renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
await screen.findByTestId('node-reply')
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
})
it('keeps Proceed gated when a domain is named but the agent has not replied', () => {
connect() connect()
useSession.getState().setDomain('air quality') useSession.getState().setDomain('air quality')
renderPage() renderPage()
expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled() expect(screen.getByRole('button', { name: /proceed/i })).toBeDisabled()
}) })
describe('say hi / online confirmation', () => { it('enables Proceed once the agent has replied AND a domain is named', async () => {
beforeEach(() => vi.useFakeTimers()) mockSayHi.mockResolvedValue('hello there')
afterEach(() => vi.useRealTimers()) connect()
useSession.getState().setDomain('structural stress')
renderPage()
it('enables Proceed once online AND a domain is named, without polluting stats', async () => { const proceed = screen.getByRole('button', { name: /proceed/i })
mockNodeStatus.mockResolvedValue({ teamId: 't', online: true }) expect(proceed).toBeDisabled()
connect()
useSession.getState().setDomain('structural stress')
renderPage()
const proceed = screen.getByRole('button', { name: /proceed/i }) fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
expect(proceed).toBeDisabled() await screen.findByTestId('node-reply')
expect(proceed).toBeEnabled()
expect(useSession.getState().stats.calls).toBe(0)
})
fireEvent.click(screen.getByRole('button', { name: /say hi \/ confirm online/i })) it('surfaces an error when the node does not answer', async () => {
await act(async () => { mockSayHi.mockRejectedValue(new Error('your node did not answer — is it online?'))
await vi.advanceTimersByTimeAsync(2000) connect()
}) renderPage()
fireEvent.click(screen.getByRole('button', { name: /say hi to your agent/i }))
expect(screen.getByText(/online ✓/i)).toBeInTheDocument() expect(await screen.findByRole('alert')).toHaveTextContent(/did not answer/i)
expect(proceed).toBeEnabled()
// confirmation polls must NOT be counted as workshop events
expect(useSession.getState().stats.calls).toBe(0)
})
}) })
}) })
+57 -33
View File
@@ -7,14 +7,10 @@ import { PhaseStrip } from '@/components/PhaseStrip'
import { OpenYourNode } from '@/components/OpenYourNode' import { OpenYourNode } from '@/components/OpenYourNode'
import { DomainPicker } from '@/components/DomainPicker' import { DomainPicker } from '@/components/DomainPicker'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { getNodeStatus } from '@/lib/api' import { sayHi } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type SelfTest = 'idle' | 'running' | 'ok' type HiState = 'idle' | 'running' | 'ok'
/** Say-hi confirmation: poll the claimed board's liveness this many times. */
const SELFTEST_POLLS = 12
const SELFTEST_POLL_MS = 500
export function EnvSetup() { export function EnvSetup() {
const navigate = useNavigate() const navigate = useNavigate()
@@ -22,27 +18,27 @@ export function EnvSetup() {
const device = useSession((s) => s.device) const device = useSession((s) => s.device)
const domain = useSession((s) => s.domain) const domain = useSession((s) => s.domain)
const completePhase = useSession((s) => s.completePhase) const completePhase = useSession((s) => s.completePhase)
const [selfTest, setSelfTest] = useState<SelfTest>('idle') const [hi, setHi] = useState<HiState>('idle')
const [reply, setReply] = useState('')
const [hiError, setHiError] = useState<string | null>(null)
const runSelfTest = async () => { const saidHi = async () => {
setSelfTest('running') if (hi === 'running') return
// Confirm the claimed board is reachable and online. setHi('running')
for (let i = 0; i < SELFTEST_POLLS; i++) { setHiError(null)
try { setReply('')
const s = await getNodeStatus(teamId) try {
if (s.online) { // Chat with the agent on the team's own board; a reply means it's live.
setSelfTest('ok') const r = await sayHi(teamId, 'cloud')
return setReply(r || '(your node replied)')
} setHi('ok')
} catch { } catch (e) {
/* board not registered yet / transient — keep polling */ setHiError(e instanceof Error ? e.message : 'Could not reach your node.')
} setHi('idle')
await new Promise((r) => setTimeout(r, SELFTEST_POLL_MS))
} }
setSelfTest('idle') // couldn't confirm — let them retry
} }
const ready = device.connected && selfTest === 'ok' && domain.trim().length > 0 const ready = device.connected && hi === 'ok' && domain.trim().length > 0
const onProceed = () => { const onProceed = () => {
completePhase('setup') completePhase('setup')
@@ -92,26 +88,54 @@ export function EnvSetup() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-sm text-muted-foreground leading-relaxed"> <p className="text-sm text-muted-foreground leading-relaxed">
Open your node&rsquo;s dashboard and say hi — it introduces itself and lists the skills Say hi to the agent running on your board. When it replies, your node is live and
it already has. Then confirm it&rsquo;s online here. listening — and you&rsquo;re clear to name its domain.
</p> </p>
{/* the exchange */}
{(hi !== 'idle' || reply) && (
<div className="space-y-2" data-testid="say-hi-chat">
<div className="flex justify-end">
<span className="rounded-lg bg-primary/10 text-foreground px-3 py-1.5 text-sm max-w-[80%]">Hi 👋</span>
</div>
{hi === 'running' && (
<div className="flex items-center gap-2 font-mono text-[11px] text-muted-foreground">
<span className="w-2 h-2 rounded-full bg-amber animate-pulse" />
your node is thinking…
</div>
)}
{reply && (
<div className="flex justify-start">
<span className="rounded-lg border border-teal/40 bg-teal/5 px-3 py-1.5 text-sm max-w-[80%]" data-testid="node-reply">
{reply}
</span>
</div>
)}
</div>
)}
<Button <Button
variant="outline" variant={hi === 'ok' ? 'outline' : 'default'}
className="w-full justify-start" className="w-full justify-start"
disabled={!device.connected || selfTest === 'running'} disabled={!device.connected || hi === 'running'}
onClick={runSelfTest} onClick={saidHi}
> >
<span <span
className={cn( className={cn(
'w-2 h-2 rounded-full mr-3', 'w-2 h-2 rounded-full mr-3',
selfTest === 'ok' ? 'bg-teal' : selfTest === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground', hi === 'ok' ? 'bg-teal' : hi === 'running' ? 'bg-amber animate-pulse' : 'bg-muted-foreground',
)} )}
/> />
{selfTest === 'ok' ? 'Online ✓' : selfTest === 'running' ? 'Checking…' : 'Say hi / confirm online'} {hi === 'ok' ? 'Your node replied ✓' : hi === 'running' ? 'Waiting for your node…' : 'Say hi to your agent'}
</Button> </Button>
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed"> {hiError && (
Confirms the board is reachable and live. Does not count toward your session stats. <p role="alert" className="text-xs text-red-500 leading-relaxed">{hiError}</p>
</p> )}
{!device.connected && (
<p className="font-mono text-[10px] text-muted-foreground leading-relaxed">
Bind your board in team registration first.
</p>
)}
</CardContent> </CardContent>
</Card> </Card>