refactor: remove silent local gateway fallback
This commit is contained in:
parent
b77a486d2c
commit
288da17a47
@ -72,7 +72,8 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
|
||||
.toList(growable: false);
|
||||
|
||||
final resolvedExplicitProviderId =
|
||||
thread?.hasExplicitProviderSelection ?? false
|
||||
sessionTarget == AssistantExecutionTarget.singleAgent &&
|
||||
(thread?.hasExplicitProviderSelection ?? false)
|
||||
? singleAgentProviderForSession(normalizedSessionKey).providerId
|
||||
: '';
|
||||
final resolvedExplicitModel = thread?.hasExplicitModelSelection ?? false
|
||||
|
||||
@ -103,6 +103,7 @@ Future<void> refreshSingleAgentCapabilitiesRuntimeInternal(
|
||||
AppController controller, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
await controller.syncExternalAcpProvidersInternal();
|
||||
final capabilities = await controller.goTaskServiceClientInternal
|
||||
.loadExternalAcpCapabilities(
|
||||
target: AssistantExecutionTarget.singleAgent,
|
||||
|
||||
@ -27,6 +27,7 @@ import '../runtime/codex_config_bridge.dart';
|
||||
import '../runtime/code_agent_node_orchestrator.dart';
|
||||
import '../runtime/assistant_artifacts.dart';
|
||||
import '../runtime/desktop_thread_artifact_service.dart';
|
||||
import '../runtime/go_task_service_client.dart';
|
||||
import '../runtime/mode_switcher.dart';
|
||||
import '../runtime/agent_registry.dart';
|
||||
import '../runtime/multi_agent_orchestrator.dart';
|
||||
@ -712,6 +713,104 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
return resolveSingleAgentAuthorizationHeaderInternal(endpoint);
|
||||
}
|
||||
|
||||
Future<List<ExternalCodeAgentAcpSyncedProvider>>
|
||||
buildExternalAcpSyncedProvidersInternal() async {
|
||||
final providers = <ExternalCodeAgentAcpSyncedProvider>[];
|
||||
for (final profile in settings.externalAcpEndpoints) {
|
||||
final provider = settings.singleAgentProviderForId(profile.providerKey);
|
||||
if (provider == SingleAgentProvider.auto) {
|
||||
continue;
|
||||
}
|
||||
final endpoint = profile.endpoint.trim();
|
||||
if (!profile.enabled || endpoint.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final authorizationHeader = profile.authRef.trim().isEmpty
|
||||
? ''
|
||||
: await settingsControllerInternal.resolveSecretValueInternal(
|
||||
refName: profile.authRef.trim(),
|
||||
);
|
||||
providers.add(
|
||||
ExternalCodeAgentAcpSyncedProvider(
|
||||
providerId: provider.providerId,
|
||||
label: provider.label,
|
||||
endpoint: endpoint,
|
||||
authorizationHeader: authorizationHeader,
|
||||
enabled: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
Future<void> syncExternalAcpProvidersInternal() async {
|
||||
await goTaskServiceClientInternal.syncExternalProviders(
|
||||
await buildExternalAcpSyncedProvidersInternal(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> persistGoTaskArtifactsForSessionInternal(
|
||||
String sessionKey,
|
||||
GoTaskServiceResult result,
|
||||
) async {
|
||||
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
|
||||
sessionKey,
|
||||
);
|
||||
final artifacts = result.artifacts;
|
||||
final syncedAtMs = DateTime.now().millisecondsSinceEpoch.toDouble();
|
||||
if (artifacts.isEmpty) {
|
||||
upsertTaskThreadInternal(
|
||||
normalizedSessionKey,
|
||||
lastArtifactSyncAtMs: syncedAtMs,
|
||||
lastArtifactSyncStatus: 'no-artifacts',
|
||||
updatedAtMs: syncedAtMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final existingThread = requireTaskThreadForSessionInternal(
|
||||
normalizedSessionKey,
|
||||
);
|
||||
if (existingThread.workspaceBinding.workspaceKind !=
|
||||
WorkspaceKind.localFs) {
|
||||
upsertTaskThreadInternal(
|
||||
normalizedSessionKey,
|
||||
lastArtifactSyncAtMs: syncedAtMs,
|
||||
lastArtifactSyncStatus: 'skipped-non-local-workspace',
|
||||
updatedAtMs: syncedAtMs,
|
||||
);
|
||||
return;
|
||||
}
|
||||
final root = Directory(existingThread.workspaceBinding.workspacePath);
|
||||
await root.create(recursive: true);
|
||||
|
||||
var wroteArtifact = false;
|
||||
for (final artifact in artifacts) {
|
||||
if (!artifact.hasInlineContent) {
|
||||
continue;
|
||||
}
|
||||
final relativePath = _sanitizeArtifactRelativePathInternal(
|
||||
artifact.relativePath,
|
||||
);
|
||||
if (relativePath.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final target = await _nextArtifactTargetFileInternal(root, relativePath);
|
||||
await target.parent.create(recursive: true);
|
||||
await target.writeAsBytes(
|
||||
_decodeArtifactContentInternal(artifact),
|
||||
flush: true,
|
||||
);
|
||||
wroteArtifact = true;
|
||||
}
|
||||
|
||||
upsertTaskThreadInternal(
|
||||
normalizedSessionKey,
|
||||
lastArtifactSyncAtMs: syncedAtMs,
|
||||
lastArtifactSyncStatus: wroteArtifact ? 'synced' : 'no-inline-content',
|
||||
updatedAtMs: syncedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
Uri? resolveGatewayAcpEndpointInternal() {
|
||||
final target = assistantExecutionTargetForSession(
|
||||
sessionsControllerInternal.currentSessionKey,
|
||||
@ -821,3 +920,51 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
String _sanitizeArtifactRelativePathInternal(String raw) {
|
||||
final trimmed = raw.trim().replaceAll('\\', '/');
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
return trimmed
|
||||
.split('/')
|
||||
.where(
|
||||
(segment) => segment.isNotEmpty && segment != '.' && segment != '..',
|
||||
)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
List<int> _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) {
|
||||
final encoding = artifact.encoding.trim().toLowerCase();
|
||||
if (encoding == 'base64') {
|
||||
return base64Decode(artifact.content);
|
||||
}
|
||||
return utf8.encode(artifact.content);
|
||||
}
|
||||
|
||||
Future<File> _nextArtifactTargetFileInternal(
|
||||
Directory root,
|
||||
String relativePath,
|
||||
) async {
|
||||
final segments = relativePath.split('/');
|
||||
final fileName = segments.removeLast();
|
||||
final parent = segments.isEmpty
|
||||
? root
|
||||
: Directory('${root.path}/${segments.join('/')}');
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex);
|
||||
final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex);
|
||||
var candidate = File('${parent.path}/$fileName');
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
for (var version = 2; version < 1000; version += 1) {
|
||||
candidate = File('${parent.path}/$baseName.v$version$extension');
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return File(
|
||||
'${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension',
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
// ignore_for_file: unused_import, unnecessary_import
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../i18n/app_language.dart';
|
||||
import '../models/app_models.dart';
|
||||
@ -153,6 +151,7 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
|
||||
.map((item) => item.label.trim().isNotEmpty ? item.label : item.key)
|
||||
.where((item) => item.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
await controller.syncExternalAcpProvidersInternal();
|
||||
final result = await controller.goTaskServiceClientInternal.executeTask(
|
||||
GoTaskServiceRequest(
|
||||
sessionId: sessionKey,
|
||||
@ -340,105 +339,4 @@ Future<void> _persistSingleAgentArtifactsDesktopInternal(
|
||||
AppController controller,
|
||||
String sessionKey,
|
||||
GoTaskServiceResult result,
|
||||
) async {
|
||||
final artifacts = result.artifacts;
|
||||
if (artifacts.isEmpty) {
|
||||
controller.upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
lastArtifactSyncStatus: 'no-artifacts',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final existingThread = controller.requireTaskThreadForSessionInternal(
|
||||
sessionKey,
|
||||
);
|
||||
if (existingThread.workspaceBinding.workspaceKind != WorkspaceKind.localFs) {
|
||||
controller.upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
lastArtifactSyncStatus: 'skipped-non-local-workspace',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final root = Directory(existingThread.workspaceBinding.workspacePath);
|
||||
await root.create(recursive: true);
|
||||
|
||||
var wroteArtifact = false;
|
||||
for (final artifact in artifacts) {
|
||||
if (!artifact.hasInlineContent) {
|
||||
continue;
|
||||
}
|
||||
final relativePath = _sanitizeArtifactRelativePathInternal(
|
||||
artifact.relativePath,
|
||||
);
|
||||
if (relativePath.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final target = await _nextArtifactTargetFileInternal(root, relativePath);
|
||||
await target.parent.create(recursive: true);
|
||||
await target.writeAsBytes(
|
||||
_decodeArtifactContentInternal(artifact),
|
||||
flush: true,
|
||||
);
|
||||
wroteArtifact = true;
|
||||
}
|
||||
|
||||
controller.upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
lastArtifactSyncStatus: wroteArtifact ? 'synced' : 'no-inline-content',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
String _sanitizeArtifactRelativePathInternal(String raw) {
|
||||
final trimmed = raw.trim().replaceAll('\\', '/');
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final cleaned = trimmed
|
||||
.split('/')
|
||||
.where(
|
||||
(segment) => segment.isNotEmpty && segment != '.' && segment != '..',
|
||||
)
|
||||
.join('/');
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
List<int> _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) {
|
||||
final encoding = artifact.encoding.trim().toLowerCase();
|
||||
if (encoding == 'base64') {
|
||||
return base64Decode(artifact.content);
|
||||
}
|
||||
return utf8.encode(artifact.content);
|
||||
}
|
||||
|
||||
Future<File> _nextArtifactTargetFileInternal(
|
||||
Directory root,
|
||||
String relativePath,
|
||||
) async {
|
||||
final segments = relativePath.split('/');
|
||||
final fileName = segments.removeLast();
|
||||
final parent = segments.isEmpty
|
||||
? root
|
||||
: Directory('${root.path}/${segments.join('/')}');
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex);
|
||||
final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex);
|
||||
var candidate = File('${parent.path}/$fileName');
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
for (var version = 2; version < 1000; version += 1) {
|
||||
candidate = File('${parent.path}/$baseName.v$version$extension');
|
||||
if (!await candidate.exists()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return File(
|
||||
'${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension',
|
||||
);
|
||||
}
|
||||
) => controller.persistGoTaskArtifactsForSessionInternal(sessionKey, result);
|
||||
|
||||
@ -334,10 +334,18 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
);
|
||||
}
|
||||
final nextProvider =
|
||||
singleAgentProvider ??
|
||||
SingleAgentProviderCopy.fromJsonValue(
|
||||
executionBinding?.providerId ?? existing?.executionBinding.providerId,
|
||||
);
|
||||
nextExecutionTarget == AssistantExecutionTarget.singleAgent
|
||||
? (singleAgentProvider ??
|
||||
SingleAgentProviderCopy.fromJsonValue(
|
||||
executionBinding?.providerId ??
|
||||
existing?.executionBinding.providerId,
|
||||
))
|
||||
: SingleAgentProvider.auto;
|
||||
final nextProviderSource =
|
||||
nextExecutionTarget == AssistantExecutionTarget.singleAgent
|
||||
? (singleAgentProviderSource ??
|
||||
existing?.executionBinding.providerSource)
|
||||
: ThreadSelectionSource.inherited;
|
||||
final nextExecutionBinding =
|
||||
(executionBinding ??
|
||||
existing?.executionBinding ??
|
||||
@ -361,9 +369,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
executionModeSource:
|
||||
executionTargetSource ??
|
||||
existing?.executionBinding.executionModeSource,
|
||||
providerSource:
|
||||
singleAgentProviderSource ??
|
||||
existing?.executionBinding.providerSource,
|
||||
providerSource: nextProviderSource,
|
||||
);
|
||||
final nextContextState =
|
||||
(contextState ??
|
||||
|
||||
@ -306,6 +306,7 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
try {
|
||||
final dispatch = await codeAgentNodeOrchestratorInternal
|
||||
.buildGatewayDispatch(buildCodeAgentNodeStateInternal());
|
||||
await syncExternalAcpProvidersInternal();
|
||||
final result = await goTaskServiceClientInternal.executeTask(
|
||||
GoTaskServiceRequest(
|
||||
sessionId: sessionKey,
|
||||
@ -347,6 +348,7 @@ extension AppControllerDesktopThreadActions on AppController {
|
||||
lastResultCode: result.success ? 'success' : 'error',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
await persistGoTaskArtifactsForSessionInternal(sessionKey, result);
|
||||
if (!result.success) {
|
||||
appendLocalSessionMessageInternal(
|
||||
sessionKey,
|
||||
|
||||
@ -255,9 +255,10 @@ extension AppControllerDesktopThreadBinding on AppController {
|
||||
required SingleAgentProvider singleAgentProvider,
|
||||
ExecutionBinding? existingBinding,
|
||||
}) {
|
||||
final sanitizedProvider = settings.sanitizeSingleAgentProviderSelection(
|
||||
singleAgentProvider,
|
||||
);
|
||||
final sanitizedProvider =
|
||||
executionTarget == AssistantExecutionTarget.singleAgent
|
||||
? settings.sanitizeSingleAgentProviderSelection(singleAgentProvider)
|
||||
: SingleAgentProvider.auto;
|
||||
return (existingBinding ??
|
||||
ExecutionBinding(
|
||||
executionMode: ThreadExecutionMode.localAgent,
|
||||
@ -275,6 +276,10 @@ extension AppControllerDesktopThreadBinding on AppController {
|
||||
},
|
||||
executorId: sanitizedProvider.providerId,
|
||||
providerId: sanitizedProvider.providerId,
|
||||
providerSource:
|
||||
executionTarget == AssistantExecutionTarget.singleAgent
|
||||
? existingBinding?.providerSource
|
||||
: ThreadSelectionSource.inherited,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -160,6 +160,7 @@ Future<void> runMultiAgentCollaborationThreadSessionInternal(
|
||||
);
|
||||
controller.recomputeTasksInternal();
|
||||
try {
|
||||
await controller.syncExternalAcpProvidersInternal();
|
||||
final result = await controller.goTaskServiceClientInternal.executeTask(
|
||||
GoTaskServiceRequest(
|
||||
sessionId: sessionKey,
|
||||
@ -203,6 +204,10 @@ Future<void> runMultiAgentCollaborationThreadSessionInternal(
|
||||
);
|
||||
},
|
||||
);
|
||||
await controller.persistGoTaskArtifactsForSessionInternal(
|
||||
sessionKey,
|
||||
result,
|
||||
);
|
||||
controller.appendLocalSessionMessageInternal(
|
||||
sessionKey,
|
||||
GatewayChatMessage(
|
||||
|
||||
@ -444,7 +444,7 @@ extension AppControllerDesktopThreadStorage on AppController {
|
||||
path: resolvedRootPath,
|
||||
bookmark: rootSpec.bookmark,
|
||||
),
|
||||
);
|
||||
);
|
||||
if (accessHandle == null) {
|
||||
continue;
|
||||
}
|
||||
@ -690,11 +690,14 @@ extension AppControllerDesktopThreadStorage on AppController {
|
||||
final recordExecutionTarget = assistantExecutionTargetFromExecutionMode(
|
||||
record.executionBinding.executionMode,
|
||||
);
|
||||
final recordProvider = settings.sanitizeSingleAgentProviderSelection(
|
||||
SingleAgentProviderCopy.fromJsonValue(
|
||||
record.executionBinding.providerId,
|
||||
),
|
||||
);
|
||||
final recordProvider =
|
||||
recordExecutionTarget == AssistantExecutionTarget.singleAgent
|
||||
? settings.sanitizeSingleAgentProviderSelection(
|
||||
SingleAgentProviderCopy.fromJsonValue(
|
||||
record.executionBinding.providerId,
|
||||
),
|
||||
)
|
||||
: SingleAgentProvider.auto;
|
||||
final workspaceBinding = record.workspaceBinding.copyWith(
|
||||
workspaceId: sessionKey,
|
||||
displayPath: record.workspaceKind == WorkspaceKind.localFs
|
||||
@ -728,6 +731,10 @@ extension AppControllerDesktopThreadStorage on AppController {
|
||||
),
|
||||
executorId: recordProvider.providerId,
|
||||
providerId: recordProvider.providerId,
|
||||
providerSource:
|
||||
recordExecutionTarget == AssistantExecutionTarget.singleAgent
|
||||
? record.executionBinding.providerSource
|
||||
: ThreadSelectionSource.inherited,
|
||||
),
|
||||
lifecycleState: record.lifecycleState.copyWith(status: 'ready'),
|
||||
);
|
||||
|
||||
@ -276,6 +276,9 @@ class MobileShellStateInternal extends State<MobileShell> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final codeController = TextEditingController();
|
||||
final enteredCode = await showDialog<String>(
|
||||
context: context,
|
||||
|
||||
@ -127,12 +127,12 @@ AssistantExecutionTarget resolveGatewayExecutionTargetFromVisibleTargets(
|
||||
visible.contains(currentTarget)) {
|
||||
return currentTarget;
|
||||
}
|
||||
if (visible.contains(AssistantExecutionTarget.local)) {
|
||||
return AssistantExecutionTarget.local;
|
||||
}
|
||||
if (visible.contains(AssistantExecutionTarget.remote)) {
|
||||
return AssistantExecutionTarget.remote;
|
||||
}
|
||||
if (visible.contains(AssistantExecutionTarget.local)) {
|
||||
return AssistantExecutionTarget.local;
|
||||
}
|
||||
return AssistantExecutionTarget.remote;
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,25 @@
|
||||
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/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_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('resolveDesktopThreadBindingSnapshotInternal', () {
|
||||
const localOwner = ThreadOwnerScope(
|
||||
realm: ThreadRealm.local,
|
||||
@ -92,6 +108,33 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveGatewayExecutionTargetFromVisibleTargets', () {
|
||||
test('prefers remote bridge target over silent local fallback', () {
|
||||
final target = resolveGatewayExecutionTargetFromVisibleTargets(
|
||||
const <AssistantExecutionTarget>[
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
AssistantExecutionTarget.local,
|
||||
AssistantExecutionTarget.remote,
|
||||
],
|
||||
currentTarget: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
expect(target, AssistantExecutionTarget.remote);
|
||||
});
|
||||
|
||||
test('preserves explicit local gateway selection when already active', () {
|
||||
final target = resolveGatewayExecutionTargetFromVisibleTargets(
|
||||
const <AssistantExecutionTarget>[
|
||||
AssistantExecutionTarget.local,
|
||||
AssistantExecutionTarget.remote,
|
||||
],
|
||||
currentTarget: AssistantExecutionTarget.local,
|
||||
);
|
||||
|
||||
expect(target, AssistantExecutionTarget.local);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveGatewayThreadConnectionStateInternal', () {
|
||||
test('uses the thread target profile as the only address source', () {
|
||||
final state = resolveGatewayThreadConnectionStateInternal(
|
||||
@ -130,4 +173,199 @@ void main() {
|
||||
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: '127.0.0.1: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.remote,
|
||||
);
|
||||
controller.upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
executionTargetSource: ThreadSelectionSource.explicit,
|
||||
);
|
||||
|
||||
final state = controller.assistantConnectionStateForSession(sessionKey);
|
||||
|
||||
expect(state.status, RuntimeConnectionStatus.connected);
|
||||
expect(state.detailLabel, 'openclaw.svc.plus:443');
|
||||
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.remote,
|
||||
singleAgentProvider: SingleAgentProvider.opencode,
|
||||
);
|
||||
controller.upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
executionTarget: AssistantExecutionTarget.remote,
|
||||
executionTargetSource: ThreadSelectionSource.explicit,
|
||||
singleAgentProvider: SingleAgentProvider.opencode,
|
||||
singleAgentProviderSource: ThreadSelectionSource.explicit,
|
||||
);
|
||||
|
||||
final routing = controller.buildExternalAcpRoutingForSessionInternal(
|
||||
sessionKey,
|
||||
);
|
||||
|
||||
expect(routing.mode, ExternalCodeAgentAcpRoutingMode.explicit);
|
||||
expect(routing.explicitExecutionTarget, 'remote');
|
||||
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.remote,
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
databasePathResolver: () async => '${root.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => root.path,
|
||||
defaultSupportDirectoryPathResolver: () 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;
|
||||
}
|
||||
|
||||
228
test/assistant_execution_target_picker_widget_test.dart
Normal file
228
test/assistant_execution_target_picker_widget_test.dart
Normal file
@ -0,0 +1,228 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller_desktop_core.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_composer_bar.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart';
|
||||
import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart';
|
||||
import 'package:xworkmate/runtime/desktop_platform_service.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';
|
||||
import 'package:xworkmate/runtime/skill_directory_access.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets(
|
||||
'compact gateway picker selects remote bridge route instead of local fallback',
|
||||
(tester) async {
|
||||
final root = Directory.systemTemp.createTempSync(
|
||||
'xworkmate-picker-widget-test-',
|
||||
);
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${root.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => root.path,
|
||||
defaultSupportDirectoryPathResolver: () async => root.path,
|
||||
);
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
desktopPlatformService: UnsupportedDesktopPlatformService(),
|
||||
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(
|
||||
root.path,
|
||||
),
|
||||
goTaskServiceClient: const _FakeGoTaskServiceClient(),
|
||||
singleAgentSharedSkillScanRootOverrides: const <String>[],
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
);
|
||||
final inputController = TextEditingController();
|
||||
final focusNode = FocusNode();
|
||||
addTearDown(() async {
|
||||
controller.dispose();
|
||||
inputController.dispose();
|
||||
focusNode.dispose();
|
||||
if (root.existsSync()) {
|
||||
await root.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
controller.settingsController.snapshotInternal = controller.settings
|
||||
.copyWith(savedGatewayTargets: const <String>['local', 'remote']);
|
||||
controller.lastObservedSettingsSnapshotInternal =
|
||||
controller.settingsController.snapshotInternal;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(platform: TargetPlatform.macOS),
|
||||
home: Scaffold(
|
||||
body: ComposerBarInternal(
|
||||
controller: controller,
|
||||
inputController: inputController,
|
||||
focusNode: focusNode,
|
||||
thinkingLabel: 'Normal',
|
||||
showModelControl: false,
|
||||
modelLabel: '',
|
||||
modelOptions: const <String>[],
|
||||
attachments: const <ComposerAttachmentInternal>[],
|
||||
availableSkills: const <ComposerSkillOptionInternal>[],
|
||||
selectedSkillKeys: const <String>[],
|
||||
onRemoveAttachment: (_) {},
|
||||
onToggleSkill: (_) {},
|
||||
onThinkingChanged: (_) {},
|
||||
onModelChanged: (_) async {},
|
||||
onOpenGateway: () {},
|
||||
onOpenAiGatewaySettings: () {},
|
||||
onReconnectGateway: () async {},
|
||||
onPickAttachments: () {},
|
||||
onAddAttachment: (_) {},
|
||||
onPasteImageAttachment: () async => null,
|
||||
onContentHeightChanged: (_) {},
|
||||
onInputHeightChanged: (_) {},
|
||||
onSend: () async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(
|
||||
controller.assistantExecutionTarget,
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
final buttonFinder = find.byKey(
|
||||
const Key('assistant-execution-target-button'),
|
||||
);
|
||||
expect(buttonFinder, findsOneWidget);
|
||||
|
||||
final button = tester.widget<PopupMenuButton<AssistantExecutionTarget>>(
|
||||
buttonFinder,
|
||||
);
|
||||
final items = button.itemBuilder(tester.element(buttonFinder));
|
||||
final values = items
|
||||
.whereType<PopupMenuItem<AssistantExecutionTarget>>()
|
||||
.map((item) => item.value)
|
||||
.toList(growable: false);
|
||||
|
||||
expect(values, contains(AssistantExecutionTarget.remote));
|
||||
expect(values, isNot(contains(AssistantExecutionTarget.local)));
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
controller.dispose();
|
||||
await tester.pump();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService {
|
||||
const _FakeSkillDirectoryAccessService(this.homeDirectory);
|
||||
|
||||
final String homeDirectory;
|
||||
|
||||
@override
|
||||
bool get isSupported => false;
|
||||
|
||||
@override
|
||||
Future<List<AuthorizedSkillDirectory>> authorizeDirectories({
|
||||
List<String> suggestedPaths = const <String>[],
|
||||
}) async {
|
||||
return const <AuthorizedSkillDirectory>[];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthorizedSkillDirectory?> authorizeDirectory({
|
||||
String suggestedPath = '',
|
||||
}) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SkillDirectoryAccessHandle?> openDirectory(
|
||||
AuthorizedSkillDirectory directory,
|
||||
) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> resolveUserHomeDirectory() async {
|
||||
return homeDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeGoTaskServiceClient implements GoTaskServiceClient {
|
||||
const _FakeGoTaskServiceClient();
|
||||
|
||||
@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 {}
|
||||
|
||||
@override
|
||||
Future<GoTaskServiceResult> executeTask(
|
||||
GoTaskServiceRequest request, {
|
||||
required void Function(GoTaskServiceUpdate update) onUpdate,
|
||||
}) async {
|
||||
return const GoTaskServiceResult(
|
||||
success: true,
|
||||
message: '',
|
||||
turnId: '',
|
||||
raw: <String, dynamic>{},
|
||||
errorMessage: '',
|
||||
resolvedModel: '',
|
||||
route: GoTaskServiceRoute.externalAcpSingle,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
|
||||
required String taskPrompt,
|
||||
required String workingDirectory,
|
||||
required ExternalCodeAgentAcpRoutingConfig routing,
|
||||
String aiGatewayBaseUrl = '',
|
||||
String aiGatewayApiKey = '',
|
||||
}) async {
|
||||
return const ExternalCodeAgentAcpRoutingResolution(
|
||||
raw: <String, dynamic>{
|
||||
'resolvedExecutionTarget': 'single-agent',
|
||||
'resolvedEndpointTarget': 'singleAgent',
|
||||
'resolvedProviderId': 'codex',
|
||||
'resolvedModel': '',
|
||||
'resolvedSkills': <String>[],
|
||||
'unavailable': false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
|
||||
required AssistantExecutionTarget target,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
return const ExternalCodeAgentAcpCapabilities.empty();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> syncExternalProviders(
|
||||
List<ExternalCodeAgentAcpSyncedProvider> providers,
|
||||
) async {}
|
||||
}
|
||||
118
test/runtime/external_acp_bridge_sync_order_test.dart
Normal file
118
test/runtime/external_acp_bridge_sync_order_test.dart
Normal file
@ -0,0 +1,118 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/external_code_agent_acp_desktop_transport.dart';
|
||||
import 'package:xworkmate/runtime/go_acp_stdio_bridge.dart';
|
||||
import 'package:xworkmate/runtime/go_task_service_client.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
|
||||
class _FakeGoAcpStdioBridgeWithSyncOrder extends GoAcpStdioBridge {
|
||||
final List<String> methods = <String>[];
|
||||
final StreamController<Map<String, dynamic>> _notifications =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<Map<String, dynamic>> get notifications => _notifications.stream;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> request({
|
||||
required String method,
|
||||
required Map<String, dynamic> params,
|
||||
Duration timeout = const Duration(seconds: 120),
|
||||
}) async {
|
||||
methods.add(method);
|
||||
return switch (method) {
|
||||
'acp.capabilities' => <String, dynamic>{
|
||||
'result': <String, dynamic>{
|
||||
'singleAgent': true,
|
||||
'multiAgent': true,
|
||||
'providers': <String>['codex'],
|
||||
},
|
||||
},
|
||||
_ => <String, dynamic>{
|
||||
'result': <String, dynamic>{
|
||||
'success': true,
|
||||
'output': 'ok',
|
||||
'resolvedExecutionTarget': 'single-agent',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
await _notifications.close();
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('External ACP bridge sync order', () {
|
||||
test('syncs providers before capabilities requests', () async {
|
||||
final bridge = _FakeGoAcpStdioBridgeWithSyncOrder();
|
||||
final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge);
|
||||
|
||||
await transport
|
||||
.syncExternalProviders(const <ExternalCodeAgentAcpSyncedProvider>[
|
||||
ExternalCodeAgentAcpSyncedProvider(
|
||||
providerId: 'codex',
|
||||
label: 'Codex',
|
||||
endpoint: 'https://acp-server.svc.plus/codex/acp/rpc',
|
||||
authorizationHeader: '',
|
||||
enabled: true,
|
||||
),
|
||||
]);
|
||||
|
||||
await transport.loadExternalAcpCapabilities(
|
||||
target: AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
expect(bridge.methods, <String>[
|
||||
'xworkmate.providers.sync',
|
||||
'xworkmate.providers.sync',
|
||||
'acp.capabilities',
|
||||
]);
|
||||
});
|
||||
|
||||
test('syncs providers before session start requests', () async {
|
||||
final bridge = _FakeGoAcpStdioBridgeWithSyncOrder();
|
||||
final transport = ExternalCodeAgentAcpDesktopTransport(bridge: bridge);
|
||||
|
||||
await transport
|
||||
.syncExternalProviders(const <ExternalCodeAgentAcpSyncedProvider>[
|
||||
ExternalCodeAgentAcpSyncedProvider(
|
||||
providerId: 'codex',
|
||||
label: 'Codex',
|
||||
endpoint: 'https://acp-server.svc.plus/codex/acp/rpc',
|
||||
authorizationHeader: '',
|
||||
enabled: true,
|
||||
),
|
||||
]);
|
||||
|
||||
await transport.executeTask(
|
||||
const GoTaskServiceRequest(
|
||||
sessionId: 's1',
|
||||
threadId: 't1',
|
||||
target: AssistantExecutionTarget.singleAgent,
|
||||
prompt: 'hello',
|
||||
workingDirectory: '/tmp',
|
||||
model: '',
|
||||
thinking: '',
|
||||
selectedSkills: <String>[],
|
||||
inlineAttachments: <GatewayChatAttachmentPayload>[],
|
||||
localAttachments: <CollaborationAttachment>[],
|
||||
aiGatewayBaseUrl: '',
|
||||
aiGatewayApiKey: '',
|
||||
agentId: '',
|
||||
metadata: <String, dynamic>{},
|
||||
),
|
||||
onUpdate: (_) {},
|
||||
);
|
||||
|
||||
expect(bridge.methods, <String>[
|
||||
'xworkmate.providers.sync',
|
||||
'xworkmate.providers.sync',
|
||||
'session.start',
|
||||
]);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user