diff --git a/docs/README_TESTING.md b/docs/README_TESTING.md
index a8202916..3593b254 100644
--- a/docs/README_TESTING.md
+++ b/docs/README_TESTING.md
@@ -8,12 +8,6 @@ Run unit and widget tests:
flutter test
```
-Run golden tests when the `test/golden` directory exists and contains golden test files:
-
-```bash
-flutter test test/golden
-```
-
Run integration tests when the `integration_test` directory exists and contains integration test files:
```bash
@@ -40,7 +34,7 @@ go test ./...
## CI Coverage
- Pull requests in `xworkmate-app` use the `verify` stage as a static-analysis gate and always run `flutter analyze`.
-- Widget, golden, integration, and Patrol suites are owned by their dedicated commands and release validation flows, not by the lightweight `verify` gate.
+- Widget, integration, and Patrol suites are owned by their dedicated commands and release validation flows, not by the lightweight `verify` gate.
- Pushes to `main`, version tags, and manual workflow runs publish build artifacts and update the GitHub Release entry for that release mode.
- `xworkmate-bridge` Go tests run in the companion repository.
- `release/*` branches run Patrol tests in addition to the PR chain.
diff --git a/docs/architecture/settings-integration-configuration-model.md b/docs/architecture/settings-integration-configuration-model.md
index 338beae1..b7aa4659 100644
--- a/docs/architecture/settings-integration-configuration-model.md
+++ b/docs/architecture/settings-integration-configuration-model.md
@@ -20,7 +20,7 @@ flowchart TD
singleAgent / multiAgent"]
H --> I["refreshSingleAgentCapabilitiesRuntimeInternal()"]
- I --> J["bridgeAdvertisedProvidersInternal
+ I --> J["bridgeProviderCatalogInternal
App 内唯一 provider 名单源"]
I --> K["singleAgentCapabilitiesByProviderInternal
App 内唯一 provider 可用性源"]
@@ -33,8 +33,8 @@ flowchart TD
codex / opencode / claude / gemini / aris / openclaw
available / discoveryState"]
- J --> P["configuredSingleAgentProviders
- = bridgeAdvertisedProvidersInternal"]
+ J --> P["bridgeProviderCatalog
+ = bridgeProviderCatalogInternal"]
P --> Q["singleAgentProviderOptions
Composer / Thread Picker 唯一数据源"]
diff --git a/docs/architecture/task-control-plane-unification.md b/docs/architecture/task-control-plane-unification.md
index 846f43e3..2c0aaa56 100644
--- a/docs/architecture/task-control-plane-unification.md
+++ b/docs/architecture/task-control-plane-unification.md
@@ -93,7 +93,7 @@ flowchart TD
subgraph APPSTATE["App-side truth sources"]
D["refreshSingleAgentCapabilitiesRuntimeInternal()"]
- E["bridgeAdvertisedProvidersInternal
App 内唯一 provider 名单源"]
+ E["bridgeProviderCatalogInternal
App 内唯一 provider 名单源"]
F["singleAgentCapabilitiesByProviderInternal
App 内唯一 provider 可用性源"]
G["refreshAcpCapabilitiesRuntimeInternal()"]
H["GatewayAcpCapabilities"]
@@ -105,7 +105,7 @@ flowchart TD
end
subgraph UISTATE["UI affordances"]
- K["configuredSingleAgentProviders
Composer / Thread Picker provider source"]
+ K["bridgeProviderCatalog
Composer / Thread Picker provider source"]
L["availableSingleAgentProviders
agent path visibility"]
M["visible gateway affordances
只看 bridge capabilities / discovery"]
E --> K
diff --git a/docs/testing/test-case-coverage-matrix.md b/docs/testing/test-case-coverage-matrix.md
index 55664790..65142776 100644
--- a/docs/testing/test-case-coverage-matrix.md
+++ b/docs/testing/test-case-coverage-matrix.md
@@ -52,7 +52,6 @@
| 失败恢复 | 错误 endpoint / 失败任务在原线程展示清晰错误,允许原线程重试 | 已设计 + 部分自动化 | runtime / feature | `test/runtime/gateway_acp_client_suite.dart` `test/features/settings_page_gateway_acp_messages_suite.dart` | 线程级失败回退还可继续加强 |
| 结果表面一致性 | 本地执行型与在线执行型都通过统一 result surface 暴露结果 | 已设计 + 部分自动化 | runtime / feature | `test/runtime/desktop_thread_artifact_service_test.dart` | 统一 artifact surface 已有基础,但在线媒体任务仍是缺口 |
| UI 冒烟 | 登录流程、首页流程、桌面导航流程、桌面设置流程 | 已自动化 | integration | `integration_test/login_flow_test.dart` `integration_test/home_flow_test.dart` `integration_test/desktop_navigation_flow_test.dart` `integration_test/desktop_settings_flow_test.dart` | 更偏入口联通验证,不替代业务细场景 |
-| UI 表现稳定性 | Home / Login golden 基线 | 已自动化 | golden | `test/golden/home_golden_test.dart` `test/golden/login_golden_test.dart` | 当前 golden 覆盖面较窄,更多页面仍未纳入 |
## 4. 按层看当前测试重点
@@ -61,7 +60,6 @@
| runtime | endpoint 规范化、账户同步、secret 边界、线程归属、provider 切换、artifact 回写、线程隔离 |
| feature | 设置页提示语与输入行为、assistant 页技能选择与提交、installed-skill E2E 壳层闭环 |
| integration | 桌面端导航、设置入口联通、登录与首页 happy path 冒烟 |
-| golden | 首页 / 登录页视觉基线 |
| manual | 在线媒体任务、外部服务依赖场景、需要真实服务/真实账号/真实产物确认的 case |
## 5. 当前最值得关注的缺口
@@ -93,19 +91,6 @@
- 失败是否稳定回写线程消息
- 在线结果是否与本地结果保持统一展示模型
-### 5.3 Golden 覆盖面较窄
-
-当前 golden 只有:
-
-- `home`
-- `login`
-
-若后续设置页、assistant 页、skills 页发生明显 UI 变化,建议补充:
-
-- settings shell
-- assistant home shell
-- 关键线程结果面
-
## 6. 建议维护方式
后续新增 case 时,建议同时更新三处:
@@ -137,4 +122,4 @@
- 媒体类技能自动化
- 在线长任务闭环
-- 更广的 UI golden 基线
+- 更贴近真实交互的桌面集成回归
diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart
index dd5b2022..17344420 100644
--- a/lib/app/app_controller_desktop_core.dart
+++ b/lib/app/app_controller_desktop_core.dart
@@ -122,6 +122,7 @@ class AppController extends ChangeNotifier {
UiFeatureManifest? uiFeatureManifest,
SkillDirectoryAccessService? skillDirectoryAccessService,
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
+ Map? environmentOverride,
List? singleAgentSharedSkillScanRootOverrides,
ArisBundleRepository? arisBundleRepository,
GoTaskServiceClient? goTaskServiceClient,
@@ -191,6 +192,9 @@ class AppController extends ChangeNotifier {
skillDirectoryAccessService ?? createSkillDirectoryAccessService();
singleAgentSharedSkillScanRootOverridesInternal =
singleAgentSharedSkillScanRootOverrides?.toList(growable: false);
+ environmentOverrideInternal = environmentOverride == null
+ ? null
+ : Map.unmodifiable(environmentOverride);
gatewayAcpClientInternal = GatewayAcpClient(
endpointResolver: resolveGatewayAcpEndpointInternal,
authorizationResolver: resolveGatewayAcpAuthorizationHeaderInternal,
@@ -293,7 +297,7 @@ class AppController extends ChangeNotifier {
GatewayAcpClient get gatewayAcpClientForTest => gatewayAcpClientInternal;
- List bridgeAdvertisedProvidersInternal =
+ List bridgeProviderCatalogInternal =
const [];
final Map> assistantThreadMessagesInternal =
>{};
@@ -350,6 +354,7 @@ class AppController extends ChangeNotifier {
StreamSubscription? runtimeEventsSubscriptionInternal;
bool disposedInternal = false;
String resolvedUserHomeDirectoryInternal = resolveUserHomeDirectory();
+ Map? environmentOverrideInternal;
SettingsSnapshot lastObservedSettingsSnapshotInternal =
SettingsSnapshot.defaults();
Future assistantThreadPersistQueueInternal = Future.value();
@@ -570,10 +575,21 @@ class AppController extends ChangeNotifier {
profileIndex,
);
- List get configuredSingleAgentProviders =>
- normalizeBridgeOwnedSingleAgentProviderList(
- bridgeAdvertisedProvidersInternal,
- );
+ List get bridgeProviderCatalog =>
+ normalizeSingleAgentProviderList(bridgeProviderCatalogInternal);
+
+ SingleAgentProvider? bridgeProviderForId(String providerId) {
+ final normalizedProviderId = normalizeSingleAgentProviderId(providerId);
+ if (normalizedProviderId.isEmpty) {
+ return null;
+ }
+ for (final provider in bridgeProviderCatalog) {
+ if (provider.providerId == normalizedProviderId) {
+ return provider;
+ }
+ }
+ return null;
+ }
List visibleAssistantExecutionTargets(
Iterable supportedTargets,
@@ -581,7 +597,7 @@ class AppController extends ChangeNotifier {
final supported = supportedTargets.toSet();
final visible = [];
if (supported.contains(AssistantExecutionTarget.singleAgent) &&
- configuredSingleAgentProviders.isNotEmpty) {
+ bridgeProviderCatalog.isNotEmpty) {
visible.add(AssistantExecutionTarget.singleAgent);
}
if (supported.contains(AssistantExecutionTarget.gateway)) {
@@ -599,31 +615,6 @@ class AppController extends ChangeNotifier {
];
}
- bool isBridgeAdvertisedSingleAgentProviderInternal(
- SingleAgentProvider provider,
- ) {
- if (provider.isUnspecified) {
- return false;
- }
- return configuredSingleAgentProviders.any(
- (item) => item.providerId == provider.providerId,
- );
- }
-
- SingleAgentProvider? advertisedSingleAgentProviderInternal(
- SingleAgentProvider selection,
- ) {
- if (selection.isUnspecified) {
- return null;
- }
- for (final provider in configuredSingleAgentProviders) {
- if (provider.providerId == selection.providerId) {
- return provider;
- }
- }
- return null;
- }
-
List get aiGatewayConversationModelChoices {
final availableModels =
settingsControllerInternal.effectiveAiGatewayAvailableModels;
diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart
index 1020d640..48ed8d44 100644
--- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart
+++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart
@@ -97,8 +97,9 @@ Future refreshSingleAgentCapabilitiesRuntimeInternal(
target: AssistantExecutionTarget.singleAgent,
forceRefresh: forceRefresh,
);
- controller.bridgeAdvertisedProvidersInternal =
- normalizeSingleAgentProviderList(capabilities.providerCatalog);
+ controller.bridgeProviderCatalogInternal = normalizeSingleAgentProviderList(
+ capabilities.providerCatalog,
+ );
if (!controller.disposedInternal) {
controller.notifyListeners();
}
diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart
index f9fbc6b3..b70b2417 100644
--- a/lib/app/app_controller_desktop_runtime_helpers.dart
+++ b/lib/app/app_controller_desktop_runtime_helpers.dart
@@ -230,8 +230,8 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
lowered.contains('missing acp endpoint')) &&
target == AssistantExecutionTarget.singleAgent) {
return appText(
- '当前线程还没有同步到 Bridge Server。请先登录账号并在设置里完成同步后再重试。',
- 'This thread does not have a synced bridge server yet. Sign in and complete Settings sync before trying again.',
+ '当前线程缺少可用的 Bridge Server,暂时无法继续。',
+ 'This thread does not have an available bridge server yet.',
);
}
if (lowered.contains('gateway not connected') ||
@@ -241,20 +241,19 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
final selection = singleAgentProviderForSession(
sessionsControllerInternal.currentSessionKey,
);
- final provider =
- advertisedSingleAgentProviderInternal(selection) ?? selection;
+ final provider = currentSingleAgentResolvedProvider ?? selection;
final providerLabel = provider.isUnspecified
? appText('Bridge Provider', 'Bridge Provider')
: provider.label;
final address = _extractGatewayAddressFromErrorInternal(raw);
return address.isEmpty
? appText(
- '当前线程的 Bridge Provider($providerLabel)未连接。请先在设置里连接并同步后再重试。',
- 'The Bridge Provider for this thread ($providerLabel) is not connected. Connect and sync it from Settings, then try again.',
+ '当前线程的 Bridge Provider($providerLabel)未连接。请先恢复该 Provider 对应连接后再重试。',
+ 'The Bridge Provider for this thread ($providerLabel) is offline. Restore that provider connection, then try again.',
)
: appText(
- '当前线程的 Bridge Provider($providerLabel)未连接:$address。请先在设置里连接并同步后再重试。',
- 'The Bridge Provider for this thread ($providerLabel) is not connected: $address. Connect and sync it from Settings, then try again.',
+ '当前线程的 Bridge Provider($providerLabel)未连接:$address。请先恢复该 Provider 对应连接后再重试。',
+ 'The Bridge Provider for this thread ($providerLabel) is offline: $address. Restore that provider connection, then try again.',
);
}
final profile = gatewayProfileForAssistantExecutionTargetInternal(target);
@@ -439,9 +438,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
if (normalizedRuntimeMode == snapshot.codeAgentRuntimeMode) {
return snapshot;
}
- return snapshot.copyWith(
- codeAgentRuntimeMode: normalizedRuntimeMode,
- );
+ return snapshot.copyWith(codeAgentRuntimeMode: normalizedRuntimeMode);
}
Future refreshAcpCapabilitiesInternal({
@@ -700,26 +697,31 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
return resolveBridgeAcpEndpointInternal();
}
+ String? runtimeEnvironmentValueInternal(String key) {
+ final override = environmentOverrideInternal?[key]?.trim() ?? '';
+ if (override.isNotEmpty) {
+ return override;
+ }
+ final value = Platform.environment[key]?.trim() ?? '';
+ return value.isEmpty ? null : value;
+ }
+
Uri? resolveBridgeAcpEndpointInternal() {
final endpoint =
- settingsControllerInternal
- .accountSyncState
- ?.syncedDefaults
- .bridgeServerUrl
- .trim()
- .isNotEmpty ==
- true
- ? settingsControllerInternal
- .accountSyncState!
- .syncedDefaults
- .bridgeServerUrl
- .trim()
- : settings
- .acpBridgeServerModeConfig
- .cloudSynced
- .remoteServerSummary
- .endpoint
- .trim();
+ runtimeEnvironmentValueInternal('BRIDGE_SERVER_URL') ??
+ (() {
+ final synced =
+ settingsControllerInternal
+ .accountSyncState
+ ?.syncedDefaults
+ .bridgeServerUrl
+ .trim() ??
+ '';
+ return synced.isEmpty ? null : synced;
+ })();
+ if (endpoint == null) {
+ return null;
+ }
final uri = Uri.tryParse(endpoint);
final scheme = uri?.scheme.trim().toLowerCase() ?? '';
if (uri == null || !kSupportedExternalAcpEndpointSchemes.contains(scheme)) {
@@ -757,12 +759,14 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
(bridgePort <= 0 || endpoint.port == bridgePort);
if (matchesBridgeEndpoint) {
final bridgeToken =
+ runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN') ??
+ runtimeEnvironmentValueInternal('INTERNAL_SERVICE_TOKEN') ??
(await storeInternal.loadAccountManagedSecret(
target: kAccountManagedSecretTargetBridgeAuthToken,
- ))?.trim() ??
- '';
- if (bridgeToken.isNotEmpty) {
- return 'Bearer $bridgeToken';
+ ))?.trim();
+ final normalizedToken = bridgeToken?.trim() ?? '';
+ if (normalizedToken.isNotEmpty) {
+ return 'Bearer $normalizedToken';
}
}
return null;
diff --git a/lib/app/app_controller_desktop_settings_runtime.dart b/lib/app/app_controller_desktop_settings_runtime.dart
index 5fd318fd..0771d527 100644
--- a/lib/app/app_controller_desktop_settings_runtime.dart
+++ b/lib/app/app_controller_desktop_settings_runtime.dart
@@ -845,6 +845,7 @@ extension AppControllerDesktopSettingsRuntime on AppController {
executionTargetSource: ThreadSelectionSource.explicit,
gatewayEntryState: gatewayEntryStateForTargetInternal(target),
latestResolvedRuntimeModel: '',
+ latestResolvedProviderId: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
recomputeTasksInternal();
diff --git a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart
index 62e01f9e..e767100b 100644
--- a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart
+++ b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart
@@ -105,15 +105,18 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal(
workingDirectory: preflightWorkingDirectory,
routing: routing,
);
- final resolvedProvider = SingleAgentProviderCopy.fromJsonValue(
- routingResolution.resolvedProviderId,
+ final resolvedProviderId = routingResolution.resolvedProviderId.trim();
+ final resolvedProvider = resolvedProviderId.isEmpty
+ ? null
+ : controller.bridgeProviderForId(resolvedProviderId) ??
+ SingleAgentProviderCopy.fromJsonValue(resolvedProviderId);
+ final effectiveProvider = resolvedProvider ?? selection;
+ controller.upsertTaskThreadInternal(
+ sessionKey,
+ latestResolvedProviderId: resolvedProviderId,
+ updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
- final effectiveProvider = !resolvedProvider.isUnspecified
- ? resolvedProvider
- : controller.advertisedSingleAgentProviderInternal(selection) ??
- selection;
- final unavailableReason =
- routingResolution.unavailable
+ final unavailableReason = routingResolution.unavailable
? singleAgentUnavailableLabelDesktopInternal(
controller,
sessionKey,
@@ -265,6 +268,7 @@ Future _applySingleAgentGoTaskResultDesktopInternal(
sessionKey,
gatewayEntryState: resolvedGatewayEntryState,
latestResolvedRuntimeModel: resolvedRuntimeModel,
+ latestResolvedProviderId: result.resolvedProviderId,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: result.success ? 'success' : 'error',
diff --git a/lib/app/app_controller_desktop_single_agent_status_messages.dart b/lib/app/app_controller_desktop_single_agent_status_messages.dart
index f86493bd..39da6f26 100644
--- a/lib/app/app_controller_desktop_single_agent_status_messages.dart
+++ b/lib/app/app_controller_desktop_single_agent_status_messages.dart
@@ -75,9 +75,9 @@ String singleAgentUnavailableLabelDesktopInternal(
sessionKey,
);
final detail = reason?.trim() ?? '';
- final selection = controller.singleAgentProviderForSession(
- normalizedSessionKey,
- );
+ final selection =
+ controller.currentSingleAgentResolvedProvider ??
+ controller.singleAgentProviderForSession(normalizedSessionKey);
if (controller.singleAgentShouldSuggestAcpSwitchForSession(
normalizedSessionKey,
)) {
diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart
index 08dc23b1..75ba819d 100644
--- a/lib/app/app_controller_desktop_skill_permissions.dart
+++ b/lib/app/app_controller_desktop_skill_permissions.dart
@@ -239,6 +239,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
ThreadSelectionSource? selectedSkillsSource,
String? gatewayEntryState,
String? latestResolvedRuntimeModel,
+ String? latestResolvedProviderId,
String? lifecycleStatus,
double? lastRunAtMs,
String? lastResultCode,
@@ -348,6 +349,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
permissionLevel: AssistantPermissionLevel.defaultAccess,
messageViewMode: AssistantMessageViewMode.rendered,
latestResolvedRuntimeModel: '',
+ latestResolvedProviderId: '',
gatewayEntryState: gatewayEntryStateForTargetInternal(
nextExecutionTarget,
),
@@ -372,6 +374,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
selectedSkillsSource ??
existing?.contextState.selectedSkillsSource,
latestResolvedRuntimeModel: latestResolvedRuntimeModel,
+ latestResolvedProviderId: latestResolvedProviderId,
gatewayEntryState: gatewayEntryState,
lastRemoteWorkingDirectory: lastRemoteWorkingDirectory,
lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind,
diff --git a/lib/app/app_controller_desktop_thread_binding.dart b/lib/app/app_controller_desktop_thread_binding.dart
index 78d6c0bb..578288d2 100644
--- a/lib/app/app_controller_desktop_thread_binding.dart
+++ b/lib/app/app_controller_desktop_thread_binding.dart
@@ -48,12 +48,12 @@ import 'app_controller_desktop_runtime_helpers.dart';
class DesktopThreadBindingSnapshotInternal {
const DesktopThreadBindingSnapshotInternal({
required this.executionTarget,
- required this.singleAgentProvider,
+ required this.selectedSingleAgentProvider,
required this.record,
});
final AssistantExecutionTarget executionTarget;
- final SingleAgentProvider singleAgentProvider;
+ final SingleAgentProvider selectedSingleAgentProvider;
final TaskThread? record;
}
@@ -70,12 +70,12 @@ resolveDesktopThreadBindingSnapshotInternal({
: assistantExecutionTargetFromExecutionMode(
latestRecord.executionBinding.executionMode,
));
- final resolvedProvider = SingleAgentProviderCopy.fromJsonValue(
+ final selectedProvider = SingleAgentProviderCopy.fromJsonValue(
latestRecord?.executionBinding.providerId ?? '',
);
return DesktopThreadBindingSnapshotInternal(
executionTarget: resolvedExecutionTarget,
- singleAgentProvider: resolvedProvider,
+ selectedSingleAgentProvider: selectedProvider,
record: latestRecord,
);
}
@@ -254,7 +254,8 @@ extension AppControllerDesktopThreadBinding on AppController {
required SingleAgentProvider singleAgentProvider,
ExecutionBinding? existingBinding,
}) {
- final providerId = executionTarget == AssistantExecutionTarget.singleAgent
+ final selectedProviderId =
+ executionTarget == AssistantExecutionTarget.singleAgent
? settings
.sanitizeSingleAgentProviderSelection(singleAgentProvider)
.providerId
@@ -262,8 +263,8 @@ extension AppControllerDesktopThreadBinding on AppController {
return (existingBinding ??
ExecutionBinding(
executionMode: ThreadExecutionMode.localAgent,
- executorId: providerId,
- providerId: providerId,
+ executorId: selectedProviderId,
+ providerId: selectedProviderId,
endpointId: '',
))
.copyWith(
@@ -272,8 +273,8 @@ extension AppControllerDesktopThreadBinding on AppController {
ThreadExecutionMode.localAgent,
AssistantExecutionTarget.gateway => ThreadExecutionMode.gateway,
},
- executorId: providerId,
- providerId: providerId,
+ executorId: selectedProviderId,
+ providerId: selectedProviderId,
providerSource:
executionTarget == AssistantExecutionTarget.singleAgent
? existingBinding?.providerSource
@@ -309,9 +310,7 @@ extension AppControllerDesktopThreadBinding on AppController {
workspaceBinding: workspaceBinding,
executionBinding: buildDesktopExecutionBindingInternal(
executionTarget: snapshot.executionTarget,
- singleAgentProvider: settings.sanitizeSingleAgentProviderSelection(
- snapshot.singleAgentProvider,
- ),
+ singleAgentProvider: snapshot.selectedSingleAgentProvider,
existingBinding: snapshot.record?.executionBinding,
),
lifecycleState:
diff --git a/lib/app/app_controller_desktop_thread_sessions.dart b/lib/app/app_controller_desktop_thread_sessions.dart
index bd4d1b7e..c5b7bc89 100644
--- a/lib/app/app_controller_desktop_thread_sessions.dart
+++ b/lib/app/app_controller_desktop_thread_sessions.dart
@@ -246,15 +246,14 @@ extension AppControllerDesktopThreadSessions on AppController {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
- final stored = SingleAgentProviderCopy.fromJsonValue(
+ final selectedProvider = SingleAgentProviderCopy.fromJsonValue(
taskThreadForSessionInternal(
normalizedSessionKey,
)?.executionBinding.providerId ??
'',
);
- final sanitized = settings.sanitizeSingleAgentProviderSelection(stored);
- if (!sanitized.isUnspecified) {
- return sanitized;
+ if (!selectedProvider.isUnspecified) {
+ return selectedProvider;
}
final options = singleAgentProviderOptions;
return options.isEmpty ? SingleAgentProvider.unspecified : options.first;
@@ -269,14 +268,32 @@ extension AppControllerDesktopThreadSessions on AppController {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
- return advertisedSingleAgentProviderInternal(
- singleAgentProviderForSession(normalizedSessionKey),
- );
+ final record = taskThreadForSessionInternal(normalizedSessionKey);
+ final resolvedProviderId = record?.latestResolvedProviderId.trim() ?? '';
+ if (resolvedProviderId.isNotEmpty) {
+ return bridgeProviderForId(resolvedProviderId) ??
+ SingleAgentProviderCopy.fromJsonValue(resolvedProviderId);
+ }
+ return null;
}
SingleAgentProvider? get currentSingleAgentResolvedProvider =>
singleAgentResolvedProviderForSession(currentSessionKey);
+ SingleAgentProvider? singleAgentCatalogProviderForSession(String sessionKey) {
+ final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
+ sessionKey,
+ );
+ final selection = singleAgentProviderForSession(normalizedSessionKey);
+ if (selection.isUnspecified) {
+ return null;
+ }
+ return bridgeProviderForId(selection.providerId);
+ }
+
+ SingleAgentProvider? get currentSingleAgentCatalogProvider =>
+ singleAgentCatalogProviderForSession(currentSessionKey);
+
bool singleAgentNeedsBridgeProviderForSession(String sessionKey) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
@@ -285,19 +302,12 @@ extension AppControllerDesktopThreadSessions on AppController {
AssistantExecutionTarget.singleAgent) {
return false;
}
- return configuredSingleAgentProviders.isEmpty;
+ return bridgeProviderCatalog.isEmpty;
}
bool get currentSingleAgentNeedsBridgeProvider =>
singleAgentNeedsBridgeProviderForSession(currentSessionKey);
- bool singleAgentHasResolvedProviderForSession(String sessionKey) {
- return singleAgentResolvedProviderForSession(sessionKey) != null;
- }
-
- bool get currentSingleAgentHasResolvedProvider =>
- singleAgentHasResolvedProviderForSession(currentSessionKey);
-
bool singleAgentShouldSuggestAcpSwitchForSession(String sessionKey) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
@@ -310,8 +320,8 @@ extension AppControllerDesktopThreadSessions on AppController {
if (selection.isUnspecified) {
return false;
}
- return !isBridgeAdvertisedSingleAgentProviderInternal(selection) &&
- configuredSingleAgentProviders.isNotEmpty;
+ final selectedProvider = bridgeProviderForId(selection.providerId);
+ return selectedProvider == null && bridgeProviderCatalog.isNotEmpty;
}
bool get currentSingleAgentShouldSuggestAcpSwitch =>
@@ -346,6 +356,7 @@ extension AppControllerDesktopThreadSessions on AppController {
}
final provider =
singleAgentResolvedProviderForSession(normalizedSessionKey) ??
+ singleAgentCatalogProviderForSession(normalizedSessionKey) ??
singleAgentProviderForSession(normalizedSessionKey);
return appText(
'请先配置 ${provider.label} 模型',
@@ -371,11 +382,7 @@ extension AppControllerDesktopThreadSessions on AppController {
singleAgentShouldShowModelControlForSession(currentSessionKey);
List get singleAgentProviderOptions =>
- configuredSingleAgentProviders;
-
- String singleAgentProviderLabelForSession(String sessionKey) {
- return singleAgentProviderForSession(sessionKey).label;
- }
+ bridgeProviderCatalog;
String get assistantConversationOwnerLabel {
if (!isSingleAgentMode) {
@@ -385,6 +392,10 @@ extension AppControllerDesktopThreadSessions on AppController {
if (resolvedProvider != null) {
return resolvedProvider.label;
}
+ final catalogProvider = currentSingleAgentCatalogProvider;
+ if (catalogProvider != null) {
+ return catalogProvider.label;
+ }
final provider = currentSingleAgentProvider;
if (!provider.isUnspecified) {
return provider.label;
@@ -408,18 +419,20 @@ extension AppControllerDesktopThreadSessions on AppController {
final resolvedProvider = singleAgentResolvedProviderForSession(
normalizedSessionKey,
);
+ final catalogProvider = singleAgentCatalogProviderForSession(
+ normalizedSessionKey,
+ );
final model = assistantModelForSession(normalizedSessionKey);
- final providerReady = resolvedProvider != null;
+ final providerReady = catalogProvider != null;
+ final displayProvider = resolvedProvider ?? catalogProvider ?? provider;
final detail = providerReady
- ? joinConnectionPartsInternal([resolvedProvider.label, model])
+ ? joinConnectionPartsInternal([displayProvider.label, model])
: singleAgentShouldSuggestAcpSwitchForSession(normalizedSessionKey)
? appText(
'${provider.label} 当前不可用,请改成 Bridge 当前可用的 Provider。',
'${provider.label} is unavailable. Switch to a provider currently advertised by the bridge.',
)
- : singleAgentNeedsBridgeProviderForSession(
- normalizedSessionKey,
- )
+ : singleAgentNeedsBridgeProviderForSession(normalizedSessionKey)
? appText(
'Bridge 当前没有可用 Provider。',
'The bridge does not currently advertise any available providers.',
diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart
index 39ce1164..73235f25 100644
--- a/lib/app/app_controller_desktop_thread_storage.dart
+++ b/lib/app/app_controller_desktop_thread_storage.dart
@@ -693,10 +693,8 @@ extension AppControllerDesktopThreadStorage on AppController {
);
final recordProvider =
recordExecutionTarget == AssistantExecutionTarget.singleAgent
- ? settings.sanitizeSingleAgentProviderSelection(
- SingleAgentProviderCopy.fromJsonValue(
- record.executionBinding.providerId,
- ),
+ ? SingleAgentProviderCopy.fromJsonValue(
+ record.executionBinding.providerId,
)
: const SingleAgentProvider(
providerId: kCanonicalGatewayProviderId,
diff --git a/lib/app/app_controller_desktop_workspace_execution.dart b/lib/app/app_controller_desktop_workspace_execution.dart
index 53b8d9b1..4492689e 100644
--- a/lib/app/app_controller_desktop_workspace_execution.dart
+++ b/lib/app/app_controller_desktop_workspace_execution.dart
@@ -86,6 +86,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
executionTargetSource: ThreadSelectionSource.explicit,
gatewayEntryState: gatewayEntryStateForTargetInternal(resolvedTarget),
latestResolvedRuntimeModel: '',
+ latestResolvedProviderId: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
recomputeTasksInternal();
@@ -128,6 +129,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
singleAgentProvider: sanitizedProvider,
singleAgentProviderSource: ThreadSelectionSource.explicit,
latestResolvedRuntimeModel: '',
+ latestResolvedProviderId: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
recomputeTasksInternal();
@@ -196,6 +198,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
upsertTaskThreadInternal(
normalizedSessionKey,
latestResolvedRuntimeModel: '',
+ latestResolvedProviderId: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
}
diff --git a/lib/features/assistant/assistant_page_components.dart b/lib/features/assistant/assistant_page_components.dart
index 07775259..1f46e252 100644
--- a/lib/features/assistant/assistant_page_components.dart
+++ b/lib/features/assistant/assistant_page_components.dart
@@ -81,11 +81,12 @@ class AssistantTaskRailStateInternal extends State {
final tasks = widget.tasks;
final groupedTasks = groupTasksForRailInternal(
tasks,
- widget.controller
- .visibleAssistantExecutionTargets(const [
- AssistantExecutionTarget.singleAgent,
- AssistantExecutionTarget.gateway,
- ]),
+ widget.controller.visibleAssistantExecutionTargets(
+ const [
+ AssistantExecutionTarget.singleAgent,
+ AssistantExecutionTarget.gateway,
+ ],
+ ),
);
final runningCount = tasks
.where((task) => normalizedTaskStatusInternal(task.status) == 'running')
@@ -506,19 +507,22 @@ class AssistantEmptyStateInternal extends StatelessWidget {
controller.currentSingleAgentNeedsBridgeProvider;
final singleAgentSuggestsAcpSwitch =
controller.currentSingleAgentShouldSuggestAcpSwitch;
- final providerLabel = controller.currentSingleAgentProvider.label;
+ final providerLabel =
+ (controller.currentSingleAgentResolvedProvider ??
+ controller.currentSingleAgentProvider)
+ .label;
final reconnectAvailable = controller.canQuickConnectGateway;
final title = singleAgent
? connected
? appText('开始智能体任务', 'Start an agent task')
: singleAgentNeedsBridgeProvider
? appText(
- '先配置 Bridge Provider',
- 'Configure a bridge provider first',
+ '等待 Bridge Provider',
+ 'Waiting for a bridge provider',
)
: appText(
- '先准备 Bridge Provider',
- 'Prepare the bridge provider first',
+ '等待 Bridge 就绪',
+ 'Waiting for bridge readiness',
)
: connected
? appText('开始对话或运行任务', 'Start a chat or run a task')
@@ -538,12 +542,12 @@ class AssistantEmptyStateInternal extends StatelessWidget {
)
: singleAgentNeedsBridgeProvider
? appText(
- '请先在 设置 -> 集成 中配置并同步可用的外部 Agent 连接,然后再继续当前任务。',
- 'Configure and sync an available external agent connection in Settings -> Integrations before continuing this task.',
+ 'Bridge 当前没有广告可用 Provider。恢复后可直接开始任务;当前流程不依赖本地集成配置。',
+ 'The bridge is not advertising any available providers right now. Once it recovers, this thread can start directly without extra local integration setup.',
)
: appText(
- '当前线程的 Bridge Provider 尚未就绪。请先检查 $providerLabel 对应连接。',
- 'The bridge provider for this thread is not ready yet. Check the connection mapped to $providerLabel first.',
+ '当前线程的 Bridge Provider 尚未就绪。请等待 Bridge 恢复,或切换到当前可用 Provider。',
+ 'The bridge provider for this thread is not ready yet. Wait for the bridge to recover, or switch to a currently available provider.',
)
: connected
? appText(
@@ -602,9 +606,7 @@ class AssistantEmptyStateInternal extends StatelessWidget {
onPressed: connected
? onFocusComposer
: singleAgent
- ? singleAgentNeedsBridgeProvider
- ? onOpenAiGatewaySettings
- : onFocusComposer
+ ? onFocusComposer
: reconnectAvailable
? () async {
await onReconnectGateway();
@@ -614,9 +616,7 @@ class AssistantEmptyStateInternal extends StatelessWidget {
connected
? Icons.edit_rounded
: singleAgent
- ? singleAgentNeedsBridgeProvider
- ? Icons.tune_rounded
- : Icons.smart_toy_outlined
+ ? Icons.smart_toy_outlined
: reconnectAvailable
? Icons.refresh_rounded
: Icons.link_rounded,
@@ -625,9 +625,7 @@ class AssistantEmptyStateInternal extends StatelessWidget {
connected
? appText('开始输入', 'Start typing')
: singleAgent
- ? singleAgentNeedsBridgeProvider
- ? appText('打开配置中心', 'Open settings')
- : appText('查看线程工具栏', 'Open toolbar')
+ ? appText('查看线程工具栏', 'Open toolbar')
: reconnectAvailable
? appText('重新连接', 'Reconnect')
: appText('连接 Gateway', 'Connect gateway'),
@@ -643,8 +641,7 @@ class AssistantEmptyStateInternal extends StatelessWidget {
),
),
),
- if (!connected &&
- (!singleAgent || singleAgentNeedsBridgeProvider))
+ if (!connected && !singleAgent)
OutlinedButton.icon(
onPressed: singleAgent
? onOpenAiGatewaySettings
diff --git a/lib/features/assistant/assistant_page_composer_bar.dart b/lib/features/assistant/assistant_page_composer_bar.dart
index 5aa4e37f..d51558c4 100644
--- a/lib/features/assistant/assistant_page_composer_bar.dart
+++ b/lib/features/assistant/assistant_page_composer_bar.dart
@@ -374,6 +374,9 @@ class ComposerBarStateInternal extends State {
final selectedSkills = widget.availableSkills
.where((skill) => widget.selectedSkillKeys.contains(skill.key))
.toList(growable: false);
+ final displayedSingleAgentProvider =
+ controller.currentSingleAgentResolvedProvider ??
+ controller.currentSingleAgentProvider;
final submitLabel = connected
? appText('提交', 'Submit')
: singleAgent
@@ -500,10 +503,10 @@ class ComposerBarStateInternal extends State {
.toList(),
child: ComposerToolbarChipInternal(
leading: SingleAgentProviderBadgeInternal(
- provider: controller.currentSingleAgentProvider,
+ provider: displayedSingleAgentProvider,
),
tooltip: singleAgentProviderTooltipInternal(
- controller.currentSingleAgentProvider,
+ displayedSingleAgentProvider,
),
showChevron: true,
padding: const EdgeInsets.symmetric(
diff --git a/lib/features/modules/modules_page.dart b/lib/features/modules/modules_page.dart
index 8e1dd4fb..892da95c 100644
--- a/lib/features/modules/modules_page.dart
+++ b/lib/features/modules/modules_page.dart
@@ -663,7 +663,7 @@ class _SkillsPanel extends StatelessWidget {
? StatusInfo(appText('当前模式', 'Current mode'), StatusTone.accent)
: StatusInfo(appText('可切换', 'Available'), StatusTone.success),
chips: [
- for (final provider in controller.configuredSingleAgentProviders)
+ for (final provider in controller.bridgeProviderCatalog)
provider.label,
],
skills: singleAgentSkills.map((item) => item.name).toList(),
diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart
index 6285a00f..9c04d925 100644
--- a/lib/runtime/runtime_models_runtime_payloads.dart
+++ b/lib/runtime/runtime_models_runtime_payloads.dart
@@ -753,6 +753,7 @@ class ThreadContextState {
required this.permissionLevel,
required this.messageViewMode,
required this.latestResolvedRuntimeModel,
+ required this.latestResolvedProviderId,
this.selectedModelSource = ThreadSelectionSource.inherited,
this.selectedSkillsSource = ThreadSelectionSource.inherited,
this.gatewayEntryState,
@@ -769,6 +770,7 @@ class ThreadContextState {
final AssistantPermissionLevel permissionLevel;
final AssistantMessageViewMode messageViewMode;
final String latestResolvedRuntimeModel;
+ final String latestResolvedProviderId;
final ThreadSelectionSource selectedModelSource;
final ThreadSelectionSource selectedSkillsSource;
final String? gatewayEntryState;
@@ -785,6 +787,7 @@ class ThreadContextState {
AssistantPermissionLevel? permissionLevel,
AssistantMessageViewMode? messageViewMode,
String? latestResolvedRuntimeModel,
+ String? latestResolvedProviderId,
ThreadSelectionSource? selectedModelSource,
ThreadSelectionSource? selectedSkillsSource,
String? gatewayEntryState,
@@ -803,6 +806,8 @@ class ThreadContextState {
messageViewMode: messageViewMode ?? this.messageViewMode,
latestResolvedRuntimeModel:
latestResolvedRuntimeModel ?? this.latestResolvedRuntimeModel,
+ latestResolvedProviderId:
+ latestResolvedProviderId ?? this.latestResolvedProviderId,
selectedModelSource: selectedModelSource ?? this.selectedModelSource,
selectedSkillsSource: selectedSkillsSource ?? this.selectedSkillsSource,
gatewayEntryState: clearGatewayEntryState
@@ -829,6 +834,7 @@ class ThreadContextState {
'permissionLevel': permissionLevel.name,
'messageViewMode': messageViewMode.name,
'latestResolvedRuntimeModel': latestResolvedRuntimeModel,
+ 'latestResolvedProviderId': latestResolvedProviderId,
'selectedModelSource': selectedModelSource.name,
'selectedSkillsSource': selectedSkillsSource.name,
'gatewayEntryState': gatewayEntryState,
@@ -890,6 +896,8 @@ class ThreadContextState {
),
latestResolvedRuntimeModel:
json['latestResolvedRuntimeModel']?.toString() ?? '',
+ latestResolvedProviderId:
+ json['latestResolvedProviderId']?.toString() ?? '',
selectedModelSource: ThreadSelectionSourceCopy.fromJsonValue(
json['selectedModelSource']?.toString(),
),
@@ -989,6 +997,7 @@ class TaskThread {
String? gatewayEntryState,
AssistantPermissionLevel? permissionLevel,
String? latestResolvedRuntimeModel,
+ String? latestResolvedProviderId,
double? lastRunAtMs,
String? lastResultCode,
String? lastRemoteWorkingDirectory,
@@ -1028,6 +1037,7 @@ class TaskThread {
messageViewMode ?? AssistantMessageViewMode.rendered,
latestResolvedRuntimeModel:
latestResolvedRuntimeModel?.trim() ?? '',
+ latestResolvedProviderId: latestResolvedProviderId?.trim() ?? '',
gatewayEntryState: gatewayEntryState?.trim(),
lastRemoteWorkingDirectory:
lastRemoteWorkingDirectory?.trim().isNotEmpty == true
@@ -1079,6 +1089,7 @@ class TaskThread {
String? get lastArtifactSyncStatus => contextState.lastArtifactSyncStatus;
String get latestResolvedRuntimeModel =>
contextState.latestResolvedRuntimeModel;
+ String get latestResolvedProviderId => contextState.latestResolvedProviderId;
bool get hasExplicitExecutionTargetSelection =>
executionBinding.executionModeSource == ThreadSelectionSource.explicit;
bool get hasExplicitProviderSelection =>
@@ -1113,6 +1124,7 @@ class TaskThread {
String? gatewayEntryState,
bool clearGatewayEntryState = false,
String? latestResolvedRuntimeModel,
+ String? latestResolvedProviderId,
String? lastRemoteWorkingDirectory,
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
double? lastArtifactSyncAtMs,
@@ -1133,6 +1145,7 @@ class TaskThread {
selectedModelSource: assistantModelSource,
selectedSkillsSource: selectedSkillsSource,
latestResolvedRuntimeModel: latestResolvedRuntimeModel,
+ latestResolvedProviderId: latestResolvedProviderId,
gatewayEntryState: gatewayEntryState,
clearGatewayEntryState: clearGatewayEntryState,
lastRemoteWorkingDirectory: lastRemoteWorkingDirectory,
@@ -1247,6 +1260,7 @@ class TaskThread {
'permissionLevel': json['permissionLevel'],
'messageViewMode': json['messageViewMode'],
'latestResolvedRuntimeModel': json['latestResolvedRuntimeModel'],
+ 'latestResolvedProviderId': json['latestResolvedProviderId'],
'selectedModelSource': json['assistantModelSource'],
'selectedSkillsSource': json['selectedSkillsSource'],
'gatewayEntryState': json['gatewayEntryState'],
diff --git a/lib/runtime/runtime_models_settings_snapshot.dart b/lib/runtime/runtime_models_settings_snapshot.dart
index 59b996e1..beadb522 100644
--- a/lib/runtime/runtime_models_settings_snapshot.dart
+++ b/lib/runtime/runtime_models_settings_snapshot.dart
@@ -392,12 +392,6 @@ class SettingsSnapshot {
SingleAgentProvider sanitizeSingleAgentProviderSelection(
SingleAgentProvider provider,
) {
- if (provider.isUnspecified) {
- return SingleAgentProvider.unspecified;
- }
- if (isBridgeOwnedSingleAgentProviderId(provider.providerId)) {
- return provider;
- }
- return SingleAgentProvider.unspecified;
+ return provider.isUnspecified ? SingleAgentProvider.unspecified : provider;
}
}
diff --git a/pubspec.lock b/pubspec.lock
index d65384b9..3b8f4dd8 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -287,14 +287,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
- golden_toolkit:
- dependency: "direct dev"
- description:
- name: golden_toolkit
- sha256: "8f74adab33154fe7b731395782797021f97d2edc52f7bfb85ff4f1b5c4a215f0"
- url: "https://pub.dev"
- source: hosted
- version: "0.15.0"
hooks:
dependency: transitive
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index ce476c54..8f20b8a6 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -37,7 +37,6 @@ dev_dependencies:
sdk: flutter
integration_test:
sdk: flutter
- golden_toolkit: ^0.15.0
patrol: ^4.3.0
flutter_lints: ^6.0.0
diff --git a/test/app_controller_desktop_runtime_cleanup_test.dart b/test/app_controller_desktop_runtime_cleanup_test.dart
index 0bed7363..4ec0cf6a 100644
--- a/test/app_controller_desktop_runtime_cleanup_test.dart
+++ b/test/app_controller_desktop_runtime_cleanup_test.dart
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller_desktop_core.dart';
import 'package:xworkmate/app/app_controller_desktop_skill_permissions.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart';
+import 'package:xworkmate/app/app_controller_desktop_thread_storage.dart';
import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart';
import 'package:xworkmate/runtime/desktop_platform_service.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
@@ -62,8 +63,9 @@ void main() {
);
}
- final settingsSnapshot = File('lib/runtime/runtime_models_settings_snapshot.dart')
- .readAsStringSync();
+ final settingsSnapshot = File(
+ 'lib/runtime/runtime_models_settings_snapshot.dart',
+ ).readAsStringSync();
expect(
settingsSnapshot.contains('providerSyncDefinitions'),
isFalse,
@@ -76,8 +78,9 @@ void main() {
reason: 'settings snapshots should not persist app-side Codex CLI paths',
);
- final accountModels = File('lib/runtime/runtime_models_account.dart')
- .readAsStringSync();
+ final accountModels = File(
+ 'lib/runtime/runtime_models_account.dart',
+ ).readAsStringSync();
expect(
accountModels.contains('acpBridgeServerProfiles'),
isFalse,
@@ -85,8 +88,9 @@ void main() {
'account advanced overrides should not mirror bridge provider catalogs',
);
- final orchestrator = File('lib/runtime/code_agent_node_orchestrator.dart')
- .readAsStringSync();
+ final orchestrator = File(
+ 'lib/runtime/code_agent_node_orchestrator.dart',
+ ).readAsStringSync();
expect(
orchestrator.contains('configuredCodexCliPath'),
isFalse,
@@ -287,13 +291,131 @@ void main() {
expect(thread!.hasExplicitProviderSelection, isFalse);
},
);
+
+ group('thread restore provider semantics', () {
+ const owner = ThreadOwnerScope(
+ realm: ThreadRealm.local,
+ subjectType: ThreadSubjectType.user,
+ subjectId: 'u1',
+ displayName: 'User',
+ );
+
+ TaskThread buildThread({
+ required String threadId,
+ required ThreadExecutionMode mode,
+ required String providerId,
+ String latestResolvedProviderId = '',
+ }) {
+ return TaskThread(
+ threadId: threadId,
+ ownerScope: owner,
+ 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: '',
+ ),
+ latestResolvedProviderId: latestResolvedProviderId,
+ );
+ }
+
+ test(
+ 'restore preserves the stored single-agent provider selection without inventing a resolved provider',
+ () {
+ final controller = AppController();
+ _seedBridgeProviders(controller, const [
+ SingleAgentProvider.codex,
+ ]);
+ addTearDown(controller.dispose);
+
+ const sessionKey = 'draft:restore-selection';
+ controller.restoreAssistantThreadsInternal([
+ buildThread(
+ threadId: sessionKey,
+ mode: ThreadExecutionMode.localAgent,
+ providerId: 'legacy-provider',
+ ),
+ ]);
+
+ final restored = controller.requireTaskThreadForSessionInternal(
+ sessionKey,
+ );
+ expect(restored.executionBinding.providerId, 'legacy-provider');
+ expect(
+ controller.singleAgentProviderForSession(sessionKey).providerId,
+ 'legacy-provider',
+ );
+ expect(
+ controller.singleAgentResolvedProviderForSession(sessionKey),
+ isNull,
+ );
+ },
+ );
+
+ test(
+ 'restore continues to treat latestResolvedProviderId as the only resolved provider source',
+ () {
+ final controller = AppController();
+ _seedBridgeProviders(controller, const [
+ SingleAgentProvider.codex,
+ ]);
+ addTearDown(controller.dispose);
+
+ const sessionKey = 'draft:restore-resolved-provider';
+ controller.restoreAssistantThreadsInternal([
+ buildThread(
+ threadId: sessionKey,
+ mode: ThreadExecutionMode.localAgent,
+ providerId: 'legacy-provider',
+ latestResolvedProviderId: SingleAgentProvider.codex.providerId,
+ ),
+ ]);
+
+ expect(
+ controller.singleAgentProviderForSession(sessionKey).providerId,
+ 'legacy-provider',
+ );
+ expect(
+ controller.singleAgentResolvedProviderForSession(sessionKey),
+ SingleAgentProvider.codex,
+ );
+ },
+ );
+
+ test('restore still canonicalizes gateway provider bindings', () {
+ final controller = AppController();
+ addTearDown(controller.dispose);
+
+ const sessionKey = 'draft:restore-gateway';
+ controller.restoreAssistantThreadsInternal([
+ buildThread(
+ threadId: sessionKey,
+ mode: ThreadExecutionMode.gateway,
+ providerId: 'legacy-provider',
+ ),
+ ]);
+
+ final restored = controller.requireTaskThreadForSessionInternal(
+ sessionKey,
+ );
+ expect(restored.executionBinding.providerId, kCanonicalGatewayProviderId);
+ expect(restored.executionBinding.executorId, kCanonicalGatewayProviderId);
+ });
+ });
}
void _seedBridgeProviders(
AppController controller,
List providers,
) {
- controller.bridgeAdvertisedProvidersInternal = providers;
+ controller.bridgeProviderCatalogInternal = providers;
}
class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService {
diff --git a/test/app_controller_desktop_thread_binding_test.dart b/test/app_controller_desktop_thread_binding_test.dart
index b468b2fc..b889e886 100644
--- a/test/app_controller_desktop_thread_binding_test.dart
+++ b/test/app_controller_desktop_thread_binding_test.dart
@@ -8,14 +8,12 @@ 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';
@@ -34,6 +32,7 @@ void main() {
required String threadId,
required ThreadExecutionMode mode,
required String providerId,
+ String latestResolvedProviderId = '',
}) {
return TaskThread(
threadId: threadId,
@@ -51,6 +50,7 @@ void main() {
providerId: providerId,
endpointId: '',
),
+ latestResolvedProviderId: latestResolvedProviderId,
);
}
@@ -67,7 +67,10 @@ void main() {
);
expect(snapshot.executionTarget, AssistantExecutionTarget.singleAgent);
- expect(snapshot.singleAgentProvider, SingleAgentProvider.opencode);
+ expect(
+ snapshot.selectedSingleAgentProvider,
+ SingleAgentProvider.opencode,
+ );
expect(snapshot.record, same(latestRecord));
});
@@ -87,7 +90,37 @@ void main() {
);
expect(snapshot.executionTarget, AssistantExecutionTarget.gateway);
- expect(snapshot.singleAgentProvider, SingleAgentProvider.opencode);
+ expect(
+ snapshot.selectedSingleAgentProvider,
+ SingleAgentProvider.opencode,
+ );
+ },
+ );
+
+ test(
+ 'keeps the stored provider selection separate from resolved provider',
+ () {
+ final latestRecord = buildThread(
+ threadId: 'thread-2b',
+ mode: ThreadExecutionMode.localAgent,
+ providerId: SingleAgentProvider.opencode.providerId,
+ latestResolvedProviderId: SingleAgentProvider.codex.providerId,
+ );
+
+ final snapshot = resolveDesktopThreadBindingSnapshotInternal(
+ defaultExecutionTarget: AssistantExecutionTarget.gateway,
+ latestRecord: latestRecord,
+ );
+
+ expect(snapshot.executionTarget, AssistantExecutionTarget.singleAgent);
+ expect(
+ snapshot.selectedSingleAgentProvider,
+ SingleAgentProvider.opencode,
+ );
+ expect(
+ latestRecord.latestResolvedProviderId,
+ SingleAgentProvider.codex.providerId,
+ );
},
);
@@ -104,7 +137,7 @@ void main() {
);
expect(snapshot.executionTarget, AssistantExecutionTarget.gateway);
- expect(snapshot.singleAgentProvider.isUnspecified, isTrue);
+ expect(snapshot.selectedSingleAgentProvider.isUnspecified, isTrue);
expect(snapshot.record, isNull);
expect(staleRecord.executionBinding.providerId, isNotEmpty);
});
@@ -228,6 +261,45 @@ void main() {
expect(state.ready, isTrue);
},
);
+
+ test(
+ 'treats an advertised bridge catalog provider as ready before the first resolved turn',
+ () {
+ final controller = AppController();
+ addTearDown(controller.dispose);
+
+ const sessionKey = 'draft:single-agent-ready-from-catalog';
+ controller.initializeAssistantThreadContext(
+ sessionKey,
+ executionTarget: AssistantExecutionTarget.singleAgent,
+ singleAgentProvider: SingleAgentProvider.codex,
+ );
+ controller.bridgeProviderCatalogInternal = const [
+ SingleAgentProvider.codex,
+ ];
+ controller.upsertTaskThreadInternal(
+ sessionKey,
+ executionTarget: AssistantExecutionTarget.singleAgent,
+ executionTargetSource: ThreadSelectionSource.explicit,
+ singleAgentProvider: SingleAgentProvider.codex,
+ singleAgentProviderSource: ThreadSelectionSource.explicit,
+ );
+
+ expect(
+ controller.singleAgentResolvedProviderForSession(sessionKey),
+ isNull,
+ );
+ expect(
+ controller.singleAgentCatalogProviderForSession(sessionKey),
+ SingleAgentProvider.codex,
+ );
+
+ final state = controller.assistantConnectionStateForSession(sessionKey);
+ expect(state.status, RuntimeConnectionStatus.connected);
+ expect(state.ready, isTrue);
+ expect(state.detailLabel, contains('Codex'));
+ },
+ );
});
group('buildExternalAcpRoutingForSessionInternal', () {
@@ -333,17 +405,13 @@ void main() {
});
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,
+ test('prefers BRIDGE_SERVER_URL from environment over local settings', () {
+ final controller = AppController(
+ environmentOverride: const {
+ 'BRIDGE_SERVER_URL': 'https://bridge.env.example/acp',
+ },
);
+ addTearDown(controller.dispose);
controller.settingsController.snapshotInternal = controller.settings
.copyWith(
@@ -367,24 +435,51 @@ void main() {
expect(
controller.resolveBridgeAcpEndpointInternal(),
- Uri.parse('https://bridge.customer.example/acp'),
+ Uri.parse('https://bridge.env.example/acp'),
);
expect(
controller.resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget.singleAgent,
),
- Uri.parse('https://bridge.customer.example/acp'),
+ Uri.parse('https://bridge.env.example/acp'),
);
expect(
controller.resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget.gateway,
),
- Uri.parse('https://bridge.customer.example/acp'),
+ Uri.parse('https://bridge.env.example/acp'),
);
});
+ test('does not recover bridge endpoint from local settings snapshot alone', () {
+ final controller = AppController();
+ addTearDown(controller.dispose);
+
+ 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(), isNull);
+ });
+
test(
- 'prefers the synced bridge bearer token over the account session token',
+ 'prefers environment bridge bearer tokens over persisted bridge secrets',
() async {
final root = await Directory.systemTemp.createTemp(
'xworkmate-bridge-auth-header-',
@@ -397,7 +492,11 @@ void main() {
);
final controller = AppController(
store: store,
- accountClientFactory: (_) => _BridgeSyncAccountRuntimeClient(),
+ environmentOverride: const {
+ 'BRIDGE_SERVER_URL': 'https://xworkmate-bridge.svc.plus/acp',
+ 'BRIDGE_AUTH_TOKEN': 'env-bridge-token',
+ 'INTERNAL_SERVICE_TOKEN': 'env-internal-token',
+ },
);
addTearDown(() async {
controller.dispose();
@@ -411,22 +510,9 @@ void main() {
});
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: '',
+ await store.saveAccountManagedSecret(
+ target: kAccountManagedSecretTargetBridgeAuthToken,
+ value: 'persisted-bridge-token',
);
final bridgeAuthorization = await controller
@@ -438,7 +524,7 @@ void main() {
Uri.parse('https://remote.example.com/acp'),
);
- expect(bridgeAuthorization, 'Bearer bridge-token');
+ expect(bridgeAuthorization, 'Bearer env-bridge-token');
expect(nonBridgeAuthorization, isNull);
},
);
@@ -517,28 +603,3 @@ class _FakeGatewayRuntimeDeps {
final SecureConfigStore store;
final DeviceIdentityStore identityStore;
}
-
-class _BridgeSyncAccountRuntimeClient extends AccountRuntimeClient {
- _BridgeSyncAccountRuntimeClient()
- : super(baseUrl: 'https://accounts.svc.plus');
-
- @override
- Future