"use client" import * as React from 'react' import { useSearchParams } from 'next/navigation' import { Play, Plug, RotateCw, Server, ShieldAlert, SquareCode } from 'lucide-react' import { Page, PageBody } from '@open-mercato/ui/backend/Page' import { Button } from '@open-mercato/ui/primitives/button' import { Textarea } from '@open-mercato/ui/primitives/textarea' import { Spinner } from '@open-mercato/ui/primitives/spinner' import { Alert } from '@open-mercato/ui/primitives/alert' import { EmptyState } from '@open-mercato/ui/primitives/empty-state' import { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge' import { SimpleTooltip } from '@open-mercato/ui/primitives/tooltip' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@open-mercato/ui/primitives/select' import { JsonDisplay } from '@open-mercato/ui/backend/JsonDisplay' import { SectionHeader, CollapsibleSection } from '@open-mercato/ui/backend/SectionHeader' import { apiCall } from '@open-mercato/ui/backend/utils/apiCall' import { useT } from '@open-mercato/shared/lib/i18n/context' import { useBackendChrome } from '@open-mercato/ui/backend/BackendChromeProvider' import { hasFeature } from '@open-mercato/shared/security/features' import { ProposeOnlyNotice } from '../../components/ProposeOnlyNotice' import { AUTOMATION_AUTHOR_FEATURE, AUTOMATION_EDITOR_HREF } from '../../lib/automationLinks' import { ProposalCard } from '../../components/ProposalCard' import { WebSearchActivity } from '../../components/WebSearchActivity' import { mapAdHocProposal, mapAgent, type AgentView } from '../../components/types' import { toolPanelStateFromResponse, type ToolPanelState } from '../../components/playgroundToolCalls' import { runErrorStateFromBody } from '../../components/playgroundRunError' import { Chip, TYPE_ICON, RUNTIME_ICON, resolveAgentIcon } from '../../components/agentChips' import { PlaygroundEvalPanel } from './PlaygroundEvalPanel' type AgentsResponse = { items?: Array> } type AgentResult = | { kind: 'research'; data: unknown } | { kind: 'proposal'; proposal: unknown } type AgentRunResponse = AgentResult & { runId?: string | null; proposalId?: string | null } // Connectivity states derived from the ai_assistant health endpoint — the same // source the AI assistant settings page reads. Three independent signals: // • opencode — the OpenCode container is up (GET /global/health) // • mcpBridge — OpenCode has bound to the MCP server (GET /mcp) // • mcpServer — the MCP HTTP server itself is healthy (GET {mcpUrl}/health) // `hidden` covers callers without `ai_assistant.view`. type ConnState = 'ok' | 'warn' | 'down' | 'unknown' type ConnectionsState = | { mode: 'loading' } | { mode: 'hidden' } | { mode: 'ready' opencode: ConnState opencodeVersion: string | null mcpBridge: ConnState mcpBridgeServers: Array<{ name: string; status: string }> mcpServer: ConnState mcpServerTools: number | null } type HealthResponse = { status?: 'ok' | 'error' opencode?: { healthy?: boolean; version?: string } mcp?: Record mcpHealth?: { healthy?: boolean; status?: string; tools?: number } } const CONN_VARIANT: Record = { ok: 'success', warn: 'warning', down: 'error', unknown: 'neutral', } function connectionsFromHealth(result: HealthResponse | null): Omit, 'mode'> { const opencodeHealthy = result?.opencode?.healthy === true const mcpEntries = Object.entries(result?.mcp ?? {}).map(([name, value]) => ({ name, status: typeof value?.status === 'string' ? value.status : 'unknown', })) const mcpBridge: ConnState = mcpEntries.some((entry) => entry.status === 'connected') ? 'ok' : mcpEntries.some((entry) => entry.status === 'connecting') ? 'warn' : mcpEntries.length > 0 ? 'down' : 'unknown' const mcpServer: ConnState = result?.mcpHealth ? result.mcpHealth.healthy === true ? 'ok' : 'down' : 'unknown' return { opencode: opencodeHealthy ? 'ok' : 'down', opencodeVersion: typeof result?.opencode?.version === 'string' ? result.opencode.version : null, mcpBridge, mcpBridgeServers: mcpEntries, mcpServer, mcpServerTools: typeof result?.mcpHealth?.tools === 'number' ? result.mcpHealth.tools : null, } } function ConnectionBadges() { const t = useT() const [state, setState] = React.useState({ mode: 'loading' }) const load = React.useCallback(async () => { const call = await apiCall('/api/ai_assistant/health', undefined, { fallback: null }) if (!call.ok) { // 401/403 → the caller cannot read health at all; hide instead of guessing. if (call.status === 401 || call.status === 403) { setState({ mode: 'hidden' }) return } setState({ mode: 'ready', opencode: 'down', opencodeVersion: null, mcpBridge: 'unknown', mcpBridgeServers: [], mcpServer: 'unknown', mcpServerTools: null, }) return } setState({ mode: 'ready', ...connectionsFromHealth(call.result) }) }, []) React.useEffect(() => { void load() }, [load]) if (state.mode === 'hidden') return null if (state.mode === 'loading') { return (
{t('agent_orchestrator.playground.connections.checking', 'Checking…')}
) } const stateLabel = (value: ConnState) => value === 'ok' ? t('agent_orchestrator.playground.connections.connected', 'Connected') : value === 'warn' ? t('agent_orchestrator.playground.connections.connecting', 'Connecting…') : value === 'down' ? t('agent_orchestrator.playground.connections.disconnected', 'Not connected') : t('agent_orchestrator.playground.connections.unknown', 'Status unknown') const opencodeTooltip = state.opencode === 'ok' && state.opencodeVersion ? `${stateLabel(state.opencode)} · ${t('agent_orchestrator.playground.connections.version', 'Version {version}', { version: state.opencodeVersion })}` : stateLabel(state.opencode) const mcpBridgeTooltip = state.mcpBridgeServers.length > 0 ? state.mcpBridgeServers.map((server) => `${server.name}: ${server.status}`).join(' · ') : stateLabel(state.mcpBridge) const mcpServerTooltip = state.mcpServer === 'ok' && state.mcpServerTools !== null ? `${stateLabel(state.mcpServer)} · ${t('agent_orchestrator.playground.connections.tools', '{count} tools', { count: state.mcpServerTools })}` : stateLabel(state.mcpServer) return (
{t('agent_orchestrator.playground.connections.opencode', 'OpenCode')} {t('agent_orchestrator.playground.connections.mcpBridge', 'MCP ↔ OpenCode')} {t('agent_orchestrator.playground.connections.mcp', 'MCP')}
) } export default function AgentPlaygroundPage() { const t = useT() const searchParams = useSearchParams() const { payload: chromePayload } = useBackendChrome() // The automation CTAs are hidden, not disabled, for a runner who cannot author // one — a dead button teaches nothing. const canAuthorAutomations = hasFeature(chromePayload?.grantedFeatures, AUTOMATION_AUTHOR_FEATURE) const [agents, setAgents] = React.useState([]) const [agentId, setAgentId] = React.useState('') const [input, setInput] = React.useState('{\n \n}') const [running, setRunning] = React.useState(false) const [result, setResult] = React.useState(null) const [runId, setRunId] = React.useState(null) const [proposalId, setProposalId] = React.useState(null) const [toolPanel, setToolPanel] = React.useState({ mode: 'idle' }) const [error, setError] = React.useState(null) const [guardrailBlock, setGuardrailBlock] = React.useState<{ guardrailKind: string; phase: string } | null>(null) React.useEffect(() => { let cancelled = false async function load() { const call = await apiCall('/api/agent_orchestrator/agents', undefined, { fallback: { items: [] }, }) if (cancelled || !call.ok) return const items = Array.isArray(call.result?.items) ? call.result!.items : [] const mapped = items .map((item) => mapAgent(item as Record)) .filter((agent): agent is AgentView => !!agent) setAgents(mapped) const requested = searchParams?.get('agent') if (requested && mapped.some((agent) => agent.id === requested)) { setAgentId(requested) } else if (mapped[0]) { setAgentId(mapped[0].id) } } load() return () => { cancelled = true } }, [searchParams]) const selectedAgent = React.useMemo( () => agents.find((agent) => agent.id === agentId) ?? null, [agents, agentId], ) // Per-agent example input ships with the agent definition (code agents via // `defineAgent({ sampleInput })`, file agents via `agents//SAMPLE.json`) // and arrives on the agent list payload — the Playground no longer hardcodes // samples. The button hides when an agent declares none. const sampleInput = selectedAgent?.sampleInput const run = React.useCallback(async () => { if (!agentId) { setError(t('agent_orchestrator.playground.selectAgentFirst')) return } let parsed: unknown try { parsed = JSON.parse(input) } catch { setError(t('agent_orchestrator.playground.invalidJson')) return } setError(null) setGuardrailBlock(null) setRunning(true) setResult(null) setRunId(null) setProposalId(null) setToolPanel({ mode: 'idle' }) try { const call = await apiCall( `/api/agent_orchestrator/agents/${encodeURIComponent(agentId)}/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ input: parsed }), }, { fallback: null }, ) if (!call.ok || !call.result) { // A guardrail block is a policy verdict with a typed reason — surface // it as its own alert, never the generic run-failed message (§3.6). const errorState = runErrorStateFromBody(call.result) if (errorState.kind === 'guardrail') { setGuardrailBlock({ guardrailKind: errorState.guardrailKind, phase: errorState.phase }) } else { setError( errorState.messageKey ? t(errorState.messageKey) : errorState.message ?? t('agent_orchestrator.playground.error'), ) } return } const data = call.result setResult(data) setRunId(typeof data.runId === 'string' ? data.runId : null) setProposalId(typeof data.proposalId === 'string' ? data.proposalId : null) } catch (err) { setError(err instanceof Error ? err.message : t('agent_orchestrator.playground.error')) } finally { setRunning(false) } }, [agentId, input, t]) // Fetch the run's ACTUAL tool calls from the trace-detail route once the run // id is known. That route gates `trace.view` while this page gates // `agents.run`; a 403 (or any failure) degrades to the declared-tools list // under a "Declared tools" heading — never the false "Tools used" framing. React.useEffect(() => { if (!runId) return let cancelled = false async function loadToolCalls(id: string) { setToolPanel({ mode: 'loading' }) const call = await apiCall>( `/api/agent_orchestrator/runs/${encodeURIComponent(id)}`, undefined, { fallback: {} }, ) if (cancelled) return setToolPanel(toolPanelStateFromResponse(call)) } void loadToolCalls(runId) return () => { cancelled = true } }, [runId]) const handleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { event.preventDefault() void run() } }, [run], ) const hasOutcome = Boolean(result || running || error || guardrailBlock) return (

{t('agent_orchestrator.playground.title')}

{t('agent_orchestrator.playground.subtitle')}

{/* Stacked, not side by side. Compose and Result shared a two-column row, which capped the result at half the viewport — the one panel whose whole job is to be read. An agent's returned payload is wide (JSON, prose, tool traces) and was the thing operators squinted at. Compose is a short form and loses nothing by being full width above it. */}
{/* Compose panel */}
{t('agent_orchestrator.playground.composeTitle', 'Run an agent')} {selectedAgent ? ( {t(`agent_orchestrator.agents.list.runtime.${selectedAgent.runtime}`)} {t(`agent_orchestrator.agents.list.resultKind.${selectedAgent.resultKind}`)} ) : null}
{selectedAgent?.description ? (

{selectedAgent.description}

) : null}
{sampleInput !== undefined ? ( ) : null}