Compare commits

...

4 Commits

Author SHA1 Message Date
Nikhil Sonti
04b9c605fa feat: support generic agent chat surfaces 2026-04-17 17:26:01 -07:00
Nikhil Sonti
4388ce66a8 feat: add dangerous local agent flags 2026-04-17 16:56:16 -07:00
Nikhil Sonti
84dfd4d073 fix: address review comments for 0417-minimal_agent_adapters 2026-04-17 16:47:03 -07:00
Nikhil Sonti
085e5368b6 feat: add minimal generic agent adapters 2026-04-17 16:46:00 -07:00
36 changed files with 4805 additions and 986 deletions

View File

@@ -2,7 +2,9 @@ import { Bot, Home, RotateCcw } from 'lucide-react'
import { type FC, useEffect, useRef } from 'react'
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
import { Button } from '@/components/ui/button'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import { canChatWithAgent } from '@/entrypoints/app/agents/agent-availability'
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
import type { OpenClawStatus } from '@/entrypoints/app/agents/useOpenClaw'
import { cn } from '@/lib/utils'
import { useAgentCommandData } from './agent-command-layout'
import { ConversationInput } from './ConversationInput'
@@ -74,14 +76,28 @@ function EmptyConversationState({ agentName }: { agentName: string }) {
}
function getConversationStatusCopy(
status: string | undefined,
agent: AgentEntry | undefined,
openClawStatus: OpenClawStatus | null,
streaming: boolean,
): string {
if (streaming) return 'Working on your request'
if (status === 'running') return 'Ready for the next task'
if (status === 'starting') return 'Connecting to OpenClaw'
if (status === 'error') return 'OpenClaw needs attention'
if (status === 'stopped') return 'OpenClaw is offline'
if (agent?.adapterType !== 'openclaw') return 'Ready for the next task'
if (canChatWithAgent(agent, openClawStatus)) return 'Ready for the next task'
if (
openClawStatus?.status === 'starting' ||
openClawStatus?.controlPlaneStatus === 'connecting' ||
openClawStatus?.controlPlaneStatus === 'reconnecting' ||
openClawStatus?.controlPlaneStatus === 'recovering'
) {
return 'Connecting to OpenClaw'
}
if (
openClawStatus?.status === 'error' ||
openClawStatus?.controlPlaneStatus === 'failed'
) {
return 'OpenClaw needs attention'
}
if (openClawStatus?.status === 'stopped') return 'OpenClaw is offline'
return 'Open agent setup to continue'
}
@@ -91,7 +107,7 @@ export const AgentCommandConversation: FC = () => {
const navigate = useNavigate()
const scrollRef = useRef<HTMLDivElement>(null)
const initialQuerySent = useRef(false)
const { status, agents } = useAgentCommandData()
const { status, agents, agentsLoading } = useAgentCommandData()
const shouldRedirectHome = !agentId
const resolvedAgentId = agentId ?? ''
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
@@ -126,7 +142,7 @@ export const AgentCommandConversation: FC = () => {
})
}, [lastTurnPartCount, shouldRedirectHome, streaming, turns.length])
if (shouldRedirectHome) {
if (shouldRedirectHome || (!agentsLoading && !agent)) {
return <Navigate to="/home" replace />
}
@@ -134,7 +150,12 @@ export const AgentCommandConversation: FC = () => {
navigate(`/home/agents/${entry.agentId}`)
}
const statusCopy = getConversationStatusCopy(status?.status, streaming)
const canSendToAgent = canChatWithAgent(agent, status)
const statusCopy = getConversationStatusCopy(agent, status, streaming)
const placeholder =
agent?.adapterType === 'openclaw' && !canSendToAgent
? `${agentName} is unavailable until OpenClaw reconnects...`
: `Message ${agentName}...`
return (
<div className="absolute inset-0 overflow-hidden">
@@ -183,9 +204,9 @@ export const AgentCommandConversation: FC = () => {
}}
onCreateAgent={() => navigate('/agents')}
streaming={streaming}
disabled={status?.status !== 'running'}
status={status?.status}
placeholder={`Message ${agentName}...`}
disabled={!canSendToAgent}
openClawStatus={status}
placeholder={placeholder}
/>
</div>
</div>

View File

@@ -3,7 +3,8 @@ import { type FC, useEffect, useState } from 'react'
import { useNavigate } from 'react-router'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import { canChatWithAgent } from '@/entrypoints/app/agents/agent-availability'
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
import { NewTabBranding } from '@/entrypoints/newtab/index/NewTabBranding'
import { NewTabTip } from '@/entrypoints/newtab/index/NewTabTip'
@@ -25,7 +26,7 @@ function AgentCommandSetupState({
<Card className="border-border/60 bg-card/85 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
<p className="max-w-xl text-muted-foreground text-sm">
Set up OpenClaw agents to turn your new tab into an agent command
Create BrowserOS agents to turn your new tab into an agent command
center.
</p>
<Button onClick={onOpenAgents} className="gap-2">
@@ -37,21 +38,6 @@ function AgentCommandSetupState({
)
}
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
return (
<Card className="border-border/60 bg-card/85 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
<p className="max-w-xl text-muted-foreground text-sm">
OpenClaw is running, but you do not have any agents yet.
</p>
<Button variant="outline" onClick={onOpenAgents}>
Create your first agent
</Button>
</CardContent>
</Card>
)
}
function OpenClawUnavailableState({
onOpenAgents,
}: {
@@ -61,8 +47,8 @@ function OpenClawUnavailableState({
<Card className="border-border/60 bg-card/85 shadow-sm">
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
<p className="max-w-xl text-muted-foreground text-sm">
OpenClaw is unavailable right now. Open the Agents page to restart the
gateway or review setup.
Some OpenClaw agents are unavailable right now. Open the Agents page
to restart the gateway or review setup.
</p>
<Button onClick={onOpenAgents} className="gap-2">
Open Agent Setup
@@ -76,10 +62,10 @@ function OpenClawUnavailableState({
export const AgentCommandHome: FC = () => {
const navigate = useNavigate()
const activeHint = useActiveHint()
const { status, agents } = useAgentCommandData()
const { status, agents, agentsLoading } = useAgentCommandData()
const [mounted, setMounted] = useState(false)
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
const cardData = useAgentCardData(agents, status?.status)
const cardData = useAgentCardData(agents, status)
useEffect(() => {
setMounted(true)
@@ -93,16 +79,22 @@ export const AgentCommandHome: FC = () => {
return
}
const fallbackAgent =
agents.find((agent) => canChatWithAgent(agent, status)) ?? agents[0]
if (
!selectedAgentId ||
!agents.some((agent) => agent.agentId === selectedAgentId)
) {
setSelectedAgentId(agents[0].agentId)
setSelectedAgentId(fallbackAgent?.agentId ?? null)
}
}, [agents, selectedAgentId])
}, [agents, selectedAgentId, status])
const handleSend = (text: string) => {
if (!selectedAgentId) return
const selectedAgent = agents.find(
(agent) => agent.agentId === selectedAgentId,
)
if (!selectedAgentId || !canChatWithAgent(selectedAgent, status)) return
navigate(`/home/agents/${selectedAgentId}?q=${encodeURIComponent(text)}`)
}
@@ -110,13 +102,18 @@ export const AgentCommandHome: FC = () => {
setSelectedAgentId(agent.agentId)
}
const openClawStatus = status?.status
const isSetup = openClawStatus != null && openClawStatus !== 'uninitialized'
const shouldShowUnavailableState =
openClawStatus != null &&
openClawStatus !== 'running' &&
openClawStatus !== 'uninitialized' &&
cardData.length === 0
const selectedAgent = agents.find(
(agent) => agent.agentId === selectedAgentId,
)
const canSendToSelectedAgent = canChatWithAgent(selectedAgent, status)
const hasUnavailableOpenClawAgent = agents.some(
(agent) =>
agent.adapterType === 'openclaw' && !canChatWithAgent(agent, status),
)
const inputPlaceholder =
selectedAgent?.adapterType === 'openclaw' && !canSendToSelectedAgent
? 'Selected OpenClaw agent is unavailable...'
: undefined
return (
<div className="pt-[max(25vh,16px)]">
@@ -131,42 +128,41 @@ export const AgentCommandHome: FC = () => {
onSend={handleSend}
onCreateAgent={() => navigate('/agents')}
streaming={false}
disabled={status?.status !== 'running'}
status={status?.status}
placeholder={
status?.status === 'running'
? undefined
: 'OpenClaw is not running...'
}
disabled={!canSendToSelectedAgent}
openClawStatus={status}
placeholder={inputPlaceholder}
/>
{mounted ? <NewTabTip /> : null}
{isSetup ? (
shouldShowUnavailableState ? (
<OpenClawUnavailableState
onOpenAgents={() => navigate('/agents')}
/>
) : cardData.length > 0 ? (
<section className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">Agents</h2>
<p className="text-muted-foreground text-sm">
Pick up where your agents left off.
</p>
</div>
{agentsLoading ? (
<Card className="border-border/60 bg-card/85 shadow-sm">
<CardContent className="p-6 text-center text-muted-foreground text-sm">
Loading agents...
</CardContent>
</Card>
) : cardData.length > 0 ? (
<section className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-base">Agents</h2>
<p className="text-muted-foreground text-sm">
Pick up where your agents left off.
</p>
</div>
<AgentCardDock
agents={cardData}
activeAgentId={selectedAgentId ?? undefined}
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
onCreateAgent={() => navigate('/agents')}
</div>
<AgentCardDock
agents={cardData}
activeAgentId={selectedAgentId ?? undefined}
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
onCreateAgent={() => navigate('/agents')}
/>
{hasUnavailableOpenClawAgent ? (
<OpenClawUnavailableState
onOpenAgents={() => navigate('/agents')}
/>
</section>
) : (
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
)
) : null}
</section>
) : (
<AgentCommandSetupState onOpenAgents={() => navigate('/agents')} />
)}

View File

@@ -15,9 +15,11 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { canChatWithAgent } from '@/entrypoints/app/agents/agent-availability'
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
import {
type AgentEntry,
getModelDisplayName,
type OpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
import { cn } from '@/lib/utils'
@@ -26,13 +28,36 @@ interface AgentSelectorProps {
selectedAgentId: string | null
onSelectAgent: (agent: AgentEntry) => void
onCreateAgent?: () => void
status?: string
openClawStatus?: OpenClawStatus | null
}
function getStatusDot(status?: string) {
if (status === 'running') return 'bg-emerald-500'
if (status === 'starting') return 'bg-amber-500 animate-pulse'
if (status === 'error') return 'bg-destructive'
function getStatusDot(
agent: AgentEntry | undefined,
openClawStatus?: OpenClawStatus | null,
) {
if (!agent) {
return 'bg-muted-foreground/50'
}
if (agent.adapterType !== 'openclaw') {
return 'bg-emerald-500'
}
if (canChatWithAgent(agent, openClawStatus ?? null)) {
return 'bg-emerald-500'
}
if (
openClawStatus?.status === 'starting' ||
openClawStatus?.controlPlaneStatus === 'connecting' ||
openClawStatus?.controlPlaneStatus === 'reconnecting' ||
openClawStatus?.controlPlaneStatus === 'recovering'
) {
return 'bg-amber-500 animate-pulse'
}
if (
openClawStatus?.status === 'error' ||
openClawStatus?.controlPlaneStatus === 'failed'
) {
return 'bg-destructive'
}
return 'bg-muted-foreground/50'
}
@@ -41,7 +66,7 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
selectedAgentId,
onSelectAgent,
onCreateAgent,
status,
openClawStatus,
}) => {
const [open, setOpen] = useState(false)
const selectedAgent = agents.find(
@@ -60,7 +85,12 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
)}
>
<Bot className="h-4 w-4" />
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
<span
className={cn(
'size-2 rounded-full',
getStatusDot(selectedAgent, openClawStatus),
)}
/>
<span className="max-w-32 truncate">
{selectedAgent?.name ?? 'Select agent'}
</span>
@@ -75,7 +105,13 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
<CommandGroup>
{agents.map((agent) => {
const isSelected = selectedAgentId === agent.agentId
const modelLabel = getModelDisplayName(agent.model)
const modelLabel =
getModelDisplayName(agent.model) ??
(agent.adapterType === 'codex_local'
? 'Codex local'
: agent.adapterType === 'claude_local'
? 'Claude local'
: 'OpenClaw')
return (
<CommandItem
key={agent.agentId}

View File

@@ -13,7 +13,8 @@ import { AppSelector } from '@/components/elements/AppSelector'
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
import { Button } from '@/components/ui/button'
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
import type { OpenClawStatus } from '@/entrypoints/app/agents/useOpenClaw'
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
import { Feature } from '@/lib/browseros/capabilities'
@@ -32,7 +33,7 @@ interface ConversationInputProps {
onCreateAgent?: () => void
streaming: boolean
disabled?: boolean
status?: string
openClawStatus?: OpenClawStatus | null
placeholder?: string
variant?: 'home' | 'conversation'
}
@@ -122,7 +123,7 @@ function ContextControls({
selectedTabs,
onToggleTab,
showAgentSelector,
status,
openClawStatus,
}: {
agents: AgentEntry[]
onCreateAgent?: () => void
@@ -131,7 +132,7 @@ function ContextControls({
selectedTabs: chrome.tabs.Tab[]
onToggleTab: (tab: chrome.tabs.Tab) => void
showAgentSelector: boolean
status?: string
openClawStatus?: OpenClawStatus | null
}) {
const { supports } = useCapabilities()
const { selectedFolder } = useWorkspace()
@@ -154,7 +155,7 @@ function ContextControls({
selectedAgentId={selectedAgentId}
onSelectAgent={onSelectAgent}
onCreateAgent={onCreateAgent}
status={status}
openClawStatus={openClawStatus}
/>
) : null}
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) ? (
@@ -256,7 +257,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
onCreateAgent,
streaming,
disabled,
status,
openClawStatus,
placeholder,
variant = 'conversation',
}) => {
@@ -349,7 +350,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
selectedTabs={selectedTabs}
onToggleTab={toggleTab}
showAgentSelector={variant === 'home'}
status={status}
openClawStatus={openClawStatus}
/>
</Shell>
)

View File

@@ -1,9 +1,9 @@
import type { FC } from 'react'
import { Outlet, useOutletContext } from 'react-router'
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
import { useAgents } from '@/entrypoints/app/agents/useAgents'
import {
type AgentEntry,
type OpenClawStatus,
useOpenClawAgents,
useOpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
@@ -16,9 +16,7 @@ interface AgentCommandContextValue {
export const AgentCommandLayout: FC = () => {
const { status, loading: statusLoading } = useOpenClawStatus(5000)
const { agents, loading: agentsLoading } = useOpenClawAgents(
status?.status === 'running' && status.controlPlaneStatus === 'connected',
)
const { agents, loading: agentsLoading } = useAgents()
return (
<Outlet

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { isOpenClawAgentReady } from '@/entrypoints/app/agents/agent-availability'
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
import {
type AgentEntry,
getModelDisplayName,
type OpenClawStatus,
} from '@/entrypoints/app/agents/useOpenClaw'
@@ -8,16 +9,32 @@ import { getLatestConversation } from '@/lib/agent-conversations/storage'
import type { AgentCardData } from '@/lib/agent-conversations/types'
function getAgentStatusTone(
status: OpenClawStatus['status'] | undefined,
agent: AgentEntry,
status: OpenClawStatus | null,
): AgentCardData['status'] {
if (status === 'error') return 'error'
if (status === 'starting') return 'working'
if (agent.adapterType !== 'openclaw') {
return 'idle'
}
if (status?.status === 'error' || status?.controlPlaneStatus === 'failed') {
return 'error'
}
if (
status?.status === 'starting' ||
status?.controlPlaneStatus === 'connecting' ||
status?.controlPlaneStatus === 'reconnecting' ||
status?.controlPlaneStatus === 'recovering'
) {
return 'working'
}
if (!isOpenClawAgentReady(status)) {
return 'error'
}
return 'idle'
}
async function getAgentCardData(
agent: AgentEntry,
status: OpenClawStatus['status'] | undefined,
status: OpenClawStatus | null,
): Promise<AgentCardData> {
const conversation = await getLatestConversation(agent.agentId)
const lastTurn = conversation?.turns[conversation.turns.length - 1]
@@ -26,8 +43,14 @@ async function getAgentCardData(
return {
agentId: agent.agentId,
name: agent.name,
model: getModelDisplayName(agent.model),
status: getAgentStatusTone(status),
model:
getModelDisplayName(agent.model) ??
(agent.adapterType === 'codex_local'
? 'Codex local'
: agent.adapterType === 'claude_local'
? 'Claude local'
: 'OpenClaw'),
status: getAgentStatusTone(agent, status),
lastMessage:
lastTextPart?.kind === 'text'
? lastTextPart.text.slice(0, 120)
@@ -38,7 +61,7 @@ async function getAgentCardData(
export function useAgentCardData(
agents: AgentEntry[],
status: OpenClawStatus['status'] | undefined,
status: OpenClawStatus | null,
) {
const [cardData, setCardData] = useState<AgentCardData[]>([])

View File

@@ -1,8 +1,11 @@
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import { useEffect, useRef, useState } from 'react'
import { chatWithAgent } from '@/entrypoints/app/agents/useAgents'
import {
chatWithAgent,
type OpenClawStreamEvent,
} from '@/entrypoints/app/agents/useOpenClaw'
buildBrowserOsConversation,
createBrowserOsAgentStreamState,
reduceBrowserOsAgentStreamEvent,
} from '@/lib/agent-conversations/browseros-agent-chat'
import {
getLatestConversation,
saveConversation,
@@ -10,7 +13,6 @@ import {
import type {
AgentConversation,
AgentConversationTurn,
AssistantPart,
} from '@/lib/agent-conversations/types'
import { consumeSSEStream } from '@/lib/sse'
@@ -19,8 +21,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
const [streaming, setStreaming] = useState(false)
const [loading, setLoading] = useState(true)
const sessionKeyRef = useRef('')
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const streamStateRef = useRef(createBrowserOsAgentStreamState())
const streamAbortRef = useRef<AbortController | null>(null)
useEffect(() => {
@@ -66,155 +67,62 @@ export function useAgentConversation(agentId: string, agentName: string) {
}
const updateCurrentTurnParts = (
updater: (parts: AssistantPart[]) => AssistantPart[],
updater: (turn: AgentConversationTurn) => AgentConversationTurn,
) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
const updated = [...prev.slice(0, -1), updater(last)]
if (updated[updated.length - 1]?.done) {
persistTurns(updated)
}
return updated
})
}
const processStreamEvent = (event: OpenClawStreamEvent) => {
switch (event.type) {
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
break
}
case 'tool-start': {
const tool = {
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
name: (event.data.toolName as string) ?? 'unknown',
status: 'running' as const,
}
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
break
}
case 'tool-end': {
const toolId = event.data.toolCallId as string
const toolStatus: 'completed' | 'error' =
(event.data.status as string) === 'error' ? 'error' : 'completed'
const durationMs = event.data.durationMs as number | undefined
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((t) => t.id === toolId)
) {
const updatedTools = part.tools.map((t) =>
t.id === toolId ? { ...t, status: toolStatus, durationMs } : t,
)
return [
...parts.slice(0, i),
{ ...part, tools: updatedTools },
...parts.slice(i + 1),
]
}
}
return parts
})
break
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
const updated = [...prev.slice(0, -1), { ...last, done: true }]
persistTurns(updated)
return updated
})
break
}
case 'error': {
const msg =
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
break
}
}
const processStreamEvent = (event: UIMessageStreamEvent) => {
streamStateRef.current = reduceBrowserOsAgentStreamEvent(
streamStateRef.current,
event,
)
updateCurrentTurnParts((turn) => ({
...turn,
parts: streamStateRef.current.parts,
done: streamStateRef.current.done,
}))
}
const send = async (text: string) => {
if (!text.trim() || streaming) return
const message = text.trim()
const conversation = buildBrowserOsConversation(turns)
const turn: AgentConversationTurn = {
id: crypto.randomUUID(),
userText: text.trim(),
userText: message,
parts: [],
done: false,
timestamp: Date.now(),
}
setTurns((prev) => [...prev, turn])
setStreaming(true)
textAccRef.current = ''
thinkAccRef.current = ''
streamStateRef.current = createBrowserOsAgentStreamState()
const abortController = new AbortController()
streamAbortRef.current = abortController
try {
const response = await chatWithAgent(
agentId,
text.trim(),
sessionKeyRef.current,
{
message,
sessionKey: sessionKeyRef.current,
conversation,
},
abortController.signal,
)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${err}` },
])
const err = await readErrorResponse(response)
processStreamEvent({ type: 'error', errorText: err })
return
}
await consumeSSEStream(
@@ -225,10 +133,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
} catch (err) {
if (abortController.signal.aborted) return
const msg = err instanceof Error ? err.message : String(err)
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
processStreamEvent({ type: 'error', errorText: msg })
} finally {
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
@@ -243,6 +148,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
setTurns([])
setStreaming(false)
sessionKeyRef.current = crypto.randomUUID()
streamStateRef.current = createBrowserOsAgentStreamState()
}
return {
@@ -254,3 +160,13 @@ export function useAgentConversation(agentId: string, agentName: string) {
resetConversation,
}
}
async function readErrorResponse(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string }
if (body.error) {
return body.error
}
} catch {}
return `Request failed with status ${response.status}`
}

View File

@@ -1,45 +1,9 @@
import {
ArrowLeft,
Bot,
CheckCircle2,
Loader2,
Send,
XCircle,
} from 'lucide-react'
import { ArrowLeft, Loader2, Send } from 'lucide-react'
import { type FC, useEffect, useRef, useState } from 'react'
import {
Message,
MessageContent,
MessageResponse,
} from '@/components/ai-elements/message'
import {
Reasoning,
ReasoningContent,
ReasoningTrigger,
} from '@/components/ai-elements/reasoning'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { consumeSSEStream } from '@/lib/sse'
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
interface ToolEntry {
id: string
name: string
status: 'running' | 'completed' | 'error'
durationMs?: number
}
type AssistantPart =
| { kind: 'thinking'; text: string; done: boolean }
| { kind: 'tool-batch'; tools: ToolEntry[] }
| { kind: 'text'; text: string }
interface ChatTurn {
id: string
userText: string
parts: AssistantPart[]
done: boolean
}
import { ConversationMessage } from '@/entrypoints/app/agent-command/ConversationMessage'
import { useAgentConversation } from '@/entrypoints/app/agent-command/useAgentConversation'
interface AgentChatProps {
agentId: string
@@ -52,209 +16,25 @@ export const AgentChat: FC<AgentChatProps> = ({
agentName,
onBack,
}) => {
const [turns, setTurns] = useState<ChatTurn[]>([])
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
const scrollRef = useRef<HTMLDivElement>(null)
const sessionKeyRef = useRef(crypto.randomUUID())
const streamAbortRef = useRef<AbortController | null>(null)
const { turns, streaming, loading, send } = useAgentConversation(
agentId,
agentName,
)
const textAccRef = useRef('')
const thinkAccRef = useRef('')
const scrollToBottom = () => {
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on conversation changes
useEffect(() => {
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
}
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
useEffect(() => {
scrollToBottom()
}, [turns])
useEffect(() => {
return () => {
streamAbortRef.current?.abort()
}
}, [])
const updateCurrentTurnParts = (
updater: (parts: AssistantPart[]) => AssistantPart[],
) => {
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
})
}
const processStreamEvent = (event: OpenClawStreamEvent) => {
switch (event.type) {
case 'text-delta': {
const delta = (event.data.text as string) ?? ''
textAccRef.current += delta
const text = textAccRef.current
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'text') {
return [...parts.slice(0, -1), { ...last, text }]
}
return [...parts, { kind: 'text', text }]
})
break
}
case 'thinking': {
const delta = (event.data.text as string) ?? ''
thinkAccRef.current += delta
const text = thinkAccRef.current
updateCurrentTurnParts((parts) => {
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
if (idx >= 0) {
return [
...parts.slice(0, idx),
{ ...parts[idx], text, done: false },
...parts.slice(idx + 1),
]
}
return [...parts, { kind: 'thinking', text, done: false }]
})
break
}
case 'tool-start': {
const tool: ToolEntry = {
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
name: (event.data.toolName as string) ?? 'unknown',
status: 'running',
}
updateCurrentTurnParts((parts) => {
const last = parts[parts.length - 1]
if (last?.kind === 'tool-batch') {
return [
...parts.slice(0, -1),
{ ...last, tools: [...last.tools, tool] },
]
}
return [...parts, { kind: 'tool-batch', tools: [tool] }]
})
break
}
case 'tool-end': {
const toolId = event.data.toolCallId as string
const status =
(event.data.status as string) === 'error' ? 'error' : 'completed'
const durationMs = event.data.durationMs as number | undefined
updateCurrentTurnParts((parts) => {
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i]
if (
part.kind === 'tool-batch' &&
part.tools.some((t) => t.id === toolId)
) {
const updatedTools = part.tools.map((t) =>
t.id === toolId
? {
...t,
status: status as ToolEntry['status'],
durationMs,
}
: t,
)
return [
...parts.slice(0, i),
{ ...part, tools: updatedTools },
...parts.slice(i + 1),
]
}
}
return parts
})
break
}
case 'done': {
updateCurrentTurnParts((parts) =>
parts.map((part) =>
part.kind === 'thinking' ? { ...part, done: true } : part,
),
)
setTurns((prev) => {
const last = prev[prev.length - 1]
if (!last) return prev
return [...prev.slice(0, -1), { ...last, done: true }]
})
break
}
case 'error': {
const msg =
(event.data.message as string) ??
(event.data.error as string) ??
'Unknown error'
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
break
}
}
}
const handleSend = async () => {
const text = input.trim()
if (!text || streaming) return
const turn: ChatTurn = {
id: crypto.randomUUID(),
userText: text,
parts: [],
done: false,
if (!text || streaming) {
return
}
setTurns((prev) => [...prev, turn])
setInput('')
setStreaming(true)
textAccRef.current = ''
thinkAccRef.current = ''
const abortController = new AbortController()
streamAbortRef.current = abortController
try {
const response = await chatWithAgent(
agentId,
text,
sessionKeyRef.current,
abortController.signal,
)
if (!response.ok) {
const err = await response.text()
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${err}` },
])
return
}
await consumeSSEStream(
response,
processStreamEvent,
abortController.signal,
)
} catch (err) {
if (abortController.signal.aborted) return
const msg = err instanceof Error ? err.message : String(err)
updateCurrentTurnParts((parts) => [
...parts,
{ kind: 'text', text: `Error: ${msg}` },
])
} finally {
if (streamAbortRef.current === abortController) {
streamAbortRef.current = null
}
setStreaming(false)
}
await send(text)
}
return (
@@ -267,108 +47,30 @@ export const AgentChat: FC<AgentChatProps> = ({
</div>
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
{turns.map((turn) => (
<div key={turn.id} className="space-y-3">
{/* User message */}
<Message from="user">
<MessageContent>
<pre className="whitespace-pre-wrap font-sans text-sm">
{turn.userText}
</pre>
</MessageContent>
</Message>
{/* Assistant response — all parts grouped */}
{turn.parts.length > 0 && (
<Message from="assistant">
<MessageContent>
{turn.parts.map((part, i) => {
const key = `${turn.id}-part-${i}`
switch (part.kind) {
case 'thinking':
return (
<Reasoning
key={key}
className="w-full"
isStreaming={!part.done}
defaultOpen={!part.done}
>
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
</Reasoning>
)
case 'tool-batch':
return (
<div key={key} className="w-full space-y-1">
{part.tools.map((tool) => (
<div
key={tool.id}
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
>
{tool.status === 'running' && (
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
)}
{tool.status === 'completed' && (
<CheckCircle2 className="size-3.5 text-green-500" />
)}
{tool.status === 'error' && (
<XCircle className="size-3.5 text-destructive" />
)}
<span className="font-mono text-xs">
{tool.name}
</span>
{tool.durationMs != null && (
<span className="ml-auto text-muted-foreground text-xs">
{(tool.durationMs / 1000).toFixed(1)}s
</span>
)}
</div>
))}
</div>
)
case 'text':
return (
<MessageResponse key={key}>
{part.text}
</MessageResponse>
)
default:
return null
}
})}
</MessageContent>
</Message>
)}
{/* Streaming indicator when waiting for first part */}
{!turn.done && turn.parts.length === 0 && streaming && (
<div className="flex gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
<Bot className="h-3.5 w-3.5" />
</div>
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
</div>
</div>
)}
{loading ? (
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
Loading conversation...
</div>
))}
) : (
turns.map((turn, index) => (
<ConversationMessage
key={turn.id}
turn={turn}
streaming={streaming && index === turns.length - 1}
/>
))
)}
</div>
<div className="border-t p-4">
<div className="flex gap-2">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
void handleSend()
}
}}
placeholder="Send a message..."
@@ -376,7 +78,9 @@ export const AgentChat: FC<AgentChatProps> = ({
rows={1}
/>
<Button
onClick={handleSend}
onClick={() => {
void handleSend()
}}
disabled={!input.trim() || streaming}
size="icon"
>

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'bun:test'
import { canChatWithAgent, isOpenClawAgentReady } from './agent-availability'
import type { AgentEntry } from './useAgents'
describe('agent-availability', () => {
it('treats only a connected running OpenClaw runtime as ready', () => {
expect(
isOpenClawAgentReady({
status: 'running',
controlPlaneStatus: 'connected',
} as never),
).toBe(true)
expect(
isOpenClawAgentReady({
status: 'running',
controlPlaneStatus: 'disconnected',
} as never),
).toBe(false)
})
it('allows local agents even when OpenClaw is unavailable', () => {
const localAgent: AgentEntry = {
agentId: 'codex-agent',
name: 'Codex Agent',
workspace: '/tmp/codex-agent',
adapterType: 'codex_local',
}
const openClawAgent: AgentEntry = {
agentId: 'main',
name: 'Main',
workspace: '/home/node/.openclaw/workspace',
adapterType: 'openclaw',
}
expect(canChatWithAgent(localAgent, null)).toBe(true)
expect(
canChatWithAgent(openClawAgent, {
status: 'stopped',
controlPlaneStatus: 'disconnected',
} as never),
).toBe(false)
})
})

View File

@@ -0,0 +1,23 @@
import type { AgentEntry } from './useAgents'
import type { OpenClawStatus } from './useOpenClaw'
export function isOpenClawAgentReady(
status: Pick<OpenClawStatus, 'status' | 'controlPlaneStatus'> | null,
): boolean {
return (
status?.status === 'running' && status.controlPlaneStatus === 'connected'
)
}
export function canChatWithAgent(
agent: Pick<AgentEntry, 'adapterType'> | null | undefined,
openClawStatus: Pick<OpenClawStatus, 'status' | 'controlPlaneStatus'> | null,
): boolean {
if (!agent) {
return false
}
if (agent.adapterType !== 'openclaw') {
return true
}
return isOpenClawAgentReady(openClawStatus)
}

View File

@@ -0,0 +1,206 @@
import type {
BrowserOsAgentAdapterType,
BrowserOsStoredAgent,
} from '@browseros/shared/types/browseros-agents'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getAgentServerUrl } from '@/lib/browseros/helpers'
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
export interface AgentEntry {
agentId: string
name: string
workspace: string
model?: unknown
adapterType: BrowserOsAgentAdapterType
}
export interface AgentCatalogEntry {
adapterType: BrowserOsAgentAdapterType
label: string
}
export interface AgentMutationInput {
id: string
name: string
adapterType: BrowserOsAgentAdapterType
binaryPath?: string
dangerouslyBypassApprovalsAndSandbox?: boolean
dangerouslySkipPermissions?: boolean
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}
export interface AgentChatInput {
message: string
sessionKey?: string
conversation?: Array<{ role: 'user' | 'assistant'; text: string }>
}
const AGENT_QUERY_KEYS = {
agents: 'browseros-agents',
catalog: 'browseros-agent-catalog',
} as const
async function agentFetch<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const response = await fetch(`${baseUrl}/agents${path}`, init)
if (!response.ok) {
let message = `Request failed with status ${response.status}`
try {
const body = (await response.json()) as { error?: string }
if (body.error) {
message = body.error
}
} catch {}
throw new Error(message)
}
return response.json() as Promise<T>
}
async function fetchAgents(baseUrl: string): Promise<AgentEntry[]> {
const data = await agentFetch<{ agents: BrowserOsStoredAgent[] }>(baseUrl, '')
return (data.agents ?? []).map(toAgentEntry)
}
async function fetchAgentCatalog(
baseUrl: string,
): Promise<AgentCatalogEntry[]> {
const data = await agentFetch<{ adapters: AgentCatalogEntry[] }>(
baseUrl,
'/catalog',
)
return data.adapters ?? []
}
async function invalidateAgentQueries(
queryClient: ReturnType<typeof useQueryClient>,
): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({ queryKey: [AGENT_QUERY_KEYS.agents] }),
queryClient.invalidateQueries({ queryKey: [AGENT_QUERY_KEYS.catalog] }),
])
}
export function useAgents() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentEntry[], Error>({
queryKey: [AGENT_QUERY_KEYS.agents, baseUrl],
queryFn: () => fetchAgents(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
})
return {
agents: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useAgentCatalog() {
const {
baseUrl,
isLoading: urlLoading,
error: urlError,
} = useAgentServerUrl()
const query = useQuery<AgentCatalogEntry[], Error>({
queryKey: [AGENT_QUERY_KEYS.catalog, baseUrl],
queryFn: () => fetchAgentCatalog(baseUrl as string),
enabled: !!baseUrl && !urlLoading,
staleTime: 60_000,
})
return {
adapters: query.data ?? [],
loading: query.isLoading || urlLoading,
error: query.error ?? urlError,
refetch: query.refetch,
}
}
export function useAgentMutations() {
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
const queryClient = useQueryClient()
const ensureBaseUrl = () => {
if (!baseUrl || urlLoading) {
throw new Error('BrowserOS agent server URL is not ready')
}
return baseUrl
}
const onSuccess = () => invalidateAgentQueries(queryClient)
const createMutation = useMutation({
mutationFn: async (input: AgentMutationInput) =>
agentFetch<{ agent: BrowserOsStoredAgent }>(ensureBaseUrl(), '', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}).then((data) => ({
agent: toAgentEntry(data.agent),
})),
onSuccess,
})
const deleteMutation = useMutation({
mutationFn: async (agentId: string) =>
agentFetch<{ success: boolean }>(ensureBaseUrl(), `/${agentId}`, {
method: 'DELETE',
}),
onSuccess,
})
return {
createAgent: createMutation.mutateAsync,
deleteAgent: deleteMutation.mutateAsync,
actionInProgress: createMutation.isPending || deleteMutation.isPending,
creating: createMutation.isPending,
deleting: deleteMutation.isPending,
}
}
export async function getAgents(): Promise<AgentEntry[]> {
const baseUrl = await getAgentServerUrl()
return fetchAgents(baseUrl)
}
export async function chatWithAgent(
agentId: string,
input: AgentChatInput,
signal?: AbortSignal,
): Promise<Response> {
const baseUrl = await getAgentServerUrl()
return fetch(`${baseUrl}/agents/${agentId}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal,
})
}
function toAgentEntry(record: BrowserOsStoredAgent): AgentEntry {
return {
agentId: record.id,
name: record.name,
workspace:
typeof record.runtimeBinding?.workspace === 'string'
? record.runtimeBinding.workspace
: record.paths.cwd,
model:
record.runtimeBinding?.model ?? record.adapterConfig.modelId ?? undefined,
adapterType: record.adapterType,
}
}

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'bun:test'
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import {
buildBrowserOsConversation,
createBrowserOsAgentStreamState,
reduceBrowserOsAgentStreamEvent,
} from './browseros-agent-chat'
import type { AgentConversationTurn } from './types'
describe('browseros-agent-chat', () => {
it('builds user and assistant conversation history from stored turns', () => {
const turns: AgentConversationTurn[] = [
{
id: 'turn-1',
userText: 'What changed?',
parts: [
{ kind: 'thinking', text: 'reviewing', done: true },
{ kind: 'text', text: 'We shipped the migration.' },
],
done: true,
timestamp: 1,
},
{
id: 'turn-2',
userText: 'Anything else?',
parts: [{ kind: 'tool-batch', tools: [] }],
done: true,
timestamp: 2,
},
]
expect(buildBrowserOsConversation(turns)).toEqual([
{ role: 'user', text: 'What changed?' },
{ role: 'assistant', text: 'We shipped the migration.' },
{ role: 'user', text: 'Anything else?' },
])
})
it('reduces generic UI stream events into conversation parts', () => {
const events: UIMessageStreamEvent[] = [
{ type: 'start' },
{ type: 'reasoning-start', id: 'reasoning-1' },
{ type: 'reasoning-delta', id: 'reasoning-1', delta: 'Thinking...' },
{ type: 'text-start', id: 'text-1' },
{ type: 'text-delta', id: 'text-1', delta: 'Hello' },
{
type: 'tool-input-start',
toolCallId: 'tool-1',
toolName: 'search',
},
{
type: 'tool-output-available',
toolCallId: 'tool-1',
output: { ok: true },
},
{ type: 'text-delta', id: 'text-1', delta: ' world' },
{ type: 'reasoning-end', id: 'reasoning-1' },
{ type: 'finish', finishReason: 'stop' },
]
const state = events.reduce(
(current, event) => reduceBrowserOsAgentStreamEvent(current, event),
createBrowserOsAgentStreamState(),
)
expect(state.done).toBe(true)
expect(state.parts).toEqual([
{ kind: 'thinking', text: 'Thinking...', done: true },
{
kind: 'tool-batch',
tools: [{ id: 'tool-1', name: 'search', status: 'completed' }],
},
{ kind: 'text', text: 'Hello world' },
])
})
})

View File

@@ -0,0 +1,189 @@
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import type { AgentConversationTurn, AssistantPart, ToolEntry } from './types'
export interface BrowserOsConversationTurn {
role: 'user' | 'assistant'
text: string
}
export interface BrowserOsAgentStreamState {
text: string
reasoning: string
reasoningStarted: boolean
reasoningDone: boolean
tools: ToolEntry[]
done: boolean
parts: AssistantPart[]
}
export function buildBrowserOsConversation(
turns: AgentConversationTurn[],
): BrowserOsConversationTurn[] {
return turns.flatMap((turn) => {
const conversation: BrowserOsConversationTurn[] = [
{ role: 'user', text: turn.userText },
]
const assistantText = turn.parts
.filter(
(part): part is Extract<AssistantPart, { kind: 'text' }> =>
part.kind === 'text',
)
.map((part) => part.text)
.join('')
.trim()
if (assistantText) {
conversation.push({ role: 'assistant', text: assistantText })
}
return conversation
})
}
export function createBrowserOsAgentStreamState(): BrowserOsAgentStreamState {
return withDerivedParts({
text: '',
reasoning: '',
reasoningStarted: false,
reasoningDone: false,
tools: [],
done: false,
})
}
export function reduceBrowserOsAgentStreamEvent(
state: BrowserOsAgentStreamState,
event: UIMessageStreamEvent,
): BrowserOsAgentStreamState {
switch (event.type) {
case 'text-delta':
return withDerivedParts({
...state,
text: state.text + event.delta,
})
case 'reasoning-start':
return withDerivedParts({
...state,
reasoningStarted: true,
reasoningDone: false,
})
case 'reasoning-delta':
return withDerivedParts({
...state,
reasoningStarted: true,
reasoningDone: false,
reasoning: state.reasoning + event.delta,
})
case 'reasoning-end':
return withDerivedParts({
...state,
reasoningStarted: true,
reasoningDone: true,
})
case 'tool-input-start':
return withDerivedParts({
...state,
tools: upsertTool(state.tools, {
id: event.toolCallId,
name: event.toolName,
status: 'running',
}),
})
case 'tool-output-available':
return withDerivedParts({
...state,
tools: updateToolStatus(state.tools, event.toolCallId, 'completed'),
})
case 'tool-output-error':
return withDerivedParts({
...state,
tools: updateToolStatus(state.tools, event.toolCallId, 'error'),
})
case 'error':
return withDerivedParts({
...state,
done: true,
reasoningDone: state.reasoningStarted ? true : state.reasoningDone,
text: appendErrorText(state.text, event.errorText),
})
case 'finish':
return withDerivedParts({
...state,
done: true,
reasoningDone: state.reasoningStarted ? true : state.reasoningDone,
})
default:
return state
}
}
function withDerivedParts(
state: Omit<BrowserOsAgentStreamState, 'parts'>,
): BrowserOsAgentStreamState {
return {
...state,
parts: buildAssistantParts(state),
}
}
function buildAssistantParts(
state: Omit<BrowserOsAgentStreamState, 'parts'>,
): AssistantPart[] {
const parts: AssistantPart[] = []
if (state.reasoningStarted || state.reasoning) {
parts.push({
kind: 'thinking',
text: state.reasoning,
done: state.reasoningDone,
})
}
if (state.tools.length > 0) {
parts.push({
kind: 'tool-batch',
tools: state.tools,
})
}
if (state.text) {
parts.push({
kind: 'text',
text: state.text,
})
}
return parts
}
function upsertTool(tools: ToolEntry[], tool: ToolEntry): ToolEntry[] {
const existingIndex = tools.findIndex((entry) => entry.id === tool.id)
if (existingIndex === -1) {
return [...tools, tool]
}
return tools.map((entry, index) =>
index === existingIndex ? { ...entry, ...tool } : entry,
)
}
function updateToolStatus(
tools: ToolEntry[],
toolId: string,
status: ToolEntry['status'],
): ToolEntry[] {
const existingIndex = tools.findIndex((entry) => entry.id === toolId)
if (existingIndex === -1) {
return [
...tools,
{
id: toolId,
name: toolId,
status,
},
]
}
return tools.map((entry, index) =>
index === existingIndex ? { ...entry, status } : entry,
)
}
function appendErrorText(text: string, errorText: string): string {
if (!text) {
return `Error: ${errorText}`
}
return `${text}\n\nError: ${errorText}`
}

View File

@@ -0,0 +1,211 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import { zValidator } from '@hono/zod-validator'
import { type Context, Hono } from 'hono'
import { stream } from 'hono/streaming'
import { z } from 'zod'
import type {
BrowserOsAgentCatalogEntry,
BrowserOsAgentChatInput,
BrowserOsAgentCreateInput,
} from '../services/agents/adapters/types'
import { getBrowserOsAgentService } from '../services/agents/agent-service'
import {
formatUIMessageStreamDone,
formatUIMessageStreamEvent,
} from '../utils/ui-message-stream'
interface BrowserOsAgentRoutesService {
catalog(): BrowserOsAgentCatalogEntry[]
list(): Promise<unknown>
create(input: BrowserOsAgentCreateInput): Promise<unknown>
remove(agentId: string): Promise<void>
chat(
agentId: string,
input: BrowserOsAgentChatInput,
): Promise<ReadableStream<UIMessageStreamEvent>>
}
const CreateAgentRequestSchema = z
.object({
id: z.string().trim().min(1),
name: z.string().trim().min(1),
adapterType: z.enum(['openclaw', 'codex_local', 'claude_local']),
binaryPath: z.string().optional(),
dangerouslyBypassApprovalsAndSandbox: z.boolean().optional(),
dangerouslySkipPermissions: z.boolean().optional(),
providerType: z.string().optional(),
providerName: z.string().optional(),
baseUrl: z.string().optional(),
apiKey: z.string().optional(),
modelId: z.string().optional(),
})
.superRefine((value, ctx) => {
if (
(value.adapterType === 'codex_local' ||
value.adapterType === 'claude_local') &&
!value.binaryPath?.trim()
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['binaryPath'],
message: 'binaryPath is required for local adapters',
})
}
})
const ChatAgentRequestSchema = z.object({
message: z.string().trim().min(1),
sessionKey: z.string().trim().min(1).optional(),
conversation: z
.array(
z.object({
role: z.enum(['user', 'assistant']),
text: z.string().trim().min(1),
}),
)
.optional(),
})
export function createAgentsRoutes(
service: BrowserOsAgentRoutesService = getBrowserOsAgentService(),
) {
return new Hono()
.get('/catalog', async (c) => c.json({ adapters: service.catalog() }))
.get('/', async (c) => {
try {
return c.json({ agents: await service.list() })
} catch (error) {
return c.json({ error: toErrorMessage(error) }, 500)
}
})
.post(
'/',
zValidator('json', CreateAgentRequestSchema, validationErrorResponse),
async (c) => {
const body = c.req.valid('json')
try {
const agent = await service.create(body as BrowserOsAgentCreateInput)
return c.json({ agent }, 201)
} catch (error) {
return c.json(
{ error: toErrorMessage(error) },
toErrorStatusCode(error),
)
}
},
)
.delete('/:id', async (c) => {
try {
await service.remove(c.req.param('id'))
return c.json({ success: true })
} catch (error) {
return c.json(
{ error: toErrorMessage(error) },
toErrorStatusCode(error),
)
}
})
.post(
'/:id/chat',
zValidator('json', ChatAgentRequestSchema, validationErrorResponse),
async (c) => {
const body = c.req.valid('json')
const sessionKey = body.sessionKey || crypto.randomUUID()
try {
const eventStream = await service.chat(c.req.param('id'), {
sessionKey,
message: body.message,
conversation: normalizeConversation(body.conversation),
})
c.header('Content-Type', 'text/event-stream')
c.header('Cache-Control', 'no-cache')
c.header('X-Session-Key', sessionKey)
c.header('x-vercel-ai-ui-message-stream', 'v1')
return stream(c, async (honoStream) => {
const reader = eventStream.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
await honoStream.write(formatUIMessageStreamEvent(value))
}
} finally {
reader.releaseLock()
}
await honoStream.write(formatUIMessageStreamDone())
})
} catch (error) {
return c.json(
{ error: toErrorMessage(error) },
toErrorStatusCode(error),
)
}
},
)
}
function normalizeConversation(
conversation: BrowserOsAgentChatInput['conversation'],
): BrowserOsAgentChatInput['conversation'] {
if (!Array.isArray(conversation)) {
return undefined
}
return conversation
.filter(
(
entry,
): entry is NonNullable<
BrowserOsAgentChatInput['conversation']
>[number] =>
!!entry &&
(entry.role === 'user' || entry.role === 'assistant') &&
typeof entry.text === 'string' &&
entry.text.trim().length > 0,
)
.map((entry) => ({
role: entry.role,
text: entry.text,
}))
}
function toErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function toErrorStatusCode(error: unknown): 400 | 404 | 409 | 500 {
const message = toErrorMessage(error)
if (/not found/i.test(message)) {
return 404
}
if (/already exists/i.test(message)) {
return 409
}
if (isBadRequestMessage(message)) {
return 400
}
return 500
}
function isBadRequestMessage(message: string): boolean {
return [
/requires/i,
/must be running/i,
/invalid/i,
/hello probe failed/i,
/unsupported openclaw provider/i,
].some((pattern) => pattern.test(message))
}
function validationErrorResponse(result: { success: boolean }, c: Context) {
if (!result.success) {
return c.json({ error: 'Invalid request body' }, 400)
}
return undefined
}

View File

@@ -23,6 +23,7 @@ import { getDb } from '../lib/db'
import { logger } from '../lib/logger'
import { Sentry } from '../lib/sentry'
import { createAclRoutes } from './routes/acl'
import { createAgentsRoutes } from './routes/agents'
import { createChatRoutes } from './routes/chat'
import { createCreditsRoutes } from './routes/credits'
import { createHealthRoute } from './routes/health'
@@ -107,6 +108,10 @@ export async function createHttpServer(config: HttpServerConfig) {
.use('/*', requireTrustedAppOrigin())
.route('/', createOpenClawRoutes())
const agentsRoutes = new Hono<Env>()
.use('/*', requireTrustedAppOrigin())
.route('/', createAgentsRoutes())
const terminalRoutes = new Hono<Env>()
.use('/*', requireTrustedAppOrigin())
.route(
@@ -195,6 +200,7 @@ export async function createHttpServer(config: HttpServerConfig) {
}),
)
.route('/claw', clawRoutes)
.route('/agents', agentsRoutes)
// Error handler
app.onError((err, c) => {

View File

@@ -0,0 +1,282 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
import { getAgentDir, getAgentRuntimeDir } from '../../../../lib/browseros-dir'
import { buildLocalAgentPrompt } from './local-prompt'
import type {
BrowserOsAgentAdapter,
BrowserOsAgentChatInput,
BrowserOsAgentCreateInput,
BrowserOsAgentMaterializationResult,
} from './types'
interface SpawnResultLike {
stdout: ReadableStream<Uint8Array> | null
stderr: ReadableStream<Uint8Array> | null
exited: Promise<number>
}
interface SpawnOptionsLike {
cwd?: string
stdin?: Uint8Array
stdout?: 'pipe'
stderr?: 'pipe'
}
type SpawnLike = (cmd: string[], options: SpawnOptionsLike) => SpawnResultLike
const CLAUDE_SYSTEM_PROMPT_FILE_NAME = 'claude-system-prompt.md'
interface ClaudeExecutionSettings {
binaryPath: string
dangerouslySkipPermissions: boolean
}
export class ClaudeLocalAgentAdapter implements BrowserOsAgentAdapter {
readonly adapterType = 'claude_local' as const
private readonly spawn: SpawnLike
constructor(options: { spawn?: SpawnLike } = {}) {
this.spawn =
options.spawn ?? ((cmd, spawnOptions) => Bun.spawn(cmd, spawnOptions))
}
async validateCreate(input: BrowserOsAgentCreateInput): Promise<void> {
if (input.adapterType !== this.adapterType) {
throw new Error(`Unsupported adapter type: ${input.adapterType}`)
}
const settings = readCreateSettings(input)
const agentCwd = getAgentDir(input.id)
await mkdir(agentCwd, { recursive: true })
const probe = await runClaudeCommand({
spawn: this.spawn,
settings,
cwd: agentCwd,
prompt: 'Respond with hello.',
})
if (probe.exitCode !== 0 || !/\bhello\b/i.test(probe.text)) {
throw new Error('Claude hello probe failed')
}
}
async materialize(): Promise<BrowserOsAgentMaterializationResult> {
return { runtimeBinding: null }
}
async remove(): Promise<void> {}
async streamChat(
record: BrowserOsStoredAgent,
input: BrowserOsAgentChatInput,
): Promise<ReadableStream<UIMessageStreamEvent>> {
const settings = readStoredSettings(record)
const prompt = await buildLocalAgentPrompt(record, {
message: input.message,
conversation: input.conversation,
})
const systemPromptFile = await writeClaudeSystemPromptFile(record)
const process = this.spawn(
[
settings.binaryPath,
...buildClaudeArgs({
dangerouslySkipPermissions: settings.dangerouslySkipPermissions,
systemPromptFile,
}),
],
{
cwd: record.paths.cwd,
stdin: new TextEncoder().encode(prompt),
stdout: 'pipe',
stderr: 'pipe',
},
)
return createClaudeUiStream(process, `${record.id}-text`)
}
}
function readCreateSettings(
input: BrowserOsAgentCreateInput,
): ClaudeExecutionSettings {
const binaryPath = normalizeBinaryPath(input.binaryPath)
if (!binaryPath) {
throw new Error('claude_local requires a configured binaryPath')
}
return {
binaryPath,
dangerouslySkipPermissions: input.dangerouslySkipPermissions === true,
}
}
function readStoredSettings(
record: BrowserOsStoredAgent,
): ClaudeExecutionSettings {
const binaryPath = normalizeBinaryPath(record.adapterConfig.binaryPath)
if (!binaryPath) {
throw new Error('claude_local requires adapterConfig.binaryPath')
}
return {
binaryPath,
dangerouslySkipPermissions:
record.adapterConfig.dangerouslySkipPermissions === true,
}
}
async function runClaudeCommand(input: {
spawn: SpawnLike
settings: ClaudeExecutionSettings
cwd: string
prompt: string
}): Promise<{ exitCode: number; text: string; stderr: string }> {
const process = input.spawn(
[input.settings.binaryPath, ...buildClaudeArgs(input.settings)],
{
cwd: input.cwd,
stdin: new TextEncoder().encode(input.prompt),
stdout: 'pipe',
stderr: 'pipe',
},
)
const [stdoutText, stderrText, exitCode] = await Promise.all([
readStreamText(process.stdout),
readStreamText(process.stderr),
process.exited,
])
return {
exitCode,
text: parseClaudeStreamJson(stdoutText).join(''),
stderr: stderrText,
}
}
async function writeClaudeSystemPromptFile(
record: BrowserOsStoredAgent,
): Promise<string> {
const runtimeDir = getAgentRuntimeDir(record.id)
await mkdir(runtimeDir, { recursive: true })
const [agentsMd, soulMd, toolsMd] = await Promise.all([
readFile(join(record.paths.agentDir, 'AGENTS.md'), 'utf8'),
readFile(join(record.paths.agentDir, 'SOUL.md'), 'utf8'),
readFile(join(record.paths.agentDir, 'TOOLS.md'), 'utf8'),
])
const filePath = join(runtimeDir, CLAUDE_SYSTEM_PROMPT_FILE_NAME)
const content = [
'# BrowserOS Claude System Prompt',
'',
'## AGENTS.md',
agentsMd.trim(),
'',
'## SOUL.md',
soulMd.trim(),
'',
'## TOOLS.md',
toolsMd.trim(),
'',
].join('\n')
await writeFile(filePath, content, 'utf8')
return filePath
}
function createClaudeUiStream(
process: SpawnResultLike,
textId: string,
): ReadableStream<UIMessageStreamEvent> {
return new ReadableStream<UIMessageStreamEvent>({
async start(controller) {
controller.enqueue({ type: 'start' })
controller.enqueue({ type: 'text-start', id: textId })
const [stdoutText, stderrText, exitCode] = await Promise.all([
readStreamText(process.stdout),
readStreamText(process.stderr),
process.exited,
])
for (const delta of parseClaudeStreamJson(stdoutText)) {
controller.enqueue({ type: 'text-delta', id: textId, delta })
}
if (exitCode !== 0) {
controller.enqueue({
type: 'error',
errorText:
stderrText.trim() || `Claude exited with status ${exitCode}`,
})
}
controller.enqueue({ type: 'text-end', id: textId })
controller.enqueue({
type: 'finish',
finishReason: exitCode === 0 ? 'stop' : 'error',
})
controller.close()
},
})
}
async function readStreamText(
stream: ReadableStream<Uint8Array> | null,
): Promise<string> {
if (!stream) {
return ''
}
return new Response(stream).text()
}
function parseClaudeStreamJson(stdoutText: string): string[] {
const textParts: string[] = []
const lines = stdoutText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
for (const line of lines) {
let parsed: Record<string, unknown> | null = null
try {
parsed = JSON.parse(line) as Record<string, unknown>
} catch {
continue
}
textParts.push(...extractClaudeText(parsed))
}
return textParts
}
function extractClaudeText(payload: Record<string, unknown>): string[] {
const message = payload.message
if (message && typeof message === 'object') {
return extractClaudeContentBlocks(
(message as Record<string, unknown>).content,
)
}
return extractClaudeContentBlocks(payload.content)
}
function extractClaudeContentBlocks(value: unknown): string[] {
if (!Array.isArray(value)) {
return []
}
return value.flatMap((block) => {
if (!block || typeof block !== 'object') {
return []
}
const item = block as Record<string, unknown>
if (item.type !== 'text') {
return []
}
return typeof item.text === 'string' ? [item.text] : []
})
}
function buildClaudeArgs(input: {
dangerouslySkipPermissions: boolean
systemPromptFile?: string
}): string[] {
const args = ['--print', '-', '--output-format', 'stream-json', '--verbose']
if (input.dangerouslySkipPermissions) {
args.push('--dangerously-skip-permissions')
}
if (input.systemPromptFile) {
args.push('--append-system-prompt-file', input.systemPromptFile)
}
return args
}
function normalizeBinaryPath(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}

View File

@@ -0,0 +1,277 @@
import { mkdir } from 'node:fs/promises'
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
import { getAgentDir } from '../../../../lib/browseros-dir'
import { buildLocalAgentPrompt } from './local-prompt'
import type {
BrowserOsAgentAdapter,
BrowserOsAgentChatInput,
BrowserOsAgentCreateInput,
BrowserOsAgentMaterializationResult,
} from './types'
interface SpawnResultLike {
stdout: ReadableStream<Uint8Array> | null
stderr: ReadableStream<Uint8Array> | null
exited: Promise<number>
}
interface SpawnOptionsLike {
cwd?: string
stdin?: Uint8Array
stdout?: 'pipe'
stderr?: 'pipe'
}
type SpawnLike = (cmd: string[], options: SpawnOptionsLike) => SpawnResultLike
interface CodexExecutionSettings {
binaryPath: string
dangerouslyBypassApprovalsAndSandbox: boolean
}
export class CodexLocalAgentAdapter implements BrowserOsAgentAdapter {
readonly adapterType = 'codex_local' as const
private readonly spawn: SpawnLike
constructor(options: { spawn?: SpawnLike } = {}) {
this.spawn =
options.spawn ?? ((cmd, spawnOptions) => Bun.spawn(cmd, spawnOptions))
}
async validateCreate(input: BrowserOsAgentCreateInput): Promise<void> {
if (input.adapterType !== this.adapterType) {
throw new Error(`Unsupported adapter type: ${input.adapterType}`)
}
const settings = readCreateSettings(input)
const agentCwd = getAgentDir(input.id)
await mkdir(agentCwd, { recursive: true })
const probe = await runCodexCommand({
spawn: this.spawn,
settings,
cwd: agentCwd,
prompt: 'Respond with hello.',
})
if (probe.exitCode !== 0 || !/\bhello\b/i.test(probe.text)) {
throw new Error('Codex hello probe failed')
}
}
async materialize(): Promise<BrowserOsAgentMaterializationResult> {
return { runtimeBinding: null }
}
async remove(): Promise<void> {}
async streamChat(
record: BrowserOsStoredAgent,
input: BrowserOsAgentChatInput,
): Promise<ReadableStream<UIMessageStreamEvent>> {
const settings = readStoredSettings(record)
const prompt = await buildLocalAgentPrompt(record, {
message: input.message,
conversation: input.conversation,
})
const process = this.spawn(
[settings.binaryPath, ...buildCodexArgs(settings)],
{
cwd: record.paths.cwd,
stdin: new TextEncoder().encode(prompt),
stdout: 'pipe',
stderr: 'pipe',
},
)
return createCodexUiStream(process, `${record.id}-text`)
}
}
function readCreateSettings(
input: BrowserOsAgentCreateInput,
): CodexExecutionSettings {
const binaryPath = normalizeBinaryPath(input.binaryPath)
if (!binaryPath) {
throw new Error('codex_local requires a configured binaryPath')
}
return {
binaryPath,
dangerouslyBypassApprovalsAndSandbox:
input.dangerouslyBypassApprovalsAndSandbox === true,
}
}
function readStoredSettings(
record: BrowserOsStoredAgent,
): CodexExecutionSettings {
const binaryPath = normalizeBinaryPath(record.adapterConfig.binaryPath)
if (!binaryPath) {
throw new Error('codex_local requires adapterConfig.binaryPath')
}
return {
binaryPath,
dangerouslyBypassApprovalsAndSandbox:
record.adapterConfig.dangerouslyBypassApprovalsAndSandbox === true,
}
}
async function runCodexCommand(input: {
spawn: SpawnLike
settings: CodexExecutionSettings
cwd: string
prompt: string
}): Promise<{ exitCode: number; text: string; stderr: string }> {
const process = input.spawn(
[input.settings.binaryPath, ...buildCodexArgs(input.settings)],
{
cwd: input.cwd,
stdin: new TextEncoder().encode(input.prompt),
stdout: 'pipe',
stderr: 'pipe',
},
)
const [stdoutText, stderrText, exitCode] = await Promise.all([
readStreamText(process.stdout),
readStreamText(process.stderr),
process.exited,
])
return {
exitCode,
text: parseCodexJsonlText(stdoutText).join(''),
stderr: stderrText,
}
}
function createCodexUiStream(
process: SpawnResultLike,
textId: string,
): ReadableStream<UIMessageStreamEvent> {
return new ReadableStream<UIMessageStreamEvent>({
async start(controller) {
controller.enqueue({ type: 'start' })
controller.enqueue({ type: 'text-start', id: textId })
const [stdoutText, stderrText, exitCode] = await Promise.all([
readStreamText(process.stdout),
readStreamText(process.stderr),
process.exited,
])
for (const delta of parseCodexJsonlText(stdoutText)) {
controller.enqueue({ type: 'text-delta', id: textId, delta })
}
if (exitCode !== 0) {
controller.enqueue({
type: 'error',
errorText:
stderrText.trim() || `Codex exited with status ${exitCode}`,
})
}
controller.enqueue({ type: 'text-end', id: textId })
controller.enqueue({
type: 'finish',
finishReason: exitCode === 0 ? 'stop' : 'error',
})
controller.close()
},
})
}
async function readStreamText(
stream: ReadableStream<Uint8Array> | null,
): Promise<string> {
if (!stream) {
return ''
}
return new Response(stream).text()
}
function parseCodexJsonlText(stdoutText: string): string[] {
const deltas: string[] = []
const lines = stdoutText
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
for (const line of lines) {
let parsed: Record<string, unknown> | null = null
try {
parsed = JSON.parse(line) as Record<string, unknown>
} catch {
continue
}
deltas.push(...extractTextParts(parsed))
}
return deltas
}
function extractTextParts(payload: Record<string, unknown>): string[] {
const fromItem = extractFromItemEnvelope(payload.item)
if (fromItem.length > 0) {
return fromItem
}
const fromMessage = extractFromContentContainer(payload.message)
if (fromMessage.length > 0) {
return fromMessage
}
const fromContent = extractFromContentContainer(payload.content)
if (fromContent.length > 0) {
return fromContent
}
if (typeof payload.delta === 'string') {
return [payload.delta]
}
if (typeof payload.text === 'string') {
return [payload.text]
}
if (typeof payload.output_text === 'string') {
return [payload.output_text]
}
return []
}
function extractFromItemEnvelope(value: unknown): string[] {
if (!value || typeof value !== 'object') {
return []
}
const item = value as Record<string, unknown>
if (typeof item.text === 'string') {
return [item.text]
}
return extractFromContentContainer(item)
}
function extractFromContentContainer(value: unknown): string[] {
if (Array.isArray(value)) {
return value.flatMap((item) => extractTextPartsFromItem(item))
}
if (value && typeof value === 'object') {
const content = (value as Record<string, unknown>).content
if (Array.isArray(content)) {
return content.flatMap((item) => extractTextPartsFromItem(item))
}
}
return []
}
function extractTextPartsFromItem(value: unknown): string[] {
if (!value || typeof value !== 'object') {
return []
}
const item = value as Record<string, unknown>
if (typeof item.text === 'string') {
return [item.text]
}
if (typeof item.delta === 'string') {
return [item.delta]
}
return []
}
function buildCodexArgs(settings: CodexExecutionSettings): string[] {
const args = ['exec', '--json']
if (settings.dangerouslyBypassApprovalsAndSandbox) {
args.push('--dangerously-bypass-approvals-and-sandbox')
}
args.push('-')
return args
}
function normalizeBinaryPath(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}

View File

@@ -0,0 +1,59 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
import type { BrowserOsAgentConversationTurn } from './types'
export interface LocalPromptInput {
message: string
conversation?: BrowserOsAgentConversationTurn[]
}
export async function buildLocalAgentPrompt(
record: BrowserOsStoredAgent,
input: LocalPromptInput,
): Promise<string> {
const [agentsMd, soulMd, toolsMd] = await Promise.all([
readAgentFile(record, 'AGENTS.md'),
readAgentFile(record, 'SOUL.md'),
readAgentFile(record, 'TOOLS.md'),
])
return [
'# BrowserOS Local Agent Prompt',
'',
'## AGENTS.md',
agentsMd.trim(),
'',
'## SOUL.md',
soulMd.trim(),
'',
'## TOOLS.md',
toolsMd.trim(),
'',
'## Recent Conversation',
renderRecentConversation(input.conversation ?? []),
'',
'## Latest User Message',
input.message,
'',
'Respond with the best possible answer for the latest user message.',
'',
].join('\n')
}
async function readAgentFile(
record: BrowserOsStoredAgent,
fileName: string,
): Promise<string> {
return readFile(join(record.paths.agentDir, fileName), 'utf8')
}
function renderRecentConversation(
messages: BrowserOsAgentConversationTurn[],
): string {
if (messages.length === 0) {
return 'None.'
}
return messages
.map((message) => `${message.role.toUpperCase()}: ${message.text}`)
.join('\n')
}

View File

@@ -0,0 +1,111 @@
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
import type { OpenClawAgentEntry } from '../../openclaw/openclaw-service'
import type { OpenClawStreamEvent } from '../../openclaw/openclaw-types'
import { normalizeOpenClawStream } from '../ui-stream'
import type {
BrowserOsAgentAdapter,
BrowserOsAgentChatInput,
BrowserOsAgentCreateInput,
BrowserOsAgentMaterializationResult,
} from './types'
interface OpenClawServiceLike {
getStatus(): Promise<{
status: string
controlPlaneStatus: string
}>
createAgent(input: {
name: string
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}): Promise<OpenClawAgentEntry>
removeAgent(agentId: string): Promise<void>
chatStream(
agentId: string,
sessionKey: string,
message: string,
): Promise<ReadableStream<OpenClawStreamEvent>>
}
export class OpenClawAgentAdapter implements BrowserOsAgentAdapter {
readonly adapterType = 'openclaw' as const
constructor(private readonly openClawService: OpenClawServiceLike) {}
async validateCreate(input: BrowserOsAgentCreateInput): Promise<void> {
if (input.adapterType !== this.adapterType) {
throw new Error(`Unsupported adapter type: ${input.adapterType}`)
}
const status = await this.openClawService.getStatus()
if (
status.status !== 'running' ||
status.controlPlaneStatus !== 'connected'
) {
throw new Error(
'OpenClaw must be running with a connected control plane before creating agents.',
)
}
}
async materialize(
input: BrowserOsAgentCreateInput,
): Promise<BrowserOsAgentMaterializationResult> {
const agent = await this.openClawService.createAgent({
name: input.id,
providerType: input.providerType,
providerName: input.providerName,
baseUrl: input.baseUrl,
apiKey: input.apiKey,
modelId: input.modelId,
})
return {
runtimeBinding: {
agentId: agent.agentId,
workspace: agent.workspace,
model: agent.model,
},
adapterConfig: toStoredAdapterConfig(input),
}
}
async remove(record: BrowserOsStoredAgent): Promise<void> {
await this.openClawService.removeAgent(resolveRuntimeAgentId(record))
}
async streamChat(
record: BrowserOsStoredAgent,
input: BrowserOsAgentChatInput,
): Promise<ReadableStream<UIMessageStreamEvent>> {
const stream = await this.openClawService.chatStream(
resolveRuntimeAgentId(record),
input.sessionKey,
input.message,
)
return normalizeOpenClawStream(stream, resolveRuntimeAgentId(record))
}
}
function toStoredAdapterConfig(
input: BrowserOsAgentCreateInput,
): Record<string, unknown> {
const config = {
providerType: input.providerType,
providerName: input.providerName,
baseUrl: input.baseUrl,
modelId: input.modelId,
}
return Object.fromEntries(
Object.entries(config).filter(([, value]) => value !== undefined),
)
}
function resolveRuntimeAgentId(record: BrowserOsStoredAgent): string {
const runtimeAgentId = record.runtimeBinding?.agentId
return typeof runtimeAgentId === 'string' && runtimeAgentId
? runtimeAgentId
: record.id
}

View File

@@ -0,0 +1,53 @@
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import type {
BrowserOsAgentAdapterType,
BrowserOsStoredAgent,
} from '@browseros/shared/types/browseros-agents'
export interface BrowserOsAgentCreateInput {
id: string
name: string
adapterType: BrowserOsAgentAdapterType
binaryPath?: string
dangerouslyBypassApprovalsAndSandbox?: boolean
dangerouslySkipPermissions?: boolean
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
modelId?: string
}
export interface BrowserOsAgentMaterializationResult {
runtimeBinding: Record<string, unknown> | null
adapterConfig?: Record<string, unknown>
}
export interface BrowserOsAgentChatInput {
sessionKey: string
message: string
conversation?: BrowserOsAgentConversationTurn[]
}
export interface BrowserOsAgentConversationTurn {
role: 'user' | 'assistant'
text: string
}
export interface BrowserOsAgentCatalogEntry {
adapterType: BrowserOsAgentAdapterType
label: string
}
export interface BrowserOsAgentAdapter {
readonly adapterType: BrowserOsAgentAdapterType
validateCreate(input: BrowserOsAgentCreateInput): Promise<void>
materialize(
input: BrowserOsAgentCreateInput,
): Promise<BrowserOsAgentMaterializationResult>
remove(record: BrowserOsStoredAgent): Promise<void>
streamChat(
record: BrowserOsStoredAgent,
input: BrowserOsAgentChatInput,
): Promise<ReadableStream<UIMessageStreamEvent>>
}

View File

@@ -0,0 +1,58 @@
export interface AgentBootstrapFiles {
'AGENTS.md': string
'SOUL.md': string
'TOOLS.md': string
'HEARTBEAT.md': string
}
export interface AgentBootstrapInput {
agentName: string
}
export function buildAgentBootstrapFiles(
input: AgentBootstrapInput,
): AgentBootstrapFiles {
return {
'AGENTS.md': `# ${input.agentName}
You are a BrowserOS-managed agent for this workspace.
## Core Purpose
- Carry out the responsibilities configured for this agent.
- Keep work inspectable inside the managed BrowserOS agent directory.
- Surface blockers, missing context, and approvals clearly.
## Default Output Style
- concise
- action-oriented
- explicit about next steps
`,
'SOUL.md': `# Operating Style
You act like a reliable BrowserOS operator.
## Working Posture
- calm
- structured
- direct
- explicit about tradeoffs
## Collaboration Rules
- Prefer reversible actions when possible.
- Ask before high-impact external mutations.
- Leave durable artifacts in the workspace when useful.
`,
'TOOLS.md': `# Tooling Guidelines
- Use BrowserOS MCP for browser and connected SaaS tasks.
- Use browseros-cli for local BrowserOS workflows when a CLI path is more direct.
- Prefer read, summarize, and draft flows until higher-impact mutations are approved.
- Keep outputs in the workspace when possible so work remains inspectable.
`,
'HEARTBEAT.md': `# Heartbeat
This file is reserved for future autonomous wake/schedule behavior.
It is unused in v1 chats and should remain informational only for now.
`,
}
}

View File

@@ -0,0 +1,142 @@
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { resolve, sep } from 'node:path'
import type {
BrowserOsAgentAdapterType,
BrowserOsStoredAgent,
BrowserOsValidationState,
} from '@browseros/shared/types/browseros-agents'
import {
ensureBrowserosDir,
getAgentDir,
getAgentMetadataPath,
getAgentRuntimeDir,
getAgentsDir,
} from '../../../lib/browseros-dir'
import { buildAgentBootstrapFiles } from './agent-bootstrap'
export interface CreateAgentRegistryInput {
id: string
name: string
adapterType: BrowserOsAgentAdapterType
adapterConfig?: Record<string, unknown>
runtimeBinding?: Record<string, unknown> | null
lastValidation?: BrowserOsValidationState | null
}
export class AgentRegistryService {
async list(): Promise<BrowserOsStoredAgent[]> {
await ensureBrowserosDir()
const entries = await readdir(getAgentsDir(), { withFileTypes: true })
const records = await Promise.all(
entries
.filter((entry) => entry.isDirectory())
.map((entry) => this.get(entry.name)),
)
return records
.filter((record): record is BrowserOsStoredAgent => record !== null)
.sort((left, right) => left.id.localeCompare(right.id))
}
async get(agentId: string): Promise<BrowserOsStoredAgent | null> {
this.assertValidAgentId(agentId)
try {
const raw = await readFile(getAgentMetadataPath(agentId), 'utf8')
return JSON.parse(raw) as BrowserOsStoredAgent
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null
}
throw error
}
}
async create(input: CreateAgentRegistryInput): Promise<BrowserOsStoredAgent> {
this.assertValidAgentId(input.id)
if (await this.get(input.id)) {
throw new Error(`Agent "${input.id}" already exists`)
}
await ensureBrowserosDir()
const agentDir = getAgentDir(input.id)
const now = new Date().toISOString()
const record: BrowserOsStoredAgent = {
version: 1,
id: input.id,
name: input.name,
adapterType: input.adapterType,
paths: {
agentDir,
cwd: agentDir,
contextDirs: [],
},
adapterConfig: input.adapterConfig ?? {},
runtimeBinding: input.runtimeBinding ?? null,
lastValidation: input.lastValidation ?? null,
createdAt: now,
updatedAt: now,
}
await mkdir(agentDir, { recursive: true })
await mkdir(getAgentRuntimeDir(input.id), { recursive: true })
await this.writeBootstrapFiles(record.name, agentDir)
await this.writeRecord(record)
return record
}
async update(record: BrowserOsStoredAgent): Promise<BrowserOsStoredAgent> {
this.assertValidAgentId(record.id)
await ensureBrowserosDir()
const agentDir = getAgentDir(record.id)
const updatedRecord: BrowserOsStoredAgent = {
...record,
version: 1,
paths: {
agentDir,
cwd: record.paths.cwd,
contextDirs: record.paths.contextDirs ?? [],
},
updatedAt: new Date().toISOString(),
}
await mkdir(agentDir, { recursive: true })
await mkdir(getAgentRuntimeDir(record.id), { recursive: true })
await this.writeRecord(updatedRecord)
return updatedRecord
}
async remove(agentId: string): Promise<void> {
this.assertValidAgentId(agentId)
await rm(getAgentDir(agentId), { recursive: true, force: true })
}
private async writeBootstrapFiles(
agentName: string,
agentDir: string,
): Promise<void> {
const files = buildAgentBootstrapFiles({ agentName })
await Promise.all(
Object.entries(files).map(([fileName, content]) =>
writeFile(resolve(agentDir, fileName), content, 'utf8'),
),
)
}
private async writeRecord(record: BrowserOsStoredAgent): Promise<void> {
await writeFile(
getAgentMetadataPath(record.id),
`${JSON.stringify(record, null, 2)}\n`,
'utf8',
)
}
private assertValidAgentId(agentId: string): void {
if (!agentId || agentId.includes('/') || agentId.includes('\\')) {
throw new Error('Invalid agent id')
}
const agentsDir = getAgentsDir()
const resolved = resolve(agentsDir, agentId)
if (resolved !== getAgentDir(agentId)) {
throw new Error('Invalid agent id')
}
if (!resolved.startsWith(`${agentsDir}${sep}`)) {
throw new Error('Invalid agent id')
}
}
}

View File

@@ -0,0 +1,181 @@
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
import { getOpenClawService } from '../openclaw/openclaw-service'
import { ClaudeLocalAgentAdapter } from './adapters/claude-local-adapter'
import { CodexLocalAgentAdapter } from './adapters/codex-local-adapter'
import { OpenClawAgentAdapter } from './adapters/openclaw-adapter'
import type {
BrowserOsAgentAdapter,
BrowserOsAgentCatalogEntry,
BrowserOsAgentChatInput,
BrowserOsAgentCreateInput,
} from './adapters/types'
import { AgentRegistryService } from './agent-registry-service'
interface BrowserOsAgentServiceOptions {
registry?: AgentRegistryService
adapters?: BrowserOsAgentAdapter[]
openClawService?: ReturnType<typeof getOpenClawService>
}
export class BrowserOsAgentService {
private readonly registry: AgentRegistryService
private readonly adapters: Map<string, BrowserOsAgentAdapter>
private readonly openClawService: ReturnType<typeof getOpenClawService>
constructor(options: BrowserOsAgentServiceOptions = {}) {
this.registry = options.registry ?? new AgentRegistryService()
this.openClawService = options.openClawService ?? getOpenClawService()
this.adapters = new Map(
(options.adapters ?? this.createDefaultAdapters()).map((adapter) => [
adapter.adapterType,
adapter,
]),
)
}
catalog(): BrowserOsAgentCatalogEntry[] {
return Array.from(this.adapters.values()).map((adapter) => ({
adapterType: adapter.adapterType,
label: toAdapterLabel(adapter.adapterType),
}))
}
async list(): Promise<BrowserOsStoredAgent[]> {
return this.registry.list()
}
async get(agentId: string): Promise<BrowserOsStoredAgent | null> {
return this.registry.get(agentId)
}
async create(
input: BrowserOsAgentCreateInput,
): Promise<BrowserOsStoredAgent> {
const adapter = this.getAdapter(input.adapterType)
const existing = await this.registry.get(input.id)
if (existing) {
throw new Error(`Agent "${input.id}" already exists`)
}
await adapter.validateCreate(input)
const created = await this.registry.create({
id: input.id,
name: input.name,
adapterType: input.adapterType,
adapterConfig: buildInitialAdapterConfig(input),
runtimeBinding: null,
})
try {
const materialized = await adapter.materialize(input)
return await this.registry.update({
...created,
adapterConfig: buildStoredAdapterConfig(input, materialized),
runtimeBinding: materialized.runtimeBinding,
})
} catch (error) {
try {
await adapter.remove(created)
} catch {}
await this.registry.remove(input.id)
throw error
}
}
async remove(agentId: string): Promise<void> {
const record = await this.registry.get(agentId)
if (!record) {
throw new Error(`Agent "${agentId}" not found`)
}
const adapter = this.getAdapter(record.adapterType)
await adapter.remove(record)
await this.registry.remove(agentId)
}
async chat(
agentId: string,
input: BrowserOsAgentChatInput,
): Promise<ReadableStream<UIMessageStreamEvent>> {
const record = await this.registry.get(agentId)
if (!record) {
throw new Error(`Agent "${agentId}" not found`)
}
const adapter = this.getAdapter(record.adapterType)
return adapter.streamChat(record, input)
}
private getAdapter(adapterType: string): BrowserOsAgentAdapter {
const adapter = this.adapters.get(adapterType)
if (!adapter) {
throw new Error(`Unsupported agent adapter: ${adapterType}`)
}
return adapter
}
private createDefaultAdapters(): BrowserOsAgentAdapter[] {
return [
new OpenClawAgentAdapter(this.openClawService),
new CodexLocalAgentAdapter(),
new ClaudeLocalAgentAdapter(),
]
}
}
let browserOsAgentService: BrowserOsAgentService | null = null
export function getBrowserOsAgentService(): BrowserOsAgentService {
if (!browserOsAgentService) {
browserOsAgentService = new BrowserOsAgentService({
openClawService: getOpenClawService(),
})
}
return browserOsAgentService
}
function toAdapterLabel(adapterType: string): string {
switch (adapterType) {
case 'openclaw':
return 'OpenClaw'
case 'codex_local':
return 'Codex Local'
case 'claude_local':
return 'Claude Local'
default:
return adapterType
}
}
function buildInitialAdapterConfig(
input: BrowserOsAgentCreateInput,
): Record<string, unknown> {
const config: Record<string, unknown> = {}
if (
(input.adapterType === 'codex_local' ||
input.adapterType === 'claude_local') &&
input.binaryPath?.trim()
) {
config.binaryPath = input.binaryPath.trim()
}
if (
input.adapterType === 'codex_local' &&
input.dangerouslyBypassApprovalsAndSandbox === true
) {
config.dangerouslyBypassApprovalsAndSandbox = true
}
if (
input.adapterType === 'claude_local' &&
input.dangerouslySkipPermissions === true
) {
config.dangerouslySkipPermissions = true
}
return config
}
function buildStoredAdapterConfig(
input: BrowserOsAgentCreateInput,
materialized: { adapterConfig?: Record<string, unknown> },
): Record<string, unknown> {
return {
...buildInitialAdapterConfig(input),
...(materialized.adapterConfig ?? {}),
}
}

View File

@@ -0,0 +1,210 @@
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import type { OpenClawStreamEvent } from '../openclaw/openclaw-types'
const OPENCLAW_TEXT_STREAM_ID_SUFFIX = 'text'
const OPENCLAW_REASONING_STREAM_ID_SUFFIX = 'reasoning'
const OPENCLAW_TOOL_NAME_FALLBACK = 'openclaw-tool'
export function normalizeOpenClawStream(
stream: ReadableStream<OpenClawStreamEvent>,
runtimeAgentId: string,
): ReadableStream<UIMessageStreamEvent> {
const textId = `${runtimeAgentId}-${OPENCLAW_TEXT_STREAM_ID_SUFFIX}`
const reasoningId = `${runtimeAgentId}-${OPENCLAW_REASONING_STREAM_ID_SUFFIX}`
return new ReadableStream<UIMessageStreamEvent>({
async start(controller) {
controller.enqueue({ type: 'start' })
controller.enqueue({ type: 'text-start', id: textId })
const reader = stream.getReader()
let closed = false
const state: OpenClawNormalizationState = {
reasoningStarted: false,
toolCallCounter: 0,
pendingFallbackToolCallId: null,
}
try {
while (true) {
const { done, value } = await reader.read()
if (done || closed) {
break
}
closed = handleOpenClawEvent(
value,
controller,
textId,
reasoningId,
state,
)
}
} finally {
reader.releaseLock()
if (!closed) {
closeReasoningStreamIfNeeded(controller, reasoningId, state)
controller.enqueue({ type: 'text-end', id: textId })
controller.enqueue({ type: 'finish', finishReason: 'stop' })
controller.close()
}
}
},
})
}
interface OpenClawNormalizationState {
reasoningStarted: boolean
toolCallCounter: number
pendingFallbackToolCallId: string | null
}
function handleOpenClawEvent(
event: OpenClawStreamEvent,
controller: ReadableStreamDefaultController<UIMessageStreamEvent>,
textId: string,
reasoningId: string,
state: OpenClawNormalizationState,
): boolean {
switch (event.type) {
case 'text-delta': {
const delta = typeof event.data.text === 'string' ? event.data.text : ''
if (delta) {
controller.enqueue({ type: 'text-delta', id: textId, delta })
}
return false
}
case 'thinking': {
const delta = readNarrativeDelta(event.data)
if (delta) {
ensureReasoningStream(controller, reasoningId, state)
controller.enqueue({ type: 'reasoning-delta', id: reasoningId, delta })
}
return false
}
case 'tool-start': {
const toolCallId = resolveToolCallId(event.data, state, textId, 'start')
const toolName = resolveToolName(event.data)
controller.enqueue({
type: 'tool-input-start',
toolCallId,
toolName,
})
if ('input' in event.data) {
controller.enqueue({
type: 'tool-input-available',
toolCallId,
toolName,
input: event.data.input,
})
}
return false
}
case 'tool-output':
controller.enqueue({
type: 'tool-output-available',
toolCallId: resolveToolCallId(event.data, state, textId, 'output'),
output: 'output' in event.data ? event.data.output : event.data,
})
return false
case 'tool-end':
controller.enqueue({
type: 'tool-output-available',
toolCallId: resolveToolCallId(event.data, state, textId, 'end'),
output: stripToolMetadata(event.data),
})
return false
case 'lifecycle':
ensureReasoningStream(controller, reasoningId, state)
controller.enqueue({
type: 'reasoning-delta',
id: reasoningId,
delta: JSON.stringify(event.data),
})
return false
case 'done':
closeReasoningStreamIfNeeded(controller, reasoningId, state)
controller.enqueue({ type: 'text-end', id: textId })
controller.enqueue({ type: 'finish', finishReason: 'stop' })
controller.close()
return true
case 'error':
closeReasoningStreamIfNeeded(controller, reasoningId, state)
controller.enqueue({
type: 'error',
errorText:
typeof event.data.message === 'string'
? event.data.message
: 'OpenClaw chat stream failed',
})
controller.close()
return true
default:
return false
}
}
function ensureReasoningStream(
controller: ReadableStreamDefaultController<UIMessageStreamEvent>,
reasoningId: string,
state: OpenClawNormalizationState,
): void {
if (state.reasoningStarted) {
return
}
controller.enqueue({ type: 'reasoning-start', id: reasoningId })
state.reasoningStarted = true
}
function closeReasoningStreamIfNeeded(
controller: ReadableStreamDefaultController<UIMessageStreamEvent>,
reasoningId: string,
state: OpenClawNormalizationState,
): void {
if (!state.reasoningStarted) {
return
}
controller.enqueue({ type: 'reasoning-end', id: reasoningId })
state.reasoningStarted = false
}
function readNarrativeDelta(data: Record<string, unknown>): string {
const candidate = data.text ?? data.message ?? data.content
return typeof candidate === 'string' ? candidate : JSON.stringify(data)
}
function resolveToolCallId(
data: Record<string, unknown>,
state: OpenClawNormalizationState,
prefix: string,
kind: 'start' | 'output' | 'end',
): string {
if (typeof data.toolCallId === 'string' && data.toolCallId) {
if (kind === 'end') {
state.pendingFallbackToolCallId = null
}
return data.toolCallId
}
if (kind === 'start' || !state.pendingFallbackToolCallId) {
state.toolCallCounter += 1
state.pendingFallbackToolCallId = `${prefix}-tool-${state.toolCallCounter}`
}
const toolCallId = state.pendingFallbackToolCallId
if (kind === 'end') {
state.pendingFallbackToolCallId = null
}
return toolCallId
}
function resolveToolName(data: Record<string, unknown>): string {
const candidate = data.toolName ?? data.name
return typeof candidate === 'string' && candidate
? candidate
: OPENCLAW_TOOL_NAME_FALLBACK
}
function stripToolMetadata(
data: Record<string, unknown>,
): Record<string, unknown> {
const output = { ...data }
delete output.toolCallId
delete output.toolName
delete output.name
return output
}

View File

@@ -25,6 +25,22 @@ export function getMemoryDir(): string {
return join(getBrowserosDir(), PATHS.MEMORY_DIR_NAME)
}
export function getAgentsDir(): string {
return join(getBrowserosDir(), PATHS.AGENTS_DIR_NAME)
}
export function getAgentDir(agentId: string): string {
return join(getAgentsDir(), agentId)
}
export function getAgentMetadataPath(agentId: string): string {
return join(getAgentDir(agentId), PATHS.AGENT_METADATA_FILE_NAME)
}
export function getAgentRuntimeDir(agentId: string): string {
return join(getAgentDir(agentId), PATHS.AGENT_RUNTIME_DIR_NAME)
}
export function getSessionsDir(): string {
return join(getBrowserosDir(), PATHS.SESSIONS_DIR_NAME)
}
@@ -69,6 +85,7 @@ export function removeServerConfigSync(): void {
export async function ensureBrowserosDir(): Promise<void> {
logDevelopmentBrowserosDir()
await mkdir(getAgentsDir(), { recursive: true })
await mkdir(getMemoryDir(), { recursive: true })
await mkdir(getSkillsDir(), { recursive: true })
await mkdir(getBuiltinSkillsDir(), { recursive: true })

View File

@@ -0,0 +1,339 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { describe, expect, it, mock } from 'bun:test'
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
import { Hono } from 'hono'
import { requireTrustedAppOrigin } from '../../../src/api/utils/request-auth'
describe('createAgentsRoutes', () => {
it('serves catalog, list, create, delete, and SSE chat through the generic agent service', async () => {
const catalog = mock(() => [
{ adapterType: 'openclaw', label: 'OpenClaw' },
{ adapterType: 'codex_local', label: 'Codex Local' },
])
const list = mock(async () => [createStoredAgent()])
const create = mock(async () => createStoredAgent())
const remove = mock(async () => {})
const chat = mock(async (_agentId: string, _input: unknown) =>
createEventStream([
{ type: 'start' },
{ type: 'text-start', id: 'agent-text' },
{ type: 'text-delta', id: 'agent-text', delta: 'Hello' },
{ type: 'text-end', id: 'agent-text' },
{ type: 'finish', finishReason: 'stop' },
]),
)
const { createAgentsRoutes } = await import(
'../../../src/api/routes/agents'
)
const route = createAgentsRoutes({
catalog,
list,
create,
remove,
chat,
} as never)
const catalogResponse = await route.request('/catalog')
expect(catalogResponse.status).toBe(200)
expect(await catalogResponse.json()).toEqual({
adapters: [
{ adapterType: 'openclaw', label: 'OpenClaw' },
{ adapterType: 'codex_local', label: 'Codex Local' },
],
})
const listResponse = await route.request('/')
expect(listResponse.status).toBe(200)
expect(await listResponse.json()).toEqual({
agents: [createStoredAgent()],
})
const createResponse = await route.request('/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'agent',
name: 'Agent',
adapterType: 'codex_local',
binaryPath: '/usr/local/bin/codex',
dangerouslyBypassApprovalsAndSandbox: true,
}),
})
expect(createResponse.status).toBe(201)
expect(await createResponse.json()).toEqual({
agent: createStoredAgent(),
})
expect(create).toHaveBeenCalledWith({
id: 'agent',
name: 'Agent',
adapterType: 'codex_local',
binaryPath: '/usr/local/bin/codex',
dangerouslyBypassApprovalsAndSandbox: true,
})
const deleteResponse = await route.request('/agent', {
method: 'DELETE',
})
expect(deleteResponse.status).toBe(200)
expect(await deleteResponse.json()).toEqual({ success: true })
expect(remove).toHaveBeenCalledWith('agent')
const chatResponse = await route.request('/agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'Summarize it.',
sessionKey: 'session-123',
conversation: [
{ role: 'user', text: 'What happened?' },
{ role: 'assistant', text: 'We shipped Task 5.' },
],
}),
})
expect(chatResponse.status).toBe(200)
expect(chatResponse.headers.get('Content-Type')).toContain(
'text/event-stream',
)
expect(chatResponse.headers.get('X-Session-Key')).toBe('session-123')
expect(chatResponse.headers.get('x-vercel-ai-ui-message-stream')).toBe('v1')
expect(chat).toHaveBeenCalledWith('agent', {
sessionKey: 'session-123',
message: 'Summarize it.',
conversation: [
{ role: 'user', text: 'What happened?' },
{ role: 'assistant', text: 'We shipped Task 5.' },
],
})
expect(await chatResponse.text()).toBe(
'data: {"type":"start"}\n\n' +
'data: {"type":"text-start","id":"agent-text"}\n\n' +
'data: {"type":"text-delta","id":"agent-text","delta":"Hello"}\n\n' +
'data: {"type":"text-end","id":"agent-text"}\n\n' +
'data: {"type":"finish","finishReason":"stop"}\n\n' +
'data: [DONE]\n\n',
)
})
it('returns 400 for malformed JSON and invalid payloads on create and chat', async () => {
const { createAgentsRoutes } = await import(
'../../../src/api/routes/agents'
)
const route = createAgentsRoutes({
catalog: mock(() => []),
list: mock(async () => []),
create: mock(async () => createStoredAgent()),
remove: mock(async () => {}),
chat: mock(async () => createEventStream([])),
} as never)
const createResponse = await route.request('/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"id":',
})
expect(createResponse.status).toBe(400)
const invalidCreateResponse = await route.request('/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: '',
name: 'Agent',
adapterType: 'codex_local',
}),
})
expect(invalidCreateResponse.status).toBe(400)
const missingBinaryResponse = await route.request('/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'agent',
name: 'Agent',
adapterType: 'claude_local',
}),
})
expect(missingBinaryResponse.status).toBe(400)
const chatResponse = await route.request('/agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"message":',
})
expect(chatResponse.status).toBe(400)
const invalidChatResponse = await route.request('/agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: '',
}),
})
expect(invalidChatResponse.status).toBe(400)
})
it('returns 404, 409, and 400 for known service errors and validation failures', async () => {
const { createAgentsRoutes } = await import(
'../../../src/api/routes/agents'
)
const route = createAgentsRoutes({
catalog: mock(() => []),
list: mock(async () => []),
create: mock(async (input: { id: string; adapterType: string }) => {
if (input.id === 'existing') {
throw new Error('Agent "agent" already exists')
}
if (input.id === 'probe') {
throw new Error('Codex hello probe failed')
}
if (input.adapterType === 'openclaw') {
throw new Error('Unsupported OpenClaw provider: unsupported')
}
throw new Error('claude_local requires a configured binaryPath')
}),
remove: mock(async () => {
throw new Error('Agent "missing" not found')
}),
chat: mock(async () => {
throw new Error('Agent "missing" not found')
}),
} as never)
const conflictResponse = await route.request('/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'existing',
name: 'Agent',
adapterType: 'codex_local',
binaryPath: '/usr/local/bin/codex',
}),
})
expect(conflictResponse.status).toBe(409)
const invalidResponse = await route.request('/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'agent',
name: 'Agent',
adapterType: 'claude_local',
binaryPath: '/usr/local/bin/claude',
}),
})
expect(invalidResponse.status).toBe(400)
const probeFailureResponse = await route.request('/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'probe',
name: 'Agent',
adapterType: 'codex_local',
binaryPath: '/usr/local/bin/codex',
}),
})
expect(probeFailureResponse.status).toBe(400)
const unsupportedProviderResponse = await route.request('/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: 'agent',
name: 'Agent',
adapterType: 'openclaw',
providerType: 'unsupported',
}),
})
expect(unsupportedProviderResponse.status).toBe(400)
const deleteResponse = await route.request('/missing', {
method: 'DELETE',
})
expect(deleteResponse.status).toBe(404)
const missingChatResponse = await route.request('/missing/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: 'hello',
}),
})
expect(missingChatResponse.status).toBe(404)
})
it('can be mounted behind trusted-origin protection for agent control routes', async () => {
const { createAgentsRoutes } = await import(
'../../../src/api/routes/agents'
)
const app = new Hono().use('/*', requireTrustedAppOrigin()).route(
'/agents',
createAgentsRoutes({
catalog: mock(() => []),
list: mock(async () => []),
create: mock(async () => createStoredAgent()),
remove: mock(async () => {}),
chat: mock(async () => createEventStream([])),
} as never),
)
const forbidden = await app.request('http://localhost/agents', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://evil.example',
},
body: JSON.stringify({
id: 'agent',
name: 'Agent',
adapterType: 'codex_local',
binaryPath: '/usr/local/bin/codex',
}),
})
expect(forbidden.status).toBe(403)
const allowed = await app.request('http://localhost/agents/catalog', {
headers: { Origin: 'chrome-extension://browseros' },
})
expect(allowed.status).toBe(200)
})
})
function createStoredAgent(): BrowserOsStoredAgent {
return {
version: 1,
id: 'agent',
name: 'Agent',
adapterType: 'codex_local',
paths: {
agentDir: '/tmp/agent',
cwd: '/tmp/agent',
contextDirs: [],
},
adapterConfig: {
binaryPath: '/usr/local/bin/codex',
},
runtimeBinding: null,
lastValidation: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
}
}
function createEventStream(events: UIMessageStreamEvent[]) {
return new ReadableStream<UIMessageStreamEvent>({
start(controller) {
for (const event of events) {
controller.enqueue(event)
}
controller.close()
},
})
}

View File

@@ -0,0 +1,103 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { mkdtemp, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
describe('AgentRegistryService', () => {
let homeDir = ''
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'browseros-agents-'))
mock.module('node:os', () => ({
homedir: () => homeDir,
tmpdir,
}))
})
afterEach(() => {
mock.restore()
})
it('creates a managed agent directory with boot files and metadata', async () => {
const { AgentRegistryService } = await import(
'../../../../src/api/services/agents/agent-registry-service'
)
const service = new AgentRegistryService()
await service.create({
id: 'chief-of-staff',
name: 'chief-of-staff',
adapterType: 'codex_local',
adapterConfig: {
binaryPath: '/opt/homebrew/bin/codex',
},
runtimeBinding: null,
lastValidation: {
status: 'ok',
checkedAt: '2026-04-16T18:30:00.000Z',
message: 'create validation passed',
},
})
const record = await service.get('chief-of-staff')
expect(record?.paths.cwd).toBe(
join(homeDir, '.browseros', 'agents', 'chief-of-staff'),
)
const toolsMd = await readFile(
join(homeDir, '.browseros', 'agents', 'chief-of-staff', 'TOOLS.md'),
'utf8',
)
const heartbeatMd = await readFile(
join(homeDir, '.browseros', 'agents', 'chief-of-staff', 'HEARTBEAT.md'),
'utf8',
)
expect(toolsMd).toContain('browseros-cli')
expect(heartbeatMd).toContain('reserved for future')
expect(record).toMatchObject({
id: 'chief-of-staff',
name: 'chief-of-staff',
adapterType: 'codex_local',
adapterConfig: {
binaryPath: '/opt/homebrew/bin/codex',
},
runtimeBinding: null,
lastValidation: {
status: 'ok',
checkedAt: '2026-04-16T18:30:00.000Z',
message: 'create validation passed',
},
})
})
it('lists stored agents in id order and removes them recursively', async () => {
const { AgentRegistryService } = await import(
'../../../../src/api/services/agents/agent-registry-service'
)
const service = new AgentRegistryService()
await service.create({
id: 'zeta',
name: 'zeta',
adapterType: 'claude_local',
})
await service.create({
id: 'alpha',
name: 'alpha',
adapterType: 'openclaw',
runtimeBinding: {
agentId: 'alpha',
},
})
expect((await service.list()).map((record) => record.id)).toEqual([
'alpha',
'zeta',
])
await service.remove('alpha')
expect(await service.get('alpha')).toBeNull()
expect((await service.list()).map((record) => record.id)).toEqual(['zeta'])
})
})

View File

@@ -0,0 +1,132 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
describe('BrowserOsAgentService', () => {
let homeDir: string
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'browseros-agent-service-'))
mock.module('node:os', () => ({
homedir: () => homeDir,
tmpdir,
}))
})
afterEach(async () => {
mock.restore()
await rm(homeDir, { recursive: true, force: true })
})
it('creates agents through the matching adapter and persists adapter config', async () => {
const { AgentRegistryService } = await import(
'../../../../src/api/services/agents/agent-registry-service'
)
const { BrowserOsAgentService } = await import(
'../../../../src/api/services/agents/agent-service'
)
const validateCreate = mock(async () => {})
const materialize = mock(async () => ({
runtimeBinding: null,
adapterConfig: {
binaryPath: '/usr/local/bin/codex',
},
}))
const remove = mock(async () => {})
const streamChat = mock(
async () =>
new ReadableStream<UIMessageStreamEvent>({
start(controller) {
controller.enqueue({ type: 'start' })
controller.enqueue({ type: 'finish', finishReason: 'stop' })
controller.close()
},
}),
)
const registry = new AgentRegistryService()
const service = new BrowserOsAgentService({
registry,
adapters: [
{
adapterType: 'codex_local',
validateCreate,
materialize,
remove,
streamChat,
} as never,
],
openClawService: {} as never,
})
const created = await service.create({
id: 'codex-agent',
name: 'codex-agent',
adapterType: 'codex_local',
binaryPath: '/usr/local/bin/codex',
dangerouslyBypassApprovalsAndSandbox: true,
})
expect(service.catalog()).toEqual([
{ adapterType: 'codex_local', label: 'Codex Local' },
])
expect(validateCreate).toHaveBeenCalledWith({
id: 'codex-agent',
name: 'codex-agent',
adapterType: 'codex_local',
binaryPath: '/usr/local/bin/codex',
dangerouslyBypassApprovalsAndSandbox: true,
})
expect(created.adapterConfig).toEqual({
binaryPath: '/usr/local/bin/codex',
dangerouslyBypassApprovalsAndSandbox: true,
})
expect((await registry.get('codex-agent'))?.adapterConfig).toEqual({
binaryPath: '/usr/local/bin/codex',
dangerouslyBypassApprovalsAndSandbox: true,
})
})
it('rolls back the registry record when materialize fails', async () => {
const { AgentRegistryService } = await import(
'../../../../src/api/services/agents/agent-registry-service'
)
const { BrowserOsAgentService } = await import(
'../../../../src/api/services/agents/agent-service'
)
const remove = mock(async () => {})
const registry = new AgentRegistryService()
const service = new BrowserOsAgentService({
registry,
adapters: [
{
adapterType: 'claude_local',
validateCreate: mock(async () => {}),
materialize: mock(async () => {
throw new Error('materialize failed')
}),
remove,
streamChat: mock(async () => {
throw new Error('not used')
}),
} as never,
],
openClawService: {} as never,
})
await expect(
service.create({
id: 'claude-agent',
name: 'claude-agent',
adapterType: 'claude_local',
binaryPath: '/usr/local/bin/claude',
}),
).rejects.toThrow('materialize failed')
expect(remove).toHaveBeenCalledTimes(1)
expect(await registry.get('claude-agent')).toBeNull()
})
})

View File

@@ -0,0 +1,293 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
interface SpawnInvocation {
cmd: string[]
cwd?: string
stdinText: string
}
describe('ClaudeLocalAgentAdapter', () => {
let homeDir: string
let invocations: SpawnInvocation[]
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'browseros-claude-local-'))
invocations = []
mock.module('node:os', () => ({
homedir: () => homeDir,
tmpdir,
}))
})
afterEach(async () => {
mock.restore()
await rm(homeDir, { recursive: true, force: true })
})
it('rejects create when binaryPath is missing or empty', async () => {
const { ClaudeLocalAgentAdapter } = await import(
'../../../../src/api/services/agents/adapters/claude-local-adapter'
)
const adapter = new ClaudeLocalAgentAdapter({
spawn: mock(() => {
throw new Error('not used')
}),
})
await expect(
adapter.validateCreate({
id: 'claude-agent',
name: 'Claude Agent',
adapterType: 'claude_local',
binaryPath: ' ',
}),
).rejects.toThrow('claude_local requires a configured binaryPath')
})
it('rejects create when the hello probe fails', async () => {
const spawn = mock(
(cmd: string[], options?: { cwd?: string; stdin?: unknown }) => {
invocations.push({
cmd,
cwd: options?.cwd,
stdinText: decodeStdin(options?.stdin),
})
return createMockProcess({
stdoutLines: [
JSON.stringify({
type: 'assistant',
message: {
content: [{ type: 'text', text: 'goodbye' }],
},
}),
],
})
},
)
const { ClaudeLocalAgentAdapter } = await import(
'../../../../src/api/services/agents/adapters/claude-local-adapter'
)
const adapter = new ClaudeLocalAgentAdapter({ spawn })
await expect(
adapter.validateCreate({
id: 'claude-agent',
name: 'Claude Agent',
adapterType: 'claude_local',
binaryPath: '/usr/local/bin/claude',
dangerouslySkipPermissions: true,
}),
).rejects.toThrow('Claude hello probe failed')
expect(invocations).toHaveLength(1)
expect(invocations[0]).toEqual({
cmd: [
'/usr/local/bin/claude',
'--print',
'-',
'--output-format',
'stream-json',
'--verbose',
'--dangerously-skip-permissions',
],
cwd: join(homeDir, '.browseros', 'agents', 'claude-agent'),
stdinText: 'Respond with hello.',
})
})
it('writes the Claude system prompt file and normalizes assistant content blocks into text events', async () => {
const agentDir = join(homeDir, 'agents', 'claude-agent')
const runtimeDir = join(
homeDir,
'.browseros',
'agents',
'claude-agent',
'runtime',
)
const agentCwd = join(homeDir, 'workspace', 'claude-agent')
await mkdir(agentDir, { recursive: true })
await mkdir(runtimeDir, { recursive: true })
await mkdir(agentCwd, { recursive: true })
await writeFile(
join(agentDir, 'AGENTS.md'),
'# Agent Rules\nFollow the BrowserOS plan.\n',
)
await writeFile(join(agentDir, 'SOUL.md'), '# Soul\nStay calm.\n')
await writeFile(join(agentDir, 'TOOLS.md'), '# Tools\nUse the workspace.\n')
const spawn = mock(
(cmd: string[], options?: { cwd?: string; stdin?: unknown }) => {
invocations.push({
cmd,
cwd: options?.cwd,
stdinText: decodeStdin(options?.stdin),
})
return createMockProcess({
stdoutLines: [
JSON.stringify({
type: 'assistant',
message: {
content: [
{ type: 'text', text: 'Hello' },
{ type: 'tool_use', name: 'search' },
{ type: 'text', text: ' world' },
],
},
}),
JSON.stringify({
type: 'assistant',
message: {
content: [{ type: 'text', text: '!' }],
},
}),
],
})
},
)
const { ClaudeLocalAgentAdapter } = await import(
'../../../../src/api/services/agents/adapters/claude-local-adapter'
)
const adapter = new ClaudeLocalAgentAdapter({ spawn })
const record = createStoredAgent({
id: 'claude-agent',
name: 'Claude Agent',
adapterType: 'claude_local',
paths: {
agentDir,
cwd: agentCwd,
contextDirs: [],
},
adapterConfig: {
binaryPath: '/usr/local/bin/claude',
dangerouslySkipPermissions: true,
},
})
expect(
await adapter.materialize({
id: 'claude-agent',
name: 'Claude Agent',
adapterType: 'claude_local',
binaryPath: '/usr/local/bin/claude',
dangerouslySkipPermissions: true,
}),
).toEqual({
runtimeBinding: null,
})
await expect(adapter.remove(record)).resolves.toBeUndefined()
const stream = await adapter.streamChat(record, {
sessionKey: 'session-123',
conversation: [
{ role: 'user', text: 'What happened yesterday?' },
{ role: 'assistant', text: 'We finished the migration.' },
],
message: 'Summarize the current state.',
})
expect(invocations).toHaveLength(1)
expect(invocations[0]?.cmd).toEqual([
'/usr/local/bin/claude',
'--print',
'-',
'--output-format',
'stream-json',
'--verbose',
'--dangerously-skip-permissions',
'--append-system-prompt-file',
join(runtimeDir, 'claude-system-prompt.md'),
])
expect(invocations[0]?.cwd).toBe(agentCwd)
expect(invocations[0]?.stdinText).toContain('What happened yesterday?')
expect(invocations[0]?.stdinText).toContain('We finished the migration.')
expect(invocations[0]?.stdinText).toContain('Summarize the current state.')
const systemPrompt = await readFile(
join(runtimeDir, 'claude-system-prompt.md'),
'utf8',
)
expect(systemPrompt).toContain('# Agent Rules')
expect(systemPrompt).toContain('# Soul')
expect(systemPrompt).toContain('# Tools')
expect(await readEvents(stream)).toEqual([
{ type: 'start' },
{ type: 'text-start', id: 'claude-agent-text' },
{ type: 'text-delta', id: 'claude-agent-text', delta: 'Hello' },
{ type: 'text-delta', id: 'claude-agent-text', delta: ' world' },
{ type: 'text-delta', id: 'claude-agent-text', delta: '!' },
{ type: 'text-end', id: 'claude-agent-text' },
{ type: 'finish', finishReason: 'stop' },
])
})
})
function createStoredAgent(
overrides: Partial<BrowserOsStoredAgent> = {},
): BrowserOsStoredAgent {
return {
version: 1,
id: 'agent',
name: 'Agent',
adapterType: 'claude_local',
paths: {
agentDir: '/tmp/agent',
cwd: '/tmp/agent',
contextDirs: [],
},
adapterConfig: {},
runtimeBinding: null,
lastValidation: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
function createMockProcess(input: {
stdoutLines?: string[]
stderrLines?: string[]
exitCode?: number
}) {
return {
stdout: createByteStream(input.stdoutLines ?? []),
stderr: createByteStream(input.stderrLines ?? []),
exited: Promise.resolve(input.exitCode ?? 0),
}
}
function createByteStream(lines: string[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
for (const line of lines) {
controller.enqueue(encoder.encode(`${line}\n`))
}
controller.close()
},
})
}
function decodeStdin(stdin: unknown): string {
if (stdin instanceof Uint8Array) {
return new TextDecoder().decode(stdin)
}
return String(stdin ?? '')
}
async function readEvents(
stream: ReadableStream<Record<string, unknown>>,
): Promise<Record<string, unknown>[]> {
const reader = stream.getReader()
const events: Record<string, unknown>[] = []
while (true) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
return events
}

View File

@@ -0,0 +1,273 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
interface SpawnInvocation {
cmd: string[]
cwd?: string
stdinText: string
}
describe('CodexLocalAgentAdapter', () => {
let homeDir: string
let invocations: SpawnInvocation[]
beforeEach(async () => {
homeDir = await mkdtemp(join(tmpdir(), 'browseros-codex-local-'))
invocations = []
mock.module('node:os', () => ({
homedir: () => homeDir,
tmpdir,
}))
})
afterEach(async () => {
mock.restore()
await rm(homeDir, { recursive: true, force: true })
})
it('rejects create when binaryPath is missing or empty', async () => {
const { CodexLocalAgentAdapter } = await import(
'../../../../src/api/services/agents/adapters/codex-local-adapter'
)
const adapter = new CodexLocalAgentAdapter({
spawn: mock(() => {
throw new Error('not used')
}),
})
await expect(
adapter.validateCreate({
id: 'codex-agent',
name: 'Codex Agent',
adapterType: 'codex_local',
binaryPath: ' ',
}),
).rejects.toThrow('codex_local requires a configured binaryPath')
})
it('rejects create when the hello probe fails', async () => {
const spawn = mock(
(cmd: string[], options?: { cwd?: string; stdin?: unknown }) => {
invocations.push({
cmd,
cwd: options?.cwd,
stdinText: decodeStdin(options?.stdin),
})
return createMockProcess({
stdoutLines: [
JSON.stringify({
type: 'assistant',
message: {
content: [{ type: 'output_text', text: 'goodbye' }],
},
}),
],
})
},
)
const { CodexLocalAgentAdapter } = await import(
'../../../../src/api/services/agents/adapters/codex-local-adapter'
)
const adapter = new CodexLocalAgentAdapter({ spawn })
await expect(
adapter.validateCreate({
id: 'codex-agent',
name: 'Codex Agent',
adapterType: 'codex_local',
binaryPath: '/usr/local/bin/codex',
dangerouslyBypassApprovalsAndSandbox: true,
}),
).rejects.toThrow('Codex hello probe failed')
expect(invocations).toHaveLength(1)
expect(invocations[0]).toEqual({
cmd: [
'/usr/local/bin/codex',
'exec',
'--json',
'--dangerously-bypass-approvals-and-sandbox',
'-',
],
cwd: join(homeDir, '.browseros', 'agents', 'codex-agent'),
stdinText: 'Respond with hello.',
})
})
it('builds the local prompt and normalizes codex JSONL stdout into text events', async () => {
const agentDir = join(homeDir, 'agents', 'codex-agent')
const agentCwd = join(homeDir, 'workspace', 'codex-agent')
await mkdir(agentDir, { recursive: true })
await mkdir(agentCwd, { recursive: true })
await writeFile(
join(agentDir, 'AGENTS.md'),
'# Agent Rules\nDo the task.\n',
)
await writeFile(join(agentDir, 'SOUL.md'), '# Soul\nStay calm.\n')
await writeFile(join(agentDir, 'TOOLS.md'), '# Tools\nUse the workspace.\n')
const spawn = mock(
(cmd: string[], options?: { cwd?: string; stdin?: unknown }) => {
invocations.push({
cmd,
cwd: options?.cwd,
stdinText: decodeStdin(options?.stdin),
})
return createMockProcess({
stdoutLines: [
JSON.stringify({
type: 'item.completed',
item: {
type: 'agent_message',
text: 'Hello',
},
}),
JSON.stringify({
type: 'response.output_text.delta',
delta: ' world',
}),
JSON.stringify({
type: 'message',
text: '!',
}),
],
})
},
)
const { CodexLocalAgentAdapter } = await import(
'../../../../src/api/services/agents/adapters/codex-local-adapter'
)
const adapter = new CodexLocalAgentAdapter({ spawn })
const record = createStoredAgent({
id: 'codex-agent',
name: 'Codex Agent',
adapterType: 'codex_local',
paths: {
agentDir,
cwd: agentCwd,
contextDirs: [],
},
adapterConfig: {
binaryPath: '/usr/local/bin/codex',
dangerouslyBypassApprovalsAndSandbox: true,
},
})
expect(
await adapter.materialize({
id: 'codex-agent',
name: 'Codex Agent',
adapterType: 'codex_local',
binaryPath: '/usr/local/bin/codex',
dangerouslyBypassApprovalsAndSandbox: true,
}),
).toEqual({
runtimeBinding: null,
})
await expect(adapter.remove(record)).resolves.toBeUndefined()
const stream = await adapter.streamChat(record, {
sessionKey: 'session-123',
conversation: [
{ role: 'user', text: 'What happened yesterday?' },
{ role: 'assistant', text: 'We finished the migration.' },
],
message: 'Summarize the current state.',
})
expect(invocations).toHaveLength(1)
expect(invocations[0]?.cmd).toEqual([
'/usr/local/bin/codex',
'exec',
'--json',
'--dangerously-bypass-approvals-and-sandbox',
'-',
])
expect(invocations[0]?.cwd).toBe(agentCwd)
expect(invocations[0]?.stdinText).toContain('# Agent Rules')
expect(invocations[0]?.stdinText).toContain('# Soul')
expect(invocations[0]?.stdinText).toContain('# Tools')
expect(invocations[0]?.stdinText).toContain('What happened yesterday?')
expect(invocations[0]?.stdinText).toContain('We finished the migration.')
expect(invocations[0]?.stdinText).toContain('Summarize the current state.')
expect(await readEvents(stream)).toEqual([
{ type: 'start' },
{ type: 'text-start', id: 'codex-agent-text' },
{ type: 'text-delta', id: 'codex-agent-text', delta: 'Hello' },
{ type: 'text-delta', id: 'codex-agent-text', delta: ' world' },
{ type: 'text-delta', id: 'codex-agent-text', delta: '!' },
{ type: 'text-end', id: 'codex-agent-text' },
{ type: 'finish', finishReason: 'stop' },
])
})
})
function createStoredAgent(
overrides: Partial<BrowserOsStoredAgent> = {},
): BrowserOsStoredAgent {
return {
version: 1,
id: 'agent',
name: 'Agent',
adapterType: 'codex_local',
paths: {
agentDir: '/tmp/agent',
cwd: '/tmp/agent',
contextDirs: [],
},
adapterConfig: {},
runtimeBinding: null,
lastValidation: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
function createMockProcess(input: {
stdoutLines?: string[]
stderrLines?: string[]
exitCode?: number
}) {
return {
stdout: createByteStream(input.stdoutLines ?? []),
stderr: createByteStream(input.stderrLines ?? []),
exited: Promise.resolve(input.exitCode ?? 0),
}
}
function createByteStream(lines: string[]): ReadableStream<Uint8Array> {
return new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
for (const line of lines) {
controller.enqueue(encoder.encode(`${line}\n`))
}
controller.close()
},
})
}
function decodeStdin(stdin: unknown): string {
if (stdin instanceof Uint8Array) {
return new TextDecoder().decode(stdin)
}
return String(stdin ?? '')
}
async function readEvents(
stream: ReadableStream<Record<string, unknown>>,
): Promise<Record<string, unknown>[]> {
const reader = stream.getReader()
const events: Record<string, unknown>[] = []
while (true) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
return events
}

View File

@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
type MutableOpenClawService = {
getStatus: ReturnType<typeof mock>
createAgent: ReturnType<typeof mock>
removeAgent: ReturnType<typeof mock>
chatStream: ReturnType<typeof mock>
}
describe('OpenClawAgentAdapter', () => {
let service: MutableOpenClawService
beforeEach(() => {
service = {
getStatus: mock(async () => ({
status: 'running',
podmanAvailable: true,
machineReady: true,
port: 18789,
agentCount: 1,
error: null,
controlPlaneStatus: 'connected',
lastGatewayError: null,
lastRecoveryReason: null,
})),
createAgent: mock(async () => ({
agentId: 'ops',
name: 'ops',
workspace: '/workspace/ops',
model: 'openclaw/ops',
})),
removeAgent: mock(async () => {}),
chatStream: mock(
async () =>
new ReadableStream({
start(controller) {
controller.enqueue({
type: 'text-delta',
data: { text: 'Hello' },
})
controller.enqueue({
type: 'text-delta',
data: { text: ' world' },
})
controller.enqueue({
type: 'done',
data: { text: 'Hello world' },
})
controller.close()
},
}),
),
}
})
afterEach(() => {
mock.restore()
})
it('rejects create when OpenClaw is not ready', async () => {
service.getStatus = mock(async () => ({
status: 'starting',
podmanAvailable: true,
machineReady: true,
port: 18789,
agentCount: 0,
error: null,
controlPlaneStatus: 'connecting',
lastGatewayError: null,
lastRecoveryReason: null,
}))
const { OpenClawAgentAdapter } = await import(
'../../../../src/api/services/agents/adapters/openclaw-adapter'
)
const adapter = new OpenClawAgentAdapter(service as never)
await expect(
adapter.validateCreate({
id: 'ops',
name: 'Ops',
adapterType: 'openclaw',
}),
).rejects.toThrow('OpenClaw must be running with a connected control plane')
})
it('materializes, removes, and streams via OpenClaw', async () => {
const { OpenClawAgentAdapter } = await import(
'../../../../src/api/services/agents/adapters/openclaw-adapter'
)
const adapter = new OpenClawAgentAdapter(service as never)
const materialized = await adapter.materialize({
id: 'ops',
name: 'Ops',
adapterType: 'openclaw',
providerType: 'openai',
providerName: 'openai',
baseUrl: 'https://api.example.com/v1',
apiKey: 'secret-key',
modelId: 'gpt-4o-mini',
})
expect(service.createAgent).toHaveBeenCalledWith({
name: 'ops',
providerType: 'openai',
providerName: 'openai',
baseUrl: 'https://api.example.com/v1',
apiKey: 'secret-key',
modelId: 'gpt-4o-mini',
})
expect(materialized).toEqual({
runtimeBinding: {
agentId: 'ops',
workspace: '/workspace/ops',
model: 'openclaw/ops',
},
adapterConfig: {
providerType: 'openai',
providerName: 'openai',
baseUrl: 'https://api.example.com/v1',
modelId: 'gpt-4o-mini',
},
})
const record = createStoredAgent({
runtimeBinding: {
agentId: 'ops-runtime',
},
})
await adapter.remove(record)
expect(service.removeAgent).toHaveBeenCalledWith('ops-runtime')
const stream = await adapter.streamChat(record, {
sessionKey: 'session-123',
message: 'hi',
})
expect(service.chatStream).toHaveBeenCalledWith(
'ops-runtime',
'session-123',
'hi',
)
expect(await readEvents(stream)).toEqual([
{ type: 'start' },
{ type: 'text-start', id: 'ops-runtime-text' },
{ type: 'text-delta', id: 'ops-runtime-text', delta: 'Hello' },
{ type: 'text-delta', id: 'ops-runtime-text', delta: ' world' },
{ type: 'text-end', id: 'ops-runtime-text' },
{ type: 'finish', finishReason: 'stop' },
])
})
it('preserves thinking, tool, and lifecycle details in normalized chat output', async () => {
service.chatStream = mock(
async () =>
new ReadableStream({
start(controller) {
controller.enqueue({
type: 'thinking',
data: { text: 'Inspecting context' },
})
controller.enqueue({
type: 'tool-start',
data: {
toolCallId: 'call-1',
toolName: 'browser.search',
input: { query: 'BrowserOS' },
},
})
controller.enqueue({
type: 'tool-output',
data: {
toolCallId: 'call-1',
output: { result: 'ok' },
},
})
controller.enqueue({
type: 'tool-end',
data: {
toolCallId: 'call-1',
status: 'completed',
},
})
controller.enqueue({
type: 'lifecycle',
data: {
phase: 'retrieval',
status: 'running',
},
})
controller.enqueue({
type: 'done',
data: { text: '' },
})
controller.close()
},
}),
)
const { OpenClawAgentAdapter } = await import(
'../../../../src/api/services/agents/adapters/openclaw-adapter'
)
const adapter = new OpenClawAgentAdapter(service as never)
const stream = await adapter.streamChat(createStoredAgent(), {
sessionKey: 'session-456',
message: 'status',
})
expect(await readEvents(stream)).toEqual([
{ type: 'start' },
{ type: 'text-start', id: 'ops-text' },
{ type: 'reasoning-start', id: 'ops-reasoning' },
{
type: 'reasoning-delta',
id: 'ops-reasoning',
delta: 'Inspecting context',
},
{
type: 'tool-input-start',
toolCallId: 'call-1',
toolName: 'browser.search',
},
{
type: 'tool-input-available',
toolCallId: 'call-1',
toolName: 'browser.search',
input: { query: 'BrowserOS' },
},
{
type: 'tool-output-available',
toolCallId: 'call-1',
output: { result: 'ok' },
},
{
type: 'tool-output-available',
toolCallId: 'call-1',
output: { status: 'completed' },
},
{
type: 'reasoning-delta',
id: 'ops-reasoning',
delta: '{"phase":"retrieval","status":"running"}',
},
{ type: 'reasoning-end', id: 'ops-reasoning' },
{ type: 'text-end', id: 'ops-text' },
{ type: 'finish', finishReason: 'stop' },
])
})
})
function createStoredAgent(
overrides: Partial<BrowserOsStoredAgent> = {},
): BrowserOsStoredAgent {
return {
version: 1,
id: 'ops',
name: 'Ops',
adapterType: 'openclaw',
paths: {
agentDir: '/tmp/agent',
cwd: '/tmp/agent',
contextDirs: [],
},
adapterConfig: {},
runtimeBinding: {
agentId: 'ops',
},
lastValidation: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
async function readEvents(
stream: ReadableStream<Record<string, unknown>>,
): Promise<Record<string, unknown>[]> {
const reader = stream.getReader()
const events: Record<string, unknown>[] = []
while (true) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
return events
}

View File

@@ -0,0 +1,40 @@
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
import { tmpdir } from 'node:os'
describe('browseros agent path helpers', () => {
let homeDir: string
beforeEach(() => {
homeDir = '/tmp/browseros-agent-path-test'
mock.module('node:os', () => ({
homedir: () => homeDir,
tmpdir,
}))
})
afterEach(() => {
mock.restore()
})
it('derives managed agent paths under ~/.browseros/agents/<id>', async () => {
const {
getAgentsDir,
getAgentDir,
getAgentMetadataPath,
getAgentRuntimeDir,
} = await import('../../src/lib/browseros-dir')
expect(getAgentsDir()).toBe(
'/tmp/browseros-agent-path-test/.browseros/agents',
)
expect(getAgentDir('chief-of-staff')).toBe(
'/tmp/browseros-agent-path-test/.browseros/agents/chief-of-staff',
)
expect(getAgentMetadataPath('chief-of-staff')).toBe(
'/tmp/browseros-agent-path-test/.browseros/agents/chief-of-staff/agent.json',
)
expect(getAgentRuntimeDir('chief-of-staff')).toBe(
'/tmp/browseros-agent-path-test/.browseros/agents/chief-of-staff/runtime',
)
})
})

View File

@@ -53,6 +53,10 @@
"types": "./src/types/server-config.ts",
"default": "./src/types/server-config.ts"
},
"./types/browseros-agents": {
"types": "./src/types/browseros-agents.ts",
"default": "./src/types/browseros-agents.ts"
},
"./types/role-aware-agents": {
"types": "./src/types/role-aware-agents.ts",
"default": "./src/types/role-aware-agents.ts"

View File

@@ -9,6 +9,9 @@
export const PATHS = {
DEFAULT_EXECUTION_DIR: process.cwd(),
BROWSEROS_DIR_NAME: '.browseros',
AGENTS_DIR_NAME: 'agents',
AGENT_METADATA_FILE_NAME: 'agent.json',
AGENT_RUNTIME_DIR_NAME: 'runtime',
MEMORY_DIR_NAME: 'memory',
SESSIONS_DIR_NAME: 'sessions',
TOOL_OUTPUT_DIR_NAME: 'tool-output',

View File

@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export type BrowserOsAgentAdapterType =
| 'openclaw'
| 'codex_local'
| 'claude_local'
export interface BrowserOsAgentPaths {
agentDir: string
cwd: string
contextDirs: string[]
}
export interface BrowserOsValidationState {
status: 'ok' | 'error'
checkedAt: string
message: string
}
export interface BrowserOsStoredAgent {
version: 1
id: string
name: string
adapterType: BrowserOsAgentAdapterType
paths: BrowserOsAgentPaths
adapterConfig: Record<string, unknown>
runtimeBinding: Record<string, unknown> | null
lastValidation: BrowserOsValidationState | null
createdAt: string
updatedAt: string
}