478 lines
17 KiB
Dart
478 lines
17 KiB
Dart
import 'dart:io';
|
||
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:xworkmate/app/app_controller.dart';
|
||
import 'package:xworkmate/runtime/gateway_acp_client.dart';
|
||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||
|
||
void main() {
|
||
group('Assistant connection state', () {
|
||
test('does not report bridge runtime configured by default', () async {
|
||
final controller = await _isolatedController(
|
||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||
SingleAgentProvider.codex,
|
||
],
|
||
initialGatewayProviderCatalog: const <SingleAgentProvider>[
|
||
SingleAgentProvider.openclaw,
|
||
],
|
||
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
|
||
AssistantExecutionTarget.agent,
|
||
AssistantExecutionTarget.gateway,
|
||
],
|
||
);
|
||
addTearDown(controller.dispose);
|
||
|
||
expect(controller.isBridgeAcpRuntimeConfiguredInternal(), isFalse);
|
||
});
|
||
|
||
test(
|
||
'keeps signed-out sessions disconnected even when provider catalogs exist',
|
||
() async {
|
||
final controller = await _isolatedController(
|
||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||
SingleAgentProvider.codex,
|
||
],
|
||
initialGatewayProviderCatalog: const <SingleAgentProvider>[
|
||
SingleAgentProvider.openclaw,
|
||
],
|
||
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
|
||
AssistantExecutionTarget.agent,
|
||
AssistantExecutionTarget.gateway,
|
||
],
|
||
);
|
||
addTearDown(controller.dispose);
|
||
|
||
await controller.sessionsController.switchSession(
|
||
'unit-fixture-task-a',
|
||
);
|
||
await controller.setAssistantExecutionTarget(
|
||
AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
final state = controller.currentAssistantConnectionState;
|
||
expect(state.connected, isFalse);
|
||
expect(state.status, RuntimeConnectionStatus.offline);
|
||
expect(state.detailLabel, '请先登录 svc.plus');
|
||
},
|
||
);
|
||
|
||
test(
|
||
'uses the same gateway capability readiness for status and send guard',
|
||
() async {
|
||
final controller = await _isolatedController(
|
||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||
SingleAgentProvider.codex,
|
||
],
|
||
initialGatewayProviderCatalog: const <SingleAgentProvider>[
|
||
SingleAgentProvider.openclaw,
|
||
],
|
||
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
|
||
AssistantExecutionTarget.agent,
|
||
AssistantExecutionTarget.gateway,
|
||
],
|
||
environmentOverride: const <String, String>{
|
||
'AI_WORKSPACE_AUTH_TOKEN': 'bridge-token',
|
||
},
|
||
);
|
||
addTearDown(controller.dispose);
|
||
|
||
await controller.sessionsController.switchSession(
|
||
'unit-fixture-task-a',
|
||
);
|
||
await controller.setAssistantExecutionTarget(
|
||
AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
final state = controller.currentAssistantConnectionState;
|
||
expect(state.connected, isTrue);
|
||
expect(
|
||
controller.bridgeCapabilityRefreshNeededForAssistantTargetInternal(
|
||
AssistantExecutionTarget.gateway,
|
||
),
|
||
isFalse,
|
||
);
|
||
},
|
||
);
|
||
|
||
test(
|
||
'refreshes gateway capabilities when previous discovery missed openclaw',
|
||
() async {
|
||
final controller = await _isolatedController(
|
||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||
SingleAgentProvider.codex,
|
||
],
|
||
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
|
||
AssistantExecutionTarget.agent,
|
||
AssistantExecutionTarget.gateway,
|
||
],
|
||
environmentOverride: const <String, String>{
|
||
'BRIDGE_AUTH_TOKEN': 'bridge-token',
|
||
},
|
||
);
|
||
addTearDown(controller.dispose);
|
||
|
||
await controller.sessionsController.switchSession(
|
||
'unit-fixture-task-a',
|
||
);
|
||
await controller.setAssistantExecutionTarget(
|
||
AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
expect(controller.currentAssistantConnectionState.connected, isFalse);
|
||
expect(
|
||
controller.bridgeCapabilityRefreshNeededForAssistantTargetInternal(
|
||
AssistantExecutionTarget.gateway,
|
||
),
|
||
isTrue,
|
||
);
|
||
},
|
||
);
|
||
|
||
test(
|
||
'labels gateway offline errors as OpenClaw runtime failures',
|
||
() async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
final label = controller.gatewayExecutionErrorLabelInternal(
|
||
'gateway not connected',
|
||
target: AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
expect(label, contains('OpenClaw Gateway 当前未连接'));
|
||
expect(label, isNot(contains('xworkmate-bridge 未连接')));
|
||
},
|
||
);
|
||
|
||
test(
|
||
'labels OpenClaw socket close without exposing raw JSON-RPC error',
|
||
() async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
final label = controller.gatewayExecutionErrorLabelInternal(
|
||
const GatewayAcpException(
|
||
'OPENCLAW_GATEWAY_SOCKET_CLOSED: OpenClaw gateway connection closed during task execution',
|
||
code: '-32002',
|
||
detailCode: 'OPENCLAW_GATEWAY_SOCKET_CLOSED',
|
||
),
|
||
target: AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
expect(label, contains('OpenClaw Gateway 连接在任务执行中断开'));
|
||
expect(label, isNot(contains('-32002')));
|
||
expect(label, isNot(contains('socket closed')));
|
||
},
|
||
);
|
||
|
||
test('labels interrupted ACP HTTP reads as incomplete results', () async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
final label = controller.gatewayExecutionErrorLabelInternal(
|
||
const GatewayAcpException(
|
||
'ACP HTTP connection closed before the response finished arriving',
|
||
code: 'ACP_HTTP_CONNECTION_CLOSED',
|
||
),
|
||
target: AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
expect(
|
||
label,
|
||
'Bridge 响应读取中断,本轮结果未完成。请重新发送请求。错误码:ACP_HTTP_CONNECTION_CLOSED',
|
||
);
|
||
expect(label, isNot(contains('下一次发送会继续同一会话')));
|
||
expect(label, isNot(contains('closed before the response')));
|
||
});
|
||
|
||
test(
|
||
'labels interrupted ACP HTTP handshakes as incomplete requests',
|
||
() async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
final label = controller.gatewayExecutionErrorLabelInternal(
|
||
const GatewayAcpException(
|
||
'ACP HTTP handshake was interrupted before the response started',
|
||
code: gatewayAcpHttpHandshakeInterruptedCode,
|
||
),
|
||
target: AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
expect(
|
||
label,
|
||
'Bridge 握手中断,本轮请求未完成。请重新发送请求。错误码:ACP_HTTP_HANDSHAKE_INTERRUPTED',
|
||
);
|
||
expect(label, isNot(contains('下一次发送会继续同一会话')));
|
||
expect(
|
||
label,
|
||
isNot(contains('Connection terminated during handshake')),
|
||
);
|
||
expect(label, isNot(contains('handshake was interrupted')));
|
||
},
|
||
);
|
||
|
||
test(
|
||
'labels ACP HTTP connect timeouts as unconfirmed retryable requests',
|
||
() async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
final label = controller.gatewayExecutionErrorLabelInternal(
|
||
const GatewayAcpException(
|
||
'SocketException: HTTP connection timed out after 0:00:08.000000, host: xworkmate-bridge.svc.plus, port: 443',
|
||
code: gatewayAcpHttpConnectTimeoutCode,
|
||
),
|
||
target: AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
expect(label, 'Bridge 连接超时,本轮请求未确认,可重试。错误码:ACP_HTTP_CONNECT_TIMEOUT');
|
||
expect(label, isNot(contains('SocketException')));
|
||
expect(label, isNot(contains('0:00:08')));
|
||
},
|
||
);
|
||
|
||
test(
|
||
'labels ACP HTTP connect failures as unconfirmed retryable requests',
|
||
() async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
final label = controller.gatewayExecutionErrorLabelInternal(
|
||
const GatewayAcpException(
|
||
'Connection refused',
|
||
code: gatewayAcpHttpConnectFailedCode,
|
||
),
|
||
target: AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
expect(label, 'Bridge 连接失败,本轮请求未确认,可重试。错误码:ACP_HTTP_CONNECT_FAILED');
|
||
expect(label, isNot(contains('Connection refused')));
|
||
},
|
||
);
|
||
|
||
test(
|
||
'labels unavailable session continuation without starting a new flow',
|
||
() async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
final label = controller.gatewayExecutionErrorLabelInternal(
|
||
const GatewayAcpException(
|
||
'SESSION_CONTINUATION_UNAVAILABLE: provider session state is unavailable',
|
||
code: '-32002',
|
||
detailCode: 'SESSION_CONTINUATION_UNAVAILABLE',
|
||
),
|
||
target: AssistantExecutionTarget.agent,
|
||
);
|
||
|
||
expect(label, contains('会话状态不可续写'));
|
||
expect(label, contains('SESSION_CONTINUATION_UNAVAILABLE'));
|
||
expect(label, isNot(contains('-32002')));
|
||
},
|
||
);
|
||
|
||
test('keeps signed-out generic runtime failures disconnected', () async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
await controller.sessionsController.switchSession('unit-fixture-task-a');
|
||
await controller.setAssistantExecutionTarget(
|
||
AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
controller.runtimeInternal.snapshotInternal = controller
|
||
.runtimeInternal
|
||
.snapshot
|
||
.copyWith(
|
||
status: RuntimeConnectionStatus.error,
|
||
statusText: 'Connection failed',
|
||
remoteAddress: 'openclaw.svc.plus:443',
|
||
lastError: 'unsupported Ed25519 private key length: 0',
|
||
lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED',
|
||
lastErrorDetailCode: null,
|
||
);
|
||
|
||
final state = controller.currentAssistantConnectionState;
|
||
expect(state.status, RuntimeConnectionStatus.offline);
|
||
expect(state.primaryLabel, '已退出登录');
|
||
expect(state.detailLabel, '请先登录 svc.plus');
|
||
});
|
||
|
||
test('keeps true offline state as bridge not connected', () async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
await controller.sessionsController.switchSession('unit-fixture-task-a');
|
||
await controller.setAssistantExecutionTarget(
|
||
AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
controller.runtimeInternal.snapshotInternal =
|
||
GatewayConnectionSnapshot.initial(
|
||
mode: controller.runtimeInternal.snapshot.mode,
|
||
);
|
||
|
||
final state = controller.currentAssistantConnectionState;
|
||
expect(state.status, RuntimeConnectionStatus.offline);
|
||
expect(state.primaryLabel, '已退出登录');
|
||
expect(state.detailLabel, '请先登录 svc.plus');
|
||
});
|
||
|
||
test(
|
||
'keeps signed-out generic failures without address disconnected',
|
||
() async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
await controller.sessionsController.switchSession(
|
||
'unit-fixture-task-a',
|
||
);
|
||
await controller.setAssistantExecutionTarget(
|
||
AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
controller.runtimeInternal.snapshotInternal = controller
|
||
.runtimeInternal
|
||
.snapshot
|
||
.copyWith(
|
||
status: RuntimeConnectionStatus.error,
|
||
statusText: 'Connection failed',
|
||
lastError: 'socket closed',
|
||
lastErrorCode: 'SOCKET_CLOSED',
|
||
lastErrorDetailCode: null,
|
||
clearRemoteAddress: true,
|
||
);
|
||
|
||
final state = controller.currentAssistantConnectionState;
|
||
expect(state.status, RuntimeConnectionStatus.offline);
|
||
expect(state.primaryLabel, '已退出登录');
|
||
expect(state.detailLabel, '请先登录 svc.plus');
|
||
},
|
||
);
|
||
|
||
test(
|
||
'keeps gateway token missing as dedicated app-visible state',
|
||
() async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
await controller.sessionsController.switchSession(
|
||
'unit-fixture-task-a',
|
||
);
|
||
await controller.setAssistantExecutionTarget(
|
||
AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
controller.runtimeInternal.snapshotInternal = controller
|
||
.runtimeInternal
|
||
.snapshot
|
||
.copyWith(
|
||
status: RuntimeConnectionStatus.error,
|
||
statusText: 'Connection failed',
|
||
lastError: 'gateway token missing',
|
||
lastErrorCode: 'AUTH_FAILED',
|
||
lastErrorDetailCode: 'AUTH_TOKEN_MISSING',
|
||
clearRemoteAddress: true,
|
||
);
|
||
|
||
final state = controller.currentAssistantConnectionState;
|
||
expect(state.status, RuntimeConnectionStatus.offline);
|
||
expect(state.primaryLabel, '已退出登录');
|
||
expect(state.detailLabel, '请先登录 svc.plus');
|
||
},
|
||
);
|
||
|
||
test(
|
||
'treats missing endpoint as true offline instead of bridge failure',
|
||
() async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
await controller.sessionsController.switchSession(
|
||
'unit-fixture-task-a',
|
||
);
|
||
await controller.setAssistantExecutionTarget(
|
||
AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
controller.runtimeInternal.snapshotInternal = controller
|
||
.runtimeInternal
|
||
.snapshot
|
||
.copyWith(
|
||
status: RuntimeConnectionStatus.error,
|
||
statusText: 'Missing gateway endpoint',
|
||
lastError: 'Configure setup code or manual host / port first.',
|
||
lastErrorCode: 'MISSING_ENDPOINT',
|
||
clearRemoteAddress: true,
|
||
);
|
||
|
||
final state = controller.currentAssistantConnectionState;
|
||
expect(state.status, RuntimeConnectionStatus.offline);
|
||
expect(state.primaryLabel, '已退出登录');
|
||
expect(state.detailLabel, '请先登录 svc.plus');
|
||
},
|
||
);
|
||
|
||
test('desktop snapshot uses derived assistant connection labels', () async {
|
||
final controller = await _isolatedController();
|
||
addTearDown(controller.dispose);
|
||
|
||
await controller.sessionsController.switchSession('unit-fixture-task-a');
|
||
await controller.setAssistantExecutionTarget(
|
||
AssistantExecutionTarget.gateway,
|
||
);
|
||
|
||
controller.runtimeInternal.snapshotInternal = controller
|
||
.runtimeInternal
|
||
.snapshot
|
||
.copyWith(
|
||
status: RuntimeConnectionStatus.error,
|
||
statusText: 'Connection failed',
|
||
remoteAddress: 'openclaw.svc.plus:443',
|
||
lastError: 'unsupported Ed25519 private key length: 0',
|
||
lastErrorCode: 'DEVICE_IDENTITY_SIGN_FAILED',
|
||
);
|
||
|
||
final snapshot = controller.desktopStatusSnapshot();
|
||
expect(snapshot['connectionStatus'], 'disconnected');
|
||
expect(snapshot['connectionLabel'], '已退出登录');
|
||
});
|
||
});
|
||
}
|
||
|
||
Future<AppController> _isolatedController({
|
||
List<SingleAgentProvider>? initialBridgeProviderCatalog,
|
||
List<SingleAgentProvider>? initialGatewayProviderCatalog,
|
||
List<AssistantExecutionTarget>? initialAvailableExecutionTargets,
|
||
Map<String, String> environmentOverride = const <String, String>{},
|
||
}) async {
|
||
final storeRoot = await Directory.systemTemp.createTemp(
|
||
'xworkmate-assistant-connection-state-',
|
||
);
|
||
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();
|
||
return AppController(
|
||
environmentOverride: environmentOverride,
|
||
store: store,
|
||
initialBridgeProviderCatalog: initialBridgeProviderCatalog,
|
||
initialGatewayProviderCatalog: initialGatewayProviderCatalog,
|
||
initialAvailableExecutionTargets: initialAvailableExecutionTargets,
|
||
);
|
||
}
|