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
This commit is contained in:
Cowork 3P 2026-06-04 07:13:29 +00:00
parent 4ed77a6d53
commit 92f81eb27a
6 changed files with 248 additions and 516 deletions

View File

@ -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.

View File

@ -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<String>? _stdoutSubscription;
StreamSubscription<String>? _stderrSubscription;
final StreamController<CodexEvent> _events = StreamController.broadcast();
final Map<String, Completer<Map<String, dynamic>>> _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<CodexEvent> get events => _events.stream;
/// Find Codex binary (DEPRECATED: Use bridge instead).
Future<String?> findCodexBinary() async => null;
/// Start Codex App Server in stdio mode (DEPRECATED: Use bridge instead).
Future<void> startStdio({
required String codexPath,
String? cwd,
CodexSandboxMode sandbox = CodexSandboxMode.workspaceWrite,
CodexApprovalPolicy approval = CodexApprovalPolicy.suggest,
List<String> 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<String> arguments, {
String? operatingSystem,
}) {
return _resolveLaunchConfiguration(
codexPath,
arguments,
operatingSystem: operatingSystem,
);
}
static CodexLaunchConfiguration _resolveLaunchConfiguration(
String codexPath,
List<String> 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: <String>['/c', codexPath, ...arguments],
);
}
return CodexLaunchConfiguration(
executable: codexPath,
arguments: arguments,
);
}
/// Send RPC request and wait for response.
Future<Map<String, dynamic>> request(
String method, {
Map<String, dynamic> 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<Map<String, dynamic>>();
_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<CodexThread> startThread({
required String cwd,
String? model,
CodexSandboxMode? sandbox,
CodexApprovalPolicy? approval,
Map<String, dynamic>? settings,
bool ephemeral = false,
}) async {
final params = <String, dynamic>{
'cwd': cwd,
...?model == null ? null : <String, dynamic>{'model': model},
...?sandbox == null ? null : <String, dynamic>{'sandbox': sandbox.value},
...?approval == null
? null
: <String, dynamic>{'approvalPolicy': approval.value},
if (ephemeral) 'ephemeral': true,
...?settings == null ? null : <String, dynamic>{'settings': settings},
};
final result = await request('thread/start', params: params);
return CodexThread.fromJson(result);
}
/// Resume an existing thread.
Future<CodexThread> resumeThread({
required String threadId,
String? cwd,
}) async {
final params = <String, dynamic>{
'threadId': threadId,
...?cwd == null ? null : <String, dynamic>{'cwd': cwd},
};
final result = await request('thread/resume', params: params);
return CodexThread.fromJson(result);
}
/// Send a message and stream events.
Stream<CodexTurnEvent> sendMessage({
required String threadId,
required String prompt,
List<CodexAttachment>? 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<void> interrupt({required String threadId}) async {
await request('turn/interrupt', params: {'threadId': threadId});
}
/// Get account information.
Future<CodexAccount> getAccount() async {
final result = await request('account/read', params: {});
_account = CodexAccount.fromJson(result);
notifyListeners();
return _account!;
}
/// List available models.
Future<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> listSkills({required String cwd}) async {
final result = await request(
'skills/list',
params: {
'cwds': [cwd],
},
);
return (result['skills'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}
/// Stop Codex process.
Future<void> 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<Map<String, dynamic>> decodeModelListResponseForTest(
Map<String, dynamic> result,
) => _decodeModelListResponse(result);
@visibleForTesting
static Object normalizeModelListErrorForTest(Object error) =>
_normalizeModelListError(error);
}
class CodexRuntime {}
List<Map<String, dynamic>> _decodeModelListResponse(
Map<String, dynamic> result,

View File

@ -61,27 +61,6 @@ class GatewayAcpCapabilities {
final Map<String, dynamic> 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<String, dynamic> 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<CollaborationAttachment> attachments;
final List<String> selectedSkills;
final bool resumeSession;
}
class GatewayAcpClient {
GatewayAcpClient({
@ -288,94 +248,6 @@ class GatewayAcpClient {
return <AssistantExecutionTarget>[defaultTarget];
}
Stream<MultiAgentRunEvent> runMultiAgent(
GatewayAcpMultiAgentRequest request,
) {
final controller = StreamController<MultiAgentRunEvent>();
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: <String, dynamic>{
'sessionId': request.sessionId,
'threadId': request.threadId,
'mode': 'multi-agent',
'taskPrompt': request.prompt,
'workingDirectory': request.workingDirectory,
'attachments': request.attachments
.map(
(item) => <String, dynamic>{
'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: <String, dynamic>{'error': error.toString()},
),
);
}
} finally {
await controller.close();
}
}());
return controller.stream;
}
Future<void> cancelSession({
required String sessionId,
@ -1171,58 +1043,7 @@ class GatewayAcpClient {
);
}
_GatewayAcpSessionUpdate? _sessionUpdateFromNotification(
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> asMap(Object? raw) {
if (raw is Map<String, dynamic>) {

View File

@ -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<void>.delayed(delay);
if (_canRefreshThroughRuntime) {
if (runtimeInternal.isConnected) {
await _doRefresh(agentId: agentId);
return;
}

View File

@ -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;
}

View File

@ -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<String> _openClawE2ECanonicalPrompts = <String>[
'从单机权限 → 网络边界 → 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 <String, String>{},
uiFeatureManifest: _defaultDesktopManifest(),
initialBridgeProviderCatalog: const <SingleAgentProvider>[
@ -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 <String, String>{},
uiFeatureManifest: _defaultDesktopManifest(),
initialBridgeProviderCatalog: const <SingleAgentProvider>[
@ -170,7 +181,7 @@ void main() {
await localHome.delete(recursive: true);
}
});
final controller = AppController(
final controller = _sandboxController(
environmentOverride: const <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
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 <String, String>{},
);
addTearDown(controller.dispose);
@ -245,11 +256,11 @@ void main() {
await localHome.delete(recursive: true);
}
});
final controller = AppController(
final controller = _sandboxController(
environmentOverride: const <String, String>{},
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 <String, String>{},
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 <String, String>{},
);
addTearDown(controller.dispose);
@ -362,11 +373,11 @@ void main() {
await localHome.delete(recursive: true);
}
});
final controller = AppController(
final controller = _sandboxController(
environmentOverride: const <String, String>{},
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 <String, String>{},
);
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 <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
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 <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
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 <String, String>{},
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
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 <String, String>{},
);
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 <String, String>{},
initialGatewayProviderCatalog: <SingleAgentProvider>[
SingleAgentProvider.fromJsonValue(
@ -927,7 +938,7 @@ void main() {
value: 'bridge-token',
);
final controller = AppController(
final controller = _sandboxController(
store: store,
environmentOverride: <String, String>{},
);
@ -980,7 +991,7 @@ void main() {
);
await store.initialize();
final controller = AppController(
final controller = _sandboxController(
store: store,
goTaskServiceClient: fakeGoTaskService,
environmentOverride: const <String, String>{},
@ -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: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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<Map<String, dynamic>> _generatedArtifactPayloads() {
UiFeatureManifest _defaultDesktopManifest() {
return UiFeatureManifest.fromYamlString(
File(UiFeatureManifest.assetPath).readAsStringSync(),
).copyWithFeature(
platform: UiFeaturePlatform.desktop,
module: 'assistant',
feature: 'multi_agent',
enabled: true,
buildModes: const <UiFeatureBuildMode>{
UiFeatureBuildMode.debug,
UiFeatureBuildMode.profile,
UiFeatureBuildMode.release,
},
);
}
AppController _connectedController(GoTaskServiceClient client) {
AppController _sandboxController({
SecureConfigStore? store,
RuntimeCoordinator? runtimeCoordinator,
DesktopPlatformService? desktopPlatformService,
UiFeatureManifest? uiFeatureManifest,
List<SingleAgentProvider>? initialBridgeProviderCatalog,
List<SingleAgentProvider>? initialGatewayProviderCatalog,
List<AssistantExecutionTarget>? initialAvailableExecutionTargets,
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
Map<String, String>? 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: <String, String>{
...?environmentOverride,
'HOME': actualHome,
},
goTaskServiceClient: goTaskServiceClient,
);
}
AppController _connectedController(GoTaskServiceClient client, {String? homeDir}) {
return _sandboxController(
goTaskServiceClient: client,
uiFeatureManifest: _defaultDesktopManifest(),
environmentOverride: const <String, String>{
@ -4238,11 +4407,12 @@ AppController _connectedController(GoTaskServiceClient client) {
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
],
homeDir: homeDir,
);
}
AppController _connectedGatewayController(GoTaskServiceClient client) {
return AppController(
AppController _connectedGatewayController(GoTaskServiceClient client, {String? homeDir}) {
return _sandboxController(
goTaskServiceClient: client,
uiFeatureManifest: _defaultDesktopManifest(),
environmentOverride: const <String, String>{
@ -4258,6 +4428,7 @@ AppController _connectedGatewayController(GoTaskServiceClient client) {
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
homeDir: homeDir,
);
}
@ -4374,8 +4545,6 @@ Future<void> _waitForThreadLastResultCode(
);
}
class _RecordingGoTaskServiceClient implements GoTaskServiceClient {
int executeCount = 0;
final List<GoTaskServiceRequest> requests = <GoTaskServiceRequest>[];