xworkmate-app/test/runtime/app_controller_assistant_flow_suite.dart
2026-03-25 18:27:13 +08:00

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

@TestOn('vm')
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
void main() {
test(
'AppController completes the minimal assistant flow against a gateway',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final gateway = await _FakeGatewayServer.start();
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-assistant-flow-',
);
addTearDown(() async {
await _deleteDirectoryWithRetry(tempDirectory);
});
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
final controller = AppController(store: store);
addTearDown(() async {
controller.dispose();
});
addTearDown(gateway.close);
await _waitFor(() => !controller.initializing);
await controller.connectManual(
host: '127.0.0.1',
port: gateway.port,
tls: false,
mode: RuntimeConnectionMode.local,
token: _FakeGatewayServer.sharedToken,
);
expect(controller.connection.status, RuntimeConnectionStatus.connected);
expect(gateway.connectAuthToken, _FakeGatewayServer.sharedToken);
await controller.selectAgent('main');
await controller.sendChatMessage('请只回复一行XWORKMATE_OK', thinking: 'low');
await _waitFor(
() => controller.chatMessages.any(
(message) =>
message.role == 'assistant' &&
message.text.contains('XWORKMATE_OK'),
),
);
await _waitFor(() => controller.tasksController.history.isNotEmpty);
expect(
controller.chatMessages.any(
(message) =>
message.role == 'assistant' &&
message.text.contains('XWORKMATE_OK'),
),
isTrue,
);
expect(
controller.tasksController.history.any(
(task) => task.summary.contains('XWORKMATE_OK'),
),
isTrue,
);
expect(gateway.lastChatSendParams?['agentId'], 'main');
expect(
((gateway.lastChatSendParams?['metadata'] as Map?)?['node']
as Map?)?['kind'],
'app-mediated-cooperative-node',
);
expect(
((gateway.lastChatSendParams?['metadata'] as Map?)?['dispatch']
as Map?)?['mode'],
'gateway-only',
);
},
);
test(
'AppController connects directly from a setup code and persists gateway auth',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final gateway = await _FakeGatewayServer.start();
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-setup-code-flow-',
);
addTearDown(() async {
await _deleteDirectoryWithRetry(tempDirectory);
});
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
final controller = AppController(store: store);
addTearDown(() async {
controller.dispose();
});
addTearDown(gateway.close);
await _waitFor(() => !controller.initializing);
final setupCode = jsonEncode(<String, Object?>{
'url': 'ws://127.0.0.1:${gateway.port}',
'token': _FakeGatewayServer.sharedToken,
});
await controller.connectWithSetupCode(setupCode: setupCode);
expect(controller.connection.status, RuntimeConnectionStatus.connected);
expect(controller.connection.mode, RuntimeConnectionMode.local);
expect(gateway.connectAuthToken, _FakeGatewayServer.sharedToken);
expect(controller.settings.primaryLocalGatewayProfile.host, '127.0.0.1');
expect(controller.settings.primaryLocalGatewayProfile.port, gateway.port);
expect(
await controller.settingsController.loadGatewayToken(
profileIndex: kGatewayLocalProfileIndex,
),
_FakeGatewayServer.sharedToken,
);
},
);
test(
'AppController hides gateway transcript after switching the thread to single-agent',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final gateway = await _FakeGatewayServer.start();
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-single-agent-history-hide-',
);
addTearDown(() async {
await _deleteDirectoryWithRetry(tempDirectory);
});
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '${tempDirectory.path}/settings.db',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
);
final controller = AppController(store: store);
addTearDown(controller.dispose);
addTearDown(gateway.close);
await _waitFor(() => !controller.initializing);
await controller.connectManual(
host: '127.0.0.1',
port: gateway.port,
tls: false,
mode: RuntimeConnectionMode.local,
token: _FakeGatewayServer.sharedToken,
);
await controller.selectAgent('main');
await controller.sendChatMessage('请只回复一行XWORKMATE_OK', thinking: 'low');
await _waitFor(
() => controller.chatMessages.any(
(message) =>
message.role == 'assistant' &&
message.text.contains('XWORKMATE_OK'),
),
);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
expect(
controller.assistantExecutionTarget,
AssistantExecutionTarget.singleAgent,
);
expect(
controller.chatMessages.any(
(message) => message.text.contains('XWORKMATE_OK'),
),
isFalse,
);
},
);
}
class _FakeGatewayServer {
_FakeGatewayServer._(this._server);
static const sharedToken = 'shared-token-from-test';
final HttpServer _server;
WebSocket? _socket;
String? connectAuthToken;
Map<String, dynamic>? lastChatSendParams;
final List<Map<String, dynamic>> _history = <Map<String, dynamic>>[];
String _lastMessagePreview = '';
double _updatedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble();
int get port => _server.port;
static Future<_FakeGatewayServer> start() async {
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
final fake = _FakeGatewayServer._(server);
unawaited(fake._serve());
return fake;
}
Future<void> close() async {
await _socket?.close();
await _server.close(force: true);
}
Future<void> _serve() async {
await for (final request in _server) {
if (request.uri.path == '/acp/rpc' && request.method == 'POST') {
await _serveAcpRpc(request);
continue;
}
if (request.uri.path == '/acp' &&
WebSocketTransformer.isUpgradeRequest(request)) {
final acpSocket = await WebSocketTransformer.upgrade(request);
await acpSocket.close(
WebSocketStatus.normalClosure,
'test gateway runtime only',
);
continue;
}
if (!WebSocketTransformer.isUpgradeRequest(request)) {
request.response.statusCode = HttpStatus.notFound;
await request.response.close();
continue;
}
final socket = await WebSocketTransformer.upgrade(request);
_socket = socket;
_send(socket, <String, dynamic>{
'type': 'event',
'event': 'connect.challenge',
'payload': <String, dynamic>{'nonce': 'nonce-1'},
});
await for (final raw in socket) {
final frame = jsonDecode(raw as String) as Map<String, dynamic>;
if (frame['type'] != 'req') {
continue;
}
final method = frame['method'] as String? ?? '';
final id = frame['id'] as String? ?? 'unknown';
final params =
(frame['params'] as Map?)?.cast<String, dynamic>() ??
const <String, dynamic>{};
switch (method) {
case 'connect':
connectAuthToken = ((params['auth'] as Map?)?['token'] as String?)
?.trim();
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'server': <String, dynamic>{'host': '127.0.0.1'},
'snapshot': <String, dynamic>{
'sessionDefaults': <String, dynamic>{
'mainSessionKey': 'agent:main:main',
},
},
},
});
break;
case 'health':
case 'status':
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{'ok': true},
});
break;
case 'agents.list':
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'agents': <Map<String, dynamic>>[
<String, dynamic>{'id': 'main', 'name': 'Main'},
],
'mainKey': 'main',
},
});
break;
case 'sessions.list':
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'sessions': <Map<String, dynamic>>[
<String, dynamic>{
'key': 'agent:main:main',
'displayName': 'main',
'surface': 'assistant',
'updatedAt': _updatedAtMs,
'derivedTitle': 'main',
'lastMessagePreview': _lastMessagePreview,
'sessionId': 'sess-main',
},
],
},
});
break;
case 'chat.history':
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{'messages': _history},
});
break;
case 'skills.status':
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{'skills': const <Object>[]},
});
break;
case 'channels.status':
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'channelMeta': const <Object>[],
'channelLabels': const <String, dynamic>{},
'channelDetailLabels': const <String, dynamic>{},
'channelAccounts': const <String, dynamic>{},
'channelOrder': const <Object>[],
},
});
break;
case 'models.list':
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{
'models': <Map<String, dynamic>>[
<String, dynamic>{
'id': 'gpt-5.4',
'name': 'gpt-5.4',
'provider': 'test',
},
],
},
});
break;
case 'cron.list':
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{'jobs': const <Object>[]},
});
break;
case 'system-presence':
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': const <Object>[],
});
break;
case 'chat.send':
lastChatSendParams = params;
final sessionKey =
params['sessionKey'] as String? ?? 'agent:main:main';
final runId = params['idempotencyKey'] as String? ?? 'run-1';
final userText = params['message'] as String? ?? '';
_appendMessage(role: 'user', text: userText);
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': <String, dynamic>{'runId': runId, 'status': 'started'},
});
unawaited(
_emitAssistantResult(
socket,
runId: runId,
sessionKey: sessionKey,
),
);
break;
default:
_send(socket, <String, dynamic>{
'type': 'res',
'id': id,
'ok': true,
'payload': const <String, dynamic>{},
});
break;
}
}
}
}
Future<void> _serveAcpRpc(HttpRequest request) async {
final body = await utf8.decodeStream(request);
final envelope = (jsonDecode(body) as Map).cast<String, dynamic>();
final id = envelope['id'];
final method = envelope['method']?.toString() ?? '';
final response = <String, dynamic>{
'jsonrpc': '2.0',
'id': id,
'result': method == 'acp.capabilities'
? <String, dynamic>{
'singleAgent': true,
'multiAgent': true,
'providers': <String>['claude', 'codex', 'gemini', 'opencode'],
'capabilities': <String, dynamic>{
'single_agent': true,
'multi_agent': true,
'providers': <String>['claude', 'codex', 'gemini', 'opencode'],
},
}
: const <String, dynamic>{},
};
request.response.headers.set(
HttpHeaders.contentTypeHeader,
'text/event-stream; charset=utf-8',
);
request.response.write('data: ${jsonEncode(response)}\n\n');
await request.response.close();
}
Future<void> _emitAssistantResult(
WebSocket socket, {
required String runId,
required String sessionKey,
}) async {
await Future<void>.delayed(const Duration(milliseconds: 20));
const reply = 'XWORKMATE_OK';
_appendMessage(role: 'assistant', text: reply);
_send(socket, <String, dynamic>{
'type': 'event',
'event': 'chat',
'payload': <String, dynamic>{
'runId': runId,
'sessionKey': sessionKey,
'state': 'delta',
'message': <String, dynamic>{
'role': 'assistant',
'content': <Map<String, dynamic>>[
<String, dynamic>{'type': 'text', 'text': reply},
],
'timestamp': _updatedAtMs.toInt(),
},
},
});
_send(socket, <String, dynamic>{
'type': 'event',
'event': 'chat',
'payload': <String, dynamic>{
'runId': runId,
'sessionKey': sessionKey,
'state': 'final',
'message': <String, dynamic>{
'role': 'assistant',
'content': <Map<String, dynamic>>[
<String, dynamic>{'type': 'text', 'text': reply},
],
'timestamp': _updatedAtMs.toInt(),
},
},
});
}
void _appendMessage({required String role, required String text}) {
_updatedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble();
_lastMessagePreview = text;
_history.add(<String, dynamic>{
'role': role,
'content': <Map<String, dynamic>>[
<String, dynamic>{'type': 'text', 'text': text},
],
'timestamp': _updatedAtMs.toInt(),
});
}
void _send(WebSocket socket, Map<String, dynamic> frame) {
socket.add(jsonEncode(frame));
}
}
Future<void> _deleteDirectoryWithRetry(Directory directory) async {
if (!await directory.exists()) {
return;
}
for (var attempt = 0; attempt < 3; attempt += 1) {
try {
await directory.delete(recursive: true);
return;
} on FileSystemException {
if (attempt == 2) {
rethrow;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
}
Future<void> _waitFor(
bool Function() predicate, {
Duration timeout = const Duration(seconds: 5),
}) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
if (predicate()) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 20));
}
throw TimeoutException('Condition not met before timeout.');
}