Route assistant threads through canonical bridge entry
This commit is contained in:
parent
fbc1ff8246
commit
0078f01a79
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 尚未就绪。',
|
||||
|
||||
@ -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(),
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>[
|
||||
|
||||
@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user