Compare commits

...

6 Commits

Author SHA1 Message Date
Nikhil Sonti
793ee5e508 fix: address review feedback for PR #865 2026-04-29 10:03:43 -07:00
Nikhil Sonti
fe43f4849d chore(agent): retire virtual sidepanel acp targets 2026-04-29 09:47:13 -07:00
Nikhil Sonti
37b4e18eda feat(agent): route sidepanel agent sends by agent id 2026-04-29 09:44:23 -07:00
Nikhil Sonti
1118d61429 feat(server): add sidepanel chat route for created agents 2026-04-29 09:43:15 -07:00
Nikhil Sonti
f16309bb0b feat(agent): show created agents in sidepanel selector 2026-04-29 09:35:49 -07:00
Nikhil Sonti
baff61cf38 feat(agent): list created agents in sidepanel target catalog 2026-04-29 09:34:38 -07:00
15 changed files with 540 additions and 337 deletions

View File

@@ -17,7 +17,7 @@ export function groupProviderOptions(
? [{ key: 'llm' as const, label: 'AI Providers', options: llm }]
: []),
...(acp.length
? [{ key: 'acp' as const, label: 'ACP Models', options: acp }]
? [{ key: 'acp' as const, label: 'Agents', options: acp }]
: []),
]
}
@@ -26,14 +26,25 @@ export function getProviderSearchValue(
provider: Provider,
groupLabel: string,
): string {
return [provider.id, provider.name, provider.type, groupLabel]
return [
provider.id,
provider.name,
provider.type,
groupLabel,
provider.adapterName,
provider.modelLabel,
]
.filter(Boolean)
.join(' ')
}
export function getProviderSubtitle(provider: Provider): string | undefined {
if (provider.kind !== 'acp') return undefined
return provider.modelControl === 'best-effort'
? 'ACP model · best effort'
: 'ACP model'
return [
provider.adapterName,
provider.modelLabel,
provider.modelControl === 'best-effort' ? 'best effort' : undefined,
]
.filter(Boolean)
.join(' · ')
}

View File

@@ -16,22 +16,26 @@ const options: Provider[] = [
},
{
kind: 'acp',
id: 'acp:claude:haiku:medium',
name: 'Claude Code Haiku',
id: 'agent-claude-review',
name: 'Review Bot',
type: 'acp',
adapterName: 'Claude Code',
modelLabel: 'Haiku',
modelControl: 'best-effort',
},
{
kind: 'acp',
id: 'acp:codex:gpt-5.5:medium',
name: 'Codex GPT-5.5',
id: 'agent-codex-browser',
name: 'Browser Driver',
type: 'acp',
adapterName: 'Codex',
modelLabel: 'GPT-5.5',
modelControl: 'runtime-supported',
},
]
describe('groupProviderOptions', () => {
it('groups normal providers separately from ACP models', () => {
it('groups normal providers separately from created agents', () => {
expect(groupProviderOptions(options)).toEqual([
{
key: 'llm',
@@ -40,7 +44,7 @@ describe('groupProviderOptions', () => {
},
{
key: 'acp',
label: 'ACP Models',
label: 'Agents',
options: [options[2], options[3]],
},
])
@@ -48,20 +52,21 @@ describe('groupProviderOptions', () => {
})
describe('getProviderSearchValue', () => {
it('matches ACP group labels and item labels', () => {
expect(getProviderSearchValue(options[2], 'ACP Models')).toContain(
'ACP Models',
)
expect(getProviderSearchValue(options[2], 'ACP Models')).toContain(
'Claude Code Haiku',
it('matches created-agent group labels and item labels', () => {
expect(getProviderSearchValue(options[2], 'Agents')).toContain('Agents')
expect(getProviderSearchValue(options[2], 'Agents')).toContain('Review Bot')
expect(getProviderSearchValue(options[2], 'Agents')).toContain(
'Claude Code',
)
})
})
describe('getProviderSubtitle', () => {
it('does not present best-effort ACP models as guaranteed routing', () => {
expect(getProviderSubtitle(options[2])).toBe('ACP model · best effort')
expect(getProviderSubtitle(options[3])).toBe('ACP model')
it('describes created-agent runtime context without model-target copy', () => {
expect(getProviderSubtitle(options[2])).toBe(
'Claude Code · Haiku · best effort',
)
expect(getProviderSubtitle(options[3])).toBe('Codex · GPT-5.5')
expect(getProviderSubtitle(options[0])).toBeUndefined()
})
})

View File

@@ -41,7 +41,10 @@ export const ChatProviderSelector: FC<
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent side="bottom" align="start" className="w-64 p-0">
<Command>
<CommandInput placeholder="Search models..." className="h-9" />
<CommandInput
placeholder="Search providers or agents..."
className="h-9"
/>
<CommandList>
<CommandEmpty>No provider found</CommandEmpty>
{groups.map((group) => (

View File

@@ -7,5 +7,8 @@ export interface Provider {
name: string
type: ChatProviderType
kind: 'llm' | 'acp'
agentId?: string
adapterName?: string
modelLabel?: string
modelControl?: 'runtime-supported' | 'best-effort'
}

View File

@@ -1,5 +1,8 @@
import { describe, expect, it } from 'bun:test'
import type { HarnessAdapterDescriptor } from '@/entrypoints/app/agents/agent-harness-types'
import type {
HarnessAdapterDescriptor,
HarnessAgent,
} from '@/entrypoints/app/agents/agent-harness-types'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import {
buildSidepanelChatTargets,
@@ -77,58 +80,96 @@ const adapters: HarnessAdapterDescriptor[] = [
},
]
const agents: HarnessAgent[] = [
{
id: 'agent-codex',
name: 'Review Bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-codex:main',
createdAt: timestamp,
updatedAt: timestamp,
},
{
id: 'agent-openclaw',
name: 'Research Claw',
adapter: 'openclaw',
modelId: 'default',
reasoningEffort: 'high',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-openclaw:main',
createdAt: timestamp,
updatedAt: timestamp,
},
]
describe('buildSidepanelChatTargets', () => {
it('returns LLM targets plus one ACP target per adapter model', () => {
const targets = buildSidepanelChatTargets({ providers, adapters })
it('returns LLM targets plus one ACP target per persisted harness agent', () => {
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
expect(targets.map((target) => target.id)).toEqual([
'browseros',
'anthropic-sonnet',
'acp:claude:sonnet:medium',
'acp:claude:haiku:medium',
'acp:codex:gpt-5.5:medium',
'acp:openclaw:default:medium',
'agent-codex',
'agent-openclaw',
])
})
it('emits a single default ACP target for adapters with no per-session model picker', () => {
const targets = buildSidepanelChatTargets({ providers, adapters })
const openclaw = targets.find(
(target) => target.id === 'acp:openclaw:default:medium',
)
it('does not emit catalog-only ACP targets without persisted agents', () => {
const targets = buildSidepanelChatTargets({
providers,
adapters,
agents: [],
})
expect(targets.map((target) => target.id)).toEqual([
'browseros',
'anthropic-sonnet',
])
})
it('uses the created OpenClaw agent name instead of a generic adapter target', () => {
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
const openclaw = targets.find((target) => target.id === 'agent-openclaw')
expect(openclaw).toMatchObject({
kind: 'acp',
id: 'agent-openclaw',
agentId: 'agent-openclaw',
adapter: 'openclaw',
adapterName: 'OpenClaw',
modelId: 'default',
modelLabel: 'default',
// Without a model picker, the target name is just the adapter
// name — the user picks the adapter, not a model under it.
name: 'OpenClaw',
name: 'Research Claw',
modelControl: 'best-effort',
reasoningEffort: 'medium',
reasoningEffort: 'high',
})
})
it('preserves ACP model-control and recommendation metadata', () => {
const targets = buildSidepanelChatTargets({ providers, adapters })
const haiku = targets.find(
(target) => target.id === 'acp:claude:haiku:medium',
)
it('preserves adapter metadata for created agent targets', () => {
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
const codex = targets.find((target) => target.id === 'agent-codex')
expect(haiku).toMatchObject({
expect(codex).toMatchObject({
kind: 'acp',
adapter: 'claude',
modelId: 'haiku',
modelControl: 'best-effort',
agentId: 'agent-codex',
adapter: 'codex',
adapterName: 'Codex',
modelId: 'gpt-5.5',
modelLabel: 'GPT-5.5',
modelControl: 'runtime-supported',
recommended: true,
reasoningEffort: 'medium',
reasoningEffortLabel: 'Medium',
})
})
it('still returns LLM targets when ACP adapters are unavailable', () => {
expect(buildSidepanelChatTargets({ providers, adapters: [] })).toEqual([
it('still returns LLM targets when agents and adapters are unavailable', () => {
expect(
buildSidepanelChatTargets({ providers, adapters: [], agents: [] }),
).toEqual([
{
kind: 'llm',
id: 'browseros',
@@ -149,7 +190,7 @@ describe('buildSidepanelChatTargets', () => {
describe('resolveSidepanelChatTarget', () => {
it('resolves selected LLM targets back to their provider config', () => {
const targets = buildSidepanelChatTargets({ providers, adapters })
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
const resolved = resolveSidepanelChatTarget({
targets,
defaultProviderId: 'browseros',
@@ -161,13 +202,32 @@ describe('resolveSidepanelChatTarget', () => {
})
it('falls back to the current default LLM provider when a persisted ACP target is stale', () => {
const targets = buildSidepanelChatTargets({ providers, adapters: [] })
const targets = buildSidepanelChatTargets({
providers,
adapters,
agents: [],
})
expect(
resolveSidepanelChatTarget({
targets,
defaultProviderId: 'anthropic-sonnet',
selection: { kind: 'acp', id: 'acp:claude:haiku:medium' },
selection: { kind: 'acp', id: 'agent-codex' },
}),
).toMatchObject({
kind: 'llm',
id: 'anthropic-sonnet',
})
})
it('falls back when an old catalog-style ACP target id is persisted', () => {
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
expect(
resolveSidepanelChatTarget({
targets,
defaultProviderId: 'anthropic-sonnet',
selection: { kind: 'acp', id: 'acp:codex:gpt-5.5:medium' },
}),
).toMatchObject({
kind: 'llm',
@@ -180,10 +240,8 @@ describe('persistSidepanelChatTargetSelection', () => {
it('stores only target identity and does not mutate LLM provider arrays', async () => {
let savedSelection: SidepanelChatTargetSelection | null = null
const originalProviders = providers.map((provider) => ({ ...provider }))
const targets = buildSidepanelChatTargets({ providers, adapters })
const target = targets.find(
(candidate) => candidate.id === 'acp:codex:gpt-5.5:medium',
)
const targets = buildSidepanelChatTargets({ providers, adapters, agents })
const target = targets.find((candidate) => candidate.id === 'agent-codex')
await persistSidepanelChatTargetSelection(target, {
setValue: async (value) => {
@@ -193,7 +251,7 @@ describe('persistSidepanelChatTargetSelection', () => {
expect(savedSelection as SidepanelChatTargetSelection | null).toEqual({
kind: 'acp',
id: 'acp:codex:gpt-5.5:medium',
id: 'agent-codex',
})
expect(providers).toEqual(originalProviders)
})

View File

@@ -1,5 +1,6 @@
import type {
HarnessAdapterDescriptor,
HarnessAgent,
HarnessAgentAdapter,
} from '@/entrypoints/app/agents/agent-harness-types'
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
@@ -19,6 +20,7 @@ export type SidepanelChatTarget =
id: string
name: string
type: 'acp'
agentId: string
adapter: HarnessAgentAdapter
adapterName: string
modelId: string
@@ -37,6 +39,7 @@ export type SidepanelChatTargetSelection = Pick<
interface BuildSidepanelChatTargetsInput {
providers: LlmProviderConfig[]
adapters: HarnessAdapterDescriptor[]
agents?: HarnessAgent[]
}
interface ResolveSidepanelChatTargetInput {
@@ -63,61 +66,49 @@ let sidepanelChatTargetSelectionStorage:
export function buildSidepanelChatTargets({
providers,
adapters,
agents = [],
}: BuildSidepanelChatTargetsInput): SidepanelChatTarget[] {
return [
...providers.map(toLlmTarget),
...adapters.flatMap(toAcpTargetsForAdapter),
...agents.map((agent) => toAcpTargetForAgent(agent, adapters)),
]
}
function toAcpTargetsForAdapter(
adapter: HarnessAdapterDescriptor,
): SidepanelChatTarget[] {
const reasoning = adapter.reasoningEfforts.find(
(effort) => effort.id === adapter.defaultReasoningEffort,
)
function toAcpTargetForAgent(
agent: HarnessAgent,
adapters: HarnessAdapterDescriptor[],
): SidepanelChatTarget {
const adapter = adapters.find((entry) => entry.id === agent.adapter)
const modelId = agent.modelId ?? adapter?.defaultModelId ?? 'default'
const reasoningEffort =
reasoning?.id ?? adapter.defaultReasoningEffort ?? 'medium'
agent.reasoningEffort ?? adapter?.defaultReasoningEffort ?? 'medium'
const model = adapter?.models.find((entry) => entry.id === modelId)
const reasoning = adapter?.reasoningEfforts.find(
(effort) => effort.id === reasoningEffort,
)
// Adapters with no per-session model picker (e.g. OpenClaw, whose
// model lives on the gateway-side agent record) still need exactly
// one sidepanel target so the user can pick the adapter at all.
if (adapter.models.length === 0) {
return [
{
kind: 'acp',
id: buildAcpTargetId(
adapter.id,
adapter.defaultModelId,
reasoningEffort,
),
name: adapter.name,
type: 'acp',
adapter: adapter.id,
adapterName: adapter.name,
modelId: adapter.defaultModelId,
modelLabel: 'default',
modelControl: adapter.modelControl,
reasoningEffort,
reasoningEffortLabel: reasoning?.label,
},
]
}
return adapter.models.map((model) => ({
kind: 'acp' as const,
id: buildAcpTargetId(adapter.id, model.id, reasoningEffort),
name: `${adapter.name} ${model.label}`,
type: 'acp' as const,
adapter: adapter.id,
adapterName: adapter.name,
modelId: model.id,
modelLabel: model.label,
modelControl: adapter.modelControl,
recommended: model.recommended,
return {
kind: 'acp',
id: agent.id,
name: agent.name,
type: 'acp',
agentId: agent.id,
adapter: agent.adapter,
adapterName: adapter?.name ?? formatAdapterName(agent.adapter),
modelId,
modelLabel: model?.label ?? modelId,
modelControl: adapter?.modelControl ?? 'best-effort',
recommended: model?.recommended,
reasoningEffort,
reasoningEffortLabel: reasoning?.label,
}))
}
}
function formatAdapterName(adapter: HarnessAgentAdapter): string {
if (adapter === 'claude') return 'Claude Code'
if (adapter === 'codex') return 'Codex'
if (adapter === 'openclaw') return 'OpenClaw'
return adapter
}
export function resolveSidepanelChatTarget({
@@ -172,14 +163,6 @@ function toLlmTarget(provider: LlmProviderConfig): SidepanelChatTarget {
}
}
export function buildAcpTargetId(
adapter: HarnessAgentAdapter,
modelId: string,
reasoningEffort: string,
): string {
return `acp:${adapter}:${modelId}:${reasoningEffort}`
}
async function getSidepanelChatTargetSelectionStorage(): Promise<SidepanelChatTargetSelectionStore> {
if (sidepanelChatTargetSelectionStorage) {
return sidepanelChatTargetSelectionStorage

View File

@@ -1,6 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import useDeepCompareEffect from 'use-deep-compare-effect'
import { useAgentAdapters } from '@/entrypoints/app/agents/useAgents'
import {
useAgentAdapters,
useHarnessAgents,
} from '@/entrypoints/app/agents/useAgents'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { type McpServer, useMcpServers } from '@/lib/mcp/mcpServerStorage'
@@ -38,6 +41,7 @@ export const useChatRefs = () => {
isLoading: isLoadingProviders,
} = useLlmProviders()
const { adapters, loading: isLoadingAdapters } = useAgentAdapters()
const { harnessAgents, loading: isLoadingAgents } = useHarnessAgents()
const { personalization } = usePersonalization()
const [targetSelection, setTargetSelection] =
useState<SidepanelChatTargetSelection | null>(null)
@@ -57,8 +61,9 @@ export const useChatRefs = () => {
buildSidepanelChatTargets({
providers: llmProviders,
adapters,
agents: harnessAgents,
}),
[llmProviders, adapters],
[llmProviders, adapters, harnessAgents],
)
const selectedChatTarget = useMemo(
@@ -116,6 +121,7 @@ export const useChatRefs = () => {
selectedChatTarget,
selectChatTarget,
selectedLlmProvider,
isLoadingProviders: isLoadingProviders || isLoadingAdapters,
isLoadingProviders:
isLoadingProviders || isLoadingAdapters || isLoadingAgents,
}
}

View File

@@ -40,7 +40,7 @@ describe('buildSidepanelPreparedSendMessagesRequest', () => {
})
})
it('sends ACP targets to the sidepanel ACP route with explicit target fields', () => {
it('sends created-agent targets to the agent-id sidepanel route', () => {
const request = buildSidepanelPreparedSendMessagesRequest({
agentServerUrl: 'http://127.0.0.1:5151',
target: acpTarget,
@@ -52,12 +52,11 @@ describe('buildSidepanelPreparedSendMessagesRequest', () => {
...commonRequestInput(),
})
expect(request.api).toBe('http://127.0.0.1:5151/agents/sidepanel/chat')
expect(request.api).toBe(
'http://127.0.0.1:5151/agents/agent-codex/sidepanel/chat',
)
expect(request.body).toEqual({
conversationId,
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
message: 'Inspect the current tab',
browserContext: {
activeTab: { id: 10, url: 'https://example.com', title: 'Example' },
@@ -140,9 +139,10 @@ const llmTarget: SidepanelChatTarget = {
const acpTarget: SidepanelChatTarget = {
kind: 'acp',
id: 'acp:codex:gpt-5.5:medium',
name: 'Codex GPT-5.5',
id: 'agent-codex',
name: 'Review bot',
type: 'acp',
agentId: 'agent-codex',
adapter: 'codex',
adapterName: 'Codex',
modelId: 'gpt-5.5',

View File

@@ -680,13 +680,20 @@ export const useChatSession = (options?: ChatSessionOptions) => {
const sendMessage = (params: { text: string; action?: ChatAction }) => {
const target = selectedChatTargetRef.current
const llmTargetProvider = toLlmProviderConfig(target)
const agentTarget = target?.kind === 'acp' ? target : undefined
track(MESSAGE_SENT_EVENT, {
mode,
provider_type: target?.kind === 'acp' ? 'acp' : llmTargetProvider?.type,
provider_id:
agentTarget?.agentId ??
llmTargetProvider?.id ??
selectedLlmProvider?.id,
provider_type: agentTarget ? 'acp' : llmTargetProvider?.type,
agent_id: agentTarget?.agentId,
adapter: agentTarget?.adapter,
model:
target?.kind === 'acp'
? target.modelId
: llmTargetProvider?.modelId || selectedLlmProvider?.modelId,
agentTarget?.modelId ??
llmTargetProvider?.modelId ??
selectedLlmProvider?.modelId,
})
if (!isIntegrationsSyncedRef.current) {
@@ -763,6 +770,8 @@ export const useChatSession = (options?: ChatSessionOptions) => {
provider_type: target.kind === 'acp' ? 'acp' : target.type,
model_id:
target.kind === 'acp' ? target.modelId : target.provider.modelId,
agent_id: target.kind === 'acp' ? target.agentId : undefined,
adapter: target.kind === 'acp' ? target.adapter : undefined,
})
void selectChatTarget(target).catch((error) => {

View File

@@ -34,15 +34,10 @@ export function buildSidepanelPreparedSendMessagesRequest({
...common
}: BuildSidepanelPreparedSendMessagesRequestInput) {
if (target?.kind === 'acp') {
// ACP session history is owned by AcpxRuntime through sessionKey, so LLM-only
// resume and approval fields are intentionally not forwarded.
return {
api: `${agentServerUrl}/agents/sidepanel/chat`,
api: `${agentServerUrl}/agents/${encodeURIComponent(target.agentId)}/sidepanel/chat`,
body: {
conversationId: common.conversationId,
adapter: target.adapter,
modelId: target.modelId,
reasoningEffort: target.reasoningEffort,
message: message ?? '',
browserContext: common.browserContext,
userSystemPrompt: common.userSystemPrompt,
@@ -71,6 +66,9 @@ export function toProviderOption(target: SidepanelChatTarget): Provider {
name: target.name,
type: target.type,
kind: target.kind,
agentId: target.kind === 'acp' ? target.agentId : undefined,
adapterName: target.kind === 'acp' ? target.adapterName : undefined,
modelLabel: target.kind === 'acp' ? target.modelLabel : undefined,
modelControl: target.kind === 'acp' ? target.modelControl : undefined,
}
}

View File

@@ -14,32 +14,22 @@ import { stream } from 'hono/streaming'
import { formatUserMessage } from '../../agent/format-message'
import type { Browser } from '../../browser/browser'
import { createAcpUIMessageStreamResponse } from '../../lib/agents/acp-ui-message-stream'
import {
AcpxRuntime,
type OpenclawGatewayAccessor,
} from '../../lib/agents/acpx-runtime'
import type { OpenclawGatewayAccessor } from '../../lib/agents/acpx-runtime'
import type {
ActiveTurnInfo,
TurnFrame,
} from '../../lib/agents/active-turn-registry'
import {
AGENT_ADAPTER_CATALOG,
getAgentAdapterDescriptor,
isAgentAdapter,
isSupportedAgentModel,
isSupportedReasoningEffort,
resolveDefaultModelId,
resolveDefaultReasoningEffort,
} from '../../lib/agents/agent-catalog'
import type {
AgentAdapter,
AgentDefinition,
} from '../../lib/agents/agent-types'
import type {
AgentHistoryPage,
AgentRuntime,
AgentStreamEvent,
} from '../../lib/agents/types'
import type { AgentHistoryPage, AgentStreamEvent } from '../../lib/agents/types'
import {
type AgentDefinitionWithActivity,
AgentHarnessService,
@@ -75,6 +65,7 @@ type AgentRouteService = {
agentId: string
message: string
attachments?: ReadonlyArray<{ mediaType: string; data: string }>
cwd?: string
}): Promise<{ turnId: string; frames: ReadableStream<TurnFrame> }>
attachTurn(input: {
turnId: string
@@ -86,22 +77,10 @@ type AgentRouteService = {
turnId?: string
reason?: string
}): boolean
/**
* Legacy wrapper used by the sidepanel ACP route and any external
* callers that want a flat AgentStreamEvent stream. Internally goes
* through the registry.
*/
send(input: {
agentId: string
message: string
attachments?: ReadonlyArray<{ mediaType: string; data: string }>
signal?: AbortSignal
}): Promise<ReadableStream<AgentStreamEvent>>
}
type AgentRouteDeps = {
service?: AgentRouteService
runtime?: AgentRuntime
browser?: Pick<Browser, 'resolveTabIds'>
browserosServerPort?: number
/**
@@ -124,11 +103,8 @@ type AgentRouteDeps = {
openclawProvisioner?: OpenClawProvisioner
}
type SidepanelAcpChatRequest = {
type SidepanelAgentChatRequest = {
conversationId: string
adapter: AgentAdapter
modelId: string
reasoningEffort: string
message: string
browserContext?: BrowserContext
selectedText?: string
@@ -146,7 +122,6 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
openclawGatewayChat: deps.openclawGatewayChat,
openclawProvisioner: deps.openclawProvisioner,
})
let sidepanelRuntime = deps.runtime
return new Hono<Env>()
.get('/adapters', (c) => c.json({ adapters: AGENT_ADAPTER_CATALOG }))
@@ -170,44 +145,82 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
return handleAgentRouteError(c, err)
}
})
.post('/sidepanel/chat', async (c) => {
const parsed = await parseSidepanelAcpChatBody(c)
.post('/:agentId/sidepanel/chat', async (c) => {
const agentId = c.req.param('agentId')
const parsed = await parseSidepanelAgentChatBody(c)
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
let browserContext = parsed.browserContext
if (deps.browser) {
browserContext = await resolveBrowserContextPageIds(
deps.browser,
browserContext,
)
}
const userContent = formatUserMessage(
parsed.message,
browserContext,
parsed.selectedText,
parsed.selectedTextSource,
)
const message = parsed.userSystemPrompt?.trim()
? `${parsed.userSystemPrompt.trim()}\n\n${userContent}`
: userContent
const agent = buildSidepanelAcpAgent(parsed)
try {
sidepanelRuntime ??= new AcpxRuntime({
browserosServerPort: deps.browserosServerPort,
openclawGateway: deps.openclawGateway,
const agent = await service.getAgent(agentId)
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
let browserContext = parsed.browserContext
if (deps.browser) {
browserContext = await resolveBrowserContextPageIds(
deps.browser,
browserContext,
)
}
const userContent = formatUserMessage(
parsed.message,
browserContext,
parsed.selectedText,
parsed.selectedTextSource,
)
const message = parsed.userSystemPrompt?.trim()
? `${parsed.userSystemPrompt.trim()}\n\n${userContent}`
: userContent
let started: { turnId: string; frames: ReadableStream<TurnFrame> }
try {
started = await service.startTurn({
agentId: agent.id,
message,
cwd: parsed.userWorkingDir,
})
} catch (err) {
if (err instanceof TurnAlreadyActiveError) {
return c.json(
{
error: 'Turn already active',
turnId: err.turnId,
attachUrl: `/agents/${agent.id}/chat/stream?turnId=${err.turnId}`,
},
409,
)
}
throw err
}
let didRequestCancel = false
const cancelStartedTurn = () => {
if (didRequestCancel) return
didRequestCancel = true
service.cancelTurn({
agentId: agent.id,
turnId: started.turnId,
reason: 'sidepanel stream cancelled',
})
}
if (c.req.raw.signal.aborted) {
cancelStartedTurn()
} else {
c.req.raw.signal.addEventListener('abort', cancelStartedTurn, {
once: true,
})
}
const events = turnFramesToAgentEvents(started.frames, {
onCancel: cancelStartedTurn,
})
const eventStream = await sidepanelRuntime.send({
agent,
sessionId: 'main',
sessionKey: agent.sessionKey,
message,
permissionMode: agent.permissionMode,
cwd: parsed.userWorkingDir,
signal: c.req.raw.signal,
return createAcpUIMessageStreamResponse(events, {
headers: {
'X-Session-Id': 'main',
'X-Turn-Id': started.turnId,
},
})
return createAcpUIMessageStreamResponse(eventStream)
} catch (err) {
return handleAgentRouteError(c, err)
}
@@ -309,6 +322,51 @@ export function createAgentRoutes(deps: AgentRouteDeps = {}) {
})
}
function turnFramesToAgentEvents(
frames: ReadableStream<TurnFrame>,
options: { onCancel(): void | Promise<void> },
): ReadableStream<AgentStreamEvent> {
let reader: ReadableStreamDefaultReader<TurnFrame> | undefined
return new ReadableStream<AgentStreamEvent>({
start() {
reader = frames.getReader()
},
async pull(controller) {
const activeReader = reader
if (!activeReader) {
controller.close()
return
}
let result: Awaited<ReturnType<typeof activeReader.read>>
try {
result = await activeReader.read()
} catch (err) {
try {
activeReader.releaseLock()
} catch {}
if (reader === activeReader) reader = undefined
throw err
}
if (result?.done === false) {
controller.enqueue(result.value.event)
} else {
controller.close()
activeReader.releaseLock()
if (reader === activeReader) reader = undefined
}
},
async cancel(reason) {
try {
await options.onCancel()
} finally {
await reader?.cancel(reason).catch(() => {})
reader = undefined
}
},
})
}
/**
* Pipe a TurnFrame stream as Server-Sent Events. Each frame becomes:
*
@@ -515,9 +573,9 @@ async function parseChatBody(
return { message, attachments }
}
async function parseSidepanelAcpChatBody(
async function parseSidepanelAgentChatBody(
c: Context<Env>,
): Promise<SidepanelAcpChatRequest | { error: string }> {
): Promise<SidepanelAgentChatRequest | { error: string }> {
const body = await readJsonBody(c)
if ('error' in body) return body
const record = body.value
@@ -526,23 +584,6 @@ async function parseSidepanelAcpChatBody(
if (!conversationId || !isUuid(conversationId)) {
return { error: 'conversationId must be a UUID' }
}
if (!isAgentAdapter(record.adapter)) {
return { error: 'Invalid adapter' }
}
const modelId =
readOptionalTrimmedString(record, 'modelId') ??
resolveDefaultModelId(record.adapter)
const reasoningEffort =
readOptionalTrimmedString(record, 'reasoningEffort') ??
resolveDefaultReasoningEffort(record.adapter)
if (!isSupportedAgentModel(record.adapter, modelId)) {
return { error: 'Invalid modelId' }
}
if (!isSupportedReasoningEffort(record.adapter, reasoningEffort)) {
return { error: 'Invalid reasoningEffort' }
}
const message = readOptionalTrimmedString(record, 'message')
if (!message) return { error: 'Message is required' }
@@ -556,9 +597,6 @@ async function parseSidepanelAcpChatBody(
return {
conversationId,
adapter: record.adapter,
modelId,
reasoningEffort,
message,
browserContext: browserContext.value,
selectedText,
@@ -568,32 +606,6 @@ async function parseSidepanelAcpChatBody(
}
}
function buildSidepanelAcpAgent(
request: SidepanelAcpChatRequest,
): AgentDefinition {
const now = Date.now()
const descriptor = getAgentAdapterDescriptor(request.adapter)
const sessionKey = [
'sidepanel',
request.conversationId,
request.adapter,
request.modelId,
request.reasoningEffort,
].join(':')
return {
id: `sidepanel:${request.conversationId}`,
name: descriptor?.name ?? request.adapter,
adapter: request.adapter,
modelId: request.modelId,
reasoningEffort: request.reasoningEffort,
permissionMode: 'approve-all',
sessionKey,
createdAt: now,
updatedAt: now,
}
}
function parseBrowserContext(
value: unknown,
): { value?: BrowserContext } | { error: string } {

View File

@@ -409,6 +409,7 @@ export class AgentHarnessService {
agentId: string
message: string
attachments?: ReadonlyArray<{ mediaType: string; data: string }>
cwd?: string
}): Promise<{ turnId: string; frames: ReadableStream<TurnFrame> }> {
const agent = await this.requireAgent(input.agentId)
@@ -490,6 +491,7 @@ export class AgentHarnessService {
agentId: string
message: string
attachments?: ReadonlyArray<{ mediaType: string; data: string }>
cwd?: string
signal?: AbortSignal
}): Promise<ReadableStream<AgentStreamEvent>> {
const { frames } = await this.startTurn(input)
@@ -513,6 +515,7 @@ export class AgentHarnessService {
input: {
message: string
attachments?: ReadonlyArray<{ mediaType: string; data: string }>
cwd?: string
},
): Promise<void> {
const turn = this.turnRegistry.get(turnId)
@@ -526,6 +529,7 @@ export class AgentHarnessService {
message: input.message,
attachments: input.attachments,
permissionMode: agent.permissionMode,
cwd: input.cwd,
signal: turn.abortController.signal,
})
const reader = upstream.getReader()

View File

@@ -756,15 +756,11 @@ function resolveOpenclawAcpCommand(
// to a synthetic acp:<uuid> session that does not resolve to any
// provisioned gateway agent.
//
// Two callers send sessionKeys here:
// * Harness: `agent:<harness-id>:main` — the harness id matches a
// dual-created gateway agent name (Step 5), so the bridge resolves
// directly. Pass through unchanged.
// * Sidepanel: `sidepanel:<convId>:openclaw:<model>:<effort>` — no
// dedicated gateway agent exists for sidepanel sessions, so we
// route them to the always-provisioned `main` gateway agent and
// keep state segregated by encoding the original key as a channel
// suffix.
// Harness keys are `agent:<harness-id>:main`; the harness id matches
// a dual-created gateway agent name, so the bridge resolves directly.
// Any legacy non-agent key falls back to the always-provisioned
// `main` gateway agent with the original key encoded as a channel
// suffix.
const bridgeSessionKey = sessionKey
? sessionKey.startsWith('agent:')
? sessionKey

View File

@@ -12,11 +12,7 @@ import {
TurnRegistry,
} from '../../../src/lib/agents/active-turn-registry'
import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
import type {
AgentPromptInput,
AgentRuntime,
AgentStreamEvent,
} from '../../../src/lib/agents/types'
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
describe('createAgentRoutes', () => {
it('creates and lists harness agents', async () => {
@@ -234,82 +230,122 @@ describe('createAgentRoutes', () => {
expect(response.status).toBe(404)
})
it('streams sidepanel ACP chat as an AI SDK UI message stream', async () => {
const conversationId = '00000000-0000-4000-8000-000000000001'
let sentInput: AgentPromptInput | undefined
const abortController = new AbortController()
const route = createMountedRoutes([], {
browser: {
async resolveTabIds(tabIds: number[]) {
return new Map(tabIds.map((tabId) => [tabId, tabId + 100]))
it('streams created-agent sidepanel chat through the persisted agent', async () => {
const agent: AgentDefinition = {
id: 'agent-1',
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
const service = createFakeService([agent])
const route = new Hono().route(
'/agents',
createAgentRoutes({
service,
browser: {
async resolveTabIds(tabIds: number[]) {
return new Map(tabIds.map((tabId) => [tabId, tabId + 100]))
},
},
},
runtime: createFakeRuntime(async (input) => {
sentInput = input
return createAgentStream([
{ type: 'text_delta', text: 'Hello', stream: 'output' },
{ type: 'done', stopReason: 'end_turn' },
])
}),
})
)
const response = await route.request('/agents/sidepanel/chat', {
const response = await route.request('/agents/agent-1/sidepanel/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({
conversationId,
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
message: 'hi',
...validCreatedAgentSidepanelBody(),
adapter: 'openclaw',
modelId: 'ignored-client-model',
reasoningEffort: 'ignored-client-effort',
userSystemPrompt: 'Always be concise.',
userWorkingDir: '/tmp/work',
browserContext: {
activeTab: { id: 1, url: 'https://example.com', title: 'Example' },
},
selectedText: 'selected text',
selectedTextSource: {
url: 'https://example.com',
title: 'Example',
},
}),
})
expect(response.status).toBe(200)
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
expect(response.headers.get('x-vercel-ai-ui-message-stream')).toBe('v1')
expect(await response.text()).toContain('"type":"text-delta"')
expect(sentInput?.agent).toMatchObject({
id: `sidepanel:${conversationId}`,
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: `sidepanel:${conversationId}:codex:gpt-5.5:medium`,
expect(service._lastStartTurnInput).toMatchObject({
agentId: 'agent-1',
cwd: '/tmp/work',
})
expect(sentInput?.cwd).toBe('/tmp/work')
expect(sentInput?.message).toContain(
expect(service._lastStartTurnInput?.message).toContain('Always be concise.')
expect(service._lastStartTurnInput?.message).toContain(
'Tab 1 (Page ID: 101) - "Example" (https://example.com)',
)
expect(sentInput?.message).toContain('<USER_QUERY>\nhi\n</USER_QUERY>')
expect(sentInput?.signal).toBe(abortController.signal)
expect(service._lastStartTurnInput?.message).toContain(
'<selected_text (from "Example"',
)
expect(service._lastStartTurnInput?.message).toContain(
'selected text\n</selected_text>',
)
expect(service._lastStartTurnInput?.message).toContain(
'<USER_QUERY>\nhi\n</USER_QUERY>',
)
const list = await route.request('/agents')
expect(await list.json()).toEqual({ agents: [], gateway: null })
expect(await list.json()).toMatchObject({
agents: [{ id: 'agent-1', adapter: 'codex', modelId: 'gpt-5.5' }],
})
})
it('rejects invalid sidepanel ACP chat requests', async () => {
const route = createMountedRoutes([])
it('rejects invalid created-agent sidepanel chat requests', async () => {
const route = createMountedRoutes([
{
id: 'agent-1',
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
},
])
const unknown = await route.request('/agents/missing/sidepanel/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validCreatedAgentSidepanelBody()),
})
expect(unknown.status).toBe(404)
for (const { patch, error } of [
{
patch: { conversationId: 'not-a-uuid' },
error: 'conversationId must be a UUID',
},
{ patch: { adapter: 'openai' }, error: 'Invalid adapter' },
{ patch: { modelId: 'unknown-model' }, error: 'Invalid modelId' },
{ patch: { reasoningEffort: 'turbo' }, error: 'Invalid reasoningEffort' },
{ patch: { message: ' ' }, error: 'Message is required' },
{
patch: { browserContext: { activeTab: { id: 'bad' } } },
error: 'Invalid browserContext',
},
{
patch: { selectedTextSource: { url: 123, title: 'Example' } },
error: 'Invalid selectedTextSource',
},
]) {
const response = await route.request('/agents/sidepanel/chat', {
const response = await route.request('/agents/agent-1/sidepanel/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...validSidepanelAcpBody(),
...validCreatedAgentSidepanelBody(),
...patch,
}),
})
@@ -319,6 +355,103 @@ describe('createAgentRoutes', () => {
}
})
it('cancels the created-agent sidepanel turn when the request is aborted', async () => {
const agent: AgentDefinition = {
id: 'agent-1',
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
const blocking = createBlockingFakeService([agent])
const route = new Hono().route(
'/agents',
createAgentRoutes({ service: blocking }),
)
const abortController = new AbortController()
const response = await route.request('/agents/agent-1/sidepanel/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify(validCreatedAgentSidepanelBody()),
})
expect(response.status).toBe(200)
abortController.abort('sidepanel closed')
await new Promise((r) => setTimeout(r, 0))
expect(blocking._cancelCalls).toEqual([
{
agentId: 'agent-1',
reason: 'sidepanel stream cancelled',
},
])
blocking._unblock()
})
it('returns 409 when a created-agent sidepanel turn is already active', async () => {
const agent: AgentDefinition = {
id: 'agent-1',
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: 'agent:agent-1:main',
createdAt: 1000,
updatedAt: 1000,
}
const blocking = createBlockingFakeService([agent])
const route = new Hono().route(
'/agents',
createAgentRoutes({ service: blocking }),
)
const first = route.request('/agents/agent-1/sidepanel/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validCreatedAgentSidepanelBody()),
})
await new Promise((r) => setTimeout(r, 5))
const second = await route.request('/agents/agent-1/sidepanel/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validCreatedAgentSidepanelBody()),
})
expect(second.status).toBe(409)
const body = await second.json()
expect(body).toMatchObject({ error: 'Turn already active' })
expect(typeof body.turnId).toBe('string')
expect(body.attachUrl).toContain(`turnId=${body.turnId}`)
blocking._unblock()
await (await first).text()
})
it('does not expose the legacy virtual sidepanel ACP chat route', async () => {
const route = createMountedRoutes([])
const response = await route.request('/agents/sidepanel/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
conversationId: '00000000-0000-4000-8000-000000000001',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
message: 'hi',
}),
})
expect(response.status).toBe(404)
})
it('rejects overlong agent names', async () => {
const route = createMountedRoutes([])
const response = await route.request('/agents', {
@@ -340,7 +473,6 @@ describe('createAgentRoutes', () => {
function createMountedRoutes(
agents: AgentDefinition[],
deps: {
runtime?: AgentRuntime
browser?: { resolveTabIds(tabIds: number[]): Promise<Map<number, number>> }
} = {},
) {
@@ -364,8 +496,14 @@ function createFakeService(agents: AgentDefinition[]) {
{ type: 'text_delta', text: 'Hello', stream: 'output' },
{ type: 'done', stopReason: 'end_turn' },
]
let lastStartTurnInput:
| { agentId: string; message?: string; cwd?: string }
| undefined
return {
get _lastStartTurnInput() {
return lastStartTurnInput
},
async listAgents() {
return agents
},
@@ -419,7 +557,18 @@ function createFakeService(agents: AgentDefinition[]) {
items: [],
}
},
async startTurn(input: { agentId: string }) {
async startTurn(input: {
agentId: string
message?: string
cwd?: string
}) {
if (!agents.some((agent) => agent.id === input.agentId)) {
const { UnknownAgentError } = await import(
'../../../src/api/services/agents/agent-harness-service'
)
throw new UnknownAgentError(input.agentId)
}
lastStartTurnInput = input
const turn = registry.register(input.agentId, 'main')
const frames = registry.subscribe(turn.turnId, { fromSeq: -1 })!
// Push the canned events asynchronously so subscribers actually
@@ -442,54 +591,18 @@ function createFakeService(agents: AgentDefinition[]) {
if (!turnId) return false
return registry.cancel(turnId, input.reason)
},
async send() {
// Legacy shape, used by the sidepanel route only. Returns a flat
// AgentStreamEvent stream.
return createAgentStream(fakeEvents)
},
/** Test-only: lets tests await turn completion deterministically. */
_registry: registry,
}
}
function validSidepanelAcpBody() {
function validCreatedAgentSidepanelBody() {
return {
conversationId: '00000000-0000-4000-8000-000000000001',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
message: 'hi',
}
}
function createFakeRuntime(
send: (input: AgentPromptInput) => Promise<ReadableStream<AgentStreamEvent>>,
): AgentRuntime {
return {
async status() {
return { state: 'ready' }
},
async listSessions(agent) {
return [{ agentId: agent.id, id: 'main', updatedAt: agent.updatedAt }]
},
async getHistory(input) {
return { agentId: input.agent.id, sessionId: 'main', items: [] }
},
send,
}
}
function createAgentStream(
events: AgentStreamEvent[],
): ReadableStream<AgentStreamEvent> {
return new ReadableStream<AgentStreamEvent>({
start(controller) {
for (const event of events) controller.enqueue(event)
controller.close()
},
})
}
/**
* Variant of `createFakeService` whose turn doesn't push frames until
* `_unblock()` is called. Used by tests that need to observe the
@@ -505,6 +618,7 @@ function createBlockingFakeService(agents: AgentDefinition[]) {
{ type: 'done', stopReason: 'end_turn' },
]
let unblock: () => void = () => {}
const cancelCalls: Array<{ agentId: string; reason?: string }> = []
const gate = new Promise<void>((resolve) => {
unblock = resolve
})
@@ -559,14 +673,13 @@ function createBlockingFakeService(agents: AgentDefinition[]) {
return t ? registry.describe(t.turnId) : null
},
cancelTurn(input: { agentId: string; turnId?: string; reason?: string }) {
cancelCalls.push({ agentId: input.agentId, reason: input.reason })
const turnId =
input.turnId ?? registry.getActiveFor(input.agentId, 'main')?.turnId
if (!turnId) return false
return registry.cancel(turnId, input.reason)
},
async send() {
return createAgentStream(events)
},
_unblock: () => unblock(),
_cancelCalls: cancelCalls,
}
}

View File

@@ -58,6 +58,7 @@ describe('AgentHarnessService', () => {
await service.send({
agentId: agent.id,
message: 'hello',
cwd: '/tmp/work',
}),
)
@@ -67,6 +68,7 @@ describe('AgentHarnessService', () => {
sessionKey: 'agent:agent-1:main',
message: 'hello',
permissionMode: 'approve-all',
cwd: '/tmp/work',
})
expect(events).toEqual([
{ type: 'text_delta', text: 'answer', stream: 'output' },