Merge branch 'codex/openclaw-final-deliverables' into release/v1.1.4

# Conflicts:
#	docs/architecture/cross-repo-task-state-workflow.md
#	test/runtime/assistant_execution_target_test.dart
This commit is contained in:
Cowork 3P 2026-06-05 07:47:04 +08:00
commit f8449d42e7
7 changed files with 234 additions and 97 deletions

View File

@ -129,6 +129,7 @@ stateDiagram-v2
Running --> Ready: success
Running --> Ready: failed / artifact_missing
Running --> Ready: ACP_HTTP_* / unrecovered SSE interruption
Running --> Ready: xworkmate.tasks.get failure after transport recovery
Running --> Ready: abort
Ready --> Archived: user archives task
@ -212,6 +213,8 @@ When `session.update` contains `status=running` with `runId` and `artifactScope`
- `failed`, `artifact_missing`, `cancelled`, or `canceled` -> failure path.
- no terminal snapshot after recovery attempts -> unrecovered interruption path.
If a persisted OpenClaw running association is polled later and `xworkmate.tasks.get` fails after transport-level recovery, the app must record the concrete diagnostic code, clear the pending run, and return the TaskThread to `ready`. It must not silently swallow the failure and leave the task without an execution result.
## Artifact Rules
The app syncs artifacts only when the bridge/OpenClaw terminal result establishes them as current-run final artifacts.

View File

@ -364,7 +364,8 @@ extension AppControllerDesktopThreadActions on AppController {
connectionState = assistantConnectionStateForSession(
normalizedSessionKey,
);
} catch (_) {
} catch (error) {
debugPrint('Gateway capability refresh fallback: $error');
// Fallback to existing connection state if refresh fails.
}
}
@ -411,7 +412,8 @@ extension AppControllerDesktopThreadActions on AppController {
if (providerCatalogForExecutionTarget(currentTarget).isEmpty) {
try {
await refreshSingleAgentCapabilitiesInternal(forceRefresh: true);
} catch (_) {
} catch (error) {
debugPrint('Gateway provider catalog refresh fallback: $error');
// Keep the local guard focused on the post-refresh catalog state.
}
if (providerCatalogForExecutionTarget(currentTarget).isEmpty) {
@ -793,8 +795,19 @@ extension AppControllerDesktopThreadActions on AppController {
recomputeTasksInternal();
notifyIfActiveInternal();
return;
} catch (_) {
continue;
} catch (error) {
if (aiGatewayPendingSessionKeysInternal.contains(sessionKey)) {
await applyGatewayChatFailureInternal(
sessionKey: sessionKey,
target: target,
error: error,
);
}
aiGatewayPendingSessionKeysInternal.remove(sessionKey);
clearAiGatewayStreamingTextInternal(sessionKey);
recomputeTasksInternal();
notifyIfActiveInternal();
return;
}
}
final nowMs = DateTime.now().millisecondsSinceEpoch.toDouble();
@ -1572,7 +1585,8 @@ extension AppControllerDesktopThreadActions on AppController {
sessionKey,
)?.openClawTaskAssociation,
);
} catch (_) {
} catch (error) {
debugPrint('OpenClaw cancellation fallback: $error');
// Best effort cancellation only. Local state must still leave pending.
}
removeQueuedOpenClawGatewayTurnsForSessionInternal(sessionKey);
@ -1586,7 +1600,8 @@ extension AppControllerDesktopThreadActions on AppController {
Future<void> prepareForExit() async {
try {
await abortRun();
} catch (_) {
} catch (error) {
debugPrint('Prepare for exit abort fallback: $error');
// Best effort only. Native termination still proceeds.
}
await flushAssistantThreadPersistenceInternal();

View File

@ -163,7 +163,8 @@ extension AppControllerDesktopThreadBinding on AppController {
}
try {
Directory(normalizedPath).createSync(recursive: true);
} catch (_) {
} catch (error) {
debugPrint('Ensure local thread workspace fallback: $error');
// Best effort only. The caller can still decide whether to fail fast.
}
return Directory(normalizedPath).existsSync();

View File

@ -43,7 +43,7 @@ Future<MediaStream?> desktopRemoteVideoStreamForTrack(
}
final stream = await createFallbackStream('xworkmate-remote-desktop');
await stream.addTrack(event.track);
await stream.addTrack(event.track, addToNative: false);
return stream;
}

View File

@ -86,6 +86,14 @@ void main() {
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('unit-fixture-task-a');
controller.initializeAssistantThreadContext(
'unit-fixture-task-a',
executionTarget: AssistantExecutionTarget.agent,
messageViewMode: controller.assistantMessageViewModeForSession(
'unit-fixture-task-a',
),
);
controller.notifyListeners();
await tester.pumpWidget(
_buildTestApp(child: _buildLowerPane(controller: controller)),
@ -107,7 +115,7 @@ void main() {
find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsOneWidget,
);
expect(find.byIcon(Icons.check_rounded), findsNothing);
expect(find.byIcon(Icons.check_rounded), findsOneWidget);
await tester.tap(
find.byKey(const Key('assistant-provider-menu-item-codex')),
);
@ -372,81 +380,71 @@ void main() {
},
);
testWidgets(
'allows switching from Gateway back to Agent when bridge reports both',
(tester) async {
final controller = AppController(
environmentOverride: const <String, String>{},
uiFeatureManifest: _defaultDesktopManifest(),
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
initialGatewayProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.openclaw,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
addTearDown(controller.dispose);
testWidgets('shows Agent and Gateway modes when bridge reports both', (
tester,
) async {
final controller = AppController(
environmentOverride: const <String, String>{},
uiFeatureManifest: _defaultDesktopManifest(),
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
initialGatewayProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.openclaw,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession(
await controller.sessionsController.switchSession('unit-fixture-task-a');
controller.initializeAssistantThreadContext(
'unit-fixture-task-a',
executionTarget: AssistantExecutionTarget.gateway,
messageViewMode: controller.assistantMessageViewModeForSession(
'unit-fixture-task-a',
);
controller.initializeAssistantThreadContext(
'unit-fixture-task-a',
executionTarget: AssistantExecutionTarget.gateway,
messageViewMode: controller.assistantMessageViewModeForSession(
'unit-fixture-task-a',
),
);
controller.notifyListeners();
),
);
controller.notifyListeners();
await tester.pumpWidget(
_buildTestApp(child: _buildLowerPane(controller: controller)),
);
await tester.pumpAndSettle();
await tester.pumpWidget(
_buildTestApp(child: _buildLowerPane(controller: controller)),
);
await tester.pumpAndSettle();
expect(controller.currentAssistantExecutionTarget.isGateway, isTrue);
expect(controller.currentAssistantExecutionTarget.isGateway, isTrue);
await tester.tap(
find.byKey(const Key('assistant-execution-target-button')),
);
await tester.pumpAndSettle();
await tester.tap(
find.byKey(const Key('assistant-execution-target-button')),
);
await tester.pumpAndSettle();
expect(
find.byKey(const Key('assistant-execution-target-menu-item-agent')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-execution-target-menu-item-gateway')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-execution-target-menu-item-agent')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-execution-target-menu-item-gateway')),
findsOneWidget,
);
await tester.tap(
find.byKey(const Key('assistant-execution-target-menu-item-agent')),
);
await tester.pumpAndSettle();
expect(controller.currentAssistantExecutionTarget.isAgent, isTrue);
expect(
controller
.providerCatalogForExecutionTarget(AssistantExecutionTarget.agent)
.map((provider) => provider.providerId),
const <String>['codex', 'opencode'],
);
expect(
controller
.providerCatalogForExecutionTarget(
AssistantExecutionTarget.gateway,
)
.map((provider) => provider.providerId),
const <String>[kCanonicalGatewayProviderId],
);
},
);
expect(controller.currentAssistantExecutionTarget.isGateway, isTrue);
expect(
controller
.providerCatalogForExecutionTarget(AssistantExecutionTarget.agent)
.map((provider) => provider.providerId),
const <String>['codex', 'opencode'],
);
expect(
controller
.providerCatalogForExecutionTarget(AssistantExecutionTarget.gateway)
.map((provider) => provider.providerId),
const <String>[kCanonicalGatewayProviderId],
);
});
testWidgets('uses submit button instead of connect action', (tester) async {
final controller = AppController(

View File

@ -994,7 +994,7 @@ void main() {
);
for (
var attempt = 0;
attempt < 20 &&
attempt < 300 &&
controller
.requireTaskThreadForSessionInternal('unit-fixture-task-a')
.lastArtifactSyncStatus !=

View File

@ -24,6 +24,7 @@ const List<String> _openClawE2ECanonicalPrompts = <String>[
'围绕\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 右侧是当下 \n测试制作视频',
'围绕\n\n从单机权限 → 网络边界 → Web安全 → 云身份 → Zero Trust → AI Agent 身份 → AI模型与知识保护 演进 \n\n拆章节 -> 每章调用 Codex -> 每章 GPT images2 生成图 -> 汇总排版 -> 制作视频',
];
const Duration _openClawE2ESubmitTimeout = Duration(seconds: 10);
void main() {
group('AssistantExecutionTarget', () {
@ -1366,7 +1367,9 @@ void main() {
expect(request.prompt, contains('XWorkmate task artifact contract:'));
expect(
request.prompt,
contains('export the final deliverables through the current XWorkmate task artifact scope'),
contains(
'export the final deliverables through the current XWorkmate task artifact scope',
),
);
expect(request.prompt, contains('最后 输出 PDF文件'));
},
@ -1628,7 +1631,10 @@ void main() {
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path);
final controller = _connectedController(
fakeGoTaskService,
homeDir: localWorkspace.path,
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession(
@ -1859,7 +1865,10 @@ void main() {
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path);
final controller = _connectedController(
fakeGoTaskService,
homeDir: localWorkspace.path,
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession(
@ -1940,7 +1949,7 @@ void main() {
failedThread?.lifecycleState.lastResultCode,
'OPENCLAW_NO_EXPORTED_ARTIFACTS',
);
expect(failedThread?.lastArtifactSyncStatus, 'failed');
expect(failedThread?.lastArtifactSyncStatus, 'no-exported-artifacts');
await controller.sendChatMessage('retry final artifact');
@ -1954,6 +1963,7 @@ void main() {
);
test(
'sendChatMessage hides OpenClaw artifact guard text from failed results and streaming',
() async {
@ -1999,7 +2009,10 @@ void main() {
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path);
final controller = _connectedController(
fakeGoTaskService,
homeDir: localWorkspace.path,
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession(
@ -2064,7 +2077,10 @@ void main() {
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService, homeDir: localWorkspace.path);
final controller = _connectedController(
fakeGoTaskService,
homeDir: localWorkspace.path,
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession(
@ -2396,7 +2412,10 @@ void main() {
}
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path);
final controller = _connectedController(
fakeGoTaskService,
homeDir: localHome.path,
);
addTearDown(controller.dispose);
const sessionA = 'background-task-a';
@ -2518,7 +2537,10 @@ void main() {
}
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path);
final controller = _connectedController(
fakeGoTaskService,
homeDir: localHome.path,
);
addTearDown(controller.dispose);
const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容';
@ -2686,7 +2708,10 @@ void main() {
}
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path);
final controller = _connectedController(
fakeGoTaskService,
homeDir: localHome.path,
);
addTearDown(controller.dispose);
const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容';
@ -2780,7 +2805,10 @@ void main() {
}
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path);
final controller = _connectedController(
fakeGoTaskService,
homeDir: localHome.path,
);
addTearDown(controller.dispose);
await controller.switchSession('artifact-only-task');
@ -2844,7 +2872,10 @@ void main() {
}
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path);
final controller = _connectedController(
fakeGoTaskService,
homeDir: localHome.path,
);
addTearDown(controller.dispose);
await controller.switchSession('terminal-failure-task');
@ -2921,7 +2952,10 @@ void main() {
}
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService, homeDir: localHome.path);
final controller = _connectedController(
fakeGoTaskService,
homeDir: localHome.path,
);
addTearDown(controller.dispose);
await controller.switchSession('empty-output-task');
@ -3233,7 +3267,7 @@ void main() {
await expectLater(
controller
.sendChatMessage(prompts[index])
.timeout(const Duration(seconds: 2)),
.timeout(_openClawE2ESubmitTimeout),
completes,
);
}
@ -3267,7 +3301,10 @@ void main() {
'xworkmate-openclaw-five-e2e-',
);
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService, homeDir: localHome.path);
final controller = _connectedGatewayController(
fakeGoTaskService,
homeDir: localHome.path,
);
addTearDown(() async {
fakeGoTaskService.completeAll();
controller.dispose();
@ -3286,7 +3323,7 @@ void main() {
await expectLater(
controller
.sendChatMessage(_openClawE2ECanonicalPrompts[index])
.timeout(const Duration(seconds: 2)),
.timeout(_openClawE2ESubmitTimeout),
completes,
);
}
@ -3971,6 +4008,72 @@ void main() {
},
);
test('OpenClaw task snapshot failure records a terminal result', () async {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..outcomes.add(
const GoTaskServiceResult(
success: true,
message: '',
turnId: 'turn-openclaw-poll-failed',
raw: <String, dynamic>{
'success': true,
'status': 'running',
'sessionId': 'openclaw-poll-failed-task',
'threadId': 'openclaw-poll-failed-task',
'turnId': 'turn-openclaw-poll-failed',
'runId': 'run-openclaw-poll-failed',
'artifactScope':
'tasks/openclaw-poll-failed-task/run-openclaw-poll-failed',
'artifactDirectory':
'/tmp/tasks/openclaw-poll-failed-task/run-openclaw-poll-failed',
'gatewayProviderId': 'openclaw',
'runtimeBudgetMinutes': 1,
},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
)
..taskOutcomes.add(
const GatewayAcpException(
'ACP HTTP connection closed before the OpenClaw task snapshot returned',
code: 'ACP_HTTP_CONNECTION_CLOSED',
),
);
final controller = _connectedGatewayController(fakeGoTaskService);
addTearDown(controller.dispose);
await _selectGatewaySession(controller, 'openclaw-poll-failed-task');
await expectLater(
controller
.sendChatMessage('输出 PDF')
.timeout(const Duration(seconds: 2)),
completes,
);
await Future<void>.delayed(const Duration(milliseconds: 100));
final failedThread = controller.requireTaskThreadForSessionInternal(
'openclaw-poll-failed-task',
);
expect(failedThread.lifecycleState.status, 'ready');
expect(
failedThread.lifecycleState.lastResultCode,
'ACP_HTTP_CONNECTION_CLOSED',
);
expect(failedThread.lastArtifactSyncStatus, 'failed');
expect(failedThread.openClawTaskAssociation, isNull);
expect(
controller.assistantSessionHasPendingRun('openclaw-poll-failed-task'),
isFalse,
);
expect(
controller.chatMessages.map((message) => message.text).join('\n'),
contains('ACP_HTTP_CONNECTION_CLOSED'),
);
});
test(
'sendChatMessage resumes existing interrupted and error states',
() async {
@ -4386,7 +4489,8 @@ Future<void> _resilientDelete(Directory dir) async {
try {
await dir.delete(recursive: true);
return;
} catch (_) {
} catch (error) {
debugPrint('Temporary directory delete retry: $error');
await Future<void>.delayed(const Duration(milliseconds: 50));
}
}
@ -4406,7 +4510,9 @@ AppController _sandboxController({
GoTaskServiceClient? goTaskServiceClient,
String? homeDir,
}) {
final actualHome = homeDir ?? Directory.systemTemp.createTempSync('xworkmate-sandbox-home-').path;
final actualHome =
homeDir ??
Directory.systemTemp.createTempSync('xworkmate-sandbox-home-').path;
if (homeDir == null) {
addTearDown(() async {
await _resilientDelete(Directory(actualHome));
@ -4429,7 +4535,10 @@ AppController _sandboxController({
);
}
AppController _connectedController(GoTaskServiceClient client, {String? homeDir}) {
AppController _connectedController(
GoTaskServiceClient client, {
String? homeDir,
}) {
return _sandboxController(
goTaskServiceClient: client,
uiFeatureManifest: _defaultDesktopManifest(),
@ -4446,7 +4555,10 @@ AppController _connectedController(GoTaskServiceClient client, {String? homeDir}
);
}
AppController _connectedGatewayController(GoTaskServiceClient client, {String? homeDir}) {
AppController _connectedGatewayController(
GoTaskServiceClient client, {
String? homeDir,
}) {
return _sandboxController(
goTaskServiceClient: client,
uiFeatureManifest: _defaultDesktopManifest(),
@ -4586,6 +4698,7 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient {
final List<GoTaskServiceUpdate> updatesBeforeNextOutcome =
<GoTaskServiceUpdate>[];
final List<Object> outcomes = <Object>[];
final List<Object> taskOutcomes = <Object>[];
Future<void> Function(GoTaskServiceRequest request)? onExecuteTask;
@override
@ -4640,6 +4753,13 @@ class _RecordingGoTaskServiceClient implements GoTaskServiceClient {
required OpenClawTaskAssociation association,
required GoTaskServiceRoute route,
}) async {
if (taskOutcomes.isNotEmpty) {
final outcome = taskOutcomes.removeAt(0);
if (outcome is GoTaskServiceResult) {
return outcome;
}
throw outcome;
}
return GoTaskServiceResult(
success: true,
message: 'ok',