545 lines
18 KiB
Dart
545 lines
18 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:xworkmate/app/app_controller_desktop_core.dart';
|
|
import 'package:xworkmate/app/app_controller_desktop_external_acp_routing.dart';
|
|
import 'package:xworkmate/app/app_controller_desktop_runtime_helpers.dart';
|
|
import 'package:xworkmate/app/app_controller_desktop_skill_permissions.dart';
|
|
import 'package:xworkmate/app/app_controller_desktop_thread_binding.dart';
|
|
import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart';
|
|
import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart';
|
|
import 'package:xworkmate/runtime/account_runtime_client.dart';
|
|
import 'package:xworkmate/runtime/codex_config_bridge.dart';
|
|
import 'package:xworkmate/runtime/codex_runtime.dart';
|
|
import 'package:xworkmate/runtime/device_identity_store.dart';
|
|
import 'package:xworkmate/runtime/gateway_runtime.dart';
|
|
import 'package:xworkmate/runtime/go_task_service_client.dart';
|
|
import 'package:xworkmate/runtime/runtime_coordinator.dart';
|
|
import 'package:xworkmate/runtime/runtime_controllers.dart';
|
|
import 'package:xworkmate/runtime/runtime_models.dart';
|
|
import 'package:xworkmate/runtime/secure_config_store.dart';
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
group('resolveDesktopThreadBindingSnapshotInternal', () {
|
|
const localOwner = ThreadOwnerScope(
|
|
realm: ThreadRealm.local,
|
|
subjectType: ThreadSubjectType.user,
|
|
subjectId: 'u1',
|
|
displayName: 'User',
|
|
);
|
|
|
|
TaskThread buildThread({
|
|
required String threadId,
|
|
required ThreadExecutionMode mode,
|
|
required String providerId,
|
|
}) {
|
|
return TaskThread(
|
|
threadId: threadId,
|
|
ownerScope: localOwner,
|
|
workspaceBinding: const WorkspaceBinding(
|
|
workspaceId: 'ws-1',
|
|
workspaceKind: WorkspaceKind.localFs,
|
|
workspacePath: '/tmp/ws',
|
|
displayPath: '/tmp/ws',
|
|
writable: true,
|
|
),
|
|
executionBinding: ExecutionBinding(
|
|
executionMode: mode,
|
|
executorId: providerId,
|
|
providerId: providerId,
|
|
endpointId: '',
|
|
),
|
|
);
|
|
}
|
|
|
|
test('prefers the latest record after async binding resumes', () {
|
|
final latestRecord = buildThread(
|
|
threadId: 'thread-1',
|
|
mode: ThreadExecutionMode.localAgent,
|
|
providerId: SingleAgentProvider.opencode.providerId,
|
|
);
|
|
|
|
final snapshot = resolveDesktopThreadBindingSnapshotInternal(
|
|
defaultExecutionTarget: AssistantExecutionTarget.gateway,
|
|
latestRecord: latestRecord,
|
|
);
|
|
|
|
expect(snapshot.executionTarget, AssistantExecutionTarget.singleAgent);
|
|
expect(snapshot.singleAgentProvider, SingleAgentProvider.opencode);
|
|
expect(snapshot.record, same(latestRecord));
|
|
});
|
|
|
|
test(
|
|
'keeps an explicit execution override while preserving latest provider',
|
|
() {
|
|
final latestRecord = buildThread(
|
|
threadId: 'thread-2',
|
|
mode: ThreadExecutionMode.localAgent,
|
|
providerId: SingleAgentProvider.opencode.providerId,
|
|
);
|
|
|
|
final snapshot = resolveDesktopThreadBindingSnapshotInternal(
|
|
defaultExecutionTarget: AssistantExecutionTarget.gateway,
|
|
executionTargetOverride: AssistantExecutionTarget.gateway,
|
|
latestRecord: latestRecord,
|
|
);
|
|
|
|
expect(snapshot.executionTarget, AssistantExecutionTarget.gateway);
|
|
expect(snapshot.singleAgentProvider, SingleAgentProvider.opencode);
|
|
},
|
|
);
|
|
|
|
test('does not recover provider from stale fallback-only records', () {
|
|
final staleRecord = buildThread(
|
|
threadId: 'thread-3',
|
|
mode: ThreadExecutionMode.gateway,
|
|
providerId: SingleAgentProvider.codex.providerId,
|
|
);
|
|
|
|
final snapshot = resolveDesktopThreadBindingSnapshotInternal(
|
|
defaultExecutionTarget: AssistantExecutionTarget.gateway,
|
|
latestRecord: null,
|
|
);
|
|
|
|
expect(snapshot.executionTarget, AssistantExecutionTarget.gateway);
|
|
expect(snapshot.singleAgentProvider.isUnspecified, isTrue);
|
|
expect(snapshot.record, isNull);
|
|
expect(staleRecord.executionBinding.providerId, isNotEmpty);
|
|
});
|
|
});
|
|
|
|
group('resolveGatewayExecutionTargetFromVisibleTargets', () {
|
|
test('prefers remote bridge target over silent local fallback', () {
|
|
final target = resolveGatewayExecutionTargetFromVisibleTargets(
|
|
const <AssistantExecutionTarget>[
|
|
AssistantExecutionTarget.singleAgent,
|
|
AssistantExecutionTarget.gateway,
|
|
],
|
|
currentTarget: AssistantExecutionTarget.singleAgent,
|
|
);
|
|
|
|
expect(target, AssistantExecutionTarget.gateway);
|
|
});
|
|
|
|
test(
|
|
'falls back to remote when legacy local gateway selection is active',
|
|
() {
|
|
final target = resolveGatewayExecutionTargetFromVisibleTargets(
|
|
const <AssistantExecutionTarget>[AssistantExecutionTarget.gateway],
|
|
currentTarget: AssistantExecutionTarget.gateway,
|
|
);
|
|
|
|
expect(target, AssistantExecutionTarget.gateway);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('resolveGatewayThreadConnectionStateInternal', () {
|
|
test('uses the thread target profile as the only address source', () {
|
|
final targetProfile = GatewayConnectionProfile.defaults().copyWith(
|
|
mode: RuntimeConnectionMode.remote,
|
|
host: 'bridge.example.internal',
|
|
port: 443,
|
|
tls: true,
|
|
);
|
|
final state = resolveGatewayThreadConnectionStateInternal(
|
|
target: AssistantExecutionTarget.gateway,
|
|
connection:
|
|
GatewayConnectionSnapshot.initial(
|
|
mode: RuntimeConnectionMode.remote,
|
|
).copyWith(
|
|
status: RuntimeConnectionStatus.connected,
|
|
remoteAddress: 'legacy-loopback:18789',
|
|
),
|
|
targetProfile: targetProfile,
|
|
);
|
|
|
|
expect(state.status, RuntimeConnectionStatus.connected);
|
|
expect(state.detailLabel, 'bridge.example.internal:443');
|
|
expect(state.ready, isTrue);
|
|
});
|
|
|
|
test('marks mismatched local snapshot as offline for remote threads', () {
|
|
final targetProfile = GatewayConnectionProfile.defaults().copyWith(
|
|
mode: RuntimeConnectionMode.remote,
|
|
host: 'bridge.example.internal',
|
|
port: 443,
|
|
tls: true,
|
|
);
|
|
final state = resolveGatewayThreadConnectionStateInternal(
|
|
target: AssistantExecutionTarget.gateway,
|
|
connection:
|
|
GatewayConnectionSnapshot.initial(
|
|
mode: RuntimeConnectionMode.local,
|
|
).copyWith(
|
|
status: RuntimeConnectionStatus.connected,
|
|
remoteAddress: 'legacy-loopback:18789',
|
|
),
|
|
targetProfile: targetProfile,
|
|
);
|
|
|
|
expect(state.status, RuntimeConnectionStatus.offline);
|
|
expect(state.detailLabel, 'bridge.example.internal:443');
|
|
expect(state.ready, isFalse);
|
|
expect(state.lastError, isNull);
|
|
});
|
|
});
|
|
|
|
group('assistantConnectionStateForSession', () {
|
|
test(
|
|
'uses target profile address instead of connection snapshot address',
|
|
() {
|
|
final gateway = _FakeGatewayRuntime(
|
|
GatewayConnectionSnapshot.initial(
|
|
mode: RuntimeConnectionMode.remote,
|
|
).copyWith(
|
|
status: RuntimeConnectionStatus.connected,
|
|
remoteAddress: 'legacy-loopback:18789',
|
|
),
|
|
);
|
|
final controller = AppController(
|
|
runtimeCoordinator: RuntimeCoordinator(
|
|
gateway: gateway,
|
|
codex: CodexRuntime(),
|
|
configBridge: CodexConfigBridge(),
|
|
),
|
|
);
|
|
addTearDown(() async {
|
|
controller.dispose();
|
|
await gateway.disposeTestResources();
|
|
});
|
|
const sessionKey = 'draft:remote-status';
|
|
controller.initializeAssistantThreadContext(
|
|
sessionKey,
|
|
executionTarget: AssistantExecutionTarget.gateway,
|
|
);
|
|
controller.upsertTaskThreadInternal(
|
|
sessionKey,
|
|
executionTarget: AssistantExecutionTarget.gateway,
|
|
executionTargetSource: ThreadSelectionSource.explicit,
|
|
);
|
|
|
|
final state = controller.assistantConnectionStateForSession(sessionKey);
|
|
|
|
expect(state.status, RuntimeConnectionStatus.connected);
|
|
expect(state.detailLabel, '未连接目标');
|
|
expect(state.ready, isTrue);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('buildExternalAcpRoutingForSessionInternal', () {
|
|
test('never emits explicit provider id for gateway threads', () {
|
|
final controller = AppController();
|
|
addTearDown(controller.dispose);
|
|
|
|
const sessionKey = 'draft:routing';
|
|
controller.initializeAssistantThreadContext(
|
|
sessionKey,
|
|
executionTarget: AssistantExecutionTarget.gateway,
|
|
singleAgentProvider: SingleAgentProvider.opencode,
|
|
);
|
|
controller.upsertTaskThreadInternal(
|
|
sessionKey,
|
|
executionTarget: AssistantExecutionTarget.gateway,
|
|
executionTargetSource: ThreadSelectionSource.explicit,
|
|
singleAgentProvider: SingleAgentProvider.opencode,
|
|
singleAgentProviderSource: ThreadSelectionSource.explicit,
|
|
);
|
|
|
|
final routing = controller.buildExternalAcpRoutingForSessionInternal(
|
|
sessionKey,
|
|
);
|
|
|
|
expect(routing.mode, ExternalCodeAgentAcpRoutingMode.explicit);
|
|
expect(routing.explicitExecutionTarget, 'gateway');
|
|
expect(routing.explicitProviderId, isEmpty);
|
|
});
|
|
});
|
|
|
|
group('persistGoTaskArtifactsForSessionInternal', () {
|
|
test(
|
|
'writes bridge-returned artifacts into the local thread workspace',
|
|
() async {
|
|
final root = Directory.systemTemp.createTempSync(
|
|
'xworkmate-thread-artifacts-test-',
|
|
);
|
|
final controller = AppController();
|
|
addTearDown(() async {
|
|
controller.dispose();
|
|
if (root.existsSync()) {
|
|
await root.delete(recursive: true);
|
|
}
|
|
});
|
|
|
|
const sessionKey = 'draft:remote-artifacts';
|
|
controller.upsertTaskThreadInternal(
|
|
sessionKey,
|
|
executionTarget: AssistantExecutionTarget.gateway,
|
|
executionTargetSource: ThreadSelectionSource.explicit,
|
|
workspaceBinding: WorkspaceBinding(
|
|
workspaceId: 'workspace-1',
|
|
workspaceKind: WorkspaceKind.localFs,
|
|
workspacePath: root.path,
|
|
displayPath: root.path,
|
|
writable: true,
|
|
),
|
|
);
|
|
|
|
await controller.persistGoTaskArtifactsForSessionInternal(
|
|
sessionKey,
|
|
GoTaskServiceResult(
|
|
success: true,
|
|
message: 'ok',
|
|
turnId: 'turn-1',
|
|
raw: <String, dynamic>{
|
|
'artifacts': <Map<String, dynamic>>[
|
|
<String, dynamic>{
|
|
'relativePath': '../notes/result.md',
|
|
'encoding': 'utf-8',
|
|
'content': 'artifact-body',
|
|
},
|
|
<String, dynamic>{
|
|
'relativePath': 'bin/data.txt',
|
|
'encoding': 'base64',
|
|
'content': 'YmluYXJ5LWRhdGE=',
|
|
},
|
|
],
|
|
},
|
|
errorMessage: '',
|
|
resolvedModel: '',
|
|
route: GoTaskServiceRoute.externalAcpSingle,
|
|
),
|
|
);
|
|
|
|
expect(
|
|
File('${root.path}/notes/result.md').readAsStringSync(),
|
|
'artifact-body',
|
|
);
|
|
expect(
|
|
File('${root.path}/bin/data.txt').readAsStringSync(),
|
|
'binary-data',
|
|
);
|
|
|
|
final record = controller.requireTaskThreadForSessionInternal(
|
|
sessionKey,
|
|
);
|
|
expect(record.lastArtifactSyncStatus, 'synced');
|
|
expect(record.lastArtifactSyncAtMs, isNotNull);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('resolveGatewayAcpAuthorizationHeaderInternal', () {
|
|
test('uses only synced or persisted BRIDGE_SERVER_URL values', () {
|
|
final controller = AppController();
|
|
addTearDown(controller.dispose);
|
|
|
|
expect(controller.resolveBridgeAcpEndpointInternal(), isNull);
|
|
expect(
|
|
controller.resolveExternalAcpEndpointForTargetInternal(
|
|
AssistantExecutionTarget.singleAgent,
|
|
),
|
|
isNull,
|
|
);
|
|
|
|
controller.settingsController.snapshotInternal = controller.settings
|
|
.copyWith(
|
|
acpBridgeServerModeConfig: controller
|
|
.settings
|
|
.acpBridgeServerModeConfig
|
|
.copyWith(
|
|
cloudSynced: controller
|
|
.settings
|
|
.acpBridgeServerModeConfig
|
|
.cloudSynced
|
|
.copyWith(
|
|
remoteServerSummary:
|
|
const AcpBridgeServerRemoteServerSummary(
|
|
endpoint: 'https://bridge.customer.example/acp',
|
|
hasAdvancedOverrides: false,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(
|
|
controller.resolveBridgeAcpEndpointInternal(),
|
|
Uri.parse('https://bridge.customer.example/acp'),
|
|
);
|
|
expect(
|
|
controller.resolveExternalAcpEndpointForTargetInternal(
|
|
AssistantExecutionTarget.singleAgent,
|
|
),
|
|
Uri.parse('https://bridge.customer.example/acp'),
|
|
);
|
|
expect(
|
|
controller.resolveExternalAcpEndpointForTargetInternal(
|
|
AssistantExecutionTarget.gateway,
|
|
),
|
|
Uri.parse('https://bridge.customer.example/acp'),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'prefers the synced bridge bearer token over the account session token',
|
|
() async {
|
|
final root = await Directory.systemTemp.createTemp(
|
|
'xworkmate-bridge-auth-header-',
|
|
);
|
|
final store = SecureConfigStore(
|
|
enableSecureStorage: false,
|
|
appDataRootPathResolver: () async => '${root.path}/settings.sqlite3',
|
|
secretRootPathResolver: () async => root.path,
|
|
supportRootPathResolver: () async => root.path,
|
|
);
|
|
final controller = AppController(
|
|
store: store,
|
|
accountClientFactory: (_) => _BridgeSyncAccountRuntimeClient(),
|
|
);
|
|
addTearDown(() async {
|
|
controller.dispose();
|
|
if (await root.exists()) {
|
|
try {
|
|
await root.delete(recursive: true);
|
|
} on FileSystemException {
|
|
// Temp cleanup is best-effort on macOS when sqlite/watch handles lag.
|
|
}
|
|
}
|
|
});
|
|
|
|
await store.initialize();
|
|
await controller.settingsController.initialize();
|
|
await controller.settingsController.saveSnapshot(
|
|
controller.settings.copyWith(
|
|
accountBaseUrl: 'https://accounts.svc.plus',
|
|
accountUsername: 'review@svc.plus',
|
|
),
|
|
);
|
|
await controller.settingsController.loginAccount(
|
|
baseUrl: 'https://accounts.svc.plus',
|
|
identifier: 'review@svc.plus',
|
|
password: '***REMOVED-CREDENTIAL***',
|
|
);
|
|
await controller.settingsController.saveGatewaySecrets(
|
|
profileIndex: kGatewayRemoteProfileIndex,
|
|
token: 'local-token',
|
|
password: '',
|
|
);
|
|
|
|
final bridgeAuthorization = await controller
|
|
.resolveGatewayAcpAuthorizationHeaderInternal(
|
|
Uri.parse('https://xworkmate-bridge.svc.plus/acp'),
|
|
);
|
|
final nonBridgeAuthorization = await controller
|
|
.resolveGatewayAcpAuthorizationHeaderInternal(
|
|
Uri.parse('https://remote.example.com/acp'),
|
|
);
|
|
|
|
expect(bridgeAuthorization, 'Bearer bridge-token');
|
|
expect(nonBridgeAuthorization, isNull);
|
|
},
|
|
);
|
|
});
|
|
|
|
group('thread working directory', () {
|
|
test(
|
|
'uses the unique thread workspace as the only workingDirectory source',
|
|
() async {
|
|
final controller = AppController();
|
|
addTearDown(controller.dispose);
|
|
|
|
const sessionKey = 'draft:thread-working-directory';
|
|
controller.initializeAssistantThreadContext(
|
|
sessionKey,
|
|
executionTarget: AssistantExecutionTarget.singleAgent,
|
|
);
|
|
final record = controller.requireTaskThreadForSessionInternal(
|
|
sessionKey,
|
|
);
|
|
|
|
expect(
|
|
controller.assistantWorkingDirectoryForSessionInternal(sessionKey),
|
|
record.workspaceBinding.workspacePath,
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
class _FakeGatewayRuntime extends GatewayRuntime {
|
|
factory _FakeGatewayRuntime(GatewayConnectionSnapshot snapshot) {
|
|
final deps = _FakeGatewayRuntimeDeps();
|
|
return _FakeGatewayRuntime._(snapshot, deps);
|
|
}
|
|
|
|
_FakeGatewayRuntime._(this._snapshot, this._deps)
|
|
: super(store: _deps.store, identityStore: _deps.identityStore);
|
|
|
|
final GatewayConnectionSnapshot _snapshot;
|
|
final _FakeGatewayRuntimeDeps _deps;
|
|
|
|
@override
|
|
GatewayConnectionSnapshot get snapshot => _snapshot;
|
|
|
|
@override
|
|
bool get isConnected => _snapshot.status == RuntimeConnectionStatus.connected;
|
|
|
|
@override
|
|
Future<void> initialize() async {}
|
|
|
|
Future<void> disposeTestResources() async {
|
|
if (_deps.root.existsSync()) {
|
|
await _deps.root.delete(recursive: true);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _FakeGatewayRuntimeDeps {
|
|
factory _FakeGatewayRuntimeDeps() {
|
|
final root = Directory.systemTemp.createTempSync(
|
|
'xworkmate-gateway-runtime-test-',
|
|
);
|
|
final store = SecureConfigStore(
|
|
enableSecureStorage: false,
|
|
appDataRootPathResolver: () async => '${root.path}/settings.sqlite3',
|
|
secretRootPathResolver: () async => root.path,
|
|
supportRootPathResolver: () async => root.path,
|
|
);
|
|
return _FakeGatewayRuntimeDeps._(root, store, DeviceIdentityStore(store));
|
|
}
|
|
|
|
_FakeGatewayRuntimeDeps._(this.root, this.store, this.identityStore);
|
|
|
|
final Directory root;
|
|
final SecureConfigStore store;
|
|
final DeviceIdentityStore identityStore;
|
|
}
|
|
|
|
class _BridgeSyncAccountRuntimeClient extends AccountRuntimeClient {
|
|
_BridgeSyncAccountRuntimeClient()
|
|
: super(baseUrl: 'https://accounts.svc.plus');
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> login({
|
|
required String identifier,
|
|
required String password,
|
|
}) async {
|
|
return <String, dynamic>{
|
|
'token': 'session-token',
|
|
'internalServiceToken': 'bridge-token',
|
|
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge.svc.plus',
|
|
'expiresAt': '2026-04-12T00:00:00Z',
|
|
'user': <String, dynamic>{
|
|
'id': 'u-1',
|
|
'email': identifier,
|
|
'name': 'Review',
|
|
'role': 'member',
|
|
'mfaEnabled': false,
|
|
},
|
|
};
|
|
}
|
|
}
|