xworkmate-app/test/runtime/assistant_connection_state_test.dart

478 lines
17 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: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,
);
}