Merge pull request #12 from x-evor/codex/provider-selection-test-mainline
Codex/provider selection test mainline
This commit is contained in:
commit
58b8e398f2
18
.github/workflows/build-and-release.yml
vendored
18
.github/workflows/build-and-release.yml
vendored
@ -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') }}
|
||||
|
||||
@ -572,16 +572,29 @@ class AppController extends ChangeNotifier {
|
||||
List<AssistantExecutionTarget> get bridgeAvailableExecutionTargets =>
|
||||
compactAssistantExecutionTargets(bridgeAvailableExecutionTargetsInternal);
|
||||
|
||||
List<SingleAgentProvider> get assistantProviderCatalogForDisplay {
|
||||
return assistantProviderCatalog;
|
||||
}
|
||||
|
||||
List<SingleAgentProvider> providerCatalogForExecutionTarget(
|
||||
AssistantExecutionTarget executionTarget,
|
||||
) {
|
||||
return executionTarget.isGateway
|
||||
final source = executionTarget.isGateway
|
||||
? 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) {
|
||||
@ -600,6 +613,7 @@ class AppController extends ChangeNotifier {
|
||||
SingleAgentProvider resolveProviderForExecutionTarget(
|
||||
String? providerId, {
|
||||
required AssistantExecutionTarget executionTarget,
|
||||
bool defaultToCatalog = false,
|
||||
}) {
|
||||
final normalizedProviderId = normalizeSingleAgentProviderId(
|
||||
providerId ?? '',
|
||||
@ -612,26 +626,12 @@ 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;
|
||||
}
|
||||
|
||||
SingleAgentProvider resolveAssistantProvider(String? providerId) {
|
||||
return resolveProviderForExecutionTarget(
|
||||
providerId,
|
||||
executionTarget: AssistantExecutionTarget.agent,
|
||||
);
|
||||
}
|
||||
|
||||
SingleAgentProvider assistantProviderForSession(String sessionKey) {
|
||||
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
|
||||
sessionKey,
|
||||
|
||||
@ -227,9 +227,9 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
List<AssistantThreadSkillEntry>? importedSkills,
|
||||
List<String>? selectedSkillKeys,
|
||||
String? assistantModelId,
|
||||
SingleAgentProvider? singleAgentProvider,
|
||||
SingleAgentProvider? selectedProvider,
|
||||
ThreadSelectionSource? executionTargetSource,
|
||||
ThreadSelectionSource? singleAgentProviderSource,
|
||||
ThreadSelectionSource? selectedProviderSource,
|
||||
ThreadSelectionSource? assistantModelSource,
|
||||
ThreadSelectionSource? selectedSkillsSource,
|
||||
String? gatewayEntryState,
|
||||
@ -291,8 +291,8 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.',
|
||||
);
|
||||
}
|
||||
final requestedProvider = singleAgentProvider?.isUnspecified == false
|
||||
? singleAgentProvider
|
||||
final requestedProvider = selectedProvider?.isUnspecified == false
|
||||
? selectedProvider
|
||||
: null;
|
||||
final nextProviderId = normalizeSingleAgentProviderId(
|
||||
requestedProvider?.providerId ??
|
||||
@ -300,12 +300,14 @@ extension AppControllerDesktopSkillPermissions on AppController {
|
||||
existing?.contextState.latestResolvedProviderId ??
|
||||
'',
|
||||
);
|
||||
final shouldDefaultProvider = existing == null && nextProviderId.isEmpty;
|
||||
final nextProvider = resolveProviderForExecutionTarget(
|
||||
nextProviderId,
|
||||
executionTarget: nextExecutionTarget,
|
||||
defaultToCatalog: shouldDefaultProvider,
|
||||
);
|
||||
final nextProviderSource =
|
||||
singleAgentProviderSource ??
|
||||
selectedProviderSource ??
|
||||
existing?.executionBinding.providerSource ??
|
||||
ThreadSelectionSource.inherited;
|
||||
final nextExecutionBinding =
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
@ -99,13 +82,13 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
|
||||
sessionsControllerInternal.currentSessionKey,
|
||||
executionTarget: resolvedTarget,
|
||||
executionTargetSource: ThreadSelectionSource.explicit,
|
||||
singleAgentProvider: resolveProviderForExecutionTarget(
|
||||
selectedProvider: resolveProviderForExecutionTarget(
|
||||
taskThreadForSessionInternal(
|
||||
sessionsControllerInternal.currentSessionKey,
|
||||
)?.executionBinding.providerId,
|
||||
executionTarget: resolvedTarget,
|
||||
),
|
||||
singleAgentProviderSource: ThreadSelectionSource.explicit,
|
||||
selectedProviderSource: ThreadSelectionSource.explicit,
|
||||
gatewayEntryState: gatewayEntryStateForTargetInternal(resolvedTarget),
|
||||
latestResolvedRuntimeModel: '',
|
||||
latestResolvedProviderId: '',
|
||||
@ -125,10 +108,17 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
|
||||
notifyIfActiveInternal();
|
||||
}
|
||||
|
||||
Future<void> setAssistantSingleAgentProvider(
|
||||
SingleAgentProvider provider,
|
||||
) async {
|
||||
final resolvedProvider = resolveAssistantProvider(provider.providerId);
|
||||
Future<void> setAssistantProvider(SingleAgentProvider provider) async {
|
||||
final executionTarget = assistantExecutionTargetForSession(
|
||||
sessionsControllerInternal.currentSessionKey,
|
||||
);
|
||||
final resolvedProvider = resolveProviderForExecutionTarget(
|
||||
provider.providerId,
|
||||
executionTarget: executionTarget,
|
||||
);
|
||||
if (resolvedProvider.isUnspecified) {
|
||||
return;
|
||||
}
|
||||
final sessionKey = normalizedAssistantSessionKeyInternal(
|
||||
sessionsControllerInternal.currentSessionKey,
|
||||
);
|
||||
@ -146,27 +136,20 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
|
||||
if (!assistantThreadRecordsInternal.containsKey(sessionKey)) {
|
||||
initializeAssistantThreadContext(
|
||||
sessionKey,
|
||||
executionTarget: AssistantExecutionTarget.agent,
|
||||
executionTarget: executionTarget,
|
||||
messageViewMode: assistantMessageViewModeForSession(sessionKey),
|
||||
);
|
||||
}
|
||||
upsertTaskThreadInternal(
|
||||
sessionKey,
|
||||
executionTarget: AssistantExecutionTarget.agent,
|
||||
executionTarget: executionTarget,
|
||||
executionTargetSource: ThreadSelectionSource.explicit,
|
||||
singleAgentProvider: resolvedProvider,
|
||||
singleAgentProviderSource: ThreadSelectionSource.explicit,
|
||||
gatewayEntryState: gatewayEntryStateForTargetInternal(
|
||||
AssistantExecutionTarget.agent,
|
||||
),
|
||||
selectedProvider: resolvedProvider,
|
||||
selectedProviderSource: ThreadSelectionSource.explicit,
|
||||
gatewayEntryState: gatewayEntryStateForTargetInternal(executionTarget),
|
||||
latestResolvedProviderId: '',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
await applyAssistantExecutionTargetInternal(
|
||||
AssistantExecutionTarget.agent,
|
||||
sessionKey: sessionKey,
|
||||
persistDefaultSelection: true,
|
||||
);
|
||||
await flushAssistantThreadPersistenceInternal();
|
||||
recomputeTasksInternal();
|
||||
notifyIfActiveInternal();
|
||||
@ -222,13 +205,13 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
|
||||
);
|
||||
upsertTaskThreadInternal(
|
||||
normalizedSessionKey,
|
||||
singleAgentProvider: resolveProviderForExecutionTarget(
|
||||
taskThreadForSessionInternal(normalizedSessionKey)
|
||||
?.executionBinding
|
||||
.providerId,
|
||||
selectedProvider: resolveProviderForExecutionTarget(
|
||||
taskThreadForSessionInternal(
|
||||
normalizedSessionKey,
|
||||
)?.executionBinding.providerId,
|
||||
executionTarget: resolvedTarget,
|
||||
),
|
||||
singleAgentProviderSource: ThreadSelectionSource.explicit,
|
||||
selectedProviderSource: ThreadSelectionSource.explicit,
|
||||
latestResolvedRuntimeModel: '',
|
||||
latestResolvedProviderId: '',
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
|
||||
@ -44,9 +44,8 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget {
|
||||
final executionTarget = collapseAssistantExecutionTargetForDisplay(
|
||||
currentExecutionTarget,
|
||||
);
|
||||
final providerMenuProviders = _taskDialogProviderCatalogForTarget(
|
||||
controller: controller,
|
||||
executionTarget: executionTarget,
|
||||
final providerMenuProviders = controller.providerCatalogForExecutionTarget(
|
||||
executionTarget,
|
||||
);
|
||||
|
||||
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 {
|
||||
const _TaskDialogExecutionTargetMenuButtonInternal({
|
||||
required this.controller,
|
||||
@ -105,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),
|
||||
@ -160,7 +150,7 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayProvider = selectedProvider.isUnspecified
|
||||
? _fallbackDisplayProvider(context)
|
||||
? _fallbackDisplayProvider()
|
||||
: selectedProvider;
|
||||
final isEnabled = providers.isNotEmpty;
|
||||
|
||||
@ -204,10 +194,7 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
SingleAgentProvider _fallbackDisplayProvider(BuildContext context) {
|
||||
if (providers.isNotEmpty) {
|
||||
return providers.first;
|
||||
}
|
||||
SingleAgentProvider _fallbackDisplayProvider() {
|
||||
return SingleAgentProvider(
|
||||
providerId: '',
|
||||
label: appText('未提供', 'Unavailable'),
|
||||
@ -218,10 +205,10 @@ class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<void> _handleProviderSelected(SingleAgentProvider provider) async {
|
||||
if (executionTarget.isGateway || providers.isEmpty) {
|
||||
if (providers.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await controller.setAssistantSingleAgentProvider(provider);
|
||||
await controller.setAssistantProvider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -128,7 +128,7 @@ String normalizeSingleAgentProviderId(String value) {
|
||||
return buffer.toString().replaceAll(RegExp(r'^[-_.]+|[-_.]+$'), '');
|
||||
}
|
||||
|
||||
String singleAgentProviderFallbackLabelInternal(String providerId) {
|
||||
String providerFallbackLabelInternal(String providerId) {
|
||||
final normalized = normalizeSingleAgentProviderId(providerId);
|
||||
if (normalized.isEmpty) {
|
||||
return appText('Bridge Provider', 'Bridge Provider');
|
||||
@ -140,7 +140,7 @@ String singleAgentProviderFallbackLabelInternal(String providerId) {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
String singleAgentProviderFallbackBadgeInternal({
|
||||
String providerFallbackBadgeInternal({
|
||||
required String providerId,
|
||||
required String label,
|
||||
}) {
|
||||
@ -259,10 +259,10 @@ class SingleAgentProvider {
|
||||
return SingleAgentProvider(
|
||||
providerId: resolvedProviderId,
|
||||
label: resolvedLabel.isEmpty
|
||||
? singleAgentProviderFallbackLabelInternal(resolvedProviderId)
|
||||
? providerFallbackLabelInternal(resolvedProviderId)
|
||||
: resolvedLabel,
|
||||
badge: resolvedBadge.isEmpty
|
||||
? singleAgentProviderFallbackBadgeInternal(
|
||||
? providerFallbackBadgeInternal(
|
||||
providerId: resolvedProviderId,
|
||||
label: resolvedLabel,
|
||||
)
|
||||
@ -297,10 +297,10 @@ class SingleAgentProvider {
|
||||
'auto' || '' => unspecified,
|
||||
_ => SingleAgentProvider(
|
||||
providerId: normalized,
|
||||
label: singleAgentProviderFallbackLabelInternal(normalized),
|
||||
badge: singleAgentProviderFallbackBadgeInternal(
|
||||
label: providerFallbackLabelInternal(normalized),
|
||||
badge: providerFallbackBadgeInternal(
|
||||
providerId: normalized,
|
||||
label: singleAgentProviderFallbackLabelInternal(normalized),
|
||||
label: providerFallbackLabelInternal(normalized),
|
||||
),
|
||||
),
|
||||
};
|
||||
|
||||
@ -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
|
||||
|
||||
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')),
|
||||
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,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 {
|
||||
final controller = AppController();
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
@ -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');
|
||||
},
|
||||
);
|
||||
|
||||
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user