936 lines
31 KiB
Dart
936 lines
31 KiB
Dart
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 continues the same session after ACP HTTP connection close',
|
||
() async {
|
||
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(
|
||
const GoTaskServiceResult(
|
||
success: true,
|
||
message: 'continued response',
|
||
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);
|
||
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')),
|
||
);
|
||
|
||
await controller.sendChatMessage('follow up');
|
||
|
||
expect(fakeGoTaskService.requests, hasLength(2));
|
||
expect(fakeGoTaskService.requests.last.resumeSession, isTrue);
|
||
expect(controller.chatMessages.last.text, 'continued response');
|
||
},
|
||
);
|
||
|
||
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]),
|
||
);
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
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<_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);
|
||
}
|
||
|
||
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,
|
||
],
|
||
);
|
||
}
|
||
|
||
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 {}
|
||
}
|