xworkmate-app/test/app_controller_desktop_thread_binding_test.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,
},
};
}
}