feat(apess): wire real ZeroClaw nodes + live/sim + cloud-local fallback #1
+2
-1
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user