From e63e839d8f065d2e2f4eec3d4fe48594fce43f67 Mon Sep 17 00:00:00 2001 From: weishao Date: Sat, 22 Aug 2026 17:24:08 +0800 Subject: [PATCH 1/9] refactor(web-ui): route business invokes through adapter layer and drop dead AgentService - Add eslint no-restricted-imports fence: business code must reach the platform only via api.invoke (ApiClient); direct invoke from '@tauri-apps/api/core' is reserved for adapters/** and the intentional PeerHostInvokeBridge exception. - Reroute 8 A-class modules (insights, i18n, companion pet, announcement, file/image context, ide-control event bus) from direct invoke to api.invoke. - Delete the dead legacy agent-service.ts wrapper (no consumers) and its FlowChatManager field/import/initialization, the orphaned getAvailableAgents() method, and its test mock. Co-Authored-By: Claude --- src/web-ui/eslint.config.mjs | 35 + .../services/FlowChatManager.test.ts | 6 - .../src/flow_chat/services/FlowChatManager.ts | 9 +- .../src/infrastructure/api/insightsApi.ts | 12 +- .../infrastructure/api/service-api/I18nAPI.ts | 12 +- .../services/AgentCompanionPetService.ts | 8 +- .../services/AnnouncementService.ts | 14 +- .../core/types/FileContextImpl.tsx | 12 +- .../core/types/ImageContextImpl.tsx | 4 +- .../src/shared/services/agent-service.ts | 632 ------------------ .../ide-control/IdeControlEventBus.ts | 4 +- 11 files changed, 69 insertions(+), 679 deletions(-) delete mode 100644 src/web-ui/src/shared/services/agent-service.ts diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 426751c7cf..7b3eaa03fc 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -22,6 +22,41 @@ export default tseslint.config( 'src/shared/context-menu-system/examples/**', ], }, + { + // Adapter-layer fence: business Tauri commands must reach the platform + // only through ApiClient (api.invoke). Direct `invoke` from + // '@tauri-apps/api/core' is reserved for the adapter implementations in + // adapters/** (and the peer-device host bridge, which intentionally runs + // outside the routed transport — see PeerHostInvokeBridge). This is the + // executable form of "front end calls go through the adapter layer"; + // reintroducing a direct invoke elsewhere fails the build. + files: ['src/**/*.{ts,tsx}'], + ignores: [ + 'src/infrastructure/api/adapters/**', + // PeerHostInvokeBridge runs on the HOST side of Peer-Device Mode: it + // executes *dynamic* command names forwarded from the peer device via a + // raw Tauri invoke. ApiClient is already routed to the peer adapter at + // this point, so routing through it would be wrong. This is the one + // intentional exception to the adapter-layer fence. + 'src/infrastructure/peer-device/PeerHostInvokeBridge.tsx', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@tauri-apps/api/core'], + importNames: ['invoke'], + message: + '业务命令必须经 api.invoke(ApiClient) 统一适配层,不可直接 import invoke。' + + '如需直连平台 invoke,放到 adapters/ 内并经 api 暴露。', + }, + ], + }, + ], + }, + }, { files: ['src/**/*.{ts,tsx}'], extends: [js.configs.recommended, ...tseslint.configs.recommended], diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts index d5e9130beb..daefcf8899 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts @@ -42,12 +42,6 @@ vi.mock('../store/FlowChatStore', () => ({ }, })); -vi.mock('../../shared/services/agent-service', () => ({ - AgentService: { - getInstance: vi.fn(() => ({})), - }, -})); - vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ ACPClientAPI: {}, })); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 690ebd6f36..3c9c8a00b6 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -10,7 +10,6 @@ import { processingStatusManager } from './ProcessingStatusManager'; import { FlowChatStore } from '../store/FlowChatStore'; import { useModernFlowChatStore } from '../store/modernFlowChatStore'; -import { AgentService } from '../../shared/services/agent-service'; import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; import { stateMachineManager } from '../state-machine'; import { EventBatcher } from './EventBatcher'; @@ -79,7 +78,6 @@ const EVENT_LISTENER_RETRY_MS = 2000; export class FlowChatManager { private static instance: FlowChatManager | null = null; private context: FlowChatContext; - private agentService: AgentService; private eventListenerInitialized = false; private eventListenerInitializationPromise: Promise | null = null; private eventListenerCleanup: (() => void) | null = null; @@ -117,8 +115,7 @@ export class FlowChatManager { currentWorkspacePath: null, ensureLiveSubscription: () => this.ensureEventListeners(), }; - - this.agentService = AgentService.getInstance(); + registerDriverSessionLookup( sessionId => this.context.flowChatStore.getState().sessions.get(sessionId), ); @@ -917,10 +914,6 @@ export class FlowChatManager { updateImageAnalysisItemModule(this.context, sessionId, dialogTurnId, imageId, updates); } - async getAvailableAgents(): Promise { - return this.agentService.getAvailableAgents(); - } - getCurrentSession() { return this.context.flowChatStore.getActiveSession(); } diff --git a/src/web-ui/src/infrastructure/api/insightsApi.ts b/src/web-ui/src/infrastructure/api/insightsApi.ts index 95cf679da3..05c5c377f7 100644 --- a/src/web-ui/src/infrastructure/api/insightsApi.ts +++ b/src/web-ui/src/infrastructure/api/insightsApi.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; +import { api } from './service-api/ApiClient'; import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { openPath } from '@tauri-apps/plugin-opener'; @@ -176,29 +176,29 @@ export interface InsightsProgressEvent { export const insightsApi = { async generateInsights(days?: number, modelId?: string): Promise { - return invoke('generate_insights', { + return api.invoke('generate_insights', { request: { days: days ?? 30, modelId: modelId || 'auto' }, }); }, async getLatestInsights(): Promise { - return invoke('get_latest_insights'); + return api.invoke('get_latest_insights'); }, async loadReport(path: string): Promise { - return invoke('load_insights_report', { + return api.invoke('load_insights_report', { request: { path }, }); }, async hasInsightsData(days?: number): Promise { - return invoke('has_insights_data', { + return api.invoke('has_insights_data', { request: { days: days ?? 30 }, }); }, async cancelGeneration(): Promise { - return invoke('cancel_insights_generation'); + return api.invoke('cancel_insights_generation'); }, async listenProgress( diff --git a/src/web-ui/src/infrastructure/api/service-api/I18nAPI.ts b/src/web-ui/src/infrastructure/api/service-api/I18nAPI.ts index b1df6adcf0..270208943e 100644 --- a/src/web-ui/src/infrastructure/api/service-api/I18nAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/I18nAPI.ts @@ -1,6 +1,6 @@ -import { invoke } from '@tauri-apps/api/core'; +import { api } from './ApiClient'; import type { LocaleId, LocaleMetadata, I18nConfig } from '@/infrastructure/i18n/types'; import { getLocaleMetadata } from '@/infrastructure/i18n/presets'; import { createLogger } from '@/shared/utils/logger'; @@ -21,7 +21,7 @@ class I18nAPIClass { async getCurrentLanguage(): Promise { try { - const language = await invoke('i18n_get_current_language'); + const language = await api.invoke('i18n_get_current_language'); return language as LocaleId; } catch (error) { log.warn('Failed to get current language, using default', error); @@ -31,14 +31,14 @@ class I18nAPIClass { async setLanguage(language: LocaleId): Promise { - return invoke('i18n_set_language', { + return api.invoke('i18n_set_language', { request: { language } }); } async getSupportedLanguages(): Promise { - const response = await invoke('i18n_get_supported_languages'); + const response = await api.invoke('i18n_get_supported_languages'); return response.map(item => { const id = item.id as LocaleId; @@ -67,7 +67,7 @@ class I18nAPIClass { async getConfig(): Promise { try { - const config = await invoke('i18n_get_config'); + const config = await api.invoke('i18n_get_config'); return { currentLanguage: config.currentLanguage || 'zh-CN', fallbackLanguage: config.fallbackLanguage || 'en-US', @@ -87,7 +87,7 @@ class I18nAPIClass { async setConfig(config: Partial): Promise { - return invoke('i18n_set_config', { config }); + return api.invoke('i18n_set_config', { config }); } } diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts index e91e114947..667705752e 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts @@ -1,4 +1,4 @@ -import { invoke } from '@tauri-apps/api/core'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { readFile } from '@tauri-apps/plugin-fs'; import type { AgentCompanionPetSelection } from './AIExperienceConfigService'; import { isTauriRuntime } from '@/infrastructure/runtime'; @@ -172,7 +172,7 @@ export async function listAgentCompanionPets(): Promise('list_agent_companion_pets'); + const response = await api.invoke('list_agent_companion_pets'); const userPets = await Promise.all(response.pets.map(withPreviewSrc)); return [...builtinPets, ...userPets]; } catch (error) { @@ -182,14 +182,14 @@ export async function listAgentCompanionPets(): Promise { - const pet = await invoke('import_agent_companion_pet_package', { + const pet = await api.invoke('import_agent_companion_pet_package', { request: { path }, }); return withPreviewSrc(pet); } export async function deleteAgentCompanionPetPackage(packagePath: string): Promise { - await invoke('delete_agent_companion_pet_package', { + await api.invoke('delete_agent_companion_pet_package', { request: { packagePath }, }); } diff --git a/src/web-ui/src/shared/announcement-system/services/AnnouncementService.ts b/src/web-ui/src/shared/announcement-system/services/AnnouncementService.ts index 4ca4a266c3..08e61b5a55 100644 --- a/src/web-ui/src/shared/announcement-system/services/AnnouncementService.ts +++ b/src/web-ui/src/shared/announcement-system/services/AnnouncementService.ts @@ -4,7 +4,7 @@ * Wraps all Tauri `invoke` calls for the announcement system so that the * rest of the frontend never touches `invoke` directly. */ -import { invoke } from '@tauri-apps/api/core'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { AnnouncementCard } from '../types'; import { createLogger } from '@/shared/utils/logger'; @@ -18,7 +18,7 @@ export const announcementService = { */ async getPendingAnnouncements(): Promise { try { - return await invoke('get_pending_announcements'); + return await api.invoke('get_pending_announcements'); } catch (e) { log.error('Failed to get pending announcements', e); return []; @@ -28,7 +28,7 @@ export const announcementService = { /** Mark a card as seen (modal was opened or action button was clicked). */ async markSeen(id: string): Promise { try { - await invoke('mark_announcement_seen', { request: { id } }); + await api.invoke('mark_announcement_seen', { request: { id } }); } catch (e) { log.error('Failed to mark announcement seen', { id, error: e }); } @@ -37,7 +37,7 @@ export const announcementService = { /** Dismiss a card for the current version cycle. */ async dismiss(id: string): Promise { try { - await invoke('dismiss_announcement', { request: { id } }); + await api.invoke('dismiss_announcement', { request: { id } }); } catch (e) { log.error('Failed to dismiss announcement', { id, error: e }); } @@ -46,7 +46,7 @@ export const announcementService = { /** Permanently suppress a card. */ async neverShow(id: string): Promise { try { - await invoke('never_show_announcement', { request: { id } }); + await api.invoke('never_show_announcement', { request: { id } }); } catch (e) { log.error('Failed to suppress announcement', { id, error: e }); } @@ -58,7 +58,7 @@ export const announcementService = { */ async triggerCard(id: string): Promise { try { - return await invoke('trigger_announcement', { request: { id } }); + return await api.invoke('trigger_announcement', { request: { id } }); } catch (e) { log.error('Failed to trigger announcement', { id, error: e }); return null; @@ -68,7 +68,7 @@ export const announcementService = { /** Fetch all currently eligible tip cards (for a tips browser). */ async getTips(): Promise { try { - return await invoke('get_announcement_tips'); + return await api.invoke('get_announcement_tips'); } catch (e) { log.error('Failed to get announcement tips', e); return []; diff --git a/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx b/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx index 939b96e58e..e1817b5e83 100644 --- a/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx +++ b/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx @@ -1,13 +1,13 @@ import React from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { FileIcon, CheckCircle } from 'lucide-react'; import type { FileContext, ValidationResult, RenderOptions } from '../../../types/context'; -import type { - ContextTransformer, - ContextValidator, - ContextCardRenderer +import type { + ContextTransformer, + ContextValidator, + ContextCardRenderer } from '../../../services/ContextRegistry'; import { i18nService } from '@/infrastructure/i18n'; @@ -42,7 +42,7 @@ export class FileContextValidator implements ContextValidator<'file'> { async validate(context: FileContext): Promise { try { - const exists = await invoke('fs_exists', { path: context.filePath }); + const exists = await api.invoke('fs_exists', { path: context.filePath }); if (!exists) { return { diff --git a/src/web-ui/src/shared/context-system/core/types/ImageContextImpl.tsx b/src/web-ui/src/shared/context-system/core/types/ImageContextImpl.tsx index d48dff6b9e..ad8a5f2b50 100644 --- a/src/web-ui/src/shared/context-system/core/types/ImageContextImpl.tsx +++ b/src/web-ui/src/shared/context-system/core/types/ImageContextImpl.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { Image as ImageIcon, Eye } from 'lucide-react'; import { Modal, Button } from '@/component-library'; import type { ImageContext, ValidationResult, RenderOptions } from '../../../types/context'; @@ -86,7 +86,7 @@ export class ImageContextValidator implements ContextValidator<'image'> { if (context.isLocal && context.imagePath) { try { - const exists = await invoke('check_path_exists', { + const exists = await api.invoke('check_path_exists', { request: { path: context.imagePath } diff --git a/src/web-ui/src/shared/services/agent-service.ts b/src/web-ui/src/shared/services/agent-service.ts deleted file mode 100644 index 6930ae80b4..0000000000 --- a/src/web-ui/src/shared/services/agent-service.ts +++ /dev/null @@ -1,632 +0,0 @@ -/** - * Agent service (frontend). - * - * Wraps agent/tool APIs and bridges backend streaming events into a convenient - * client-side interface. - */ -import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; -import { toolAPI } from '@/infrastructure/api/service-api/ToolAPI'; -import { listen } from '@tauri-apps/api/event'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('AgentService'); -const hasTauriRuntime = (): boolean => - typeof window !== 'undefined' && - ('__TAURI_INTERNALS__' in window || '__TAURI__' in window); -import type { - AgentExecutionRequest, - AgentExecutionResponse, - AgentInfo, - ToolInfo, - ToolExecutionRequest, - ToolExecutionResponse, - ToolValidationRequest, - ToolValidationResponse, - AgentTaskUpdateEvent, - StreamChunkEvent, - StreamToolUseEvent, - StreamToolResultEvent, - StreamProgressEvent, - StreamStartEvent, - StreamCompleteEvent, - StreamErrorEvent, - ToolCallConfirmationEvent, -} from '../types/agent-api'; - -export class AgentService { - private static instance: AgentService; - private taskListeners = new Map void>(); - private streamListeners = new Map void; - onToolUse?: (event: StreamToolUseEvent) => void; - onToolResult?: (event: StreamToolResultEvent) => void; - onProgress?: (event: StreamProgressEvent) => void; - onComplete?: (event: StreamCompleteEvent) => void; - onError?: (event: StreamErrorEvent) => void; - onModelRoundStart?: (event: any) => void; - onToolConfirmation?: (event: ToolCallConfirmationEvent) => void; - }>(); - private unlistenFunctions: Array<() => void> = []; - - private constructor() { - void this.setupEventListeners().catch(error => { - log.warn('Failed to setup event listeners during startup', error); - }); - - - if (import.meta.hot) { - import.meta.hot.dispose(() => { - this.cleanup(); - }); - } - } - - static getInstance(): AgentService { - if (!AgentService.instance) { - AgentService.instance = new AgentService(); - } - return AgentService.instance; - } - - - private cleanup(): void { - this.unlistenFunctions.forEach(unlisten => { - try { - unlisten(); - } catch (e) { - log.warn('Failed to cleanup listener', e); - } - }); - this.unlistenFunctions = []; - this.taskListeners.clear(); - this.streamListeners.clear(); - } - - private async setupEventListeners() { - if (!hasTauriRuntime()) { - log.warn('Tauri runtime not available, skipping agent event listeners'); - return; - } - - const unlisten1 = await listen('agent_task_update', (event) => { - const taskEvent = event.payload; - const listener = this.taskListeners.get(taskEvent.task_id); - if (listener) { - listener(taskEvent); - } - }); - this.unlistenFunctions.push(unlisten1); - - - const unlisten2 = await listen('agentic_stream_start', () => { - // Stream started event handled - }); - this.unlistenFunctions.push(unlisten2); - - - const unlisten3 = await listen('model_round_start', (event) => { - const startEvent = event.payload; - - if (startEvent.task_id) { - const listener = this.streamListeners.get(startEvent.task_id); - if (listener && listener.onModelRoundStart) { - listener.onModelRoundStart(startEvent); - } - } - }); - this.unlistenFunctions.push(unlisten3); - - - const unlisten4 = await listen('tool_execution_event', (event) => { - const toolEvent = event.payload; - - - if (toolEvent.tool_name === 'TodoWrite' && toolEvent.type === 'tool_start') { - - Promise.all([ - import('@/flow_chat/services/FlowChatManager'), - import('@/flow_chat/state-machine') - ]).then(([{ FlowChatManager }, { stateMachineManager }]) => { - const todos = toolEvent.input?.todos || []; - const merge = toolEvent.input?.merge || false; - - - const flowChatManager = FlowChatManager.getInstance(); - const sessionId = flowChatManager.getSessionIdByTaskId(toolEvent.task_id); - - if (sessionId) { - const machine = stateMachineManager.get(sessionId); - if (machine) { - const context = machine.getContext(); - - - if (merge && context.planner) { - - const existingTodos = context.planner.todos; - const todoMap = new Map(existingTodos.map(t => [t.id, t])); - todos.forEach((todo: any) => { - todoMap.set(todo.id, todo); - }); - context.planner.todos = Array.from(todoMap.values()); - } else { - - context.planner = { - todos, - isActive: true - }; - } - } - } - }).catch(err => { - log.error('Failed to update state machine Planner', err); - }); - } - - if (toolEvent.task_id) { - const listener = this.streamListeners.get(toolEvent.task_id); - - if (listener) { - - if (toolEvent.type === 'tool_preparing' && listener.onToolUse) { - listener.onToolUse({ - task_id: toolEvent.task_id, - tool_use_id: toolEvent.tool_use_id, - tool_name: toolEvent.tool_name, - input: { _early_detection: true }, - model_round_id: toolEvent.model_round_id, - dialog_turn_id: toolEvent.dialog_turn_id, - timestamp: toolEvent.timestamp || Date.now(), - ai_intent: undefined, - requires_confirmation: false, - _is_early_detection: true - } as any); - } else if (toolEvent.type === 'tool_start' && listener.onToolUse) { - listener.onToolUse({ - task_id: toolEvent.task_id, - tool_use_id: toolEvent.tool_use_id, - tool_name: toolEvent.tool_name, - input: toolEvent.input, - model_round_id: toolEvent.model_round_id, - dialog_turn_id: toolEvent.dialog_turn_id, - timestamp: toolEvent.timestamp || Date.now(), - ai_intent: toolEvent.ai_intent, - requires_confirmation: toolEvent.requires_confirmation - } as any); - } else if (toolEvent.type === 'tool_complete' && listener.onToolResult) { - const resultEvent = { - task_id: toolEvent.task_id, - type: 'tool_result' as const, - content: toolEvent.result?.content || '', - timestamp: toolEvent.timestamp || Date.now(), - tool: toolEvent.tool_name, - tool_name: toolEvent.tool_name, - tool_use_id: toolEvent.tool_use_id, - result: { - content: toolEvent.result?.content || '', - data: toolEvent.result?.data, - type: toolEvent.success ? 'result' : 'error', - success: toolEvent.success, - error: toolEvent.error, - duration_ms: toolEvent.duration_ms - } - }; - - listener.onToolResult(resultEvent as any); - } - } - - } - }); - this.unlistenFunctions.push(unlisten4); - - - const unlisten5 = await listen('model_round_content', (event) => { - const contentEvent = event.payload; - - if (contentEvent.task_id) { - const listener = this.streamListeners.get(contentEvent.task_id); - - if (listener) { - - if (contentEvent.content_type === 'text' && contentEvent.content && listener.onChunk) { - listener.onChunk({ - task_id: contentEvent.task_id, - type: 'text' as const, - content: contentEvent.content, - model_round_id: contentEvent.model_round_id, - dialog_turn_id: contentEvent.dialog_turn_id, - timestamp: Date.now() - }); - } else if (contentEvent.content_type === 'thinking' && contentEvent.content && listener.onChunk) { - - listener.onChunk({ - task_id: contentEvent.task_id, - type: 'thinking' as const, - content: contentEvent.content, - model_round_id: contentEvent.model_round_id, - dialog_turn_id: contentEvent.dialog_turn_id, - timestamp: Date.now() - }); - } - } - - } - }); - this.unlistenFunctions.push(unlisten5); - - const unlisten6 = await listen('agentic_stream_chunk', (event) => { - const chunkEvent = event.payload; - const listener = this.streamListeners.get(chunkEvent.task_id); - if (listener?.onChunk) { - listener.onChunk(chunkEvent); - } - }); - this.unlistenFunctions.push(unlisten6); - - const unlisten7 = await listen('agentic_stream_tool_use', (event) => { - const toolUseEvent = event.payload; - const listener = this.streamListeners.get(toolUseEvent.task_id); - if (listener?.onToolUse) { - - listener.onToolUse(toolUseEvent); - } - }); - this.unlistenFunctions.push(unlisten7); - - const unlisten8 = await listen('agentic_stream_tool_result', (event) => { - const toolResultEvent = event.payload; - const listener = this.streamListeners.get(toolResultEvent.task_id); - if (listener?.onToolResult) { - listener.onToolResult(toolResultEvent); - } - }); - this.unlistenFunctions.push(unlisten8); - - const unlisten9 = await listen('agentic_stream_progress', (event) => { - const progressEvent = event.payload; - const listener = this.streamListeners.get(progressEvent.task_id); - if (listener?.onProgress) { - listener.onProgress(progressEvent); - } - }); - this.unlistenFunctions.push(unlisten9); - - const unlisten10 = await listen('agentic_stream_complete', (event) => { - const completeEvent = event.payload; - - - const listener = this.streamListeners.get(completeEvent.task_id); - if (listener?.onComplete) { - listener.onComplete(completeEvent); - } - - - - - - this.streamListeners.delete(completeEvent.task_id); - }); - this.unlistenFunctions.push(unlisten10); - - const unlisten11 = await listen('agentic_stream_error', (event) => { - const errorEvent = event.payload; - const listener = this.streamListeners.get(errorEvent.task_id); - if (listener?.onError) { - listener.onError(errorEvent); - } - - this.streamListeners.delete(errorEvent.task_id); - }); - this.unlistenFunctions.push(unlisten11); - - - const unlisten12 = await listen('backend-event-toolcallconfirmation', (event) => { - const confirmationEvent = event.payload; - - - - for (const listener of this.streamListeners.values()) { - if (listener?.onToolConfirmation) { - listener.onToolConfirmation(confirmationEvent); - break; - } - } - }); - this.unlistenFunctions.push(unlisten12); - } - - - - - async getAvailableAgents(): Promise { - - return ['general-purpose']; - } - - - async getActiveAgentConfigs(): Promise { - const agentTypes = await agentAPI.getAvailableTools(); - - return agentTypes.map(type => ({ - id: type, - name: type, - type: type, - description: `${type} agent`, - version: '1.0.0', - status: 'active' as const, - agent_type: type, - when_to_use: `Use ${type} agent for specialized tasks`, - tools: 'all', - location: 'builtin' - })); - } - - - async getAgentInfo(agentType: string): Promise { - return agentAPI.getAgentInfo(agentType); - } - - - async startAgentTaskStream( - request: AgentExecutionRequest, - onUpdate: (event: AgentTaskUpdateEvent) => void - ): Promise { - - const taskId = await this.executeAgentTaskStream(request, {}); - - - this.taskListeners.set(taskId, onUpdate); - - return taskId; - } - - - async executeAgentTaskStream( - request: AgentExecutionRequest, - callbacks: { - onChunk?: (event: StreamChunkEvent) => void; - onToolUse?: (event: StreamToolUseEvent) => void; - onToolResult?: (event: StreamToolResultEvent) => void; - onProgress?: (event: StreamProgressEvent) => void; - onComplete?: (event: StreamCompleteEvent) => void; - onError?: (event: StreamErrorEvent) => void; - onModelRoundStart?: (event: any) => void; - onToolConfirmation?: (event: ToolCallConfirmationEvent) => void; - } - ): Promise { - - - let sessionId: string; - try { - const workspacePath = request.workspace_path; - if (!workspacePath) { - throw new Error('Workspace path is required to create an agent task session'); - } - - const response = await agentAPI.createSession({ - sessionName: `task-${Date.now()}`, - agentType: request.agent_type, - workspacePath, - config: { - modelName: request.model_name, - enableTools: true, - safeMode: true, - } - }); - sessionId = response.sessionId; - } catch (error) { - log.error('Failed to create session', error); - throw error; - } - - - const existingListener = this.streamListeners.get(sessionId); - if (existingListener) { - log.warn('Session ID already has listener, will override', { sessionId }); - } - - - this.streamListeners.set(sessionId, callbacks); - - - try { - const workspacePath = request.workspace_path; - if (!workspacePath) { - throw new Error('Workspace path is required to start an agent task'); - } - - await agentAPI.startDialogTurn({ - sessionId, - userInput: request.prompt, - agentType: request.agent_type, - workspacePath, - }); - } catch (error) { - log.error('Failed to send message', error); - throw error; - } - - return sessionId; - } - - - async cancelAgentTask(taskId: string): Promise { - - await agentAPI.cancelSession(taskId); - const result = true; - - - this.taskListeners.delete(taskId); - - return result; - } - - - cleanupTaskListener(taskId: string) { - this.taskListeners.delete(taskId); - } - - - - - async getAllToolsInfo(): Promise { - return toolAPI.getAllToolsInfo(); - } - - - async getReadonlyToolsInfo(): Promise { - - const allTools = await toolAPI.getAllToolsInfo(); - return allTools.filter((tool: any) => tool.is_readonly === true); - } - - - async getToolInfo(toolName: string): Promise { - return toolAPI.getToolInfo(toolName); - } - - - async validateToolInput(request: ToolValidationRequest): Promise { - - const validationRequest = { - toolName: (request as any).tool_name || (request as any).toolName, - input: request.input || (request as any).parameters, - workspacePath: (request as any).workspace_path || (request as any).workspacePath, - }; - return toolAPI.validateToolInput(validationRequest); - } - - - async executeTool(request: ToolExecutionRequest): Promise { - - const executeRequest = { - toolName: (request as any).tool_name || (request as any).toolName, - parameters: request.input || {}, - workspacePath: (request as any).workspace_path || (request as any).workspacePath, - }; - return toolAPI.executeTool(executeRequest); - } - - - async executeTask( - description: string, - prompt: string, - agentType: string = 'general-purpose', - options: { - modelName?: string; - workspacePath?: string; - context?: Record; - safeMode?: boolean; - verbose?: boolean; - } = {} - ): Promise { - const request: AgentExecutionRequest = { - agent_type: agentType, - prompt, - description, - model_name: options.modelName, - workspace_path: options.workspacePath, - context: options.context, - safe_mode: options.safeMode, - verbose: options.verbose, - }; - - return this.executeAgentTask(request); - } - - async executeAgentTask(request: AgentExecutionRequest): Promise { - const sessionId = await this.executeAgentTaskStream(request, {}); - return { - id: sessionId, - status: 'started', - agent_type: request.agent_type, - }; - } - - - async executeTaskStream( - description: string, - prompt: string, - onUpdate: (event: AgentTaskUpdateEvent) => void, - agentType: string = 'general-purpose', - options: { - modelName?: string; - workspacePath?: string; - context?: Record; - safeMode?: boolean; - verbose?: boolean; - } = {} - ): Promise { - const request: AgentExecutionRequest = { - agent_type: agentType, - prompt, - description, - model_name: options.modelName, - workspace_path: options.workspacePath, - context: options.context, - safe_mode: options.safeMode, - verbose: options.verbose, - }; - - return this.startAgentTaskStream(request, onUpdate); - } - - - async executeTaskStreamNew( - description: string, - prompt: string, - callbacks: { - onChunk?: (text: string) => void; - onToolUse?: (toolName: string, input: any) => void; - onToolResult?: (content: string) => void; - onProgress?: () => void; - onComplete?: (result?: any) => void; - onError?: (error: string) => void; - }, - agentType: string = 'general-purpose', - options: { - modelName?: string; - workspacePath?: string; - context?: Record; - safeMode?: boolean; - verbose?: boolean; - } = {} - ): Promise { - const request: AgentExecutionRequest = { - agent_type: agentType, - prompt, - description, - model_name: options.modelName, - workspace_path: options.workspacePath, - context: options.context, - safe_mode: options.safeMode, - verbose: options.verbose, - }; - - return this.executeAgentTaskStream(request, { - onChunk: callbacks.onChunk ? (event) => callbacks.onChunk!(event.content) : undefined, - onToolUse: callbacks.onToolUse ? (event) => callbacks.onToolUse!(event.tool_name, event.input) : undefined, - onToolResult: callbacks.onToolResult ? (event) => callbacks.onToolResult!(event.content) : undefined, - onProgress: callbacks.onProgress, - onComplete: callbacks.onComplete ? (event) => callbacks.onComplete!(event.result) : undefined, - onError: callbacks.onError ? (event) => callbacks.onError!(event.error) : undefined, - }); - } - - - async isAgentAvailable(agentType: string): Promise { - const availableAgents = await this.getAvailableAgents(); - return availableAgents.includes(agentType); - } - - - async getRecommendedAgent(_taskDescription: string): Promise { - - return 'general-purpose'; - } - - -} - - -export const agentService = AgentService.getInstance(); diff --git a/src/web-ui/src/shared/services/ide-control/IdeControlEventBus.ts b/src/web-ui/src/shared/services/ide-control/IdeControlEventBus.ts index 19807ce15f..c2dcdbe149 100644 --- a/src/web-ui/src/shared/services/ide-control/IdeControlEventBus.ts +++ b/src/web-ui/src/shared/services/ide-control/IdeControlEventBus.ts @@ -4,6 +4,7 @@ * Listens to backend IDE control events and dispatches them to registered controllers. */ import { listen, UnlistenFn } from '@tauri-apps/api/event'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { IdeControlEvent, IdeController, IdeControlOperation } from './types'; import { PanelController } from './PanelController'; import { createLogger } from '@/shared/utils/logger'; @@ -108,8 +109,7 @@ export class IdeControlEventBus { private async sendErrorResult(requestId: string, error: any): Promise { try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('report_ide_control_result', { + await api.invoke('report_ide_control_result', { request_id: requestId, success: false, message: undefined, From 5fb1d247cef0a2eff0973130dbaf3c39d05ec3d8 Mon Sep 17 00:00:00 2001 From: weishao Date: Mon, 24 Aug 2026 16:21:21 +0800 Subject: [PATCH 2/9] chore(web-ui): delete confirmed zero-consumer dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 11 files and prune the api barrel of entries with no real consumers anywhere in src (verified by full-tree grep + dynamic import + bitfunAPI. access). Follow-up to 433f60112 which dropped the legacy agent-service.ts. Deleted (11 files): - infrastructure/services/ dead chain (5): business/agentService.ts, infra/contextManager.ts, infra/index.ts, api/index.ts (broken barrel exporting non-existent contextService), index.ts (barrel with no importer) - infrastructure/api/service-api/ProjectAPI.ts + GitRepoHistoryAPI.ts (only referenced inside the dead bitfunAPI collection) - shared/crypto/ (e2e-encryption.ts + index.ts, no @/shared/crypto import) - infrastructure/agents/constants.ts (BUILTIN_SUB_AGENT_IDS/isBuiltinSubAgent) - shared/context-menu-system/examples/FileTreeIntegrationExample.tsx Barrel edit (infrastructure/api/index.ts): drop the dead bitfunAPI collection object, its default export, the GitRepoHistory type re-export, and the projectAPI/gitRepoHistoryAPI imports; keep the 23 re-exports that have real consumers. Sync the eslint examples/** ignore to the deleted example dir. Conservatively retained: - ContextAPI.ts: contextAPI loses its only consumer (ContextManager) but wraps backend session commands (compress_context/save_session_data/...) — cross-layer decision, pruned from bitfunAPI but file kept. - Method-level dead code (~60 methods across RemoteConnectAPI/MiniAppAPI/ SubagentAPI/AgentAPI/etc): TS wrapper dead != Rust handler dead; deferred to a follow-up batch that checks the backend command table per method. Verified: tsc --noEmit introduces no new errors (only a pre-existing, unrelated websocket-adapter GitTrustReport import error remains); eslint src clean; vitest failures pre-exist on baseline (jsdom localStorage env issue). Co-Authored-By: Claude --- src/web-ui/eslint.config.mjs | 1 - .../src/infrastructure/agents/constants.ts | 10 - src/web-ui/src/infrastructure/api/index.ts | 38 +-- .../api/service-api/GitRepoHistoryAPI.ts | 41 --- .../api/service-api/ProjectAPI.ts | 65 ----- .../src/infrastructure/services/api/index.ts | 5 - .../services/business/agentService.ts | 248 ------------------ .../src/infrastructure/services/index.ts | 15 -- .../services/infra/contextManager.ts | 136 ---------- .../infrastructure/services/infra/index.ts | 4 - .../examples/FileTreeIntegrationExample.tsx | 117 --------- .../src/shared/crypto/e2e-encryption.ts | 184 ------------- src/web-ui/src/shared/crypto/index.ts | 9 - 13 files changed, 1 insertion(+), 872 deletions(-) delete mode 100644 src/web-ui/src/infrastructure/agents/constants.ts delete mode 100644 src/web-ui/src/infrastructure/api/service-api/GitRepoHistoryAPI.ts delete mode 100644 src/web-ui/src/infrastructure/api/service-api/ProjectAPI.ts delete mode 100644 src/web-ui/src/infrastructure/services/api/index.ts delete mode 100644 src/web-ui/src/infrastructure/services/business/agentService.ts delete mode 100644 src/web-ui/src/infrastructure/services/index.ts delete mode 100644 src/web-ui/src/infrastructure/services/infra/contextManager.ts delete mode 100644 src/web-ui/src/infrastructure/services/infra/index.ts delete mode 100644 src/web-ui/src/shared/context-menu-system/examples/FileTreeIntegrationExample.tsx delete mode 100644 src/web-ui/src/shared/crypto/e2e-encryption.ts delete mode 100644 src/web-ui/src/shared/crypto/index.ts diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 7b3eaa03fc..56815d1f17 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -19,7 +19,6 @@ export default tseslint.config( 'src/component-library/components/registry.tsx', 'src/component-library/preview/**', 'src/shared/context-system/core/types/**', - 'src/shared/context-menu-system/examples/**', ], }, { diff --git a/src/web-ui/src/infrastructure/agents/constants.ts b/src/web-ui/src/infrastructure/agents/constants.ts deleted file mode 100644 index 0adb97b202..0000000000 --- a/src/web-ui/src/infrastructure/agents/constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Built-in agent ids that are displayed with "Sub-Agent" badge (e.g. Explore, FileFinder). - * Other builtin agents keep the "Built-in" badge. - */ -export const BUILTIN_SUB_AGENT_IDS = ['explore', 'file_finder'] as const; - -export function isBuiltinSubAgent(agentId: string): boolean { - const id = agentId.toLowerCase().replace(/\s+/g, '_'); - return id === 'explore' || id === 'file_finder' || id === 'filefinder'; -} diff --git a/src/web-ui/src/infrastructure/api/index.ts b/src/web-ui/src/infrastructure/api/index.ts index a058377cf3..06e641b535 100644 --- a/src/web-ui/src/infrastructure/api/index.ts +++ b/src/web-ui/src/infrastructure/api/index.ts @@ -21,7 +21,6 @@ import { aiApi } from './service-api/AIApi'; import { toolAPI } from './service-api/ToolAPI'; import { agentAPI } from './service-api/AgentAPI'; import { systemAPI } from './service-api/SystemAPI'; -import { projectAPI } from './service-api/ProjectAPI'; import { diffAPI } from './service-api/DiffAPI'; import { snapshotAPI } from './service-api/SnapshotAPI'; import { globalAPI } from './service-api/GlobalAPI'; @@ -31,7 +30,6 @@ import { permissionAPI } from './service-api/PermissionAPI'; import { pageAPI } from './service-api/PageAPI'; import { gitAPI } from './service-api/GitAPI'; import { gitAgentAPI } from './service-api/GitAgentAPI'; -import { gitRepoHistoryAPI, type GitRepoHistory } from './service-api/GitRepoHistoryAPI'; import { sessionAPI } from './service-api/SessionAPI'; import { i18nAPI } from './service-api/I18nAPI'; import { btwAPI } from './service-api/BtwAPI'; @@ -43,7 +41,7 @@ import { speechAPI } from './service-api/SpeechAPI'; import { worktreeAPI } from './service-api/WorktreeAPI'; // Export API modules -export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, projectAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, cronAPI, permissionAPI, pageAPI, gitAPI, gitAgentAPI, gitRepoHistoryAPI, sessionAPI, i18nAPI, btwAPI, editorAiAPI, reviewPlatformAPI, insightsApi, tokenUsageStatisticsApi, speechAPI, worktreeAPI }; +export { workspaceAPI, configAPI, aiApi, toolAPI, agentAPI, systemAPI, diffAPI, snapshotAPI, globalAPI, contextAPI, cronAPI, permissionAPI, pageAPI, gitAPI, gitAgentAPI, sessionAPI, i18nAPI, btwAPI, editorAiAPI, reviewPlatformAPI, insightsApi, tokenUsageStatisticsApi, speechAPI, worktreeAPI }; export { TokenUsageStatisticsUnavailableError } from './tokenUsageStatisticsApi'; export * from './service-api/ReviewPlatformAPI'; export type { @@ -58,38 +56,4 @@ export type { } from './tokenUsageStatisticsApi'; // Export types -export type { GitRepoHistory }; export type { CheckForUpdatesResponse } from './service-api/SystemAPI'; - -// BitFun API collection: a single access point for all API modules. -export const bitfunAPI = { - workspace: workspaceAPI, - config: configAPI, - ai: aiApi, - tool: toolAPI, - agent: agentAPI, - system: systemAPI, - project: projectAPI, - diff: diffAPI, - snapshot: snapshotAPI, - global: globalAPI, - context: contextAPI, - cron: cronAPI, - permission: permissionAPI, - pages: pageAPI, - git: gitAPI, - gitAgent: gitAgentAPI, - gitRepoHistory: gitRepoHistoryAPI, - session: sessionAPI, - i18n: i18nAPI, - btw: btwAPI, - editorAi: editorAiAPI, - reviewPlatform: reviewPlatformAPI, - insights: insightsApi, - tokenUsageStatistics: tokenUsageStatisticsApi, - speech: speechAPI, - worktree: worktreeAPI, -}; - -// Default export -export default bitfunAPI; diff --git a/src/web-ui/src/infrastructure/api/service-api/GitRepoHistoryAPI.ts b/src/web-ui/src/infrastructure/api/service-api/GitRepoHistoryAPI.ts deleted file mode 100644 index 3d58a89e29..0000000000 --- a/src/web-ui/src/infrastructure/api/service-api/GitRepoHistoryAPI.ts +++ /dev/null @@ -1,41 +0,0 @@ - - -import { api } from './ApiClient'; -import { createTauriCommandError } from '../errors/TauriCommandError'; - - -export interface GitRepoHistory { - url: string; - lastUsed: string; - localPath?: string; -} - - -export class GitRepoHistoryAPI { - - async saveGitRepoHistory(repos: GitRepoHistory[]): Promise { - try { - await api.invoke('save_git_repo_history', { - request: { repos } - }); - } catch (error) { - throw createTauriCommandError('save_git_repo_history', error, { repos }); - } - } - - - async loadGitRepoHistory(): Promise { - try { - return await api.invoke('load_git_repo_history', { - request: {} - }); - } catch (error) { - throw createTauriCommandError('load_git_repo_history', error); - } - } -} - - -export const gitRepoHistoryAPI = new GitRepoHistoryAPI(); - - diff --git a/src/web-ui/src/infrastructure/api/service-api/ProjectAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ProjectAPI.ts deleted file mode 100644 index 3eaa5fa2bb..0000000000 --- a/src/web-ui/src/infrastructure/api/service-api/ProjectAPI.ts +++ /dev/null @@ -1,65 +0,0 @@ - - -import { api } from './ApiClient'; -import { createTauriCommandError } from '../errors/TauriCommandError'; - - -export class ProjectAPI { - - async analyzeProject(path: string, options?: any): Promise { - try { - return await api.invoke('analyze_project', { - request: { path, options } - }); - } catch (error) { - throw createTauriCommandError('analyze_project', error, { path, options }); - } - } - - - async getProjectStructure(path: string): Promise { - try { - return await api.invoke('get_project_structure', { - request: { path } - }); - } catch (error) { - throw createTauriCommandError('get_project_structure', error, { path }); - } - } - - - async getDependencyGraph(path: string): Promise { - try { - return await api.invoke('get_dependency_graph', { - request: { path } - }); - } catch (error) { - throw createTauriCommandError('get_dependency_graph', error, { path }); - } - } - - - async searchCode(query: string, options?: any): Promise { - try { - return await api.invoke('search_code', { - request: { query, options } - }); - } catch (error) { - throw createTauriCommandError('search_code', error, { query, options }); - } - } - - - async clearProjectCache(workspacePath: string): Promise { - try { - await api.invoke('clear_project_cache', { - request: { workspacePath } - }); - } catch (error) { - throw createTauriCommandError('clear_project_cache', error, { workspacePath }); - } - } -} - - -export const projectAPI = new ProjectAPI(); \ No newline at end of file diff --git a/src/web-ui/src/infrastructure/services/api/index.ts b/src/web-ui/src/infrastructure/services/api/index.ts deleted file mode 100644 index ac125dc042..0000000000 --- a/src/web-ui/src/infrastructure/services/api/index.ts +++ /dev/null @@ -1,5 +0,0 @@ - - -export { default as aiService } from './aiService'; -export { default as contextService } from './contextService'; - diff --git a/src/web-ui/src/infrastructure/services/business/agentService.ts b/src/web-ui/src/infrastructure/services/business/agentService.ts deleted file mode 100644 index e7a1a35190..0000000000 --- a/src/web-ui/src/infrastructure/services/business/agentService.ts +++ /dev/null @@ -1,248 +0,0 @@ - - -import { createLogger } from '../../../shared/utils/logger'; -import { agentAPI } from '../../api'; -import { i18nService } from '@/infrastructure/i18n'; - -const logger = createLogger('AgentService'); - - -type AgentType = 'project_qa' | 'requirement_clarification' | 'core'; - -export interface AgentResponse { - content: string; - metadata: Record; -} - -export interface AgentCallOptions { - agentType: AgentType; - message: string; - workspacePath?: string; -} - - -export interface AgentExecutionRequest { - agent_type: string; - prompt: string; - model_name?: string; - workspace_path?: string; - context?: Record; - verbose?: boolean; -} - - -class SessionManager { - private sessions = new Map(); // workspacePath::agentType -> sessionId - - private buildKey(agentType: string, workspacePath: string): string { - return `${workspacePath}::${agentType}`; - } - - getSession(agentType: string, workspacePath: string): string | undefined { - return this.sessions.get(this.buildKey(agentType, workspacePath)); - } - - setSession(agentType: string, workspacePath: string, sessionId: string): void { - this.sessions.set(this.buildKey(agentType, workspacePath), sessionId); - } - - deleteSession(agentType: string, workspacePath: string): void { - this.sessions.delete(this.buildKey(agentType, workspacePath)); - } - - clear(): void { - this.sessions.clear(); - } -} - -export class AgentService { - private static sessionManager = new SessionManager(); - - - static async getOrCreateSession(agentType: string, workspacePath: string, modelName?: string): Promise { - - const existingSessionId = this.sessionManager.getSession(agentType, workspacePath); - if (existingSessionId) { - logger.debug(`Using existing session: ${existingSessionId}`); - return existingSessionId; - } - - - logger.info(`Creating new session: ${agentType}`); - - try { - const response = await agentAPI.createSession({ - sessionName: `${agentType}-session-${Date.now()}`, - agentType, - workspacePath, - config: { - modelName, - enableTools: true, - safeMode: true, - autoCompact: true, - enableContextCompression: true, - } - }); - this.sessionManager.setSession(agentType, workspacePath, response.sessionId); - logger.info(`Session created: ${response.sessionId}`); - return response.sessionId; - } catch (error) { - logger.error('Failed to create session', error); - throw error; - } - } - - - static async executeAgentTaskStream( - request: AgentExecutionRequest, - callbacks: { - onModelRoundStart?: (event: any) => void; - onTextChunk?: (event: any) => void; - onToolCall?: (event: any) => void; - onToolResult?: (event: any) => void; - onToolConfirmation?: (event: any) => void; - onProgress?: (event: any) => void; - onComplete?: (event: any) => void; - onError?: (error: any) => void; - } - ): Promise { - logger.info('Executing agent task flow', { - agentType: request.agent_type, - hasContext: !!request.context - }); - - try { - - const workspacePath = request.workspace_path; - if (!workspacePath) { - throw new Error('Workspace path is required to start an agent task'); - } - const sessionId = await this.getOrCreateSession(request.agent_type, workspacePath, request.model_name); - - - const unlistenFunctions: Array<() => void> = []; - - - if (callbacks.onTextChunk) { - const unlisten = await agentAPI.onTextChunk((event) => { - if (event.sessionId === sessionId) { - callbacks.onTextChunk?.(event); - } - }); - unlistenFunctions.push(unlisten); - } - - - if (callbacks.onModelRoundStart) { - const unlisten = await agentAPI.onModelRoundStarted((event) => { - if (event.sessionId === sessionId) { - callbacks.onModelRoundStart?.(event); - } - }); - unlistenFunctions.push(unlisten); - } - - - if (callbacks.onToolCall || callbacks.onToolResult || callbacks.onToolConfirmation) { - const unlisten = await agentAPI.onToolEvent((event) => { - if (event.sessionId === sessionId) { - const toolEvent = event.toolEvent; - - - if (toolEvent.Started || toolEvent.EarlyDetected) { - callbacks.onToolCall?.(toolEvent); - } else if (toolEvent.Completed || toolEvent.Failed) { - callbacks.onToolResult?.(toolEvent); - } else if (toolEvent.ConfirmationNeeded) { - callbacks.onToolConfirmation?.(toolEvent); - } else if (toolEvent.Progress || toolEvent.StreamChunk) { - callbacks.onProgress?.(toolEvent); - } - } - }); - unlistenFunctions.push(unlisten); - } - - - if (callbacks.onComplete) { - const unlisten = await agentAPI.onDialogTurnCompleted((event) => { - if (event.sessionId === sessionId) { - callbacks.onComplete?.(event); - - unlistenFunctions.forEach(fn => fn()); - } - }); - unlistenFunctions.push(unlisten); - } - - - await agentAPI.startDialogTurn({ - sessionId, - userInput: request.prompt, - agentType: request.agent_type, - workspacePath, - }); - - - return sessionId; - } catch (error) { - logger.error('Agent task flow failed', error); - callbacks.onError?.(error); - throw error; - } - } - - - static async cancelAgentTask(taskId: string): Promise { - try { - await agentAPI.cancelSession(taskId); - logger.info(`Task cancelled: ${taskId}`); - } catch (error) { - logger.error('Failed to cancel task', error); - throw error; - } - } - - - static async getAgentHealth(agentType: AgentType): Promise<{ healthy: boolean; name: string; description: string }> { - return { - healthy: true, - name: this.getAgentDisplayName(agentType), - description: this.getAgentDescription(agentType) - }; - } - - - private static getAgentDisplayName(agentType: AgentType): string { - const nameMap: Record = { - 'project_qa': i18nService.t('common:agents.projectQa.name'), - 'requirement_clarification': i18nService.t('common:agents.requirementClarification.name'), - 'core': i18nService.t('common:agents.core.name') - }; - return nameMap[agentType] || agentType; - } - - - private static getAgentDescription(agentType: AgentType): string { - const descMap: Record = { - 'project_qa': i18nService.t('common:agents.projectQa.description'), - 'requirement_clarification': i18nService.t('common:agents.requirementClarification.description'), - 'core': i18nService.t('common:agents.core.description') - }; - return descMap[agentType] || i18nService.t('common:agents.general.description'); - } - - - - static requiresSpecialVisualization(agentType: AgentType, metadata?: Record): boolean { - if (agentType === 'requirement_clarification') { - - return !!(metadata?.interactive_sections && Array.isArray(metadata.interactive_sections)); - } - - return false; - } - - -} -export default AgentService; diff --git a/src/web-ui/src/infrastructure/services/index.ts b/src/web-ui/src/infrastructure/services/index.ts deleted file mode 100644 index be57530f1b..0000000000 --- a/src/web-ui/src/infrastructure/services/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Core services unified exports. - * - * Service layering: API → Business → Infrastructure - */ - -// API layer: external IO and data access -export * from './api/aiService'; - -// Business layer: domain logic and orchestration -export * from './business/agentService'; -export * from './business/workspaceManager'; - -// Infrastructure layer: low-level technical services -export * from './infra/contextManager'; diff --git a/src/web-ui/src/infrastructure/services/infra/contextManager.ts b/src/web-ui/src/infrastructure/services/infra/contextManager.ts deleted file mode 100644 index 9ee643ea48..0000000000 --- a/src/web-ui/src/infrastructure/services/infra/contextManager.ts +++ /dev/null @@ -1,136 +0,0 @@ - - -import { contextAPI } from '../../api'; -import { createLogger } from '@/shared/utils/logger'; - -const log = createLogger('ContextManager'); - - -export type { ContextStats, SessionMetadata, StorageStats } from '../../api/service-api/ContextAPI'; -import type { ContextStats, SessionMetadata, StorageStats } from '../../api/service-api/ContextAPI'; - -export class ContextManager { - - async compressContext(): Promise { - try { - return await contextAPI.compressContext(); - } catch (error) { - log.error('Failed to compress context', error); - throw new Error(`Context compression failed: ${error}`); - } - } - - - async getContextStats(): Promise { - try { - return await contextAPI.getContextStats(); - } catch (error) { - log.error('Failed to get context stats', error); - throw new Error(`Failed to get context stats: ${error}`); - } - } - - - async clearContext(): Promise { - try { - return await contextAPI.clearContext(); - } catch (error) { - log.error('Failed to clear context', error); - throw new Error(`Context clear failed: ${error}`); - } - } - - - async saveSessionData(sessionData: any): Promise { - try { - return await contextAPI.saveSessionData(sessionData); - } catch (error) { - log.error('Failed to save session data', error); - throw new Error(`Session save failed: ${error}`); - } - } - - - async loadSessionData(sessionId: string): Promise { - try { - return await contextAPI.loadSessionData(sessionId); - } catch (error) { - log.error('Failed to load session data', { sessionId, error }); - throw new Error(`Session load failed: ${error}`); - } - } - - - async listSessions(includeArchived: boolean = false): Promise { - try { - return await contextAPI.listSessions(includeArchived); - } catch (error) { - log.error('Failed to list sessions', { includeArchived, error }); - throw new Error(`Failed to list sessions: ${error}`); - } - } - - - async searchSessions(query: string, tags?: string[]): Promise { - try { - return await contextAPI.searchSessions(query, tags); - } catch (error) { - log.error('Failed to search sessions', { query, tags, error }); - throw new Error(`Failed to search sessions: ${error}`); - } - } - - - async deleteSession(sessionId: string): Promise { - try { - return await contextAPI.deleteSession(sessionId); - } catch (error) { - log.error('Failed to delete session', { sessionId, error }); - throw new Error(`Failed to delete session: ${error}`); - } - } - - - async archiveSession(sessionId: string): Promise { - try { - return await contextAPI.archiveSession(sessionId); - } catch (error) { - log.error('Failed to archive session', { sessionId, error }); - throw new Error(`Failed to archive session: ${error}`); - } - } - - - async exportSession(sessionId: string, exportPath: string): Promise { - try { - return await contextAPI.exportSession(sessionId, exportPath); - } catch (error) { - log.error('Failed to export session', { sessionId, exportPath, error }); - throw new Error(`Failed to export session: ${error}`); - } - } - - - async importSession(importPath: string): Promise { - try { - return await contextAPI.importSession(importPath); - } catch (error) { - log.error('Failed to import session', { importPath, error }); - throw new Error(`Failed to import session: ${error}`); - } - } - - - async getStorageStats(): Promise { - try { - return await contextAPI.getStorageStats(); - } catch (error) { - log.error('Failed to get storage stats', error); - throw new Error(`Failed to get storage stats: ${error}`); - } - } -} - - -export const contextManager = new ContextManager(); - diff --git a/src/web-ui/src/infrastructure/services/infra/index.ts b/src/web-ui/src/infrastructure/services/infra/index.ts deleted file mode 100644 index 592d8da905..0000000000 --- a/src/web-ui/src/infrastructure/services/infra/index.ts +++ /dev/null @@ -1,4 +0,0 @@ - - -export { default as contextManager } from './contextManager'; - diff --git a/src/web-ui/src/shared/context-menu-system/examples/FileTreeIntegrationExample.tsx b/src/web-ui/src/shared/context-menu-system/examples/FileTreeIntegrationExample.tsx deleted file mode 100644 index bd8d2f0ad8..0000000000 --- a/src/web-ui/src/shared/context-menu-system/examples/FileTreeIntegrationExample.tsx +++ /dev/null @@ -1,117 +0,0 @@ - - -import React, { useEffect } from 'react'; -import { initContextMenuSystem } from '../init'; - - - - - - -export function initializeFileTreeContextMenu() { - initContextMenuSystem({ - registerBuiltinCommands: true, - registerBuiltinProviders: true, - debug: process.env.NODE_ENV === 'development' - }); -} - - - - - - - - - - - -import { globalEventBus } from '../../../infrastructure/event-bus'; - -export function FileTreeEventHandler() { - useEffect(() => { - - const unsubOpen = globalEventBus.on('file:open', (data: any) => { - - }); - - - const unsubNewFile = globalEventBus.on('file:new-file', (data: any) => { - - - - - }); - - - const unsubNewFolder = globalEventBus.on('file:new-folder', (data: any) => { - - }); - - - const unsubRename = globalEventBus.on('file:rename', (data: any) => { - - - - - }); - - - const unsubDelete = globalEventBus.on('file:delete', (data: any) => { - - - - - }); - - - const unsubReveal = globalEventBus.on('file:reveal', (data: any) => { - - - }); - - - const unsubTerminal = globalEventBus.on('terminal:open-at-path', (data: any) => { - - }); - - - return () => { - unsubOpen(); - unsubNewFile(); - unsubNewFolder(); - unsubRename(); - unsubDelete(); - unsubReveal(); - unsubTerminal(); - }; - }, []); - - return null; -} - - - -export function FileTreeWithContextMenuExample() { - - useEffect(() => { - initializeFileTreeContextMenu(); - }, []); - - return ( -
- - - - - -
- ); -} - - - - - - - diff --git a/src/web-ui/src/shared/crypto/e2e-encryption.ts b/src/web-ui/src/shared/crypto/e2e-encryption.ts deleted file mode 100644 index 6e5fad1328..0000000000 --- a/src/web-ui/src/shared/crypto/e2e-encryption.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * End-to-end encryption for Remote Connect using Web Crypto API. - * - * Key exchange: X25519 ECDH (Chrome 113+, Safari 17+). - * Symmetric encryption: AES-256-GCM. - * - * For older browsers that lack X25519 support in Web Crypto, this module - * falls back to the @noble/curves library (must be installed separately). - */ - -const ALGO_AES = 'AES-GCM'; -const KEY_LENGTH = 256; -const NONCE_LENGTH = 12; - -// X25519 is available in Web Crypto starting Chrome 113 / Safari 17. -// We detect support at runtime and fall back to @noble/curves if needed. - -let _useNobleFallback: boolean | null = null; - -async function supportsWebCryptoX25519(): Promise { - if (_useNobleFallback !== null) return !_useNobleFallback; - try { - await crypto.subtle.generateKey( - { name: 'X25519' } as any, - true, - ['deriveKey'], - ); - _useNobleFallback = false; - return true; - } catch { - _useNobleFallback = true; - return false; - } -} - -// ── Key types ────────────────────────────────────────────────────── - -export interface E2EKeyPair { - publicKey: Uint8Array; - /** Opaque handle — either a CryptoKeyPair or noble private key bytes. */ - _internal: any; -} - -// ── Key generation ───────────────────────────────────────────────── - -export async function generateKeyPair(): Promise { - if (await supportsWebCryptoX25519()) { - return generateKeyPairWebCrypto(); - } - return generateKeyPairNoble(); -} - -async function generateKeyPairWebCrypto(): Promise { - const keyPair = await crypto.subtle.generateKey( - { name: 'X25519' } as any, - true, - ['deriveKey'], - ); - const rawPub = await crypto.subtle.exportKey('raw', (keyPair as any).publicKey); - return { - publicKey: new Uint8Array(rawPub), - _internal: keyPair, - }; -} - -async function generateKeyPairNoble(): Promise { - const { x25519 } = await import('@noble/curves/ed25519'); - const privateKey = crypto.getRandomValues(new Uint8Array(32)); - const publicKey = x25519.getPublicKey(privateKey); - return { - publicKey, - _internal: privateKey, - }; -} - -// ── Shared secret derivation ─────────────────────────────────────── - -export async function deriveSharedSecret( - keyPair: E2EKeyPair, - peerPublicKey: Uint8Array, -): Promise { - if (await supportsWebCryptoX25519()) { - return deriveSharedSecretWebCrypto(keyPair, peerPublicKey); - } - return deriveSharedSecretNoble(keyPair, peerPublicKey); -} - -async function deriveSharedSecretWebCrypto( - keyPair: E2EKeyPair, - peerPublicKey: Uint8Array, -): Promise { - const peerKey = await crypto.subtle.importKey( - 'raw', - peerPublicKey, - { name: 'X25519' } as any, - true, - [], - ); - return crypto.subtle.deriveKey( - { name: 'X25519', public: peerKey } as any, - (keyPair._internal as CryptoKeyPair).privateKey, - { name: ALGO_AES, length: KEY_LENGTH }, - false, - ['encrypt', 'decrypt'], - ); -} - -async function deriveSharedSecretNoble( - keyPair: E2EKeyPair, - peerPublicKey: Uint8Array, -): Promise { - const { x25519 } = await import('@noble/curves/ed25519'); - const sharedBytes = x25519.getSharedSecret(keyPair._internal as Uint8Array, peerPublicKey); - return crypto.subtle.importKey( - 'raw', - sharedBytes, - { name: ALGO_AES, length: KEY_LENGTH }, - false, - ['encrypt', 'decrypt'], - ); -} - -// ── Encrypt / Decrypt ────────────────────────────────────────────── - -export async function encrypt( - sharedKey: CryptoKey, - plaintext: string, -): Promise<{ data: string; nonce: string }> { - const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); - const encoded = new TextEncoder().encode(plaintext); - const ciphertext = await crypto.subtle.encrypt( - { name: ALGO_AES, iv: nonce }, - sharedKey, - encoded, - ); - return { - data: uint8ToBase64(new Uint8Array(ciphertext)), - nonce: uint8ToBase64(nonce), - }; -} - -export async function decrypt( - sharedKey: CryptoKey, - dataBase64: string, - nonceBase64: string, -): Promise { - const ciphertext = base64ToUint8(dataBase64); - const nonce = base64ToUint8(nonceBase64); - const plainBuffer = await crypto.subtle.decrypt( - { name: ALGO_AES, iv: nonce }, - sharedKey, - ciphertext, - ); - return new TextDecoder().decode(plainBuffer); -} - -// ── Public key encoding helpers ──────────────────────────────────── - -export function publicKeyToBase64(key: Uint8Array): string { - return uint8ToBase64(key); -} - -export function base64ToPublicKey(b64: string): Uint8Array { - return base64ToUint8(b64); -} - -// ── Base64 utilities ─────────────────────────────────────────────── - -function uint8ToBase64(bytes: Uint8Array): string { - let binary = ''; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -} - -function base64ToUint8(b64: string): Uint8Array { - const binary = atob(b64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; -} diff --git a/src/web-ui/src/shared/crypto/index.ts b/src/web-ui/src/shared/crypto/index.ts deleted file mode 100644 index 59632091c9..0000000000 --- a/src/web-ui/src/shared/crypto/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - generateKeyPair, - deriveSharedSecret, - encrypt, - decrypt, - publicKeyToBase64, - base64ToPublicKey, -} from './e2e-encryption'; -export type { E2EKeyPair } from './e2e-encryption'; From 7d555f73a473eed4b91cc5192244bf9fb021c8ce Mon Sep 17 00:00:00 2001 From: weishao Date: Mon, 24 Aug 2026 19:53:04 +0800 Subject: [PATCH 3/9] fix(web-ui): declare Peer-Device-Mode owners and close adapter fence gaps Address PR #2428 review (CHANGES_REQUESTED): - Declare LOCAL_ONLY owners for i18n/announcement/companion-pet/insights/ IDE-control/browser/webview/devtools/desktop-pet commands routed to peer without an owner; cross-device routing regressed controller app-shell state. - Add SIDE_EFFECTING_GET_COMMANDS so get_pending/get_announcement_tips (scheduler-mutating reads) are never auto-retried by the peer read path. - Add no-restricted-syntax ImportExpression selector to the ESLint fence so dynamic import('@tauri-apps/api/core') bypasses fail the build too. - Migrate all ~30 pre-existing dynamic-import sites to api.invoke (15 files); each command's peer-vs-local owner is declared to preserve behavior. - FileContextImpl: fs_exists -> check_path_exists (peer-routed, CLI-peer supported) so file-tree path checks resolve on the rendered surface. - PanelController: route report_ide_control_result success branch through api.invoke so both branches use the same LOCAL_ONLY transport. Verified: eslint src -> 0 errors; peer-device-adapter.test.ts 39/39 passed. Co-Authored-By: Claude --- src/web-ui/eslint.config.mjs | 19 +++++ src/web-ui/src/app/App.tsx | 7 +- .../AgentCompanionDesktopPet.tsx | 15 ++-- src/web-ui/src/app/layout/AppLayout.tsx | 8 +- .../app/scenes/agents/hooks/useAgentsList.ts | 4 +- .../browser/useEmbeddedBrowserWebview.ts | 20 ++--- .../profile/views/AssistantDefaultsPage.tsx | 4 +- .../app/services/agentCompanionPetCommands.ts | 8 +- .../tool-cards/ComputerUseToolCard.tsx | 4 +- .../flow_chat/tool-cards/TerminalToolCard.tsx | 4 +- .../api/adapters/peer-device-adapter.test.ts | 72 ++++++++++++++++ .../api/adapters/peer-device-adapter.ts | 85 +++++++++++++++++++ .../config/components/SessionConfig.tsx | 24 ++---- .../services/AgentCompanionWindowService.ts | 4 +- .../infrastructure/debug/useDebugInspector.ts | 7 +- .../flowChatDiagnosticsTransport.ts | 4 +- .../core/ProjectDetector.ts | 5 +- .../core/types/FileContextImpl.tsx | 4 +- .../services/ide-control/PanelController.ts | 23 ++--- .../tools/editor/components/CodeEditor.tsx | 7 +- 20 files changed, 241 insertions(+), 87 deletions(-) diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 56815d1f17..475d741296 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -18,6 +18,10 @@ export default tseslint.config( 'src/**/*.example.tsx', 'src/component-library/components/registry.tsx', 'src/component-library/preview/**', + // Pre-existing legacy: context-system type impls use class components + // with hooks and other legacy patterns. Kept out of lint to avoid + // unrelated churn; the dynamic-import fence still covers the rest of + // src/**. FileContextImpl.tsx here is migrated to api.invoke. 'src/shared/context-system/core/types/**', ], }, @@ -54,6 +58,21 @@ export default tseslint.config( ], }, ], + // no-restricted-imports only covers static ImportDeclaration in ESLint 9; + // dynamic `import('@tauri-apps/api/core')` to grab `invoke` bypasses it. + // Block the same surface with an ImportExpression selector so a future + // dynamic-import bypass fails the build too. Same ignores (adapters/** + + // PeerHostInvokeBridge) apply via this block's ignores; exceptions must be + // added with an owner comment, like the static rule. + 'no-restricted-syntax': [ + 'error', + { + selector: "ImportExpression[source.value='@tauri-apps/api/core']", + message: + '业务命令必须经 api.invoke(ApiClient) 统一适配层,不可动态 import invoke。' + + '如需直连平台 invoke,放到 adapters/ 内并经 api 暴露。', + }, + ], }, }, { diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 889bd4b6ff..b2fd17dd40 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -14,6 +14,7 @@ import { SessionUsageModal } from '../flow_chat/components/usage/SessionUsageMod import { createLogger } from '@/shared/utils/logger'; import { startupTrace } from '@/shared/utils/startupTrace'; import { isTauriRuntime } from '@/infrastructure/runtime'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { useWorkspaceContext } from '../infrastructure/contexts/WorkspaceContext'; import { useGlobalSceneShortcuts } from './hooks/useGlobalSceneShortcuts'; import { useDebugInspector } from '@/infrastructure/debug/useDebugInspector'; @@ -225,8 +226,7 @@ function App() { mainWindowShownRef.current = true; try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); log.debug('Main window shown', { reason }); startupTrace.markPhase('main_window_shown', { reason }); window.dispatchEvent(new CustomEvent('bitfun:main-window-shown', { detail: { reason } })); @@ -663,8 +663,7 @@ function App() { await openAgentCompanionSession(sessionId); try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to show main window from Agent companion bubble', { sessionId, diff --git a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx index 3f82a5dfa3..4f0e21e6e5 100644 --- a/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx +++ b/src/web-ui/src/app/components/AgentCompanionDesktopPet/AgentCompanionDesktopPet.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { emit, listen } from '@tauri-apps/api/event'; import { cursorPosition, getCurrentWindow } from '@tauri-apps/api/window'; import { aiExperienceConfigService, type AgentCompanionPetSelection, type AIExperienceSettings } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { ChatInputPixelPet, type ChatInputPixelPetMood } from '@/flow_chat/components/ChatInputPixelPet'; import type { ChatInputPetMood } from '@/flow_chat/utils/chatInputPetMood'; import type { @@ -412,11 +413,10 @@ export const AgentCompanionDesktopPet: React.FC = () => { return; } - void import('@tauri-apps/api/core') - .then(({ invoke }) => invoke('resize_agent_companion_desktop_pet', { + void api.invoke('resize_agent_companion_desktop_pet', { width: nextWidth, height: nextHeight, - })) + }) .catch(error => { log.warn('Failed to resize Agent companion window', error); }); @@ -568,8 +568,7 @@ export const AgentCompanionDesktopPet: React.FC = () => { const showMainWindowFromPet = useCallback(async () => { try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to show main window from Agent companion pet', error); } @@ -824,12 +823,8 @@ export const AgentCompanionDesktopPet: React.FC = () => { const openTaskSession = async (task: AgentCompanionTaskStatus) => { try { - const [{ invoke }, { emit }] = await Promise.all([ - import('@tauri-apps/api/core'), - import('@tauri-apps/api/event'), - ]); await emit('agent-companion://open-session', { sessionId: task.sessionId }); - await invoke('show_main_window'); + await api.invoke('show_main_window'); } catch (error) { log.warn('Failed to open Agent companion task session', { sessionId: task.sessionId, diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 32d94bd012..e0e98bc122 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -37,6 +37,7 @@ import { useSessionModeStore } from '../stores/sessionModeStore'; import { isMacOSDesktopRuntime } from '@/infrastructure/runtime'; import { flowChatSessionConfigForWorkspace } from '../utils/projectSessionWorkspace'; import { notificationService } from '@/shared/notification-system'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { AppearanceBackgroundMediaLayer, appearanceRuntime, useAppearance } from '@/infrastructure/appearance'; import './AppLayout.scss'; @@ -445,10 +446,7 @@ const AppLayout: React.FC = ({ className = '' }) => { try { // Both macOS and Windows/Linux: Rust intercepts the native close request // and emits this event. We decide hide vs quit; persist interrupted turns only on quit. - const [{ listen }, { invoke }] = await Promise.all([ - import('@tauri-apps/api/event'), - import('@tauri-apps/api/core'), - ]); + const { listen } = await import('@tauri-apps/api/event'); const persistInterruptedTurnsForExit = async () => { try { @@ -466,7 +464,7 @@ const AppLayout: React.FC = ({ className = '' }) => { if (isMacOS) { // macOS always hides to keep the app alive in the dock. try { - await invoke('hide_main_window_after_close_request'); + await api.invoke('hide_main_window_after_close_request'); } catch (error) { log.error('Failed to hide main window after close request', error); } diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index 70ec6cb3a5..d20c573f57 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { TFunction } from 'i18next'; import { agentAPI, type ModeInfo } from '@/infrastructure/api/service-api/AgentAPI'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { AgentSource } from '@/infrastructure/api/service-api/CustomAgentAPI'; import { SubagentAPI, type SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; @@ -183,8 +184,7 @@ export function useAgentsList({ const fetchTools = async (): Promise => { try { - const { invoke } = await import('@tauri-apps/api/core'); - return await invoke('get_all_tools_info'); + return await api.invoke('get_all_tools_info'); } catch { return []; } diff --git a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts index 57698ca972..d2c343a666 100644 --- a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts +++ b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { BLANK_TARGET_INTERCEPT_SCRIPT } from './browserInspectorScript'; import { STREAM_RENDER_OPTIMIZATION_SCRIPT } from './browserStreamPerformanceScript'; import { validateUrl } from './browserUrlCheck'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; const WEBVIEW_RESIZE_DEBOUNCE_MS = 160; const WEBVIEW_BOUNDS_EPSILON = 1; @@ -109,8 +110,7 @@ function normalizeUrl(raw: string, defaultUrl: string): string { } async function evalWebview(label: string, script: string): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('browser_webview_eval', { request: { label, script } }); + await api.invoke('browser_webview_eval', { request: { label, script } }); } async function injectBrowserPageScripts(label: string): Promise { @@ -118,18 +118,15 @@ async function injectBrowserPageScripts(label: string): Promise { } async function navigateWebview(label: string, url: string): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('browser_webview_navigate', { request: { label, url } }); + await api.invoke('browser_webview_navigate', { request: { label, url } }); } async function reloadWebview(label: string): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('browser_webview_reload', { request: { label } }); + await api.invoke('browser_webview_reload', { request: { label } }); } async function setWebviewBounds(label: string, bounds: WebviewBounds): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('browser_webview_set_bounds', { + await api.invoke('browser_webview_set_bounds', { request: { label, x: bounds.left, @@ -141,11 +138,8 @@ async function setWebviewBounds(label: string, bounds: WebviewBounds): Promise { - const [{ invoke }, { Webview }] = await Promise.all([ - import('@tauri-apps/api/core'), - import('@tauri-apps/api/webview'), - ]); - await invoke('browser_webview_create', { + const { Webview } = await import('@tauri-apps/api/webview'); + await api.invoke('browser_webview_create', { request: { label, url, diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx index 8be42bf7ee..7c907082ed 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx @@ -14,6 +14,7 @@ import { GalleryZone } from '@/app/components'; import '@/app/components/GalleryLayout/GalleryLayout.scss'; import { Switch } from '@/component-library'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { AgentProfileConfigItem, ModeSkillInfo } from '@/infrastructure/config/types'; import { buildSkillCoverageSourceMap, @@ -183,10 +184,9 @@ const AssistantDefaultsPage: React.FC = () => { (async () => { setLoading(true); try { - const { invoke } = await import('@tauri-apps/api/core'); const [modeConf, tools, skillList, servers] = await Promise.all([ configAPI.getAgentProfileConfig(ASSISTANT_MODE_ID).catch(() => null as AgentProfileConfigItem | null), - invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), + api.invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), configAPI.getModeSkillConfigs({ modeId: ASSISTANT_MODE_ID }).catch(() => [] as ModeSkillInfo[]), MCPAPI.getServers().catch(() => [] as MCPServerInfo[]), ]); diff --git a/src/web-ui/src/app/services/agentCompanionPetCommands.ts b/src/web-ui/src/app/services/agentCompanionPetCommands.ts index f8fae0d492..d9ae05c8a2 100644 --- a/src/web-ui/src/app/services/agentCompanionPetCommands.ts +++ b/src/web-ui/src/app/services/agentCompanionPetCommands.ts @@ -1,6 +1,7 @@ import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; import { FlowChatStore } from '@/flow_chat/store/FlowChatStore'; import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { createLogger } from '@/shared/utils/logger'; const log = createLogger('AgentCompanionPetCommands'); @@ -46,12 +47,9 @@ async function closeAgentCompanionDesktopPet(): Promise { } async function openAgentCompanionPetSettings(): Promise { - const [{ quickActions }, { invoke }] = await Promise.all([ - import('@/shared/services/ide-control'), - import('@tauri-apps/api/core'), - ]); + const { quickActions } = await import('@/shared/services/ide-control'); quickActions.openSettings('session-personalization'); - await invoke('show_main_window'); + await api.invoke('show_main_window'); log.info('Agent companion settings opened from pet context menu'); } diff --git a/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx index 7938240494..7dd8baa1cc 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ComputerUseToolCard.tsx @@ -20,6 +20,7 @@ import { import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; import { ToolCardStatusSlot } from './ToolCardStatusSlot'; @@ -91,8 +92,7 @@ function isPermissionDeniedError(message: string | null): boolean { } async function openComputerUseSettings(pane: 'accessibility' | 'screen_capture'): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('computer_use_open_system_settings', { request: { pane } }); + await api.invoke('computer_use_open_system_settings', { request: { pane } }); } /** Groups the ~40 ComputerUse actions into a handful of recognizable icons instead of one icon per action. */ diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index a0c95b46aa..591f194d2f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -29,6 +29,7 @@ import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; import { ToolCardCopyAction, ToolCardHeaderActions } from './ToolCardHeaderActions'; import { CopyableTextPreview } from '../components/CopyableTextPreview'; import { formatSessionViewPreviewText } from '../utils/sessionViewPreview'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import './TerminalToolCard.scss'; const log = createLogger('TerminalToolCard'); @@ -420,8 +421,7 @@ export const TerminalToolCard: React.FC = ({ setInterruptRequested(true); try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('cancel_tool', { + await api.invoke('cancel_tool', { request: { toolUseId, reason: 'User cancelled', diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index 522c3dfd1b..8f5ee829b4 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -42,6 +42,70 @@ describe('isPeerLocalOnlyCommand', () => { it('keeps native main-window geometry control on the controller computer', () => { expect(isPeerLocalOnlyCommand('set_main_window_transient_geometry')).toBe(true); }); + + it('keeps controller app-shell locale on the controller device', () => { + expect(isPeerLocalOnlyCommand('i18n_get_current_language')).toBe(true); + expect(isPeerLocalOnlyCommand('i18n_set_language')).toBe(true); + expect(isPeerLocalOnlyCommand('i18n_get_supported_languages')).toBe(true); + expect(isPeerLocalOnlyCommand('i18n_get_config')).toBe(true); + expect(isPeerLocalOnlyCommand('i18n_set_config')).toBe(true); + }); + + it('keeps announcement scheduler and state on the controller device', () => { + expect(isPeerLocalOnlyCommand('get_pending_announcements')).toBe(true); + expect(isPeerLocalOnlyCommand('get_announcement_tips')).toBe(true); + expect(isPeerLocalOnlyCommand('mark_announcement_seen')).toBe(true); + expect(isPeerLocalOnlyCommand('dismiss_announcement')).toBe(true); + expect(isPeerLocalOnlyCommand('never_show_announcement')).toBe(true); + expect(isPeerLocalOnlyCommand('trigger_announcement')).toBe(true); + }); + + it('keeps companion-pet import and preview on the controller device', () => { + expect(isPeerLocalOnlyCommand('list_agent_companion_pets')).toBe(true); + expect(isPeerLocalOnlyCommand('import_agent_companion_pet_package')).toBe(true); + expect(isPeerLocalOnlyCommand('delete_agent_companion_pet_package')).toBe(true); + }); + + it('keeps insights generation, progress and report on the controller device', () => { + expect(isPeerLocalOnlyCommand('generate_insights')).toBe(true); + expect(isPeerLocalOnlyCommand('get_latest_insights')).toBe(true); + expect(isPeerLocalOnlyCommand('load_insights_report')).toBe(true); + expect(isPeerLocalOnlyCommand('has_insights_data')).toBe(true); + expect(isPeerLocalOnlyCommand('cancel_insights_generation')).toBe(true); + }); + + it('keeps IDE control result reporting on the controller device', () => { + expect(isPeerLocalOnlyCommand('report_ide_control_result')).toBe(true); + }); + + it('keeps controller browser/webview/devtools/desktop-pet/diagnostics on the controller device', () => { + // These previously hit the local Tauri host via dynamic invoke(); routing + // them to a peer would regress (peer host does not implement them). + expect(isPeerLocalOnlyCommand('browser_control_launch')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_control_list_browsers')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_control_get_status')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_control_restart_with_cdp')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_control_enable_default_cdp')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_create')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_eval')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_navigate')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_reload')).toBe(true); + expect(isPeerLocalOnlyCommand('browser_webview_set_bounds')).toBe(true); + expect(isPeerLocalOnlyCommand('computer_use_get_status')).toBe(true); + expect(isPeerLocalOnlyCommand('debug_devtools_available')).toBe(true); + expect(isPeerLocalOnlyCommand('debug_open_devtools')).toBe(true); + expect(isPeerLocalOnlyCommand('resize_agent_companion_desktop_pet')).toBe(true); + expect(isPeerLocalOnlyCommand('show_agent_companion_desktop_pet')).toBe(true); + expect(isPeerLocalOnlyCommand('hide_agent_companion_desktop_pet')).toBe(true); + expect(isPeerLocalOnlyCommand('append_flow_chat_diagnostics')).toBe(true); + }); + + it('keeps file-tree path checks routed to the peer surface', () => { + // check_path_exists is the one CLI-Peer-supported routed command: the path + // comes from the rendered surface's file tree, so it must stay peer-routed. + expect(isPeerLocalOnlyCommand('check_path_exists')).toBe(false); + expect(peerInvokePriorityFor('check_path_exists')).toBe('high'); + }); }); describe('peerInvokePriorityFor', () => { @@ -109,6 +173,14 @@ describe('peerInvokePriorityFor', () => { expect(isPeerRetryableReadCommand('respond_permission')).toBe(false); }); + it('does not retry side-effecting announcement get_* commands', () => { + // These run the scheduler (mutate app_open_count + persist) and must never + // be auto-retried by the peer read path, where retries would multiply the + // side effect. + expect(isPeerRetryableReadCommand('get_pending_announcements')).toBe(false); + expect(isPeerRetryableReadCommand('get_announcement_tips')).toBe(false); + }); + it('retries only mutations with an explicit host idempotency identity', () => { expect(isPeerRetryableIdempotentMutation('start_dialog_turn', { request: { sessionId: 'session-1', turnId: 'turn-1' }, diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index a391a7b966..3de99b7ee2 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -126,6 +126,76 @@ const LOCAL_ONLY_COMMANDS = new Set([ 'speech_append_audio_chunk', 'speech_finish_input_session', 'speech_cancel_input_session', + // UI locale is controller app-shell state: it writes the controller's config + // file, rebuilds THIS machine's macOS menubar/tray, and drives the UI the user + // is looking at. Routing it to a peer both writes the wrong config and rebuilds + // the wrong machine's chrome (CLI peer returns unsupported). See PR #2428. + 'i18n_get_current_language', + 'i18n_set_language', + 'i18n_get_supported_languages', + 'i18n_get_config', + 'i18n_set_config', + // Announcement cards/scheduler are controller app-shell state. get_pending / + // get_tips trigger the scheduler (mutate app_open_count + persist); seen / + // dismiss / never-show write the controller's announcement state. CLI peer + // returns unsupported. Keeping them LOCAL_ONLY also removes them from peer + // read-retry (see SIDE_EFFECTING_GET_COMMANDS). See scheduler.rs run(). + 'get_pending_announcements', + 'get_announcement_tips', + 'mark_announcement_seen', + 'dismiss_announcement', + 'never_show_announcement', + 'trigger_announcement', + // Companion pets live on the controller's desktop. The import zip is picked by + // a local dialog on A; its absolute path only exists on A. Peer B cannot read + // it, and B's returned spritesheetPath is a B-absolute path A's plugin-fs + // cannot open. CLI peer returns unsupported at dispatch. Keeping these + // LOCAL_ONLY gates Peer Mode (invariant 10: download destinations stay on the + // controller). See PR #2428. + 'list_agent_companion_pets', + 'import_agent_companion_pet_package', + 'delete_agent_companion_pet_package', + // Insights is the controller's own usage report: it reads the controller's + // session history and writes the HTML to the controller's user_data_dir. In + // Peer Mode generate_insights would run on B but listenProgress listens on A, + // openReport opens B's absolute path on A, and the 30s mutation timeout fires + // while B keeps running. Keep it controller-local so report, progress event + // and openPath all land on one machine. (Cross-device insights tracking is + // out of scope; this LOCAL gate is the reviewer-asked fix.) See PR #2428. + 'generate_insights', + 'get_latest_insights', + 'load_insights_report', + 'has_insights_data', + 'cancel_insights_generation', + // IDE control events drive THIS window's panels (window.dispatchEvent). The + // listen is local; the result report must use the same transport on both + // success and error branches so a request never splits across hosts. CLI peer + // returns unsupported. See PR #2428. + 'report_ide_control_result', + // Controller app-shell / local-device commands reached by migrating dynamic + // invoke() sites behind the adapter fence. These previously hit the local + // Tauri host directly; routing them to a peer would be a regression (the peer + // host does not implement them, and they operate on the controller's own + // browser/webview/DevTools/desktop-pet/diagnostics). Declared LOCAL_ONLY so + // api.invoke keeps them on the controller. See PR #2428 (lint fence + dynamic + // import migration). + 'browser_control_launch', + 'browser_control_list_browsers', + 'browser_control_get_status', + 'browser_control_restart_with_cdp', + 'browser_control_enable_default_cdp', + 'browser_webview_create', + 'browser_webview_eval', + 'browser_webview_navigate', + 'browser_webview_reload', + 'browser_webview_set_bounds', + 'computer_use_get_status', + 'debug_devtools_available', + 'debug_open_devtools', + 'resize_agent_companion_desktop_pet', + 'show_agent_companion_desktop_pet', + 'hide_agent_companion_desktop_pet', + 'append_flow_chat_diagnostics', ]); /** @@ -226,7 +296,22 @@ export function isPeerLocalOnlyCommand(command: string): boolean { return LOCAL_ONLY_COMMANDS.has(command); } +/** + * `get_*` commands that run the announcement scheduler (mutate app_open_count + + * persist state). They are NOT side-effect-free reads and must never be + * auto-retried by the peer read path, where a retry would multiply the side + * effect. They are also LOCAL_ONLY, but this guard keeps the contract explicit + * if ownership ever moves back to the peer. See scheduler.rs run(). + */ +const SIDE_EFFECTING_GET_COMMANDS = new Set([ + 'get_pending_announcements', + 'get_announcement_tips', +]); + export function isPeerRetryableReadCommand(command: string): boolean { + if (SIDE_EFFECTING_GET_COMMANDS.has(command)) { + return false; + } return RETRYABLE_READ_COMMANDS.has(command) || command.startsWith('read_') || command.startsWith('list_') || diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index 285025c8e3..e4f25d758d 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -34,6 +34,7 @@ import { permissionConfigService, } from '../services/PermissionConfigService'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { useNotification, notificationService } from '@/shared/notification-system'; import type { DebugModeConfig, @@ -167,8 +168,7 @@ const SessionSettingsPanels: React.FC = ({ variant } if (!IS_TAURI_DESKTOP) return false; setComputerUseStatusLoading(true); try { - const { invoke } = await import('@tauri-apps/api/core'); - const s = await invoke('computer_use_get_status'); + const s = await api.invoke('computer_use_get_status'); setComputerUseEnabled(s.computerUseEnabled); setComputerUseAccess(s.accessibilityGranted); setComputerUseScreen(s.screenCaptureGranted); @@ -186,9 +186,8 @@ const SessionSettingsPanels: React.FC = ({ variant } if (!IS_TAURI_DESKTOP) return; setBrowserStatusLoading(true); try { - const { invoke } = await import('@tauri-apps/api/core'); const [s, browsers] = await Promise.all([ - invoke<{ + api.invoke<{ cdpAvailable: boolean; defaultCdpSupported: boolean; defaultCdpEnabled: boolean; @@ -198,7 +197,7 @@ const SessionSettingsPanels: React.FC = ({ variant } port: number; pageCount: number; }>('browser_control_get_status', { request: { port: 9222 } }), - invoke<{ options: BrowserControlBrowserOption[] }>('browser_control_list_browsers'), + api.invoke<{ options: BrowserControlBrowserOption[] }>('browser_control_list_browsers'), ]); setBrowserCdpAvailable(s.cdpAvailable); setBrowserDefaultCdpSupported(s.defaultCdpSupported); @@ -585,8 +584,7 @@ const SessionSettingsPanels: React.FC = ({ variant } // Screen Recording) the moment the user opts in, instead of waiting // for the first agent tool call to fail with a permission error. try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('computer_use_request_permissions'); + await api.invoke('computer_use_request_permissions'); } catch (permError) { log.warn('computer_use_request_permissions failed', permError); } @@ -603,8 +601,7 @@ const SessionSettingsPanels: React.FC = ({ variant } const handleComputerUseOpenSettings = async (pane: 'accessibility' | 'screen_capture') => { try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('computer_use_open_system_settings', { request: { pane } }); + await api.invoke('computer_use_open_system_settings', { request: { pane } }); } catch (error) { log.error('computer_use_open_system_settings failed', error); notificationService.error(t('messages.saveFailed')); @@ -683,8 +680,7 @@ const SessionSettingsPanels: React.FC = ({ variant } const handleBrowserControlLaunch = async () => { setBrowserControlBusy(true); try { - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke('browser_control_launch', { request: { port: 9222 } }); + const result = await api.invoke('browser_control_launch', { request: { port: 9222 } }); presentBrowserControlLaunchResult(result); await refreshBrowserControlStatus(); } catch (error) { @@ -707,8 +703,7 @@ const SessionSettingsPanels: React.FC = ({ variant } ), { duration: 12000 }, ); - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke( + const result = await api.invoke( 'browser_control_enable_default_cdp', { request: { port: 9222 } }, ); @@ -726,8 +721,7 @@ const SessionSettingsPanels: React.FC = ({ variant } if (!browserRestartPrompt) return; setBrowserControlBusy(true); try { - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke('browser_control_restart_with_cdp', { + const result = await api.invoke('browser_control_restart_with_cdp', { request: { port: 9222 }, }); if (result.success) { diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts index a5ec3657c9..0bcebef2f4 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts @@ -1,5 +1,6 @@ import { isTauriRuntime } from '@/infrastructure/runtime'; import { createLogger } from '@/shared/utils/logger'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import type { AIExperienceSettings } from './AIExperienceConfigService'; const log = createLogger('AgentCompanionWindowService'); @@ -36,8 +37,7 @@ export async function syncAgentCompanionDesktopWindow( command, displayMode: settings.agent_companion_display_mode, }); - const { invoke } = await import('@tauri-apps/api/core'); - await invoke(command); + await api.invoke(command); if (requestId !== companionDesktopWindowSyncRequestId) { return; } diff --git a/src/web-ui/src/infrastructure/debug/useDebugInspector.ts b/src/web-ui/src/infrastructure/debug/useDebugInspector.ts index 795f2e85b1..f4e37dda1e 100644 --- a/src/web-ui/src/infrastructure/debug/useDebugInspector.ts +++ b/src/web-ui/src/infrastructure/debug/useDebugInspector.ts @@ -10,6 +10,7 @@ */ import { useEffect } from 'react'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { createLogger } from '@/shared/utils/logger'; import { isTauriRuntime } from '@/infrastructure/runtime'; import { @@ -30,8 +31,7 @@ async function loadDevToolsAvailable(): Promise { if (!isTauriRuntime()) return false; try { - const { invoke } = await import('@tauri-apps/api/core'); - return await invoke('debug_devtools_available'); + return await api.invoke('debug_devtools_available'); } catch (error) { log.error('Failed to detect DevTools availability', error); return false; @@ -70,8 +70,7 @@ async function evalInPage(script: string): Promise { /** Open the native webview DevTools window. */ async function openNativeDevTools(): Promise { try { - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('debug_open_devtools'); + await api.invoke('debug_open_devtools'); log.info('Native DevTools opened'); } catch (error) { log.error('Failed to open native DevTools', error); diff --git a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts index c08c68e098..a1e71dcef7 100644 --- a/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts +++ b/src/web-ui/src/infrastructure/diagnostics/flowChatDiagnosticsTransport.ts @@ -1,4 +1,5 @@ import { isTauriRuntime } from '@/infrastructure/runtime'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; export interface FlowChatDiagnosticTransportEntry { sequence: number; @@ -17,8 +18,7 @@ export async function appendFlowChatDiagnosticEntries( return; } - const { invoke } = await import('@tauri-apps/api/core'); - await invoke('append_flow_chat_diagnostics', { + await api.invoke('append_flow_chat_diagnostics', { request: { entries }, }); } diff --git a/src/web-ui/src/infrastructure/language-detection/core/ProjectDetector.ts b/src/web-ui/src/infrastructure/language-detection/core/ProjectDetector.ts index 2ec9fa63e2..a2f20d5bfb 100644 --- a/src/web-ui/src/infrastructure/language-detection/core/ProjectDetector.ts +++ b/src/web-ui/src/infrastructure/language-detection/core/ProjectDetector.ts @@ -8,6 +8,7 @@ import type { ProjectDetectionPlugin } from '../types'; import { createLogger } from '@/shared/utils/logger'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; const log = createLogger('ProjectDetector'); @@ -230,9 +231,7 @@ class ProjectDetector { private async detectWithBackend(workspacePath: string): Promise { - const { invoke } = await import('@tauri-apps/api/core'); - - const backendResult = await invoke<{ + const backendResult = await api.invoke<{ languages: string[]; primaryLanguage?: string; fileCount: Record; diff --git a/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx b/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx index e1817b5e83..fa51e4d55d 100644 --- a/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx +++ b/src/web-ui/src/shared/context-system/core/types/FileContextImpl.tsx @@ -42,7 +42,9 @@ export class FileContextValidator implements ContextValidator<'file'> { async validate(context: FileContext): Promise { try { - const exists = await api.invoke('fs_exists', { path: context.filePath }); + const exists = await api.invoke('check_path_exists', { + request: { path: context.filePath }, + }); if (!exists) { return { diff --git a/src/web-ui/src/shared/services/ide-control/PanelController.ts b/src/web-ui/src/shared/services/ide-control/PanelController.ts index 81e3580424..9c673591bc 100644 --- a/src/web-ui/src/shared/services/ide-control/PanelController.ts +++ b/src/web-ui/src/shared/services/ide-control/PanelController.ts @@ -4,6 +4,7 @@ * Implements a subset of IDE control operations focused on opening/closing panels. */ import { i18nService } from '@/infrastructure/i18n'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { IdeController, IdeControlEvent, @@ -237,17 +238,17 @@ export class PanelController implements IdeController { private sendExecutionResult(requestId: string, success: boolean, message: string): void { - - import('@tauri-apps/api/core').then(({ invoke }) => { - invoke('report_ide_control_result', { - request_id: requestId, - success, - message: success ? message : undefined, - error: success ? undefined : message, - timestamp: Date.now(), - }).catch((error) => { - log.error('Failed to send execution result', error); - }); + // Route through the shared adapter so the success branch uses the same + // transport as the error branch in IdeControlEventBus. report_ide_control_result + // is LOCAL_ONLY, so both branches settle on the controller's local host. + api.invoke('report_ide_control_result', { + request_id: requestId, + success, + message: success ? message : undefined, + error: success ? undefined : message, + timestamp: Date.now(), + }).catch((error) => { + log.error('Failed to send execution result', error); }); } } diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index 3eba368dc7..3eef819def 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -24,6 +24,7 @@ import { createLogger } from '@/shared/utils/logger'; import { sendDebugProbe } from '@/shared/utils/debugProbe'; import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { isSamePath } from '@/shared/utils/pathUtils'; +import { api } from '@/infrastructure/api/service-api/ApiClient'; import { isPeerDeviceModeActive, PEER_MODE_FILE_SYNC_POLL_MS, @@ -1813,7 +1814,6 @@ const CodeEditor: React.FC = ({ return; } - const { invoke } = await import('@tauri-apps/api/core'); const fileInfo = await fetchFileMetadata(); if (isFileMissingFromMetadata(fileInfo)) { outcome = 'missing-on-disk'; @@ -1841,7 +1841,7 @@ const CodeEditor: React.FC = ({ const bufferBeforeRead = modelRef.current?.getValue(); try { - const hashRes: any = await invoke('get_file_editor_sync_hash', { + const hashRes: any = await api.invoke('get_file_editor_sync_hash', { request: { path: filePath }, }); const diskHash = @@ -2166,10 +2166,9 @@ const CodeEditor: React.FC = ({ try { const { workspaceAPI } = await import('@/infrastructure/api'); - const { invoke } = await import('@tauri-apps/api/core'); const bufferBeforeRead = modelRef.current?.getValue(); try { - const hashRes: any = await invoke('get_file_editor_sync_hash', { + const hashRes: any = await api.invoke('get_file_editor_sync_hash', { request: { path: filePath }, }); const diskHash = From 3d5279c6aa81baa4befe69464ae7a5b372f1a2de Mon Sep 17 00:00:00 2001 From: weishao Date: Mon, 24 Aug 2026 20:33:24 +0800 Subject: [PATCH 4/9] fix(peer-host): mirror controller-owned commands into peer deny lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core-boundaries check enforces a one-way ownership boundary: every command the FE adapter marks LOCAL_ONLY must also be refused by each peer host, because an older or non-Web-UI controller can still HostInvoke them. The previous commit added i18n/announcement/companion-pet/insights/IDE- control/browser/webview/devtools/desktop-pet/diagnostics commands to the FE deny list but not to the desktop and CLI peer-host deny lists, so CI's "Check core boundaries" step failed. Add the 37 controller-owned commands to both src/apps/desktop/src/api/peer_host_invoke.rs and src/apps/cli/src/peer_host/deny.rs, grouped with owner comments mirroring the FE adapter. Being unimplemented on the CLI peer is not the boundary — they are refused explicitly. Verified: check-core-boundaries -> passed; check-core-boundaries.test -> 126/126; cargo test -p bitfun-desktop peer_host -> 6/6; cargo test --bin bitfun peer_host -> 79/79. Co-Authored-By: Claude --- src/apps/cli/src/peer_host/deny.rs | 44 ++++++++++++++++ src/apps/desktop/src/api/peer_host_invoke.rs | 55 ++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 16701bec1c..2cfa80db8a 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -113,6 +113,50 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ // belongs to the person at this machine, so refuse it explicitly rather // than relying on the command being unimplemented here. "git_trust_repository", + // Controller app-shell state mirrored from the FE deny list. An older or + // non-Web-UI controller can still HostInvoke these onto this peer, so the + // CLI peer host must refuse them independently of the FE optimization. + // Keep in sync with `src/web-ui/.../adapters/peer-device-adapter.ts` + // LOCAL_ONLY_COMMANDS and `src/apps/desktop/src/api/peer_host_invoke.rs`. + // These controller-owned commands are not implemented here either, but + // being unimplemented is not the boundary — refuse explicitly. + "i18n_get_current_language", + "i18n_set_language", + "i18n_get_supported_languages", + "i18n_get_config", + "i18n_set_config", + "get_pending_announcements", + "get_announcement_tips", + "mark_announcement_seen", + "dismiss_announcement", + "never_show_announcement", + "trigger_announcement", + "list_agent_companion_pets", + "import_agent_companion_pet_package", + "delete_agent_companion_pet_package", + "generate_insights", + "get_latest_insights", + "load_insights_report", + "has_insights_data", + "cancel_insights_generation", + "report_ide_control_result", + "browser_control_launch", + "browser_control_list_browsers", + "browser_control_get_status", + "browser_control_restart_with_cdp", + "browser_control_enable_default_cdp", + "browser_webview_create", + "browser_webview_eval", + "browser_webview_navigate", + "browser_webview_reload", + "browser_webview_set_bounds", + "computer_use_get_status", + "debug_devtools_available", + "debug_open_devtools", + "resize_agent_companion_desktop_pet", + "show_agent_companion_desktop_pet", + "hide_agent_companion_desktop_pet", + "append_flow_chat_diagnostics", ]; /// Desktop IDE surfaces that CLI Peer Host does not implement. diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index fcc2800946..ba793ae92f 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -145,6 +145,61 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ // That decision stays with the person at that machine; a controller can // still read `git_get_repository_trust` and relay the manual command. "git_trust_repository", + // Controller app-shell state mirrored from the FE deny list. An older or + // non-Web-UI controller can still HostInvoke these onto this peer, so the + // peer host must refuse them independently of the FE optimization. Keep in + // sync with `src/web-ui/.../adapters/peer-device-adapter.ts` + // LOCAL_ONLY_COMMANDS and `src/apps/cli/src/peer_host/deny.rs`. + // UI locale writes the controller's config and rebuilds THIS machine's + // macOS menubar/tray; routing it to a peer writes the wrong config. + "i18n_get_current_language", + "i18n_set_language", + "i18n_get_supported_languages", + "i18n_get_config", + "i18n_set_config", + // Announcement scheduler/state: get_pending / get_tips run the scheduler + // (mutate app_open_count + persist); seen / dismiss / never-show write + // controller announcement state. Refused on the peer. + "get_pending_announcements", + "get_announcement_tips", + "mark_announcement_seen", + "dismiss_announcement", + "never_show_announcement", + "trigger_announcement", + // Companion pets live on the controller's desktop; the import zip path is + // picked by a local dialog on the controller and is not readable here. + "list_agent_companion_pets", + "import_agent_companion_pet_package", + "delete_agent_companion_pet_package", + // Insights is the controller's own usage report: it reads the controller's + // session history and writes the HTML to the controller's user_data_dir. + "generate_insights", + "get_latest_insights", + "load_insights_report", + "has_insights_data", + "cancel_insights_generation", + // IDE control events drive the controller window's panels; the result + // report must settle on the controller's transport, not here. + "report_ide_control_result", + // Controller app-shell / local-device commands (browser/webview/DevTools/ + // desktop-pet/diagnostics) operate on the controller's own surfaces. + "browser_control_launch", + "browser_control_list_browsers", + "browser_control_get_status", + "browser_control_restart_with_cdp", + "browser_control_enable_default_cdp", + "browser_webview_create", + "browser_webview_eval", + "browser_webview_navigate", + "browser_webview_reload", + "browser_webview_set_bounds", + "computer_use_get_status", + "debug_devtools_available", + "debug_open_devtools", + "resize_agent_companion_desktop_pet", + "show_agent_companion_desktop_pet", + "hide_agent_companion_desktop_pet", + "append_flow_chat_diagnostics", ]; static PENDING: OnceLock>>> = From 35e534cfc4e0602a80f4d15c3b93e72e8e92215a Mon Sep 17 00:00:00 2001 From: weishao Date: Mon, 24 Aug 2026 21:17:53 +0800 Subject: [PATCH 5/9] fix(web-ui): point migrated tests at the ApiClient mock surface The dynamic-import migration moved useDebugInspector and agentCompanionPetCommands off `@tauri-apps/api/core` and onto `api.invoke`, but their tests still mocked `@tauri-apps/api/core`, so the mock never intercepted the call. CI "Run web UI tests" failed: - useDebugInspector.test.tsx: expected mocks.invoke to be called with 'debug_devtools_available' but it was called 0 times. - agentCompanionPetCommands.test.ts: api.invoke('show_main_window') hit the real ApiClient (WebSocket connection failed). Repoint both tests at `@/infrastructure/api/service-api/ApiClient` (the established pattern) and flush the post-invoke microtask in useDebugInspector before dispatching keys, since the keydown listener now registers right after the (synchronous) api.invoke resolves rather than after the old dynamic-import microtask. Verified: useDebugInspector 4/4, agentCompanionPetCommands 7/7. Co-Authored-By: Claude --- .../app/services/agentCompanionPetCommands.test.ts | 6 ++++-- .../infrastructure/debug/useDebugInspector.test.tsx | 11 +++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts b/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts index 0230192476..7dde5dbe95 100644 --- a/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts +++ b/src/web-ui/src/app/services/agentCompanionPetCommands.test.ts @@ -9,8 +9,10 @@ const saveSettingsMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); const openSettingsMock = vi.hoisted(() => vi.fn()); const invokeMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); -vi.mock('@tauri-apps/api/core', () => ({ - invoke: invokeMock, +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { + invoke: invokeMock, + }, })); vi.mock('@/flow_chat/services/FlowChatManager', () => ({ diff --git a/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx b/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx index f9ecdff6f9..29e06ff7ed 100644 --- a/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx +++ b/src/web-ui/src/infrastructure/debug/useDebugInspector.test.tsx @@ -11,8 +11,10 @@ const mocks = vi.hoisted(() => ({ invoke: vi.fn(), })); -vi.mock('@tauri-apps/api/core', () => ({ - invoke: mocks.invoke, +vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ + api: { + invoke: mocks.invoke, + }, })); vi.mock('./mainWindowInspector', () => ({ @@ -91,6 +93,10 @@ describe('useDebugInspector', () => { root.render(); }); await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith('debug_devtools_available')); + // loadDevToolsAvailable awaits api.invoke before registering the keydown + // listener; flush the effect's microtask so the handler is attached + // before we dispatch. + await act(async () => { await Promise.resolve(); }); mocks.invoke.mockClear(); const event = dispatchKey({ key: 'F12' }); @@ -105,6 +111,7 @@ describe('useDebugInspector', () => { root.render(); }); await vi.waitFor(() => expect(mocks.invoke).toHaveBeenCalledWith('debug_devtools_available')); + await act(async () => { await Promise.resolve(); }); mocks.invoke.mockClear(); const event = dispatchKey({ key: 'i', ctrlKey: true, shiftKey: true }); From 3c9f5a86b3e563f5466a59f98c84135c6798d986 Mon Sep 17 00:00:00 2001 From: weishao Date: Tue, 25 Aug 2026 14:12:30 +0800 Subject: [PATCH 6/9] fix(web-ui): add CLI peer routes for cancel_tool + tool catalog and harden adapter fence Address PR #2428 review round 3 (issues #1-#3): 1. CLI peer host lacked a `cancel_tool` route. Expose `cancel_tool` on `CoreAgentRuntimeCompatibility` (delegates to the coordinator), add the CLI dialog handler + dispatch branch, and advertise the `cancel_tool` capability in both desktop and CLI `peer_mode_ping`. 2. CLI peer host lacked a `get_all_tools_info` route and the tool-info DTO was duplicated between desktop and core. Add a shared `ToolInfoDto` + `build_tool_info`/`build_all_tools_info` in core (backed by the global tool registry), a thin CLI `tools` handler + dispatch branch, and the `tool_catalog` capability in both `peer_mode_ping` surfaces. Desktop's `tool_api` now aliases the core DTO instead of redefining it. 3. The ESLint adapter fence leaked: a global `ignores` entry for `src/shared/context-system/core/types/**` let direct/dynamic `invoke` imports pass lint there. Drop the blanket ignore and add a targeted override that exempts only `react-hooks/rules-of-hooks`; the fence rules now apply. Add a config-level regression test pinning that both the ordinary business dir and the context-system types dir block invoke, while the adapter exception still permits it. Frontend: propagate `cancelTool`/`toolCatalog` capabilities through the peer-device snapshot/context, gate the Terminal interrupt button and the tool-catalog fetches (useAgentsList, AssistantDefaultsPage) on host support; default to allowed when capabilities are unknown to avoid flicker. Co-Authored-By: Claude --- src/apps/cli/src/peer_host/commands/dialog.rs | 24 ++++ src/apps/cli/src/peer_host/commands/mod.rs | 15 +++ src/apps/cli/src/peer_host/commands/tools.rs | 17 +++ src/apps/cli/src/peer_host/control.rs | 7 ++ src/apps/cli/src/peer_host/dispatch.rs | 32 +++++ src/apps/desktop/src/api/peer_host_invoke.rs | 18 +++ src/apps/desktop/src/api/tool_api.rs | 77 ++---------- .../core/src/agentic/tools/product_runtime.rs | 1 + .../agentic/tools/product_runtime/catalog.rs | 70 ++++++++++- .../assembly/core/src/product_runtime.rs | 15 +++ src/web-ui/eslint.config.mjs | 18 ++- src/web-ui/eslint.fence.regression.test.ts | 113 ++++++++++++++++++ .../app/scenes/agents/hooks/useAgentsList.ts | 26 +++- .../profile/views/AssistantDefaultsPage.tsx | 28 ++++- .../flow_chat/tool-cards/TerminalToolCard.tsx | 18 +++ .../tool-cards/terminalToolCardState.test.ts | 30 +++++ .../tool-cards/terminalToolCardState.ts | 13 +- .../peer-device/PeerConnectionManager.ts | 10 ++ .../peer-device/PeerDeviceContext.tsx | 27 +++-- .../PeerDeviceSurfaceController.test.ts | 2 + .../PeerDeviceSurfaceController.ts | 1 + .../peer-device/peerDeviceContextState.ts | 13 ++ 22 files changed, 491 insertions(+), 84 deletions(-) create mode 100644 src/apps/cli/src/peer_host/commands/tools.rs create mode 100644 src/web-ui/eslint.fence.regression.test.ts diff --git a/src/apps/cli/src/peer_host/commands/dialog.rs b/src/apps/cli/src/peer_host/commands/dialog.rs index 801dc61f7d..2b963b893d 100644 --- a/src/apps/cli/src/peer_host/commands/dialog.rs +++ b/src/apps/cli/src/peer_host/commands/dialog.rs @@ -224,6 +224,30 @@ pub(crate) async fn cancel_dialog_turn( Ok(json!({ "success": true })) } +/// Cancel a single running tool execution on this host. +/// +/// The controller renders Terminal cards for Turns this host owns, including +/// the Interrupt button. Without this handler the `cancel_tool` HostInvoke +/// command fell into the unsupported dispatch branch: the controller restored +/// the button and logged an error while the target command kept running here. +/// This reaches the Core-owned coordinator via the same compatibility surface +/// the Desktop `cancel_tool` Tauri command uses — one level finer than +/// `cancel_dialog_turn`. +pub(crate) async fn cancel_tool( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let tool_use_id = get_string(request, "toolUseId")?; + let reason = optional_string(request, "reason") + .unwrap_or_else(|| "User cancelled".to_string()); + state + .compatibility + .cancel_tool(&tool_use_id, reason) + .await?; + Ok(json!({ "success": true })) +} + #[cfg(test)] mod tests { use serde_json::json; diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 3435aeed39..054bb505f5 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -10,6 +10,7 @@ mod session; mod snapshot; mod soft; mod system; +mod tools; mod workspace; use serde_json::Value; @@ -69,6 +70,14 @@ pub(crate) async fn dispatch( "check_path_exists" => filesystem::check_path_exists(args).await, "create_directory" => filesystem::create_directory(state, args).await, + // Tools catalog — read-only tool listing for Agents / Assistant + // Defaults UI. CLI Host assembles the same Core tool registry as + // Desktop and returns the identical DTO shape, so a controller cannot + // tell "CLI Host doesn't support catalog query" from "the runtime + // really has no tools". Without this the call fell into the unsupported + // dispatch branch and the UI silently rendered an empty tool list. + "get_all_tools_info" => tools::get_all_tools_info().await, + // Sessions "list_persisted_sessions" => session::list_persisted_sessions(state, args).await, "list_persisted_sessions_page" => session::list_persisted_sessions_page(state, args).await, @@ -101,6 +110,12 @@ pub(crate) async fn dispatch( // Dialog / tools "start_dialog_turn" => dialog::start_dialog_turn(state, args).await, "cancel_dialog_turn" => dialog::cancel_dialog_turn(state, args).await, + // Per-tool interrupt. The controller renders Terminal cards for Turns + // this host owns, so it must be able to stop a running tool here — + // same owner as cancel_dialog_turn, one level finer. Reaches the Core + // coordinator via the compatibility surface both CLI and Desktop Peer + // Hosts share. + "cancel_tool" => dialog::cancel_tool(state, args).await, "list_pending_permission_requests" => permission::list_pending_permission_requests(state), "subscribe_permission_requests" => permission::subscribe_permission_requests(), "respond_permission" => permission::respond_permission(state, args).await, diff --git a/src/apps/cli/src/peer_host/commands/tools.rs b/src/apps/cli/src/peer_host/commands/tools.rs new file mode 100644 index 0000000000..24db666e0f --- /dev/null +++ b/src/apps/cli/src/peer_host/commands/tools.rs @@ -0,0 +1,17 @@ +//! Tools HostInvoke handlers for CLI Peer Host. + +use serde_json::Value; + +use bitfun_core::agentic::tools::product_runtime::build_all_tools_info; + +/// Read-only tool catalog for the Agents / Assistant Defaults UI. +/// +/// CLI Host assembles the same Core tool registry as Desktop; this returns the +/// identical DTO shape so a controller cannot tell "CLI Host doesn't support +/// catalog query" from "the runtime really has no tools". Without this, the +/// controller's `get_all_tools_info` call would fall into the unsupported +/// dispatch branch and the UI would silently render an empty tool list. +pub(crate) async fn get_all_tools_info() -> Result { + let tools = build_all_tools_info().await; + serde_json::to_value(tools).map_err(|error| format!("Failed to serialize tool info: {error}")) +} diff --git a/src/apps/cli/src/peer_host/control.rs b/src/apps/cli/src/peer_host/control.rs index c03c4ddb86..ecf79f6c7e 100644 --- a/src/apps/cli/src/peer_host/control.rs +++ b/src/apps/cli/src/peer_host/control.rs @@ -104,6 +104,13 @@ pub(crate) fn peer_mode_ping_value() -> Value { "idempotent_dialog_submit": true, "targeted_session_rollback": true, "token_usage_statistics": true, + // Per-tool interrupt and read-only tool catalog are implemented on + // this host (see commands::dialog::cancel_tool and + // commands::tools::get_all_tools_info). Advertising them lets the + // controller gate the Terminal Interrupt button and the tool + // catalog UI on a real capability instead of guessing. + "cancel_tool": true, + "tool_catalog": true, }, }) } diff --git a/src/apps/cli/src/peer_host/dispatch.rs b/src/apps/cli/src/peer_host/dispatch.rs index 38435b0b3c..a36d036327 100644 --- a/src/apps/cli/src/peer_host/dispatch.rs +++ b/src/apps/cli/src/peer_host/dispatch.rs @@ -158,6 +158,14 @@ mod tests { value.pointer("/capabilities/token_usage_statistics"), Some(&json!(true)) ); + assert_eq!( + value.pointer("/capabilities/cancel_tool"), + Some(&json!(true)) + ); + assert_eq!( + value.pointer("/capabilities/tool_catalog"), + Some(&json!(true)) + ); } other => panic!("unexpected response: {other:?}"), } @@ -188,6 +196,30 @@ mod tests { assert_eq!(dispatch_target_verb("dispatch_target_unknown"), None); } + /// `cancel_tool` and `get_all_tools_info` were previously unimplemented on + /// the CLI peer host, so a controller rendering a CLI Peer session saw an + /// ineffective Interrupt button and an empty tool list. They are now + /// implemented in `commands::dialog::cancel_tool` and + /// `commands::tools::get_all_tools_info`; this test pins that neither is + /// refused by the local-only or CLI-unsupported gate before reaching the + /// implemented handler. A future regression that removes the handler but + /// leaves the command routable would land in the unsupported fallthrough + /// branch, not here — that is caught by the capability advertisement + + /// frontend gate instead. + #[test] + fn cancel_tool_and_tool_catalog_are_not_refused_before_dispatch() { + for command in ["cancel_tool", "get_all_tools_info"] { + assert!( + !is_local_only_command(command), + "{command} must be routable to the peer host" + ); + assert!( + !is_cli_unsupported_command(command), + "{command} must reach its implemented handler, not the unsupported gate" + ); + } + } + #[tokio::test] async fn attach_detach_updates_subscribers() { let _ = handle_host_invoke( diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index ba793ae92f..d09d6307a4 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -423,6 +423,12 @@ pub async fn peer_mode_ping() -> Result { "idempotent_dialog_submit": true, "targeted_session_rollback": true, "token_usage_statistics": true, + // Desktop implements both per-tool cancel and the tool catalog + // (agentic_api::cancel_tool, tool_api::get_all_tools_info), so the + // controller can gate the Terminal Interrupt button and the tool + // catalog UI on these the same way it does on the CLI peer host. + "cancel_tool": true, + "tool_catalog": true, }, })) } @@ -542,6 +548,18 @@ mod tests { .and_then(Value::as_bool), Some(true) ); + assert_eq!( + value + .pointer("/capabilities/cancel_tool") + .and_then(Value::as_bool), + Some(true) + ); + assert_eq!( + value + .pointer("/capabilities/tool_catalog") + .and_then(Value::as_bool), + Some(true) + ); } #[test] diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 06dbc09ad0..8c8b281972 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -4,7 +4,6 @@ use log::error; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; use tauri::State; use bitfun_agent_runtime::sdk::AgentUserAnswersRequest; @@ -14,6 +13,7 @@ use bitfun_core::agentic::{ workspace::{local_workspace_services, remote_workspace_services}, WorkspaceBinding, }; +use bitfun_core::agentic::tools::product_runtime::{build_tool_info, ToolInfoDto}; use bitfun_core::product_runtime::CoreRuntimeServicesProvider; use bitfun_core::service::remote_ssh::workspace_state::{ get_remote_workspace_manager, lookup_remote_connection, workspace_session_identity, @@ -22,6 +22,12 @@ use bitfun_core::util::elapsed_ms_u64; use crate::runtime::DesktopRuntimeContext; +/// Re-export the shared tool catalog DTO so callers see one `ToolInfo` type +/// across the Desktop Tauri command and the CLI Peer Host handler. Core owns +/// the shape; both hosts must answer `get_all_tools_info` with it so a +/// controller cannot tell "unsupported" from "empty". +pub type ToolInfo = ToolInfoDto; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ToolExecutionRequest { @@ -38,34 +44,11 @@ pub struct GetToolInfoRequest { pub tool_name: String, } -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DynamicMcpToolInfo { - pub server_id: String, - pub server_name: String, - pub tool_name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DynamicToolInfo { - pub provider_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub provider_kind: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub mcp: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ToolInfo { - pub name: String, - pub description: String, - pub input_schema: serde_json::Value, - pub is_readonly: bool, - pub is_concurrency_safe: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub dynamic_info: Option, -} +// Re-export the shared dynamic tool DTOs (Core already owns them under +// `bitfun_core::agentic::tools::framework`); Desktop used to carry byte-for-byte +// duplicates. Keeping the names re-exported preserves downstream `use ...::*` +// imports in lib.rs. +pub use bitfun_core::agentic::tools::framework::{DynamicMcpToolInfo, DynamicToolInfo}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolExecutionResponse { @@ -165,42 +148,6 @@ async fn build_tool_context(workspace_path: Option<&str>) -> ToolUseContext { ) } -fn to_dynamic_mcp_tool_info( - info: bitfun_core::agentic::tools::framework::DynamicMcpToolInfo, -) -> DynamicMcpToolInfo { - DynamicMcpToolInfo { - server_id: info.server_id, - server_name: info.server_name, - tool_name: info.tool_name, - } -} - -fn to_dynamic_tool_info( - info: bitfun_core::agentic::tools::framework::DynamicToolInfo, -) -> DynamicToolInfo { - DynamicToolInfo { - provider_id: info.provider_id, - provider_kind: info.provider_kind, - mcp: info.mcp.map(to_dynamic_mcp_tool_info), - } -} - -async fn build_tool_info(tool: &Arc) -> ToolInfo { - let description = tool - .description() - .await - .unwrap_or_else(|_| "No description available".to_string()); - - ToolInfo { - name: tool.name().to_string(), - description, - input_schema: tool.input_schema_for_model().await, - is_readonly: tool.is_readonly(), - is_concurrency_safe: tool.is_concurrency_safe(None), - dynamic_info: tool.dynamic_tool_info().map(to_dynamic_tool_info), - } -} - fn has_explicit_workspace_path(workspace_path: Option<&str>) -> bool { workspace_path.is_some_and(|path| !path.trim().is_empty()) } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs index de458d4028..fd1678389c 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime.rs @@ -31,6 +31,7 @@ pub(crate) use catalog::{ resolve_product_resolved_visible_tools, ProductGetToolSpecRuntime, ProductToolCatalogProvider, }; pub use catalog::{ResolvedToolManifest, ResolvedVisibleTools}; +pub use catalog::{build_all_tools_info, build_tool_info, ToolInfoDto}; pub use get_tool_spec_tool::GetToolSpecTool; pub(crate) use loaded_spec_state::collect_product_loaded_deferred_tool_specs; diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index f83d5addd9..21547a7fa3 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -8,15 +8,83 @@ use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::ToolDefinition; use bitfun_agent_tools::{ resolve_contextual_tool_manifest, resolve_contextual_visible_tools, ContextualToolManifest, - ContextualVisibleTools, GetToolSpecCatalogProvider, GetToolSpecDeferredToolSummary, + ContextualVisibleTools, DynamicMcpToolInfo, DynamicToolInfo, + GetToolSpecCatalogProvider, GetToolSpecDeferredToolSummary, GetToolSpecExecutionError, GetToolSpecRuntime, ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolManifestDefinition, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, }; +use serde::Serialize; use serde_json::Value; use std::sync::Arc; const DEFERRED_TOOL_LOADING_CONTEXT_KEY: &str = "enable_deferred_tool_loading"; +/// Read-only tool catalog DTO returned by `get_all_tools_info`. +/// +/// Owned by Core so both the Desktop Tauri command and the CLI Peer Host +/// `get_all_tools_info` handler return the same shape — a controller cannot +/// tell "CLI Host doesn't support catalog query" from "the runtime really has +/// no tools", and a Peer must not answer with a different DTO than Desktop. +/// Field names are snake_case to match the existing Web UI `ToolInfo` contract. +#[derive(Debug, Clone, Serialize)] +pub struct ToolInfoDto { + pub name: String, + pub description: String, + pub input_schema: Value, + pub is_readonly: bool, + pub is_concurrency_safe: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub dynamic_info: Option, +} + +/// Build the catalog DTO for one tool. +/// +/// Mirrors the former Desktop `build_tool_info` exactly: same description +/// fallback, same `input_schema_for_model`, same `is_concurrency_safe(None)`, +/// same `dynamic_tool_info`. Desktop now delegates here; CLI reuses the same +/// path so the two hosts never drift. +pub async fn build_tool_info(tool: &Arc) -> ToolInfoDto { + let description = tool + .description() + .await + .unwrap_or_else(|_| "No description available".to_string()); + ToolInfoDto { + name: tool.name().to_string(), + description, + input_schema: tool.input_schema_for_model().await, + is_readonly: tool.is_readonly(), + is_concurrency_safe: tool.is_concurrency_safe(None), + dynamic_info: tool.dynamic_tool_info(), + } +} + +/// Build the catalog DTO for every tool in the global registry, in registry +/// order. This is the Core-owned implementation behind the +/// `get_all_tools_info` HostInvoke command on both Desktop and CLI Peer Hosts. +pub async fn build_all_tools_info() -> Vec { + let tools = get_global_tool_registry().read().await.get_all_tools(); + let mut infos = Vec::with_capacity(tools.len()); + for tool in &tools { + infos.push(build_tool_info(tool).await); + } + infos +} + +/// Map a core dynamic tool descriptor to the shared `DynamicToolInfo` DTO. +/// +/// Kept as the single conversion so Desktop and any future host share one +/// shape; Core's `Tool::dynamic_tool_info` already returns `DynamicToolInfo`, +/// so this is currently identity, but it pins the boundary in one place. +#[allow(dead_code)] +pub fn to_dynamic_tool_info(info: DynamicToolInfo) -> DynamicToolInfo { + info +} + +#[allow(dead_code)] +pub fn to_dynamic_mcp_tool_info(info: DynamicMcpToolInfo) -> DynamicMcpToolInfo { + info +} + #[derive(Debug, Clone)] pub struct ResolvedToolManifest { pub allowed_tool_names: Vec, diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 289815c99c..18825c7f13 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -844,6 +844,21 @@ impl CoreAgentRuntimeCompatibility { .map_err(|error| error.to_string()) } + /// Cancel a running tool execution on this host. + /// + /// The controller renders tool cards (e.g. the Terminal card's Interrupt + /// button) for Turns this host owns, so it must be able to stop a running + /// tool here. This is the per-tool interrupt contract behind the + /// `cancel_tool` HostInvoke command; both Desktop and CLI Peer Hosts reach + /// the same Core-owned coordinator the local UI does, one level finer than + /// `cancel_dialog_turn`. + pub async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { + self.coordinator + .cancel_tool(tool_id, reason) + .await + .map_err(|error| error.to_string()) + } + /// Applies the same Core deployment owner before a product compatibility /// path attaches to or mutates a structured workspace scope. pub fn ensure_workspace_runtime_ownership( diff --git a/src/web-ui/eslint.config.mjs b/src/web-ui/eslint.config.mjs index 475d741296..5cbbff59c4 100644 --- a/src/web-ui/eslint.config.mjs +++ b/src/web-ui/eslint.config.mjs @@ -18,11 +18,6 @@ export default tseslint.config( 'src/**/*.example.tsx', 'src/component-library/components/registry.tsx', 'src/component-library/preview/**', - // Pre-existing legacy: context-system type impls use class components - // with hooks and other legacy patterns. Kept out of lint to avoid - // unrelated churn; the dynamic-import fence still covers the rest of - // src/**. FileContextImpl.tsx here is migrated to api.invoke. - 'src/shared/context-system/core/types/**', ], }, { @@ -139,6 +134,19 @@ export default tseslint.config( ], }, }, + { + // Pre-existing legacy: context-system type impls use class components + // that call React Hooks (a pattern predating the adapter fence). Exempt + // ONLY the noisy legacy rule here — the adapter fence + // (no-restricted-imports / no-restricted-syntax) still applies to this + // directory, so a direct or dynamic `invoke` import from + // '@tauri-apps/api/core' here fails the build just like anywhere else. + // This was previously a global ignore that let the whole fence be bypassed. + files: ['src/shared/context-system/core/types/**/*.{ts,tsx}'], + rules: { + 'react-hooks/rules-of-hooks': 'off', + }, + }, { files: ['*.{ts,mts,cts}', '*.config.{ts,mts,cts}', 'vite.config.ts'], extends: [js.configs.recommended, ...tseslint.configs.recommended], diff --git a/src/web-ui/eslint.fence.regression.test.ts b/src/web-ui/eslint.fence.regression.test.ts new file mode 100644 index 0000000000..91d01537e1 --- /dev/null +++ b/src/web-ui/eslint.fence.regression.test.ts @@ -0,0 +1,113 @@ +/** + * Adapter-fence regression test. + * + * The `no-restricted-imports` / `no-restricted-syntax` rules in + * `eslint.config.mjs` block direct and dynamic `invoke` imports from + * `@tauri-apps/api/core` everywhere except `adapters/**` and the documented + * `PeerHostInvokeBridge` exception. PR #2428 review #3 found that a global + * `ignores` entry for `src/shared/context-system/core/types/**` let the whole + * fence be bypassed in that directory — a direct `invoke` there passed lint. + * + * This test pins that the fence now applies to: + * - an ordinary business directory (always did), and + * - the context-system types directory (the regression). + * + * It also pins that the adapter exception still permits direct `invoke` inside + * `adapters/**`. Run via `pnpm vitest run`. + */ +import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +const webUiRoot = resolve(__dirname); + +interface ProbeCase { + name: string; + filename: string; + source: string; + expectError: boolean; +} + +const STATIC_PROBE = `import { invoke } from '@tauri-apps/api/core'; +export const run = () => invoke('probe'); +`; + +const DYNAMIC_PROBE = `const mod = await import('@tauri-apps/api/core'); +export const run = () => mod.invoke('probe'); +`; + +const cases: ProbeCase[] = [ + { + name: 'ordinary business dir: static invoke is blocked', + filename: 'src/app/__fence_probe_static.tsx', + source: STATIC_PROBE, + expectError: true, + }, + { + name: 'ordinary business dir: dynamic invoke is blocked', + filename: 'src/app/__fence_probe_dynamic.tsx', + source: DYNAMIC_PROBE, + expectError: true, + }, + { + name: 'context-system types dir: static invoke is blocked (regression)', + filename: 'src/shared/context-system/core/types/__fence_probe_static.tsx', + source: STATIC_PROBE, + expectError: true, + }, + { + name: 'context-system types dir: dynamic invoke is blocked (regression)', + filename: 'src/shared/context-system/core/types/__fence_probe_dynamic.tsx', + source: DYNAMIC_PROBE, + expectError: true, + }, + { + name: 'adapter dir: static invoke is allowed (exception)', + filename: 'src/infrastructure/api/adapters/__fence_probe_static.ts', + source: STATIC_PROBE, + expectError: false, + }, +]; + +function lintProbe(probe: ProbeCase): { hasError: boolean; output: string } { + // Invoke the local eslint CLI with a stdin probe under the probe filename. + // pnpm resolves the workspace eslint binary; --stdin + --stdin-filename make + // the rule's path selectors see the probe as if it lived at that path. + const args = [ + 'exec', + 'eslint', + '--stdin', + '--stdin-filename', + probe.filename, + ]; + const result = spawnSync('pnpm', args, { + cwd: webUiRoot, + input: probe.source, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + const combined = `${result.stdout ?? ''}${result.stderr ?? ''}`; + // ESLint exits non-zero and reports the restricted-imports/syntax error when + // the fence fires; a clean probe exits 0 with no error lines. + const hasError = /no-restricted-(imports|syntax)/.test(combined); + return { hasError, output: combined }; +} + +describe('adapter fence regression', () => { + for (const probe of cases) { + it(probe.name, () => { + const { hasError, output } = lintProbe(probe); + if (probe.expectError) { + expect( + hasError, + `expected the fence to block a direct/dynamic invoke at ${probe.filename}, but it did not:\n${output}`, + ).toBe(true); + } else { + expect( + hasError, + `expected the adapter exception to allow invoke at ${probe.filename}, but the fence fired:\n${output}`, + ).toBe(false); + } + }); + } +}); diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index d20c573f57..64de186c32 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -22,6 +22,10 @@ import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext' import { loadDefaultReviewTeamDefinition } from '@/shared/services/reviewTeamService'; import { globalEventBus } from '@/infrastructure/event-bus'; import { isRemoteWorkspace } from '@/shared/types'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { createLogger } from '@/shared/utils/logger'; + +const toolLog = createLogger('useAgentsList'); export type FilterLevel = 'all' | 'builtin' | 'user' | 'project' | 'external'; export type FilterType = 'all' | 'mode' | 'subagent'; @@ -165,6 +169,19 @@ export function useAgentsList({ }: UseAgentsListOptions) { const notification = useNotification(); const { workspace, workspacePath } = useCurrentWorkspace(); + const peerDevice = usePeerDeviceModeOptional(); + // True on this machine; on a peer, true only after the host advertises the + // `tool_catalog` capability (null while probing = optimistic, since a CLI + // Peer Host now implements it). When a peer does not support the catalog we + // skip the invoke instead of swallowing the unsupported error as an empty + // list — the UI can then show "no tools" without masking a transport failure. + const canQueryToolCatalog = (() => { + if (!peerDevice || !peerDevice.peerMode.active) { + return true; + } + const capabilities = peerDevice.currentPeerCapabilities; + return capabilities === null ? true : capabilities.toolCatalog; + })(); const [allAgents, setAllAgents] = useState([]); const [loading, setLoading] = useState(true); const [availableTools, setAvailableTools] = useState([]); @@ -183,9 +200,14 @@ export function useAgentsList({ setLoading(true); const fetchTools = async (): Promise => { + if (!canQueryToolCatalog) { + toolLog.info('Tool catalog unsupported on the current peer host; leaving the list empty'); + return []; + } try { return await api.invoke('get_all_tools_info'); - } catch { + } catch (error) { + toolLog.error('Failed to load tool catalog', { error }); return []; } }; @@ -304,7 +326,7 @@ export function useAgentsList({ setLoading(false); } } - }, [workspacePath]); + }, [canQueryToolCatalog, workspacePath]); useEffect(() => { void loadAgents(); diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx index 7c907082ed..1f833211a2 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx @@ -27,6 +27,7 @@ import type { DynamicToolInfo } from '@/shared/types/agent-api'; import { createLogger } from '@/shared/utils/logger'; import { isUserSelectableToolName } from '@/shared/utils/toolVisibility'; import { useNurseryStore } from '../nurseryStore'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; import './NurseryView.scss'; const log = createLogger('AssistantDefaultsPage'); @@ -82,6 +83,19 @@ function formatSkillDisplayName( const AssistantDefaultsPage: React.FC = () => { const { t } = useTranslation('scenes/profile'); const { openGallery } = useNurseryStore(); + const peerDevice = usePeerDeviceModeOptional(); + // Whether the current host advertises the `tool_catalog` capability. Local + // always does; a peer host must answer `peer_mode_ping` with tool_catalog. + // While the capability is still being probed (null) we stay optimistic so the + // tool list doesn't disappear then reappear — a CLI Peer Host now implements + // get_all_tools_info, so the optimistic default is correct in the common case. + const canQueryToolCatalog = (() => { + if (!peerDevice || !peerDevice.peerMode.active) { + return true; + } + const capabilities = peerDevice.currentPeerCapabilities; + return capabilities === null ? true : capabilities.toolCatalog; + })(); const [assistantModeConfig, setAssistantModeConfig] = useState(null); const [availableTools, setAvailableTools] = useState([]); @@ -184,9 +198,19 @@ const AssistantDefaultsPage: React.FC = () => { (async () => { setLoading(true); try { + // Skip the tool catalog invoke when the peer host cannot answer it, + // instead of swallowing the unsupported error as an empty list. The + // empty list then means "this host doesn't expose a catalog", not + // "the runtime has no tools". + const toolsPromise = canQueryToolCatalog + ? api.invoke('get_all_tools_info').catch((error) => { + log.error('Failed to load tool catalog', { error }); + return [] as ToolInfo[]; + }) + : Promise.resolve([] as ToolInfo[]); const [modeConf, tools, skillList, servers] = await Promise.all([ configAPI.getAgentProfileConfig(ASSISTANT_MODE_ID).catch(() => null as AgentProfileConfigItem | null), - api.invoke('get_all_tools_info').catch(() => [] as ToolInfo[]), + toolsPromise, configAPI.getModeSkillConfigs({ modeId: ASSISTANT_MODE_ID }).catch(() => [] as ModeSkillInfo[]), MCPAPI.getServers().catch(() => [] as MCPServerInfo[]), ]); @@ -200,7 +224,7 @@ const AssistantDefaultsPage: React.FC = () => { setLoading(false); } })(); - }, []); + }, [canQueryToolCatalog]); useEffect(() => { if (!detail) return; diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index 591f194d2f..0bb3d95da2 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -30,6 +30,7 @@ import { ToolCardCopyAction, ToolCardHeaderActions } from './ToolCardHeaderActio import { CopyableTextPreview } from '../components/CopyableTextPreview'; import { formatSessionViewPreviewText } from '../utils/sessionViewPreview'; import { api } from '@/infrastructure/api/service-api/ApiClient'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; import './TerminalToolCard.scss'; const log = createLogger('TerminalToolCard'); @@ -246,6 +247,7 @@ export const TerminalToolCard: React.FC = ({ isLastItem, }) => { const { t } = useTranslation('flow-chat'); + const peerDevice = usePeerDeviceModeOptional(); const toolCall = toolItem.toolCall; const toolResult = toolItem.toolResult; const command = toolCall?.input?.command; @@ -391,6 +393,20 @@ export const TerminalToolCard: React.FC = ({ [command], ); + // The Interrupt button is only meaningful if the current host can actually + // cancel a running tool. Local always can; a peer must advertise the + // `cancel_tool` capability. While the peer's capabilities are still being + // probed (null), stay optimistic so the button doesn't flicker off then on + // once the handshake resolves — a CLI Peer Host now implements cancel_tool, + // so the optimistic default is correct in the common case. + const canCancelTool = (() => { + if (!peerDevice || !peerDevice.peerMode.active) { + return true; + } + const capabilities = peerDevice.currentPeerCapabilities; + return capabilities === null ? true : capabilities.cancelTool; + })(); + const viewState = useMemo(() => { return getTerminalViewState({ status, @@ -399,8 +415,10 @@ export const TerminalToolCard: React.FC = ({ interruptRequested, showConfirmButtons, wasInterrupted: parsedResult.wasInterrupted, + canCancelTool, }); }, [ + canCancelTool, isParamsStreaming, interruptRequested, liveOutput, diff --git a/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.test.ts b/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.test.ts index 2b608ead5a..8579749e70 100644 --- a/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.test.ts +++ b/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.test.ts @@ -11,6 +11,7 @@ describe('terminalToolCardState', () => { interruptRequested: false, showConfirmButtons: false, wasInterrupted: false, + canCancelTool: true, }); expect(state.displayPhase).toBe('receiving_params'); @@ -25,6 +26,7 @@ describe('terminalToolCardState', () => { interruptRequested: false, showConfirmButtons: false, wasInterrupted: false, + canCancelTool: true, }); expect(state.displayPhase).toBe('executing'); @@ -39,6 +41,7 @@ describe('terminalToolCardState', () => { interruptRequested: false, showConfirmButtons: false, wasInterrupted: false, + canCancelTool: true, }); expect(state.displayPhase).toBe('live_output'); @@ -53,9 +56,36 @@ describe('terminalToolCardState', () => { interruptRequested: false, showConfirmButtons: false, wasInterrupted: false, + canCancelTool: true, }); expect(state.displayPhase).toBe('completed'); expect(state.showCompletedResult).toBe(true); }); + + it('hides the interrupt button when the host cannot cancel tools', () => { + // A peer host that does not advertise `cancel_tool` must not offer an + // interrupt that would be a no-op — the target command would keep running. + const withoutCapability = getTerminalViewState({ + status: 'running', + liveOutput: '', + isParamsStreaming: false, + interruptRequested: false, + showConfirmButtons: false, + wasInterrupted: false, + canCancelTool: false, + }); + expect(withoutCapability.showInterruptButton).toBe(false); + + const withCapability = getTerminalViewState({ + status: 'running', + liveOutput: '', + isParamsStreaming: false, + interruptRequested: false, + showConfirmButtons: false, + wasInterrupted: false, + canCancelTool: true, + }); + expect(withCapability.showInterruptButton).toBe(true); + }); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.ts b/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.ts index d16c48e457..62fceb8a59 100644 --- a/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.ts +++ b/src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.ts @@ -30,6 +30,13 @@ interface GetTerminalViewStateParams { interruptRequested: boolean; showConfirmButtons: boolean; wasInterrupted: boolean; + /** + * Whether the current host advertises the `cancel_tool` capability. When + * false (e.g. a peer host without per-tool interrupt support), the + * Interrupt button is hidden so the UI never offers an ineffective action. + * Local and full-peer hosts set this to true. + */ + canCancelTool: boolean; } function deriveDisplayPhase(params: { @@ -91,6 +98,7 @@ export function getTerminalViewState( interruptRequested, showConfirmButtons, wasInterrupted, + canCancelTool, } = params; const isRunning = status === 'running'; const isLoading = @@ -98,7 +106,10 @@ export function getTerminalViewState( status === 'streaming' || status === 'receiving' || status === 'running'; - const showInterruptButton = isRunning && !interruptRequested; + // Never offer an interrupt the host can't act on. A peer host that doesn't + // implement `cancel_tool` would otherwise leave the target command running + // while the controller just restored the button and logged an error. + const showInterruptButton = isRunning && !interruptRequested && canCancelTool; let statusLabel: TerminalViewState['statusLabel'] = null; let statusClassName: TerminalViewState['statusClassName'] = null; diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts index f449866b03..00fcb202c9 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts @@ -43,6 +43,10 @@ export interface PeerHostCapabilities { readonly idempotentDialogSubmit: boolean; readonly targetedSessionRollback: boolean; readonly tokenUsageStatistics: boolean; + /** Host implements `cancel_tool` (per-tool interrupt). Gates the Terminal Interrupt button. */ + readonly cancelTool: boolean; + /** Host implements `get_all_tools_info` (read-only tool catalog). Gates the Agents/Assistant tool list. */ + readonly toolCatalog: boolean; } /** Immutable view of one connection; safe to hold in component state. */ @@ -104,6 +108,8 @@ interface PeerModePingResult { idempotent_dialog_submit?: boolean; targeted_session_rollback?: boolean; token_usage_statistics?: boolean; + cancel_tool?: boolean; + tool_catalog?: boolean; }; } @@ -111,6 +117,8 @@ const NO_CAPABILITIES: PeerHostCapabilities = { idempotentDialogSubmit: false, targetedSessionRollback: false, tokenUsageStatistics: false, + cancelTool: false, + toolCatalog: false, }; interface ConnectionEntry { @@ -359,6 +367,8 @@ export class PeerConnectionManager { idempotentDialogSubmit: result?.capabilities?.idempotent_dialog_submit === true, targetedSessionRollback: result?.capabilities?.targeted_session_rollback === true, tokenUsageStatistics: result?.capabilities?.token_usage_statistics === true, + cancelTool: result?.capabilities?.cancel_tool === true, + toolCatalog: result?.capabilities?.tool_catalog === true, }; } diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDeviceContext.tsx b/src/web-ui/src/infrastructure/peer-device/PeerDeviceContext.tsx index 40e61639a8..ab7162d28a 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceContext.tsx +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceContext.tsx @@ -44,14 +44,25 @@ export const PeerDeviceProvider: React.FC<{ children: React.ReactNode }> = ({ ch ); const value = useMemo( - () => ({ - peerMode: snapshot.peerMode, - attachments: [...snapshot.attachments], - switchToDevice, - switchToLocal, - disconnectDevice, - disconnectAllDevices, - }), + () => { + const currentDeviceId = snapshot.peerMode.active + ? snapshot.peerMode.deviceId + : null; + const currentPeerCapabilities = currentDeviceId + ? snapshot.attachments.find( + (attachment) => attachment.deviceId === currentDeviceId, + )?.capabilities ?? null + : null; + return { + peerMode: snapshot.peerMode, + attachments: [...snapshot.attachments], + currentPeerCapabilities, + switchToDevice, + switchToLocal, + disconnectDevice, + disconnectAllDevices, + }; + }, [ snapshot, switchToDevice, diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts index 1bddcc5d5a..1dfeda8e67 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.test.ts @@ -132,6 +132,8 @@ class FakeConnectionManager { idempotentDialogSubmit: true, targetedSessionRollback: true, tokenUsageStatistics: true, + cancelTool: true, + toolCatalog: true, }, consecutiveFailures: 0, lostReason: null, diff --git a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts index 435db71a12..8f8b810b7e 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerDeviceSurfaceController.ts @@ -180,6 +180,7 @@ export class PeerDeviceSurfaceController { deviceName: connection.deviceName, health: connection.health, lostReason: connection.lostReason, + capabilities: connection.capabilities, })), }; } diff --git a/src/web-ui/src/infrastructure/peer-device/peerDeviceContextState.ts b/src/web-ui/src/infrastructure/peer-device/peerDeviceContextState.ts index 10cc2a9f52..178f8afc05 100644 --- a/src/web-ui/src/infrastructure/peer-device/peerDeviceContextState.ts +++ b/src/web-ui/src/infrastructure/peer-device/peerDeviceContextState.ts @@ -2,6 +2,7 @@ import { createContext, useContext } from 'react'; import type { PeerConnectionHealth, PeerConnectionLostReason, + PeerHostCapabilities, } from './PeerConnectionManager'; /** @@ -20,6 +21,11 @@ export interface PeerAttachmentState { deviceName: string; health: PeerConnectionHealth; lostReason: PeerConnectionLostReason | null; + /** + * Host capabilities probed via `peer_mode_ping`. Null when the host has not + * answered yet; consumers must fall back to a safe default (unsupported). + */ + capabilities: PeerHostCapabilities | null; } /** A newer request may intentionally supersede an earlier rapid switch. */ @@ -34,6 +40,13 @@ export interface PeerDeviceContextValue { * reporting progress. */ attachments: PeerAttachmentState[]; + /** + * Capabilities of the currently rendered peer host, or null when rendering + * this machine or the host has not answered `peer_mode_ping` yet. Consumers + * gate peer-specific UI (e.g. Terminal Interrupt, tool catalog) on this + * instead of guessing from surface state. + */ + currentPeerCapabilities: PeerHostCapabilities | null; /** Render another device, attaching it first when needed. */ switchToDevice: (deviceId: string, deviceName: string) => Promise; /** Render this machine again. Peer attachments are left running. */ From 9d27a89e1e4cf694fa7059e9ca812aca6395b365 Mon Sep 17 00:00:00 2001 From: weishao Date: Tue, 25 Aug 2026 18:31:20 +0800 Subject: [PATCH 7/9] fix(web-ui): route browser/computer-use to tool host and harden peer adapter surface PR #2428 review #4 (limityan, 2026-08-25). All but the truncated PR-title P3. P1: - Browser Control / Computer Use now run on the host that runs the Tool: removed browser_control_*/computer_use_get_status/request_permissions/ open_system_settings from FE + Desktop LOCAL_ONLY; CLI deny.rs already refuses them. Desktop Peer B bridges to its own webview (reads B's browser/OS); SessionConfig surfaces an explicit unsupported notice on a CLI Peer instead of silent invoke failures. browser_webview_* stays controller-local (embedded UI). - cancel_tool joins HIGH_PRIORITY_COMMANDS so Terminal Interrupt takes the reserved high slot instead of queueing behind saturated normal work. - Tool catalog request bound to Device Surface: renderedPeerDeviceId added to useAgentsList loadAgents deps + AssistantDefaultsPage effect, so A->B (same workspace + capability) reloads; requestId guard drops stale results. P2: - Capability versioning: cancelTool/toolCatalog become boolean|null (null = unknown/older host); consumers stay optimistic so an older Desktop keeps its working button/list instead of being gated off. - Tool catalog status tri-state (available/unsupported/failed/empty) in useAgentsList + AssistantDefaultsPage; AssistantDefaultsPage distinguishes failure from a truly empty list. - keepalive publishes a React snapshot when a ready peer's capabilities change (not only on recovery), via capabilitiesEqual(). P3: - Remove two unreachable-pub identity fns + unused import from catalog.rs. - eslint fence regression test runs the eslint JS bin via process.execPath with shell:false (was pnpm + shell:true -> Node DEP0190 on Windows). Verified: eslint src clean; focus tests 72/72 (adapter 42, manager 19, surface + fence); core-boundaries + 126 self-tests pass; cargo build core/desktop clean; desktop peer_host_invoke 6/6; CLI cli_command_contracts 41/41. Pre-existing @generated/api type error and 2 flaky terminal_process contract tests are unrelated. Co-Authored-By: Claude --- src/apps/desktop/src/api/peer_host_invoke.rs | 24 ++--- .../agentic/tools/product_runtime/catalog.rs | 17 +--- src/web-ui/eslint.fence.regression.test.ts | 23 +++-- .../app/scenes/agents/hooks/useAgentsList.ts | 46 +++++++++- .../profile/views/AssistantDefaultsPage.tsx | 48 ++++++++-- .../flow_chat/tool-cards/TerminalToolCard.tsx | 11 ++- .../api/adapters/peer-device-adapter.test.ts | 88 +++++++++++++++++-- .../api/adapters/peer-device-adapter.ts | 35 +++++--- .../config/components/SessionConfig.tsx | 68 +++++++++++++- .../peer-device/PeerConnectionManager.test.ts | 73 +++++++++++++++ .../peer-device/PeerConnectionManager.ts | 72 ++++++++++++--- .../src/locales/en-US/scenes/profile.json | 2 + .../en-US/settings/session-config.json | 2 + .../src/locales/zh-CN/scenes/profile.json | 2 + .../zh-CN/settings/session-config.json | 2 + .../src/locales/zh-TW/scenes/profile.json | 2 + .../zh-TW/settings/session-config.json | 2 + 17 files changed, 433 insertions(+), 84 deletions(-) diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index d09d6307a4..8f9bf098d2 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -100,9 +100,10 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "remote_connect_weixin_qr_poll", "remote_connect_get_bot_verbose_mode", "remote_connect_set_bot_verbose_mode", - // This-machine computer-use / OS permission prompts - "computer_use_request_permissions", - "computer_use_open_system_settings", + // Computer-use OS permission prompts + system-settings are intentionally NOT + // local-only: under Desktop Peer Mode they must run on the peer host B (B + // surfaces B's own OS permission prompts / settings), reached via + // bridge_via_webview. CLI Peer refuses them in deny.rs. See SessionConfig. // Detached dispatch uses controller-owned SSH credentials and observers. "dispatch_list_targets", "dispatch_probe_target", @@ -181,19 +182,20 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ // IDE control events drive the controller window's panels; the result // report must settle on the controller's transport, not here. "report_ide_control_result", - // Controller app-shell / local-device commands (browser/webview/DevTools/ - // desktop-pet/diagnostics) operate on the controller's own surfaces. - "browser_control_launch", - "browser_control_list_browsers", - "browser_control_get_status", - "browser_control_restart_with_cdp", - "browser_control_enable_default_cdp", + // Controller app-shell / local-device commands (embedded webview/DevTools/ + // desktop-pet/diagnostics) operate on the controller's OWN surfaces and a + // peer host has no implementation for them, so they stay local-only. + // + // NOTE: the runtime-owning Browser Control and Computer Use commands are + // NOT local-only — they run the agent Tool, so under Desktop Peer Mode they + // route to the peer host B via bridge_via_webview (reads B's own browser + // and OS). CLI Peer refuses them in deny.rs and the UI gates the section on + // host type. See SessionConfig + cli deny.rs. "browser_webview_create", "browser_webview_eval", "browser_webview_navigate", "browser_webview_reload", "browser_webview_set_bounds", - "computer_use_get_status", "debug_devtools_available", "debug_open_devtools", "resize_agent_companion_desktop_pet", diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index 21547a7fa3..f62d9a241c 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -8,7 +8,7 @@ use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::ToolDefinition; use bitfun_agent_tools::{ resolve_contextual_tool_manifest, resolve_contextual_visible_tools, ContextualToolManifest, - ContextualVisibleTools, DynamicMcpToolInfo, DynamicToolInfo, + ContextualVisibleTools, DynamicToolInfo, GetToolSpecCatalogProvider, GetToolSpecDeferredToolSummary, GetToolSpecExecutionError, GetToolSpecRuntime, ToolCatalogRuntime, ToolCatalogSnapshotProvider, ToolManifestDefinition, CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, @@ -70,21 +70,6 @@ pub async fn build_all_tools_info() -> Vec { infos } -/// Map a core dynamic tool descriptor to the shared `DynamicToolInfo` DTO. -/// -/// Kept as the single conversion so Desktop and any future host share one -/// shape; Core's `Tool::dynamic_tool_info` already returns `DynamicToolInfo`, -/// so this is currently identity, but it pins the boundary in one place. -#[allow(dead_code)] -pub fn to_dynamic_tool_info(info: DynamicToolInfo) -> DynamicToolInfo { - info -} - -#[allow(dead_code)] -pub fn to_dynamic_mcp_tool_info(info: DynamicMcpToolInfo) -> DynamicMcpToolInfo { - info -} - #[derive(Debug, Clone)] pub struct ResolvedToolManifest { pub allowed_tool_names: Vec, diff --git a/src/web-ui/eslint.fence.regression.test.ts b/src/web-ui/eslint.fence.regression.test.ts index 91d01537e1..3c4aa40e0b 100644 --- a/src/web-ui/eslint.fence.regression.test.ts +++ b/src/web-ui/eslint.fence.regression.test.ts @@ -69,22 +69,31 @@ const cases: ProbeCase[] = [ }, ]; +/** + * Resolve the eslint CLI entry as an absolute path and run it with `node`, + * without a shell. Going through `pnpm`/`pnpm.cmd` needed `shell: true` on + * Windows (a `.cmd` shim cannot be spawned with `shell: false`), which triggers + * Node's DEP0190 security deprecation. Running the eslint JS entry directly + * via `node` keeps `shell: false` on every platform and avoids the warning. + */ +function eslintBinPath(): string { + return resolve(webUiRoot, 'node_modules/eslint/bin/eslint.js'); +} + function lintProbe(probe: ProbeCase): { hasError: boolean; output: string } { - // Invoke the local eslint CLI with a stdin probe under the probe filename. - // pnpm resolves the workspace eslint binary; --stdin + --stdin-filename make - // the rule's path selectors see the probe as if it lived at that path. + // --stdin + --stdin-filename make the rule's path selectors see the probe as + // if it lived at that path, so the fence applies per the probe's location. const args = [ - 'exec', - 'eslint', + eslintBinPath(), '--stdin', '--stdin-filename', probe.filename, ]; - const result = spawnSync('pnpm', args, { + const result = spawnSync(process.execPath, args, { cwd: webUiRoot, input: probe.source, encoding: 'utf8', - shell: process.platform === 'win32', + shell: false, }); const combined = `${result.stdout ?? ''}${result.stderr ?? ''}`; // ESLint exits non-zero and reports the restricted-imports/syntax error when diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index 64de186c32..9ba452fd21 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -30,6 +30,18 @@ const toolLog = createLogger('useAgentsList'); export type FilterLevel = 'all' | 'builtin' | 'user' | 'project' | 'external'; export type FilterType = 'all' | 'mode' | 'subagent'; +/** + * State of the tool catalog load, so the UI can distinguish "host doesn't expose + * a catalog", "the read failed (retryable)", and "the runtime really has no + * tools" — collapsing all three into `[]` masked transport failures as an + * empty list. See PR #2428 #5. + */ +export type ToolCatalogStatus = + | 'available' + | 'unsupported' + | 'failed' + | 'empty'; + export interface ToolInfo { name: string; description: string; @@ -170,6 +182,14 @@ export function useAgentsList({ const notification = useNotification(); const { workspace, workspacePath } = useCurrentWorkspace(); const peerDevice = usePeerDeviceModeOptional(); + // Identity of the rendered surface: null on the controller, otherwise the + // peer device id. Part of the catalog-load deps so A→B (same workspacePath, + // same capability) still reloads — otherwise the UI keeps A's catalog while + // config mutations route to B. The loadRequestIdRef guard drops A's in-flight + // result once B's load starts. See PR #2428 #3. + const renderedPeerDeviceId = peerDevice?.peerMode.active + ? peerDevice.peerMode.deviceId + : null; // True on this machine; on a peer, true only after the host advertises the // `tool_catalog` capability (null while probing = optimistic, since a CLI // Peer Host now implements it). When a peer does not support the catalog we @@ -180,11 +200,19 @@ export function useAgentsList({ return true; } const capabilities = peerDevice.currentPeerCapabilities; - return capabilities === null ? true : capabilities.toolCatalog; + // null capabilities = host not yet probed → optimistic. A probed host may + // also report `null` for this field (older host that didn't advertise it): + // stay optimistic so an older Desktop that does implement get_all_tools_info + // keeps its list. An older CLI that lacks it returns unsupported on invoke, + // which the catch path surfaces. See PR #2428 #4. + return capabilities === null || capabilities.toolCatalog === null + ? true + : capabilities.toolCatalog; })(); const [allAgents, setAllAgents] = useState([]); const [loading, setLoading] = useState(true); const [availableTools, setAvailableTools] = useState([]); + const [toolCatalogStatus, setToolCatalogStatus] = useState('available'); const [configuredModels, setConfiguredModels] = useState([]); const [modeProfiles, setModeProfiles] = useState>({}); const [agentSkills, setAgentSkills] = useState>({}); @@ -198,16 +226,25 @@ export function useAgentsList({ const loadAgents = useCallback(async () => { const requestId = ++loadRequestIdRef.current; setLoading(true); + // `renderedPeerDeviceId` is read here so a surface switch (A→B) recreates + // this callback even when canQueryToolCatalog/workspacePath are unchanged; + // the requestId guard then drops the previous surface's in-flight result. + // See PR #2428 #3. + const surfaceTag = renderedPeerDeviceId ?? 'controller'; const fetchTools = async (): Promise => { if (!canQueryToolCatalog) { - toolLog.info('Tool catalog unsupported on the current peer host; leaving the list empty'); + toolLog.info('Tool catalog unsupported on the current peer host; leaving the list empty', { surface: surfaceTag }); + setToolCatalogStatus('unsupported'); return []; } try { - return await api.invoke('get_all_tools_info'); + const tools = await api.invoke('get_all_tools_info'); + setToolCatalogStatus(tools.length > 0 ? 'available' : 'empty'); + return tools; } catch (error) { toolLog.error('Failed to load tool catalog', { error }); + setToolCatalogStatus('failed'); return []; } }; @@ -326,7 +363,7 @@ export function useAgentsList({ setLoading(false); } } - }, [canQueryToolCatalog, workspacePath]); + }, [canQueryToolCatalog, workspacePath, renderedPeerDeviceId]); useEffect(() => { void loadAgents(); @@ -608,6 +645,7 @@ export function useAgentsList({ filteredAgents, loading, availableTools, + toolCatalogStatus, configuredModels, getModeProfile, getAgentSkills, diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx index 1f833211a2..b6df2aaa6e 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx @@ -84,6 +84,12 @@ const AssistantDefaultsPage: React.FC = () => { const { t } = useTranslation('scenes/profile'); const { openGallery } = useNurseryStore(); const peerDevice = usePeerDeviceModeOptional(); + // Identity of the rendered surface, so A→B (same capability, same workspace) + // still reloads the catalog from B instead of keeping A's stale list while + // config mutations route to B. See PR #2428 #3. + const renderedPeerDeviceId = peerDevice?.peerMode.active + ? peerDevice.peerMode.deviceId + : null; // Whether the current host advertises the `tool_catalog` capability. Local // always does; a peer host must answer `peer_mode_ping` with tool_catalog. // While the capability is still being probed (null) we stay optimistic so the @@ -94,11 +100,23 @@ const AssistantDefaultsPage: React.FC = () => { return true; } const capabilities = peerDevice.currentPeerCapabilities; - return capabilities === null ? true : capabilities.toolCatalog; + // null capabilities = host not yet probed → optimistic. A probed host may + // also report `null` for this field (older host that didn't advertise it): + // stay optimistic so an older Desktop that does implement get_all_tools_info + // keeps its list. An older CLI that lacks it returns unsupported on invoke. + // See PR #2428 #4. + return capabilities === null || capabilities.toolCatalog === null + ? true + : capabilities.toolCatalog; })(); const [assistantModeConfig, setAssistantModeConfig] = useState(null); const [availableTools, setAvailableTools] = useState([]); + // Distinguish "host doesn't expose a catalog" / "read failed" / "really no + // tools" so the UI doesn't collapse all three into an empty list. See #2428 #5. + const [toolCatalogStatus, setToolCatalogStatus] = useState< + 'available' | 'unsupported' | 'failed' | 'empty' + >('available'); const [mcpServers, setMcpServers] = useState([]); const [modeSkills, setModeSkills] = useState([]); const [toolsLoading, setToolsLoading] = useState>({}); @@ -202,12 +220,22 @@ const AssistantDefaultsPage: React.FC = () => { // instead of swallowing the unsupported error as an empty list. The // empty list then means "this host doesn't expose a catalog", not // "the runtime has no tools". - const toolsPromise = canQueryToolCatalog - ? api.invoke('get_all_tools_info').catch((error) => { + let toolsPromise: Promise; + if (canQueryToolCatalog) { + toolsPromise = api.invoke('get_all_tools_info') + .then((tools) => { + setToolCatalogStatus(tools.length > 0 ? 'available' : 'empty'); + return tools; + }) + .catch((error) => { log.error('Failed to load tool catalog', { error }); + setToolCatalogStatus('failed'); return [] as ToolInfo[]; - }) - : Promise.resolve([] as ToolInfo[]); + }); + } else { + setToolCatalogStatus('unsupported'); + toolsPromise = Promise.resolve([] as ToolInfo[]); + } const [modeConf, tools, skillList, servers] = await Promise.all([ configAPI.getAgentProfileConfig(ASSISTANT_MODE_ID).catch(() => null as AgentProfileConfigItem | null), toolsPromise, @@ -224,7 +252,7 @@ const AssistantDefaultsPage: React.FC = () => { setLoading(false); } })(); - }, [canQueryToolCatalog]); + }, [canQueryToolCatalog, renderedPeerDeviceId]); useEffect(() => { if (!detail) return; @@ -739,7 +767,13 @@ const AssistantDefaultsPage: React.FC = () => { )} > {builtinTools.length === 0 ? ( -

{t('empty.tools')}

+

+ {toolCatalogStatus === 'unsupported' + ? t('empty.toolsUnsupported') + : toolCatalogStatus === 'failed' + ? t('empty.toolsFailed') + : t('empty.tools')} +

) : ( renderToolEnabledDisabledSplit(builtinToolsEnabled, builtinToolsDisabled, false) )} diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index 0bb3d95da2..9d0370d23d 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -398,13 +398,20 @@ export const TerminalToolCard: React.FC = ({ // `cancel_tool` capability. While the peer's capabilities are still being // probed (null), stay optimistic so the button doesn't flicker off then on // once the handshake resolves — a CLI Peer Host now implements cancel_tool, - // so the optimistic default is correct in the common case. + // so the optimistic default is correct in the common case. A probed host may + // also report `null` for this field (an older host that didn't advertise it): + // stay optimistic so an older Desktop that does implement cancel_tool keeps + // a working Interrupt button; an older CLI that lacks it returns unsupported + // on invoke, which the handler already surfaces by restoring the button. + // See PR #2428 #4. const canCancelTool = (() => { if (!peerDevice || !peerDevice.peerMode.active) { return true; } const capabilities = peerDevice.currentPeerCapabilities; - return capabilities === null ? true : capabilities.cancelTool; + return capabilities === null || capabilities.cancelTool === null + ? true + : capabilities.cancelTool; })(); const viewState = useMemo(() => { diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index 8f5ee829b4..269e430bbc 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -78,20 +78,15 @@ describe('isPeerLocalOnlyCommand', () => { expect(isPeerLocalOnlyCommand('report_ide_control_result')).toBe(true); }); - it('keeps controller browser/webview/devtools/desktop-pet/diagnostics on the controller device', () => { + it('keeps controller webview/devtools/desktop-pet/diagnostics on the controller device', () => { // These previously hit the local Tauri host via dynamic invoke(); routing - // them to a peer would regress (peer host does not implement them). - expect(isPeerLocalOnlyCommand('browser_control_launch')).toBe(true); - expect(isPeerLocalOnlyCommand('browser_control_list_browsers')).toBe(true); - expect(isPeerLocalOnlyCommand('browser_control_get_status')).toBe(true); - expect(isPeerLocalOnlyCommand('browser_control_restart_with_cdp')).toBe(true); - expect(isPeerLocalOnlyCommand('browser_control_enable_default_cdp')).toBe(true); + // them to a peer would regress (peer host does not implement them, and + // they drive the controller's own embedded surfaces). expect(isPeerLocalOnlyCommand('browser_webview_create')).toBe(true); expect(isPeerLocalOnlyCommand('browser_webview_eval')).toBe(true); expect(isPeerLocalOnlyCommand('browser_webview_navigate')).toBe(true); expect(isPeerLocalOnlyCommand('browser_webview_reload')).toBe(true); expect(isPeerLocalOnlyCommand('browser_webview_set_bounds')).toBe(true); - expect(isPeerLocalOnlyCommand('computer_use_get_status')).toBe(true); expect(isPeerLocalOnlyCommand('debug_devtools_available')).toBe(true); expect(isPeerLocalOnlyCommand('debug_open_devtools')).toBe(true); expect(isPeerLocalOnlyCommand('resize_agent_companion_desktop_pet')).toBe(true); @@ -100,6 +95,25 @@ describe('isPeerLocalOnlyCommand', () => { expect(isPeerLocalOnlyCommand('append_flow_chat_diagnostics')).toBe(true); }); + it('routes Browser Control and Computer Use to the host that runs the Tool', () => { + // Browser Control / Computer Use run the agent Tool, so they must reach the + // host that executes it: Desktop Peer B bridges them to its own webview + // (reads B's browser/OS), CLI Peer refuses them in deny.rs, and the UI + // gates the section on host type. They must NOT be LOCAL_ONLY, otherwise + // config is written to the peer while status/launch/repair run on the + // controller (split across hosts). See PR #2428 review #4 issue #1. + expect(isPeerLocalOnlyCommand('browser_control_launch')).toBe(false); + expect(isPeerLocalOnlyCommand('browser_control_list_browsers')).toBe(false); + expect(isPeerLocalOnlyCommand('browser_control_get_status')).toBe(false); + expect(isPeerLocalOnlyCommand('browser_control_restart_with_cdp')).toBe(false); + expect(isPeerLocalOnlyCommand('browser_control_enable_default_cdp')).toBe(false); + expect(isPeerLocalOnlyCommand('computer_use_get_status')).toBe(false); + expect(isPeerLocalOnlyCommand('computer_use_request_permissions')).toBe(false); + expect(isPeerLocalOnlyCommand('computer_use_open_system_settings')).toBe(false); + // But the embedded browser_webview_* surface stays controller-local. + expect(isPeerLocalOnlyCommand('browser_webview_create')).toBe(true); + }); + it('keeps file-tree path checks routed to the peer surface', () => { // check_path_exists is the one CLI-Peer-supported routed command: the path // comes from the rendered surface's file tree, so it must stay peer-routed. @@ -163,6 +177,14 @@ describe('peerInvokePriorityFor', () => { expect(peerInvokePriorityFor('terminal_signal')).toBe('high'); }); + it('ranks per-tool cancel high so Interrupt is not queued behind normal work', () => { + // A long-running shell keeps producing side effects until the cancel + // reaches the host; it must take the reserved high-priority slot, not the + // normal queue that saturated reads/mutations can block. See PR #2428 #2. + expect(peerInvokePriorityFor('cancel_tool')).toBe('high'); + expect(peerInvokePriorityFor('cancel_dialog_turn')).toBe('high'); + }); + it('retries only idempotent Peer reads', () => { expect(isPeerRetryableReadCommand('list_persisted_sessions_page')).toBe(true); expect(isPeerRetryableReadCommand('get_opened_workspaces')).toBe(true); @@ -296,6 +318,56 @@ describe('PeerDeviceTransportAdapter queue', () => { ]); }); + it('dispatches cancel_tool immediately when the non-high slot is busy', async () => { + // The Terminal Interrupt button calls cancel_tool; a long-running shell on + // the peer keeps producing side effects until the cancel lands. cancel_tool + // is high-priority, so it takes the reserved high slot and dispatches even + // while a normal-priority mutation occupies the single non-high slot. + const started: string[] = []; + const normalGate = createDeferred(); + + const deviceRpc = vi.fn(async (_target: string, commandJson: string) => { + const parsed = JSON.parse(commandJson) as { command: string }; + started.push(parsed.command); + if (parsed.command === 'set_config') { + await normalGate.promise; + } + return JSON.stringify({ + resp: 'host_invoke_result', + ok: true, + value: parsed.command === 'set_config' ? {} : { ok: true }, + }); + }); + + const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, {}, 2); + await adapter.connect(); + + // One normal-priority mutation occupies the single non-high slot + // (maxConcurrent 2 → one slot reserved for high). + const normal1 = adapter.request('set_config', { request: { path: 'a' } }); + await Promise.resolve(); + await Promise.resolve(); + expect(started).toEqual(['set_config']); + expect(adapter.getActiveCountsForTest()).toEqual({ + total: 1, + high: 0, + normal: 1, + low: 0, + }); + + // Interrupt fires while the normal slot is busy. It must start on the + // reserved high slot without waiting for the mutation to finish. + const cancel = adapter.request('cancel_tool', { request: { toolUseId: 'tu-1' } }); + await Promise.resolve(); + await Promise.resolve(); + expect(started).toEqual(['set_config', 'cancel_tool']); + expect(adapter.getActiveCountsForTest().high).toBe(1); + + await cancel; + normalGate.resolve(); + await normal1; + }); + it('sends split-endpoint file reads as direct peer commands', async () => { const deviceRpc = vi.fn(async (_target: string, commandJson: string) => { const parsed = JSON.parse(commandJson) as { cmd: string; path: string }; diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index 3de99b7ee2..e7fdffa96b 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -72,8 +72,11 @@ const LOCAL_ONLY_COMMANDS = new Set([ 'peer_control_detach', 'peer_mode_ping', 'peer_controller_set_active', - 'computer_use_request_permissions', - 'computer_use_open_system_settings', + // `computer_use_request_permissions` / `computer_use_open_system_settings` + // are intentionally NOT local-only: they run on the host that runs the + // Computer Use Tool, so Desktop Peer B surfaces B's own OS permission prompts + // and settings panes. CLI Peer refuses them in deny.rs and the UI gates the + // section on host type. See SessionConfig + peer_host_invoke + cli deny.rs. // Detached dispatch uses this controller's SSH credentials and observer index. 'dispatch_list_targets', 'dispatch_probe_target', @@ -173,23 +176,22 @@ const LOCAL_ONLY_COMMANDS = new Set([ // returns unsupported. See PR #2428. 'report_ide_control_result', // Controller app-shell / local-device commands reached by migrating dynamic - // invoke() sites behind the adapter fence. These previously hit the local - // Tauri host directly; routing them to a peer would be a regression (the peer - // host does not implement them, and they operate on the controller's own - // browser/webview/DevTools/desktop-pet/diagnostics). Declared LOCAL_ONLY so - // api.invoke keeps them on the controller. See PR #2428 (lint fence + dynamic - // import migration). - 'browser_control_launch', - 'browser_control_list_browsers', - 'browser_control_get_status', - 'browser_control_restart_with_cdp', - 'browser_control_enable_default_cdp', + // invoke() sites behind the adapter fence. These operate on the controller's + // OWN surfaces (embedded webview, DevTools, desktop pet, diagnostics) and a + // peer host has no implementation for them, so routing to a peer would be a + // regression. Declared LOCAL_ONLY so api.invoke keeps them on the controller. + // See PR #2428 (lint fence + dynamic import migration). + // + // NOTE: the runtime-owning Browser Control and Computer Use commands are NOT + // here — they run the agent Tool, so they route to the host that runs the + // Tool: Desktop Peer bridges them to its own webview (reads the peer's own + // browser/OS), and CLI Peer refuses them in deny.rs (the UI gates the section + // on host type). See SessionConfig + peer_host_invoke + cli deny.rs. 'browser_webview_create', 'browser_webview_eval', 'browser_webview_navigate', 'browser_webview_reload', 'browser_webview_set_bounds', - 'computer_use_get_status', 'debug_devtools_available', 'debug_open_devtools', 'resize_agent_companion_desktop_pet', @@ -235,6 +237,11 @@ const HIGH_PRIORITY_COMMANDS = new Set([ 'get_agent_profile_config', 'start_dialog_turn', 'cancel_dialog_turn', + // Per-tool interrupt (Terminal cards) is interactive and time-sensitive: a + // long-running shell command keeps producing side effects until the cancel + // reaches the host. It must take the reserved high-priority slot, not queue + // behind normal reads/mutations. See PR #2428 review #4. + 'cancel_tool', 'rollback_session_to_turn', 'list_pending_permission_requests', 'subscribe_permission_requests', diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index e4f25d758d..aac21e4f42 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -52,6 +52,7 @@ import { GlobalPermissionRulesDialog } from './GlobalPermissionRulesDialog'; import { ChatInputPixelPet } from '@/flow_chat/components/ChatInputPixelPet'; import { ask, open } from '@tauri-apps/plugin-dialog'; import { createLogger } from '@/shared/utils/logger'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; import './AIFeaturesConfig.scss'; import './DebugConfig.scss'; @@ -59,6 +60,18 @@ const log = createLogger('SessionSettingsPanels'); const IS_TAURI_DESKTOP = typeof window !== 'undefined' && '__TAURI__' in window; +/** + * A peer host that refuses Browser Control / Computer Use (CLI Peer returns + * "local-only and cannot run on peer"; Desktop Peer would surface a different + * error). We detect that string so the settings section can show an explicit + * "unsupported on this peer" notice instead of firing invokes that silently + * fail on every refresh. See PR #2428 review #4 issue #1. + */ +function isPeerUnsupportedBrowserControlError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error ?? ''); + return /is local-only and cannot run on peer|is not supported on CLI peer host/i.test(message); +} + type ComputerUseStatusPayload = { computerUseEnabled: boolean; accessibilityGranted: boolean; @@ -135,6 +148,15 @@ const SessionSettingsPanels: React.FC = ({ variant } const [isGlobalPermissionRulesDialogOpen, setIsGlobalPermissionRulesDialogOpen] = useState(false); const { computerUseEnabled, setComputerUseEnabled } = useComputerUseEnabled(); + // Browser Control / Computer Use run on the host that runs the Tool. Desktop + // Peer B executes them on B; CLI Peer refuses them (deny.rs). When the + // rendered peer is a CLI Peer the refresh invokes return "local-only and + // cannot run on peer" — we surface that as an explicit unsupported notice + // instead of silently degrading. Null = controller local (no peer) or peer + // capabilities not yet probed (optimistically supported). See PR #2428 #1. + const peerDevice = usePeerDeviceModeOptional(); + const peerModeActive = peerDevice?.peerMode.active === true; + const [peerBrowserControlUnsupported, setPeerBrowserControlUnsupported] = useState(false); const [computerUseAccess, setComputerUseAccess] = useState(false); const [computerUseScreen, setComputerUseScreen] = useState(false); const [computerUseBusy, setComputerUseBusy] = useState(false); @@ -169,12 +191,17 @@ const SessionSettingsPanels: React.FC = ({ variant } setComputerUseStatusLoading(true); try { const s = await api.invoke('computer_use_get_status'); + setPeerBrowserControlUnsupported(false); setComputerUseEnabled(s.computerUseEnabled); setComputerUseAccess(s.accessibilityGranted); setComputerUseScreen(s.screenCaptureGranted); setComputerUsePlatformNote(s.platformNote); return true; } catch (error) { + if (isPeerUnsupportedBrowserControlError(error)) { + setPeerBrowserControlUnsupported(true); + return false; + } log.error('computer_use_get_status failed', error); return false; } finally { @@ -199,6 +226,7 @@ const SessionSettingsPanels: React.FC = ({ variant } }>('browser_control_get_status', { request: { port: 9222 } }), api.invoke<{ options: BrowserControlBrowserOption[] }>('browser_control_list_browsers'), ]); + setPeerBrowserControlUnsupported(false); setBrowserCdpAvailable(s.cdpAvailable); setBrowserDefaultCdpSupported(s.defaultCdpSupported); setBrowserDefaultCdpEnabled(s.defaultCdpEnabled); @@ -208,6 +236,11 @@ const SessionSettingsPanels: React.FC = ({ variant } setBrowserPageCount(s.pageCount); setBrowserOptions(browsers.options); } catch (error) { + if (isPeerUnsupportedBrowserControlError(error)) { + setPeerBrowserControlUnsupported(true); + } else { + log.error('browser_control_get_status failed', error); + } log.error('browser_control_get_status failed', error); } finally { setBrowserStatusLoading(false); @@ -231,6 +264,21 @@ const SessionSettingsPanels: React.FC = ({ variant } .catch((error) => log.warn('getSystemInfo failed', error)); }, [refreshComputerUseStatus, refreshBrowserControlStatus, setComputerUseEnabled]); + // Browser Control / Computer Use route to the rendered host. Re-probe on every + // surface switch (local ↔ peer A ↔ peer B): a CLI Peer returns unsupported, + // a Desktop Peer / local host returns status. Resets the unsupported flag so + // a switch away from a CLI Peer re-shows controls instead of the notice. + // The current peer's deviceId is part of the dep so A→B (both peers) fires. + const renderedPeerDeviceId = peerDevice?.peerMode.active + ? peerDevice.peerMode.deviceId + : null; + useEffect(() => { + if (!IS_TAURI_DESKTOP) return; + setPeerBrowserControlUnsupported(false); + void refreshComputerUseStatus(); + void refreshBrowserControlStatus(); + }, [peerModeActive, renderedPeerDeviceId, refreshComputerUseStatus, refreshBrowserControlStatus]); + const loadAllData = useCallback(async () => { setIsLoading(true); try { @@ -1315,7 +1363,7 @@ const SessionSettingsPanels: React.FC = ({ variant } IS_TAURI_DESKTOP ? t('computerUse.sectionDescription') : t('computerUse.desktopOnly') } > - {IS_TAURI_DESKTOP ? ( + {IS_TAURI_DESKTOP && !peerBrowserControlUnsupported ? ( <>
@@ -1443,6 +1491,14 @@ const SessionSettingsPanels: React.FC = ({ variant }
)} + ) : peerBrowserControlUnsupported ? ( + + + ) : null} @@ -1453,7 +1509,7 @@ const SessionSettingsPanels: React.FC = ({ variant } IS_TAURI_DESKTOP ? t('browserControl.sectionDescription') : t('browserControl.desktopOnly') } > - {IS_TAURI_DESKTOP ? ( + {IS_TAURI_DESKTOP && !peerBrowserControlUnsupported ? ( <> {/* Only show browser selector when CDP is not connected */} {!browserCdpAvailable && ( @@ -1595,6 +1651,14 @@ const SessionSettingsPanels: React.FC = ({ variant }
+ ) : peerBrowserControlUnsupported ? ( + + + ) : null} diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts index 919d71c886..8e93b915f6 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts @@ -33,6 +33,41 @@ describe('PeerConnectionManager attach', () => { expect(manager.get('peer-1')).toBe(connection); }); + it('reports new capability fields as null (unknown) when the host omits them', async () => { + // An older host's peer_mode_ping does not advertise cancel_tool / + // tool_catalog; coercing those to `false` would hide a working capability + // on an older Desktop (Interrupt button / tool list). They must parse to + // `null` so consumers stay optimistic. See PR #2428 #4. + const rpc = createRpc(); + const manager = createManager(rpc.deviceRpc); + + const connection = await manager.connect('peer-1', 'Studio'); + const caps = connection.getState().capabilities; + expect(caps.cancelTool).toBeNull(); + expect(caps.toolCatalog).toBeNull(); + }); + + it('parses advertised new capability fields as true', async () => { + const manager = new PeerConnectionManager({ + deviceRpc: async () => JSON.stringify({ + resp: 'host_invoke_result', + ok: true, + value: { + capabilities: { + cancel_tool: true, + tool_catalog: true, + }, + }, + }), + getControllerDeviceId: async () => 'controller-1', + }); + + const connection = await manager.connect('peer-1', 'Studio'); + const caps = connection.getState().capabilities; + expect(caps.cancelTool).toBe(true); + expect(caps.toolCatalog).toBe(true); + }); + it('leaves nothing attached when the handshake fails', async () => { const rpc = createRpc({ failCommands: new Set(['peer_control_attach']) }); const manager = createManager(rpc.deviceRpc); @@ -309,6 +344,44 @@ describe('PeerConnectionManager disposal', () => { await disposing; expect(commands.at(-1)).toBe('peer_control_detach'); }); + + it('publishes a snapshot when a ready peer reports changed capabilities', async () => { + // A peer that stays `ready` but whose host changes capabilities mid-session + // (e.g. a restart on a different build) must push a fresh snapshot, or the + // UI keeps gating on stale capabilities. See PR #2428 #6. + let capabilities: Record = { cancel_tool: false, tool_catalog: false }; + const manager = new PeerConnectionManager({ + deviceRpc: async (_target, commandJson) => { + const parsed = JSON.parse(commandJson) as { command?: string }; + if (parsed.command === 'peer_mode_ping') { + return JSON.stringify({ + resp: 'host_invoke_result', + ok: true, + value: { capabilities }, + }); + } + return JSON.stringify({ resp: 'host_invoke_result', ok: true, value: null }); + }, + getControllerDeviceId: async () => 'controller-1', + keepaliveIntervalMs: KEEPALIVE_MS, + }); + + const connection = await manager.connect('peer-1', 'Studio'); + expect(connection.getState().capabilities.cancelTool).toBe(false); + + const snapshots: number[] = []; + manager.subscribe(states => snapshots.push(states.length)); + + // Host restarts advertising cancel_tool now available, without going degraded. + capabilities = { cancel_tool: true, tool_catalog: false }; + await vi.advanceTimersByTimeAsync(KEEPALIVE_MS); + + await vi.waitFor(() => { + expect(connection.getState().capabilities.cancelTool).toBe(true); + }); + // A snapshot was published for the capability change while staying ready. + expect(snapshots.length).toBeGreaterThan(0); + }); }); function createManager( diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts index 00fcb202c9..0260441c27 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts @@ -43,10 +43,20 @@ export interface PeerHostCapabilities { readonly idempotentDialogSubmit: boolean; readonly targetedSessionRollback: boolean; readonly tokenUsageStatistics: boolean; - /** Host implements `cancel_tool` (per-tool interrupt). Gates the Terminal Interrupt button. */ - readonly cancelTool: boolean; - /** Host implements `get_all_tools_info` (read-only tool catalog). Gates the Agents/Assistant tool list. */ - readonly toolCatalog: boolean; + /** + * Host implements `cancel_tool` (per-tool interrupt). Gates the Terminal + * Interrupt button. `null` = the host's `peer_mode_ping` did not advertise + * the field (older host): consumers treat `null` as optimistic-supported so + * an older Desktop that actually implements cancel_tool keeps a working + * button, while an older CLI that doesn't returns unsupported on invoke. + */ + readonly cancelTool: boolean | null; + /** + * Host implements `get_all_tools_info` (read-only tool catalog). Gates the + * Agents/Assistant tool list. `null` = the host did not advertise the field + * (older host); consumers treat `null` as optimistic-supported. + */ + readonly toolCatalog: boolean | null; } /** Immutable view of one connection; safe to hold in component state. */ @@ -117,8 +127,10 @@ const NO_CAPABILITIES: PeerHostCapabilities = { idempotentDialogSubmit: false, targetedSessionRollback: false, tokenUsageStatistics: false, - cancelTool: false, - toolCatalog: false, + // Unknown (not yet probed) — not the same as `false` (probed, unsupported). + // Consumers treat `null` optimistically so an unprobed host is not gated off. + cancelTool: null, + toolCatalog: null, }; interface ConnectionEntry { @@ -363,12 +375,19 @@ export class PeerConnectionManager { private async probeCapabilities(deviceId: string): Promise { const result = await this.hostInvoke(deviceId, 'peer_mode_ping', {}); + // For the new fields (cancel_tool / tool_catalog) preserve `undefined` as + // `null` (unknown) rather than coercing to `false`: an older Desktop that + // does not advertise the field but does implement the command would + // otherwise have its working capability hidden. `null` lets consumers + // stay optimistic; an older CLI that truly lacks the command returns + // unsupported on invoke, which the UI already handles. See PR #2428 #4. + const caps = result?.capabilities; return { - idempotentDialogSubmit: result?.capabilities?.idempotent_dialog_submit === true, - targetedSessionRollback: result?.capabilities?.targeted_session_rollback === true, - tokenUsageStatistics: result?.capabilities?.token_usage_statistics === true, - cancelTool: result?.capabilities?.cancel_tool === true, - toolCatalog: result?.capabilities?.tool_catalog === true, + idempotentDialogSubmit: caps?.idempotent_dialog_submit === true, + targetedSessionRollback: caps?.targeted_session_rollback === true, + tokenUsageStatistics: caps?.token_usage_statistics === true, + cancelTool: caps?.cancel_tool === undefined ? null : caps.cancel_tool === true, + toolCatalog: caps?.tool_catalog === undefined ? null : caps.tool_catalog === true, }; } @@ -421,6 +440,7 @@ export class PeerConnectionManager { if (this.entries.get(entry.deviceId) !== entry) { return; } + const previousCapabilities = entry.capabilities; entry.capabilities = capabilities; entry.adapter.setHostCapabilities({ supportsIdempotentDialogSubmit: capabilities.idempotentDialogSubmit, @@ -431,8 +451,15 @@ export class PeerConnectionManager { const recovered = entry.health !== 'ready'; entry.health = 'ready'; this.scheduleKeepalive(entry); - if (recovered) { - log.info('Peer connection recovered', { deviceId: entry.deviceId }); + // Publish when the host's advertised capabilities changed too, not only + // on recovery: a peer that stayed `ready` but restarted on a different + // build mid-session must push a fresh React snapshot, or UI keeps gating + // on stale capabilities (e.g. a tool-catalog flag flipping). See #2428 #6. + const capabilitiesChanged = !capabilitiesEqual(previousCapabilities, capabilities); + if (recovered || capabilitiesChanged) { + if (recovered) { + log.info('Peer connection recovered', { deviceId: entry.deviceId }); + } this.publish(); } } catch (error) { @@ -573,5 +600,24 @@ export class PeerConnectionManager { } } +/** + * Shallow-compare the capability fields a React snapshot exposes. Used by the + * keepalive path to decide whether a fresh `publish()` is warranted when a + * `ready` peer's host reports different capabilities (e.g. after a restart on + * a different build) without a state transition. `null` (unknown) and a boolean + * are intentionally distinct: an unprobed field flipping to a concrete value is + * a change the UI should react to. + */ +function capabilitiesEqual( + a: PeerHostCapabilities, + b: PeerHostCapabilities, +): boolean { + return a.idempotentDialogSubmit === b.idempotentDialogSubmit && + a.targetedSessionRollback === b.targetedSessionRollback && + a.tokenUsageStatistics === b.tokenUsageStatistics && + a.cancelTool === b.cancelTool && + a.toolCatalog === b.toolCatalog; +} + /** Window-wide instance; peer links outlive any component that renders them. */ export const peerConnectionManager = new PeerConnectionManager(); diff --git a/src/web-ui/src/locales/en-US/scenes/profile.json b/src/web-ui/src/locales/en-US/scenes/profile.json index e1f67011fa..cfba423750 100644 --- a/src/web-ui/src/locales/en-US/scenes/profile.json +++ b/src/web-ui/src/locales/en-US/scenes/profile.json @@ -97,6 +97,8 @@ "rules": "No rules configured", "memory": "No memories", "tools": "No tools available", + "toolsUnsupported": "The connected peer host does not expose a tool catalog.", + "toolsFailed": "Could not load the tool catalog. Try refreshing.", "skills": "No skills" }, diff --git a/src/web-ui/src/locales/en-US/settings/session-config.json b/src/web-ui/src/locales/en-US/settings/session-config.json index dab7fb9bb0..f78f03f852 100644 --- a/src/web-ui/src/locales/en-US/settings/session-config.json +++ b/src/web-ui/src/locales/en-US/settings/session-config.json @@ -136,6 +136,7 @@ "openSettings": "System settings", "refreshStatus": "Refresh status", "desktopOnly": "Desktop control is only available in the BitFun desktop app.", + "peerUnsupported": "Desktop control runs on the connected peer host. This peer host (CLI) does not support it, so it cannot be configured here.", "platformNote": "Note", "platformNotes": { "macos": "This build still needs Accessibility permission in System Settings.", @@ -148,6 +149,7 @@ "sectionTitle": "Browser control", "sectionDescription": "Choose the browser BitFun uses and how it connects.", "desktopOnly": "Browser control is only available in the BitFun desktop app.", + "peerUnsupported": "Browser control runs on the connected peer host. This peer host (CLI) does not support it, so it cannot be configured here.", "preferredBrowser": "Browser", "preferredBrowserDesc": "The default option follows your system browser.", "notInstalled": "not installed", diff --git a/src/web-ui/src/locales/zh-CN/scenes/profile.json b/src/web-ui/src/locales/zh-CN/scenes/profile.json index 9adf6c4daf..63ef119c72 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/profile.json +++ b/src/web-ui/src/locales/zh-CN/scenes/profile.json @@ -97,6 +97,8 @@ "rules": "暂无守则", "memory": "暂无记忆", "tools": "暂无工具信息", + "toolsUnsupported": "所连接的 Peer 主机未提供工具目录。", + "toolsFailed": "无法加载工具目录,请尝试刷新。", "skills": "暂无技能" }, diff --git a/src/web-ui/src/locales/zh-CN/settings/session-config.json b/src/web-ui/src/locales/zh-CN/settings/session-config.json index d31804bf3f..927295d31b 100644 --- a/src/web-ui/src/locales/zh-CN/settings/session-config.json +++ b/src/web-ui/src/locales/zh-CN/settings/session-config.json @@ -136,6 +136,7 @@ "openSettings": "系统设置", "refreshStatus": "刷新状态", "desktopOnly": "桌面控制仅在 BitFun 桌面应用中可用。", + "peerUnsupported": "桌面控制运行在所连接的 Peer 主机上。该 Peer 主机(CLI)不支持此功能,无法在此配置。", "platformNote": "说明", "platformNotes": { "macos": "当前构建仍需在系统设置中授予辅助功能权限。", @@ -148,6 +149,7 @@ "sectionTitle": "浏览器控制", "sectionDescription": "选择 BitFun 使用的浏览器和连接方式。", "desktopOnly": "浏览器控制仅在 BitFun 桌面应用中可用。", + "peerUnsupported": "浏览器控制运行在所连接的 Peer 主机上。该 Peer 主机(CLI)不支持此功能,无法在此配置。", "preferredBrowser": "浏览器", "preferredBrowserDesc": "默认使用系统浏览器。", "notInstalled": "未安装", diff --git a/src/web-ui/src/locales/zh-TW/scenes/profile.json b/src/web-ui/src/locales/zh-TW/scenes/profile.json index 065eb41625..5bbc7abc65 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/profile.json +++ b/src/web-ui/src/locales/zh-TW/scenes/profile.json @@ -97,6 +97,8 @@ "rules": "暫無守則", "memory": "暫無記憶", "tools": "暫無工具資訊", + "toolsUnsupported": "所連接的 Peer 主機未提供工具目錄。", + "toolsFailed": "無法載入工具目錄,請嘗試重新整理。", "skills": "暫無技能" }, diff --git a/src/web-ui/src/locales/zh-TW/settings/session-config.json b/src/web-ui/src/locales/zh-TW/settings/session-config.json index 39f51ff2d3..9e8f53cd6f 100644 --- a/src/web-ui/src/locales/zh-TW/settings/session-config.json +++ b/src/web-ui/src/locales/zh-TW/settings/session-config.json @@ -136,6 +136,7 @@ "openSettings": "系統設置", "refreshStatus": "重新整理狀態", "desktopOnly": "桌面控制僅在 BitFun 桌面應用程式中可用。", + "peerUnsupported": "桌面控制執行於所連接的 Peer 主機。該 Peer 主機(CLI)不支援此功能,無法在此設定。", "platformNote": "說明", "platformNotes": { "macos": "目前版本仍需在系統設定中授予輔助功能權限。", @@ -148,6 +149,7 @@ "sectionTitle": "瀏覽器控制", "sectionDescription": "選擇 BitFun 使用的瀏覽器和連線方式。", "desktopOnly": "瀏覽器控制僅在 BitFun 桌面應用中可用。", + "peerUnsupported": "瀏覽器控制執行於所連接的 Peer 主機。該 Peer 主機(CLI)不支援此功能,無法在此設定。", "status": "連接狀態", "statusDesc": "", "notConnected": "未連接", From 56ff5e12bd6abbe7bdd35fd7292ae1163417b6b7 Mon Sep 17 00:00:00 2001 From: weishao Date: Tue, 25 Aug 2026 20:05:13 +0800 Subject: [PATCH 8/9] fix(web-ui): resolve mixed-version peer capabilities via host_type field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 review fixes for PR #2428 (reviewer limityan, 2026-08-25). P1 mixed-version cancel_tool: - Add declarative `host_type: "desktop"|"cli"` to peer_mode_ping on both Rust sides (desktop peer_host_invoke.rs, cli control.rs). - Parse into PeerHostCapabilities.hostKind (PeerConnectionManager.ts); null when an older host omits the field. - New resolveCanCancelTool() (terminalToolCardState.ts): null capability resolves by hostKind — old CLI hides the button, old Desktop shows it, unknown stays optimistic. Replaces the inline IIFE in TerminalToolCard. - Add a failure toast (toolCards.terminal.interruptFailed) in the catch path. P2 mixed-version tool_catalog: - New peerCapabilityResolution.ts with canQueryToolCatalogOnSurface() mirroring the cancel_tool resolution (null + cli -> unsupported). Replaces inline IIFEs in useAgentsList + AssistantDefaultsPage. - Surface tool-catalog status: AgentsScene renders an unsupported/failed message (agentsOverview.toolsUnsupported/toolsFailed) and gates writes; AssistantDefaultsPage MCP zone distinguishes unsupported/failed/empty and disables all tool writes (Switch/reset/group-toggle) when not writable. P2 browser/computer-use read reclassification: - Add browser_control_get_status, browser_control_list_browsers, computer_use_get_status to RETRYABLE_READ_COMMANDS (prefix matching had misclassified them as mutations). Tests on all sides: desktop peer_host_invoke host_type assertion, cli peer_mode_ping_advertises_cli_host_type, PeerConnectionManager hostKind parsing, terminalToolCardState resolveCanCancelTool cases, new peerCapabilityResolution suite, adapter read reclassification, AgentsScene unsupported-catalog rendering. Co-Authored-By: Claude --- src/apps/cli/src/peer_host/control.rs | 7 +++ src/apps/cli/src/peer_host/dispatch.rs | 19 ++++++ src/apps/desktop/src/api/peer_host_invoke.rs | 13 ++++ .../app/scenes/agents/AgentsScene.test.tsx | 48 +++++++++++++++ .../src/app/scenes/agents/AgentsScene.tsx | 23 ++++++- .../app/scenes/agents/hooks/useAgentsList.ts | 21 +++---- .../profile/views/AssistantDefaultsPage.tsx | 61 ++++++++++++------- .../flow_chat/tool-cards/TerminalToolCard.tsx | 39 ++++++------ .../tool-cards/terminalToolCardState.test.ts | 46 +++++++++++++- .../tool-cards/terminalToolCardState.ts | 39 ++++++++++++ .../api/adapters/peer-device-adapter.test.ts | 14 +++++ .../api/adapters/peer-device-adapter.ts | 10 +++ .../peer-device/PeerConnectionManager.test.ts | 35 +++++++++++ .../peer-device/PeerConnectionManager.ts | 37 ++++++++--- .../PeerDeviceSurfaceController.test.ts | 1 + .../peerCapabilityResolution.test.ts | 50 +++++++++++++++ .../peer-device/peerCapabilityResolution.ts | 37 +++++++++++ src/web-ui/src/locales/en-US/flow-chat.json | 3 +- .../src/locales/en-US/scenes/agents.json | 2 + src/web-ui/src/locales/zh-CN/flow-chat.json | 3 +- .../src/locales/zh-CN/scenes/agents.json | 2 + src/web-ui/src/locales/zh-TW/flow-chat.json | 3 +- .../src/locales/zh-TW/scenes/agents.json | 2 + 23 files changed, 446 insertions(+), 69 deletions(-) create mode 100644 src/web-ui/src/infrastructure/peer-device/peerCapabilityResolution.test.ts create mode 100644 src/web-ui/src/infrastructure/peer-device/peerCapabilityResolution.ts diff --git a/src/apps/cli/src/peer_host/control.rs b/src/apps/cli/src/peer_host/control.rs index ecf79f6c7e..b35a87688f 100644 --- a/src/apps/cli/src/peer_host/control.rs +++ b/src/apps/cli/src/peer_host/control.rs @@ -100,6 +100,13 @@ pub(crate) fn peer_mode_ping_value() -> Value { "ok": true, "peer": true, "device_id": device_id, + // Declares which kind of host answered so the controller can resolve + // capabilities that an older CLI did not advertise. An older CLI + // (pre-`50b76516`) omits `cancel_tool`/`tool_catalog` and never + // implemented them; reporting `host_type: "cli"` lets the controller + // gate the Terminal Interrupt button / tool list off instead of showing + // an action that silently fails. See PR #2428 round 5 #1. + "host_type": "cli", "capabilities": { "idempotent_dialog_submit": true, "targeted_session_rollback": true, diff --git a/src/apps/cli/src/peer_host/dispatch.rs b/src/apps/cli/src/peer_host/dispatch.rs index a36d036327..bd74a51f00 100644 --- a/src/apps/cli/src/peer_host/dispatch.rs +++ b/src/apps/cli/src/peer_host/dispatch.rs @@ -171,6 +171,25 @@ mod tests { } } + #[tokio::test] + async fn peer_mode_ping_advertises_cli_host_type() { + // An older CLI did not advertise `cancel_tool`/`tool_catalog`; the + // `host_type: "cli"` field lets the controller resolve those missing + // capabilities as unsupported instead of optimistically invoking a + // command the CLI never implemented. See PR #2428 round 5 #1. + let resp = handle_host_invoke("peer_mode_ping", json!({})).await; + match resp { + RemoteResponse::HostInvokeResult { + ok: true, + value: Some(value), + error: None, + } => { + assert_eq!(value.get("host_type").and_then(|v| v.as_str()), Some("cli")); + } + other => panic!("unexpected response: {other:?}"), + } + } + #[tokio::test] async fn local_only_commands_are_denied() { let resp = handle_host_invoke("account_logout", json!({})).await; diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index 8f9bf098d2..3541f092cf 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -421,6 +421,15 @@ pub async fn peer_mode_ping() -> Result { "peer": true, "device_id": current_device_id_for_peer() .unwrap_or_else(|_| "unknown".to_string()), + // Declares which kind of host answered so the controller can resolve + // capabilities that an older host did not advertise. An older Desktop + // (pre-`50b76516`) omits `cancel_tool`/`tool_catalog` but still reports + // `host_type: "desktop"` — and Desktop has always implemented both — so + // the controller keeps the Interrupt button / tool list. An older CLI + // reports `host_type: "cli"` and never implemented them, so the + // controller gates them off instead of showing an action that silently + // fails. See PR #2428 round 5 #1. + "host_type": "desktop", "capabilities": { "idempotent_dialog_submit": true, "targeted_session_rollback": true, @@ -532,6 +541,10 @@ mod tests { #[tokio::test] async fn peer_ping_advertises_mutation_capabilities() { let value = peer_mode_ping().await.expect("peer ping"); + assert_eq!( + value.get("host_type").and_then(Value::as_str), + Some("desktop") + ); assert_eq!( value .pointer("/capabilities/idempotent_dialog_submit") diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx index 030c780218..5d40d38339 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx @@ -116,6 +116,7 @@ function mockAgentsList(overrides: Record = {}) { filteredAgents: [], loading: false, availableTools: [], + toolCatalogStatus: 'available', getModeProfile: () => null, getAgentSkills: () => [], getModeManageableSubagents: () => [], @@ -377,4 +378,51 @@ describeWithJsdom('AgentsScene', () => { expect(summary?.textContent).toBe('Read'); expect(summary?.textContent).not.toContain('mcp__github__list_issues'); }); + + it('surfaces an unsupported tool catalog in the tools tab instead of an empty list', async () => { + // When the host can't answer get_all_tools_info the tools tab must say so + // and disable editing, rather than rendering as "no tools". See PR #2428 + // round 5 #2. + const mode = { + key: 'mode::custom-mode', + id: 'custom-mode', + name: 'Custom mode', + description: 'General coding mode.', + isReadonly: false, + isReview: false, + toolCount: 1, + defaultTools: ['Read'], + defaultEnabled: true, + effectiveEnabled: true, + source: 'user', + agentKind: 'mode' as const, + capabilities: [], + }; + mockAgentsList({ + allAgents: [mode], + filteredAgents: [mode], + availableTools: [], + toolCatalogStatus: 'unsupported', + getModeConfig: () => ({ + profile_id: 'custom-mode', + enabled_tools: ['Read'], + default_tools: ['Read'], + }), + }); + const { default: AgentsScene } = await import('./AgentsScene'); + + await act(async () => { + root.render(); + }); + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === mode.name) + ?.click(); + }); + + const status = container.querySelector('[data-testid="agent-detail-tools-catalog-status"]'); + expect(status?.textContent).toContain('agentsOverview.toolsUnsupported'); + // The tool summary picker must not render — the catalog is not available. + expect(container.querySelector('[data-testid="agent-detail-tool-summary"]')).toBeNull(); + }); }); diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index f92488e9fe..6a44bd90eb 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -215,6 +215,7 @@ const AgentsHomeView: React.FC = () => { filteredAgents, loading, availableTools, + toolCatalogStatus, configuredModels = [], getModeProfile, getAgentSkills, @@ -236,6 +237,18 @@ const AgentsHomeView: React.FC = () => { t, }); + // Tool-catalog load state from the host (available / unsupported / failed / + // empty). When the host doesn't expose a catalog or the read failed, the + // tools tab must say so instead of rendering as "no tools". Writes are gated + // off too — toggling against a failed catalog would save a config the host + // can't act on. See PR #2428 round 5 #2. + const toolCatalogWritable = toolCatalogStatus === 'available' || toolCatalogStatus === 'empty'; + const toolCatalogMessage = toolCatalogStatus === 'unsupported' + ? t('agentsOverview.toolsUnsupported') + : toolCatalogStatus === 'failed' + ? t('agentsOverview.toolsFailed') + : null; + useGallerySceneAutoRefresh({ sceneId: 'agents', refetch: () => { @@ -1090,8 +1103,10 @@ const AgentsHomeView: React.FC = () => {