Refactor bridge provider readiness and trim stale tests
This commit is contained in:
parent
9e80740378
commit
53d411fb9e
@ -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.
|
||||
|
||||
@ -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 唯一数据源"]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 基线
|
||||
- 更贴近真实交互的桌面集成回归
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -845,6 +845,7 @@ extension AppControllerDesktopSettingsRuntime on AppController {
|
||||
executionTargetSource: ThreadSelectionSource.explicit,
|
||||
gatewayEntryState: gatewayEntryStateForTargetInternal(target),
|
||||
latestResolvedRuntimeModel: '',
|
||||
latestResolvedProviderId: '',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
recomputeTasksInternal();
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
)) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -216,7 +216,7 @@ void _seedBridgeProviders(
|
||||
AppController controller,
|
||||
List<SingleAgentProvider> providers,
|
||||
) {
|
||||
controller.bridgeAdvertisedProvidersInternal = providers;
|
||||
controller.bridgeProviderCatalogInternal = providers;
|
||||
}
|
||||
|
||||
class _CapturingGoTaskServiceClient implements GoTaskServiceClient {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 |
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user