From 806aef8ba5f130eae761517fc3e978a0d03b99db Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sat, 11 Apr 2026 17:04:33 +0800 Subject: [PATCH] Align app bridge API contract --- .../2026-04-11-app-bridge-api-alignment.md | 45 ++++ ...ntroller_desktop_external_acp_routing.dart | 2 +- ...ler_desktop_runtime_coordination_impl.dart | 2 +- ...ler_desktop_single_agent_go_task_flow.dart | 7 +- ..._controller_desktop_skill_permissions.dart | 5 + ...app_controller_desktop_thread_actions.dart | 12 +- ...pp_controller_desktop_thread_sessions.dart | 29 +++ ...ontroller_desktop_workspace_execution.dart | 26 ++ .../assistant_page_composer_bar.dart | 143 ++++++----- .../assistant_page_state_actions.dart | 52 ++-- ...rnal_code_agent_acp_desktop_transport.dart | 8 + lib/runtime/go_task_service_client.dart | 19 +- lib/runtime/runtime_models_connection.dart | 3 +- .../runtime_models_runtime_payloads.dart | 21 ++ ...ntroller_desktop_runtime_cleanup_test.dart | 5 +- ...ontroller_desktop_thread_binding_test.dart | 58 ++++- ...sktop_working_directory_dispatch_test.dart | 206 ++++++++++++++++ ...t_execution_target_picker_widget_test.dart | 33 ++- .../assistant_page_composer_golden_test.dart | 232 ++++++++++++++++++ ...istant_page_composer_working_directory.png | Bin 0 -> 8101 bytes .../external_acp_bridge_sync_order_test.dart | 2 +- ...code_agent_acp_desktop_transport_test.dart | 21 +- 22 files changed, 800 insertions(+), 131 deletions(-) create mode 100644 docs/feature/2026-04-11-app-bridge-api-alignment.md create mode 100644 test/app_controller_desktop_working_directory_dispatch_test.dart create mode 100644 test/features/assistant/assistant_page_composer_golden_test.dart create mode 100644 test/features/assistant/goldens/assistant_page_composer_working_directory.png diff --git a/docs/feature/2026-04-11-app-bridge-api-alignment.md b/docs/feature/2026-04-11-app-bridge-api-alignment.md new file mode 100644 index 00000000..f226729f --- /dev/null +++ b/docs/feature/2026-04-11-app-bridge-api-alignment.md @@ -0,0 +1,45 @@ +# APP 侧对齐当前 xworkmate-bridge API + +本轮 APP 侧对接以当前 `xworkmate-bridge` 实际返回为准,不再额外定义前端私有 contract。 + +## 当前后端事实 + +- `acp.capabilities` 当前继续返回: + - `singleAgent` + - `multiAgent` + - `providerCatalog` + - `gatewayProviders` +- `xworkmate.routing.resolve` 当前继续返回: + - `resolvedExecutionTarget` + - `resolvedEndpointTarget` + - `resolvedProviderId` + - `resolvedGatewayProviderId` +- `session.start` / `session.message` 当前请求仍消费线程级 `workingDirectory` +- 当前 bridge 还没有项目列表接口 + +## APP 侧执行约定 + +- APP 模式选择入口只暴露: + - `single-agent` + - `gateway` +- `multi-agent` 仍作为 bridge 可返回状态被解析和展示,但不再作为用户主动选择入口 +- 线程级“项目选择”当前直接等价于 bridge 请求里的 `workingDirectory` +- `workingDirectory` 与本地 `workspaceBinding` 分离: + - `workingDirectory`: 发给 bridge 的执行目录 + - `workspaceBinding`: APP 本地 artifact 回写目录 + +## 当前实现结果 + +- 每个线程持久化 `selectedWorkingDirectory` +- `single-agent` 与 `gateway` 都复用同一个线程级 `selectedWorkingDirectory` +- follow-up 请求继续沿用: + - `sessionId == threadId == sessionKey` + - 同一线程绑定的 `workingDirectory` +- 若线程没有选项目目录,APP 会阻断发送并提示先选择项目 + +## 兼容策略 + +- 继续解析 `resolvedEndpointTarget`,但它不再作为前端主状态来源 +- 继续解析 `multiAgent`,但不提供手动切换入口 +- `providerCatalog` 继续驱动 single-agent provider picker +- `gatewayProviders` 继续按 bridge 返回结构保存和消费,不在 APP 侧硬编码扩展 diff --git a/lib/app/app_controller_desktop_external_acp_routing.dart b/lib/app/app_controller_desktop_external_acp_routing.dart index 70adfb3e..94f735a8 100644 --- a/lib/app/app_controller_desktop_external_acp_routing.dart +++ b/lib/app/app_controller_desktop_external_acp_routing.dart @@ -121,7 +121,7 @@ extension AppControllerDesktopExternalAcpRouting on AppController { String _routingExecutionTargetValueInternal(AssistantExecutionTarget target) { return switch (target) { - AssistantExecutionTarget.singleAgent => 'agent', + AssistantExecutionTarget.singleAgent => 'single-agent', AssistantExecutionTarget.gateway => 'gateway', }; } diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index 37bf0bd2..da21fd4c 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -158,7 +158,7 @@ String? assistantWorkingDirectoryForSessionRuntimeInternal( String sessionKey, ) { final candidate = controller - .assistantWorkspacePathForSession(sessionKey) + .assistantSelectedWorkingDirectoryForSession(sessionKey) .trim(); if (candidate.isEmpty) { return null; diff --git a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart index 488ab693..0470dd23 100644 --- a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart +++ b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart @@ -94,7 +94,7 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( ); final unavailableReason = routingResolution.unavailable || - (routingResolution.resolvedExecutionTarget == 'agent' && + (routingResolution.resolvedExecutionTarget == 'single-agent' && effectiveProvider == null) ? (routingResolution.unavailableMessage.isNotEmpty ? routingResolution.unavailableMessage @@ -176,7 +176,7 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( routing, routingResolution, ), - routingHint: 'agent', + routingHint: 'single-agent', provider: effectiveProvider ?? SingleAgentProvider.unspecified, remoteWorkingDirectoryHint: controller @@ -238,7 +238,8 @@ ExternalCodeAgentAcpRoutingConfig _resolvedRoutingConfigDesktopInternal( final explicitExecutionTarget = switch (resolution.resolvedExecutionTarget .trim() .toLowerCase()) { - 'agent' => 'agent', + 'single-agent' => 'single-agent', + 'multi-agent' => 'multi-agent', 'gateway' => 'gateway', _ => original.explicitExecutionTarget, }; diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index 08dc23b1..ea0dd13a 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -237,6 +237,8 @@ extension AppControllerDesktopSkillPermissions on AppController { ThreadSelectionSource? singleAgentProviderSource, ThreadSelectionSource? assistantModelSource, ThreadSelectionSource? selectedSkillsSource, + String? selectedWorkingDirectory, + bool clearSelectedWorkingDirectory = false, String? gatewayEntryState, String? latestResolvedRuntimeModel, String? lifecycleStatus, @@ -348,6 +350,7 @@ extension AppControllerDesktopSkillPermissions on AppController { permissionLevel: AssistantPermissionLevel.defaultAccess, messageViewMode: AssistantMessageViewMode.rendered, latestResolvedRuntimeModel: '', + selectedWorkingDirectory: null, gatewayEntryState: gatewayEntryStateForTargetInternal( nextExecutionTarget, ), @@ -372,6 +375,8 @@ extension AppControllerDesktopSkillPermissions on AppController { selectedSkillsSource ?? existing?.contextState.selectedSkillsSource, latestResolvedRuntimeModel: latestResolvedRuntimeModel, + selectedWorkingDirectory: selectedWorkingDirectory?.trim(), + clearSelectedWorkingDirectory: clearSelectedWorkingDirectory, gatewayEntryState: gatewayEntryState, lastRemoteWorkingDirectory: lastRemoteWorkingDirectory, lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind, diff --git a/lib/app/app_controller_desktop_thread_actions.dart b/lib/app/app_controller_desktop_thread_actions.dart index 3e885f36..2d2537dd 100644 --- a/lib/app/app_controller_desktop_thread_actions.dart +++ b/lib/app/app_controller_desktop_thread_actions.dart @@ -247,14 +247,14 @@ extension AppControllerDesktopThreadActions on AppController { currentSessionKey, executionTarget: currentTarget, ); - var workspacePath = assistantWorkspacePathForSession( + final workingDirectory = assistantSelectedWorkingDirectoryForSession( currentSessionKey, ).trim(); - if (workspacePath.isEmpty) { + if (workingDirectory.isEmpty) { final error = StateError( appText( - '当前线程缺少工作路径,无法运行。请先配置工作区根目录后再试。', - 'This thread has no workspace path, so it cannot run. Configure a workspace root and try again.', + '当前线程尚未选择项目目录,无法运行。请先选择项目。', + 'This thread has no project directory yet. Select a project before running.', ), ); appendAssistantThreadMessageInternal( @@ -312,9 +312,7 @@ extension AppControllerDesktopThreadActions on AppController { threadId: sessionKey, target: currentTarget, prompt: message, - workingDirectory: assistantWorkspacePathForSession( - sessionKey, - ).trim(), + workingDirectory: workingDirectory, model: assistantModelForSession(sessionKey), thinking: thinking, selectedSkills: selectedSkillLabels, diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart index 80e13b04..d78c5984 100644 --- a/lib/app/app_controller_desktop_thread_sessions.dart +++ b/lib/app/app_controller_desktop_thread_sessions.dart @@ -216,6 +216,35 @@ extension AppControllerDesktopThreadSessions on AppController { ''; } + String assistantSelectedWorkingDirectoryForSession(String sessionKey) { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + return taskThreadForSessionInternal( + normalizedSessionKey, + )?.selectedWorkingDirectory?.trim() ?? + ''; + } + + String assistantSelectedWorkingDirectoryDisplayLabelForSession( + String sessionKey, + ) { + final workingDirectory = assistantSelectedWorkingDirectoryForSession( + sessionKey, + ); + if (workingDirectory.isEmpty) { + return appText('选择项目', 'Select Project'); + } + final segments = workingDirectory + .split(RegExp(r'[\\/]')) + .where((item) => item.trim().isNotEmpty) + .toList(growable: false); + if (segments.isEmpty) { + return workingDirectory; + } + return segments.last; + } + Future loadAssistantArtifactSnapshot({ String? sessionKey, }) { diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart index c86efa47..1f105527 100644 --- a/lib/app/app_controller_desktop_workspace_execution.dart +++ b/lib/app/app_controller_desktop_workspace_execution.dart @@ -372,6 +372,32 @@ extension AppControllerDesktopWorkspaceExecution on AppController { notifyIfActiveInternal(); } + Future saveAssistantSelectedWorkingDirectoryForSession( + String sessionKey, + String workingDirectory, + ) async { + final normalizedSessionKey = normalizedAssistantSessionKeyInternal( + sessionKey, + ); + final normalizedWorkingDirectory = workingDirectory.trim(); + if (normalizedWorkingDirectory.isEmpty) { + upsertTaskThreadInternal( + normalizedSessionKey, + clearSelectedWorkingDirectory: true, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + } else { + upsertTaskThreadInternal( + normalizedSessionKey, + selectedWorkingDirectory: normalizedWorkingDirectory, + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + } + recomputeTasksInternal(); + notifyIfActiveInternal(); + await flushAssistantThreadPersistenceInternal(); + } + Future refreshSingleAgentSkillsForSession(String sessionKey) async { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, diff --git a/lib/features/assistant/assistant_page_composer_bar.dart b/lib/features/assistant/assistant_page_composer_bar.dart index 1a544d92..f11fec2e 100644 --- a/lib/features/assistant/assistant_page_composer_bar.dart +++ b/lib/features/assistant/assistant_page_composer_bar.dart @@ -343,6 +343,31 @@ class ComposerBarStateInternal extends State { }); } + Future pickWorkingDirectoryInternal() async { + final controller = widget.controller; + final sessionKey = controller.currentSessionKey; + final selectedWorkingDirectory = controller + .assistantSelectedWorkingDirectoryForSession(sessionKey) + .trim(); + final fallbackWorkspacePath = controller + .assistantWorkspacePathForSession(sessionKey) + .trim(); + final initialDirectory = selectedWorkingDirectory.isNotEmpty + ? selectedWorkingDirectory + : fallbackWorkspacePath; + final pickedDirectory = await getDirectoryPath( + confirmButtonText: appText('选择项目', 'Select Project'), + initialDirectory: initialDirectory.isNotEmpty ? initialDirectory : null, + ); + if (!mounted || pickedDirectory == null || pickedDirectory.trim().isEmpty) { + return; + } + await controller.saveAssistantSelectedWorkingDirectoryForSession( + sessionKey, + pickedDirectory, + ); + } + @override Widget build(BuildContext context) { final palette = context.palette; @@ -375,6 +400,15 @@ class ComposerBarStateInternal extends State { final selectedSkills = widget.availableSkills .where((skill) => widget.selectedSkillKeys.contains(skill.key)) .toList(growable: false); + final selectedWorkingDirectory = controller + .assistantSelectedWorkingDirectoryForSession( + controller.currentSessionKey, + ) + .trim(); + final selectedWorkingDirectoryLabel = controller + .assistantSelectedWorkingDirectoryDisplayLabelForSession( + controller.currentSessionKey, + ); final submitLabel = connected ? appText('提交', 'Submit') : singleAgent @@ -471,6 +505,57 @@ class ComposerBarStateInternal extends State { ), const SizedBox(width: 4), ], + Tooltip( + message: selectedWorkingDirectory.isEmpty + ? appText( + '选择 bridge 执行使用的项目目录(workingDirectory)', + 'Choose the bridge project directory (workingDirectory).', + ) + : selectedWorkingDirectory, + child: InkWell( + key: const Key('assistant-working-directory-button'), + onTap: () => unawaited(pickWorkingDirectoryInternal()), + borderRadius: BorderRadius.circular(AppRadius.chip), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: palette.surfacePrimary, + borderRadius: BorderRadius.circular(AppRadius.chip), + border: Border.all(color: palette.strokeSoft), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.folder_open_rounded, + size: 16, + color: selectedWorkingDirectory.isEmpty + ? palette.textMuted + : palette.textPrimary, + ), + const SizedBox(width: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: Text( + selectedWorkingDirectoryLabel, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: selectedWorkingDirectory.isEmpty + ? palette.textMuted + : palette.textPrimary, + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 4), if (singleAgent) ...[ PopupMenuButton( key: const Key('assistant-single-agent-provider-button'), @@ -557,64 +642,6 @@ class ComposerBarStateInternal extends State { ), const SizedBox(width: 4), ], - if (uiFeatures.supportsMultiAgent) ...[ - Tooltip( - message: appText( - '多 Agent 协作模式(Architect 调度/文档 → Lead Engineer 主程 → Worker/Review)', - 'Multi-Agent Collaboration Mode (Architect docs/scheduler -> Lead Engineer -> Worker/Review)', - ), - child: AnimatedBuilder( - animation: controller.multiAgentOrchestrator, - builder: (context, _) { - final collab = controller.multiAgentOrchestrator; - final enabled = collab.config.enabled; - return IconButton( - key: const Key('assistant-collaboration-toggle'), - icon: Icon( - enabled - ? Icons.auto_awesome - : Icons.auto_awesome_outlined, - size: 20, - color: enabled ? Colors.orange : null, - ), - onPressed: - collab.isRunning || - controller.isMultiAgentRunPending - ? null - : () => unawaited( - controller.saveMultiAgentConfig( - collab.config.copyWith(enabled: !enabled), - ), - ), - splashRadius: 18, - ); - }, - ), - ), - AnimatedBuilder( - animation: controller.multiAgentOrchestrator, - builder: (context, _) { - final collab = controller.multiAgentOrchestrator; - if (!collab.config.enabled) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(left: 4), - child: ComposerToolbarChipInternal( - icon: Icons.hub_rounded, - tooltip: collab.config.usesAris - ? appText('多智能体模式: ARIS', 'Multi-agent mode: ARIS') - : appText('多智能体模式: 原生', 'Multi-agent mode: Native'), - showChevron: false, - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 6, - ), - ), - ); - }, - ), - ], ], ), const SizedBox(height: 8), diff --git a/lib/features/assistant/assistant_page_state_actions.dart b/lib/features/assistant/assistant_page_state_actions.dart index 4cad9bc6..b1cdeea7 100644 --- a/lib/features/assistant/assistant_page_state_actions.dart +++ b/lib/features/assistant/assistant_page_state_actions.dart @@ -140,9 +140,14 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { inputControllerInternal.clear(); try { - if (uiFeatures.supportsMultiAgent && - controller.settings.multiAgent.enabled) { - final collaborationAttachments = submittedAttachments + final attachmentPayloads = await buildAttachmentPayloadsInternal( + submittedAttachments, + ); + await controller.sendChatMessage( + prompt, + thinking: thinkingLabelInternal, + attachments: attachmentPayloads, + localAttachments: submittedAttachments .map( (item) => CollaborationAttachment( name: item.name, @@ -150,33 +155,9 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { path: item.path, ), ) - .toList(growable: false); - await controller.runMultiAgentCollaboration( - rawPrompt: rawPrompt, - composedPrompt: prompt, - attachments: collaborationAttachments, - selectedSkillLabels: selectedSkillLabels, - ); - } else { - final attachmentPayloads = await buildAttachmentPayloadsInternal( - submittedAttachments, - ); - await controller.sendChatMessage( - prompt, - thinking: thinkingLabelInternal, - attachments: attachmentPayloads, - localAttachments: submittedAttachments - .map( - (item) => CollaborationAttachment( - name: item.name, - description: item.mimeType, - path: item.path, - ), - ) - .toList(growable: false), - selectedSkillLabels: selectedSkillLabels, - ); - } + .toList(growable: false), + selectedSkillLabels: selectedSkillLabels, + ); } catch (_) { if (!mounted) { rethrow; @@ -440,11 +421,12 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal { final sessionKey = buildDraftSessionKeyInternal(widget.controller); final inheritedTarget = pickDraftThreadExecutionTargetInternal( currentTarget: widget.controller.currentAssistantExecutionTarget, - visibleTargets: widget.controller - .visibleAssistantExecutionTargets(const [ - AssistantExecutionTarget.singleAgent, - AssistantExecutionTarget.gateway, - ]), + visibleTargets: widget.controller.visibleAssistantExecutionTargets( + const [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.gateway, + ], + ), localWorkspaceAvailable: widget.controller.settings.workspacePath .trim() .isNotEmpty, diff --git a/lib/runtime/external_code_agent_acp_desktop_transport.dart b/lib/runtime/external_code_agent_acp_desktop_transport.dart index bb9aa15c..14eadfd9 100644 --- a/lib/runtime/external_code_agent_acp_desktop_transport.dart +++ b/lib/runtime/external_code_agent_acp_desktop_transport.dart @@ -40,6 +40,9 @@ class ExternalCodeAgentAcpDesktopTransport final providerCatalog = _parseProviderCatalog( result['providerCatalog'] ?? caps['providerCatalog'], ); + final gatewayProviders = _castMapList( + result['gatewayProviders'] ?? caps['gatewayProviders'], + ); return ExternalCodeAgentAcpCapabilities( singleAgent: _boolValue(result['singleAgent']) ?? @@ -50,6 +53,7 @@ class ExternalCodeAgentAcpDesktopTransport _boolValue(caps['multi_agent']) ?? true, providerCatalog: providerCatalog, + gatewayProviders: gatewayProviders, raw: result, ); } @@ -173,6 +177,10 @@ class ExternalCodeAgentAcpDesktopTransport return const []; } + List> _castMapList(Object? raw) { + return _asList(raw).map(_castMap).toList(growable: false); + } + bool? _boolValue(Object? raw) { if (raw is bool) { return raw; diff --git a/lib/runtime/go_task_service_client.dart b/lib/runtime/go_task_service_client.dart index d212f03d..25db6d42 100644 --- a/lib/runtime/go_task_service_client.dart +++ b/lib/runtime/go_task_service_client.dart @@ -9,6 +9,7 @@ class ExternalCodeAgentAcpCapabilities { required this.singleAgent, required this.multiAgent, required this.providerCatalog, + required this.gatewayProviders, required this.raw, }); @@ -16,11 +17,13 @@ class ExternalCodeAgentAcpCapabilities { : singleAgent = false, multiAgent = false, providerCatalog = const [], + gatewayProviders = const >[], raw = const {}; final bool singleAgent; final bool multiAgent; final List providerCatalog; + final List> gatewayProviders; final Map raw; } @@ -38,6 +41,9 @@ class ExternalCodeAgentAcpRoutingResolution { String get resolvedProviderId => raw['resolvedProviderId']?.toString().trim() ?? ''; + String get resolvedGatewayProviderId => + raw['resolvedGatewayProviderId']?.toString().trim() ?? ''; + String get resolvedModel => raw['resolvedModel']?.toString().trim() ?? ''; List get resolvedSkills { @@ -264,14 +270,14 @@ class GoTaskServiceRequest { String get acpMode { return switch (target) { - AssistantExecutionTarget.singleAgent => 'agent', + AssistantExecutionTarget.singleAgent => 'single-agent', AssistantExecutionTarget.gateway => _gatewaySessionMode, }; } String get routingExecutionTarget { return switch (target) { - AssistantExecutionTarget.singleAgent => 'agent', + AssistantExecutionTarget.singleAgent => 'single-agent', AssistantExecutionTarget.gateway => 'gateway', }; } @@ -345,7 +351,7 @@ class GoTaskServiceRequest { _ => 'gateway', }; final explicitExecutionTarget = switch (gatewayTarget) { - AssistantExecutionTarget.singleAgent => 'agent', + AssistantExecutionTarget.singleAgent => 'single-agent', AssistantExecutionTarget.gateway => 'gateway', }; final explicitProviderId = provider.isUnspecified @@ -582,16 +588,17 @@ String? goTaskServiceGatewayEntryState({ .trim() .toLowerCase(); if (resolvedEndpointTarget.isEmpty || - resolvedEndpointTarget == 'gateway') { + resolvedEndpointTarget == 'gateway' || + resolvedEndpointTarget == 'local' || + resolvedEndpointTarget == 'remote') { return AssistantExecutionTarget.gateway.promptValue; } throw StateError( 'Bridge protocol mismatch: unsupported resolvedEndpointTarget "$resolvedEndpointTarget".', ); - case 'agent': - return AssistantExecutionTarget.singleAgent.promptValue; case 'single-agent': case 'multi-agent': + return AssistantExecutionTarget.singleAgent.promptValue; case 'local': case 'remote': throw StateError( diff --git a/lib/runtime/runtime_models_connection.dart b/lib/runtime/runtime_models_connection.dart index 631cc1c3..56feb6f0 100644 --- a/lib/runtime/runtime_models_connection.dart +++ b/lib/runtime/runtime_models_connection.dart @@ -51,7 +51,7 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget { }; String get promptValue => switch (this) { - AssistantExecutionTarget.singleAgent => 'agent', + AssistantExecutionTarget.singleAgent => 'single-agent', AssistantExecutionTarget.gateway => 'gateway', }; @@ -66,6 +66,7 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget { final normalized = value?.trim() ?? ''; switch (normalized) { case 'singleAgent': + case 'single-agent': case 'agent': return AssistantExecutionTarget.singleAgent; case 'gateway': diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 3734ac92..1c9f8c6f 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -520,6 +520,7 @@ extension ThreadExecutionModeCopy on ThreadExecutionMode { final normalized = value?.trim(); switch (normalized) { case 'singleAgent': + case 'single-agent': case 'localAgent': case 'agent': return ThreadExecutionMode.localAgent; @@ -754,6 +755,7 @@ class ThreadContextState { required this.latestResolvedRuntimeModel, this.selectedModelSource = ThreadSelectionSource.inherited, this.selectedSkillsSource = ThreadSelectionSource.inherited, + this.selectedWorkingDirectory, this.gatewayEntryState, this.lastRemoteWorkingDirectory, this.lastRemoteWorkspaceRefKind, @@ -770,6 +772,7 @@ class ThreadContextState { final String latestResolvedRuntimeModel; final ThreadSelectionSource selectedModelSource; final ThreadSelectionSource selectedSkillsSource; + final String? selectedWorkingDirectory; final String? gatewayEntryState; final String? lastRemoteWorkingDirectory; final WorkspaceRefKind? lastRemoteWorkspaceRefKind; @@ -786,6 +789,8 @@ class ThreadContextState { String? latestResolvedRuntimeModel, ThreadSelectionSource? selectedModelSource, ThreadSelectionSource? selectedSkillsSource, + String? selectedWorkingDirectory, + bool clearSelectedWorkingDirectory = false, String? gatewayEntryState, bool clearGatewayEntryState = false, String? lastRemoteWorkingDirectory, @@ -804,6 +809,9 @@ class ThreadContextState { latestResolvedRuntimeModel ?? this.latestResolvedRuntimeModel, selectedModelSource: selectedModelSource ?? this.selectedModelSource, selectedSkillsSource: selectedSkillsSource ?? this.selectedSkillsSource, + selectedWorkingDirectory: clearSelectedWorkingDirectory + ? null + : (selectedWorkingDirectory ?? this.selectedWorkingDirectory), gatewayEntryState: clearGatewayEntryState ? null : (gatewayEntryState ?? this.gatewayEntryState), @@ -830,6 +838,7 @@ class ThreadContextState { 'latestResolvedRuntimeModel': latestResolvedRuntimeModel, 'selectedModelSource': selectedModelSource.name, 'selectedSkillsSource': selectedSkillsSource.name, + 'selectedWorkingDirectory': selectedWorkingDirectory, 'gatewayEntryState': gatewayEntryState, 'lastRemoteWorkingDirectory': lastRemoteWorkingDirectory, 'lastRemoteWorkspaceRefKind': lastRemoteWorkspaceRefKind?.name, @@ -895,6 +904,7 @@ class ThreadContextState { selectedSkillsSource: ThreadSelectionSourceCopy.fromJsonValue( json['selectedSkillsSource']?.toString(), ), + selectedWorkingDirectory: json['selectedWorkingDirectory']?.toString(), gatewayEntryState: json['gatewayEntryState']?.toString(), lastRemoteWorkingDirectory: json['lastRemoteWorkingDirectory'] ?.toString(), @@ -990,6 +1000,7 @@ class TaskThread { String? latestResolvedRuntimeModel, double? lastRunAtMs, String? lastResultCode, + String? selectedWorkingDirectory, String? lastRemoteWorkingDirectory, WorkspaceRefKind? lastRemoteWorkspaceRefKind, double? lastArtifactSyncAtMs, @@ -1027,6 +1038,10 @@ class TaskThread { messageViewMode ?? AssistantMessageViewMode.rendered, latestResolvedRuntimeModel: latestResolvedRuntimeModel?.trim() ?? '', + selectedWorkingDirectory: + selectedWorkingDirectory?.trim().isNotEmpty == true + ? selectedWorkingDirectory!.trim() + : null, gatewayEntryState: gatewayEntryState?.trim(), lastRemoteWorkingDirectory: lastRemoteWorkingDirectory?.trim().isNotEmpty == true @@ -1069,6 +1084,7 @@ class TaskThread { List get selectedSkillKeys => contextState.selectedSkillKeys; String get assistantModelId => contextState.selectedModelId; AssistantMessageViewMode get messageViewMode => contextState.messageViewMode; + String? get selectedWorkingDirectory => contextState.selectedWorkingDirectory; String? get gatewayEntryState => contextState.gatewayEntryState; String? get lastRemoteWorkingDirectory => contextState.lastRemoteWorkingDirectory; @@ -1109,6 +1125,8 @@ class TaskThread { String? assistantModelId, ThreadSelectionSource? assistantModelSource, ThreadSelectionSource? selectedSkillsSource, + String? selectedWorkingDirectory, + bool clearSelectedWorkingDirectory = false, String? gatewayEntryState, bool clearGatewayEntryState = false, String? latestResolvedRuntimeModel, @@ -1132,6 +1150,8 @@ class TaskThread { selectedModelSource: assistantModelSource, selectedSkillsSource: selectedSkillsSource, latestResolvedRuntimeModel: latestResolvedRuntimeModel, + selectedWorkingDirectory: selectedWorkingDirectory, + clearSelectedWorkingDirectory: clearSelectedWorkingDirectory, gatewayEntryState: gatewayEntryState, clearGatewayEntryState: clearGatewayEntryState, lastRemoteWorkingDirectory: lastRemoteWorkingDirectory, @@ -1248,6 +1268,7 @@ class TaskThread { 'latestResolvedRuntimeModel': json['latestResolvedRuntimeModel'], 'selectedModelSource': json['assistantModelSource'], 'selectedSkillsSource': json['selectedSkillsSource'], + 'selectedWorkingDirectory': json['selectedWorkingDirectory'], 'gatewayEntryState': json['gatewayEntryState'], 'lastRemoteWorkingDirectory': json['lastRemoteWorkingDirectory'], 'lastRemoteWorkspaceRefKind': json['lastRemoteWorkspaceRefKind'], diff --git a/test/app_controller_desktop_runtime_cleanup_test.dart b/test/app_controller_desktop_runtime_cleanup_test.dart index c65b2ebc..7c5a88ac 100644 --- a/test/app_controller_desktop_runtime_cleanup_test.dart +++ b/test/app_controller_desktop_runtime_cleanup_test.dart @@ -316,6 +316,7 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient { singleAgent: true, multiAgent: false, providerCatalog: [SingleAgentProvider.codex], + gatewayProviders: >[], raw: {}, ); } @@ -330,8 +331,8 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient { }) async { return const ExternalCodeAgentAcpRoutingResolution( raw: { - 'resolvedExecutionTarget': 'agent', - 'resolvedEndpointTarget': 'agent', + 'resolvedExecutionTarget': 'single-agent', + 'resolvedEndpointTarget': 'singleAgent', 'resolvedProviderId': 'codex', 'resolvedModel': '', 'resolvedSkills': [], diff --git a/test/app_controller_desktop_thread_binding_test.dart b/test/app_controller_desktop_thread_binding_test.dart index b14e2398..e3e585f5 100644 --- a/test/app_controller_desktop_thread_binding_test.dart +++ b/test/app_controller_desktop_thread_binding_test.dart @@ -121,16 +121,17 @@ void main() { expect(target, AssistantExecutionTarget.gateway); }); - test('falls back to remote when legacy local gateway selection is active', () { - final target = resolveGatewayExecutionTargetFromVisibleTargets( - const [ - AssistantExecutionTarget.gateway, - ], - currentTarget: AssistantExecutionTarget.gateway, - ); + test( + 'falls back to remote when legacy local gateway selection is active', + () { + final target = resolveGatewayExecutionTargetFromVisibleTargets( + const [AssistantExecutionTarget.gateway], + currentTarget: AssistantExecutionTarget.gateway, + ); - expect(target, AssistantExecutionTarget.gateway); - }); + expect(target, AssistantExecutionTarget.gateway); + }, + ); }); group('resolveGatewayThreadConnectionStateInternal', () { @@ -328,6 +329,45 @@ void main() { }, ); }); + + group('selected working directory', () { + test( + 'persists thread project directory without changing local workspace binding', + () async { + final controller = AppController(); + addTearDown(controller.dispose); + + const sessionKey = 'draft:project-dir'; + controller.initializeAssistantThreadContext( + sessionKey, + executionTarget: AssistantExecutionTarget.singleAgent, + ); + final originalWorkspacePath = controller + .assistantWorkspacePathForSession(sessionKey); + + await controller.saveAssistantSelectedWorkingDirectoryForSession( + sessionKey, + '/tmp/project-alpha', + ); + + final record = controller.requireTaskThreadForSessionInternal( + sessionKey, + ); + expect(record.selectedWorkingDirectory, '/tmp/project-alpha'); + expect( + controller.assistantSelectedWorkingDirectoryForSession(sessionKey), + '/tmp/project-alpha', + ); + expect( + controller.assistantSelectedWorkingDirectoryDisplayLabelForSession( + sessionKey, + ), + 'project-alpha', + ); + expect(record.workspaceBinding.workspacePath, originalWorkspacePath); + }, + ); + }); } class _FakeGatewayRuntime extends GatewayRuntime { diff --git a/test/app_controller_desktop_working_directory_dispatch_test.dart b/test/app_controller_desktop_working_directory_dispatch_test.dart new file mode 100644 index 00000000..91da59e3 --- /dev/null +++ b/test/app_controller_desktop_working_directory_dispatch_test.dart @@ -0,0 +1,206 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller_desktop_core.dart'; +import 'package:xworkmate/app/app_controller_desktop_thread_actions.dart'; +import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart'; +import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('thread workingDirectory dispatch', () { + test( + 'single-agent requests reuse the thread selected workingDirectory', + () async { + final client = _CapturingGoTaskServiceClient(); + final projectDir = Directory.systemTemp.createTempSync( + 'xworkmate-project-alpha-', + ); + final controller = AppController( + goTaskServiceClient: client, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + ); + addTearDown(() async { + controller.dispose(); + if (projectDir.existsSync()) { + await projectDir.delete(recursive: true); + } + }); + + const sessionKey = 'draft:single-agent-working-directory'; + controller.initializeAssistantThreadContext( + sessionKey, + executionTarget: AssistantExecutionTarget.singleAgent, + ); + await controller.switchSession(sessionKey); + await controller.saveAssistantSelectedWorkingDirectoryForSession( + sessionKey, + projectDir.path, + ); + + await controller.sendChatMessage('first turn'); + await controller.sendChatMessage('second turn'); + + expect(client.requests, hasLength(2)); + expect(client.requests.map((item) => item.sessionId).toList(), [ + sessionKey, + sessionKey, + ]); + expect(client.requests.map((item) => item.threadId).toList(), [ + sessionKey, + sessionKey, + ]); + expect( + client.requests.map((item) => item.workingDirectory).toList(), + [projectDir.path, projectDir.path], + ); + }, + ); + + test( + 'gateway threads persist their selected workingDirectory separately from workspace binding', + () async { + final projectDir = Directory.systemTemp.createTempSync( + 'xworkmate-project-beta-', + ); + final controller = AppController( + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + ); + addTearDown(() async { + controller.dispose(); + if (projectDir.existsSync()) { + await projectDir.delete(recursive: true); + } + }); + + controller.appUiStateInternal = controller.appUiState.copyWith( + savedGatewayTargets: const ['gateway'], + ); + controller.lastObservedSettingsSnapshotInternal = + controller.settingsController.snapshotInternal; + + const sessionKey = 'draft:gateway-working-directory'; + controller.initializeAssistantThreadContext( + sessionKey, + executionTarget: AssistantExecutionTarget.gateway, + ); + await controller.switchSession(sessionKey); + await controller.setAssistantExecutionTarget( + AssistantExecutionTarget.gateway, + ); + await controller.saveAssistantSelectedWorkingDirectoryForSession( + sessionKey, + projectDir.path, + ); + + final record = controller.requireTaskThreadForSessionInternal( + sessionKey, + ); + expect( + controller.assistantExecutionTargetForSession(sessionKey), + AssistantExecutionTarget.gateway, + ); + expect(record.selectedWorkingDirectory, projectDir.path); + expect(record.workspaceBinding.workspacePath, isNot(projectDir.path)); + }, + ); + }); +} + +class _CapturingGoTaskServiceClient implements GoTaskServiceClient { + final List requests = []; + + @override + Future cancelTask({ + required GoTaskServiceRoute route, + required AssistantExecutionTarget target, + required String sessionId, + required String threadId, + }) async {} + + @override + Future closeTask({ + required GoTaskServiceRoute route, + required AssistantExecutionTarget target, + required String sessionId, + required String threadId, + }) async {} + + @override + Future dispose() async {} + + @override + Future executeTask( + GoTaskServiceRequest request, { + required void Function(GoTaskServiceUpdate update) onUpdate, + }) async { + requests.add(request); + return GoTaskServiceResult( + success: true, + message: 'ok', + turnId: 'turn-${requests.length}', + raw: { + 'resolvedExecutionTarget': + request.target == AssistantExecutionTarget.gateway + ? 'gateway' + : 'single-agent', + 'resolvedEndpointTarget': + request.target == AssistantExecutionTarget.gateway + ? 'local' + : 'singleAgent', + 'resolvedProviderId': request.provider.providerId, + 'resolvedWorkingDirectory': request.workingDirectory, + }, + errorMessage: '', + resolvedModel: request.model, + route: request.route, + ); + } + + @override + Future loadExternalAcpCapabilities({ + required AssistantExecutionTarget target, + bool forceRefresh = false, + }) async { + return const ExternalCodeAgentAcpCapabilities( + singleAgent: true, + multiAgent: true, + providerCatalog: [SingleAgentProvider.codex], + gatewayProviders: >[], + raw: {}, + ); + } + + @override + Future resolveExternalAcpRouting({ + required String taskPrompt, + required String workingDirectory, + required ExternalCodeAgentAcpRoutingConfig routing, + String aiGatewayBaseUrl = '', + String aiGatewayApiKey = '', + }) async { + return const ExternalCodeAgentAcpRoutingResolution( + raw: { + 'resolvedExecutionTarget': 'single-agent', + 'resolvedEndpointTarget': 'singleAgent', + 'resolvedProviderId': 'codex', + 'resolvedGatewayProviderId': 'local', + 'resolvedModel': 'codex', + 'resolvedSkills': [], + 'unavailable': false, + }, + ); + } + + @override + Future syncExternalProviders( + List providers, + ) async {} +} diff --git a/test/assistant_execution_target_picker_widget_test.dart b/test/assistant_execution_target_picker_widget_test.dart index ec4e6108..effd45a9 100644 --- a/test/assistant_execution_target_picker_widget_test.dart +++ b/test/assistant_execution_target_picker_widget_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller_desktop_core.dart'; +import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart'; import 'package:xworkmate/features/assistant/assistant_page_composer_bar.dart'; import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart'; import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart'; @@ -17,7 +18,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); testWidgets( - 'compact gateway picker selects remote bridge route instead of local fallback', + 'mode picker keeps single-agent and gateway visible while project selector stays available', (tester) async { final root = Directory.systemTemp.createTempSync( 'xworkmate-picker-widget-test-', @@ -56,6 +57,10 @@ void main() { ); controller.lastObservedSettingsSnapshotInternal = controller.settingsController.snapshotInternal; + await controller.saveAssistantSelectedWorkingDirectoryForSession( + controller.currentSessionKey, + '/tmp/project-alpha', + ); await tester.pumpWidget( MaterialApp( @@ -111,7 +116,19 @@ void main() { .map((item) => item.value) .toList(growable: false); - expect(values, contains(AssistantExecutionTarget.gateway)); + expect(values, [ + AssistantExecutionTarget.singleAgent, + AssistantExecutionTarget.gateway, + ]); + expect( + find.byKey(const Key('assistant-working-directory-button')), + findsOneWidget, + ); + expect(find.text('project-alpha'), findsOneWidget); + expect( + find.byKey(const Key('assistant-collaboration-toggle')), + findsNothing, + ); await tester.pumpWidget(const SizedBox.shrink()); controller.dispose(); @@ -203,8 +220,8 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient { }) async { return const ExternalCodeAgentAcpRoutingResolution( raw: { - 'resolvedExecutionTarget': 'agent', - 'resolvedEndpointTarget': 'agent', + 'resolvedExecutionTarget': 'single-agent', + 'resolvedEndpointTarget': 'singleAgent', 'resolvedProviderId': 'codex', 'resolvedModel': '', 'resolvedSkills': [], @@ -218,7 +235,13 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient { required AssistantExecutionTarget target, bool forceRefresh = false, }) async { - return const ExternalCodeAgentAcpCapabilities.empty(); + return const ExternalCodeAgentAcpCapabilities( + singleAgent: true, + multiAgent: true, + providerCatalog: [SingleAgentProvider.codex], + gatewayProviders: >[], + raw: {}, + ); } @override diff --git a/test/features/assistant/assistant_page_composer_golden_test.dart b/test/features/assistant/assistant_page_composer_golden_test.dart new file mode 100644 index 00000000..40c6bac4 --- /dev/null +++ b/test/features/assistant/assistant_page_composer_golden_test.dart @@ -0,0 +1,232 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:xworkmate/app/app_controller_desktop_core.dart'; +import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart'; +import 'package:xworkmate/features/assistant/assistant_page_composer_bar.dart'; +import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart'; +import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart'; +import 'package:xworkmate/runtime/desktop_platform_service.dart'; +import 'package:xworkmate/runtime/go_task_service_client.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; +import 'package:xworkmate/runtime/skill_directory_access.dart'; +import 'package:xworkmate/theme/app_theme.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('renders composer with project workingDirectory chip', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1400, 320)); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + + final root = Directory.systemTemp.createTempSync( + 'xworkmate-composer-golden-', + ); + final store = SecureConfigStore( + enableSecureStorage: false, + appDataRootPathResolver: () async => '${root.path}/settings.sqlite3', + secretRootPathResolver: () async => root.path, + supportRootPathResolver: () async => root.path, + ); + final controller = AppController( + store: store, + desktopPlatformService: UnsupportedDesktopPlatformService(), + skillDirectoryAccessService: _GoldenSkillDirectoryAccessService( + root.path, + ), + goTaskServiceClient: const _GoldenGoTaskServiceClient(), + singleAgentSharedSkillScanRootOverrides: const [], + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + ); + final inputController = TextEditingController(text: '请整理今天的任务进展'); + final focusNode = FocusNode(); + + addTearDown(() async { + controller.dispose(); + inputController.dispose(); + focusNode.dispose(); + if (root.existsSync()) { + await root.delete(recursive: true); + } + }); + + controller.appUiStateInternal = controller.appUiState.copyWith( + savedGatewayTargets: const ['gateway'], + ); + controller.lastObservedSettingsSnapshotInternal = + controller.settingsController.snapshotInternal; + await controller.saveAssistantSelectedWorkingDirectoryForSession( + controller.currentSessionKey, + '${root.path}/project-alpha', + ); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(platform: TargetPlatform.macOS), + home: Scaffold( + body: Center( + child: RepaintBoundary( + key: const ValueKey('assistant-composer-boundary'), + child: SizedBox( + width: 1280, + child: ComposerBarInternal( + controller: controller, + inputController: inputController, + focusNode: focusNode, + thinkingLabel: 'Normal', + showModelControl: false, + modelLabel: '', + modelOptions: const [], + attachments: const [], + availableSkills: const [], + selectedSkillKeys: const [], + onRemoveAttachment: (_) {}, + onToggleSkill: (_) {}, + onThinkingChanged: (_) {}, + onModelChanged: (_) async {}, + onOpenGateway: () {}, + onOpenAiGatewaySettings: () {}, + onReconnectGateway: () async {}, + onPickAttachments: () {}, + onAddAttachment: (_) {}, + onPasteImageAttachment: () async => null, + onContentHeightChanged: (_) {}, + onInputHeightChanged: (_) {}, + onSend: () async {}, + ), + ), + ), + ), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 300)); + + await expectLater( + find.byKey(const ValueKey('assistant-composer-boundary')), + matchesGoldenFile( + 'goldens/assistant_page_composer_working_directory.png', + ), + ); + }); +} + +class _GoldenSkillDirectoryAccessService + implements SkillDirectoryAccessService { + const _GoldenSkillDirectoryAccessService(this.homeDirectory); + + final String homeDirectory; + + @override + bool get isSupported => false; + + @override + Future> authorizeDirectories({ + List suggestedPaths = const [], + }) async { + return const []; + } + + @override + Future authorizeDirectory({ + String suggestedPath = '', + }) async { + return null; + } + + @override + Future openDirectory( + AuthorizedSkillDirectory directory, + ) async { + return null; + } + + @override + Future resolveUserHomeDirectory() async { + return homeDirectory; + } +} + +class _GoldenGoTaskServiceClient implements GoTaskServiceClient { + const _GoldenGoTaskServiceClient(); + + @override + Future cancelTask({ + required GoTaskServiceRoute route, + required AssistantExecutionTarget target, + required String sessionId, + required String threadId, + }) async {} + + @override + Future closeTask({ + required GoTaskServiceRoute route, + required AssistantExecutionTarget target, + required String sessionId, + required String threadId, + }) async {} + + @override + Future dispose() async {} + + @override + Future executeTask( + GoTaskServiceRequest request, { + required void Function(GoTaskServiceUpdate update) onUpdate, + }) async { + return const GoTaskServiceResult( + success: true, + message: '', + turnId: '', + raw: {}, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + } + + @override + Future loadExternalAcpCapabilities({ + required AssistantExecutionTarget target, + bool forceRefresh = false, + }) async { + return const ExternalCodeAgentAcpCapabilities( + singleAgent: true, + multiAgent: true, + providerCatalog: [SingleAgentProvider.codex], + gatewayProviders: >[], + raw: {}, + ); + } + + @override + Future resolveExternalAcpRouting({ + required String taskPrompt, + required String workingDirectory, + required ExternalCodeAgentAcpRoutingConfig routing, + String aiGatewayBaseUrl = '', + String aiGatewayApiKey = '', + }) async { + return const ExternalCodeAgentAcpRoutingResolution( + raw: { + 'resolvedExecutionTarget': 'single-agent', + 'resolvedEndpointTarget': 'singleAgent', + 'resolvedProviderId': 'codex', + 'resolvedModel': '', + 'resolvedSkills': [], + 'unavailable': false, + }, + ); + } + + @override + Future syncExternalProviders( + List providers, + ) async {} +} diff --git a/test/features/assistant/goldens/assistant_page_composer_working_directory.png b/test/features/assistant/goldens/assistant_page_composer_working_directory.png new file mode 100644 index 0000000000000000000000000000000000000000..2d4b37fcac946619f5064fcaf762a7cf79f59327 GIT binary patch literal 8101 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 P9zd36)}~dzdH?ZWb7tXL literal 0 HcmV?d00001 diff --git a/test/runtime/external_acp_bridge_sync_order_test.dart b/test/runtime/external_acp_bridge_sync_order_test.dart index d0073efa..42aaf2d7 100644 --- a/test/runtime/external_acp_bridge_sync_order_test.dart +++ b/test/runtime/external_acp_bridge_sync_order_test.dart @@ -30,7 +30,7 @@ class _FakeGatewayAcpClientWithSyncOrder extends GatewayAcpClient { 'result': { 'success': true, 'output': 'ok', - 'resolvedExecutionTarget': 'agent', + 'resolvedExecutionTarget': 'single-agent', }, }, }; diff --git a/test/runtime/external_code_agent_acp_desktop_transport_test.dart b/test/runtime/external_code_agent_acp_desktop_transport_test.dart index 82f460f5..79ed7f6d 100644 --- a/test/runtime/external_code_agent_acp_desktop_transport_test.dart +++ b/test/runtime/external_code_agent_acp_desktop_transport_test.dart @@ -28,15 +28,23 @@ class _FakeGatewayAcpClient extends GatewayAcpClient { {'providerId': 'opencode', 'label': 'OpenCode'}, {'providerId': 'gemini', 'label': 'Gemini'}, ], + 'gatewayProviders': >[ + {'providerId': 'local', 'label': 'Local Gateway'}, + { + 'providerId': 'openclaw', + 'label': 'OpenClaw Gateway', + }, + ], }, }; } if (method == 'xworkmate.routing.resolve') { return { 'result': { - 'resolvedExecutionTarget': 'agent', - 'resolvedEndpointTarget': 'agent', + 'resolvedExecutionTarget': 'single-agent', + 'resolvedEndpointTarget': 'singleAgent', 'resolvedProviderId': 'gemini', + 'resolvedGatewayProviderId': 'local', 'resolvedModel': 'gemini-2.5-pro', 'resolvedSkills': ['pptx'], 'unavailable': false, @@ -67,6 +75,12 @@ void main() { capabilities.providerCatalog.map((item) => item.providerId).toList(), ['codex', 'opencode', 'gemini'], ); + expect( + capabilities.gatewayProviders + .map((item) => item['providerId']?.toString()) + .toList(), + ['local', 'openclaw'], + ); }, ); @@ -109,7 +123,10 @@ void main() { ); expect(client.methods, ['xworkmate.routing.resolve']); + expect(resolution.resolvedExecutionTarget, 'single-agent'); + expect(resolution.resolvedEndpointTarget, 'singleAgent'); expect(resolution.resolvedProviderId, 'gemini'); + expect(resolution.resolvedGatewayProviderId, 'local'); expect(resolution.resolvedModel, 'gemini-2.5-pro'); expect(resolution.resolvedSkills, ['pptx']); },