From 9e4f7f7fd5177fd3fd141b0d0d478c9bf8f6da4a Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 14 Jul 2026 03:03:36 +0700 Subject: [PATCH] fix(evaos): show truthful OpenClaw MCP transport --- .../renderer/pages/guid/hooks/useGuidSend.ts | 24 +++- .../settings/AgentSettings/AgentCard.tsx | 14 +++ .../src/renderer/services/i18n/i18n-keys.d.ts | 4 + .../services/i18n/locales/en-US/settings.json | 6 +- .../services/i18n/locales/es-ES/settings.json | 6 +- .../services/i18n/locales/fa-IR/settings.json | 6 +- .../services/i18n/locales/fr-FR/settings.json | 6 +- .../services/i18n/locales/ja-JP/settings.json | 6 +- .../services/i18n/locales/ko-KR/settings.json | 6 +- .../services/i18n/locales/pt-BR/settings.json | 6 +- .../services/i18n/locales/ru-RU/settings.json | 6 +- .../services/i18n/locales/tr-TR/settings.json | 6 +- .../services/i18n/locales/uk-UA/settings.json | 6 +- .../services/i18n/locales/zh-CN/settings.json | 6 +- .../services/i18n/locales/zh-TW/settings.json | 6 +- .../src/renderer/utils/model/agentTypes.ts | 36 ++++++ .../unit/agent/agentCatalogProjection.test.ts | 33 +++++- tests/unit/renderer/AcpSendBox.dom.test.tsx | 68 +++++++++-- tests/unit/renderer/LocalAgents.dom.test.tsx | 54 +++++++++ tests/unit/renderer/useGuidSend.dom.test.ts | 111 +++++++++++++++++- 20 files changed, 383 insertions(+), 33 deletions(-) diff --git a/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts b/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts index 37f6f896c7..81d278e501 100644 --- a/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts +++ b/packages/desktop/src/renderer/pages/guid/hooks/useGuidSend.ts @@ -25,6 +25,7 @@ import { getConversationCreateErrorMessage } from '@/renderer/pages/conversation import type { AcpModelInfo, AvailableAgent, EffectiveAgentInfo } from '../types'; const EVAOS_MAC_CONTROL_AGENT_PATTERN = /(evaos|openclaw)/i; +const OPENCLAW_AGENT_TYPES = new Set(['openclaw', 'openclaw-gateway']); function isBuiltinEvaosMacControlServer(server: IMcpServer): boolean { return ( @@ -54,6 +55,15 @@ function isEvaosMacControlAgent(params: { return candidates.some((candidate) => EVAOS_MAC_CONTROL_AGENT_PATTERN.test(String(candidate || ''))); } +function isOpenClawAgent(params: { + selectedAgentInfo: AvailableAgent | undefined; + effectiveAgentType: string; +}): boolean { + const { selectedAgentInfo, effectiveAgentType } = params; + const candidates = [effectiveAgentType, selectedAgentInfo?.agent_type, selectedAgentInfo?.backend]; + return candidates.some((candidate) => OPENCLAW_AGENT_TYPES.has(String(candidate || '').toLowerCase())); +} + function appendEvaosMacControlMcpId( ids: string[] | undefined, availableMcpServers: IMcpServer[], @@ -212,12 +222,14 @@ export const useGuidSend = (deps: GuidSendDeps): GuidSendResult => { const excludeBuiltinSkills = guidDisabledBuiltinSkills ?? (is_presetAgent ? assistantDefaultDisabledBuiltinSkillIds : resolveDisabledBuiltinSkills(agentInfo)); - const shouldAttachEvaosMacControl = isEvaosMacControlAgent({ + const agentIdentity = { selectedAgent, selectedAgentKey, selectedAgentInfo: agentInfo, effectiveAgentType, - }); + }; + const isOpenClaw = isOpenClawAgent(agentIdentity); + const shouldAttachEvaosMacControl = !isOpenClaw && isEvaosMacControlAgent(agentIdentity); const selectedAllMcpServerIds = appendEvaosMacControlMcpId( selectedMcpServerIds ?? [], availableMcpServers, @@ -251,6 +263,11 @@ export const useGuidSend = (deps: GuidSendDeps): GuidSendResult => { : availableMcpServers .filter((server) => (defaultSelectedMcpServerIds ?? []).includes(server.id)) .map((server) => toSessionMcpServer(server)); + const acpSelectedSessionMcpServersToSend = isOpenClaw + ? [] + : selectedMcpServerIds !== undefined + ? selectedSessionMcpServers + : selectedSessionMcpServersToSend; const finalEffectiveAgentType = effectiveAgentType; const assistantOverrideModel = @@ -427,8 +444,7 @@ export const useGuidSend = (deps: GuidSendDeps): GuidSendResult => { default_files: files, ...nonPresetSkillExtra, selected_mcp_server_ids: selectedUserMcpServerIdsToSend, - selected_session_mcp_servers: - selectedMcpServerIds !== undefined ? selectedSessionMcpServers : selectedSessionMcpServersToSend, + selected_session_mcp_servers: acpSelectedSessionMcpServersToSend, pending_config_options: !is_preset && compatibleThoughtLevelValue && thoughtLevelOptionId ? { [thoughtLevelOptionId]: compatibleThoughtLevelValue } diff --git a/packages/desktop/src/renderer/pages/settings/AgentSettings/AgentCard.tsx b/packages/desktop/src/renderer/pages/settings/AgentSettings/AgentCard.tsx index 27e65560c3..dd7f0cd23f 100644 --- a/packages/desktop/src/renderer/pages/settings/AgentSettings/AgentCard.tsx +++ b/packages/desktop/src/renderer/pages/settings/AgentSettings/AgentCard.tsx @@ -12,6 +12,7 @@ import { resolveAgentLogo } from '@/renderer/utils/model/agentLogo'; import { resolveExtensionAssetUrl } from '@/renderer/utils/platform'; import { getEvaosAgentDisplayName, isEvaosCustomAgentPresentation } from '@/renderer/evaos/evaosAgentPresentation'; import type { EvaosNativeAgentAvailability } from '@/renderer/evaos/evaosNativeAgentAvailability'; +import { getCatalogMcpTransportPresentation } from '@/renderer/utils/model/agentTypes'; type DetectedAgent = { agent_type: string; @@ -74,6 +75,7 @@ const AgentCard: React.FC = (props) => { : t('settings.agentManagement.goToChat'); const isRepairRequired = nativeAvailability?.status === 'repair_required'; const extensionAvatar = resolveExtensionAssetUrl(agent.isExtension ? agent.avatar : undefined); + const mcpTransportPresentation = getCatalogMcpTransportPresentation(agent); const logo = useNeutralCustomVisual ? undefined : extensionAvatar || @@ -106,6 +108,18 @@ const AgentCard: React.FC = (props) => { {repairReason} ) : null} + {mcpTransportPresentation ? ( +
+ + {t(mcpTransportPresentation.labelKey)} + + {mcpTransportPresentation.detailKeys.map((key) => ( + + {t(key)} + + ))} +
+ ) : null} + default: ({ onSend, tools }: { onSend: (message: string) => Promise; tools?: React.ReactNode }) => ( + <> + {tools} + + ), })); @@ -70,7 +75,19 @@ vi.mock('@/renderer/components/chat/MobileActionSheet', () => ({ useAttachEntry: () => ({ entries: [], hiddenFileInput: null }), })); vi.mock('@/renderer/components/chat/ThoughtDisplay', () => ({ default: () => null })); -vi.mock('@/renderer/components/media/FileAttachButton', () => ({ default: () => null })); +vi.mock('@/renderer/components/media/FileAttachButton', () => ({ + default: ({ + loadedMcpStatuses, + }: { + loadedMcpStatuses?: Array<{ id: string; name: string; status: string; reason?: string }>; + }) => ( +
+ {(loadedMcpStatuses ?? []).map((item) => ( + {`${item.name}|${item.status}|${item.reason ?? ''}`} + ))} +
+ ), +})); vi.mock('@/renderer/components/media/FilePreview', () => ({ default: () => null })); vi.mock('@/renderer/components/media/HorizontalFileList', () => ({ default: ({ children }: { children?: React.ReactNode }) => <>{children}, @@ -108,7 +125,7 @@ vi.mock('@/renderer/hooks/chat/useAutoTitle', () => ({ }), })); vi.mock('@/renderer/hooks/context/ConversationContext', () => ({ - useConversationContextSafe: () => null, + useConversationContextSafe: () => conversationContextRef.current, })); vi.mock('@/renderer/hooks/context/LayoutContext', () => ({ useLayoutContext: () => ({ isMobile: false }), @@ -204,6 +221,35 @@ describe('AcpSendBox', () => { beforeEach(() => { vi.clearAllMocks(); stopInvokeMock.mockResolvedValue({ runtime: undefined }); + conversationContextRef.current = null; + }); + + it('keeps exact post-session MCP statuses and reasons visible', () => { + conversationContextRef.current = { + loadedMcpServers: ['stale-fallback'], + loadedMcpStatuses: [ + { + id: 'mac-control', + name: 'evaos-mac-control', + status: 'unsupported', + reason: "transport 'stdio' is not supported by this agent", + }, + { + id: 'other-mcp', + name: 'Other MCP', + status: 'failed', + reason: 'authentication failed', + }, + ], + }; + + render(); + + expect(screen.getByTestId('mcp-statuses')).toHaveTextContent( + "evaos-mac-control|unsupported|transport 'stdio' is not supported by this agent" + ); + expect(screen.getByTestId('mcp-statuses')).toHaveTextContent('Other MCP|failed|authentication failed'); + expect(screen.getByTestId('mcp-statuses')).not.toHaveTextContent('stale-fallback'); }); it('resets ACP loading state when sendMessage fails before any stream error arrives', async () => { diff --git a/tests/unit/renderer/LocalAgents.dom.test.tsx b/tests/unit/renderer/LocalAgents.dom.test.tsx index 0741189b5b..cd928fb94a 100644 --- a/tests/unit/renderer/LocalAgents.dom.test.tsx +++ b/tests/unit/renderer/LocalAgents.dom.test.tsx @@ -32,6 +32,16 @@ vi.mock('react-i18next', () => ({ if (key === 'settings.agentManagement.nativeNotRequiredReason') { return 'Agent does not depend on evaOS Mac control pairing.'; } + if (key === 'settings.agentManagement.gatewayManagedTools') return 'Gateway-managed tools'; + if (key === 'settings.agentManagement.workbenchSessionMcpUnsupported') { + return 'Workbench session MCP unsupported'; + } + if (key === 'settings.agentManagement.desktopBridgeGatewayPluginRequired') { + return 'Desktop Bridge gateway plugin required'; + } + if (key === 'settings.agentManagement.sessionMcpSupportDeterminedAtConnection') { + return 'Session MCP support is determined at connection time'; + } return key; }, }), @@ -158,6 +168,50 @@ describe('LocalAgents', () => { expect(screen.getAllByText('Go to Chat').length).toBeGreaterThan(0); }); + it('shows the gateway-managed OpenClaw transport contract', () => { + managedAgentsMock.mockReturnValue({ + agents: [ + agent({ + id: 'openclaw', + name: 'OpenClaw', + backend: 'openclaw', + available: true, + }), + ], + isLoading: false, + error: null, + revalidate: revalidateMock, + refreshCustomAgents: vi.fn(), + }); + + render(); + + expect(screen.getByText('Gateway-managed tools')).toBeInTheDocument(); + expect(screen.getByText('Workbench session MCP unsupported')).toBeInTheDocument(); + expect(screen.getByText('Desktop Bridge gateway plugin required')).toBeInTheDocument(); + }); + + it('shows connection-time session MCP truth for a generic ACP agent', () => { + managedAgentsMock.mockReturnValue({ + agents: [ + agent({ + id: 'generic-acp', + name: 'Generic ACP', + backend: 'generic', + available: true, + }), + ], + isLoading: false, + error: null, + revalidate: revalidateMock, + refreshCustomAgents: vi.fn(), + }); + + render(); + + expect(screen.getByText('Session MCP support is determined at connection time')).toBeInTheDocument(); + }); + it('shows a retryable error instead of an empty catalog when loading fails', () => { managedAgentsMock.mockReturnValue({ agents: [], diff --git a/tests/unit/renderer/useGuidSend.dom.test.ts b/tests/unit/renderer/useGuidSend.dom.test.ts index ef20c2f341..252b868a93 100644 --- a/tests/unit/renderer/useGuidSend.dom.test.ts +++ b/tests/unit/renderer/useGuidSend.dom.test.ts @@ -143,7 +143,7 @@ describe('useGuidSend', () => { expect(sessionStorage.getItem('acp_initial_message_conv-1')).toBe(JSON.stringify({ input: 'hello' })); }); - it('auto-attaches built-in Mac-control tools to evaOS/OpenClaw ACP conversations', async () => { + it('preserves a selected user MCP without sending Workbench session MCP to OpenClaw', async () => { const deps = createDeps(); deps.selectedAgent = 'openclaw-gateway'; deps.selectedAgentKey = 'openclaw-gateway'; @@ -165,7 +165,42 @@ describe('useGuidSend', () => { { id: 'mcp-user', name: 'User MCP', enabled: true, builtin: false } as IMcpServer, { id: 'mac-control-mcp', name: BUILTIN_EVAOS_MAC_CONTROL_NAME, enabled: true, builtin: true } as IMcpServer, ]; - deps.selectedMcpServerIds = []; + deps.selectedMcpServerIds = ['mcp-user']; + + const { result } = renderHook(() => useGuidSend(deps)); + + await act(async () => { + await result.current.handleSend(); + }); + + const payload = createConversationInvokeMock.mock.calls[0][0]; + expect(payload.extra.selected_mcp_server_ids).toEqual(['mcp-user']); + expect(payload.extra.selected_session_mcp_servers).toEqual([]); + }); + + it('omits selected built-in MCP servers from the unsupported OpenClaw session payload', async () => { + const deps = createDeps(); + deps.selectedAgent = 'openclaw-gateway'; + deps.selectedAgentKey = 'openclaw-gateway'; + deps.selectedAgentInfo = { + id: 'openclaw-gateway', + key: 'openclaw-gateway', + name: 'evaOS', + agent_type: 'openclaw-gateway', + backend: 'openclaw-gateway', + cli_path: '/opt/evaos/openclaw', + isExtension: false, + } as never; + deps.is_presetAgent = false; + deps.getEffectiveAgentType = vi.fn(() => ({ + agent_type: 'openclaw-gateway', + isAvailable: true, + })); + deps.availableMcpServers = [ + { id: 'builtin-unrelated', name: 'Unrelated Builtin', enabled: true, builtin: true } as IMcpServer, + { id: 'mac-control-mcp', name: BUILTIN_EVAOS_MAC_CONTROL_NAME, enabled: true, builtin: true } as IMcpServer, + ]; + deps.selectedMcpServerIds = ['builtin-unrelated', 'mac-control-mcp']; const { result } = renderHook(() => useGuidSend(deps)); @@ -175,9 +210,7 @@ describe('useGuidSend', () => { const payload = createConversationInvokeMock.mock.calls[0][0]; expect(payload.extra.selected_mcp_server_ids).toEqual([]); - expect(payload.extra.selected_session_mcp_servers).toEqual([ - expect.objectContaining({ id: 'mac-control-mcp', name: BUILTIN_EVAOS_MAC_CONTROL_NAME }), - ]); + expect(payload.extra.selected_session_mcp_servers).toEqual([]); }); it('does not auto-attach Mac-control tools to unrelated ACP conversations', async () => { @@ -198,6 +231,74 @@ describe('useGuidSend', () => { expect(payload.extra.selected_session_mcp_servers).toEqual([]); }); + it('preserves session MCP for a non-OpenClaw backend even when its display name mentions OpenClaw', async () => { + const deps = createDeps(); + deps.selectedAgent = 'custom'; + deps.selectedAgentKey = 'custom-openclaw-helper'; + deps.selectedAgentInfo = { + id: 'custom-openclaw-helper', + key: 'custom-openclaw-helper', + name: 'OpenClaw Helper', + agent_type: 'acp', + backend: 'custom', + isExtension: true, + } as never; + deps.is_presetAgent = false; + deps.getEffectiveAgentType = vi.fn(() => ({ + agent_type: 'acp', + isAvailable: true, + })); + deps.availableMcpServers = [ + { id: 'builtin-unrelated', name: 'Unrelated Builtin', enabled: true, builtin: true } as IMcpServer, + ]; + deps.selectedMcpServerIds = ['builtin-unrelated']; + + const { result } = renderHook(() => useGuidSend(deps)); + + await act(async () => { + await result.current.handleSend(); + }); + + const payload = createConversationInvokeMock.mock.calls[0][0]; + expect(payload.extra.selected_session_mcp_servers).toEqual([ + expect.objectContaining({ id: 'builtin-unrelated', name: 'Unrelated Builtin' }), + ]); + }); + + it('preserves session MCP when a custom ACP row id and key collide with the OpenClaw backend name', async () => { + const deps = createDeps(); + deps.selectedAgent = 'openclaw'; + deps.selectedAgentKey = 'openclaw'; + deps.selectedAgentInfo = { + id: 'openclaw', + key: 'openclaw', + name: 'Custom OpenClaw', + agent_type: 'acp', + backend: 'custom', + isExtension: true, + } as never; + deps.is_presetAgent = false; + deps.getEffectiveAgentType = vi.fn(() => ({ + agent_type: 'acp', + isAvailable: true, + })); + deps.availableMcpServers = [ + { id: 'builtin-unrelated', name: 'Unrelated Builtin', enabled: true, builtin: true } as IMcpServer, + ]; + deps.selectedMcpServerIds = ['builtin-unrelated']; + + const { result } = renderHook(() => useGuidSend(deps)); + + await act(async () => { + await result.current.handleSend(); + }); + + const payload = createConversationInvokeMock.mock.calls[0][0]; + expect(payload.extra.selected_session_mcp_servers).toEqual([ + expect.objectContaining({ id: 'builtin-unrelated', name: 'Unrelated Builtin' }), + ]); + }); + it('passes selected mode into assistant conversation overrides when creating a preset ACP conversation', async () => { const { result } = renderHook(() => useGuidSend(createDeps()));