Align app bridge API contract

This commit is contained in:
Haitao Pan 2026-04-11 17:04:33 +08:00
parent e9ff99cacd
commit 806aef8ba5
22 changed files with 800 additions and 131 deletions

View File

@ -0,0 +1,45 @@
# APP 侧对齐当前 xworkmate-bridge API
本轮 APP 侧对接以当前 `xworkmate-bridge` 实际返回为准,不再额外定义前端私有 contract。
## 当前后端事实
- `acp.capabilities` 当前继续返回:
- `singleAgent`
- `multiAgent`
- `providerCatalog`
- `gatewayProviders`
- `xworkmate.routing.resolve` 当前继续返回:
- `resolvedExecutionTarget`
- `resolvedEndpointTarget`
- `resolvedProviderId`
- `resolvedGatewayProviderId`
- `session.start` / `session.message` 当前请求仍消费线程级 `workingDirectory`
- 当前 bridge 还没有项目列表接口
## APP 侧执行约定
- APP 模式选择入口只暴露:
- `single-agent`
- `gateway`
- `multi-agent` 仍作为 bridge 可返回状态被解析和展示,但不再作为用户主动选择入口
- 线程级“项目选择”当前直接等价于 bridge 请求里的 `workingDirectory`
- `workingDirectory` 与本地 `workspaceBinding` 分离:
- `workingDirectory`: 发给 bridge 的执行目录
- `workspaceBinding`: APP 本地 artifact 回写目录
## 当前实现结果
- 每个线程持久化 `selectedWorkingDirectory`
- `single-agent``gateway` 都复用同一个线程级 `selectedWorkingDirectory`
- follow-up 请求继续沿用:
- `sessionId == threadId == sessionKey`
- 同一线程绑定的 `workingDirectory`
- 若线程没有选项目目录APP 会阻断发送并提示先选择项目
## 兼容策略
- 继续解析 `resolvedEndpointTarget`,但它不再作为前端主状态来源
- 继续解析 `multiAgent`,但不提供手动切换入口
- `providerCatalog` 继续驱动 single-agent provider picker
- `gatewayProviders` 继续按 bridge 返回结构保存和消费,不在 APP 侧硬编码扩展

View File

@ -121,7 +121,7 @@ extension AppControllerDesktopExternalAcpRouting on AppController {
String _routingExecutionTargetValueInternal(AssistantExecutionTarget target) {
return switch (target) {
AssistantExecutionTarget.singleAgent => 'agent',
AssistantExecutionTarget.singleAgent => 'single-agent',
AssistantExecutionTarget.gateway => 'gateway',
};
}

View File

@ -158,7 +158,7 @@ String? assistantWorkingDirectoryForSessionRuntimeInternal(
String sessionKey,
) {
final candidate = controller
.assistantWorkspacePathForSession(sessionKey)
.assistantSelectedWorkingDirectoryForSession(sessionKey)
.trim();
if (candidate.isEmpty) {
return null;

View File

@ -94,7 +94,7 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
);
final unavailableReason =
routingResolution.unavailable ||
(routingResolution.resolvedExecutionTarget == 'agent' &&
(routingResolution.resolvedExecutionTarget == 'single-agent' &&
effectiveProvider == null)
? (routingResolution.unavailableMessage.isNotEmpty
? routingResolution.unavailableMessage
@ -176,7 +176,7 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
routing,
routingResolution,
),
routingHint: 'agent',
routingHint: 'single-agent',
provider: effectiveProvider ?? SingleAgentProvider.unspecified,
remoteWorkingDirectoryHint:
controller
@ -238,7 +238,8 @@ ExternalCodeAgentAcpRoutingConfig _resolvedRoutingConfigDesktopInternal(
final explicitExecutionTarget = switch (resolution.resolvedExecutionTarget
.trim()
.toLowerCase()) {
'agent' => 'agent',
'single-agent' => 'single-agent',
'multi-agent' => 'multi-agent',
'gateway' => 'gateway',
_ => original.explicitExecutionTarget,
};

View File

@ -237,6 +237,8 @@ extension AppControllerDesktopSkillPermissions on AppController {
ThreadSelectionSource? singleAgentProviderSource,
ThreadSelectionSource? assistantModelSource,
ThreadSelectionSource? selectedSkillsSource,
String? selectedWorkingDirectory,
bool clearSelectedWorkingDirectory = false,
String? gatewayEntryState,
String? latestResolvedRuntimeModel,
String? lifecycleStatus,
@ -348,6 +350,7 @@ extension AppControllerDesktopSkillPermissions on AppController {
permissionLevel: AssistantPermissionLevel.defaultAccess,
messageViewMode: AssistantMessageViewMode.rendered,
latestResolvedRuntimeModel: '',
selectedWorkingDirectory: null,
gatewayEntryState: gatewayEntryStateForTargetInternal(
nextExecutionTarget,
),
@ -372,6 +375,8 @@ extension AppControllerDesktopSkillPermissions on AppController {
selectedSkillsSource ??
existing?.contextState.selectedSkillsSource,
latestResolvedRuntimeModel: latestResolvedRuntimeModel,
selectedWorkingDirectory: selectedWorkingDirectory?.trim(),
clearSelectedWorkingDirectory: clearSelectedWorkingDirectory,
gatewayEntryState: gatewayEntryState,
lastRemoteWorkingDirectory: lastRemoteWorkingDirectory,
lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind,

View File

@ -247,14 +247,14 @@ extension AppControllerDesktopThreadActions on AppController {
currentSessionKey,
executionTarget: currentTarget,
);
var workspacePath = assistantWorkspacePathForSession(
final workingDirectory = assistantSelectedWorkingDirectoryForSession(
currentSessionKey,
).trim();
if (workspacePath.isEmpty) {
if (workingDirectory.isEmpty) {
final error = StateError(
appText(
'当前线程缺少工作路径,无法运行。请先配置工作区根目录后再试',
'This thread has no workspace path, so it cannot run. Configure a workspace root and try again.',
'当前线程尚未选择项目目录,无法运行。请先选择项目',
'This thread has no project directory yet. Select a project before running.',
),
);
appendAssistantThreadMessageInternal(
@ -312,9 +312,7 @@ extension AppControllerDesktopThreadActions on AppController {
threadId: sessionKey,
target: currentTarget,
prompt: message,
workingDirectory: assistantWorkspacePathForSession(
sessionKey,
).trim(),
workingDirectory: workingDirectory,
model: assistantModelForSession(sessionKey),
thinking: thinking,
selectedSkills: selectedSkillLabels,

View File

@ -216,6 +216,35 @@ extension AppControllerDesktopThreadSessions on AppController {
'';
}
String assistantSelectedWorkingDirectoryForSession(String sessionKey) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
return taskThreadForSessionInternal(
normalizedSessionKey,
)?.selectedWorkingDirectory?.trim() ??
'';
}
String assistantSelectedWorkingDirectoryDisplayLabelForSession(
String sessionKey,
) {
final workingDirectory = assistantSelectedWorkingDirectoryForSession(
sessionKey,
);
if (workingDirectory.isEmpty) {
return appText('选择项目', 'Select Project');
}
final segments = workingDirectory
.split(RegExp(r'[\\/]'))
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
if (segments.isEmpty) {
return workingDirectory;
}
return segments.last;
}
Future<AssistantArtifactSnapshot> loadAssistantArtifactSnapshot({
String? sessionKey,
}) {

View File

@ -372,6 +372,32 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
notifyIfActiveInternal();
}
Future<void> saveAssistantSelectedWorkingDirectoryForSession(
String sessionKey,
String workingDirectory,
) async {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final normalizedWorkingDirectory = workingDirectory.trim();
if (normalizedWorkingDirectory.isEmpty) {
upsertTaskThreadInternal(
normalizedSessionKey,
clearSelectedWorkingDirectory: true,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
} else {
upsertTaskThreadInternal(
normalizedSessionKey,
selectedWorkingDirectory: normalizedWorkingDirectory,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
}
recomputeTasksInternal();
notifyIfActiveInternal();
await flushAssistantThreadPersistenceInternal();
}
Future<void> refreshSingleAgentSkillsForSession(String sessionKey) async {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,

View File

@ -343,6 +343,31 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
});
}
Future<void> pickWorkingDirectoryInternal() async {
final controller = widget.controller;
final sessionKey = controller.currentSessionKey;
final selectedWorkingDirectory = controller
.assistantSelectedWorkingDirectoryForSession(sessionKey)
.trim();
final fallbackWorkspacePath = controller
.assistantWorkspacePathForSession(sessionKey)
.trim();
final initialDirectory = selectedWorkingDirectory.isNotEmpty
? selectedWorkingDirectory
: fallbackWorkspacePath;
final pickedDirectory = await getDirectoryPath(
confirmButtonText: appText('选择项目', 'Select Project'),
initialDirectory: initialDirectory.isNotEmpty ? initialDirectory : null,
);
if (!mounted || pickedDirectory == null || pickedDirectory.trim().isEmpty) {
return;
}
await controller.saveAssistantSelectedWorkingDirectoryForSession(
sessionKey,
pickedDirectory,
);
}
@override
Widget build(BuildContext context) {
final palette = context.palette;
@ -375,6 +400,15 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
final selectedSkills = widget.availableSkills
.where((skill) => widget.selectedSkillKeys.contains(skill.key))
.toList(growable: false);
final selectedWorkingDirectory = controller
.assistantSelectedWorkingDirectoryForSession(
controller.currentSessionKey,
)
.trim();
final selectedWorkingDirectoryLabel = controller
.assistantSelectedWorkingDirectoryDisplayLabelForSession(
controller.currentSessionKey,
);
final submitLabel = connected
? appText('提交', 'Submit')
: singleAgent
@ -471,6 +505,57 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
),
const SizedBox(width: 4),
],
Tooltip(
message: selectedWorkingDirectory.isEmpty
? appText(
'选择 bridge 执行使用的项目目录workingDirectory',
'Choose the bridge project directory (workingDirectory).',
)
: selectedWorkingDirectory,
child: InkWell(
key: const Key('assistant-working-directory-button'),
onTap: () => unawaited(pickWorkingDirectoryInternal()),
borderRadius: BorderRadius.circular(AppRadius.chip),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(AppRadius.chip),
border: Border.all(color: palette.strokeSoft),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_open_rounded,
size: 16,
color: selectedWorkingDirectory.isEmpty
? palette.textMuted
: palette.textPrimary,
),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 160),
child: Text(
selectedWorkingDirectoryLabel,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelMedium
?.copyWith(
color: selectedWorkingDirectory.isEmpty
? palette.textMuted
: palette.textPrimary,
),
),
),
],
),
),
),
),
const SizedBox(width: 4),
if (singleAgent) ...[
PopupMenuButton<SingleAgentProvider>(
key: const Key('assistant-single-agent-provider-button'),
@ -557,64 +642,6 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
),
const SizedBox(width: 4),
],
if (uiFeatures.supportsMultiAgent) ...[
Tooltip(
message: appText(
'多 Agent 协作模式Architect 调度/文档 → Lead Engineer 主程 → Worker/Review',
'Multi-Agent Collaboration Mode (Architect docs/scheduler -> Lead Engineer -> Worker/Review)',
),
child: AnimatedBuilder(
animation: controller.multiAgentOrchestrator,
builder: (context, _) {
final collab = controller.multiAgentOrchestrator;
final enabled = collab.config.enabled;
return IconButton(
key: const Key('assistant-collaboration-toggle'),
icon: Icon(
enabled
? Icons.auto_awesome
: Icons.auto_awesome_outlined,
size: 20,
color: enabled ? Colors.orange : null,
),
onPressed:
collab.isRunning ||
controller.isMultiAgentRunPending
? null
: () => unawaited(
controller.saveMultiAgentConfig(
collab.config.copyWith(enabled: !enabled),
),
),
splashRadius: 18,
);
},
),
),
AnimatedBuilder(
animation: controller.multiAgentOrchestrator,
builder: (context, _) {
final collab = controller.multiAgentOrchestrator;
if (!collab.config.enabled) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(left: 4),
child: ComposerToolbarChipInternal(
icon: Icons.hub_rounded,
tooltip: collab.config.usesAris
? appText('多智能体模式: ARIS', 'Multi-agent mode: ARIS')
: appText('多智能体模式: 原生', 'Multi-agent mode: Native'),
showChevron: false,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
),
);
},
),
],
],
),
const SizedBox(height: 8),

View File

@ -140,9 +140,14 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
inputControllerInternal.clear();
try {
if (uiFeatures.supportsMultiAgent &&
controller.settings.multiAgent.enabled) {
final collaborationAttachments = submittedAttachments
final attachmentPayloads = await buildAttachmentPayloadsInternal(
submittedAttachments,
);
await controller.sendChatMessage(
prompt,
thinking: thinkingLabelInternal,
attachments: attachmentPayloads,
localAttachments: submittedAttachments
.map(
(item) => CollaborationAttachment(
name: item.name,
@ -150,33 +155,9 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
path: item.path,
),
)
.toList(growable: false);
await controller.runMultiAgentCollaboration(
rawPrompt: rawPrompt,
composedPrompt: prompt,
attachments: collaborationAttachments,
selectedSkillLabels: selectedSkillLabels,
);
} else {
final attachmentPayloads = await buildAttachmentPayloadsInternal(
submittedAttachments,
);
await controller.sendChatMessage(
prompt,
thinking: thinkingLabelInternal,
attachments: attachmentPayloads,
localAttachments: submittedAttachments
.map(
(item) => CollaborationAttachment(
name: item.name,
description: item.mimeType,
path: item.path,
),
)
.toList(growable: false),
selectedSkillLabels: selectedSkillLabels,
);
}
.toList(growable: false),
selectedSkillLabels: selectedSkillLabels,
);
} catch (_) {
if (!mounted) {
rethrow;
@ -440,11 +421,12 @@ extension AssistantPageStateActionsInternal on AssistantPageStateInternal {
final sessionKey = buildDraftSessionKeyInternal(widget.controller);
final inheritedTarget = pickDraftThreadExecutionTargetInternal(
currentTarget: widget.controller.currentAssistantExecutionTarget,
visibleTargets: widget.controller
.visibleAssistantExecutionTargets(const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.gateway,
]),
visibleTargets: widget.controller.visibleAssistantExecutionTargets(
const <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.gateway,
],
),
localWorkspaceAvailable: widget.controller.settings.workspacePath
.trim()
.isNotEmpty,

View File

@ -40,6 +40,9 @@ class ExternalCodeAgentAcpDesktopTransport
final providerCatalog = _parseProviderCatalog(
result['providerCatalog'] ?? caps['providerCatalog'],
);
final gatewayProviders = _castMapList(
result['gatewayProviders'] ?? caps['gatewayProviders'],
);
return ExternalCodeAgentAcpCapabilities(
singleAgent:
_boolValue(result['singleAgent']) ??
@ -50,6 +53,7 @@ class ExternalCodeAgentAcpDesktopTransport
_boolValue(caps['multi_agent']) ??
true,
providerCatalog: providerCatalog,
gatewayProviders: gatewayProviders,
raw: result,
);
}
@ -173,6 +177,10 @@ class ExternalCodeAgentAcpDesktopTransport
return const <Object?>[];
}
List<Map<String, dynamic>> _castMapList(Object? raw) {
return _asList(raw).map(_castMap).toList(growable: false);
}
bool? _boolValue(Object? raw) {
if (raw is bool) {
return raw;

View File

@ -9,6 +9,7 @@ class ExternalCodeAgentAcpCapabilities {
required this.singleAgent,
required this.multiAgent,
required this.providerCatalog,
required this.gatewayProviders,
required this.raw,
});
@ -16,11 +17,13 @@ class ExternalCodeAgentAcpCapabilities {
: singleAgent = false,
multiAgent = false,
providerCatalog = const <SingleAgentProvider>[],
gatewayProviders = const <Map<String, dynamic>>[],
raw = const <String, dynamic>{};
final bool singleAgent;
final bool multiAgent;
final List<SingleAgentProvider> providerCatalog;
final List<Map<String, dynamic>> gatewayProviders;
final Map<String, dynamic> raw;
}
@ -38,6 +41,9 @@ class ExternalCodeAgentAcpRoutingResolution {
String get resolvedProviderId =>
raw['resolvedProviderId']?.toString().trim() ?? '';
String get resolvedGatewayProviderId =>
raw['resolvedGatewayProviderId']?.toString().trim() ?? '';
String get resolvedModel => raw['resolvedModel']?.toString().trim() ?? '';
List<String> get resolvedSkills {
@ -264,14 +270,14 @@ class GoTaskServiceRequest {
String get acpMode {
return switch (target) {
AssistantExecutionTarget.singleAgent => 'agent',
AssistantExecutionTarget.singleAgent => 'single-agent',
AssistantExecutionTarget.gateway => _gatewaySessionMode,
};
}
String get routingExecutionTarget {
return switch (target) {
AssistantExecutionTarget.singleAgent => 'agent',
AssistantExecutionTarget.singleAgent => 'single-agent',
AssistantExecutionTarget.gateway => 'gateway',
};
}
@ -345,7 +351,7 @@ class GoTaskServiceRequest {
_ => 'gateway',
};
final explicitExecutionTarget = switch (gatewayTarget) {
AssistantExecutionTarget.singleAgent => 'agent',
AssistantExecutionTarget.singleAgent => 'single-agent',
AssistantExecutionTarget.gateway => 'gateway',
};
final explicitProviderId = provider.isUnspecified
@ -582,16 +588,17 @@ String? goTaskServiceGatewayEntryState({
.trim()
.toLowerCase();
if (resolvedEndpointTarget.isEmpty ||
resolvedEndpointTarget == 'gateway') {
resolvedEndpointTarget == 'gateway' ||
resolvedEndpointTarget == 'local' ||
resolvedEndpointTarget == 'remote') {
return AssistantExecutionTarget.gateway.promptValue;
}
throw StateError(
'Bridge protocol mismatch: unsupported resolvedEndpointTarget "$resolvedEndpointTarget".',
);
case 'agent':
return AssistantExecutionTarget.singleAgent.promptValue;
case 'single-agent':
case 'multi-agent':
return AssistantExecutionTarget.singleAgent.promptValue;
case 'local':
case 'remote':
throw StateError(

View File

@ -51,7 +51,7 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget {
};
String get promptValue => switch (this) {
AssistantExecutionTarget.singleAgent => 'agent',
AssistantExecutionTarget.singleAgent => 'single-agent',
AssistantExecutionTarget.gateway => 'gateway',
};
@ -66,6 +66,7 @@ extension AssistantExecutionTargetCopy on AssistantExecutionTarget {
final normalized = value?.trim() ?? '';
switch (normalized) {
case 'singleAgent':
case 'single-agent':
case 'agent':
return AssistantExecutionTarget.singleAgent;
case 'gateway':

View File

@ -520,6 +520,7 @@ extension ThreadExecutionModeCopy on ThreadExecutionMode {
final normalized = value?.trim();
switch (normalized) {
case 'singleAgent':
case 'single-agent':
case 'localAgent':
case 'agent':
return ThreadExecutionMode.localAgent;
@ -754,6 +755,7 @@ class ThreadContextState {
required this.latestResolvedRuntimeModel,
this.selectedModelSource = ThreadSelectionSource.inherited,
this.selectedSkillsSource = ThreadSelectionSource.inherited,
this.selectedWorkingDirectory,
this.gatewayEntryState,
this.lastRemoteWorkingDirectory,
this.lastRemoteWorkspaceRefKind,
@ -770,6 +772,7 @@ class ThreadContextState {
final String latestResolvedRuntimeModel;
final ThreadSelectionSource selectedModelSource;
final ThreadSelectionSource selectedSkillsSource;
final String? selectedWorkingDirectory;
final String? gatewayEntryState;
final String? lastRemoteWorkingDirectory;
final WorkspaceRefKind? lastRemoteWorkspaceRefKind;
@ -786,6 +789,8 @@ class ThreadContextState {
String? latestResolvedRuntimeModel,
ThreadSelectionSource? selectedModelSource,
ThreadSelectionSource? selectedSkillsSource,
String? selectedWorkingDirectory,
bool clearSelectedWorkingDirectory = false,
String? gatewayEntryState,
bool clearGatewayEntryState = false,
String? lastRemoteWorkingDirectory,
@ -804,6 +809,9 @@ class ThreadContextState {
latestResolvedRuntimeModel ?? this.latestResolvedRuntimeModel,
selectedModelSource: selectedModelSource ?? this.selectedModelSource,
selectedSkillsSource: selectedSkillsSource ?? this.selectedSkillsSource,
selectedWorkingDirectory: clearSelectedWorkingDirectory
? null
: (selectedWorkingDirectory ?? this.selectedWorkingDirectory),
gatewayEntryState: clearGatewayEntryState
? null
: (gatewayEntryState ?? this.gatewayEntryState),
@ -830,6 +838,7 @@ class ThreadContextState {
'latestResolvedRuntimeModel': latestResolvedRuntimeModel,
'selectedModelSource': selectedModelSource.name,
'selectedSkillsSource': selectedSkillsSource.name,
'selectedWorkingDirectory': selectedWorkingDirectory,
'gatewayEntryState': gatewayEntryState,
'lastRemoteWorkingDirectory': lastRemoteWorkingDirectory,
'lastRemoteWorkspaceRefKind': lastRemoteWorkspaceRefKind?.name,
@ -895,6 +904,7 @@ class ThreadContextState {
selectedSkillsSource: ThreadSelectionSourceCopy.fromJsonValue(
json['selectedSkillsSource']?.toString(),
),
selectedWorkingDirectory: json['selectedWorkingDirectory']?.toString(),
gatewayEntryState: json['gatewayEntryState']?.toString(),
lastRemoteWorkingDirectory: json['lastRemoteWorkingDirectory']
?.toString(),
@ -990,6 +1000,7 @@ class TaskThread {
String? latestResolvedRuntimeModel,
double? lastRunAtMs,
String? lastResultCode,
String? selectedWorkingDirectory,
String? lastRemoteWorkingDirectory,
WorkspaceRefKind? lastRemoteWorkspaceRefKind,
double? lastArtifactSyncAtMs,
@ -1027,6 +1038,10 @@ class TaskThread {
messageViewMode ?? AssistantMessageViewMode.rendered,
latestResolvedRuntimeModel:
latestResolvedRuntimeModel?.trim() ?? '',
selectedWorkingDirectory:
selectedWorkingDirectory?.trim().isNotEmpty == true
? selectedWorkingDirectory!.trim()
: null,
gatewayEntryState: gatewayEntryState?.trim(),
lastRemoteWorkingDirectory:
lastRemoteWorkingDirectory?.trim().isNotEmpty == true
@ -1069,6 +1084,7 @@ class TaskThread {
List<String> get selectedSkillKeys => contextState.selectedSkillKeys;
String get assistantModelId => contextState.selectedModelId;
AssistantMessageViewMode get messageViewMode => contextState.messageViewMode;
String? get selectedWorkingDirectory => contextState.selectedWorkingDirectory;
String? get gatewayEntryState => contextState.gatewayEntryState;
String? get lastRemoteWorkingDirectory =>
contextState.lastRemoteWorkingDirectory;
@ -1109,6 +1125,8 @@ class TaskThread {
String? assistantModelId,
ThreadSelectionSource? assistantModelSource,
ThreadSelectionSource? selectedSkillsSource,
String? selectedWorkingDirectory,
bool clearSelectedWorkingDirectory = false,
String? gatewayEntryState,
bool clearGatewayEntryState = false,
String? latestResolvedRuntimeModel,
@ -1132,6 +1150,8 @@ class TaskThread {
selectedModelSource: assistantModelSource,
selectedSkillsSource: selectedSkillsSource,
latestResolvedRuntimeModel: latestResolvedRuntimeModel,
selectedWorkingDirectory: selectedWorkingDirectory,
clearSelectedWorkingDirectory: clearSelectedWorkingDirectory,
gatewayEntryState: gatewayEntryState,
clearGatewayEntryState: clearGatewayEntryState,
lastRemoteWorkingDirectory: lastRemoteWorkingDirectory,
@ -1248,6 +1268,7 @@ class TaskThread {
'latestResolvedRuntimeModel': json['latestResolvedRuntimeModel'],
'selectedModelSource': json['assistantModelSource'],
'selectedSkillsSource': json['selectedSkillsSource'],
'selectedWorkingDirectory': json['selectedWorkingDirectory'],
'gatewayEntryState': json['gatewayEntryState'],
'lastRemoteWorkingDirectory': json['lastRemoteWorkingDirectory'],
'lastRemoteWorkspaceRefKind': json['lastRemoteWorkspaceRefKind'],

View File

@ -316,6 +316,7 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient {
singleAgent: true,
multiAgent: false,
providerCatalog: <SingleAgentProvider>[SingleAgentProvider.codex],
gatewayProviders: <Map<String, dynamic>>[],
raw: <String, dynamic>{},
);
}
@ -330,8 +331,8 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient {
}) async {
return const ExternalCodeAgentAcpRoutingResolution(
raw: <String, dynamic>{
'resolvedExecutionTarget': 'agent',
'resolvedEndpointTarget': 'agent',
'resolvedExecutionTarget': 'single-agent',
'resolvedEndpointTarget': 'singleAgent',
'resolvedProviderId': 'codex',
'resolvedModel': '',
'resolvedSkills': <String>[],

View File

@ -121,16 +121,17 @@ void main() {
expect(target, AssistantExecutionTarget.gateway);
});
test('falls back to remote when legacy local gateway selection is active', () {
final target = resolveGatewayExecutionTargetFromVisibleTargets(
const <AssistantExecutionTarget>[
AssistantExecutionTarget.gateway,
],
currentTarget: AssistantExecutionTarget.gateway,
);
test(
'falls back to remote when legacy local gateway selection is active',
() {
final target = resolveGatewayExecutionTargetFromVisibleTargets(
const <AssistantExecutionTarget>[AssistantExecutionTarget.gateway],
currentTarget: AssistantExecutionTarget.gateway,
);
expect(target, AssistantExecutionTarget.gateway);
});
expect(target, AssistantExecutionTarget.gateway);
},
);
});
group('resolveGatewayThreadConnectionStateInternal', () {
@ -328,6 +329,45 @@ void main() {
},
);
});
group('selected working directory', () {
test(
'persists thread project directory without changing local workspace binding',
() async {
final controller = AppController();
addTearDown(controller.dispose);
const sessionKey = 'draft:project-dir';
controller.initializeAssistantThreadContext(
sessionKey,
executionTarget: AssistantExecutionTarget.singleAgent,
);
final originalWorkspacePath = controller
.assistantWorkspacePathForSession(sessionKey);
await controller.saveAssistantSelectedWorkingDirectoryForSession(
sessionKey,
'/tmp/project-alpha',
);
final record = controller.requireTaskThreadForSessionInternal(
sessionKey,
);
expect(record.selectedWorkingDirectory, '/tmp/project-alpha');
expect(
controller.assistantSelectedWorkingDirectoryForSession(sessionKey),
'/tmp/project-alpha',
);
expect(
controller.assistantSelectedWorkingDirectoryDisplayLabelForSession(
sessionKey,
),
'project-alpha',
);
expect(record.workspaceBinding.workspacePath, originalWorkspacePath);
},
);
});
}
class _FakeGatewayRuntime extends GatewayRuntime {

View File

@ -0,0 +1,206 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller_desktop_core.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_actions.dart';
import 'package:xworkmate/app/app_controller_desktop_thread_sessions.dart';
import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('thread workingDirectory dispatch', () {
test(
'single-agent requests reuse the thread selected workingDirectory',
() async {
final client = _CapturingGoTaskServiceClient();
final projectDir = Directory.systemTemp.createTempSync(
'xworkmate-project-alpha-',
);
final controller = AppController(
goTaskServiceClient: client,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
);
addTearDown(() async {
controller.dispose();
if (projectDir.existsSync()) {
await projectDir.delete(recursive: true);
}
});
const sessionKey = 'draft:single-agent-working-directory';
controller.initializeAssistantThreadContext(
sessionKey,
executionTarget: AssistantExecutionTarget.singleAgent,
);
await controller.switchSession(sessionKey);
await controller.saveAssistantSelectedWorkingDirectoryForSession(
sessionKey,
projectDir.path,
);
await controller.sendChatMessage('first turn');
await controller.sendChatMessage('second turn');
expect(client.requests, hasLength(2));
expect(client.requests.map((item) => item.sessionId).toList(), <String>[
sessionKey,
sessionKey,
]);
expect(client.requests.map((item) => item.threadId).toList(), <String>[
sessionKey,
sessionKey,
]);
expect(
client.requests.map((item) => item.workingDirectory).toList(),
<String>[projectDir.path, projectDir.path],
);
},
);
test(
'gateway threads persist their selected workingDirectory separately from workspace binding',
() async {
final projectDir = Directory.systemTemp.createTempSync(
'xworkmate-project-beta-',
);
final controller = AppController(
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
);
addTearDown(() async {
controller.dispose();
if (projectDir.existsSync()) {
await projectDir.delete(recursive: true);
}
});
controller.appUiStateInternal = controller.appUiState.copyWith(
savedGatewayTargets: const <String>['gateway'],
);
controller.lastObservedSettingsSnapshotInternal =
controller.settingsController.snapshotInternal;
const sessionKey = 'draft:gateway-working-directory';
controller.initializeAssistantThreadContext(
sessionKey,
executionTarget: AssistantExecutionTarget.gateway,
);
await controller.switchSession(sessionKey);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.gateway,
);
await controller.saveAssistantSelectedWorkingDirectoryForSession(
sessionKey,
projectDir.path,
);
final record = controller.requireTaskThreadForSessionInternal(
sessionKey,
);
expect(
controller.assistantExecutionTargetForSession(sessionKey),
AssistantExecutionTarget.gateway,
);
expect(record.selectedWorkingDirectory, projectDir.path);
expect(record.workspaceBinding.workspacePath, isNot(projectDir.path));
},
);
});
}
class _CapturingGoTaskServiceClient implements GoTaskServiceClient {
final List<GoTaskServiceRequest> requests = <GoTaskServiceRequest>[];
@override
Future<void> cancelTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
}) async {}
@override
Future<void> closeTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
}) async {}
@override
Future<void> dispose() async {}
@override
Future<GoTaskServiceResult> executeTask(
GoTaskServiceRequest request, {
required void Function(GoTaskServiceUpdate update) onUpdate,
}) async {
requests.add(request);
return GoTaskServiceResult(
success: true,
message: 'ok',
turnId: 'turn-${requests.length}',
raw: <String, dynamic>{
'resolvedExecutionTarget':
request.target == AssistantExecutionTarget.gateway
? 'gateway'
: 'single-agent',
'resolvedEndpointTarget':
request.target == AssistantExecutionTarget.gateway
? 'local'
: 'singleAgent',
'resolvedProviderId': request.provider.providerId,
'resolvedWorkingDirectory': request.workingDirectory,
},
errorMessage: '',
resolvedModel: request.model,
route: request.route,
);
}
@override
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
required AssistantExecutionTarget target,
bool forceRefresh = false,
}) async {
return const ExternalCodeAgentAcpCapabilities(
singleAgent: true,
multiAgent: true,
providerCatalog: <SingleAgentProvider>[SingleAgentProvider.codex],
gatewayProviders: <Map<String, dynamic>>[],
raw: <String, dynamic>{},
);
}
@override
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
required String taskPrompt,
required String workingDirectory,
required ExternalCodeAgentAcpRoutingConfig routing,
String aiGatewayBaseUrl = '',
String aiGatewayApiKey = '',
}) async {
return const ExternalCodeAgentAcpRoutingResolution(
raw: <String, dynamic>{
'resolvedExecutionTarget': 'single-agent',
'resolvedEndpointTarget': 'singleAgent',
'resolvedProviderId': 'codex',
'resolvedGatewayProviderId': 'local',
'resolvedModel': 'codex',
'resolvedSkills': <String>[],
'unavailable': false,
},
);
}
@override
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
) async {}
}

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller_desktop_core.dart';
import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_bar.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart';
@ -17,7 +18,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'compact gateway picker selects remote bridge route instead of local fallback',
'mode picker keeps single-agent and gateway visible while project selector stays available',
(tester) async {
final root = Directory.systemTemp.createTempSync(
'xworkmate-picker-widget-test-',
@ -56,6 +57,10 @@ void main() {
);
controller.lastObservedSettingsSnapshotInternal =
controller.settingsController.snapshotInternal;
await controller.saveAssistantSelectedWorkingDirectoryForSession(
controller.currentSessionKey,
'/tmp/project-alpha',
);
await tester.pumpWidget(
MaterialApp(
@ -111,7 +116,19 @@ void main() {
.map((item) => item.value)
.toList(growable: false);
expect(values, contains(AssistantExecutionTarget.gateway));
expect(values, <AssistantExecutionTarget>[
AssistantExecutionTarget.singleAgent,
AssistantExecutionTarget.gateway,
]);
expect(
find.byKey(const Key('assistant-working-directory-button')),
findsOneWidget,
);
expect(find.text('project-alpha'), findsOneWidget);
expect(
find.byKey(const Key('assistant-collaboration-toggle')),
findsNothing,
);
await tester.pumpWidget(const SizedBox.shrink());
controller.dispose();
@ -203,8 +220,8 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient {
}) async {
return const ExternalCodeAgentAcpRoutingResolution(
raw: <String, dynamic>{
'resolvedExecutionTarget': 'agent',
'resolvedEndpointTarget': 'agent',
'resolvedExecutionTarget': 'single-agent',
'resolvedEndpointTarget': 'singleAgent',
'resolvedProviderId': 'codex',
'resolvedModel': '',
'resolvedSkills': <String>[],
@ -218,7 +235,13 @@ class _FakeGoTaskServiceClient implements GoTaskServiceClient {
required AssistantExecutionTarget target,
bool forceRefresh = false,
}) async {
return const ExternalCodeAgentAcpCapabilities.empty();
return const ExternalCodeAgentAcpCapabilities(
singleAgent: true,
multiAgent: true,
providerCatalog: <SingleAgentProvider>[SingleAgentProvider.codex],
gatewayProviders: <Map<String, dynamic>>[],
raw: <String, dynamic>{},
);
}
@override

View File

@ -0,0 +1,232 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:xworkmate/app/app_controller_desktop_core.dart';
import 'package:xworkmate/app/app_controller_desktop_workspace_execution.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_bar.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart';
import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart';
import 'package:xworkmate/runtime/desktop_platform_service.dart';
import 'package:xworkmate/runtime/go_task_service_client.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
import 'package:xworkmate/runtime/skill_directory_access.dart';
import 'package:xworkmate/theme/app_theme.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets('renders composer with project workingDirectory chip', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(1400, 320));
addTearDown(() async => tester.binding.setSurfaceSize(null));
final root = Directory.systemTemp.createTempSync(
'xworkmate-composer-golden-',
);
final store = SecureConfigStore(
enableSecureStorage: false,
appDataRootPathResolver: () async => '${root.path}/settings.sqlite3',
secretRootPathResolver: () async => root.path,
supportRootPathResolver: () async => root.path,
);
final controller = AppController(
store: store,
desktopPlatformService: UnsupportedDesktopPlatformService(),
skillDirectoryAccessService: _GoldenSkillDirectoryAccessService(
root.path,
),
goTaskServiceClient: const _GoldenGoTaskServiceClient(),
singleAgentSharedSkillScanRootOverrides: const <String>[],
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
);
final inputController = TextEditingController(text: '请整理今天的任务进展');
final focusNode = FocusNode();
addTearDown(() async {
controller.dispose();
inputController.dispose();
focusNode.dispose();
if (root.existsSync()) {
await root.delete(recursive: true);
}
});
controller.appUiStateInternal = controller.appUiState.copyWith(
savedGatewayTargets: const <String>['gateway'],
);
controller.lastObservedSettingsSnapshotInternal =
controller.settingsController.snapshotInternal;
await controller.saveAssistantSelectedWorkingDirectoryForSession(
controller.currentSessionKey,
'${root.path}/project-alpha',
);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(platform: TargetPlatform.macOS),
home: Scaffold(
body: Center(
child: RepaintBoundary(
key: const ValueKey('assistant-composer-boundary'),
child: SizedBox(
width: 1280,
child: ComposerBarInternal(
controller: controller,
inputController: inputController,
focusNode: focusNode,
thinkingLabel: 'Normal',
showModelControl: false,
modelLabel: '',
modelOptions: const <String>[],
attachments: const <ComposerAttachmentInternal>[],
availableSkills: const <ComposerSkillOptionInternal>[],
selectedSkillKeys: const <String>[],
onRemoveAttachment: (_) {},
onToggleSkill: (_) {},
onThinkingChanged: (_) {},
onModelChanged: (_) async {},
onOpenGateway: () {},
onOpenAiGatewaySettings: () {},
onReconnectGateway: () async {},
onPickAttachments: () {},
onAddAttachment: (_) {},
onPasteImageAttachment: () async => null,
onContentHeightChanged: (_) {},
onInputHeightChanged: (_) {},
onSend: () async {},
),
),
),
),
),
),
);
await tester.pump(const Duration(milliseconds: 300));
await expectLater(
find.byKey(const ValueKey('assistant-composer-boundary')),
matchesGoldenFile(
'goldens/assistant_page_composer_working_directory.png',
),
);
});
}
class _GoldenSkillDirectoryAccessService
implements SkillDirectoryAccessService {
const _GoldenSkillDirectoryAccessService(this.homeDirectory);
final String homeDirectory;
@override
bool get isSupported => false;
@override
Future<List<AuthorizedSkillDirectory>> authorizeDirectories({
List<String> suggestedPaths = const <String>[],
}) async {
return const <AuthorizedSkillDirectory>[];
}
@override
Future<AuthorizedSkillDirectory?> authorizeDirectory({
String suggestedPath = '',
}) async {
return null;
}
@override
Future<SkillDirectoryAccessHandle?> openDirectory(
AuthorizedSkillDirectory directory,
) async {
return null;
}
@override
Future<String> resolveUserHomeDirectory() async {
return homeDirectory;
}
}
class _GoldenGoTaskServiceClient implements GoTaskServiceClient {
const _GoldenGoTaskServiceClient();
@override
Future<void> cancelTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
}) async {}
@override
Future<void> closeTask({
required GoTaskServiceRoute route,
required AssistantExecutionTarget target,
required String sessionId,
required String threadId,
}) async {}
@override
Future<void> dispose() async {}
@override
Future<GoTaskServiceResult> executeTask(
GoTaskServiceRequest request, {
required void Function(GoTaskServiceUpdate update) onUpdate,
}) async {
return const GoTaskServiceResult(
success: true,
message: '',
turnId: '',
raw: <String, dynamic>{},
errorMessage: '',
resolvedModel: '',
route: GoTaskServiceRoute.externalAcpSingle,
);
}
@override
Future<ExternalCodeAgentAcpCapabilities> loadExternalAcpCapabilities({
required AssistantExecutionTarget target,
bool forceRefresh = false,
}) async {
return const ExternalCodeAgentAcpCapabilities(
singleAgent: true,
multiAgent: true,
providerCatalog: <SingleAgentProvider>[SingleAgentProvider.codex],
gatewayProviders: <Map<String, dynamic>>[],
raw: <String, dynamic>{},
);
}
@override
Future<ExternalCodeAgentAcpRoutingResolution> resolveExternalAcpRouting({
required String taskPrompt,
required String workingDirectory,
required ExternalCodeAgentAcpRoutingConfig routing,
String aiGatewayBaseUrl = '',
String aiGatewayApiKey = '',
}) async {
return const ExternalCodeAgentAcpRoutingResolution(
raw: <String, dynamic>{
'resolvedExecutionTarget': 'single-agent',
'resolvedEndpointTarget': 'singleAgent',
'resolvedProviderId': 'codex',
'resolvedModel': '',
'resolvedSkills': <String>[],
'unavailable': false,
},
);
}
@override
Future<void> syncExternalProviders(
List<ExternalCodeAgentAcpSyncedProvider> providers,
) async {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -30,7 +30,7 @@ class _FakeGatewayAcpClientWithSyncOrder extends GatewayAcpClient {
'result': <String, dynamic>{
'success': true,
'output': 'ok',
'resolvedExecutionTarget': 'agent',
'resolvedExecutionTarget': 'single-agent',
},
},
};

View File

@ -28,15 +28,23 @@ class _FakeGatewayAcpClient extends GatewayAcpClient {
<String, dynamic>{'providerId': 'opencode', 'label': 'OpenCode'},
<String, dynamic>{'providerId': 'gemini', 'label': 'Gemini'},
],
'gatewayProviders': <Map<String, dynamic>>[
<String, dynamic>{'providerId': 'local', 'label': 'Local Gateway'},
<String, dynamic>{
'providerId': 'openclaw',
'label': 'OpenClaw Gateway',
},
],
},
};
}
if (method == 'xworkmate.routing.resolve') {
return <String, dynamic>{
'result': <String, dynamic>{
'resolvedExecutionTarget': 'agent',
'resolvedEndpointTarget': 'agent',
'resolvedExecutionTarget': 'single-agent',
'resolvedEndpointTarget': 'singleAgent',
'resolvedProviderId': 'gemini',
'resolvedGatewayProviderId': 'local',
'resolvedModel': 'gemini-2.5-pro',
'resolvedSkills': <String>['pptx'],
'unavailable': false,
@ -67,6 +75,12 @@ void main() {
capabilities.providerCatalog.map((item) => item.providerId).toList(),
<String>['codex', 'opencode', 'gemini'],
);
expect(
capabilities.gatewayProviders
.map((item) => item['providerId']?.toString())
.toList(),
<String>['local', 'openclaw'],
);
},
);
@ -109,7 +123,10 @@ void main() {
);
expect(client.methods, <String>['xworkmate.routing.resolve']);
expect(resolution.resolvedExecutionTarget, 'single-agent');
expect(resolution.resolvedEndpointTarget, 'singleAgent');
expect(resolution.resolvedProviderId, 'gemini');
expect(resolution.resolvedGatewayProviderId, 'local');
expect(resolution.resolvedModel, 'gemini-2.5-pro');
expect(resolution.resolvedSkills, <String>['pptx']);
},