Route assistant threads through canonical bridge entry

This commit is contained in:
Haitao Pan 2026-04-12 08:59:26 +08:00
parent fbc1ff8246
commit 0078f01a79
12 changed files with 91 additions and 69 deletions

View File

@ -736,16 +736,7 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
}
Uri? resolveBridgeAcpEndpointInternal() {
final rawEndpoint = settings
.acpBridgeServerModeConfig
.cloudSynced
.remoteServerSummary
.endpoint
.trim();
if (rawEndpoint.isEmpty) {
return null;
}
final uri = Uri.tryParse(rawEndpoint);
final uri = Uri.tryParse(kCanonicalBridgeAcpEndpoint);
final scheme = uri?.scheme.trim().toLowerCase() ?? '';
if (uri == null || !kSupportedExternalAcpEndpointSchemes.contains(scheme)) {
return null;

View File

@ -102,8 +102,8 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
) ==
null
? appText(
'当前线程还没有同步到 Bridge Server。请先登录账号并在设置里完成同步后再重试',
'This thread does not have a synced bridge server yet. Sign in and complete Settings sync before trying again.',
'Bridge ACP 入口当前不可用',
'The bridge ACP entrypoint is currently unavailable.',
)
: null;
if (unavailableReason != null) {

View File

@ -96,12 +96,12 @@ String singleAgentUnavailableLabelDesktopInternal(
)) {
return detail.isEmpty
? appText(
'当前没有可用的 Bridge Provider。请先在设置里配置并同步外部 Agent 连接',
'No bridge provider is available. Configure and sync an external agent connection in Settings first.',
'Bridge 当前没有可用 Provider。',
'The bridge does not currently advertise any available providers.',
)
: appText(
'$detail 当前没有可用的 Bridge Provider。请先在设置里配置并同步外部 Agent 连接',
'$detail No bridge provider is available. Configure and sync an external agent connection in Settings first.',
'$detail Bridge 当前没有可用 Provider。',
'$detail The bridge does not currently advertise any available providers.',
);
}
return detail.isEmpty

View File

@ -423,8 +423,8 @@ extension AppControllerDesktopThreadSessions on AppController {
normalizedSessionKey,
)
? appText(
'当前没有可用的 Bridge Provider。请先在设置里配置并同步可用连接',
'No bridge provider is currently available. Configure and sync an available upstream connection in Settings first.',
'Bridge 当前没有可用 Provider。',
'The bridge does not currently advertise any available providers.',
)
: appText(
'当前线程的 Bridge Provider 尚未就绪。',

View File

@ -360,7 +360,14 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
assistantMessageViewModeForSession(currentSessionKey),
singleAgentProvider:
singleAgentProvider ??
singleAgentProviderForSession(currentSessionKey),
settings.sanitizeSingleAgentProviderSelection(
SingleAgentProviderCopy.fromJsonValue(
assistantThreadRecordsInternal[normalizedSessionKey]
?.executionBinding
.providerId ??
'',
),
),
singleAgentProviderSource: ThreadSelectionSource.inherited,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);

View File

@ -94,7 +94,7 @@ class _AppShellState extends State<AppShell> {
title: appText('新对话', 'New conversation'),
executionTarget: target,
messageViewMode: controller.currentAssistantMessageViewMode,
singleAgentProvider: controller.currentSingleAgentProvider,
singleAgentProvider: SingleAgentProvider.unspecified,
);
controller.navigateTo(WorkspaceDestination.assistant);
await controller.switchSession(sessionKey);

View File

@ -304,35 +304,10 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
.endpoint
.trim()
: '';
if (bridgeServerUrl.isEmpty ||
!isSupportedExternalAcpEndpoint(bridgeServerUrl)) {
const result = AccountSyncResult(
state: 'blocked',
message: 'Bridge server is unavailable',
);
await controller.storeInternal.saveAccountSyncState(
AccountSyncState.defaults().copyWith(
syncedDefaults: AccountRemoteProfile.defaults(),
syncState: result.state,
syncMessage: result.message,
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncError: result.message,
profileScope: 'bridge',
tokenConfigured: const AccountTokenConfigured(
bridge: true,
vault: false,
apisix: false,
),
),
);
await controller.reloadDerivedStateInternal();
controller.accountStatusInternal = result.message;
if (!quiet) {
controller.accountBusyInternal = false;
controller.notifyListeners();
}
return result;
}
final resolvedBridgeServerUrl =
isSupportedExternalAcpEndpoint(bridgeServerUrl)
? bridgeServerUrl
: kCanonicalBridgeAcpEndpoint;
await controller.storeInternal.clearAccountManagedSecret(
target: kAccountManagedSecretTargetAIGatewayAccessToken,
);
@ -342,12 +317,12 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
final nextState = AccountSyncState.defaults().copyWith(
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
bridgeServerUrl: bridgeServerUrl,
bridgeServerUrl: resolvedBridgeServerUrl,
),
syncState: 'ready',
syncMessage: 'Bridge access synced',
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
lastSyncSource: bridgeServerUrl,
lastSyncSource: resolvedBridgeServerUrl,
lastSyncError: '',
profileScope: 'bridge',
tokenConfigured: const AccountTokenConfigured(
@ -365,7 +340,10 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
accountIdentifier: '',
lastSyncAt: nextState.lastSyncAtMs,
remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary
.copyWith(endpoint: bridgeServerUrl, hasAdvancedOverrides: false),
.copyWith(
endpoint: resolvedBridgeServerUrl,
hasAdvancedOverrides: false,
),
),
);
final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings(

View File

@ -335,6 +335,7 @@ const List<SingleAgentProvider> kPresetExternalAcpProviders =
const String kCanonicalGatewayProviderId = 'openclaw';
const String kCanonicalGatewayProviderLabel = 'OpenClaw';
const String kCanonicalBridgeAcpEndpoint = 'https://xworkmate-bridge.svc.plus';
const List<SingleAgentProvider> kKnownSingleAgentProviders =
<SingleAgentProvider>[

View File

@ -333,16 +333,19 @@ void main() {
});
group('resolveGatewayAcpAuthorizationHeaderInternal', () {
test('requires synced bridge endpoint before ACP endpoint can resolve', () {
test('resolves ACP endpoint through the canonical bridge entry', () {
final controller = AppController();
addTearDown(controller.dispose);
expect(controller.resolveBridgeAcpEndpointInternal(), isNull);
expect(
controller.resolveBridgeAcpEndpointInternal(),
Uri.parse(kCanonicalBridgeAcpEndpoint),
);
expect(
controller.resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget.singleAgent,
),
isNull,
Uri.parse(kCanonicalBridgeAcpEndpoint),
);
controller.settingsController.snapshotInternal = controller.settings
@ -367,19 +370,19 @@ void main() {
expect(
controller.resolveBridgeAcpEndpointInternal(),
Uri.parse('https://bridge.customer.example/acp'),
Uri.parse(kCanonicalBridgeAcpEndpoint),
);
expect(
controller.resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget.singleAgent,
),
Uri.parse('https://bridge.customer.example/acp'),
Uri.parse(kCanonicalBridgeAcpEndpoint),
);
expect(
controller.resolveExternalAcpEndpointForTargetInternal(
AssistantExecutionTarget.gateway,
),
Uri.parse('https://bridge.customer.example/acp'),
Uri.parse(kCanonicalBridgeAcpEndpoint),
);
});

View File

@ -101,7 +101,7 @@ void main() {
);
test(
'single-agent turns stay blocked until bridge server has been synced',
'single-agent turns go through the canonical bridge entry without synced endpoint state',
() async {
final client = _CapturingGoTaskServiceClient();
final controller = AppController(
@ -121,12 +121,9 @@ void main() {
await controller.sendChatMessage('first turn');
expect(client.requests, isEmpty);
final messages = controller
.requireTaskThreadForSessionInternal(sessionKey)
.messages;
expect(messages, isNotEmpty);
expect(messages.last.text, contains('Bridge Server'));
expect(client.requests, hasLength(1));
expect(client.requests.single.sessionId, sessionKey);
expect(client.requests.single.threadId, sessionKey);
},
);
@ -167,6 +164,38 @@ void main() {
isNot(recordB.workspaceBinding.workspacePath),
);
});
test('new task threads do not inherit another thread provider choice', () {
final controller = AppController(
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.gemini,
],
);
addTearDown(controller.dispose);
const firstSessionKey = 'draft:thread-provider-a';
const secondSessionKey = 'draft:thread-provider-b';
controller.initializeAssistantThreadContext(
firstSessionKey,
executionTarget: AssistantExecutionTarget.singleAgent,
singleAgentProvider: SingleAgentProvider.gemini,
);
controller.initializeAssistantThreadContext(
secondSessionKey,
executionTarget: AssistantExecutionTarget.singleAgent,
);
expect(
controller.singleAgentProviderForSession(firstSessionKey),
SingleAgentProvider.gemini,
);
expect(
controller.singleAgentProviderForSession(secondSessionKey),
SingleAgentProvider.codex,
);
});
});
}

View File

@ -220,6 +220,19 @@ void main() {
find.byKey(const Key('assistant-gateway-provider-button')),
findsOneWidget,
);
final gatewayButton = tester.widget<PopupMenuButton<String>>(
find.byKey(const Key('assistant-gateway-provider-button')),
);
final items = gatewayButton.itemBuilder(
tester.element(
find.byKey(const Key('assistant-gateway-provider-button')),
),
);
expect(items, hasLength(1));
expect(
items.whereType<PopupMenuItem<String>>().single.value,
kCanonicalGatewayProviderId,
);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();

View File

@ -135,7 +135,7 @@ void main() {
});
test(
'login stays blocked when bridge server is not included in sync data',
'login still syncs bridge access when sync data omits bridge server',
() async {
final root = await Directory.systemTemp.createTemp(
'xworkmate-account-auth-missing-bridge-server-',
@ -179,10 +179,10 @@ void main() {
controller.accountStatus,
'Signed in as review@customer.example',
);
expect(controller.accountSyncState?.syncState, 'blocked');
expect(controller.accountSyncState?.syncState, 'ready');
expect(
controller.accountSyncState?.syncMessage,
'Bridge server is unavailable',
'Bridge access synced',
);
expect(
controller
@ -191,7 +191,7 @@ void main() {
.cloudSynced
.remoteServerSummary
.endpoint,
isEmpty,
kCanonicalBridgeAcpEndpoint,
);
expect(
await store.loadAccountManagedSecret(