mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
6 Commits
fix/dev-se
...
feat/sidep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
793ee5e508 | ||
|
|
fe43f4849d | ||
|
|
37b4e18eda | ||
|
|
1118d61429 | ||
|
|
f16309bb0b | ||
|
|
baff61cf38 |
@@ -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(' · ')
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user