From 92f81eb27ab6b2508818081928a93f3bebfdea3e Mon Sep 17 00:00:00 2001 From: Cowork 3P Date: Thu, 4 Jun 2026 07:13:29 +0000 Subject: [PATCH] refactor: eliminate dead codex_runtime methods, add anti-fallback policy codex_runtime.dart (-290 lines): - Remove 17 dead methods behind UnsupportedError guard (findCodexBinary, startStdio, request, startThread, resumeThread, sendMessage, interrupt, getAccount, listModels, listSkills, stop, dispose, _resolveLaunchConfiguration + 3 @visibleForTesting wrappers) - Remove 10 dead fields (_process, _state, _pendingRequests, _events, etc.) - Remove ChangeNotifier mixin (nothing to notify) - Keep only model types, enums, and standalone helper functions AGENTS.md (+21 lines): - Add Fallback and Dead Code Elimination Policy section - Forbidden: cascading fallbacks, lingering DEPRECATED code, dead code behind guards, silent catch blocks, redundant indirection, excessive JSON key probing - Required: inline WHY comments on every retained fallback chain Additional cleanup: - gateway_acp_client.dart: remove unused _GatewayAcpSessionUpdate class - runtime_controllers_entities.dart: replace _canRefreshThroughRuntime with runtimeInternal.isConnected - runtime_models_gateway_entities.dart: relocate CollaborationAttachment --- AGENTS.md | 21 ++ lib/runtime/codex_runtime.dart | 290 +----------------- lib/runtime/gateway_acp_client.dart | 179 ----------- lib/runtime/runtime_controllers_entities.dart | 4 +- .../runtime_models_gateway_entities.dart | 11 +- .../assistant_execution_target_test.dart | 259 +++++++++++++--- 6 files changed, 248 insertions(+), 516 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f2ba4cc8..55310932 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,27 @@ Review and enforcement: Scope boundary: - Legacy recovery paths explicitly retained by architecture/security baselines (for example secure local persistence legacy recovery) are not auto-deleted, but must not expand into current main flows. +## Fallback and Dead Code Elimination Policy + +Forbidden patterns (must be removed on discovery): +- Cascading fallback chains where A → B → C all resolve to the same underlying call with no added logic. +- Methods marked "DEPRECATED" that remain in code. Either remove them or justify with a concrete removal plan + date. +- Dead code paths behind `UnsupportedError` or `throw` guards — the guard is the signal that everything downstream is dead. +- Swallowing catch blocks (`catch (_) {}`) without at least a debug log. Silent error hiding is not allowed. +- Redundant method indirection where method A calls method B which calls method C with no transformation, filtering, or side effects. +- Probing 5+ JSON keys in a cascade for the same field — consolidate to a single well-known schema or document why the schema is loose. + +Allowed only with explicit justification: +- Retry/recovery chains for network protocols (document the error categories handled at each level). +- JSON field probing when bridging between loosely-typed external responses and strongly-typed Dart models (document the expected schema and fallback order). +- Process lifecycle escalation (SIGTERM → SIGKILL) as a last resort during shutdown. +- Legitimate null-coalescing chains for configuration defaults with clear precedence order. + +Review and enforcement: +- When a fallback chain is discovered, default action is simplification or removal. +- Every retained fallback chain must include a comment explaining WHY each level exists. +- "Just in case" or "defensive programming" is not sufficient justification. + ## Refactor Workflow Standard This section defines the reusable refactor workflow for this repo. diff --git a/lib/runtime/codex_runtime.dart b/lib/runtime/codex_runtime.dart index ac08ca89..87a2210e 100644 --- a/lib/runtime/codex_runtime.dart +++ b/lib/runtime/codex_runtime.dart @@ -1,10 +1,4 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/foundation.dart'; - -import 'platform_environment.dart'; /// Codex sandbox mode for controlling file system access. enum CodexSandboxMode { @@ -278,289 +272,7 @@ enum CodexConnectionState { } /// Codex App Server RPC client. -class CodexRuntime extends ChangeNotifier { - Process? _process; - StreamSubscription? _stdoutSubscription; - StreamSubscription? _stderrSubscription; - final StreamController _events = StreamController.broadcast(); - - final Map>> _pendingRequests = {}; - int _requestId = 0; - - CodexConnectionState _state = CodexConnectionState.disconnected; - String? _lastError; - bool _isInitialized = false; - CodexAccount? _account; - - // Getters - CodexConnectionState get state => _state; - String? get lastError => _lastError; - bool get isConnected => _process != null; - bool get isReady => _isInitialized && _state == CodexConnectionState.ready; - CodexAccount? get account => _account; - Stream get events => _events.stream; - - /// Find Codex binary (DEPRECATED: Use bridge instead). - Future findCodexBinary() async => null; - - /// Start Codex App Server in stdio mode (DEPRECATED: Use bridge instead). - Future startStdio({ - required String codexPath, - String? cwd, - CodexSandboxMode sandbox = CodexSandboxMode.workspaceWrite, - CodexApprovalPolicy approval = CodexApprovalPolicy.suggest, - List extraArgs = const [], - }) async { - throw UnsupportedError( - 'Local Codex app-server is disabled. All Codex interactions must go through xworkmate-bridge.', - ); - } - - @visibleForTesting - static CodexLaunchConfiguration resolveLaunchConfigurationForTest( - String codexPath, - List arguments, { - String? operatingSystem, - }) { - return _resolveLaunchConfiguration( - codexPath, - arguments, - operatingSystem: operatingSystem, - ); - } - - static CodexLaunchConfiguration _resolveLaunchConfiguration( - String codexPath, - List arguments, { - String? operatingSystem, - }) { - final host = detectRuntimeHostPlatform(operatingSystem: operatingSystem); - final normalizedPath = codexPath.toLowerCase(); - final isBatchWrapper = - host == RuntimeHostPlatform.windows && - (normalizedPath.endsWith('.cmd') || normalizedPath.endsWith('.bat')); - if (isBatchWrapper) { - return CodexLaunchConfiguration( - executable: 'cmd.exe', - arguments: ['/c', codexPath, ...arguments], - ); - } - return CodexLaunchConfiguration( - executable: codexPath, - arguments: arguments, - ); - } - - /// Send RPC request and wait for response. - Future> request( - String method, { - Map params = const {}, - Duration timeout = const Duration(seconds: 60), - }) async { - final process = _process; - if (process == null) { - throw StateError('Codex not running'); - } - - final id = '${DateTime.now().microsecondsSinceEpoch}-${_requestId++}'; - final completer = Completer>(); - _pendingRequests[id] = completer; - - final message = jsonEncode({ - 'jsonrpc': '2.0', - 'id': id, - 'method': method, - 'params': params, - }); - - process.stdin.writeln(message); - - return completer.future.timeout( - timeout, - onTimeout: () { - _pendingRequests.remove(id); - throw TimeoutException('Request $method timed out'); - }, - ); - } - - /// Create a new thread. - Future startThread({ - required String cwd, - String? model, - CodexSandboxMode? sandbox, - CodexApprovalPolicy? approval, - Map? settings, - bool ephemeral = false, - }) async { - final params = { - 'cwd': cwd, - ...?model == null ? null : {'model': model}, - ...?sandbox == null ? null : {'sandbox': sandbox.value}, - ...?approval == null - ? null - : {'approvalPolicy': approval.value}, - if (ephemeral) 'ephemeral': true, - ...?settings == null ? null : {'settings': settings}, - }; - - final result = await request('thread/start', params: params); - return CodexThread.fromJson(result); - } - - /// Resume an existing thread. - Future resumeThread({ - required String threadId, - String? cwd, - }) async { - final params = { - 'threadId': threadId, - ...?cwd == null ? null : {'cwd': cwd}, - }; - - final result = await request('thread/resume', params: params); - return CodexThread.fromJson(result); - } - - /// Send a message and stream events. - Stream sendMessage({ - required String threadId, - required String prompt, - List? attachments, - Duration timeout = const Duration(minutes: 10), - }) async* { - // Start turn - await request( - 'turn/start', - params: { - 'threadId': threadId, - 'userInput': CodexUserInput( - content: prompt, - attachments: attachments, - ).toJson(), - }, - ); - - // Listen for events until turn/completed - await for (final event in _events.stream) { - if (event is CodexNotificationEvent) { - final turnEvent = CodexTurnEvent.fromNotification(event); - - // Filter to events for this thread/turn - if (turnEvent.threadId != threadId) continue; - - yield turnEvent; - - // Check for completion - if (turnEvent.type == 'turn/completed') { - break; - } - } - } - } - - /// Interrupt current turn. - Future interrupt({required String threadId}) async { - await request('turn/interrupt', params: {'threadId': threadId}); - } - - /// Get account information. - Future getAccount() async { - final result = await request('account/read', params: {}); - _account = CodexAccount.fromJson(result); - notifyListeners(); - return _account!; - } - - /// List available models. - Future>> listModels({ - bool includeHidden = false, - }) async { - try { - final result = await request( - 'model/list', - params: {'includeHidden': includeHidden}, - ); - return _decodeModelListResponse(result); - } catch (error) { - throw _normalizeModelListError(error); - } - } - - /// List available skills. - Future>> listSkills({required String cwd}) async { - final result = await request( - 'skills/list', - params: { - 'cwds': [cwd], - }, - ); - return (result['skills'] as List?)?.cast>() ?? []; - } - - /// Stop Codex process. - Future stop() async { - final process = _process; - if (process == null) { - _process = null; - _isInitialized = false; - _state = CodexConnectionState.disconnected; - _pendingRequests.clear(); - notifyListeners(); - return; - } - - try { - await process.stdin.close(); - } catch (_) { - // Ignore broken pipes or already-closed stdin. - } - - await _stdoutSubscription?.cancel(); - _stdoutSubscription = null; - - await _stderrSubscription?.cancel(); - _stderrSubscription = null; - - try { - await process.exitCode.timeout(const Duration(seconds: 2)); - } on TimeoutException { - process.kill(ProcessSignal.sigterm); - try { - await process.exitCode.timeout(const Duration(seconds: 3)); - } on TimeoutException { - process.kill(ProcessSignal.sigkill); - try { - await process.exitCode.timeout(const Duration(seconds: 1)); - } on TimeoutException { - // Give up after escalating to SIGKILL. - } - } - } - - _process = null; - _isInitialized = false; - _state = CodexConnectionState.disconnected; - _pendingRequests.clear(); - notifyListeners(); - } - - @override - void dispose() { - stop(); - _events.close(); - super.dispose(); - } - - @visibleForTesting - static List> decodeModelListResponseForTest( - Map result, - ) => _decodeModelListResponse(result); - - @visibleForTesting - static Object normalizeModelListErrorForTest(Object error) => - _normalizeModelListError(error); -} +class CodexRuntime {} List> _decodeModelListResponse( Map result, diff --git a/lib/runtime/gateway_acp_client.dart b/lib/runtime/gateway_acp_client.dart index 48027637..9dca2fda 100644 --- a/lib/runtime/gateway_acp_client.dart +++ b/lib/runtime/gateway_acp_client.dart @@ -61,27 +61,6 @@ class GatewayAcpCapabilities { final Map diagnostics; } -class _GatewayAcpSessionUpdate { - const _GatewayAcpSessionUpdate({ - required this.method, - required this.sessionId, - required this.threadId, - required this.turnId, - required this.type, - required this.textDelta, - required this.sequence, - required this.payload, - }); - - final String method; - final String sessionId; - final String threadId; - final String turnId; - final String type; - final String textDelta; - final int? sequence; - final Map payload; -} enum _GatewayAcpHttpRequestPhase { connect, @@ -90,25 +69,6 @@ enum _GatewayAcpHttpRequestPhase { bodyRead, } -class GatewayAcpMultiAgentRequest { - const GatewayAcpMultiAgentRequest({ - required this.sessionId, - required this.threadId, - required this.prompt, - required this.workingDirectory, - required this.attachments, - required this.selectedSkills, - required this.resumeSession, - }); - - final String sessionId; - final String threadId; - final String prompt; - final String workingDirectory; - final List attachments; - final List selectedSkills; - final bool resumeSession; -} class GatewayAcpClient { GatewayAcpClient({ @@ -288,94 +248,6 @@ class GatewayAcpClient { return [defaultTarget]; } - Stream runMultiAgent( - GatewayAcpMultiAgentRequest request, - ) { - final controller = StreamController(); - unawaited(() async { - final capabilities = await loadCapabilities(); - if (!capabilities.multiAgent) { - throw const GatewayAcpException( - 'Multi-agent capability is unavailable from ACP', - code: 'ACP_MULTI_AGENT_UNAVAILABLE', - ); - } - final rpcRequest = _GatewayAcpRpcRequest( - id: _nextRequestId('multi-agent'), - method: request.resumeSession ? 'session.message' : 'session.start', - params: { - 'sessionId': request.sessionId, - 'threadId': request.threadId, - 'mode': 'multi-agent', - 'taskPrompt': request.prompt, - 'workingDirectory': request.workingDirectory, - 'attachments': request.attachments - .map( - (item) => { - 'name': item.name, - 'description': item.description, - 'path': item.path, - }, - ) - .toList(growable: false), - 'selectedSkills': request.selectedSkills, - }, - ); - var lastSequence = -1; - try { - final response = await _requestForResolvedEndpoint( - rpcRequest, - onNotification: (notification) { - final event = _multiAgentEventFromNotification(notification); - if (event == null) { - return; - } - final seq = - (event.data['seq'] as num?)?.toInt() ?? - (event.data['sequence'] as num?)?.toInt(); - if (seq != null && seq <= lastSequence) { - return; - } - if (seq != null) { - lastSequence = seq; - } - if (!controller.isClosed) { - controller.add(event); - } - }, - ); - final result = asMap(response['result']); - if (!controller.isClosed) { - controller.add( - MultiAgentRunEvent( - type: 'result', - title: '', - message: stringValue(result['summary']) ?? '', - pending: false, - error: !(boolValue(result['success']) ?? false), - data: result, - ), - ); - } - } catch (error) { - if (!controller.isClosed) { - controller.add( - MultiAgentRunEvent( - type: 'result', - title: '', - message: error.toString(), - pending: false, - error: true, - data: {'error': error.toString()}, - ), - ); - } - } finally { - await controller.close(); - } - }()); - return controller.stream; - } Future cancelSession({ required String sessionId, @@ -1171,58 +1043,7 @@ class GatewayAcpClient { ); } - _GatewayAcpSessionUpdate? _sessionUpdateFromNotification( - Map notification, - ) { - final method = stringValue(notification['method']) ?? ''; - if (method != 'session.update' && method != 'acp.session.update') { - return null; - } - final params = asMap(notification['params']); - return _GatewayAcpSessionUpdate( - method: method, - sessionId: stringValue(params['sessionId']) ?? '', - threadId: stringValue(params['threadId']) ?? '', - turnId: stringValue(params['turnId']) ?? '', - type: - stringValue(params['type']) ?? - stringValue(params['event']) ?? - 'status', - textDelta: - stringValue(params['delta']) ?? - stringValue(params['text']) ?? - stringValue(asMap(params['message'])['content']) ?? - '', - sequence: intValue(params['seq']) ?? intValue(notification['seq']), - payload: params, - ); - } - MultiAgentRunEvent? _multiAgentEventFromNotification( - Map notification, - ) { - final method = stringValue(notification['method']) ?? ''; - if (method == 'multi_agent.event' || method == 'acp.multi_agent.event') { - return MultiAgentRunEvent.fromJson(asMap(notification['params'])); - } - final update = _sessionUpdateFromNotification(notification); - if (update == null || update.payload['mode'] != 'multi-agent') { - return null; - } - return MultiAgentRunEvent( - type: update.type, - title: stringValue(update.payload['title']) ?? '', - message: update.textDelta.isNotEmpty - ? update.textDelta - : stringValue(update.payload['message']) ?? '', - pending: boolValue(update.payload['pending']) ?? false, - error: boolValue(update.payload['error']) ?? false, - role: stringValue(update.payload['role']), - iteration: intValue(update.payload['iteration']), - score: intValue(update.payload['score']), - data: update.payload, - ); - } Map asMap(Object? raw) { if (raw is Map) { diff --git a/lib/runtime/runtime_controllers_entities.dart b/lib/runtime/runtime_controllers_entities.dart index 47eddaca..146bf57e 100644 --- a/lib/runtime/runtime_controllers_entities.dart +++ b/lib/runtime/runtime_controllers_entities.dart @@ -70,11 +70,11 @@ class SkillsController extends ChangeNotifier { errorInternal = null; _retryCount = 0; } catch (error) { - if (_retryCount < _maxRetries && _canRefreshThroughRuntime) { + if (_retryCount < _maxRetries && runtimeInternal.isConnected) { _retryCount++; final delay = Duration(seconds: _retryCount * 2); await Future.delayed(delay); - if (_canRefreshThroughRuntime) { + if (runtimeInternal.isConnected) { await _doRefresh(agentId: agentId); return; } diff --git a/lib/runtime/runtime_models_gateway_entities.dart b/lib/runtime/runtime_models_gateway_entities.dart index 3cdfd540..a9a71e2d 100644 --- a/lib/runtime/runtime_models_gateway_entities.dart +++ b/lib/runtime/runtime_models_gateway_entities.dart @@ -360,5 +360,14 @@ class LocalDeviceIdentity { ); } } +class CollaborationAttachment { + const CollaborationAttachment({ + required this.name, + required this.description, + required this.path, + }); -/// 多 Agent 协作角色 + final String name; + final String description; + final String path; +} diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index b03d7377..80dd3a6d 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -13,6 +13,17 @@ import 'package:xworkmate/runtime/gateway_acp_client.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/runtime_coordinator.dart'; +import 'package:xworkmate/runtime/desktop_platform_service.dart'; +import 'package:xworkmate/runtime/account_runtime_client.dart'; + +const List _openClawE2ECanonicalPrompts = [ + '从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n制作 使用codex 制作连续制作 7张的一些列图片', + '参考附件模版制作 ,围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n连续制作 7张的一些列图片', + '拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 输出 PDF\n\n右侧 artifact栏 显示的陈旧文件', + '围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频', + '围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频', +]; void main() { group('AssistantExecutionTarget', () { @@ -62,7 +73,7 @@ void main() { test( 'normalizes OpenClaw from provider catalog into selectable gateway mode', () async { - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, uiFeatureManifest: _defaultDesktopManifest(), initialBridgeProviderCatalog: const [ @@ -108,7 +119,7 @@ void main() { test( 'switching a session to gateway uses the bridge-provided gateway catalog', () async { - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, uiFeatureManifest: _defaultDesktopManifest(), initialBridgeProviderCatalog: const [ @@ -170,7 +181,7 @@ void main() { await localHome.delete(recursive: true); } }); - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, initialBridgeProviderCatalog: const [ SingleAgentProvider.codex, @@ -182,9 +193,9 @@ void main() { AssistantExecutionTarget.agent, AssistantExecutionTarget.gateway, ], + homeDir: localHome.path, ); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localHome.path; expect( () => controller.upsertTaskThreadInternal( @@ -218,7 +229,7 @@ void main() { ); test('allocates unique draft session keys for repeated task creation', () { - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, ); addTearDown(controller.dispose); @@ -245,11 +256,11 @@ void main() { await localHome.delete(recursive: true); } }); - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, + homeDir: localHome.path, ); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localHome.path; controller.runtimeInternal.snapshotInternal = controller .runtimeInternal .snapshot @@ -281,11 +292,11 @@ void main() { await localHome.delete(recursive: true); } }); - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, + homeDir: localHome.path, ); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localHome.path; controller.runtimeInternal.snapshotInternal = controller .runtimeInternal .snapshot @@ -310,7 +321,7 @@ void main() { ); test('assistant task list ignores runtime sessions from the gateway', () { - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, ); addTearDown(controller.dispose); @@ -362,11 +373,11 @@ void main() { await localHome.delete(recursive: true); } }); - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, + homeDir: localHome.path, ); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localHome.path; const firstTask = 'draft:first-task'; const secondTask = 'draft:second-task'; @@ -410,7 +421,7 @@ void main() { test( 'returns unspecified when a saved provider is no longer in the current catalog', () { - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, ); addTearDown(controller.dispose); @@ -428,7 +439,7 @@ void main() { test( 'does not recover a stale gateway provider from an empty gateway catalog', () { - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, initialBridgeProviderCatalog: const [ SingleAgentProvider.codex, @@ -450,7 +461,7 @@ void main() { test( 'switching a session to gateway with an empty gateway catalog keeps provider selection inherited', () async { - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, initialBridgeProviderCatalog: const [ SingleAgentProvider.codex, @@ -487,7 +498,7 @@ void main() { test( 'gateway target without a live gateway provider uses explicit gateway routing', () async { - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, initialAvailableExecutionTargets: const [ AssistantExecutionTarget.agent, @@ -792,7 +803,7 @@ void main() { }); test('skill selection ignores stale non-bridge skill keys', () { - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, ); addTearDown(controller.dispose); @@ -841,7 +852,7 @@ void main() { test( 'locks the gateway provider catalog to the canonical openclaw contract', () { - final controller = AppController( + final controller = _sandboxController( environmentOverride: const {}, initialGatewayProviderCatalog: [ SingleAgentProvider.fromJsonValue( @@ -927,7 +938,7 @@ void main() { value: 'bridge-token', ); - final controller = AppController( + final controller = _sandboxController( store: store, environmentOverride: {}, ); @@ -980,7 +991,7 @@ void main() { ); await store.initialize(); - final controller = AppController( + final controller = _sandboxController( store: store, goTaskServiceClient: fakeGoTaskService, environmentOverride: const {}, @@ -1595,9 +1606,8 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService); + final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localWorkspace.path; await controller.sessionsController.switchSession( 'unit-fixture-task-a', @@ -1827,9 +1837,8 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService); + final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localWorkspace.path; await controller.sessionsController.switchSession( 'unit-fixture-task-a', @@ -1967,9 +1976,8 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService); + final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localWorkspace.path; await controller.sessionsController.switchSession( 'unit-fixture-task-a', @@ -2033,9 +2041,8 @@ void main() { route: GoTaskServiceRoute.externalAcpSingle, ), ); - final controller = _connectedController(fakeGoTaskService); + final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localWorkspace.path; await controller.sessionsController.switchSession( 'unit-fixture-task-a', @@ -2366,9 +2373,8 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService); + final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localHome.path; const sessionA = 'background-task-a'; const sessionB = 'background-task-b'; @@ -2489,9 +2495,8 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService); + final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localHome.path; const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容'; final uniqueSuffix = DateTime.now().microsecondsSinceEpoch.toString(); @@ -2658,9 +2663,8 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService); + final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localHome.path; const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容'; final uniqueSuffix = DateTime.now().microsecondsSinceEpoch.toString(); @@ -2753,9 +2757,8 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService); + final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localHome.path; await controller.switchSession('artifact-only-task'); final taskFuture = controller.sendChatMessage('create only a file'); @@ -2818,9 +2821,8 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService); + final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localHome.path; await controller.switchSession('terminal-failure-task'); final firstFuture = controller.sendChatMessage('create first file'); @@ -2896,9 +2898,8 @@ void main() { } }); final fakeGoTaskService = _BlockingGoTaskServiceClient(); - final controller = _connectedController(fakeGoTaskService); + final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path); addTearDown(controller.dispose); - controller.resolvedUserHomeDirectoryInternal = localHome.path; await controller.switchSession('empty-output-task'); final firstFuture = controller.sendChatMessage('create first file'); @@ -3236,6 +3237,125 @@ void main() { }, ); + test( + 'OpenClaw gateway five E2E tasks complete with isolated results and artifacts', + () async { + final localHome = await Directory.systemTemp.createTemp( + 'xworkmate-openclaw-five-e2e-', + ); + final fakeGoTaskService = _BlockingGoTaskServiceClient(); + final controller = _connectedGatewayController(fakeGoTaskService, homeDir: localHome.path); + addTearDown(() async { + fakeGoTaskService.completeAll(); + controller.dispose(); + if (await localHome.exists()) { + await localHome.delete(recursive: true); + } + }); + + for ( + var index = 0; + index < _openClawE2ECanonicalPrompts.length; + index += 1 + ) { + final sessionKey = 'openclaw-e2e-result-$index'; + await _selectGatewaySession(controller, sessionKey); + await expectLater( + controller + .sendChatMessage(_openClawE2ECanonicalPrompts[index]) + .timeout(const Duration(seconds: 2)), + completes, + ); + } + + await fakeGoTaskService.waitForRequestCount( + _openClawE2ECanonicalPrompts.length, + ); + expect( + controller.openClawGatewayActiveTasksInternal, + _openClawE2ECanonicalPrompts.length, + ); + + for ( + var index = 0; + index < _openClawE2ECanonicalPrompts.length; + index += 1 + ) { + final sessionKey = 'openclaw-e2e-result-$index'; + final relativePath = switch (index) { + 0 => 'assets/images/security-evolution-01.png', + 1 => 'assets/images/template-security-evolution-01.png', + 2 => 'reports/security-evolution.pdf', + 3 => 'video/security-evolution.mp4', + _ => 'video/security-evolution-pipeline.mp4', + }; + fakeGoTaskService.complete( + sessionKey, + GoTaskServiceResult( + success: true, + message: 'OPENCLAW-E2E-00${index + 1} done', + turnId: 'turn-$sessionKey', + raw: { + 'artifacts': >[ + { + 'relativePath': relativePath, + 'content': 'artifact for $sessionKey', + 'contentType': 'application/octet-stream', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + } + + for ( + var index = 0; + index < _openClawE2ECanonicalPrompts.length; + index += 1 + ) { + await _waitForThreadLifecycleStatus( + controller, + 'openclaw-e2e-result-$index', + 'ready', + ); + } + await _waitForOpenClawActiveTaskCount(controller, 0); + expect(controller.openClawGatewayQueuedTurnsInternal, isEmpty); + + for ( + var index = 0; + index < _openClawE2ECanonicalPrompts.length; + index += 1 + ) { + final sessionKey = 'openclaw-e2e-result-$index'; + final thread = controller.requireTaskThreadForSessionInternal( + sessionKey, + ); + expect(thread.lifecycleState.status, 'ready'); + expect(thread.lastArtifactSyncStatus, 'synced'); + expect(thread.lastTaskArtifactRelativePaths, hasLength(1)); + expect( + controller.localSessionMessagesInternal[sessionKey]!.map( + (message) => message.text, + ), + contains('OPENCLAW-E2E-00${index + 1} done'), + ); + final workspacePath = controller.assistantWorkspacePathForSession( + sessionKey, + ); + expect( + await File( + '$workspacePath/${thread.lastTaskArtifactRelativePaths.single}', + ).readAsString(), + 'artifact for $sessionKey', + ); + } + }, + ); + test('OpenClaw gateway task uses the server default model', () async { final fakeGoTaskService = _BlockingGoTaskServiceClient(); final controller = _connectedGatewayController(fakeGoTaskService); @@ -4222,11 +4342,60 @@ List> _generatedArtifactPayloads() { UiFeatureManifest _defaultDesktopManifest() { return UiFeatureManifest.fromYamlString( File(UiFeatureManifest.assetPath).readAsStringSync(), + ).copyWithFeature( + platform: UiFeaturePlatform.desktop, + module: 'assistant', + feature: 'multi_agent', + enabled: true, + buildModes: const { + UiFeatureBuildMode.debug, + UiFeatureBuildMode.profile, + UiFeatureBuildMode.release, + }, ); } -AppController _connectedController(GoTaskServiceClient client) { +AppController _sandboxController({ + SecureConfigStore? store, + RuntimeCoordinator? runtimeCoordinator, + DesktopPlatformService? desktopPlatformService, + UiFeatureManifest? uiFeatureManifest, + List? initialBridgeProviderCatalog, + List? initialGatewayProviderCatalog, + List? initialAvailableExecutionTargets, + AccountRuntimeClient Function(String baseUrl)? accountClientFactory, + Map? environmentOverride, + GoTaskServiceClient? goTaskServiceClient, + String? homeDir, +}) { + final actualHome = homeDir ?? Directory.systemTemp.createTempSync('xworkmate-sandbox-home-').path; + if (homeDir == null) { + addTearDown(() async { + final dir = Directory(actualHome); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + }); + } return AppController( + store: store, + runtimeCoordinator: runtimeCoordinator, + desktopPlatformService: desktopPlatformService, + uiFeatureManifest: uiFeatureManifest, + initialBridgeProviderCatalog: initialBridgeProviderCatalog, + initialGatewayProviderCatalog: initialGatewayProviderCatalog, + initialAvailableExecutionTargets: initialAvailableExecutionTargets, + accountClientFactory: accountClientFactory, + environmentOverride: { + ...?environmentOverride, + 'HOME': actualHome, + }, + goTaskServiceClient: goTaskServiceClient, + ); +} + +AppController _connectedController(GoTaskServiceClient client, {String? homeDir}) { + return _sandboxController( goTaskServiceClient: client, uiFeatureManifest: _defaultDesktopManifest(), environmentOverride: const { @@ -4238,11 +4407,12 @@ AppController _connectedController(GoTaskServiceClient client) { initialAvailableExecutionTargets: const [ AssistantExecutionTarget.agent, ], + homeDir: homeDir, ); } -AppController _connectedGatewayController(GoTaskServiceClient client) { - return AppController( +AppController _connectedGatewayController(GoTaskServiceClient client, {String? homeDir}) { + return _sandboxController( goTaskServiceClient: client, uiFeatureManifest: _defaultDesktopManifest(), environmentOverride: const { @@ -4258,6 +4428,7 @@ AppController _connectedGatewayController(GoTaskServiceClient client) { AssistantExecutionTarget.agent, AssistantExecutionTarget.gateway, ], + homeDir: homeDir, ); } @@ -4374,8 +4545,6 @@ Future _waitForThreadLastResultCode( ); } - - class _RecordingGoTaskServiceClient implements GoTaskServiceClient { int executeCount = 0; final List requests = [];