refactor(agent): route every interaction through one agent constant (AGENT)

Say-hi, the module chat, and Refine each hard-coded the 'cloud' agent
while Telegram ran on 'demo' — different agents for web vs Telegram, easy
to get wrong. Introduce a single `AGENT = 'default'` in api.ts and use it
everywhere. 'default' is the node's fallback agent (the one used when no
alias is given), fully loaded with the cloud model + all skills + all
tools — so no call site can pick a different or missing agent.

Board side (config): 'default' is now the sole enabled agent and owns the
telegram.default channel; 'cloud' and 'demo' are disabled and any stray
request for them falls back to 'default'.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-22 10:11:40 -07:00
co-authored by Claude Opus 4.8
parent 06f2342892
commit 5a140ccb17
6 changed files with 23 additions and 11 deletions
+2 -1
View File
@@ -8,6 +8,7 @@ import type { WsEvent } from '@/types'
let emit: (e: WsEvent) => void = () => {} let emit: (e: WsEvent) => void = () => {}
vi.mock('@/lib/api', () => ({ vi.mock('@/lib/api', () => ({
AGENT: 'default',
sendPrompt: vi.fn().mockResolvedValue(undefined), sendPrompt: vi.fn().mockResolvedValue(undefined),
openTeamActivity: (_t: string, on: (e: WsEvent) => void) => { openTeamActivity: (_t: string, on: (e: WsEvent) => void) => {
emit = on emit = on
@@ -28,7 +29,7 @@ describe('AgentChat', () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<AgentChat />) render(<AgentChat />)
await user.click(screen.getByTestId('prompt-i2c')) await user.click(screen.getByTestId('prompt-i2c'))
expect(mockSend).toHaveBeenCalledWith(expect.any(String), 'List the I2C devices on the bus', 'cloud') expect(mockSend).toHaveBeenCalledWith(expect.any(String), 'List the I2C devices on the bus', 'default')
expect(screen.getByTestId('chat-transcript')).toHaveTextContent(/list the i2c devices/i) expect(screen.getByTestId('chat-transcript')).toHaveTextContent(/list the i2c devices/i)
expect(screen.getByTestId('prompt-i2c')).toHaveAttribute('data-state', 'running') expect(screen.getByTestId('prompt-i2c')).toHaveAttribute('data-state', 'running')
}) })
+2 -2
View File
@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity } from '@/lib/api' import { sendPrompt, openTeamActivity, AGENT } from '@/lib/api'
import type { NodeActivityKind, WsEvent } from '@/types' import type { NodeActivityKind, WsEvent } from '@/types'
/** /**
@@ -90,7 +90,7 @@ export function AgentChat({ onProgress }: AgentChatProps) {
setStatus((s) => ({ ...s, [id]: 'running' })) setStatus((s) => ({ ...s, [id]: 'running' }))
setLines((prev) => [...prev, { who: 'you', text }]) setLines((prev) => [...prev, { who: 'you', text }])
try { try {
await sendPrompt(teamId, text, 'cloud') await sendPrompt(teamId, text, AGENT)
} catch { } catch {
running.current = null running.current = null
setStatus((s) => ({ ...s, [id]: 'idle' })) setStatus((s) => ({ ...s, [id]: 'idle' }))
+3 -2
View File
@@ -12,6 +12,7 @@ let liveOnEvent: ((e: WsEvent) => void) | null = null
const closeSpy = vi.fn() const closeSpy = vi.fn()
vi.mock('@/lib/api', () => ({ vi.mock('@/lib/api', () => ({
AGENT: 'default',
sendPrompt: (...args: unknown[]) => sendPrompt(...args), sendPrompt: (...args: unknown[]) => sendPrompt(...args),
openTeamActivity: (_teamId: string, onEvent: (e: WsEvent) => void) => { openTeamActivity: (_teamId: string, onEvent: (e: WsEvent) => void) => {
liveOnEvent = onEvent liveOnEvent = onEvent
@@ -39,13 +40,13 @@ describe('BuildFlash', () => {
expect(screen.getByText(/live board/i)).toBeInTheDocument() expect(screen.getByText(/live board/i)).toBeInTheDocument()
}) })
it('sends the prompt to the single cloud agent and renders streamed board activity', async () => { it('sends the prompt to the single agent and renders streamed board activity', async () => {
const user = userEvent.setup() const user = userEvent.setup()
render(<MemoryRouter><BuildFlash /></MemoryRouter>) render(<MemoryRouter><BuildFlash /></MemoryRouter>)
await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO') await user.type(screen.getByLabelText(/prompt your board/i), 'scroll HELLO')
await user.click(screen.getByRole('button', { name: /working|send/i })) await user.click(screen.getByRole('button', { name: /working|send/i }))
expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO', 'cloud') expect(sendPrompt).toHaveBeenCalledWith(useSession.getState().teamId, 'scroll HELLO', 'default')
// a flash event streams in over the (mocked) SSE feed // a flash event streams in over the (mocked) SSE feed
act(() => { act(() => {
+2 -2
View File
@@ -6,7 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { OpenYourNode } from '@/components/OpenYourNode' import { OpenYourNode } from '@/components/OpenYourNode'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { sendPrompt, openTeamActivity } from '@/lib/api' import { sendPrompt, openTeamActivity, AGENT } from '@/lib/api'
import type { NodeActivityKind, WsEvent } from '@/types' import type { NodeActivityKind, WsEvent } from '@/types'
interface Entry { interface Entry {
@@ -70,7 +70,7 @@ export function BuildFlash() {
setBusy(true) setBusy(true)
try { try {
await sendPrompt(teamId, msg, 'cloud') await sendPrompt(teamId, msg, AGENT)
} catch { } catch {
append({ kind: 'error', label: 'Could not reach your board — is it registered and online?' }) append({ kind: 'error', label: 'Could not reach your board — is it registered and online?' })
setBusy(false) setBusy(false)
+4 -3
View File
@@ -2,13 +2,14 @@ import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useSession } from '@/store/session' import { useSession } from '@/store/session'
import { sayHi } from '@/lib/api' import { sayHi, AGENT } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
type HiState = 'idle' | 'running' | 'ok' type HiState = 'idle' | 'running' | 'ok'
/** /**
* "Say hi to your agent" — chats with the cloud agent on the team's own board. * "Say hi to your agent" — chats with the one workshop agent (AGENT) on the
* team's own board.
* A reply proves the node is live and listening. Shown once the board is bound. * A reply proves the node is live and listening. Shown once the board is bound.
*/ */
export function SayHiCard() { export function SayHiCard() {
@@ -24,7 +25,7 @@ export function SayHiCard() {
setError(null) setError(null)
setReply('') setReply('')
try { try {
const r = await sayHi(teamId, 'cloud') const r = await sayHi(teamId, AGENT)
setReply(r || '(your node replied)') setReply(r || '(your node replied)')
setHi('ok') setHi('ok')
} catch (e) { } catch (e) {
+10 -1
View File
@@ -16,6 +16,15 @@ import type {
const RAW_API_BASE = (import.meta.env.VITE_API_BASE as string | undefined)?.trim() const RAW_API_BASE = (import.meta.env.VITE_API_BASE as string | undefined)?.trim()
export const API_BASE = RAW_API_BASE || 'https://apess-api.redclaw.dev' export const API_BASE = RAW_API_BASE || 'https://apess-api.redclaw.dev'
/**
* The ONE agent every workshop interaction uses — web say-hi, the module chat,
* Refine, and Telegram all route here. It's the node's `default` agent (the one
* the daemon falls back to when no alias is given), fully loaded: cloud model +
* all skills + all tools. Referencing this single constant everywhere means no
* call site can accidentally pick a different (or missing) agent.
*/
export const AGENT = 'default'
/** Build the WS URL. Absolute base → swap http→ws; relative/same-origin base /** Build the WS URL. Absolute base → swap http→ws; relative/same-origin base
* ('' or '/api') → derive scheme+host from the page so it works at any LAN IP. */ * ('' or '/api') → derive scheme+host from the page so it works at any LAN IP. */
function wsUrl(code: string): string { function wsUrl(code: string): string {
@@ -184,7 +193,7 @@ export async function sayHi(teamId: string, agent?: string, message?: string): P
/** Ask the team's node a one-off prompt and wait for its reply (reuses the /** Ask the team's node a one-off prompt and wait for its reply (reuses the
* blocking say-hi path). Backs the "Refine" features. Defaults to the cloud agent. */ * blocking say-hi path). Backs the "Refine" features. Defaults to the cloud agent. */
export async function askNode(teamId: string, prompt: string, agent = 'cloud'): Promise<string> { export async function askNode(teamId: string, prompt: string, agent = AGENT): Promise<string> {
return sayHi(teamId, agent, prompt) return sayHi(teamId, agent, prompt)
} }