diff --git a/lib/app/app_controller_desktop_runtime_helpers.dart b/lib/app/app_controller_desktop_runtime_helpers.dart index 9e101e6b..f7ce4baa 100644 --- a/lib/app/app_controller_desktop_runtime_helpers.dart +++ b/lib/app/app_controller_desktop_runtime_helpers.dart @@ -792,7 +792,17 @@ extension AppControllerDesktopRuntimeHelpers on AppController { } final bytes = bytesResult.bytes; if (bytes == null) { - skippedArtifact = true; + final existingArtifactPaths = + await _existingWorkspaceArtifactPathsInternal(root, relativePath); + if (existingArtifactPaths.isEmpty) { + skippedArtifact = true; + continue; + } + wroteArtifact = true; + _appendArtifactRelativePathsInternal( + currentTaskArtifactRelativePaths, + existingArtifactPaths, + ); continue; } final target = await _nextArtifactTargetFileInternal(root, relativePath); @@ -813,7 +823,10 @@ extension AppControllerDesktopRuntimeHelpers on AppController { target.path, ); if (writtenRelativePath != null && writtenRelativePath.isNotEmpty) { - currentTaskArtifactRelativePaths.add(writtenRelativePath); + _appendArtifactRelativePathsInternal( + currentTaskArtifactRelativePaths, + [writtenRelativePath], + ); } } @@ -1145,6 +1158,57 @@ extension AppControllerDesktopRuntimeHelpers on AppController { ) => kGatewayRemoteProfileIndex; } +void _appendArtifactRelativePathsInternal( + List target, + List paths, +) { + for (final path in paths) { + if (!target.contains(path)) { + target.add(path); + } + } +} + +Future> _existingWorkspaceArtifactPathsInternal( + Directory root, + String relativePath, +) async { + final targetPath = DesktopThreadArtifactService.resolveAbsolutePathInternal( + root.path, + relativePath, + ); + final targetType = await FileSystemEntity.type( + targetPath, + followLinks: false, + ); + if (targetType == FileSystemEntityType.file) { + final resolvedRelativePath = + DesktopThreadArtifactService.relativePathInternal( + root.path, + targetPath, + ); + return resolvedRelativePath == null || resolvedRelativePath.isEmpty + ? const [] + : [resolvedRelativePath]; + } + if (targetType != FileSystemEntityType.directory) { + return const []; + } + final files = await DesktopThreadArtifactService().collectFilesInternal( + Directory(targetPath), + ); + final paths = []; + for (final file in files) { + final resolvedRelativePath = + DesktopThreadArtifactService.relativePathInternal(root.path, file.path); + if (resolvedRelativePath != null && resolvedRelativePath.isNotEmpty) { + paths.add(resolvedRelativePath); + } + } + paths.sort(); + return paths; +} + String _normalizeAuthorizationHeaderInternal(String raw) { final trimmed = raw.trim(); if (trimmed.isEmpty) { diff --git a/test/runtime/app_controller_thread_workspace_binding_test.dart b/test/runtime/app_controller_thread_workspace_binding_test.dart index 3025ceea..63a67609 100644 --- a/test/runtime/app_controller_thread_workspace_binding_test.dart +++ b/test/runtime/app_controller_thread_workspace_binding_test.dart @@ -589,6 +589,88 @@ void main() { }, ); + test('syncs existing workspace directory artifacts recursively', () async { + final controller = AppController( + environmentOverride: const {}, + ); + addTearDown(controller.dispose); + + final localWorkspace = await Directory.systemTemp.createTemp( + 'xworkmate-recursive-artifact-workspace-', + ); + addTearDown(() async { + if (await localWorkspace.exists()) { + await localWorkspace.delete(recursive: true); + } + }); + await Directory( + '${localWorkspace.path}/assets/images/chapters', + ).create(recursive: true); + await File( + '${localWorkspace.path}/assets/images/cover.png', + ).writeAsBytes([1, 2, 3]); + await File( + '${localWorkspace.path}/assets/images/chapters/chapter-1.png', + ).writeAsBytes([4, 5, 6]); + await File( + '${localWorkspace.path}/chapters/codex-chapter-breakdown.md', + ).create(recursive: true); + + controller.upsertTaskThreadInternal( + 'unit-fixture-task-a', + workspaceBinding: WorkspaceBinding( + workspaceId: 'unit-fixture-task-a', + workspaceKind: WorkspaceKind.localFs, + workspacePath: localWorkspace.path, + displayPath: localWorkspace.path, + writable: true, + ), + ); + + final result = GoTaskServiceResult( + success: true, + message: 'generated files', + turnId: 'turn-recursive', + raw: { + 'artifacts': >[ + {'relativePath': 'assets/images/'}, + { + 'relativePath': 'chapters/codex-chapter-breakdown.md', + }, + ], + }, + errorMessage: '', + resolvedModel: '', + route: GoTaskServiceRoute.externalAcpSingle, + ); + + await controller.persistGoTaskArtifactsForSessionInternal( + 'unit-fixture-task-a', + result, + ); + + final thread = controller.requireTaskThreadForSessionInternal( + 'unit-fixture-task-a', + ); + expect(thread.lastArtifactSyncStatus, 'synced'); + expect(thread.lastTaskArtifactRelativePaths, [ + 'assets/images/chapters/chapter-1.png', + 'assets/images/cover.png', + 'chapters/codex-chapter-breakdown.md', + ]); + final snapshot = await controller.loadAssistantArtifactSnapshot( + sessionKey: 'unit-fixture-task-a', + ); + expect( + snapshot.resultEntries.map((entry) => entry.relativePath), + containsAll([ + 'assets/images/chapters/chapter-1.png', + 'assets/images/cover.png', + 'chapters/codex-chapter-breakdown.md', + ]), + ); + }); + test( 'downloads bridge URL artifacts into the local thread workspace', () async {