feat(apess): wire real ZeroClaw nodes + live/sim + cloud-local fallback #1

Merged
osobh merged 3 commits from feat/zeroclaw-node-integration into main 2026-07-03 13:39:31 +00:00
8 changed files with 52 additions and 20 deletions
Showing only changes of commit 100b8fd946 - Show all commits
+2 -1
View File
@@ -134,7 +134,8 @@ export function createApp(opts: AppOptions): Express {
if (typeof b.message !== 'string' || !b.message.trim()) {
return res.status(400).json({ error: 'message is required' })
}
const ok = await nodes.prompt(String(req.params.teamId), b.message)
const agent = typeof b.agent === 'string' ? b.agent : undefined
const ok = await nodes.prompt(String(req.params.teamId), b.message, agent)
if (!ok) return res.status(404).json({ error: 'no node registered for team' })
res.status(202).json({ accepted: true })
})
+5 -4
View File
@@ -15,7 +15,7 @@ const store = {} as unknown as Store
describe('node bridge + /nodes routes', () => {
let events: WsEvent[]
let sent: { node: NodeRef; message: string }[]
let sent: { node: NodeRef; message: string; agent?: string }[]
let app: ReturnType<typeof createApp>
beforeEach(() => {
@@ -24,8 +24,8 @@ describe('node bridge + /nodes routes', () => {
const nodes = createNodeBridge({
broadcast: (e) => events.push(e),
ping: async () => true, // pretend the node is online
send: async (node, message) => {
sent.push({ node, message })
send: async (node, message, agent) => {
sent.push({ node, message, agent })
},
subscribe: () => () => {}, // no live SSE in the unit test
})
@@ -61,9 +61,10 @@ describe('node bridge + /nodes routes', () => {
.expect(201)
// prompting is public (no code) — matches PUT /teams/:id
await request(app).post('/nodes/t1/prompt').send({ message: 'scroll HELLO' }).expect(202)
await request(app).post('/nodes/t1/prompt').send({ message: 'scroll HELLO', agent: 'local' }).expect(202)
expect(sent).toHaveLength(1)
expect(sent[0].message).toBe('scroll HELLO')
expect(sent[0].agent).toBe('local')
expect(sent[0].node.token).toBe('zc_secret')
})
+10 -9
View File
@@ -93,8 +93,6 @@ export function mapNodeEvent(teamId: string, raw: unknown): WsEvent | null {
// --- I/O bridge to a node's ZeroClaw gateway --------------------------------
// (Uses global fetch; exercised by integration against a live node, not units.)
const AGENT = 'default'
/** Health-ping a node's gateway. */
export async function pingNode(node: NodeRef, timeoutMs = 3000): Promise<boolean> {
try {
@@ -106,9 +104,12 @@ export async function pingNode(node: NodeRef, timeoutMs = 3000): Promise<boolean
}
}
/** Forward a participant prompt to the node's agent (fire-and-return). */
export async function sendPrompt(node: NodeRef, message: string): Promise<void> {
await fetch(`${node.url}/webhook?agent=${AGENT}`, {
/**
* Forward a participant prompt to a specific pre-provisioned agent alias on
* the node (routed via `?agent=`; defaults to `default`). Fire-and-return.
*/
export async function sendPrompt(node: NodeRef, message: string, agent = 'default'): Promise<void> {
await fetch(`${node.url}/webhook?agent=${encodeURIComponent(agent)}`, {
method: 'POST',
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
body: JSON.stringify({ message }),
@@ -177,7 +178,7 @@ export interface NodeBridge {
register(ref: NodeRef): Promise<void>
remove(teamId: string): void
list(): NodeView[]
prompt(teamId: string, message: string): Promise<boolean>
prompt(teamId: string, message: string, agent?: string): Promise<boolean>
/** Stream one team's node activity to a participant. Returns an unsubscribe fn. */
onTeamActivity(teamId: string, listener: (e: WsEvent) => void): () => void
stopAll(): void
@@ -188,7 +189,7 @@ export interface NodeBridgeDeps {
registry?: NodeRegistry
/** Injectable for tests. */
ping?: (n: NodeRef) => Promise<boolean>
send?: (n: NodeRef, m: string) => Promise<void>
send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void) => () => void
}
@@ -231,10 +232,10 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
list() {
return registry.list().map((n) => ({ teamId: n.teamId, url: n.url, online: online.get(n.teamId) ?? false }))
},
async prompt(teamId, message) {
async prompt(teamId, message, agent) {
const node = registry.get(teamId)
if (!node) return false
await send(node, message)
await send(node, message, agent)
return true
},
onTeamActivity(teamId, listener) {
+2 -1
View File
@@ -74,7 +74,8 @@ describe('BuildFlash', () => {
await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO')
await user.click(screen.getByRole('button', { name: /working|send/i }))
expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO')
// default harness (cloud + fallback) routes to the `default` agent alias
expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO', 'default')
// a flash event streams in over the (mocked) SSE feed
act(() => {
+3 -1
View File
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity } from '@/lib/api'
import { harnessToAgent } from '@/lib/harness'
import type { NodeActivityKind, WsEvent } from '@/types'
interface Entry {
@@ -35,6 +36,7 @@ const isTerminal = (k: NodeActivityKind) => k === 'flash' || k === 'response' ||
export function BuildFlash() {
const mode = useSession((s) => s.mode)
const teamId = useSession((s) => s.teamId)
const harness = useSession((s) => s.harness)
const [prompt, setPrompt] = useState('')
const [busy, setBusy] = useState(false)
const [entries, setEntries] = useState<Entry[]>([])
@@ -72,7 +74,7 @@ export function BuildFlash() {
}
try {
await sendPrompt(teamId, msg)
await sendPrompt(teamId, msg, harnessToAgent(harness))
} catch {
append({ kind: 'error', label: 'Could not reach your board — is it registered and online?' })
setBusy(false)
+3 -3
View File
@@ -70,12 +70,12 @@ export async function getLeaderboard(code: string): Promise<LeaderboardRow[]> {
}
// --- ZeroClaw node (participant) ------------------------------------------
/** Send a prompt to the team's board (public, keyed by teamId). */
export async function sendPrompt(teamId: string, message: string): Promise<void> {
/** Send a prompt to the team's board, routed to a pre-provisioned agent alias. */
export async function sendPrompt(teamId: string, message: string, agent?: string): Promise<void> {
const res = await fetch(`${API_BASE}/nodes/${encodeURIComponent(teamId)}/prompt`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ message }),
body: JSON.stringify({ message, agent }),
})
if (!res.ok) throw new Error(`sendPrompt ${res.status}`)
}
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { harnessToToml } from './harness'
import { harnessToToml, harnessToAgent } from './harness'
import type { Harness } from '@/store/session'
const harness: Harness = {
@@ -43,3 +43,17 @@ describe('harnessToToml', () => {
expect(harnessToToml({ ...harness, provider: 'local', fallbackLocal: true })).not.toContain('fallback')
})
})
describe('harnessToAgent', () => {
it('routes a local primary to the local agent', () => {
expect(harnessToAgent({ ...harness, provider: 'local' })).toBe('local')
})
it('routes a cloud primary with fallback to the default (cloud+fallback) agent', () => {
expect(harnessToAgent({ ...harness, provider: 'anthropic', fallbackLocal: true })).toBe('default')
})
it('routes a cloud primary without fallback to the cloud-only agent', () => {
expect(harnessToAgent({ ...harness, provider: 'groq', fallbackLocal: false })).toBe('cloud')
})
})
+12
View File
@@ -19,3 +19,15 @@ export function harnessToToml(h: Harness): string {
lines.push('')
return lines.join('\n')
}
/**
* Map a harness to the ZeroClaw agent alias a board should handle the request
* with (routed per-request via `?agent=`). Boards are provisioned with:
* - `local` — on-board Qwen only
* - `cloud` — cloud provider, no fallback
* - `default` — cloud provider with on-board Qwen fallback
*/
export function harnessToAgent(h: Harness): string {
if (h.provider === 'local') return 'local'
return h.fallbackLocal ? 'default' : 'cloud'
}