test: lock provider selection mainline contract
This commit is contained in:
parent
51c90e9613
commit
1f977caee2
18
.github/workflows/build-and-release.yml
vendored
18
.github/workflows/build-and-release.yml
vendored
@ -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') }}
|
||||||
|
|||||||
@ -587,6 +587,16 @@ class AppController extends ChangeNotifier {
|
|||||||
.toList(growable: false);
|
.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) {
|
||||||
final normalizedProviderId = normalizeSingleAgentProviderId(providerId);
|
final normalizedProviderId = normalizeSingleAgentProviderId(providerId);
|
||||||
if (normalizedProviderId.isEmpty) {
|
if (normalizedProviderId.isEmpty) {
|
||||||
@ -603,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 ?? '',
|
||||||
@ -615,16 +626,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -300,9 +300,11 @@ 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 =
|
||||||
selectedProviderSource ??
|
selectedProviderSource ??
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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;
|
||||||
@ -125,9 +108,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
|
|||||||
notifyIfActiveInternal();
|
notifyIfActiveInternal();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setAssistantProvider(
|
Future<void> setAssistantProvider(SingleAgentProvider provider) async {
|
||||||
SingleAgentProvider provider,
|
|
||||||
) async {
|
|
||||||
final executionTarget = assistantExecutionTargetForSession(
|
final executionTarget = assistantExecutionTargetForSession(
|
||||||
sessionsControllerInternal.currentSessionKey,
|
sessionsControllerInternal.currentSessionKey,
|
||||||
);
|
);
|
||||||
@ -135,6 +116,9 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
|
|||||||
provider.providerId,
|
provider.providerId,
|
||||||
executionTarget: executionTarget,
|
executionTarget: executionTarget,
|
||||||
);
|
);
|
||||||
|
if (resolvedProvider.isUnspecified) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final sessionKey = normalizedAssistantSessionKeyInternal(
|
final sessionKey = normalizedAssistantSessionKeyInternal(
|
||||||
sessionsControllerInternal.currentSessionKey,
|
sessionsControllerInternal.currentSessionKey,
|
||||||
);
|
);
|
||||||
@ -162,9 +146,7 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
|
|||||||
executionTargetSource: ThreadSelectionSource.explicit,
|
executionTargetSource: ThreadSelectionSource.explicit,
|
||||||
selectedProvider: resolvedProvider,
|
selectedProvider: resolvedProvider,
|
||||||
selectedProviderSource: ThreadSelectionSource.explicit,
|
selectedProviderSource: ThreadSelectionSource.explicit,
|
||||||
gatewayEntryState: gatewayEntryStateForTargetInternal(
|
gatewayEntryState: gatewayEntryStateForTargetInternal(executionTarget),
|
||||||
executionTarget,
|
|
||||||
),
|
|
||||||
latestResolvedProviderId: '',
|
latestResolvedProviderId: '',
|
||||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||||
);
|
);
|
||||||
@ -224,9 +206,9 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
|
|||||||
upsertTaskThreadInternal(
|
upsertTaskThreadInternal(
|
||||||
normalizedSessionKey,
|
normalizedSessionKey,
|
||||||
selectedProvider: resolveProviderForExecutionTarget(
|
selectedProvider: resolveProviderForExecutionTarget(
|
||||||
taskThreadForSessionInternal(normalizedSessionKey)
|
taskThreadForSessionInternal(
|
||||||
?.executionBinding
|
normalizedSessionKey,
|
||||||
.providerId,
|
)?.executionBinding.providerId,
|
||||||
executionTarget: resolvedTarget,
|
executionTarget: resolvedTarget,
|
||||||
),
|
),
|
||||||
selectedProviderSource: ThreadSelectionSource.explicit,
|
selectedProviderSource: ThreadSelectionSource.explicit,
|
||||||
|
|||||||
@ -97,8 +97,7 @@ 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,
|
||||||
@ -114,8 +113,7 @@ class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.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),
|
||||||
@ -197,9 +195,6 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SingleAgentProvider _fallbackDisplayProvider() {
|
SingleAgentProvider _fallbackDisplayProvider() {
|
||||||
if (providers.isNotEmpty) {
|
|
||||||
return providers.first;
|
|
||||||
}
|
|
||||||
return SingleAgentProvider(
|
return SingleAgentProvider(
|
||||||
providerId: '',
|
providerId: '',
|
||||||
label: appText('未提供', 'Unavailable'),
|
label: appText('未提供', 'Unavailable'),
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
181
scripts/ci/verify_remote_provider_contract.sh
Executable file
181
scripts/ci/verify_remote_provider_contract.sh
Executable 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}"
|
||||||
@ -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,55 +260,35 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('allows switching gateway providers from the dynamic catalog', (
|
testWidgets(
|
||||||
tester,
|
'does not reverse-infer a menu selection from a stale saved provider',
|
||||||
) async {
|
(tester) async {
|
||||||
final controller = AppController(
|
final controller = AppController(
|
||||||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||||||
SingleAgentProvider.codex,
|
SingleAgentProvider.codex,
|
||||||
SingleAgentProvider.opencode,
|
SingleAgentProvider.opencode,
|
||||||
SingleAgentProvider.gemini,
|
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);
|
addTearDown(controller.dispose);
|
||||||
|
|
||||||
await controller.sessionsController.switchSession('session-1');
|
await controller.sessionsController.switchSession('session-1');
|
||||||
controller.initializeAssistantThreadContext(
|
controller.initializeAssistantThreadContext(
|
||||||
'session-1',
|
'session-1',
|
||||||
executionTarget: AssistantExecutionTarget.gateway,
|
executionTarget: AssistantExecutionTarget.agent,
|
||||||
messageViewMode: controller.assistantMessageViewModeForSession(
|
messageViewMode: controller.assistantMessageViewModeForSession(
|
||||||
'session-1',
|
'session-1',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final gatewayThread = controller
|
final staleThread = controller
|
||||||
.requireTaskThreadForSessionInternal('session-1')
|
.requireTaskThreadForSessionInternal('session-1')
|
||||||
.copyWith(
|
.copyWith(
|
||||||
executionBinding: ExecutionBinding(
|
executionBinding: ExecutionBinding(
|
||||||
executionMode: threadExecutionModeFromAssistantExecutionTarget(
|
executionMode: threadExecutionModeFromAssistantExecutionTarget(
|
||||||
AssistantExecutionTarget.gateway,
|
AssistantExecutionTarget.agent,
|
||||||
),
|
),
|
||||||
executorId: SingleAgentProvider.openclaw.providerId,
|
executorId: 'legacy-provider',
|
||||||
providerId: SingleAgentProvider.openclaw.providerId,
|
providerId: 'legacy-provider',
|
||||||
endpointId: '',
|
endpointId: '',
|
||||||
executionModeSource: ThreadSelectionSource.explicit,
|
executionModeSource: ThreadSelectionSource.explicit,
|
||||||
providerSource: ThreadSelectionSource.explicit,
|
providerSource: ThreadSelectionSource.explicit,
|
||||||
@ -309,7 +296,7 @@ void main() {
|
|||||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||||
);
|
);
|
||||||
controller.taskThreadRepositoryInternal.replace(
|
controller.taskThreadRepositoryInternal.replace(
|
||||||
gatewayThread,
|
staleThread,
|
||||||
persist: false,
|
persist: false,
|
||||||
);
|
);
|
||||||
controller.notifyListeners();
|
controller.notifyListeners();
|
||||||
@ -322,31 +309,25 @@ void main() {
|
|||||||
await tester.tap(find.byKey(const Key('assistant-provider-button')));
|
await tester.tap(find.byKey(const Key('assistant-provider-button')));
|
||||||
await tester.pumpAndSettle();
|
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(
|
expect(
|
||||||
find.byKey(const Key('assistant-provider-menu-item-codex')),
|
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,
|
findsNothing,
|
||||||
);
|
);
|
||||||
|
expect(find.byIcon(Icons.check_rounded), findsNothing);
|
||||||
await tester.tap(
|
},
|
||||||
find.byKey(const Key('assistant-provider-menu-item-hermes')),
|
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
controller
|
|
||||||
.assistantProviderForSession(controller.currentSessionKey)
|
|
||||||
.providerId,
|
|
||||||
'hermes',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
@ -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
|
||||||
|
.resolveProviderForExecutionTarget(
|
||||||
'gemini',
|
'gemini',
|
||||||
executionTarget: AssistantExecutionTarget.agent,
|
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');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user