Align app bridge API contract
This commit is contained in:
parent
e9ff99cacd
commit
806aef8ba5
45
docs/feature/2026-04-11-app-bridge-api-alignment.md
Normal file
45
docs/feature/2026-04-11-app-bridge-api-alignment.md
Normal 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 侧硬编码扩展
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@ -158,7 +158,7 @@ String? assistantWorkingDirectoryForSessionRuntimeInternal(
|
||||
String sessionKey,
|
||||
) {
|
||||
final candidate = controller
|
||||
.assistantWorkspacePathForSession(sessionKey)
|
||||
.assistantSelectedWorkingDirectoryForSession(sessionKey)
|
||||
.trim();
|
||||
if (candidate.isEmpty) {
|
||||
return null;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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':
|
||||
|
||||
@ -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'],
|
||||
|
||||
@ -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>[],
|
||||
|
||||
@ -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 {
|
||||
|
||||
206
test/app_controller_desktop_working_directory_dispatch_test.dart
Normal file
206
test/app_controller_desktop_working_directory_dispatch_test.dart
Normal 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 {}
|
||||
}
|
||||
@ -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
|
||||
|
||||
232
test/features/assistant/assistant_page_composer_golden_test.dart
Normal file
232
test/features/assistant/assistant_page_composer_golden_test.dart
Normal 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 |
@ -30,7 +30,7 @@ class _FakeGatewayAcpClientWithSyncOrder extends GatewayAcpClient {
|
||||
'result': <String, dynamic>{
|
||||
'success': true,
|
||||
'output': 'ok',
|
||||
'resolvedExecutionTarget': 'agent',
|
||||
'resolvedExecutionTarget': 'single-agent',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -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']);
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user