Refactor bridge provider readiness and trim stale tests

This commit is contained in:
Haitao Pan 2026-04-12 15:42:15 +08:00
parent 9e80740378
commit 53d411fb9e
32 changed files with 542 additions and 905 deletions

View File

@ -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.

View File

@ -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 唯一数据源"]

View File

@ -93,7 +93,7 @@ flowchart TD
subgraph APPSTATE["App-side truth sources"]
D["refreshSingleAgentCapabilitiesRuntimeInternal()"]
E["bridgeAdvertisedProvidersInternal<br/>App 内唯一 provider 名单源"]
E["bridgeProviderCatalogInternal<br/>App 内唯一 provider 名单源"]
F["singleAgentCapabilitiesByProviderInternal<br/>App 内唯一 provider 可用性源"]
G["refreshAcpCapabilitiesRuntimeInternal()"]
H["GatewayAcpCapabilities"]
@ -105,7 +105,7 @@ flowchart TD
end
subgraph UISTATE["UI affordances"]
K["configuredSingleAgentProviders<br/>Composer / Thread Picker provider source"]
K["bridgeProviderCatalog<br/>Composer / Thread Picker provider source"]
L["availableSingleAgentProviders<br/>agent path visibility"]
M["visible gateway affordances<br/>只看 bridge capabilities / discovery"]
E --> K

View File

@ -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 基线
- 更贴近真实交互的桌面集成回归

View File

@ -122,6 +122,7 @@ class AppController extends ChangeNotifier {
UiFeatureManifest? uiFeatureManifest,
SkillDirectoryAccessService? skillDirectoryAccessService,
AccountRuntimeClient Function(String baseUrl)? accountClientFactory,
Map<String, String>? environmentOverride,
List<String>? 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<String, String>.unmodifiable(environmentOverride);
gatewayAcpClientInternal = GatewayAcpClient(
endpointResolver: resolveGatewayAcpEndpointInternal,
authorizationResolver: resolveGatewayAcpAuthorizationHeaderInternal,
@ -293,7 +297,7 @@ class AppController extends ChangeNotifier {
GatewayAcpClient get gatewayAcpClientForTest => gatewayAcpClientInternal;
List<SingleAgentProvider> bridgeAdvertisedProvidersInternal =
List<SingleAgentProvider> bridgeProviderCatalogInternal =
const <SingleAgentProvider>[];
final Map<String, List<GatewayChatMessage>> assistantThreadMessagesInternal =
<String, List<GatewayChatMessage>>{};
@ -350,6 +354,7 @@ class AppController extends ChangeNotifier {
StreamSubscription<GatewayPushEvent>? runtimeEventsSubscriptionInternal;
bool disposedInternal = false;
String resolvedUserHomeDirectoryInternal = resolveUserHomeDirectory();
Map<String, String>? environmentOverrideInternal;
SettingsSnapshot lastObservedSettingsSnapshotInternal =
SettingsSnapshot.defaults();
Future<void> assistantThreadPersistQueueInternal = Future<void>.value();
@ -570,10 +575,21 @@ class AppController extends ChangeNotifier {
profileIndex,
);
List<SingleAgentProvider> get configuredSingleAgentProviders =>
normalizeBridgeOwnedSingleAgentProviderList(
bridgeAdvertisedProvidersInternal,
);
List<SingleAgentProvider> 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<AssistantExecutionTarget> visibleAssistantExecutionTargets(
Iterable<AssistantExecutionTarget> supportedTargets,
@ -581,7 +597,7 @@ class AppController extends ChangeNotifier {
final supported = supportedTargets.toSet();
final visible = <AssistantExecutionTarget>[];
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<String> get aiGatewayConversationModelChoices {
final availableModels =
settingsControllerInternal.effectiveAiGatewayAvailableModels;

View File

@ -97,8 +97,9 @@ Future<void> refreshSingleAgentCapabilitiesRuntimeInternal(
target: AssistantExecutionTarget.singleAgent,
forceRefresh: forceRefresh,
);
controller.bridgeAdvertisedProvidersInternal =
normalizeSingleAgentProviderList(capabilities.providerCatalog);
controller.bridgeProviderCatalogInternal = normalizeSingleAgentProviderList(
capabilities.providerCatalog,
);
if (!controller.disposedInternal) {
controller.notifyListeners();
}

View File

@ -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<void> 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;

View File

@ -845,6 +845,7 @@ extension AppControllerDesktopSettingsRuntime on AppController {
executionTargetSource: ThreadSelectionSource.explicit,
gatewayEntryState: gatewayEntryStateForTargetInternal(target),
latestResolvedRuntimeModel: '',
latestResolvedProviderId: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
recomputeTasksInternal();

View File

@ -105,15 +105,18 @@ Future<void> 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<void> _applySingleAgentGoTaskResultDesktopInternal(
sessionKey,
gatewayEntryState: resolvedGatewayEntryState,
latestResolvedRuntimeModel: resolvedRuntimeModel,
latestResolvedProviderId: result.resolvedProviderId,
lifecycleStatus: 'ready',
lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
lastResultCode: result.success ? 'success' : 'error',

View File

@ -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,
)) {

View File

@ -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,

View File

@ -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:

View File

@ -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<SingleAgentProvider> 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(<String>[resolvedProvider.label, model])
? joinConnectionPartsInternal(<String>[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.',

View File

@ -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,

View File

@ -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(),
);
}

View File

@ -81,11 +81,12 @@ class AssistantTaskRailStateInternal extends State<AssistantTaskRailInternal> {
final tasks = widget.tasks;
final groupedTasks = groupTasksForRailInternal(
tasks,
widget.controller
.visibleAssistantExecutionTargets(const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.gateway,
]),
widget.controller.visibleAssistantExecutionTargets(
const <AssistantExecutionTarget>[
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

View File

@ -374,6 +374,9 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
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<ComposerBarInternal> {
.toList(),
child: ComposerToolbarChipInternal(
leading: SingleAgentProviderBadgeInternal(
provider: controller.currentSingleAgentProvider,
provider: displayedSingleAgentProvider,
),
tooltip: singleAgentProviderTooltipInternal(
controller.currentSingleAgentProvider,
displayedSingleAgentProvider,
),
showChevron: true,
padding: const EdgeInsets.symmetric(

View File

@ -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(),

View File

@ -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'],

View File

@ -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;
}
}

View File

@ -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:

View File

@ -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

View File

@ -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>[
SingleAgentProvider.codex,
]);
addTearDown(controller.dispose);
const sessionKey = 'draft:restore-selection';
controller.restoreAssistantThreadsInternal(<TaskThread>[
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>[
SingleAgentProvider.codex,
]);
addTearDown(controller.dispose);
const sessionKey = 'draft:restore-resolved-provider';
controller.restoreAssistantThreadsInternal(<TaskThread>[
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(<TaskThread>[
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<SingleAgentProvider> providers,
) {
controller.bridgeAdvertisedProvidersInternal = providers;
controller.bridgeProviderCatalogInternal = providers;
}
class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService {

View File

@ -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>[
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 <String, String>{
'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 <String, String>{
'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<Map<String, dynamic>> login({
required String identifier,
required String password,
}) async {
return <String, dynamic>{
'token': 'session-token',
'internalServiceToken': 'bridge-token',
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge.svc.plus',
'expiresAt': '2026-04-12T00:00:00Z',
'user': <String, dynamic>{
'id': 'u-1',
'email': identifier,
'name': 'Review',
'role': 'member',
'mfaEnabled': false,
},
};
}
}

View File

@ -216,7 +216,7 @@ void _seedBridgeProviders(
AppController controller,
List<SingleAgentProvider> providers,
) {
controller.bridgeAdvertisedProvidersInternal = providers;
controller.bridgeProviderCatalogInternal = providers;
}
class _CapturingGoTaskServiceClient implements GoTaskServiceClient {

View File

@ -7,6 +7,7 @@ import 'package:xworkmate/app/app_controller_desktop_workspace_execution.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/features/assistant/assistant_page_components.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';
@ -237,13 +238,72 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
});
testWidgets(
'single-agent empty state no longer routes users to Settings -> Integrations',
(tester) async {
final root = Directory.systemTemp.createTempSync(
'xworkmate-empty-state-widget-test-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
appDataRootPathResolver: () async => '${root.path}/settings.sqlite3',
secretRootPathResolver: () async => root.path,
supportRootPathResolver: () async => root.path,
);
final controller = AppController(
store: store,
desktopPlatformService: UnsupportedDesktopPlatformService(),
skillDirectoryAccessService: _FakeSkillDirectoryAccessService(
root.path,
),
goTaskServiceClient: const _FakeGoTaskServiceClient(),
singleAgentSharedSkillScanRootOverrides: const <String>[],
);
addTearDown(() async {
controller.dispose();
if (root.existsSync()) {
await root.delete(recursive: true);
}
});
controller.initializeAssistantThreadContext(
controller.currentSessionKey,
executionTarget: AssistantExecutionTarget.singleAgent,
singleAgentProvider: SingleAgentProvider.codex,
);
controller.bridgeProviderCatalogInternal = const <SingleAgentProvider>[];
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(platform: TargetPlatform.macOS),
home: Scaffold(
body: AssistantEmptyStateInternal(
controller: controller,
onFocusComposer: () {},
onOpenGateway: () {},
onOpenAiGatewaySettings: () {},
onReconnectGateway: () async {},
),
),
),
);
await tester.pump();
expect(find.textContaining('设置 -> 集成'), findsNothing);
expect(find.textContaining('本地集成配置'), findsOneWidget);
expect(find.text('打开配置中心'), findsNothing);
expect(find.text('打开设置中心'), findsNothing);
expect(find.text('查看线程工具栏'), findsOneWidget);
},
);
}
void _seedBridgeProviders(
AppController controller,
List<SingleAgentProvider> providers,
) {
controller.bridgeAdvertisedProvidersInternal = providers;
controller.bridgeProviderCatalogInternal = providers;
}
class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService {

View File

@ -1,232 +0,0 @@
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('renders composer with thread provider controls only', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1400, 320));
addTearDown(() async => tester.binding.setSurfaceSize(null));
final root = Directory.systemTemp.createTempSync(
'xworkmate-composer-golden-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
appDataRootPathResolver: () async => '${root.path}/settings.sqlite3',
secretRootPathResolver: () async => root.path,
supportRootPathResolver: () async => root.path,
);
final controller = AppController(
store: store,
desktopPlatformService: UnsupportedDesktopPlatformService(),
skillDirectoryAccessService: _GoldenSkillDirectoryAccessService(
root.path,
),
goTaskServiceClient: const _GoldenGoTaskServiceClient(),
singleAgentSharedSkillScanRootOverrides: const <String>[],
);
_seedBridgeProviders(controller, const <SingleAgentProvider>[
SingleAgentProvider.codex,
]);
final inputController = TextEditingController(text: '请整理今天的任务进展');
final focusNode = FocusNode();
addTearDown(() async {
controller.dispose();
inputController.dispose();
focusNode.dispose();
if (root.existsSync()) {
await root.delete(recursive: true);
}
});
controller.appUiStateInternal = controller.appUiState.copyWith(
savedGatewayTargets: const <String>['gateway'],
);
controller.lastObservedSettingsSnapshotInternal =
controller.settingsController.snapshotInternal;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(platform: TargetPlatform.macOS),
home: Scaffold(
body: Center(
child: RepaintBoundary(
key: const ValueKey('assistant-composer-boundary'),
child: SizedBox(
width: 1280,
child: 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(const Duration(milliseconds: 300));
await expectLater(
find.byKey(const ValueKey('assistant-composer-boundary')),
matchesGoldenFile(
'goldens/assistant_page_composer_working_directory.png',
),
);
});
}
void _seedBridgeProviders(
AppController controller,
List<SingleAgentProvider> providers,
) {
controller.bridgeAdvertisedProvidersInternal = providers;
}
class _GoldenSkillDirectoryAccessService
implements SkillDirectoryAccessService {
const _GoldenSkillDirectoryAccessService(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 _GoldenGoTaskServiceClient implements GoTaskServiceClient {
const _GoldenGoTaskServiceClient();
@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<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
required AssistantExecutionTarget target,
bool forceRefresh = false,
}) async {
return const ExternalCodeAgentAcpCapabilities(
singleAgent: true,
multiAgent: true,
providerCatalog: <SingleAgentProvider>[SingleAgentProvider.codex],
gatewayProviders: <Map<String, dynamic>>[],
raw: <String, dynamic>{},
);
}
@override
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
required String taskPrompt,
required String workingDirectory,
required ExternalCodeAgentAcpRoutingConfig routing,
}) async {
return const ExternalCodeAgentAcpRoutingResolution(
raw: <String, dynamic>{
'resolvedExecutionTarget': 'single-agent',
'resolvedEndpointTarget': 'singleAgent',
'resolvedProviderId': 'codex',
'resolvedModel': '',
'resolvedSkills': <String>[],
'unavailable': false,
},
);
}
@override
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
) async {}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@ -165,24 +165,6 @@ void main() {
},
);
testWidgets('renders the signed-out login card consistently', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1600, 1200));
addTearDown(() async => tester.binding.setSurfaceSize(null));
final fixtures = _buildSettingsPageFixtures(
seed: _SettingsAccountSeed.signedOut,
);
final controller = fixtures.controller;
await tester.pumpWidget(_buildSettingsPageApp(controller));
await tester.pump(const Duration(milliseconds: 300));
await expectLater(
find.byKey(const ValueKey('settings-page-boundary')),
matchesGoldenFile('goldens/settings_page_account_status_canonical.png'),
);
});
});
}
@ -190,13 +172,10 @@ Widget _buildSettingsPageApp(_FakeSettingsPageController controller) {
return MaterialApp(
theme: AppTheme.light(platform: TargetPlatform.macOS),
home: Scaffold(
body: RepaintBoundary(
key: const ValueKey('settings-page-boundary'),
child: SizedBox(
width: 1600,
height: 1200,
child: SettingsPage(controller: controller),
),
body: SizedBox(
width: 1600,
height: 1200,
child: SettingsPage(controller: controller),
),
),
);

View File

@ -1,365 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/runtime/desktop_thread_artifact_sync.dart';
import 'package:xworkmate/runtime/external_code_agent_acp_desktop_transport.dart';
import 'package:xworkmate/runtime/gateway_acp_client.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
const _providerEndpoints = <String, String>{
'codex': 'https://acp-server.svc.plus/codex/acp/rpc',
'opencode': 'https://acp-server.svc.plus/opencode/acp/rpc',
'gemini': 'https://acp-server.svc.plus/gemini/acp/rpc',
};
const _tinyPngBase64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0x8AAAAASUVORK5CYII=';
void main() {
final runRealE2E =
Platform.environment['RUN_REAL_BRIDGE_E2E'] == '1' ||
Platform.environment['RUN_REAL_BRIDGE_E2E'] == 'true';
final bridgeAuthToken =
Platform.environment['BRIDGE_AUTH_TOKEN']?.trim() ?? '';
final bridgeAcpEndpoint =
Platform.environment['BRIDGE_ACP_ENDPOINT']?.trim() ?? '';
final openclawGatewayToken =
Platform.environment['OPENCLAW_GATEWAY_TOKEN']?.trim() ?? '';
group('real bridge provider matrix', () {
late ExternalCodeAgentAcpDesktopTransport transport;
setUpAll(() async {
if (!runRealE2E || bridgeAuthToken.isEmpty || bridgeAcpEndpoint.isEmpty) {
return;
}
final client = GatewayAcpClient(
endpointResolver: () => Uri.parse(bridgeAcpEndpoint),
authorizationResolver: (_) async => 'Bearer $bridgeAuthToken',
);
transport = ExternalCodeAgentAcpDesktopTransport(
client: client,
endpointResolver: (_) => Uri.parse(bridgeAcpEndpoint),
);
await transport.syncExternalProviders(
_providerEndpoints.entries
.map(
(entry) => ExternalCodeAgentAcpSyncedProvider(
providerId: entry.key,
label: entry.key,
endpoint: entry.value,
authorizationHeader: 'Bearer $bridgeAuthToken',
enabled: true,
),
)
.toList(growable: false),
);
});
tearDownAll(() async {
if (runRealE2E &&
bridgeAuthToken.isNotEmpty &&
bridgeAcpEndpoint.isNotEmpty) {
await transport.dispose();
}
});
test('loads external ACP capabilities and provider catalog', () async {
if (!runRealE2E || bridgeAuthToken.isEmpty || bridgeAcpEndpoint.isEmpty) {
return;
}
final capabilities = await transport.loadExternalAcpCapabilities(
target: AssistantExecutionTarget.singleAgent,
);
expect(capabilities.singleAgent, isTrue);
expect(
capabilities.providerCatalog.map((item) => item.providerId),
containsAll(<String>['codex', 'opencode', 'gemini']),
);
});
for (final providerId in _providerEndpoints.keys) {
test('$providerId supports a two-turn conversation', () async {
if (!runRealE2E ||
bridgeAuthToken.isEmpty ||
bridgeAcpEndpoint.isEmpty) {
return;
}
final workdir = await Directory.systemTemp.createTemp(
'xworkmate-$providerId-conversation-',
);
addTearDown(() async {
if (await workdir.exists()) {
await workdir.delete(recursive: true);
}
});
final startResult = await transport.executeTask(
_buildRequest(
providerId: providerId,
sessionId: 'conversation-$providerId',
threadId: 'conversation-$providerId',
workingDirectory: workdir.path,
prompt: 'Reply with exactly pong.',
),
onUpdate: (_) {},
);
expect(startResult.success, isTrue);
expect(startResult.resolvedProviderId, providerId);
final messageResult = await transport.executeTask(
_buildRequest(
providerId: providerId,
sessionId: 'conversation-$providerId',
threadId: 'conversation-$providerId',
workingDirectory: workdir.path,
prompt: 'Reply with exactly round2.',
resumeSession: true,
),
onUpdate: (_) {},
);
expect(messageResult.success, isTrue);
expect(messageResult.resolvedProviderId, providerId);
expect(
messageResult.message.toLowerCase(),
contains('round2'),
reason: 'follow-up should stay on the same provider/thread',
);
});
}
for (final providerId in <String>['codex', 'opencode']) {
for (final scenario in _artifactScenarios) {
test(
'$providerId can return ${scenario.skill} artifacts to local workspace',
() async {
if (!runRealE2E || bridgeAuthToken.isEmpty) {
return;
}
final workdir = await Directory.systemTemp.createTemp(
'xworkmate-$providerId-${scenario.skill}-',
);
addTearDown(() async {
if (await workdir.exists()) {
await workdir.delete(recursive: true);
}
});
await scenario.prepare?.call(workdir);
final result = await transport.executeTask(
_buildRequest(
providerId: providerId,
sessionId: '$providerId-${scenario.skill}',
threadId: '$providerId-${scenario.skill}',
workingDirectory: workdir.path,
prompt: scenario.prompt,
selectedSkills: <String>[scenario.skill],
),
onUpdate: (_) {},
);
expect(result.success, isTrue, reason: result.errorMessage);
expect(result.resolvedProviderId, providerId);
expect(result.remoteWorkingDirectory.trim(), isNotEmpty);
expect(result.remoteWorkspaceRefKind, WorkspaceRefKind.remotePath);
expect(result.resultSummary.trim(), isNotEmpty);
expect(result.artifacts, isNotEmpty);
final syncResult = await syncInlineArtifactsToLocalWorkspace(
root: workdir,
artifacts: result.artifacts,
);
expect(syncResult.wroteArtifact, isTrue);
expect(
syncResult.writtenFiles.any(
(path) => path.endsWith(scenario.expectedSuffix),
),
isTrue,
);
},
timeout: const Timeout(Duration(minutes: 4)),
);
}
}
for (final scenario in _artifactScenarios) {
test(
'gemini reports either success or a provider limitation for ${scenario.skill}',
() async {
if (!runRealE2E || bridgeAuthToken.isEmpty) {
return;
}
final workdir = await Directory.systemTemp.createTemp(
'xworkmate-gemini-${scenario.skill}-',
);
addTearDown(() async {
if (await workdir.exists()) {
await workdir.delete(recursive: true);
}
});
await scenario.prepare?.call(workdir);
final result = await transport.executeTask(
_buildRequest(
providerId: 'gemini',
sessionId: 'gemini-${scenario.skill}',
threadId: 'gemini-${scenario.skill}',
workingDirectory: workdir.path,
prompt: scenario.prompt,
selectedSkills: <String>[scenario.skill],
),
onUpdate: (_) {},
);
expect(result.resolvedProviderId, 'gemini');
if (result.success) {
final syncResult = await syncInlineArtifactsToLocalWorkspace(
root: workdir,
artifacts: result.artifacts,
);
expect(syncResult.wroteArtifact, isTrue);
} else {
expect(
result.errorMessage.trim().isNotEmpty ||
result.message.trim().isNotEmpty,
isTrue,
reason:
'provider limitation should still surface a clear summary',
);
}
},
timeout: const Timeout(Duration(minutes: 4)),
);
}
});
group('bridge-owned deployment examples', () {
test('default gateway profile starts unconfigured', () {
final profile = GatewayConnectionProfile.defaults();
expect(profile.host, isEmpty);
expect(profile.port, 443);
expect(profile.tls, isTrue);
expect(profile.mode, RuntimeConnectionMode.unconfigured);
});
test('wss endpoint is reachable', () async {
if (!runRealE2E) {
return;
}
final client = HttpClient();
addTearDown(client.close);
final request = await client.getUrl(
Uri.parse('https://openclaw.svc.plus'),
);
final response = await request.close();
expect(response.statusCode, anyOf(200, 400, 401, 403, 404, 426));
});
test(
'gateway token is wired for future remote runtime coverage',
() {
if (!runRealE2E) {
return;
}
expect(
openclawGatewayToken.isNotEmpty,
isTrue,
reason:
'Set OPENCLAW_GATEWAY_TOKEN to run remote gateway-chat coverage against openclaw.svc.plus.',
);
},
skip: !runRealE2E || openclawGatewayToken.isNotEmpty,
);
});
}
class _ArtifactScenario {
const _ArtifactScenario({
required this.skill,
required this.prompt,
required this.expectedSuffix,
this.prepare,
});
final String skill;
final String prompt;
final String expectedSuffix;
final Future<void> Function(Directory root)? prepare;
}
final _artifactScenarios = <_ArtifactScenario>[
const _ArtifactScenario(
skill: 'docx',
prompt:
'Use the docx skill to create report.docx in the working directory. Include a title and a 2-column table with two rows.',
expectedSuffix: '/report.docx',
),
const _ArtifactScenario(
skill: 'pptx',
prompt:
'Use the pptx skill to create deck.pptx in the working directory with two slides titled Intro and Summary.',
expectedSuffix: '/deck.pptx',
),
const _ArtifactScenario(
skill: 'xlsx',
prompt:
'Use the xlsx skill to create sales.xlsx in the working directory with a totals formula column.',
expectedSuffix: '/sales.xlsx',
),
const _ArtifactScenario(
skill: 'pdf',
prompt:
'Use the pdf skill to create summary.pdf in the working directory with a one-page summary of bridge validation.',
expectedSuffix: '/summary.pdf',
),
_ArtifactScenario(
skill: 'image-resizer',
prompt:
'Use the image-resizer skill to resize input.png to 1200x800 and save the output as resized.png in the working directory.',
expectedSuffix: '/resized.png',
prepare: (root) async {
final bytes = base64Decode(_tinyPngBase64);
await File('${root.path}/input.png').writeAsBytes(bytes, flush: true);
},
),
];
GoTaskServiceRequest _buildRequest({
required String providerId,
required String sessionId,
required String threadId,
required String workingDirectory,
required String prompt,
List<String> selectedSkills = const <String>[],
bool resumeSession = false,
}) {
return GoTaskServiceRequest(
sessionId: sessionId,
threadId: threadId,
target: AssistantExecutionTarget.singleAgent,
prompt: prompt,
workingDirectory: workingDirectory,
model: '',
thinking: '',
selectedSkills: selectedSkills,
inlineAttachments: const <GatewayChatAttachmentPayload>[],
localAttachments: const <CollaborationAttachment>[],
agentId: '',
metadata: const <String, dynamic>{},
routing: ExternalCodeAgentAcpRoutingConfig(
mode: ExternalCodeAgentAcpRoutingMode.explicit,
preferredGatewayTarget: 'gateway',
explicitExecutionTarget: 'singleAgent',
explicitProviderId: providerId,
explicitModel: '',
explicitSkills: selectedSkills,
allowSkillInstall: false,
availableSkills: const <ExternalCodeAgentAcpAvailableSkill>[],
),
provider: SingleAgentProviderCopy.fromJsonValue(providerId),
remoteWorkingDirectoryHint: '',
resumeSession: resumeSession,
);
}

View File

@ -40,6 +40,16 @@ void main() {
expect(decoded.toJson().containsKey('codexCliPath'), isFalse);
});
test('single-agent provider selection preserves bridge catalog ids', () {
final decoded = SettingsSnapshot.defaults();
final provider = SingleAgentProvider.fromJsonValue(
'xworkmate-bridge-foo',
label: 'Bridge Foo',
);
expect(decoded.sanitizeSingleAgentProviderSelection(provider), provider);
});
test('removed ui restore and local provider fields are not serialized', () {
final json = SettingsSnapshot.defaults().toJson();
@ -55,27 +65,34 @@ void main() {
});
group('AcpBridgeServerModeConfig advanced overrides', () {
test('legacy ACP bridge server profiles are ignored and not reserialized', () {
final config = AcpBridgeServerModeConfig.fromJson(<String, dynamic>{
'advancedOverrides': <String, dynamic>{
'acpBridgeServerProfiles': <Map<String, dynamic>>[
<String, dynamic>{
'providerKey': 'opencode',
'label': 'OpenCode',
'badge': 'O',
'endpoint': 'https://opencode.example.com',
'authRef': 'secret://opencode',
'enabled': true,
},
],
},
});
test(
'legacy ACP bridge server profiles are ignored and not reserialized',
() {
final config = AcpBridgeServerModeConfig.fromJson(<String, dynamic>{
'advancedOverrides': <String, dynamic>{
'acpBridgeServerProfiles': <Map<String, dynamic>>[
<String, dynamic>{
'providerKey': 'opencode',
'label': 'OpenCode',
'badge': 'O',
'endpoint': 'https://opencode.example.com',
'authRef': 'secret://opencode',
'enabled': true,
},
],
},
});
final json = config.toJson();
final advancedOverrides = (json['advancedOverrides'] as Map?)?.cast<String, dynamic>();
final json = config.toJson();
final advancedOverrides = (json['advancedOverrides'] as Map?)
?.cast<String, dynamic>();
expect(advancedOverrides, isNotNull);
expect(advancedOverrides!.containsKey('acpBridgeServerProfiles'), isFalse);
});
expect(advancedOverrides, isNotNull);
expect(
advancedOverrides!.containsKey('acpBridgeServerProfiles'),
isFalse,
);
},
);
});
}