Merge pull request #12 from x-evor/codex/provider-selection-test-mainline

Codex/provider selection test mainline
This commit is contained in:
Haitao Pan 2026-04-14 15:03:46 +08:00 committed by GitHub
commit 58b8e398f2
12 changed files with 391 additions and 123 deletions

View File

@ -19,6 +19,7 @@ on:
- "linux/**" - "linux/**"
- "windows/**" - "windows/**"
- "rust/**" - "rust/**"
- "test/**"
- "scripts/**" - "scripts/**"
- "pubspec.*" - "pubspec.*"
- "Makefile" - "Makefile"
@ -89,6 +90,23 @@ jobs:
shell: bash shell: bash
run: bash ./scripts/ci/run_flutter_ci_suite.sh 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: 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') }} 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

@ -572,16 +572,29 @@ class AppController extends ChangeNotifier {
List<AssistantExecutionTarget> get bridgeAvailableExecutionTargets => List<AssistantExecutionTarget> get bridgeAvailableExecutionTargets =>
compactAssistantExecutionTargets(bridgeAvailableExecutionTargetsInternal); compactAssistantExecutionTargets(bridgeAvailableExecutionTargetsInternal);
List<SingleAgentProvider> get assistantProviderCatalogForDisplay {
return assistantProviderCatalog;
}
List<SingleAgentProvider> providerCatalogForExecutionTarget( List<SingleAgentProvider> providerCatalogForExecutionTarget(
AssistantExecutionTarget executionTarget, AssistantExecutionTarget executionTarget,
) { ) {
return executionTarget.isGateway final source = executionTarget.isGateway
? gatewayProviderCatalog ? gatewayProviderCatalog
: assistantProviderCatalogForDisplay; : assistantProviderCatalog;
return source
.where(
(provider) =>
provider.supportedTargets.isEmpty ||
provider.supportedTargets.contains(executionTarget),
)
.toList(growable: false);
}
SingleAgentProvider defaultProviderForExecutionTarget(
AssistantExecutionTarget executionTarget,
) {
final catalog = providerCatalogForExecutionTarget(executionTarget);
if (catalog.isNotEmpty) {
return catalog.first;
}
return SingleAgentProvider.unspecified;
} }
SingleAgentProvider? bridgeProviderForId(String providerId) { SingleAgentProvider? bridgeProviderForId(String providerId) {
@ -600,6 +613,7 @@ class AppController extends ChangeNotifier {
SingleAgentProvider resolveProviderForExecutionTarget( SingleAgentProvider resolveProviderForExecutionTarget(
String? providerId, { String? providerId, {
required AssistantExecutionTarget executionTarget, required AssistantExecutionTarget executionTarget,
bool defaultToCatalog = false,
}) { }) {
final normalizedProviderId = normalizeSingleAgentProviderId( final normalizedProviderId = normalizeSingleAgentProviderId(
providerId ?? '', providerId ?? '',
@ -612,26 +626,12 @@ class AppController extends ChangeNotifier {
} }
} }
} }
if (catalog.isNotEmpty) { if (defaultToCatalog && catalog.isNotEmpty) {
return catalog.first; return catalog.first;
} }
if (normalizedProviderId.isNotEmpty) {
return SingleAgentProvider.fromJsonValue(
normalizedProviderId,
supportedTargets: <AssistantExecutionTarget>[executionTarget],
enabled: false,
);
}
return SingleAgentProvider.unspecified; return SingleAgentProvider.unspecified;
} }
SingleAgentProvider resolveAssistantProvider(String? providerId) {
return resolveProviderForExecutionTarget(
providerId,
executionTarget: AssistantExecutionTarget.agent,
);
}
SingleAgentProvider assistantProviderForSession(String sessionKey) { SingleAgentProvider assistantProviderForSession(String sessionKey) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal( final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey, sessionKey,

View File

@ -227,9 +227,9 @@ extension AppControllerDesktopSkillPermissions on AppController {
List<AssistantThreadSkillEntry>? importedSkills, List<AssistantThreadSkillEntry>? importedSkills,
List<String>? selectedSkillKeys, List<String>? selectedSkillKeys,
String? assistantModelId, String? assistantModelId,
SingleAgentProvider? singleAgentProvider, SingleAgentProvider? selectedProvider,
ThreadSelectionSource? executionTargetSource, ThreadSelectionSource? executionTargetSource,
ThreadSelectionSource? singleAgentProviderSource, ThreadSelectionSource? selectedProviderSource,
ThreadSelectionSource? assistantModelSource, ThreadSelectionSource? assistantModelSource,
ThreadSelectionSource? selectedSkillsSource, ThreadSelectionSource? selectedSkillsSource,
String? gatewayEntryState, String? gatewayEntryState,
@ -291,8 +291,8 @@ extension AppControllerDesktopSkillPermissions on AppController {
'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.', 'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.',
); );
} }
final requestedProvider = singleAgentProvider?.isUnspecified == false final requestedProvider = selectedProvider?.isUnspecified == false
? singleAgentProvider ? selectedProvider
: null; : null;
final nextProviderId = normalizeSingleAgentProviderId( final nextProviderId = normalizeSingleAgentProviderId(
requestedProvider?.providerId ?? requestedProvider?.providerId ??
@ -300,12 +300,14 @@ extension AppControllerDesktopSkillPermissions on AppController {
existing?.contextState.latestResolvedProviderId ?? existing?.contextState.latestResolvedProviderId ??
'', '',
); );
final shouldDefaultProvider = existing == null && nextProviderId.isEmpty;
final nextProvider = resolveProviderForExecutionTarget( final nextProvider = resolveProviderForExecutionTarget(
nextProviderId, nextProviderId,
executionTarget: nextExecutionTarget, executionTarget: nextExecutionTarget,
defaultToCatalog: shouldDefaultProvider,
); );
final nextProviderSource = final nextProviderSource =
singleAgentProviderSource ?? selectedProviderSource ??
existing?.executionBinding.providerSource ?? existing?.executionBinding.providerSource ??
ThreadSelectionSource.inherited; ThreadSelectionSource.inherited;
final nextExecutionBinding = final nextExecutionBinding =

View File

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

View File

@ -53,23 +53,6 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
final currentTarget = assistantExecutionTargetForSession( final currentTarget = assistantExecutionTargetForSession(
sessionsControllerInternal.currentSessionKey, 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 && if (currentTarget == resolvedTarget &&
settings.assistantExecutionTarget == resolvedTarget) { settings.assistantExecutionTarget == resolvedTarget) {
return; return;
@ -99,13 +82,13 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
sessionsControllerInternal.currentSessionKey, sessionsControllerInternal.currentSessionKey,
executionTarget: resolvedTarget, executionTarget: resolvedTarget,
executionTargetSource: ThreadSelectionSource.explicit, executionTargetSource: ThreadSelectionSource.explicit,
singleAgentProvider: resolveProviderForExecutionTarget( selectedProvider: resolveProviderForExecutionTarget(
taskThreadForSessionInternal( taskThreadForSessionInternal(
sessionsControllerInternal.currentSessionKey, sessionsControllerInternal.currentSessionKey,
)?.executionBinding.providerId, )?.executionBinding.providerId,
executionTarget: resolvedTarget, executionTarget: resolvedTarget,
), ),
singleAgentProviderSource: ThreadSelectionSource.explicit, selectedProviderSource: ThreadSelectionSource.explicit,
gatewayEntryState: gatewayEntryStateForTargetInternal(resolvedTarget), gatewayEntryState: gatewayEntryStateForTargetInternal(resolvedTarget),
latestResolvedRuntimeModel: '', latestResolvedRuntimeModel: '',
latestResolvedProviderId: '', latestResolvedProviderId: '',
@ -125,10 +108,17 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
notifyIfActiveInternal(); notifyIfActiveInternal();
} }
Future<void> setAssistantSingleAgentProvider( Future<void> setAssistantProvider(SingleAgentProvider provider) async {
SingleAgentProvider provider, final executionTarget = assistantExecutionTargetForSession(
) async { sessionsControllerInternal.currentSessionKey,
final resolvedProvider = resolveAssistantProvider(provider.providerId); );
final resolvedProvider = resolveProviderForExecutionTarget(
provider.providerId,
executionTarget: executionTarget,
);
if (resolvedProvider.isUnspecified) {
return;
}
final sessionKey = normalizedAssistantSessionKeyInternal( final sessionKey = normalizedAssistantSessionKeyInternal(
sessionsControllerInternal.currentSessionKey, sessionsControllerInternal.currentSessionKey,
); );
@ -146,27 +136,20 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
if (!assistantThreadRecordsInternal.containsKey(sessionKey)) { if (!assistantThreadRecordsInternal.containsKey(sessionKey)) {
initializeAssistantThreadContext( initializeAssistantThreadContext(
sessionKey, sessionKey,
executionTarget: AssistantExecutionTarget.agent, executionTarget: executionTarget,
messageViewMode: assistantMessageViewModeForSession(sessionKey), messageViewMode: assistantMessageViewModeForSession(sessionKey),
); );
} }
upsertTaskThreadInternal( upsertTaskThreadInternal(
sessionKey, sessionKey,
executionTarget: AssistantExecutionTarget.agent, executionTarget: executionTarget,
executionTargetSource: ThreadSelectionSource.explicit, executionTargetSource: ThreadSelectionSource.explicit,
singleAgentProvider: resolvedProvider, selectedProvider: resolvedProvider,
singleAgentProviderSource: ThreadSelectionSource.explicit, selectedProviderSource: ThreadSelectionSource.explicit,
gatewayEntryState: gatewayEntryStateForTargetInternal( gatewayEntryState: gatewayEntryStateForTargetInternal(executionTarget),
AssistantExecutionTarget.agent,
),
latestResolvedProviderId: '', latestResolvedProviderId: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
); );
await applyAssistantExecutionTargetInternal(
AssistantExecutionTarget.agent,
sessionKey: sessionKey,
persistDefaultSelection: true,
);
await flushAssistantThreadPersistenceInternal(); await flushAssistantThreadPersistenceInternal();
recomputeTasksInternal(); recomputeTasksInternal();
notifyIfActiveInternal(); notifyIfActiveInternal();
@ -222,13 +205,13 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
); );
upsertTaskThreadInternal( upsertTaskThreadInternal(
normalizedSessionKey, normalizedSessionKey,
singleAgentProvider: resolveProviderForExecutionTarget( selectedProvider: resolveProviderForExecutionTarget(
taskThreadForSessionInternal(normalizedSessionKey) taskThreadForSessionInternal(
?.executionBinding normalizedSessionKey,
.providerId, )?.executionBinding.providerId,
executionTarget: resolvedTarget, executionTarget: resolvedTarget,
), ),
singleAgentProviderSource: ThreadSelectionSource.explicit, selectedProviderSource: ThreadSelectionSource.explicit,
latestResolvedRuntimeModel: '', latestResolvedRuntimeModel: '',
latestResolvedProviderId: '', latestResolvedProviderId: '',
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),

View File

@ -44,9 +44,8 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget {
final executionTarget = collapseAssistantExecutionTargetForDisplay( final executionTarget = collapseAssistantExecutionTargetForDisplay(
currentExecutionTarget, currentExecutionTarget,
); );
final providerMenuProviders = _taskDialogProviderCatalogForTarget( final providerMenuProviders = controller.providerCatalogForExecutionTarget(
controller: controller, executionTarget,
executionTarget: executionTarget,
); );
return Wrap( return Wrap(
@ -73,13 +72,6 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget {
} }
} }
List<SingleAgentProvider> _taskDialogProviderCatalogForTarget({
required AppController controller,
required AssistantExecutionTarget executionTarget,
}) {
return controller.providerCatalogForExecutionTarget(executionTarget);
}
class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget { class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget {
const _TaskDialogExecutionTargetMenuButtonInternal({ const _TaskDialogExecutionTargetMenuButtonInternal({
required this.controller, required this.controller,
@ -105,25 +97,23 @@ class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget {
unawaited(_handleExecutionTargetSelected(value)); unawaited(_handleExecutionTargetSelected(value));
}, },
itemBuilder: (context) => supportedExecutionTargets itemBuilder: (context) => supportedExecutionTargets
.map( .map((value) {
(value) { final enabled = visibleExecutionTargets.contains(value);
final enabled = visibleExecutionTargets.contains(value); return PopupMenuItem<AssistantExecutionTarget>(
return PopupMenuItem<AssistantExecutionTarget>( value: value,
value: value, enabled: enabled,
enabled: enabled, key: Key('assistant-execution-target-menu-item-${value.name}'),
key: Key('assistant-execution-target-menu-item-${value.name}'), child: Row(
child: Row( children: [
children: [ Icon(value.icon, size: 18),
Icon(value.icon, size: 18), const SizedBox(width: 10),
const SizedBox(width: 10), Expanded(child: Text(value.label)),
Expanded(child: Text(value.label)), if (value == executionTarget)
if (value == executionTarget) const Icon(Icons.check_rounded, size: 18),
const Icon(Icons.check_rounded, size: 18), ],
], ),
), );
); })
},
)
.toList(growable: false), .toList(growable: false),
child: _TaskDialogSelectorChipInternal( child: _TaskDialogSelectorChipInternal(
leading: Icon(executionTarget.icon, size: 14, color: palette.textMuted), leading: Icon(executionTarget.icon, size: 14, color: palette.textMuted),
@ -160,7 +150,7 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final displayProvider = selectedProvider.isUnspecified final displayProvider = selectedProvider.isUnspecified
? _fallbackDisplayProvider(context) ? _fallbackDisplayProvider()
: selectedProvider; : selectedProvider;
final isEnabled = providers.isNotEmpty; final isEnabled = providers.isNotEmpty;
@ -204,10 +194,7 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
); );
} }
SingleAgentProvider _fallbackDisplayProvider(BuildContext context) { SingleAgentProvider _fallbackDisplayProvider() {
if (providers.isNotEmpty) {
return providers.first;
}
return SingleAgentProvider( return SingleAgentProvider(
providerId: '', providerId: '',
label: appText('未提供', 'Unavailable'), label: appText('未提供', 'Unavailable'),
@ -218,10 +205,10 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
} }
Future<void> _handleProviderSelected(SingleAgentProvider provider) async { Future<void> _handleProviderSelected(SingleAgentProvider provider) async {
if (executionTarget.isGateway || providers.isEmpty) { if (providers.isEmpty) {
return; return;
} }
await controller.setAssistantSingleAgentProvider(provider); await controller.setAssistantProvider(provider);
} }
} }

View File

@ -128,7 +128,7 @@ String normalizeSingleAgentProviderId(String value) {
return buffer.toString().replaceAll(RegExp(r'^[-_.]+|[-_.]+$'), ''); return buffer.toString().replaceAll(RegExp(r'^[-_.]+|[-_.]+$'), '');
} }
String singleAgentProviderFallbackLabelInternal(String providerId) { String providerFallbackLabelInternal(String providerId) {
final normalized = normalizeSingleAgentProviderId(providerId); final normalized = normalizeSingleAgentProviderId(providerId);
if (normalized.isEmpty) { if (normalized.isEmpty) {
return appText('Bridge Provider', 'Bridge Provider'); return appText('Bridge Provider', 'Bridge Provider');
@ -140,7 +140,7 @@ String singleAgentProviderFallbackLabelInternal(String providerId) {
.join(' '); .join(' ');
} }
String singleAgentProviderFallbackBadgeInternal({ String providerFallbackBadgeInternal({
required String providerId, required String providerId,
required String label, required String label,
}) { }) {
@ -259,10 +259,10 @@ class SingleAgentProvider {
return SingleAgentProvider( return SingleAgentProvider(
providerId: resolvedProviderId, providerId: resolvedProviderId,
label: resolvedLabel.isEmpty label: resolvedLabel.isEmpty
? singleAgentProviderFallbackLabelInternal(resolvedProviderId) ? providerFallbackLabelInternal(resolvedProviderId)
: resolvedLabel, : resolvedLabel,
badge: resolvedBadge.isEmpty badge: resolvedBadge.isEmpty
? singleAgentProviderFallbackBadgeInternal( ? providerFallbackBadgeInternal(
providerId: resolvedProviderId, providerId: resolvedProviderId,
label: resolvedLabel, label: resolvedLabel,
) )
@ -297,10 +297,10 @@ class SingleAgentProvider {
'auto' || '' => unspecified, 'auto' || '' => unspecified,
_ => SingleAgentProvider( _ => SingleAgentProvider(
providerId: normalized, providerId: normalized,
label: singleAgentProviderFallbackLabelInternal(normalized), label: providerFallbackLabelInternal(normalized),
badge: singleAgentProviderFallbackBadgeInternal( badge: providerFallbackBadgeInternal(
providerId: normalized, providerId: normalized,
label: singleAgentProviderFallbackLabelInternal(normalized), label: providerFallbackLabelInternal(normalized),
), ),
), ),
}; };

View File

@ -3,3 +3,6 @@ set -euo pipefail
flutter pub get flutter pub get
flutter analyze 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')), find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsOneWidget, 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( await tester.tap(
find.byKey(const Key('assistant-provider-menu-item-codex')), find.byKey(const Key('assistant-provider-menu-item-codex')),
); );
@ -151,6 +155,8 @@ void main() {
find.byKey(const Key('assistant-provider-menu-item-gemini')), find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsNothing, findsNothing,
); );
expect(find.text('OpenClaw'), findsWidgets);
expect(find.byIcon(Icons.check_rounded), findsOneWidget);
await tester.tap( await tester.tap(
find.byKey(const Key('assistant-provider-menu-item-openclaw')), find.byKey(const Key('assistant-provider-menu-item-openclaw')),
); );
@ -203,6 +209,7 @@ void main() {
find.byKey(const Key('assistant-provider-menu-item-gemini')), find.byKey(const Key('assistant-provider-menu-item-gemini')),
findsOneWidget, findsOneWidget,
); );
expect(find.byIcon(Icons.check_rounded), findsOneWidget);
}); });
testWidgets('shows assistant providers and allows switching provider', ( testWidgets('shows assistant providers and allows switching provider', (
@ -253,6 +260,75 @@ void main() {
); );
}); });
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.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,
),
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.tap(find.byKey(const Key('assistant-provider-button')));
await tester.pumpAndSettle();
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 { testWidgets('uses submit button instead of connect action', (tester) async {
final controller = AppController(); final controller = AppController();
addTearDown(controller.dispose); addTearDown(controller.dispose);

View File

@ -80,7 +80,7 @@ void main() {
expect(controller.currentAssistantExecutionTarget.isAgent, isTrue); expect(controller.currentAssistantExecutionTarget.isAgent, isTrue);
expect( expect(
controller.assistantProviderForSession(controller.currentSessionKey), controller.assistantProviderForSession(controller.currentSessionKey),
SingleAgentProvider.codex, SingleAgentProvider.unspecified,
); );
await controller.setAssistantExecutionTarget( await controller.setAssistantExecutionTarget(
@ -108,23 +108,23 @@ void main() {
); );
test( 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(); final controller = AppController();
addTearDown(controller.dispose); addTearDown(controller.dispose);
final unavailableProvider = controller.resolveProviderForExecutionTarget( final unavailableProvider = controller
'gemini', .resolveProviderForExecutionTarget(
executionTarget: AssistantExecutionTarget.agent, 'gemini',
); executionTarget: AssistantExecutionTarget.agent,
);
expect(unavailableProvider.providerId, 'gemini'); expect(unavailableProvider, SingleAgentProvider.unspecified);
expect(unavailableProvider.enabled, isFalse);
}, },
); );
test( 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 { () async {
final capture = await _startCapabilityServer(); final capture = await _startCapabilityServer();
addTearDown(capture.close); addTearDown(capture.close);
@ -169,18 +169,18 @@ void main() {
await controller.sessionsController.switchSession('session-1'); await controller.sessionsController.switchSession('session-1');
await _waitForRequest(capture, minimumCount: 1); await _waitForRequest(capture, minimumCount: 1);
await Future<void>.delayed(const Duration(milliseconds: 200));
expect(controller.assistantProviderCatalog, isEmpty); expect(controller.assistantProviderCatalog, isEmpty);
final requestCountBefore = capture.requestCount;
await controller.setAssistantExecutionTarget( await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.agent, AssistantExecutionTarget.agent,
); );
await Future<void>.delayed(const Duration(milliseconds: 200));
expect( expect(controller.assistantProviderCatalog, isEmpty);
controller.assistantProviderCatalog.map((item) => item.providerId), expect(capture.requestCount, requestCountBefore);
containsAll(<String>['codex', 'opencode', 'gemini']),
);
expect(capture.requestCount, greaterThanOrEqualTo(2));
expect(capture.lastAuthorizationHeader, 'Bearer bridge-token'); expect(capture.lastAuthorizationHeader, 'Bearer bridge-token');
}, },
); );

View File

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@ -140,6 +141,16 @@ void main() {
), ),
'bridge-token-from-sync', '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,
);
}, },
); );