Implement local-first single-agent artifact sync

This commit is contained in:
Haitao Pan 2026-04-10 14:59:42 +08:00
parent ee452fc7ea
commit 0391039c18
15 changed files with 420 additions and 119 deletions

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "vendor/codex"]
path = vendor/codex
url = https://github.com/openai/codex.git

View File

@ -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<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
routing: routing,
routingHint: 'single-agent',
provider: effectiveProvider,
remoteWorkingDirectoryHint:
controller
.requireTaskThreadForSessionInternal(sessionKey)
.lastRemoteWorkingDirectory ??
'',
),
onUpdate: (update) {
if (update.isDelta) {
@ -175,7 +182,7 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
}
},
);
_applySingleAgentGoTaskResultDesktopInternal(
await _applySingleAgentGoTaskResultDesktopInternal(
controller,
sessionKey: sessionKey,
sessionTarget: sessionTarget,
@ -212,7 +219,7 @@ Future<void> sendSingleAgentMessageDesktopGoTaskFlowInternal(
});
}
void _applySingleAgentGoTaskResultDesktopInternal(
Future<void> _applySingleAgentGoTaskResultDesktopInternal(
AppController controller, {
required String sessionKey,
required AssistantExecutionTarget sessionTarget,
@ -220,7 +227,7 @@ void _applySingleAgentGoTaskResultDesktopInternal(
required String thinking,
required List<GatewayChatAttachmentPayload> 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<void> _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,
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: '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,
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<int> _decodeArtifactContentInternal(GoTaskServiceArtifact artifact) {
final encoding = artifact.encoding.trim().toLowerCase();
if (encoding == 'base64') {
return base64Decode(artifact.content);
}
return utf8.encode(artifact.content);
}
Future<File> _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',
);
}

View File

@ -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 ??

View File

@ -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<String, dynamic> 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<Map<String, dynamic>> get memorySources =>
_castMapList(raw['memorySources']);
List<GoTaskServiceArtifact> get artifacts {
final rawArtifacts = raw['artifacts'];
if (rawArtifacts is! List) {
return const <GoTaskServiceArtifact>[];
}
return rawArtifacts
.whereType<Map>()
.map(
(item) => GoTaskServiceArtifact.fromJson(
item.cast<String, dynamic>(),
),
)
.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({

View File

@ -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<GatewayChatMessage> 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<GatewayChatMessage>? 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<String, dynamic> 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'],
};
}

View File

@ -96,7 +96,7 @@ List<ManagedMountTargetState> withAvailableMountTargetsInternal(
Future<void> 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()) {

View File

@ -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>{SingleAgentProvider.opencode},
raw: <String, dynamic>{},
),
result: GoTaskServiceResult(
success: true,
message: 'ARTIFACT_OK',
turnId: 'turn-artifact-1',
raw: <String, dynamic>{
'resolvedWorkingDirectory': '/remote/threads/artifact-thread',
'resolvedWorkspaceRefKind': 'remotePath',
'artifacts': <Map<String, dynamic>>[
<String, dynamic>{
'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>[
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 {

1
vendor/codex vendored

@ -1 +0,0 @@
Subproject commit 78280f872a58dfbb51d2883791d036db00cbfe0f

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

View File

@ -1,49 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta
name="description"
content="XWorkmate Web keeps the Assistant-first workflow with Single Agent and Relay OpenClaw Gateway."
>
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="XWorkmate">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>XWorkmate</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

View File

@ -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"
}
]
}