test: lock provider selection mainline contract

This commit is contained in:
Haitao Pan 2026-04-14 13:56:58 +08:00
parent 51c90e9613
commit 1f977caee2
11 changed files with 342 additions and 158 deletions

View File

@ -19,6 +19,7 @@ on:
- "linux/**"
- "windows/**"
- "rust/**"
- "test/**"
- "scripts/**"
- "pubspec.*"
- "Makefile"
@ -89,6 +90,23 @@ jobs:
shell: bash
run: bash ./scripts/ci/run_flutter_ci_suite.sh
remote_contract:
runs-on: ubuntu-22.04
needs:
- verify
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
steps:
- name: Checkout source
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Verify accounts to bridge provider contract
shell: bash
env:
REVIEW_ACCOUNT_BASE_URL: ${{ vars.REVIEW_ACCOUNT_BASE_URL }}
REVIEW_ACCOUNT_LOGIN_NAME: ${{ vars.REVIEW_ACCOUNT_LOGIN_NAME }}
REVIEW_ACCOUNT_LOGIN_PASSWORD: ${{ secrets.REVIEW_ACCOUNT_LOGIN_PASSWORD }}
run: bash ./scripts/ci/verify_remote_provider_contract.sh
build:
if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main') }}

View File

@ -587,6 +587,16 @@ class AppController extends ChangeNotifier {
.toList(growable: false);
}
SingleAgentProvider defaultProviderForExecutionTarget(
AssistantExecutionTarget executionTarget,
) {
final catalog = providerCatalogForExecutionTarget(executionTarget);
if (catalog.isNotEmpty) {
return catalog.first;
}
return SingleAgentProvider.unspecified;
}
SingleAgentProvider? bridgeProviderForId(String providerId) {
final normalizedProviderId = normalizeSingleAgentProviderId(providerId);
if (normalizedProviderId.isEmpty) {
@ -603,6 +613,7 @@ class AppController extends ChangeNotifier {
SingleAgentProvider resolveProviderForExecutionTarget(
String? providerId, {
required AssistantExecutionTarget executionTarget,
bool defaultToCatalog = false,
}) {
final normalizedProviderId = normalizeSingleAgentProviderId(
providerId ?? '',
@ -615,16 +626,9 @@ class AppController extends ChangeNotifier {
}
}
}
if (catalog.isNotEmpty) {
if (defaultToCatalog && catalog.isNotEmpty) {
return catalog.first;
}
if (normalizedProviderId.isNotEmpty) {
return SingleAgentProvider.fromJsonValue(
normalizedProviderId,
supportedTargets: <AssistantExecutionTarget>[executionTarget],
enabled: false,
);
}
return SingleAgentProvider.unspecified;
}

View File

@ -300,9 +300,11 @@ extension AppControllerDesktopSkillPermissions on AppController {
existing?.contextState.latestResolvedProviderId ??
'',
);
final shouldDefaultProvider = existing == null && nextProviderId.isEmpty;
final nextProvider = resolveProviderForExecutionTarget(
nextProviderId,
executionTarget: nextExecutionTarget,
defaultToCatalog: shouldDefaultProvider,
);
final nextProviderSource =
selectedProviderSource ??

View File

@ -222,9 +222,16 @@ extension AppControllerDesktopThreadBinding on AppController {
final persistedProviderId = normalizeSingleAgentProviderId(
existingBinding?.providerId ?? '',
);
final existingTarget = existingBinding == null
? null
: assistantExecutionTargetFromExecutionMode(
existingBinding.executionMode,
);
final selectedProvider = resolveProviderForExecutionTarget(
persistedProviderId,
executionTarget: executionTarget,
defaultToCatalog:
existingBinding == null || existingTarget != executionTarget,
);
return (existingBinding ??
ExecutionBinding(

View File

@ -53,23 +53,6 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
final currentTarget = assistantExecutionTargetForSession(
sessionsControllerInternal.currentSessionKey,
);
final shouldRefreshAgentProviders =
providerCatalogForExecutionTarget(resolvedTarget).isEmpty;
if (shouldRefreshAgentProviders) {
try {
await refreshSingleAgentCapabilitiesInternal(forceRefresh: true);
} catch (_) {
// Keep target selection interactive even when a just-in-time
// capabilities refresh fails. The dialog stays interactive while the
// live catalog catches up from bridge capabilities.
}
if (currentTarget == resolvedTarget &&
settings.assistantExecutionTarget == resolvedTarget) {
recomputeTasksInternal();
notifyIfActiveInternal();
return;
}
}
if (currentTarget == resolvedTarget &&
settings.assistantExecutionTarget == resolvedTarget) {
return;
@ -125,9 +108,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
notifyIfActiveInternal();
}
Future<void> setAssistantProvider(
SingleAgentProvider provider,
) async {
Future<void> setAssistantProvider(SingleAgentProvider provider) async {
final executionTarget = assistantExecutionTargetForSession(
sessionsControllerInternal.currentSessionKey,
);
@ -135,6 +116,9 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
provider.providerId,
executionTarget: executionTarget,
);
if (resolvedProvider.isUnspecified) {
return;
}
final sessionKey = normalizedAssistantSessionKeyInternal(
sessionsControllerInternal.currentSessionKey,
);
@ -162,9 +146,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
executionTargetSource: ThreadSelectionSource.explicit,
selectedProvider: resolvedProvider,
selectedProviderSource: ThreadSelectionSource.explicit,
gatewayEntryState: gatewayEntryStateForTargetInternal(
executionTarget,
),
gatewayEntryState: gatewayEntryStateForTargetInternal(executionTarget),
latestResolvedProviderId: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
@ -224,9 +206,9 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
upsertTaskThreadInternal(
normalizedSessionKey,
selectedProvider: resolveProviderForExecutionTarget(
taskThreadForSessionInternal(normalizedSessionKey)
?.executionBinding
.providerId,
taskThreadForSessionInternal(
normalizedSessionKey,
)?.executionBinding.providerId,
executionTarget: resolvedTarget,
),
selectedProviderSource: ThreadSelectionSource.explicit,

View File

@ -97,25 +97,23 @@ class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget {
unawaited(_handleExecutionTargetSelected(value));
},
itemBuilder: (context) => supportedExecutionTargets
.map(
(value) {
final enabled = visibleExecutionTargets.contains(value);
return PopupMenuItem<AssistantExecutionTarget>(
value: value,
enabled: enabled,
key: Key('assistant-execution-target-menu-item-${value.name}'),
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value == executionTarget)
const Icon(Icons.check_rounded, size: 18),
],
),
);
},
)
.map((value) {
final enabled = visibleExecutionTargets.contains(value);
return PopupMenuItem<AssistantExecutionTarget>(
value: value,
enabled: enabled,
key: Key('assistant-execution-target-menu-item-${value.name}'),
child: Row(
children: [
Icon(value.icon, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(value.label)),
if (value == executionTarget)
const Icon(Icons.check_rounded, size: 18),
],
),
);
})
.toList(growable: false),
child: _TaskDialogSelectorChipInternal(
leading: Icon(executionTarget.icon, size: 14, color: palette.textMuted),
@ -197,9 +195,6 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
}
SingleAgentProvider _fallbackDisplayProvider() {
if (providers.isNotEmpty) {
return providers.first;
}
return SingleAgentProvider(
providerId: '',
label: appText('未提供', 'Unavailable'),

View File

@ -3,3 +3,6 @@ set -euo pipefail
flutter pub get
flutter analyze
flutter test test/runtime/assistant_execution_target_test.dart
flutter test test/runtime/runtime_controllers_settings_account_test.dart
flutter test test/features/assistant/assistant_lower_pane_test.dart

View File

@ -0,0 +1,181 @@
#!/usr/bin/env bash
set -euo pipefail
ACCOUNTS_BASE_URL="${REVIEW_ACCOUNT_BASE_URL:-https://accounts.svc.plus}"
REVIEW_ACCOUNT_LOGIN_NAME="${REVIEW_ACCOUNT_LOGIN_NAME:-review@svc.plus}"
REVIEW_ACCOUNT_LOGIN_PASSWORD="${REVIEW_ACCOUNT_LOGIN_PASSWORD:-}"
HTTP_TIMEOUT_SECONDS="${HTTP_TIMEOUT_SECONDS:-30}"
if [[ -z "${REVIEW_ACCOUNT_LOGIN_PASSWORD}" ]]; then
echo "REVIEW_ACCOUNT_LOGIN_PASSWORD is required" >&2
exit 1
fi
normalize_url() {
local raw="$1"
raw="${raw%"${raw##*[![:space:]]}"}"
raw="${raw#"${raw%%[![:space:]]*}"}"
printf '%s\n' "${raw%/}"
}
json_post() {
local url="$1"
local data="$2"
shift 2
curl \
--silent \
--show-error \
--fail \
--location \
--max-time "${HTTP_TIMEOUT_SECONDS}" \
-H 'Accept: application/json' \
-H 'Content-Type: application/json' \
"$@" \
--data "${data}" \
"${url}"
}
json_get() {
local url="$1"
shift
curl \
--silent \
--show-error \
--fail \
--location \
--max-time "${HTTP_TIMEOUT_SECONDS}" \
-H 'Accept: application/json' \
"$@" \
"${url}"
}
accounts_base_url="$(normalize_url "${ACCOUNTS_BASE_URL}")"
login_payload="$(python3 - <<'PY'
import json
import os
print(json.dumps({
"identifier": os.environ["REVIEW_ACCOUNT_LOGIN_NAME"],
"password": os.environ["REVIEW_ACCOUNT_LOGIN_PASSWORD"],
}))
PY
)"
login_json="$(
json_post \
"${accounts_base_url}/api/auth/login" \
"${login_payload}"
)"
session_token="$(
RESPONSE_JSON="${login_json}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["RESPONSE_JSON"])
token = str(payload.get("token", "")).strip()
if not token:
raise SystemExit("accounts login response did not include token")
print(token)
PY
)"
sync_json="$(
json_get \
"${accounts_base_url}/api/auth/xworkmate/profile/sync" \
-H "Authorization: Bearer ${session_token}"
)"
bridge_server_url="$(
RESPONSE_JSON="${sync_json}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["RESPONSE_JSON"])
bridge_url = str(
payload.get("BRIDGE_SERVER_URL")
or payload.get("bridgeServerUrl")
or ""
).strip()
if not bridge_url:
raise SystemExit("account sync response did not include BRIDGE_SERVER_URL")
print(bridge_url.rstrip("/"))
PY
)"
bridge_auth_token="$(
RESPONSE_JSON="${sync_json}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["RESPONSE_JSON"])
token = str(payload.get("BRIDGE_AUTH_TOKEN") or "").strip()
if not token:
raise SystemExit("account sync response did not include BRIDGE_AUTH_TOKEN")
print(token)
PY
)"
capabilities_json="$(
json_post \
"${bridge_server_url}/acp/rpc" \
'{"jsonrpc":"2.0","id":"capabilities","method":"acp.capabilities"}' \
-H "Authorization: Bearer ${bridge_auth_token}"
)"
RESPONSE_JSON="${capabilities_json}" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["RESPONSE_JSON"])
if payload.get("jsonrpc") != "2.0":
raise SystemExit("bridge capabilities response missing jsonrpc envelope")
result = payload.get("result")
if not isinstance(result, dict):
raise SystemExit("bridge capabilities response missing result payload")
expected_targets = ["agent", "gateway"]
if result.get("availableExecutionTargets") != expected_targets:
raise SystemExit(
f"expected availableExecutionTargets {expected_targets!r}, got {result.get('availableExecutionTargets')!r}"
)
provider_catalog = result.get("providerCatalog")
if not isinstance(provider_catalog, list):
raise SystemExit("providerCatalog is missing or invalid")
gateway_providers = result.get("gatewayProviders")
if not isinstance(gateway_providers, list):
raise SystemExit("gatewayProviders is missing or invalid")
expected_agent_ids = ["codex", "opencode", "gemini"]
expected_agent_labels = ["Codex", "OpenCode", "Gemini"]
if len(provider_catalog) != len(expected_agent_ids):
raise SystemExit(
f"expected {len(expected_agent_ids)} agent providers, got {provider_catalog!r}"
)
for index, (provider_id, label) in enumerate(zip(expected_agent_ids, expected_agent_labels)):
item = provider_catalog[index]
if item.get("providerId") != provider_id:
raise SystemExit(f"expected providerId {provider_id!r} at index {index}, got {item!r}")
if item.get("label") != label:
raise SystemExit(f"expected provider label {label!r} at index {index}, got {item!r}")
if item.get("targets") != ["agent"]:
raise SystemExit(f"expected agent targets for {provider_id!r}, got {item!r}")
if len(gateway_providers) != 1:
raise SystemExit(f"expected exactly one gateway provider, got {gateway_providers!r}")
gateway = gateway_providers[0]
if gateway.get("providerId") != "openclaw":
raise SystemExit(f"expected gateway providerId 'openclaw', got {gateway!r}")
if gateway.get("label") != "OpenClaw":
raise SystemExit(f"expected gateway label 'OpenClaw', got {gateway!r}")
if gateway.get("targets") != ["gateway"]:
raise SystemExit(f"expected gateway targets ['gateway'], got {gateway!r}")
PY
printf 'accounts -> bridge provider contract verified via %s\n' "${bridge_server_url}"

View File

@ -94,6 +94,10 @@ void main() {
find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsOneWidget,
);
expect(find.text('Codex'), findsOneWidget);
expect(find.text('OpenCode'), findsOneWidget);
expect(find.text('Gemini'), findsOneWidget);
expect(find.byIcon(Icons.check_rounded), findsNothing);
await tester.tap(
find.byKey(const Key('assistant-provider-menu-item-codex')),
);
@ -151,6 +155,8 @@ void main() {
find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsNothing,
);
expect(find.text('OpenClaw'), findsWidgets);
expect(find.byIcon(Icons.check_rounded), findsOneWidget);
await tester.tap(
find.byKey(const Key('assistant-provider-menu-item-openclaw')),
);
@ -203,6 +209,7 @@ void main() {
find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsOneWidget,
);
expect(find.byIcon(Icons.check_rounded), findsOneWidget);
});
testWidgets('shows assistant providers and allows switching provider', (
@ -253,100 +260,74 @@ void main() {
);
});
testWidgets('allows switching gateway providers from the dynamic catalog', (
tester,
) async {
final controller = AppController(
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.gemini,
],
initialGatewayProviderCatalog: <SingleAgentProvider>[
SingleAgentProvider.openclaw.copyWith(
logoEmoji: '🦞',
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.gateway,
],
),
SingleAgentProvider.fromJsonValue(
'hermes',
label: 'Hermes',
badge: 'H',
supportedTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.gateway,
],
),
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
AssistantExecutionTarget.agent,
AssistantExecutionTarget.gateway,
],
);
addTearDown(controller.dispose);
testWidgets(
'does not reverse-infer a menu selection from a stale saved provider',
(tester) async {
final controller = AppController(
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
SingleAgentProvider.gemini,
],
);
addTearDown(controller.dispose);
await controller.sessionsController.switchSession('session-1');
controller.initializeAssistantThreadContext(
'session-1',
executionTarget: AssistantExecutionTarget.gateway,
messageViewMode: controller.assistantMessageViewModeForSession(
await controller.sessionsController.switchSession('session-1');
controller.initializeAssistantThreadContext(
'session-1',
),
);
final gatewayThread = controller
.requireTaskThreadForSessionInternal('session-1')
.copyWith(
executionBinding: ExecutionBinding(
executionMode: threadExecutionModeFromAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
executionTarget: AssistantExecutionTarget.agent,
messageViewMode: controller.assistantMessageViewModeForSession(
'session-1',
),
);
final staleThread = controller
.requireTaskThreadForSessionInternal('session-1')
.copyWith(
executionBinding: ExecutionBinding(
executionMode: threadExecutionModeFromAssistantExecutionTarget(
AssistantExecutionTarget.agent,
),
executorId: 'legacy-provider',
providerId: 'legacy-provider',
endpointId: '',
executionModeSource: ThreadSelectionSource.explicit,
providerSource: ThreadSelectionSource.explicit,
),
executorId: SingleAgentProvider.openclaw.providerId,
providerId: SingleAgentProvider.openclaw.providerId,
endpointId: '',
executionModeSource: ThreadSelectionSource.explicit,
providerSource: ThreadSelectionSource.explicit,
),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
controller.taskThreadRepositoryInternal.replace(
gatewayThread,
persist: false,
);
controller.notifyListeners();
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
controller.taskThreadRepositoryInternal.replace(
staleThread,
persist: false,
);
controller.notifyListeners();
await tester.pumpWidget(
_buildTestApp(child: _buildLowerPane(controller: controller)),
);
await tester.pumpAndSettle();
await tester.pumpWidget(
_buildTestApp(child: _buildLowerPane(controller: controller)),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('assistant-provider-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('assistant-provider-button')));
await tester.pumpAndSettle();
expect(
find.byKey(const Key('assistant-provider-menu-item-openclaw')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-hermes')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-codex')),
findsNothing,
);
await tester.tap(
find.byKey(const Key('assistant-provider-menu-item-hermes')),
);
await tester.pumpAndSettle();
expect(
controller
.assistantProviderForSession(controller.currentSessionKey)
.providerId,
'hermes',
);
});
expect(
find.byKey(const Key('assistant-provider-menu-item-codex')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-opencode')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-provider-menu-item-openclaw')),
findsNothing,
);
expect(find.byIcon(Icons.check_rounded), findsNothing);
},
);
testWidgets('uses submit button instead of connect action', (tester) async {
final controller = AppController();

View File

@ -80,7 +80,7 @@ void main() {
expect(controller.currentAssistantExecutionTarget.isAgent, isTrue);
expect(
controller.assistantProviderForSession(controller.currentSessionKey),
SingleAgentProvider.codex,
SingleAgentProvider.unspecified,
);
await controller.setAssistantExecutionTarget(
@ -108,23 +108,23 @@ void main() {
);
test(
'returns an unavailable provider placeholder when a saved provider is no longer in the bridge catalog',
'returns an unspecified provider when a saved provider is no longer in the bridge catalog',
() {
final controller = AppController();
addTearDown(controller.dispose);
final unavailableProvider = controller.resolveProviderForExecutionTarget(
'gemini',
executionTarget: AssistantExecutionTarget.agent,
);
final unavailableProvider = controller
.resolveProviderForExecutionTarget(
'gemini',
executionTarget: AssistantExecutionTarget.agent,
);
expect(unavailableProvider.providerId, 'gemini');
expect(unavailableProvider.enabled, isFalse);
expect(unavailableProvider, SingleAgentProvider.unspecified);
},
);
test(
'refreshes agent provider catalog when agent mode is selected with an empty catalog',
'does not refresh agent provider catalog when agent mode is selected with an empty catalog',
() async {
final capture = await _startCapabilityServer();
addTearDown(capture.close);
@ -169,18 +169,18 @@ void main() {
await controller.sessionsController.switchSession('session-1');
await _waitForRequest(capture, minimumCount: 1);
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(controller.assistantProviderCatalog, isEmpty);
final requestCountBefore = capture.requestCount;
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.agent,
);
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(
controller.assistantProviderCatalog.map((item) => item.providerId),
containsAll(<String>['codex', 'opencode', 'gemini']),
);
expect(capture.requestCount, greaterThanOrEqualTo(2));
expect(controller.assistantProviderCatalog, isEmpty);
expect(capture.requestCount, requestCountBefore);
expect(capture.lastAuthorizationHeader, 'Bearer bridge-token');
},
);

View File

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
@ -140,6 +141,16 @@ void main() {
),
'bridge-token-from-sync',
);
expect(
controller.snapshot.toJsonString().contains('bridge-token-from-sync'),
isFalse,
);
expect(
jsonEncode(
controller.accountSyncState!.toJson(),
).contains('bridge-token-from-sync'),
isFalse,
);
},
);