diff --git a/.gitmodules b/.gitmodules index bcae2ac2..e69de29b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "vendor/codex"] - path = vendor/codex - url = https://github.com/openai/codex.git diff --git a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart index 728d9266..44ccc4c7 100644 --- a/lib/app/app_controller_desktop_single_agent_go_task_flow.dart +++ b/lib/app/app_controller_desktop_single_agent_go_task_flow.dart @@ -1,6 +1,8 @@ // ignore_for_file: unused_import, unnecessary_import import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import '../i18n/app_language.dart'; import '../models/app_models.dart'; @@ -164,6 +166,11 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( routing: routing, routingHint: 'single-agent', provider: effectiveProvider, + remoteWorkingDirectoryHint: + controller + .requireTaskThreadForSessionInternal(sessionKey) + .lastRemoteWorkingDirectory ?? + '', ), onUpdate: (update) { if (update.isDelta) { @@ -175,7 +182,7 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( } }, ); - _applySingleAgentGoTaskResultDesktopInternal( + await _applySingleAgentGoTaskResultDesktopInternal( controller, sessionKey: sessionKey, sessionTarget: sessionTarget, @@ -212,7 +219,7 @@ Future sendSingleAgentMessageDesktopGoTaskFlowInternal( }); } -void _applySingleAgentGoTaskResultDesktopInternal( +Future _applySingleAgentGoTaskResultDesktopInternal( AppController controller, { required String sessionKey, required AssistantExecutionTarget sessionTarget, @@ -220,7 +227,7 @@ void _applySingleAgentGoTaskResultDesktopInternal( required String thinking, required List attachments, required GoTaskServiceResult result, -}) { +}) async { final resolvedRuntimeModel = result.resolvedModel.trim(); final resolvedGatewayEntryState = goTaskServiceGatewayEntryState( requestedTarget: sessionTarget, @@ -233,13 +240,13 @@ void _applySingleAgentGoTaskResultDesktopInternal( lifecycleStatus: 'ready', lastRunAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), lastResultCode: result.success ? 'success' : 'error', + lastRemoteWorkingDirectory: result.remoteWorkingDirectory.trim().isEmpty + ? null + : result.remoteWorkingDirectory.trim(), + lastRemoteWorkspaceRefKind: result.remoteWorkspaceRefKind, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); - _updateSingleAgentWorkspaceBindingFromResultDesktopInternal( - controller, - sessionKey, - result, - ); + await _persistSingleAgentArtifactsDesktopInternal(controller, sessionKey, result); controller.clearAiGatewayStreamingTextInternal(sessionKey); if (!result.success) { controller.appendAssistantThreadMessageInternal( @@ -285,30 +292,107 @@ void _applySingleAgentGoTaskResultDesktopInternal( ); } -void _updateSingleAgentWorkspaceBindingFromResultDesktopInternal( +Future _persistSingleAgentArtifactsDesktopInternal( AppController controller, String sessionKey, GoTaskServiceResult result, -) { - final resolvedWorkspaceKind = result.resolvedWorkspaceRefKind; - final resolvedWorkingDirectory = result.resolvedWorkingDirectory.trim(); - if (resolvedWorkspaceKind == null || resolvedWorkingDirectory.isEmpty) { +) async { + final artifacts = result.artifacts; + if (artifacts.isEmpty) { + controller.upsertTaskThreadInternal( + sessionKey, + lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastArtifactSyncStatus: 'no-artifacts', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); return; } final existingThread = controller.requireTaskThreadForSessionInternal( sessionKey, ); + if (existingThread.workspaceBinding.workspaceKind != WorkspaceKind.localFs) { + controller.upsertTaskThreadInternal( + sessionKey, + lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastArtifactSyncStatus: 'skipped-non-local-workspace', + updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + ); + return; + } + final root = Directory(existingThread.workspaceBinding.workspacePath); + await root.create(recursive: true); + + var wroteArtifact = false; + for (final artifact in artifacts) { + if (!artifact.hasInlineContent) { + continue; + } + final relativePath = _sanitizeArtifactRelativePathInternal( + artifact.relativePath, + ); + if (relativePath.isEmpty) { + continue; + } + final target = await _nextArtifactTargetFileInternal(root, relativePath); + await target.parent.create(recursive: true); + await target.writeAsBytes( + _decodeArtifactContentInternal(artifact), + flush: true, + ); + wroteArtifact = true; + } + controller.upsertTaskThreadInternal( sessionKey, - workspaceBinding: WorkspaceBinding( - workspaceId: existingThread.workspaceBinding.workspaceId, - workspaceKind: resolvedWorkspaceKind == WorkspaceRefKind.remotePath - ? WorkspaceKind.remoteFs - : WorkspaceKind.localFs, - workspacePath: resolvedWorkingDirectory, - displayPath: resolvedWorkingDirectory, - writable: existingThread.workspaceBinding.writable, - ), + lastArtifactSyncAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), + lastArtifactSyncStatus: wroteArtifact ? 'synced' : 'no-inline-content', updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); } + +String _sanitizeArtifactRelativePathInternal(String raw) { + final trimmed = raw.trim().replaceAll('\\', '/'); + if (trimmed.isEmpty) { + return ''; + } + final cleaned = trimmed + .split('/') + .where((segment) => segment.isNotEmpty && segment != '.' && segment != '..') + .join('/'); + return cleaned; +} + +List _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) { + final encoding = artifact.encoding.trim().toLowerCase(); + if (encoding == 'base64') { + return base64Decode(artifact.content); + } + return utf8.encode(artifact.content); +} + +Future _nextArtifactTargetFileInternal( + Directory root, + String relativePath, +) async { + final segments = relativePath.split('/'); + final fileName = segments.removeLast(); + final parent = segments.isEmpty + ? root + : Directory('${root.path}/${segments.join('/')}'); + final dotIndex = fileName.lastIndexOf('.'); + final baseName = dotIndex <= 0 ? fileName : fileName.substring(0, dotIndex); + final extension = dotIndex <= 0 ? '' : fileName.substring(dotIndex); + var candidate = File('${parent.path}/$fileName'); + if (!await candidate.exists()) { + return candidate; + } + for (var version = 2; version < 1000; version += 1) { + candidate = File('${parent.path}/$baseName.v$version$extension'); + if (!await candidate.exists()) { + return candidate; + } + } + return File( + '${parent.path}/$baseName.${DateTime.now().millisecondsSinceEpoch}$extension', + ); +} diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index 5bf30183..541613a5 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -276,6 +276,10 @@ extension AppControllerDesktopSkillPermissions on AppController { String? lifecycleStatus, double? lastRunAtMs, String? lastResultCode, + String? lastRemoteWorkingDirectory, + WorkspaceRefKind? lastRemoteWorkspaceRefKind, + double? lastArtifactSyncAtMs, + String? lastArtifactSyncStatus, }) { final normalizedSessionKey = normalizedAssistantSessionKeyInternal( sessionKey, @@ -379,6 +383,10 @@ extension AppControllerDesktopSkillPermissions on AppController { gatewayEntryState: gatewayEntryStateForTargetInternal( nextExecutionTarget, ), + lastRemoteWorkingDirectory: null, + lastRemoteWorkspaceRefKind: null, + lastArtifactSyncAtMs: null, + lastArtifactSyncStatus: null, )) .copyWith( messages: nextMessages, @@ -397,6 +405,10 @@ extension AppControllerDesktopSkillPermissions on AppController { existing?.contextState.selectedSkillsSource, latestResolvedRuntimeModel: latestResolvedRuntimeModel, gatewayEntryState: gatewayEntryState, + lastRemoteWorkingDirectory: lastRemoteWorkingDirectory, + lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind, + lastArtifactSyncAtMs: lastArtifactSyncAtMs, + lastArtifactSyncStatus: lastArtifactSyncStatus, ); final nextStatus = lifecycleStatus ?? diff --git a/lib/runtime/go_task_service_client.dart b/lib/runtime/go_task_service_client.dart index f3bfea3d..a0070aab 100644 --- a/lib/runtime/go_task_service_client.dart +++ b/lib/runtime/go_task_service_client.dart @@ -174,6 +174,7 @@ class GoTaskServiceRequest { this.routing, this.routingHint = '', this.provider = SingleAgentProvider.auto, + this.remoteWorkingDirectoryHint = '', this.resumeSession = false, this.collaborationMode = GoTaskServiceCollaborationMode.standard, this.multiAgent = false, @@ -196,6 +197,7 @@ class GoTaskServiceRequest { final ExternalCodeAgentAcpRoutingConfig? routing; final String routingHint; final SingleAgentProvider provider; + final String remoteWorkingDirectoryHint; final bool resumeSession; final GoTaskServiceCollaborationMode collaborationMode; final bool multiAgent; @@ -275,6 +277,8 @@ class GoTaskServiceRequest { ) .toList(growable: false), if (provider != SingleAgentProvider.auto) 'provider': provider.providerId, + if (remoteWorkingDirectoryHint.trim().isNotEmpty) + 'remoteWorkingDirectoryHint': remoteWorkingDirectoryHint.trim(), if (model.trim().isNotEmpty) 'model': model.trim(), if (thinking.trim().isNotEmpty) 'thinking': thinking.trim(), if (aiGatewayBaseUrl.trim().isNotEmpty) @@ -370,6 +374,53 @@ class GoTaskServiceUpdate { bool get isDone => type == 'done' || payload['event'] == 'completed'; } +class GoTaskServiceArtifact { + const GoTaskServiceArtifact({ + required this.relativePath, + required this.label, + required this.contentType, + required this.encoding, + required this.content, + required this.downloadUrl, + required this.sizeBytes, + required this.sha256, + }); + + final String relativePath; + final String label; + final String contentType; + final String encoding; + final String content; + final String downloadUrl; + final int? sizeBytes; + final String sha256; + + bool get hasInlineContent => content.trim().isNotEmpty; + + factory GoTaskServiceArtifact.fromJson(Map json) { + int? parseSize(Object? value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse(value?.toString() ?? ''); + } + + return GoTaskServiceArtifact( + relativePath: json['relativePath']?.toString().trim() ?? '', + label: json['label']?.toString().trim() ?? '', + contentType: json['contentType']?.toString().trim() ?? '', + encoding: json['encoding']?.toString().trim() ?? '', + content: json['content']?.toString() ?? '', + downloadUrl: json['downloadUrl']?.toString().trim() ?? '', + sizeBytes: parseSize(json['sizeBytes'] ?? json['size']), + sha256: json['sha256']?.toString().trim() ?? '', + ); + } +} + class GoTaskServiceResult { const GoTaskServiceResult({ required this.success, @@ -395,6 +446,11 @@ class GoTaskServiceResult { raw['workingDirectory']?.toString().trim() ?? ''; + String get resultSummary => + raw['resultSummary']?.toString().trim().isNotEmpty == true + ? raw['resultSummary'].toString().trim() + : raw['summary']?.toString().trim() ?? ''; + String get resolvedExecutionTarget => raw['resolvedExecutionTarget']?.toString().trim() ?? ''; @@ -429,6 +485,22 @@ class GoTaskServiceResult { List> get memorySources => _castMapList(raw['memorySources']); + List get artifacts { + final rawArtifacts = raw['artifacts']; + if (rawArtifacts is! List) { + return const []; + } + return rawArtifacts + .whereType() + .map( + (item) => GoTaskServiceArtifact.fromJson( + item.cast(), + ), + ) + .where((item) => item.relativePath.isNotEmpty) + .toList(growable: false); + } + WorkspaceRefKind? get resolvedWorkspaceRefKind { final rawValue = raw['resolvedWorkspaceRefKind']?.toString().trim() ?? ''; if (rawValue.isEmpty) { @@ -436,6 +508,25 @@ class GoTaskServiceResult { } return WorkspaceRefKindCopy.fromJsonValue(rawValue); } + + String get remoteWorkingDirectory { + final remoteExecution = _castMap(raw['remoteExecution']); + return remoteExecution['remoteWorkingDirectory']?.toString().trim() ?? + raw['remoteWorkingDirectory']?.toString().trim() ?? + resolvedWorkingDirectory; + } + + WorkspaceRefKind? get remoteWorkspaceRefKind { + final remoteExecution = _castMap(raw['remoteExecution']); + final rawValue = + remoteExecution['remoteWorkspaceRefKind']?.toString().trim() ?? + raw['remoteWorkspaceRefKind']?.toString().trim() ?? + ''; + if (rawValue.isEmpty) { + return resolvedWorkspaceRefKind; + } + return WorkspaceRefKindCopy.fromJsonValue(rawValue); + } } String? goTaskServiceGatewayEntryState({ diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 4d1dfa73..25520628 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -765,6 +765,10 @@ class ThreadContextState { this.selectedModelSource = ThreadSelectionSource.inherited, this.selectedSkillsSource = ThreadSelectionSource.inherited, this.gatewayEntryState, + this.lastRemoteWorkingDirectory, + this.lastRemoteWorkspaceRefKind, + this.lastArtifactSyncAtMs, + this.lastArtifactSyncStatus, }); final List messages; @@ -777,6 +781,10 @@ class ThreadContextState { final ThreadSelectionSource selectedModelSource; final ThreadSelectionSource selectedSkillsSource; final String? gatewayEntryState; + final String? lastRemoteWorkingDirectory; + final WorkspaceRefKind? lastRemoteWorkspaceRefKind; + final double? lastArtifactSyncAtMs; + final String? lastArtifactSyncStatus; ThreadContextState copyWith({ List? messages, @@ -790,6 +798,10 @@ class ThreadContextState { ThreadSelectionSource? selectedSkillsSource, String? gatewayEntryState, bool clearGatewayEntryState = false, + String? lastRemoteWorkingDirectory, + WorkspaceRefKind? lastRemoteWorkspaceRefKind, + double? lastArtifactSyncAtMs, + String? lastArtifactSyncStatus, }) { return ThreadContextState( messages: messages ?? this.messages, @@ -805,6 +817,13 @@ class ThreadContextState { gatewayEntryState: clearGatewayEntryState ? null : (gatewayEntryState ?? this.gatewayEntryState), + lastRemoteWorkingDirectory: + lastRemoteWorkingDirectory ?? this.lastRemoteWorkingDirectory, + lastRemoteWorkspaceRefKind: + lastRemoteWorkspaceRefKind ?? this.lastRemoteWorkspaceRefKind, + lastArtifactSyncAtMs: lastArtifactSyncAtMs ?? this.lastArtifactSyncAtMs, + lastArtifactSyncStatus: + lastArtifactSyncStatus ?? this.lastArtifactSyncStatus, ); } @@ -822,10 +841,21 @@ class ThreadContextState { 'selectedModelSource': selectedModelSource.name, 'selectedSkillsSource': selectedSkillsSource.name, 'gatewayEntryState': gatewayEntryState, + 'lastRemoteWorkingDirectory': lastRemoteWorkingDirectory, + 'lastRemoteWorkspaceRefKind': lastRemoteWorkspaceRefKind?.name, + 'lastArtifactSyncAtMs': lastArtifactSyncAtMs, + 'lastArtifactSyncStatus': lastArtifactSyncStatus, }; } factory ThreadContextState.fromJson(Map json) { + double? asDouble(Object? value) { + if (value is num) { + return value.toDouble(); + } + return double.tryParse(value?.toString() ?? ''); + } + final rawMessages = json['messages']; final messages = rawMessages is List ? rawMessages @@ -876,6 +906,18 @@ class ThreadContextState { json['selectedSkillsSource']?.toString(), ), gatewayEntryState: json['gatewayEntryState']?.toString(), + lastRemoteWorkingDirectory: + json['lastRemoteWorkingDirectory']?.toString(), + lastRemoteWorkspaceRefKind: (() { + final rawValue = + json['lastRemoteWorkspaceRefKind']?.toString().trim() ?? ''; + if (rawValue.isEmpty) { + return null; + } + return WorkspaceRefKindCopy.fromJsonValue(rawValue); + })(), + lastArtifactSyncAtMs: asDouble(json['lastArtifactSyncAtMs']), + lastArtifactSyncStatus: json['lastArtifactSyncStatus']?.toString(), ); } } @@ -958,6 +1000,10 @@ class TaskThread { String? latestResolvedRuntimeModel, double? lastRunAtMs, String? lastResultCode, + String? lastRemoteWorkingDirectory, + WorkspaceRefKind? lastRemoteWorkspaceRefKind, + double? lastArtifactSyncAtMs, + String? lastArtifactSyncStatus, }) : threadId = _resolveThreadId(threadId), title = title ?? '', ownerScope = @@ -992,6 +1038,16 @@ class TaskThread { latestResolvedRuntimeModel: latestResolvedRuntimeModel?.trim() ?? '', gatewayEntryState: gatewayEntryState?.trim(), + lastRemoteWorkingDirectory: + lastRemoteWorkingDirectory?.trim().isNotEmpty == true + ? lastRemoteWorkingDirectory!.trim() + : null, + lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind, + lastArtifactSyncAtMs: lastArtifactSyncAtMs, + lastArtifactSyncStatus: + lastArtifactSyncStatus?.trim().isNotEmpty == true + ? lastArtifactSyncStatus!.trim() + : null, ), lifecycleState = lifecycleState ?? @@ -1024,6 +1080,12 @@ class TaskThread { String get assistantModelId => contextState.selectedModelId; AssistantMessageViewMode get messageViewMode => contextState.messageViewMode; String? get gatewayEntryState => contextState.gatewayEntryState; + String? get lastRemoteWorkingDirectory => + contextState.lastRemoteWorkingDirectory; + WorkspaceRefKind? get lastRemoteWorkspaceRefKind => + contextState.lastRemoteWorkspaceRefKind; + double? get lastArtifactSyncAtMs => contextState.lastArtifactSyncAtMs; + String? get lastArtifactSyncStatus => contextState.lastArtifactSyncStatus; String get latestResolvedRuntimeModel => contextState.latestResolvedRuntimeModel; bool get hasExplicitExecutionTargetSelection => @@ -1060,6 +1122,10 @@ class TaskThread { String? gatewayEntryState, bool clearGatewayEntryState = false, String? latestResolvedRuntimeModel, + String? lastRemoteWorkingDirectory, + WorkspaceRefKind? lastRemoteWorkspaceRefKind, + double? lastArtifactSyncAtMs, + String? lastArtifactSyncStatus, }) { return TaskThread( threadId: threadId ?? this.threadId, @@ -1078,6 +1144,10 @@ class TaskThread { latestResolvedRuntimeModel: latestResolvedRuntimeModel, gatewayEntryState: gatewayEntryState, clearGatewayEntryState: clearGatewayEntryState, + lastRemoteWorkingDirectory: lastRemoteWorkingDirectory, + lastRemoteWorkspaceRefKind: lastRemoteWorkspaceRefKind, + lastArtifactSyncAtMs: lastArtifactSyncAtMs, + lastArtifactSyncStatus: lastArtifactSyncStatus, ), lifecycleState: (lifecycleState ?? this.lifecycleState).copyWith( archived: archived, @@ -1213,6 +1283,10 @@ class TaskThread { 'selectedModelSource': json['assistantModelSource'], 'selectedSkillsSource': json['selectedSkillsSource'], 'gatewayEntryState': json['gatewayEntryState'], + 'lastRemoteWorkingDirectory': json['lastRemoteWorkingDirectory'], + 'lastRemoteWorkspaceRefKind': json['lastRemoteWorkspaceRefKind'], + 'lastArtifactSyncAtMs': json['lastArtifactSyncAtMs'], + 'lastArtifactSyncStatus': json['lastArtifactSyncStatus'], }; } diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_fixtures.dart b/test/runtime/app_controller_ai_gateway_chat_suite_fixtures.dart index a63ef0ab..8b29656d 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite_fixtures.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite_fixtures.dart @@ -96,7 +96,7 @@ List withAvailableMountTargetsInternal( Future waitForInternal( bool Function() predicate, { - Duration timeout = const Duration(seconds: 5), + Duration timeout = const Duration(seconds: 20), }) async { final deadline = DateTime.now().add(timeout); while (!predicate()) { diff --git a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart index f3e2048d..3533fa2b 100644 --- a/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart +++ b/test/runtime/app_controller_ai_gateway_chat_suite_single_agent.dart @@ -858,7 +858,7 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { ); test( - 'AppController rebinds local Single Agent threads to the structured resolved directory', + 'AppController keeps local Single Agent threads bound to the local workspace while recording remote metadata', () async { final tempDirectory = await createTempDirectoryInternal( 'xworkmate-single-agent-remote-thread-cwd-', @@ -918,29 +918,39 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { await controller.switchSession('draft:remote-thread'); await controller.sendChatMessage('第一次运行', thinking: 'low'); + final localThreadDir = + '${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread'; + const remoteThreadDir = + '/opt/data/.xworkmate/threads/draft-remote-thread'; expect( client.requests.first.workingDirectory, - '${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread', + localThreadDir, ); expect( controller.assistantWorkspacePathForSession('draft:remote-thread'), - '/opt/data/.xworkmate/threads/draft-remote-thread', + localThreadDir, ); expect( controller.assistantWorkspaceKindForSession('draft:remote-thread'), WorkspaceRefKind.localPath, ); + final thread = controller.requireTaskThreadForSessionInternal( + 'draft:remote-thread', + ); + expect(thread.lastRemoteWorkingDirectory, remoteThreadDir); + expect(thread.lastRemoteWorkspaceRefKind, WorkspaceRefKind.localPath); await controller.sendChatMessage('第二次运行', thinking: 'low'); expect( client.requests.last.workingDirectory, - '/opt/data/.xworkmate/threads/draft-remote-thread', + localThreadDir, ); + expect(client.requests.last.remoteWorkingDirectoryHint, remoteThreadDir); }, ); test( - 'AppController rebinds remote Single Agent threads to the resolved thread directory', + 'AppController keeps remote Single Agent threads on the local workspace and forwards the remote directory as a hint', () async { final tempDirectory = await createTempDirectoryInternal( 'xworkmate-single-agent-remote-rebind-cwd-', @@ -1013,27 +1023,145 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() { await controller.switchSession('draft:remote-thread'); await controller.sendChatMessage('第一次运行', thinking: 'low'); + final localThreadDir = + '${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread'; expect( client.requests.first.workingDirectory, - '${defaultWorkspace.path}/.xworkmate/threads/draft-remote-thread', + localThreadDir, ); expect( controller.assistantWorkspacePathForSession('draft:remote-thread'), - '/remote/threads/task-42', + localThreadDir, ); expect( controller.assistantWorkspaceKindForSession('draft:remote-thread'), - WorkspaceRefKind.remotePath, + WorkspaceRefKind.localPath, ); + final thread = controller.requireTaskThreadForSessionInternal( + 'draft:remote-thread', + ); + expect(thread.lastRemoteWorkingDirectory, '/remote/threads/task-42'); + expect(thread.lastRemoteWorkspaceRefKind, WorkspaceRefKind.remotePath); await controller.sendChatMessage('第二次运行', thinking: 'low'); expect( client.requests.last.workingDirectory, + localThreadDir, + ); + expect( + client.requests.last.remoteWorkingDirectoryHint, '/remote/threads/task-42', ); }, ); + test( + 'AppController writes returned Single Agent artifacts into the local workspace and versions name conflicts', + () async { + final tempDirectory = await createTempDirectoryInternal( + 'xworkmate-single-agent-artifact-sync-', + ); + final defaultWorkspace = Directory( + '${tempDirectory.path}/default-workspace', + ); + await defaultWorkspace.create(recursive: true); + + final store = createStoreFromTempDirectoryInternal(tempDirectory); + await store.initialize(); + await store.saveSettingsSnapshot( + SettingsSnapshot.defaults().copyWith( + workspacePath: defaultWorkspace.path, + assistantExecutionTarget: AssistantExecutionTarget.singleAgent, + ), + ); + + final artifactPayload = base64Encode(utf8.encode('new report body')); + final client = FakeGoTaskServiceClientInternal( + capabilities: ExternalCodeAgentAcpCapabilities( + singleAgent: true, + multiAgent: false, + providers: {SingleAgentProvider.opencode}, + raw: {}, + ), + result: GoTaskServiceResult( + success: true, + message: 'ARTIFACT_OK', + turnId: 'turn-artifact-1', + raw: { + 'resolvedWorkingDirectory': '/remote/threads/artifact-thread', + 'resolvedWorkspaceRefKind': 'remotePath', + 'artifacts': >[ + { + 'relativePath': 'outputs/report.docx', + 'label': 'Report', + 'contentType': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'encoding': 'base64', + 'content': artifactPayload, + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ), + ); + final controller = await createAppControllerInternal( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.opencode, + ], + runtimeCoordinator: RuntimeCoordinator( + gateway: FakeGatewayRuntimeInternal(store: store), + codex: FakeCodexRuntimeInternal(), + ), + goTaskServiceClient: client, + ); + + controller.initializeAssistantThreadContext( + 'draft:artifact-thread', + title: 'Artifact Thread', + executionTarget: AssistantExecutionTarget.singleAgent, + ); + await controller.switchSession('draft:artifact-thread'); + + final localThreadDir = Directory( + '${defaultWorkspace.path}/.xworkmate/threads/draft-artifact-thread', + ); + await localThreadDir.create(recursive: true); + final existingArtifact = File('${localThreadDir.path}/outputs/report.docx'); + await existingArtifact.parent.create(recursive: true); + await existingArtifact.writeAsString('old report body'); + + await controller.sendChatMessage('生成文档', thinking: 'low'); + + final versionedArtifact = File( + '${localThreadDir.path}/outputs/report.v2.docx', + ); + expect(existingArtifact.existsSync(), isTrue); + expect(versionedArtifact.existsSync(), isTrue); + expect(await versionedArtifact.readAsString(), 'new report body'); + expect( + controller.assistantWorkspacePathForSession('draft:artifact-thread'), + localThreadDir.path, + ); + expect( + controller.assistantWorkspaceKindForSession('draft:artifact-thread'), + WorkspaceRefKind.localPath, + ); + final thread = controller.requireTaskThreadForSessionInternal( + 'draft:artifact-thread', + ); + expect( + thread.lastRemoteWorkingDirectory, + '/remote/threads/artifact-thread', + ); + expect(thread.lastRemoteWorkspaceRefKind, WorkspaceRefKind.remotePath); + expect(thread.lastArtifactSyncStatus, 'synced'); + expect(thread.lastArtifactSyncAtMs, isNotNull); + }, + ); + test( 'AppController keeps local Codex-style working directories for remote thread refs', () async { diff --git a/vendor/codex b/vendor/codex deleted file mode 160000 index 78280f87..00000000 --- a/vendor/codex +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 78280f872a58dfbb51d2883791d036db00cbfe0f diff --git a/web/favicon.png b/web/favicon.png deleted file mode 100644 index fac626bd..00000000 Binary files a/web/favicon.png and /dev/null differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png deleted file mode 100644 index a7366e8c..00000000 Binary files a/web/icons/Icon-192.png and /dev/null differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png deleted file mode 100644 index b7c4e5b6..00000000 Binary files a/web/icons/Icon-512.png and /dev/null differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png deleted file mode 100644 index a7366e8c..00000000 Binary files a/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png deleted file mode 100644 index b7c4e5b6..00000000 Binary files a/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/web/index.html b/web/index.html deleted file mode 100644 index bade6f0a..00000000 --- a/web/index.html +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - - - - - XWorkmate - - - - - - - diff --git a/web/manifest.json b/web/manifest.json deleted file mode 100644 index e020c894..00000000 --- a/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "XWorkmate", - "short_name": "XWorkmate", - "start_url": "/", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "Assistant-first Flutter Web shell for Single Agent and Relay OpenClaw Gateway.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -}