xworkmate-app/test/runtime/assistant_execution_target_test.dart
2026-05-11 11:45:32 +08:00

2278 lines
77 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/app/app_controller_desktop_external_acp_routing.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart';
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';
void main() {
group('AssistantExecutionTarget', () {
test('maps agent and gateway values without collapsing them', () {
expect(
threadExecutionModeFromAssistantExecutionTarget(
AssistantExecutionTarget.agent,
),
ThreadExecutionMode.agent,
);
expect(
threadExecutionModeFromAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
),
ThreadExecutionMode.gateway,
);
expect(
assistantExecutionTargetFromExecutionMode(ThreadExecutionMode.agent),
AssistantExecutionTarget.agent,
);
expect(
assistantExecutionTargetFromExecutionMode(ThreadExecutionMode.gateway),
AssistantExecutionTarget.gateway,
);
});
test('keeps both task dialog modes visible when both are supported', () {
expect(
compactAssistantExecutionTargets(const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
]),
const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
});
test('recognizes openclaw as the canonical gateway provider', () {
final provider = SingleAgentProvider.fromJsonValue('openclaw');
expect(provider.providerId, kCanonicalGatewayProviderId);
expect(provider.label, kCanonicalGatewayProviderLabel);
});
test(
'switching a session to gateway uses the bridge-provided gateway catalog',
() async {
final controller = AppController(
environmentOverride: const <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.gemini,
],
initialGatewayProviderCatalog: <SingleAgentProvider>[
SingleAgentProvider.openclaw.copyWith(
logoEmoji: '🦞',
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.gateway,
],
),
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
expect(controller.currentAssistantExecutionTarget.isAgent, isTrue);
expect(
controller.assistantProviderForSession(controller.currentSessionKey),
SingleAgentProvider.unspecified,
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
final record = controller.requireTaskThreadForSessionInternal(
'session-1',
);
expect(
controller.assistantExecutionTargetForSession('session-1').isGateway,
isTrue,
);
expect(
assistantExecutionTargetFromExecutionMode(
record.executionBinding.executionMode,
),
AssistantExecutionTarget.gateway,
);
expect(
controller.assistantProviderForSession('session-1'),
SingleAgentProvider.openclaw,
);
},
);
test(
'returns unspecified when a saved provider is no longer in the current catalog',
() {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
final unavailableProvider = controller
.resolveProviderForExecutionTarget(
'gemini',
executionTarget: AssistantExecutionTarget.agent,
);
expect(unavailableProvider.isUnspecified, isTrue);
},
);
test(
'does not recover a stale gateway provider from an empty gateway catalog',
() {
final controller = AppController(
environmentOverride: const <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.gemini,
],
);
addTearDown(controller.dispose);
final provider = controller.resolveProviderForExecutionTarget(
'openclaw',
executionTarget: AssistantExecutionTarget.gateway,
);
expect(provider.isUnspecified, isTrue);
},
);
test(
'switching a session to gateway with an empty gateway catalog keeps provider selection inherited',
() async {
final controller = AppController(
environmentOverride: const <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.gemini,
],
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
final record = controller.requireTaskThreadForSessionInternal(
'session-1',
);
expect(
controller.assistantExecutionTargetForSession('session-1'),
AssistantExecutionTarget.gateway,
);
expect(record.executionBinding.providerId, isEmpty);
expect(
record.executionBinding.providerSource,
ThreadSelectionSource.inherited,
);
expect(record.hasExplicitProviderSelection, isFalse);
},
);
test(
'gateway target without a live gateway provider uses explicit gateway routing',
() async {
final controller = AppController(
environmentOverride: const <String, String>{},
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
final routing = controller.buildExternalAcpRoutingForSessionInternal(
'session-1',
);
expect(routing.mode, ExternalCodeAgentAcpRoutingMode.explicit);
expect(routing.explicitExecutionTarget, 'gateway');
expect(routing.preferredGatewayTarget, 'openclaw');
expect(routing.explicitProviderId, '');
},
);
test(
'bridge skill summaries preserve bridge key and name without remap',
() {
final option = skillOptionFromGatewayInternal(
const GatewaySkillSummary(
name: 'Browser Fetch',
description: 'Bridge-managed browser skill',
source: 'bridge',
skillKey: 'browser-fetch',
primaryEnv: null,
eligible: true,
disabled: false,
missingBins: <String>[],
missingEnv: <String>[],
missingConfig: <String>[],
),
);
expect(option.key, 'browser-fetch');
expect(option.label, 'Browser Fetch');
expect(option.description, 'Bridge-managed browser skill');
},
);
test(
'locks the gateway provider catalog to the canonical openclaw contract',
() {
final controller = AppController(
environmentOverride: const <String, String>{},
initialGatewayProviderCatalog: <SingleAgentProvider>[
SingleAgentProvider.fromJsonValue(
'hermes',
label: 'Hermes',
badge: 'H',
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.gateway,
],
),
SingleAgentProvider.openclaw.copyWith(
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.gateway,
],
),
],
);
addTearDown(controller.dispose);
expect(
controller
.providerCatalogForExecutionTarget(
AssistantExecutionTarget.gateway,
)
.map((item) => item.providerId)
.toList(growable: false),
const <String>['openclaw'],
);
},
);
test(
'does not refresh agent provider catalog when agent mode is selected with an empty catalog',
() async {
final capture = await _startCapabilityServer();
addTearDown(capture.close);
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-agent-provider-refresh-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
try {
await storeRoot.delete(recursive: true);
} on FileSystemException {
// Temp cleanup is best effort here. The controller may still be
// releasing files when teardown starts.
}
}
});
final store = SecureConfigStore(
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
supportRootPathResolver: () async => '${storeRoot.path}/support',
enableSecureStorage: false,
);
await store.initialize();
await store.saveAccountSessionToken('session-token');
await store.saveAccountSessionSummary(
const AccountSessionSummary(
userId: 'user-1',
email: 'review@svc.plus',
name: 'Review User',
role: 'reviewer',
mfaEnabled: true,
),
);
await store.saveAccountSyncState(
AccountSyncState.defaults().copyWith(
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
bridgeServerUrl: capture.baseEndpoint.toString(),
),
syncState: 'ready',
tokenConfigured: const AccountTokenConfigured(
bridge: true,
vault: false,
apisix: false,
),
),
);
await store.saveAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
value: 'bridge-token',
);
final controller = AppController(
store: store,
environmentOverride: <String, String>{},
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(controller.assistantProviderCatalog, isEmpty);
final requestCountBefore = capture.requestCount;
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.agent,
);
controller.bridgeCapabilitiesRefreshAttemptedInternal = true;
controller.bridgeCapabilitiesRefreshErrorInternal = '';
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(controller.assistantProviderCatalog, isEmpty);
expect(capture.requestCount, lessThanOrEqualTo(requestCountBefore + 2));
if (capture.requestCount > requestCountBefore) {
expect(capture.lastAuthorizationHeader, 'Bearer bridge-token');
}
},
);
test(
'sendChatMessage fails locally without bridge sync token and does not execute ACP task',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient();
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-missing-bridge-token-send-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
try {
await storeRoot.delete(recursive: true);
} on FileSystemException {
// Temp cleanup is best effort here.
}
}
});
final store = SecureConfigStore(
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
supportRootPathResolver: () async => '${storeRoot.path}/support',
enableSecureStorage: false,
);
await store.initialize();
final controller = AppController(
store: store,
goTaskServiceClient: fakeGoTaskService,
environmentOverride: const <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
initialGatewayProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.openclaw,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
await expectLater(
controller.sendChatMessage('hi'),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
contains('请先登录 svc.plus'),
),
),
);
expect(fakeGoTaskService.executeCount, 0);
expect(controller.chatMessages.last.text, contains('请先登录 svc.plus'));
},
);
test(
'sendChatMessage surfaces managed bridge auth failure before agent provider dispatch',
() async {
final capture = await _startEmptyCapabilityServer();
addTearDown(capture.close);
final fakeGoTaskService = _RecordingGoTaskServiceClient();
final storeRoot = await Directory.systemTemp.createTemp(
'xworkmate-empty-gateway-provider-send-',
);
addTearDown(() async {
if (await storeRoot.exists()) {
try {
await storeRoot.delete(recursive: true);
} on FileSystemException {
// Temp cleanup is best effort here. The controller may still be
// releasing files when teardown starts.
}
}
});
final store = SecureConfigStore(
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
supportRootPathResolver: () async => '${storeRoot.path}/support',
enableSecureStorage: false,
);
await store.initialize();
await store.saveAccountSessionToken('session-token');
await store.saveAccountSessionSummary(
const AccountSessionSummary(
userId: 'user-1',
email: 'review@svc.plus',
name: 'Review User',
role: 'reviewer',
mfaEnabled: true,
),
);
await store.saveAccountSyncState(
AccountSyncState.defaults().copyWith(
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
bridgeServerUrl: capture.baseEndpoint.toString(),
),
syncState: 'ready',
tokenConfigured: const AccountTokenConfigured(
bridge: true,
vault: false,
apisix: false,
),
),
);
await store.saveAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
value: 'bridge-token',
);
final controller = AppController(
store: store,
goTaskServiceClient: fakeGoTaskService,
environmentOverride: <String, String>{
'BRIDGE_AUTH_TOKEN': 'bridge-token',
},
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
addTearDown(controller.dispose);
controller.settingsControllerInternal.accountSessionTokenInternal =
'session-token';
controller.settingsControllerInternal.accountSessionInternal =
const AccountSessionSummary(
userId: 'user-1',
email: 'review@svc.plus',
name: 'Review User',
role: 'reviewer',
mfaEnabled: true,
);
controller.settingsControllerInternal.accountSyncStateInternal =
AccountSyncState.defaults().copyWith(
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
bridgeServerUrl: capture.baseEndpoint.toString(),
),
syncState: 'ready',
tokenConfigured: const AccountTokenConfigured(
bridge: true,
vault: false,
apisix: false,
),
);
await controller.sessionsController.switchSession('session-1');
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.agent,
);
await Future<void>.delayed(const Duration(milliseconds: 200));
controller.bridgeCapabilitiesRefreshAttemptedInternal = true;
controller.bridgeCapabilitiesRefreshErrorInternal = '';
await expectLater(
controller.sendChatMessage('hi'),
throwsA(
isA<StateError>().having(
(error) => error.message,
'message',
anyOf(contains('ACP_HTTP_401'), contains('请先登录 svc.plus')),
),
),
);
expect(fakeGoTaskService.executeCount, 0);
expect(capture.requestCount, 0);
if (controller.chatMessages.isNotEmpty) {
expect(
controller.chatMessages.last.text,
anyOf(contains('ACP_HTTP_401'), contains('请先登录 svc.plus')),
);
}
},
);
test(
'sendChatMessage resumes only when the thread already has a committed user turn',
() async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
expect(
controller.hasCommittedUserTurnForGatewaySessionInternal('session-1'),
isFalse,
);
controller.appendLocalSessionMessageInternal(
'session-1',
GatewayChatMessage(
id: 'error-1',
role: 'assistant',
text: 'ACP_HTTP_CONNECTION_CLOSED',
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: true,
),
persistInThreadContext: true,
);
expect(
controller.hasCommittedUserTurnForGatewaySessionInternal('session-1'),
isFalse,
);
controller.appendLocalSessionMessageInternal(
'session-1',
GatewayChatMessage(
id: 'assistant-1',
role: 'assistant',
text: 'assistant-only history',
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
persistInThreadContext: true,
);
expect(
controller.hasCommittedUserTurnForGatewaySessionInternal('session-1'),
isFalse,
);
controller.appendLocalSessionMessageInternal(
'session-1',
GatewayChatMessage(
id: 'user-1',
role: 'user',
text: 'first turn',
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
persistInThreadContext: true,
);
expect(
controller.hasCommittedUserTurnForGatewaySessionInternal('session-1'),
isTrue,
);
},
);
test('sendChatMessage starts an empty thread with session.start', () async {
final fakeGoTaskService = _RecordingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await controller.sendChatMessage('first turn');
expect(fakeGoTaskService.requests, hasLength(1));
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
});
test('sendChatMessage restarts after ACP HTTP connection close', () async {
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-acp-interrupt-artifacts-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
});
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..updatesBeforeNextOutcome.add(
const GoTaskServiceUpdate(
sessionId: 'session-1',
threadId: 'session-1',
turnId: 'turn-1',
type: 'delta',
text: 'partial output that must not persist',
message: '',
pending: true,
error: false,
route: GoTaskServiceRoute.externalAcpSingle,
payload: <String, dynamic>{},
),
)
..outcomes.add(
const GatewayAcpException(
'ACP HTTP connection closed before the response finished arriving',
code: 'ACP_HTTP_CONNECTION_CLOSED',
),
)
..outcomes.add(
GoTaskServiceResult(
success: true,
message: '全部 6 个文件已生成 ✅',
turnId: 'turn-2',
raw: <String, dynamic>{'artifacts': _generatedArtifactPayloads()},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
controller.resolvedUserHomeDirectoryInternal = localWorkspace.path;
await controller.sessionsController.switchSession('session-1');
await controller.sendChatMessage('first turn');
expect(fakeGoTaskService.requests, hasLength(1));
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
expect(
controller
.taskThreadForSessionInternal('session-1')
?.lifecycleState
.status,
'ready',
);
expect(
controller.chatMessages.last.text,
'Bridge 响应读取中断本轮结果未完成。请重新发送请求。错误码ACP_HTTP_CONNECTION_CLOSED',
);
expect(
controller.chatMessages.map((message) => message.text),
isNot(contains('partial output that must not persist')),
);
expect(
controller
.taskThreadForSessionInternal('session-1')
?.lastArtifactSyncStatus,
'failed',
);
await controller.sendChatMessage('follow up');
expect(fakeGoTaskService.requests, hasLength(2));
expect(fakeGoTaskService.requests.last.resumeSession, isFalse);
await _waitForLastChatMessageText(controller, '全部 6 个文件已生成 ✅');
expect(controller.chatMessages.last.text, '全部 6 个文件已生成 ✅');
final thread = controller.taskThreadForSessionInternal('session-1');
expect(thread?.lifecycleState.status, 'ready');
expect(thread?.lastArtifactSyncStatus, 'synced');
expect(thread?.lastArtifactSyncAtMs, greaterThan(0));
final workspacePath = controller.assistantWorkspacePathForSession(
'session-1',
);
for (final artifact in _generatedArtifactPayloads()) {
final relativePath = artifact['relativePath']! as String;
final content = artifact['content']! as String;
expect(
await File('$workspacePath/$relativePath').readAsString(),
content,
);
}
});
test(
'sendChatMessage starts a new session after ACP HTTP connect timeout',
() async {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..outcomes.add(
const GatewayAcpException(
'ACP HTTP connection timed out before the request was confirmed',
code: gatewayAcpHttpConnectTimeoutCode,
),
)
..outcomes.add(
const GoTaskServiceResult(
success: true,
message: 'retried from a confirmed new start',
turnId: 'turn-2',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
await controller.sendChatMessage('first turn');
expect(fakeGoTaskService.requests, hasLength(1));
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
final failedThread = controller.taskThreadForSessionInternal(
'session-1',
);
expect(failedThread?.lifecycleState.status, 'ready');
expect(
failedThread?.lifecycleState.lastResultCode,
gatewayAcpHttpConnectTimeoutCode,
);
expect(failedThread?.lastArtifactSyncStatus, isNull);
expect(
controller.chatMessages.last.text,
'Bridge 连接超时本轮请求未确认可重试。错误码ACP_HTTP_CONNECT_TIMEOUT',
);
await controller.sendChatMessage('retry after unconfirmed connect');
expect(fakeGoTaskService.requests, hasLength(2));
expect(fakeGoTaskService.requests.last.resumeSession, isFalse);
await _waitForLastChatMessageText(
controller,
'retried from a confirmed new start',
);
expect(
controller.chatMessages.last.text,
'retried from a confirmed new start',
);
final thread = controller.taskThreadForSessionInternal('session-1');
expect(thread?.lifecycleState.status, 'ready');
expect(thread?.lifecycleState.lastResultCode, 'success');
},
);
test(
'sendChatMessage restarts before handling OpenClaw artifact guard results',
() async {
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-acp-interrupt-guard-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
});
const guardMessage =
'未检测到 OpenClaw 本轮导出的实际文件。已阻止口头下载声明进入 artifacts 面板;请重新执行并要求 OpenClaw 在 workspace 中真实生成文件。';
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..updatesBeforeNextOutcome.add(
const GoTaskServiceUpdate(
sessionId: 'session-1',
threadId: 'session-1',
turnId: 'turn-1',
type: 'delta',
text: 'guard partial output must not persist',
message: '',
pending: true,
error: false,
route: GoTaskServiceRoute.externalAcpSingle,
payload: <String, dynamic>{},
),
)
..outcomes.add(
const GatewayAcpException(
'ACP HTTP connection closed before the response finished arriving',
code: 'ACP_HTTP_CONNECTION_CLOSED',
),
)
..outcomes.add(
const GoTaskServiceResult(
success: true,
message: guardMessage,
turnId: 'turn-2',
raw: <String, dynamic>{'code': 'OPENCLAW_NO_EXPORTED_ARTIFACTS'},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
controller.resolvedUserHomeDirectoryInternal = localWorkspace.path;
await controller.sessionsController.switchSession('session-1');
await controller.sendChatMessage('first turn');
await controller.sendChatMessage('follow up');
expect(fakeGoTaskService.requests, hasLength(2));
expect(fakeGoTaskService.requests.first.resumeSession, isFalse);
expect(fakeGoTaskService.requests.last.resumeSession, isFalse);
final transcript = controller.chatMessages
.map((message) => message.text)
.join('\n');
expect(transcript, isNot(contains('未检测到 OpenClaw 本轮导出的实际文件')));
expect(transcript, isNot(contains('口头下载声明')));
expect(
transcript,
isNot(contains('guard partial output must not persist')),
);
final thread = controller.taskThreadForSessionInternal('session-1');
expect(thread?.lifecycleState.status, 'ready');
expect(thread?.lastArtifactSyncStatus, 'no-exported-artifacts');
expect(thread?.lastArtifactSyncAtMs, greaterThan(0));
},
);
test(
'sendChatMessage hides OpenClaw artifact guard text from failed results and streaming',
() async {
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-acp-guard-failure-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
});
const guardMessage =
'未检测到 OpenClaw 本轮导出的实际文件。已阻止口头下载声明进入 artifacts 面板;请重新执行并要求 OpenClaw 在 workspace 中真实生成文件。';
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..updatesBeforeNextOutcome.add(
const GoTaskServiceUpdate(
sessionId: 'session-1',
threadId: 'session-1',
turnId: 'turn-1',
type: 'delta',
text: guardMessage,
message: '',
pending: true,
error: false,
route: GoTaskServiceRoute.externalAcpSingle,
payload: <String, dynamic>{},
),
)
..outcomes.add(
const GoTaskServiceResult(
success: false,
message: '',
turnId: 'turn-1',
raw: <String, dynamic>{'code': 'OPENCLAW_ARTIFACT_GUARD'},
errorMessage: guardMessage,
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
controller.resolvedUserHomeDirectoryInternal = localWorkspace.path;
await controller.sessionsController.switchSession('session-1');
await controller.sendChatMessage('create files');
final transcript = controller.chatMessages
.map((message) => message.text)
.join('\n');
expect(transcript, isNot(contains('未检测到 OpenClaw 本轮导出的实际文件')));
expect(transcript, isNot(contains('口头下载声明')));
final thread = controller.taskThreadForSessionInternal('session-1');
expect(thread?.lastArtifactSyncStatus, 'no-exported-artifacts');
expect(thread?.lastArtifactSyncAtMs, greaterThan(0));
},
);
test(
'sendChatMessage restarts after ACP HTTP handshake interruption',
() async {
final localWorkspace = await Directory.systemTemp.createTemp(
'xworkmate-acp-handshake-interrupt-artifacts-',
);
addTearDown(() async {
if (await localWorkspace.exists()) {
await localWorkspace.delete(recursive: true);
}
});
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..updatesBeforeNextOutcome.add(
const GoTaskServiceUpdate(
sessionId: 'session-1',
threadId: 'session-1',
turnId: 'turn-1',
type: 'delta',
text: 'handshake partial output must not persist',
message: '',
pending: true,
error: false,
route: GoTaskServiceRoute.externalAcpSingle,
payload: <String, dynamic>{},
),
)
..outcomes.add(
const GatewayAcpException(
'ACP HTTP handshake was interrupted before the response started',
code: gatewayAcpHttpHandshakeInterruptedCode,
),
)
..outcomes.add(
GoTaskServiceResult(
success: true,
message: '全部 6 个文件已生成 ✅',
turnId: 'turn-2',
raw: <String, dynamic>{'artifacts': _generatedArtifactPayloads()},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
controller.resolvedUserHomeDirectoryInternal = localWorkspace.path;
await controller.sessionsController.switchSession('session-1');
await controller.sendChatMessage('first turn');
expect(fakeGoTaskService.requests, hasLength(1));
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
final failedThread = controller.taskThreadForSessionInternal(
'session-1',
);
expect(failedThread?.lifecycleState.status, 'ready');
expect(
failedThread?.lifecycleState.lastResultCode,
gatewayAcpHttpHandshakeInterruptedCode,
);
expect(failedThread?.lastArtifactSyncStatus, 'failed');
expect(
controller.chatMessages.last.text,
'Bridge 握手中断本轮请求未完成。请重新发送请求。错误码ACP_HTTP_HANDSHAKE_INTERRUPTED',
);
expect(
controller.chatMessages.map((message) => message.text),
isNot(contains('handshake partial output must not persist')),
);
await controller.sendChatMessage('follow up');
expect(fakeGoTaskService.requests, hasLength(2));
expect(fakeGoTaskService.requests.last.resumeSession, isFalse);
await _waitForLastChatMessageText(controller, '全部 6 个文件已生成 ✅');
expect(controller.chatMessages.last.text, '全部 6 个文件已生成 ✅');
final thread = controller.taskThreadForSessionInternal('session-1');
expect(thread?.lifecycleState.status, 'ready');
expect(thread?.lastArtifactSyncStatus, 'synced');
expect(thread?.lastArtifactSyncAtMs, greaterThan(0));
final workspacePath = controller.assistantWorkspacePathForSession(
'session-1',
);
for (final artifact in _generatedArtifactPayloads()) {
final relativePath = artifact['relativePath']! as String;
final content = artifact['content']! as String;
expect(
await File('$workspacePath/$relativePath').readAsString(),
content,
);
}
},
);
test(
'chatMessages does not duplicate persisted local turn messages',
() async {
final controller = AppController(
environmentOverride: const <String, String>{},
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
final userMessage = GatewayChatMessage(
id: 'local-user-1',
role: 'user',
text: 'hi',
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
);
final assistantMessage = GatewayChatMessage(
id: 'local-assistant-1',
role: 'assistant',
text: 'Bridge response',
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
);
controller.appendLocalSessionMessageInternal(
'session-1',
userMessage,
persistInThreadContext: true,
);
controller.appendLocalSessionMessageInternal(
'session-1',
assistantMessage,
persistInThreadContext: true,
);
controller.assistantThreadMessagesInternal['session-1'] =
List<GatewayChatMessage>.from(
controller
.requireTaskThreadForSessionInternal('session-1')
.messages,
);
final visibleMessages = controller.chatMessages;
expect(
visibleMessages.where((message) => message.id == userMessage.id),
hasLength(1),
);
expect(
visibleMessages.where((message) => message.id == assistantMessage.id),
hasLength(1),
);
expect(
visibleMessages.map((message) => message.text),
containsAllInOrder(<String>[userMessage.text, assistantMessage.text]),
);
},
);
test('sendChatMessage runs independent sessions concurrently', () async {
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.switchSession('task-a');
final taskAFuture = controller.sendChatMessage('task A');
await fakeGoTaskService.waitForRequestCount(1);
expect(fakeGoTaskService.requests.single.sessionId, 'task-a');
expect(controller.assistantSessionHasPendingRun('task-a'), isTrue);
await controller.switchSession('task-b');
final taskBFuture = controller.sendChatMessage('task B');
await fakeGoTaskService.waitForRequestCount(2);
expect(
fakeGoTaskService.requests.map((request) => request.sessionId),
<String>['task-a', 'task-b'],
);
expect(controller.assistantSessionHasPendingRun('task-a'), isTrue);
expect(controller.assistantSessionHasPendingRun('task-b'), isTrue);
fakeGoTaskService.complete(
'task-b',
const GoTaskServiceResult(
success: true,
message: 'result B',
turnId: 'turn-b',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskBFuture;
expect(controller.assistantSessionHasPendingRun('task-a'), isTrue);
expect(controller.assistantSessionHasPendingRun('task-b'), isFalse);
expect(
controller.localSessionMessagesInternal['task-b']!.map(
(message) => message.text,
),
contains('result B'),
);
expect(
controller.localSessionMessagesInternal['task-a']!.map(
(message) => message.text,
),
isNot(contains('result B')),
);
fakeGoTaskService.complete(
'task-a',
const GoTaskServiceResult(
success: true,
message: 'result A',
turnId: 'turn-a',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskAFuture;
expect(controller.assistantSessionHasPendingRun('task-a'), isFalse);
expect(
controller.localSessionMessagesInternal['task-a']!.map(
(message) => message.text,
),
contains('result A'),
);
});
test(
'sendChatMessage keeps same-prompt draft task artifacts isolated',
() async {
final localHome = await Directory.systemTemp.createTemp(
'xworkmate-same-prompt-home-',
);
addTearDown(() async {
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
controller.resolvedUserHomeDirectoryInternal = localHome.path;
const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容';
final uniqueSuffix = DateTime.now().microsecondsSinceEpoch.toString();
final sessionA = 'draft-task-a-$uniqueSuffix';
final sessionB = 'draft-task-b-$uniqueSuffix';
addTearDown(() async {
for (final sessionKey in <String>[sessionA, sessionB]) {
final workspace = controller.assistantWorkspacePathForSession(
sessionKey,
);
if (workspace.trim().isEmpty) {
continue;
}
final directory = Directory(workspace);
if (await directory.exists()) {
await directory.delete(recursive: true);
}
}
});
await controller.switchSession(sessionA);
final taskAFuture = controller.sendChatMessage(prompt);
await fakeGoTaskService.waitForRequestCount(1);
await controller.switchSession(sessionB);
final taskBFuture = controller.sendChatMessage(prompt);
await fakeGoTaskService.waitForRequestCount(2);
final taskARequest = fakeGoTaskService.requests[0];
final taskBRequest = fakeGoTaskService.requests[1];
expect(taskARequest.sessionId, sessionA);
expect(taskBRequest.sessionId, sessionB);
expect(taskARequest.prompt, taskBRequest.prompt);
expect(taskARequest.resumeSession, isFalse);
expect(taskBRequest.resumeSession, isFalse);
expect(taskARequest.workingDirectory, endsWith('/$sessionA'));
expect(taskBRequest.workingDirectory, endsWith('/$sessionB'));
expect(
taskARequest.workingDirectory,
isNot(taskBRequest.workingDirectory),
);
expect(
taskARequest.remoteWorkingDirectoryHint,
isNot(taskBRequest.remoteWorkingDirectoryHint),
);
expect(
taskARequest.remoteWorkingDirectoryHint,
endsWith('/threads/$sessionA'),
);
expect(
taskBRequest.remoteWorkingDirectoryHint,
endsWith('/threads/$sessionB'),
);
fakeGoTaskService.complete(
sessionA,
GoTaskServiceResult(
success: true,
message: 'result A',
turnId: 'turn-a',
raw: <String, dynamic>{
'remoteWorkingDirectory':
'/home/ubuntu/.openclaw/workspace/tasks/$sessionA/turn-a',
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': 'same-prompt-a.md',
'content': 'artifact A',
'contentType': 'text/markdown',
},
],
},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskAFuture;
fakeGoTaskService.complete(
sessionB,
GoTaskServiceResult(
success: true,
message: 'result B',
turnId: 'turn-b',
raw: <String, dynamic>{
'remoteWorkingDirectory':
'/home/ubuntu/.openclaw/workspace/tasks/$sessionB/turn-b',
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': 'same-prompt-b.md',
'content': 'artifact B',
'contentType': 'text/markdown',
},
],
},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskBFuture;
final taskAWorkspace = controller.assistantWorkspacePathForSession(
sessionA,
);
final taskBWorkspace = controller.assistantWorkspacePathForSession(
sessionB,
);
expect(
await File('$taskAWorkspace/same-prompt-a.md').readAsString(),
'artifact A',
);
expect(
await File('$taskBWorkspace/same-prompt-b.md').readAsString(),
'artifact B',
);
final taskAThread = controller.requireTaskThreadForSessionInternal(
sessionA,
);
final taskBThread = controller.requireTaskThreadForSessionInternal(
sessionB,
);
expect(taskAThread.lastArtifactSyncStatus, 'synced');
expect(taskBThread.lastArtifactSyncStatus, 'synced');
expect(taskAThread.lastTaskArtifactRelativePaths, <String>[
'same-prompt-a.md',
]);
expect(taskBThread.lastTaskArtifactRelativePaths, <String>[
'same-prompt-b.md',
]);
expect(
taskAThread.lastRemoteWorkingDirectory,
'/home/ubuntu/.openclaw/workspace/tasks/$sessionA/turn-a',
);
expect(
taskBThread.lastRemoteWorkingDirectory,
'/home/ubuntu/.openclaw/workspace/tasks/$sessionB/turn-b',
);
final taskBSnapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: sessionB,
);
expect(
taskBSnapshot.fileEntries.map((entry) => entry.relativePath),
<String>['same-prompt-b.md'],
);
},
);
test(
'sendChatMessage clears same-prompt draft task artifacts when no files return',
() async {
final localHome = await Directory.systemTemp.createTemp(
'xworkmate-same-prompt-empty-home-',
);
addTearDown(() async {
if (await localHome.exists()) {
await localHome.delete(recursive: true);
}
});
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
controller.resolvedUserHomeDirectoryInternal = localHome.path;
const prompt = '用户要求我生成一个关于现代AI基础设施的技术营销内容';
final uniqueSuffix = DateTime.now().microsecondsSinceEpoch.toString();
final sessionA = 'draft-task-a-empty-$uniqueSuffix';
final sessionB = 'draft-task-b-empty-$uniqueSuffix';
addTearDown(() async {
for (final sessionKey in <String>[sessionA, sessionB]) {
final workspace = controller.assistantWorkspacePathForSession(
sessionKey,
);
if (workspace.trim().isEmpty) {
continue;
}
final directory = Directory(workspace);
if (await directory.exists()) {
await directory.delete(recursive: true);
}
}
});
await controller.switchSession(sessionA);
final taskAFuture = controller.sendChatMessage(prompt);
await fakeGoTaskService.waitForRequestCount(1);
fakeGoTaskService.complete(
sessionA,
const GoTaskServiceResult(
success: true,
message: 'result A',
turnId: 'turn-a',
raw: <String, dynamic>{
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': 'same-prompt-a.md',
'content': 'artifact A',
'contentType': 'text/markdown',
},
],
},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskAFuture;
await controller.switchSession(sessionB);
final taskBFuture = controller.sendChatMessage(prompt);
await fakeGoTaskService.waitForRequestCount(2);
fakeGoTaskService.complete(
sessionB,
const GoTaskServiceResult(
success: true,
message: 'result B',
turnId: 'turn-b',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskBFuture;
final taskBThread = controller.requireTaskThreadForSessionInternal(
sessionB,
);
expect(taskBThread.lastArtifactSyncStatus, 'no-artifacts');
expect(taskBThread.lastTaskArtifactRelativePaths, isEmpty);
final taskBSnapshot = await controller.loadAssistantArtifactSnapshot(
sessionKey: sessionB,
);
expect(taskBSnapshot.fileEntries, isEmpty);
expect(
taskBSnapshot.filesMessage,
'No files found in the recorded working directory.',
);
},
);
test('abortRun cancels only the current pending session', () async {
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.switchSession('task-a');
final taskAFuture = controller.sendChatMessage('task A');
await fakeGoTaskService.waitForRequestCount(1);
await controller.switchSession('task-b');
final taskBFuture = controller.sendChatMessage('task B');
await fakeGoTaskService.waitForRequestCount(2);
fakeGoTaskService.emitDelta('task-b', 'streaming text');
expect(controller.assistantSessionHasPendingRun('task-a'), isTrue);
expect(controller.assistantSessionHasPendingRun('task-b'), isTrue);
await controller.abortRun();
expect(fakeGoTaskService.cancelledSessionIds, <String>['task-b']);
expect(controller.assistantSessionHasPendingRun('task-a'), isTrue);
expect(controller.assistantSessionHasPendingRun('task-b'), isFalse);
expect(
controller
.requireTaskThreadForSessionInternal('task-b')
.lifecycleState
.lastResultCode,
'aborted',
);
expect(
controller.aiGatewayStreamingTextBySessionInternal['task-b'],
isNull,
);
fakeGoTaskService.complete(
'task-b',
const GoTaskServiceResult(
success: true,
message: 'late result B',
turnId: 'turn-b',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskBFuture;
expect(
controller.localSessionMessagesInternal['task-b']!.map(
(message) => message.text,
),
isNot(contains('late result B')),
);
fakeGoTaskService.complete(
'task-a',
const GoTaskServiceResult(
success: true,
message: 'result A',
turnId: 'turn-a',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskAFuture;
expect(
controller.localSessionMessagesInternal['task-a']!.map(
(message) => message.text,
),
contains('result A'),
);
});
test(
'OpenClaw gateway tasks queue globally and keep captured session context',
() async {
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
addTearDown(() {
fakeGoTaskService.completeAll();
controller.dispose();
});
await _selectGatewaySession(controller, 'queue-task-a');
final taskAFuture = controller.sendChatMessage('same prompt');
await fakeGoTaskService.waitForRequestCount(1);
await _selectGatewaySession(controller, 'queue-task-b');
final taskBFuture = controller.sendChatMessage('same prompt');
await _selectGatewaySession(controller, 'queue-task-c');
final taskCFuture = controller.sendChatMessage('different prompt');
await _waitForThreadLifecycleStatus(
controller,
'queue-task-b',
'queued',
);
await _waitForThreadLifecycleStatus(
controller,
'queue-task-c',
'queued',
);
expect(fakeGoTaskService.requests, hasLength(1));
expect(
controller
.requireTaskThreadForSessionInternal('queue-task-b')
.lifecycleState
.status,
'queued',
);
expect(
controller
.requireTaskThreadForSessionInternal('queue-task-c')
.lifecycleState
.status,
'queued',
);
fakeGoTaskService.complete(
'queue-task-a',
const GoTaskServiceResult(
success: true,
message: 'result A',
turnId: 'turn-a',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskAFuture;
await fakeGoTaskService.waitForRequestCount(2);
final taskBRequest = fakeGoTaskService.requests[1];
expect(taskBRequest.sessionId, 'queue-task-b');
expect(taskBRequest.prompt, 'same prompt');
expect(taskBRequest.resumeSession, isFalse);
expect(taskBRequest.workingDirectory, endsWith('/queue-task-b'));
expect(
taskBRequest.remoteWorkingDirectoryHint,
endsWith('/threads/queue-task-b'),
);
fakeGoTaskService.complete(
'queue-task-b',
const GoTaskServiceResult(
success: true,
message: 'result B',
turnId: 'turn-b',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskBFuture;
await fakeGoTaskService.waitForRequestCount(3);
final taskCRequest = fakeGoTaskService.requests[2];
expect(taskCRequest.sessionId, 'queue-task-c');
expect(taskCRequest.prompt, 'different prompt');
expect(taskCRequest.workingDirectory, endsWith('/queue-task-c'));
fakeGoTaskService.complete(
'queue-task-c',
const GoTaskServiceResult(
success: true,
message: 'result C',
turnId: 'turn-c',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await taskCFuture;
},
);
test(
'abortRun removes a queued OpenClaw task without bridge cancel',
() async {
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
addTearDown(() {
fakeGoTaskService.completeAll();
controller.dispose();
});
await _selectGatewaySession(controller, 'running-openclaw-task');
final runningFuture = controller.sendChatMessage('running');
await fakeGoTaskService.waitForRequestCount(1);
await _selectGatewaySession(controller, 'queued-openclaw-task');
final queuedFuture = controller.sendChatMessage('queued');
await _waitForThreadLifecycleStatus(
controller,
'queued-openclaw-task',
'queued',
);
expect(fakeGoTaskService.requests, hasLength(1));
await controller.abortRun();
expect(fakeGoTaskService.cancelledSessionIds, isEmpty);
expect(
controller
.requireTaskThreadForSessionInternal('queued-openclaw-task')
.lifecycleState
.lastResultCode,
'aborted',
);
await queuedFuture;
fakeGoTaskService.complete(
'running-openclaw-task',
const GoTaskServiceResult(
success: true,
message: 'running done',
turnId: 'turn-running',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await runningFuture;
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(fakeGoTaskService.requests, hasLength(1));
},
);
test('OpenClaw queue overflow fails without artifact sync', () async {
final fakeGoTaskService = _BlockingGoTaskServiceClient();
final controller = _connectedGatewayController(fakeGoTaskService);
addTearDown(() {
fakeGoTaskService.completeAll();
controller.dispose();
});
await _selectGatewaySession(controller, 'queue-full-running');
final runningFuture = controller.sendChatMessage('running');
await fakeGoTaskService.waitForRequestCount(1);
final queuedFutures = <Future<void>>[];
for (var index = 0; index < 20; index += 1) {
final sessionKey = 'queue-full-waiting-$index';
await _selectGatewaySession(controller, sessionKey);
queuedFutures.add(controller.sendChatMessage('queued $index'));
}
await _waitForOpenClawQueuedTaskCount(controller, 20);
await _selectGatewaySession(controller, 'queue-full-overflow');
await expectLater(
controller.sendChatMessage('overflow'),
throwsA(isA<StateError>()),
);
expect(fakeGoTaskService.requests, hasLength(1));
final overflowThread = controller.requireTaskThreadForSessionInternal(
'queue-full-overflow',
);
expect(overflowThread.lastArtifactSyncStatus, 'failed');
expect(overflowThread.lastTaskArtifactRelativePaths, isEmpty);
fakeGoTaskService.complete(
'queue-full-running',
const GoTaskServiceResult(
success: true,
message: 'running done',
turnId: 'turn-running',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await runningFuture;
for (var index = 0; index < 20; index += 1) {
final sessionKey = 'queue-full-waiting-$index';
await fakeGoTaskService.waitForRequestCount(index + 2);
fakeGoTaskService.complete(
sessionKey,
GoTaskServiceResult(
success: true,
message: 'queued $index done',
turnId: 'turn-queued-$index',
raw: const <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
}
await Future.wait(queuedFutures);
});
test(
'sendChatMessage restarts stale interrupted and error states',
() async {
late final AppController controller;
final observedRequestStatuses = <String>[];
final fakeGoTaskService = _BlockingGoTaskServiceClient(
onRequest: (request) {
observedRequestStatuses.add(
controller
.taskThreadForSessionInternal(request.sessionId)
?.lifecycleState
.status ??
'',
);
},
);
controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.switchSession('interrupted-task');
controller.appendLocalSessionMessageInternal(
'interrupted-task',
GatewayChatMessage(
id: 'user-interrupted',
role: 'user',
text: 'previous turn',
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
persistInThreadContext: true,
);
controller.upsertTaskThreadInternal(
'interrupted-task',
lifecycleStatus: 'interrupted',
lastResultCode: 'ACP_HTTP_CONNECTION_CLOSED',
);
expect(
controller.hasCommittedUserTurnForGatewaySessionInternal(
'interrupted-task',
),
isTrue,
);
final interruptedFuture = controller.sendChatMessage('continue');
await fakeGoTaskService.waitForRequestCount(1);
expect(observedRequestStatuses.single, 'running');
expect(fakeGoTaskService.requests.single.resumeSession, isFalse);
expect(
controller.assistantSessionHasPendingRun('interrupted-task'),
isTrue,
);
fakeGoTaskService.complete(
'interrupted-task',
const GoTaskServiceResult(
success: true,
message: 'continued',
turnId: 'turn-continued',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await interruptedFuture;
await controller.switchSession('retry-task');
controller.appendLocalSessionMessageInternal(
'retry-task',
GatewayChatMessage(
id: 'user-retry',
role: 'user',
text: 'previous failed turn',
timestampMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
toolCallId: null,
toolName: null,
stopReason: null,
pending: false,
error: false,
),
persistInThreadContext: true,
);
controller.upsertTaskThreadInternal(
'retry-task',
lifecycleStatus: 'ready',
lastResultCode: 'error',
);
final retryFuture = controller.sendChatMessage('retry');
await fakeGoTaskService.waitForRequestCount(2);
expect(observedRequestStatuses.last, 'running');
expect(fakeGoTaskService.requests.last.resumeSession, isFalse);
expect(controller.assistantSessionHasPendingRun('retry-task'), isTrue);
fakeGoTaskService.complete(
'retry-task',
const GoTaskServiceResult(
success: true,
message: 'retried',
turnId: 'turn-retried',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
await retryFuture;
},
);
test('sendChatMessage resumes only after a confirmed success', () async {
final fakeGoTaskService = _RecordingGoTaskServiceClient()
..outcomes.add(
const GoTaskServiceResult(
success: true,
message: 'first success',
turnId: 'turn-1',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
)
..outcomes.add(
const GoTaskServiceResult(
success: true,
message: 'second success',
turnId: 'turn-2',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
);
final controller = _connectedController(fakeGoTaskService);
addTearDown(controller.dispose);
await controller.switchSession('confirmed-session');
await controller.sendChatMessage('first turn');
await controller.sendChatMessage('second turn');
expect(fakeGoTaskService.requests, hasLength(2));
expect(fakeGoTaskService.requests.first.resumeSession, isFalse);
expect(fakeGoTaskService.requests.last.resumeSession, isTrue);
});
});
}
Future<_CapabilityServerCapture> _startCapabilityServer() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final capture = _CapabilityServerCapture._(
server,
Uri.parse('http://127.0.0.1:${server.port}'),
);
server.listen((request) async {
capture.requestCount += 1;
capture.lastAuthorizationHeader =
request.headers.value(HttpHeaders.authorizationHeader) ?? '';
await utf8.decoder.bind(request).join();
if (capture.requestCount == 1) {
request.response.statusCode = HttpStatus.internalServerError;
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode(<String, dynamic>{
'error': <String, dynamic>{'message': 'startup refresh failed'},
}),
);
await request.response.close();
return;
}
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': 'capabilities',
'result': <String, dynamic>{
'singleAgent': true,
'multiAgent': true,
'providerCatalog': <Map<String, dynamic>>[
<String, dynamic>{'providerId': 'codex', 'label': 'Codex'},
<String, dynamic>{'providerId': 'opencode', 'label': 'OpenCode'},
<String, dynamic>{'providerId': 'gemini', 'label': 'Gemini'},
],
},
}),
);
await request.response.close();
});
return capture;
}
Future<void> _waitForLastChatMessageText(
AppController controller,
String expectedText,
) async {
final deadline = DateTime.now().add(const Duration(seconds: 15));
while (DateTime.now().isBefore(deadline)) {
if (controller.chatMessages.isNotEmpty &&
controller.chatMessages.last.text == expectedText) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
expect(
controller.chatMessages.isEmpty ? '' : controller.chatMessages.last.text,
expectedText,
);
}
Future<_CapabilityServerCapture> _startEmptyCapabilityServer() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final capture = _CapabilityServerCapture._(
server,
Uri.parse('http://127.0.0.1:${server.port}'),
);
server.listen((request) async {
capture.requestCount += 1;
capture.lastAuthorizationHeader =
request.headers.value(HttpHeaders.authorizationHeader) ?? '';
await utf8.decoder.bind(request).join();
request.response.headers.contentType = ContentType.json;
request.response.write(
jsonEncode(<String, dynamic>{
'jsonrpc': '2.0',
'id': 'capabilities',
'result': <String, dynamic>{
'singleAgent': false,
'multiAgent': true,
'availableExecutionTargets': const <String>[],
'providerCatalog': const <Map<String, dynamic>>[],
'gatewayProviders': const <Map<String, dynamic>>[],
},
}),
);
await request.response.close();
});
return capture;
}
class _CapabilityServerCapture {
_CapabilityServerCapture._(this._server, this.baseEndpoint);
final HttpServer _server;
final Uri baseEndpoint;
int requestCount = 0;
String lastAuthorizationHeader = '';
Future<void> close() => _server.close(force: true);
}
List<Map<String, dynamic>> _generatedArtifactPayloads() {
return <Map<String, dynamic>>[
<String, dynamic>{
'relativePath': '网络与协议专题-图片生成提示词.md',
'content': 'prompt content',
'contentType': 'text/markdown',
},
<String, dynamic>{
'relativePath': '小红书风格文案.md',
'content': 'xiaohongshu copy',
'contentType': 'text/markdown',
},
<String, dynamic>{
'relativePath': 'X文案.md',
'content': 'x copy',
'contentType': 'text/markdown',
},
<String, dynamic>{
'relativePath': '领英文案.md',
'content': 'linkedin copy',
'contentType': 'text/markdown',
},
<String, dynamic>{
'relativePath': '云原生网络与协议专题.pptx',
'content': 'pptx bytes',
'contentType': 'application/octet-stream',
},
<String, dynamic>{
'relativePath': 'PptxGenJS_脚本.js',
'content': 'console.log("pptx");',
'contentType': 'text/javascript',
},
];
}
AppController _connectedController(GoTaskServiceClient client) {
return AppController(
goTaskServiceClient: client,
environmentOverride: const <String, String>{
'BRIDGE_AUTH_TOKEN': 'bridge-token',
},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
],
);
}
AppController _connectedGatewayController(GoTaskServiceClient client) {
return AppController(
goTaskServiceClient: client,
environmentOverride: const <String, String>{
'BRIDGE_AUTH_TOKEN': 'bridge-token',
},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
initialGatewayProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.openclaw,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
}
Future<void> _selectGatewaySession(
AppController controller,
String sessionKey,
) async {
await controller.switchSession(sessionKey);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
}
Future<void> _waitForThreadLifecycleStatus(
AppController controller,
String sessionKey,
String status,
) async {
final deadline = DateTime.now().add(const Duration(seconds: 15));
while (DateTime.now().isBefore(deadline)) {
final currentStatus = controller
.taskThreadForSessionInternal(sessionKey)
?.lifecycleState
.status;
if (currentStatus == status) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
final currentStatus = controller
.taskThreadForSessionInternal(sessionKey)
?.lifecycleState
.status;
throw StateError(
'Timed out waiting for $sessionKey status $status. Current status: $currentStatus.',
);
}
Future<void> _waitForOpenClawQueuedTaskCount(
AppController controller,
int count,
) async {
final deadline = DateTime.now().add(const Duration(seconds: 15));
while (DateTime.now().isBefore(deadline)) {
if (controller.openClawGatewayQueuedTurnsInternal.length >= count) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 10));
}
throw StateError(
'Timed out waiting for $count queued OpenClaw tasks. Current count: ${controller.openClawGatewayQueuedTurnsInternal.length}.',
);
}
class _RecordingGoTaskServiceClient implements GoTaskServiceClient {
int executeCount = 0;
final List<GoTaskServiceRequest> requests = <GoTaskServiceRequest>[];
final List<GoTaskServiceUpdate> updatesBeforeNextOutcome =
<GoTaskServiceUpdate>[];
final List<Object> outcomes = <Object>[];
@override
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
required AssistantExecutionTarget target,
bool forceRefresh = false,
}) async => const ExternalCodeAgentAcpCapabilities.empty();
@override
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
required String taskPrompt,
required String workingDirectory,
required ExternalCodeAgentAcpRoutingConfig routing,
}) async =>
const ExternalCodeAgentAcpRoutingResolution(raw: <String, dynamic>{});
@override
Future<GoTaskServiceResult> executeTask(
GoTaskServiceRequest request, {
required void Function(GoTaskServiceUpdate update) onUpdate,
}) async {
executeCount += 1;
requests.add(request);
for (final update in List<GoTaskServiceUpdate>.from(
updatesBeforeNextOutcome,
)) {
onUpdate(update);
}
updatesBeforeNextOutcome.clear();
if (outcomes.isNotEmpty) {
final outcome = outcomes.removeAt(0);
if (outcome is GoTaskServiceResult) {
return outcome;
}
throw outcome;
}
return const GoTaskServiceResult(
success: true,
message: 'ok',
turnId: 'turn',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
);
}
@override
Future<void> cancelTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
}) async {}
@override
Future<void> closeTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
}) async {}
@override
Future<void> dispose() async {}
}
class _BlockingGoTaskServiceClient implements GoTaskServiceClient {
_BlockingGoTaskServiceClient({this.onRequest});
final void Function(GoTaskServiceRequest request)? onRequest;
final List<GoTaskServiceRequest> requests = <GoTaskServiceRequest>[];
final List<String> cancelledSessionIds = <String>[];
final Map<String, Completer<GoTaskServiceResult>> _pending =
<String, Completer<GoTaskServiceResult>>{};
final Map<String, void Function(GoTaskServiceUpdate)> _updates =
<String, void Function(GoTaskServiceUpdate)>{};
@override
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
required AssistantExecutionTarget target,
bool forceRefresh = false,
}) async => const ExternalCodeAgentAcpCapabilities.empty();
@override
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
required String taskPrompt,
required String workingDirectory,
required ExternalCodeAgentAcpRoutingConfig routing,
}) async =>
const ExternalCodeAgentAcpRoutingResolution(raw: <String, dynamic>{});
@override
Future<GoTaskServiceResult> executeTask(
GoTaskServiceRequest request, {
required void Function(GoTaskServiceUpdate update) onUpdate,
}) {
requests.add(request);
onRequest?.call(request);
_updates[request.sessionId] = onUpdate;
final completer = Completer<GoTaskServiceResult>();
_pending[request.sessionId] = completer;
return completer.future;
}
Future<void> waitForRequestCount(int count) async {
final deadline = DateTime.now().add(const Duration(seconds: 15));
while (requests.length < count && DateTime.now().isBefore(deadline)) {
await Future<void>.delayed(const Duration(milliseconds: 10));
}
if (requests.length < count) {
throw StateError('Timed out waiting for $count requests.');
}
}
void complete(String sessionId, GoTaskServiceResult result) {
final completer = _pending.remove(sessionId);
_updates.remove(sessionId);
if (completer == null) {
throw StateError('No pending task for $sessionId.');
}
completer.complete(result);
}
void completeAll([
GoTaskServiceResult result = const GoTaskServiceResult(
success: true,
message: 'cleanup',
turnId: 'turn-cleanup',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
),
]) {
final pendingSessionIds = List<String>.from(_pending.keys);
for (final sessionId in pendingSessionIds) {
complete(sessionId, result);
}
}
void emitDelta(String sessionId, String text) {
final onUpdate = _updates[sessionId];
if (onUpdate == null) {
throw StateError('No pending update sink for $sessionId.');
}
onUpdate(
GoTaskServiceUpdate(
sessionId: sessionId,
threadId: sessionId,
turnId: 'turn-$sessionId',
type: 'delta',
text: text,
message: '',
pending: true,
error: false,
route: GoTaskServiceRoute.externalAcpSingle,
payload: const <String, dynamic>{},
),
);
}
@override
Future<void> cancelTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
}) async {
cancelledSessionIds.add(sessionId);
}
@override
Future<void> closeTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
}) async {}
@override
Future<void> dispose() async {}
}