Merge branch 'codex/strict-taskthread-workspacebinding'

This commit is contained in:
Haitao Pan 2026-04-06 06:48:19 +08:00
commit b4b6e642cc
27 changed files with 491 additions and 371 deletions

View File

@ -9,10 +9,10 @@
1. `TaskThread` 是任务线程的唯一主对象。
2. UI 保持现有结构不变,但线程选择的唯一键是 `TaskThread.threadId`
3. UI 选中线程后,系统必须读取完整 `TaskThread`,而不是从页面状态拼装线程信息。
4. `TaskThread` 持久化 schema 保持不变;本轮只迁移 runtime 解释层与执行链路
4. `TaskThread` 持久化 schema 保持不变,但 `workspaceBinding` 在 create/load 时必须完整;缺失 binding 的旧记录按非法数据处理并跳过加载
5. 执行请求由 controller / runtime 根据 `ownerScope / workspaceBinding / executionBinding / contextState` 构造。
6. controller / runtime 统一通过 `Go Agent-core` 调度执行Desktop 走 App 内 local bridgeWeb 走远端 ACP / RPC endpoint。
7. 执行结果先回写 `TaskThread.contextState`,主体区域同步显示;只有必要时才更新 `workspaceBinding`;右栏展示读取的是当前 `TaskThread` 最新记录
7. 执行结果先回写 `TaskThread.contextState`,主体区域同步显示;UI 与执行始终只读取当前 `TaskThread.workspaceBinding`,不再存在 runtime first-binding 或 fallback 到 `main`
## 2. TaskThread 结构
@ -112,7 +112,7 @@ ThreadLifecycleState
职责:
- `archived`:归档标记
- `status`:线程生命周期摘要,例如 `needs_workspace / ready`
- `status`:线程生命周期摘要,例如 `ready / error`
- `lastRunAtMs / lastResultCode`:最近执行摘要
## 3. TaskThread 生命周期主链
@ -136,7 +136,7 @@ flowchart LR
F --> G["执行结果"]
G --> H["回写线程上下文\n(主体区域 同步显示)"]
G --> I["必要时更新 workspaceBinding"]
G --> I["仅显式更新当前线程 workspaceBinding"]
H --> J["右栏显示"]
I --> J
@ -149,8 +149,8 @@ flowchart LR
3. `构造执行请求` 属于 agent-core / runtime 协调层,不属于 UI。
4. `Go Agent-core` 是唯一执行调度面Desktop / Web 共用同一套 session 语义,只在 transport 上有差异。
5. `回写线程上下文` 是执行结束后的第一落点;主体区域同步显示依赖这一回写。
6. `必要时更新 workspaceBinding` 是条件分支,不是固定每次都发生的回写步骤
7. `右栏显示` 读取的是当前 `TaskThread` 最新记录,因此它与主体区域共享同一线程事实来源。
6. `workspaceBinding` 不是运行时补齐对象;线程在 create/load 时必须已经完整
7. `右栏显示` 与执行请求都读取当前 `TaskThread.workspaceBinding`,因此它与主体区域共享同一线程事实来源。
## 4. 当前设计约束
@ -170,7 +170,7 @@ flowchart LR
### 4.3 TaskThread 约束
- `threadId` 是线程身份唯一键。
- `workspaceBinding`线程生命周期字段,不再承担 fallback 猜测语义
- `workspaceBinding` `TaskThread` 的必填生命周期字段create/load 阶段缺失即为非法线程数据
- `contextState` 是线程上下文真相源。
- `lifecycleState` 只表达归档与生命周期摘要,不替代线程主体模型。

View File

@ -27,7 +27,7 @@ currentSessionKey
这条链路说明:
1. 真正决定执行目录的是 `sessionKey`
2. `workspace_root` 文本上下文本身不会自动进入线程绑定
2. `workspace_root` 文本上下文本身不会创建 first binding
3. 空 `sessionKey` 会被归一为 `main`
4. 一旦多个任务线没有真正切换到独立 `sessionKey`,它们就会共享 `main`
@ -51,7 +51,7 @@ currentSessionKey
3. `TaskThread.threadId` 就是该任务线的 `sessionKey`
4. 任何可触发执行的入口都不得在空 `sessionKey` 下运行。
5. 对非主线程任务线,禁止 silent fallback 到 `main`
6. `workspace_root` 不是线程身份;它最多只能作为该线程 `workspaceBinding` 的输入之一
6. `workspace_root` 不是线程身份;它只能更新当前已存在线程的 `workspaceBinding`,不能创建 first binding
换句话说:
@ -146,7 +146,7 @@ request.workingDirectory == current TaskThread.workspaceBinding.workspacePath
- 空 `sessionKey -> main` 只允许用于真正的主线程初始化阶段
- 非主线程任务线若缺少 `sessionKey`,状态应为 `needs_binding``not_runnable`
- 非主线程任务线若缺少工作路径,状态应为 `needs_workspace`
- 本地可执行线程在 create/load 阶段必须已经拥有唯一工作目录
- 不允许继续执行并偷偷落到 `threads/main`
## 5. `workspace_root` 的正确角色
@ -161,15 +161,14 @@ request.workingDirectory == current TaskThread.workspaceBinding.workspacePath
它可以是:
- 创建新线程时的初始工作区候选根目录
- 外部 provider / 执行上下文导入时的显式 workspace bootstrap 输入
- 当前线程 `workspaceBinding` 的一次用户确认更新来源
- 当前线程 `workspaceBinding` 的一次显式更新来源
- 外部 provider / 执行上下文导入时对当前线程 binding 的确认更新输入
因此正确顺序应为:
```text
Execution context.workspace_root
-> bind/update current TaskThread.workspaceBinding
-> update current TaskThread.workspaceBinding
-> persist on that TaskThread
-> subsequent execute reads TaskThread.workspaceBinding.workspacePath
```
@ -280,7 +279,7 @@ single-agent 入口必须在执行前验证:
1. 历史共享 `main` 的记录继续作为 `main`
2. 从修正版本开始,新建任务线必须创建独立 `sessionKey`
3. 对已暴露出共享问题的入口,优先阻止继续 silent fallback
3. 对已暴露出共享问题的入口,优先阻止继续 silent fallback,并移除 runtime first-binding
### 8.3 未绑定任务线
@ -303,7 +302,7 @@ single-agent 入口必须在执行前验证:
3. 切换任务线后,`currentSessionKey` 与右栏路径同步变化。
4. single-agent 请求里的 `sessionId / threadId / workingDirectory` 始终对应当前线程。
5. 任意非主线程缺少 `sessionKey` 时,执行被阻止,而不是回落到 `main`
6. `workspace_root` 被当作线程 binding 输入处理,而不是 prompt-only 文本或跨线程覆盖指令。
6. `workspace_root` 被当作当前线程 binding 的显式更新输入处理,而不是 prompt-only 文本、first-binding 指令或跨线程覆盖指令。
## 10. 与现有架构文档的关系

View File

@ -181,6 +181,8 @@ Ownership summary:
- 如果值已经存在于 `TaskThread`,则线程值优先于 Settings 默认值
- `TaskThread` 是当前线程展示与执行的唯一主对象
- `TaskThread` 在 create/load 时必须已经拥有完整 `workspaceBinding`
- 缺少 `workspaceBinding` 的旧记录属于非法线程数据,应在恢复阶段跳过并通过启动告警暴露
### 3.3 Agent-Core / Runtime 协调状态
@ -196,6 +198,7 @@ Primary responsibilities:
- 请求构造不属于 UI
- Flutter UI 不直接承担 runtime dispatch 职责
- 工作空间选择不再通过旧式运行前猜测获得
- 不允许 runtime fallback 到 `main`、`Directory.current` 或 prompt first-binding
- 结果回写先更新线程上下文,再驱动主体区域与右栏刷新
- Desktop / Web 共用相同 session 生命周期;不再单独发明 relay-only 执行协议

View File

@ -357,6 +357,7 @@ class AppController extends ChangeNotifier {
String settingsDraftStatusMessageInternal = '';
bool initializingInternal = true;
String? bootstrapErrorInternal;
String? startupTaskThreadWarningInternal;
StreamSubscription<GatewayPushEvent>? runtimeEventsSubscriptionInternal;
bool disposedInternal = false;
String resolvedUserHomeDirectoryInternal = resolveUserHomeDirectory();
@ -414,6 +415,15 @@ class AppController extends ChangeNotifier {
DetailPanelData? get detailPanel => detailPanelInternal;
bool get initializing => initializingInternal;
String? get bootstrapError => bootstrapErrorInternal;
String? get startupTaskThreadWarning => startupTaskThreadWarningInternal;
void dismissStartupTaskThreadWarning() {
if ((startupTaskThreadWarningInternal ?? '').trim().isEmpty) {
return;
}
startupTaskThreadWarningInternal = null;
notifyIfActiveInternal();
}
UiFeatureAccess featuresFor(UiFeaturePlatform platform) {
final manifest = applyAppleAppStorePolicy(

View File

@ -449,6 +449,13 @@ extension AppControllerDesktopSettingsRuntime on AppController {
await skillDirectoryAccessServiceInternal.resolveUserHomeDirectory();
await settingsControllerInternal.initialize();
final storedAssistantThreads = await storeInternal.loadTaskThreads();
final skippedInvalidThreadIds = storeInternal.lastSkippedInvalidTaskThreadIds;
startupTaskThreadWarningInternal = skippedInvalidThreadIds.isEmpty
? null
: appText(
'已跳过 ${skippedInvalidThreadIds.length} 个缺少完整 workspaceBinding 的旧任务线程: ${skippedInvalidThreadIds.join(', ')}',
'Skipped ${skippedInvalidThreadIds.length} persisted task threads missing a complete workspaceBinding: ${skippedInvalidThreadIds.join(', ')}',
);
if (disposedInternal) {
return;
}

View File

@ -232,8 +232,19 @@ extension AppControllerDesktopSingleAgent on AppController {
resolvedWorkspaceKind != WorkspaceRefKind.localPath) {
upsertTaskThreadInternal(
sessionKey,
workspaceRef: resolvedWorkingDirectory,
workspaceRefKind: resolvedWorkspaceKind,
workspaceBinding: WorkspaceBinding(
workspaceId: normalizedAssistantSessionKeyInternal(sessionKey),
workspaceKind: resolvedWorkspaceKind == WorkspaceRefKind.remotePath
? WorkspaceKind.remoteFs
: WorkspaceKind.localFs,
workspacePath: resolvedWorkingDirectory,
displayPath: resolvedWorkingDirectory,
writable:
assistantThreadRecordsInternal[normalizedAssistantSessionKeyInternal(sessionKey)]
?.workspaceBinding
.writable ??
true,
),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
}

View File

@ -273,8 +273,6 @@ extension AppControllerDesktopSkillPermissions on AppController {
ThreadSelectionSource? assistantModelSource,
ThreadSelectionSource? selectedSkillsSource,
String? gatewayEntryState,
String? workspaceRef,
WorkspaceRefKind? workspaceRefKind,
}) {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
@ -307,40 +305,13 @@ extension AppControllerDesktopSkillPermissions on AppController {
subjectId: '',
displayName: '',
);
final explicitWorkspaceKind = workspaceRefKind == null
? null
: (workspaceRefKind == WorkspaceRefKind.localPath
? WorkspaceKind.localFs
: WorkspaceKind.remoteFs);
final baseWorkspaceBinding =
workspaceBinding ??
existing?.workspaceBinding ??
WorkspaceBinding(
workspaceId: normalizedSessionKey,
workspaceKind:
explicitWorkspaceKind ??
(nextExecutionTarget == AssistantExecutionTarget.singleAgent
? WorkspaceKind.localFs
: WorkspaceKind.remoteFs),
workspacePath: '',
displayPath: '',
writable: true,
);
final nextWorkspacePath =
workspaceRef ?? baseWorkspaceBinding.workspacePath;
final nextDisplayPath =
workspaceRef ??
(baseWorkspaceBinding.displayPath.trim().isNotEmpty
? baseWorkspaceBinding.displayPath
: nextWorkspacePath);
final nextWorkspaceBinding = baseWorkspaceBinding.copyWith(
workspaceId: baseWorkspaceBinding.workspaceId.trim().isEmpty
? normalizedSessionKey
: baseWorkspaceBinding.workspaceId,
workspaceKind: explicitWorkspaceKind,
workspacePath: nextWorkspacePath,
displayPath: nextDisplayPath,
);
final nextWorkspaceBinding =
workspaceBinding ?? existing?.workspaceBinding;
if (nextWorkspaceBinding == null || !nextWorkspaceBinding.isComplete) {
throw StateError(
'TaskThread $normalizedSessionKey is missing a complete workspaceBinding.',
);
}
final nextProvider =
singleAgentProvider ??
SingleAgentProviderCopy.fromJsonValue(
@ -410,11 +381,8 @@ extension AppControllerDesktopSkillPermissions on AppController {
existing?.contextState.selectedSkillsSource,
gatewayEntryState: gatewayEntryState,
);
final nextStatus = nextWorkspaceBinding.workspacePath.trim().isEmpty
? 'needs_workspace'
: (lifecycleState?.status ??
existing?.lifecycleState.status ??
'ready');
final nextStatus =
lifecycleState?.status ?? existing?.lifecycleState.status ?? 'ready';
final nextLifecycleState =
(lifecycleState ??
existing?.lifecycleState ??

View File

@ -544,6 +544,9 @@ extension AppControllerDesktopThreadActions on AppController {
sessionKey,
);
final existing = assistantThreadRecordsInternal[normalizedSessionKey];
if (existing == null || !existing.workspaceBinding.isComplete) {
return;
}
upsertTaskThreadInternal(
normalizedSessionKey,
workspaceBinding: WorkspaceBinding(
@ -551,16 +554,10 @@ extension AppControllerDesktopThreadActions on AppController {
workspaceKind: WorkspaceKind.localFs,
workspacePath: workspaceRoot,
displayPath: workspaceRoot,
writable: existing?.workspaceBinding.writable ?? true,
writable: existing.workspaceBinding.writable,
),
lifecycleState:
(existing?.lifecycleState ??
const ThreadLifecycleState(
archived: false,
status: 'ready',
lastRunAtMs: null,
lastResultCode: null,
))
(existing.lifecycleState)
.copyWith(status: 'ready'),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
@ -597,56 +594,5 @@ extension AppControllerDesktopThreadActions on AppController {
Future<void> tryBindWorkspaceForOnlyChatFallbackInternal(
String sessionKey,
AssistantExecutionTarget currentTarget,
) async {
if (currentTarget != AssistantExecutionTarget.singleAgent &&
currentTarget != AssistantExecutionTarget.auto) {
return;
}
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
if (!singleAgentUsesAiChatFallbackForSession(normalizedSessionKey)) {
return;
}
if (assistantWorkspacePathForSession(normalizedSessionKey).trim().isNotEmpty) {
return;
}
final candidateRoots = <String>{
settings.workspacePath.trim(),
Directory.current.path.trim(),
settings.remoteProjectRoot.trim(),
}.where((item) => item.isNotEmpty);
for (final root in candidateRoots) {
final threadWorkspace =
'${trimTrailingPathSeparatorInternal(root)}/.xworkmate/threads/${threadWorkspaceDirectoryNameInternal(normalizedSessionKey)}';
if (!ensureLocalWorkspaceDirectoryInternal(threadWorkspace)) {
continue;
}
final existing = assistantThreadRecordsInternal[normalizedSessionKey];
upsertTaskThreadInternal(
normalizedSessionKey,
workspaceBinding: WorkspaceBinding(
workspaceId: normalizedSessionKey,
workspaceKind: WorkspaceKind.localFs,
workspacePath: threadWorkspace,
displayPath: threadWorkspace,
writable: existing?.workspaceBinding.writable ?? true,
),
lifecycleState:
(existing?.lifecycleState ??
const ThreadLifecycleState(
archived: false,
status: 'ready',
lastRunAtMs: null,
lastResultCode: null,
))
.copyWith(status: 'ready'),
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await flushAssistantThreadPersistenceInternal();
recomputeTasksInternal();
return;
}
}
) async {}
}

View File

@ -142,29 +142,16 @@ extension AppControllerDesktopThreadBinding on AppController {
if (executionTarget == AssistantExecutionTarget.auto ||
executionTarget == AssistantExecutionTarget.singleAgent) {
if (existingBinding != null &&
existingBinding.workspacePath.trim().isNotEmpty) {
if (existingBinding.workspaceKind == WorkspaceKind.localFs) {
if (ensureLocalWorkspaceDirectoryInternal(
existingBinding.workspacePath,
)) {
return existingBinding.copyWith(
displayPath: existingBinding.workspacePath,
);
}
}
final defaultRemotePath = remoteThreadWorkspacePathInternal(
sessionKey,
ownerScope,
);
if (existingBinding.workspacePath.trim() != defaultRemotePath) {
return existingBinding.copyWith(
displayPath: existingBinding.displayPath.trim().isEmpty
? existingBinding.workspacePath
: null,
);
}
existingBinding.workspaceKind == WorkspaceKind.localFs &&
ensureLocalWorkspaceDirectoryInternal(existingBinding.workspacePath)) {
return existingBinding.copyWith(displayPath: existingBinding.workspacePath);
}
final localPath = localThreadWorkspacePathInternal(sessionKey);
if (localPath.isEmpty) {
throw StateError(
'Local executable thread $sessionKey requires a writable local workspace.',
);
}
return WorkspaceBinding(
workspaceId: normalizedAssistantSessionKeyInternal(sessionKey),
workspaceKind: WorkspaceKind.localFs,
@ -242,9 +229,6 @@ extension AppControllerDesktopThreadBinding on AppController {
ownerScope: ownerScope,
existingBinding: existing?.workspaceBinding,
);
final lifecycleStatus = workspaceBinding.workspacePath.trim().isEmpty
? 'needs_workspace'
: 'ready';
upsertTaskThreadInternal(
normalizedSessionKey,
ownerScope: ownerScope,
@ -263,7 +247,7 @@ extension AppControllerDesktopThreadBinding on AppController {
lastRunAtMs: null,
lastResultCode: null,
))
.copyWith(status: lifecycleStatus),
.copyWith(status: 'ready'),
executionTarget: resolvedExecutionTarget,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);

View File

@ -144,21 +144,11 @@ extension AppControllerDesktopThreadSessions on AppController {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final existing =
assistantThreadRecordsInternal[normalizedSessionKey]
return assistantThreadRecordsInternal[normalizedSessionKey]
?.workspaceBinding
.workspacePath
.trim() ??
'';
if (existing.isNotEmpty) {
return existing;
}
final target = assistantExecutionTargetForSession(normalizedSessionKey);
if (target == AssistantExecutionTarget.singleAgent ||
target == AssistantExecutionTarget.auto) {
return localThreadWorkspacePathInternal(normalizedSessionKey);
}
return '';
}
WorkspaceRefKind assistantWorkspaceKindForSession(String sessionKey) {

View File

@ -669,9 +669,20 @@ extension AppControllerDesktopThreadStorage on AppController {
if (sessionKey.isEmpty) {
continue;
}
if (!record.workspaceBinding.isComplete) {
continue;
}
final titleFromSettings = assistantCustomTaskTitle(sessionKey);
final workspaceBinding = record.workspaceBinding.copyWith(
workspaceId: sessionKey,
displayPath: record.workspaceKind == WorkspaceKind.localFs
? record.workspacePath.trim()
: (record.displayPath.trim().isEmpty
? record.workspacePath.trim()
: record.displayPath.trim()),
);
final normalizedRecord = record.copyWith(
sessionKey: sessionKey,
threadId: sessionKey,
title: titleFromSettings.isEmpty
? record.title.trim()
: titleFromSettings,
@ -690,16 +701,8 @@ extension AppControllerDesktopThreadStorage on AppController {
gatewayEntryState: (record.gatewayEntryState ?? '').trim().isEmpty
? gatewayEntryStateForTargetInternal(record.executionTarget)
: record.gatewayEntryState,
workspacePath: record.workspacePath.trim(),
displayPath: record.workspaceKind == WorkspaceKind.localFs
? record.workspacePath.trim()
: (record.displayPath.trim().isEmpty
? record.workspacePath.trim()
: record.displayPath.trim()),
workspaceKind: record.workspaceKind,
lifecycleStatus: record.workspacePath.trim().isEmpty
? 'needs_workspace'
: record.lifecycleState.status,
workspaceBinding: workspaceBinding,
lifecycleState: record.lifecycleState.copyWith(status: 'ready'),
);
if (normalizedRecord.workspaceKind == WorkspaceKind.localFs &&
normalizedRecord.workspacePath.trim().isNotEmpty) {

View File

@ -60,16 +60,26 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
settings.assistantExecutionTarget == resolvedTarget) {
return;
}
if (!assistantThreadRecordsInternal.containsKey(
sessionsControllerInternal.currentSessionKey,
)) {
initializeAssistantThreadContext(
sessionsControllerInternal.currentSessionKey,
executionTarget: resolvedTarget,
messageViewMode: currentAssistantMessageViewMode,
singleAgentProvider: currentSingleAgentProvider,
);
}
await ensureDesktopTaskThreadBindingInternal(
sessionsControllerInternal.currentSessionKey,
executionTarget: resolvedTarget,
);
upsertTaskThreadInternal(
sessionsControllerInternal.currentSessionKey,
executionTarget: resolvedTarget,
executionTargetSource: ThreadSelectionSource.explicit,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
);
await ensureDesktopTaskThreadBindingInternal(
sessionsControllerInternal.currentSessionKey,
executionTarget: resolvedTarget,
);
recomputeTasksInternal();
notifyIfActiveInternal();
await applyAssistantExecutionTargetInternal(
@ -93,6 +103,14 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
if (singleAgentProviderForSession(sessionKey) == sanitizedProvider) {
return;
}
if (!assistantThreadRecordsInternal.containsKey(sessionKey)) {
initializeAssistantThreadContext(
sessionKey,
executionTarget: assistantExecutionTargetForSession(sessionKey),
messageViewMode: assistantMessageViewModeForSession(sessionKey),
singleAgentProvider: currentSingleAgentProvider,
);
}
singleAgentRuntimeModelBySessionInternal.remove(sessionKey);
upsertTaskThreadInternal(
sessionKey,
@ -122,6 +140,14 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
if (assistantMessageViewModeForSession(sessionKey) == mode) {
return;
}
if (!assistantThreadRecordsInternal.containsKey(sessionKey)) {
initializeAssistantThreadContext(
sessionKey,
executionTarget: assistantExecutionTargetForSession(sessionKey),
messageViewMode: assistantMessageViewModeForSession(sessionKey),
singleAgentProvider: singleAgentProviderForSession(sessionKey),
);
}
upsertTaskThreadInternal(
sessionKey,
messageViewMode: mode,
@ -245,6 +271,14 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
trimmed) {
return;
}
if (!assistantThreadRecordsInternal.containsKey(normalizedSessionKey)) {
initializeAssistantThreadContext(
normalizedSessionKey,
executionTarget: assistantExecutionTargetForSession(normalizedSessionKey),
messageViewMode: assistantMessageViewModeForSession(normalizedSessionKey),
singleAgentProvider: singleAgentProviderForSession(normalizedSessionKey),
);
}
upsertTaskThreadInternal(
normalizedSessionKey,
assistantModelId: trimmed,
@ -281,24 +315,25 @@ extension AppControllerDesktopWorkspaceExecution on AppController {
final resolvedTarget =
executionTarget ??
assistantExecutionTargetForSession(currentSessionKey);
final initialWorkspaceBinding =
resolvedTarget == AssistantExecutionTarget.singleAgent
? (() {
final localPath = localThreadWorkspacePathInternal(
normalizedSessionKey,
);
return WorkspaceBinding(
workspaceId: normalizedSessionKey,
workspaceKind: WorkspaceKind.localFs,
workspacePath: localPath,
displayPath: localPath,
writable: true,
);
})()
: null;
final initialOwnerScope =
assistantThreadRecordsInternal[normalizedSessionKey]?.ownerScope ??
const ThreadOwnerScope(
realm: ThreadRealm.local,
subjectType: ThreadSubjectType.user,
subjectId: '',
displayName: '',
);
final initialWorkspaceBinding = buildDesktopWorkspaceBindingInternal(
normalizedSessionKey,
executionTarget: resolvedTarget,
ownerScope: initialOwnerScope,
existingBinding:
assistantThreadRecordsInternal[normalizedSessionKey]?.workspaceBinding,
);
upsertTaskThreadInternal(
normalizedSessionKey,
title: title.trim(),
ownerScope: initialOwnerScope,
executionTarget: resolvedTarget,
workspaceBinding: initialWorkspaceBinding,
messageViewMode:

View File

@ -247,7 +247,7 @@ extension AppControllerWebGatewayRelay on AppController {
);
final existing = threadRecordsInternal[resolvedKey];
final next = (existing ?? newRecordInternal(target: target)).copyWith(
sessionKey: resolvedKey,
threadId: resolvedKey,
messages: messages,
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
title: deriveThreadTitleInternal(

View File

@ -105,19 +105,22 @@ extension AppControllerWebHelpers on AppController {
final target =
sanitizeTargetInternal(record.executionTarget) ??
AssistantExecutionTarget.singleAgent;
final workspacePath = record.workspacePath.trim();
return record.copyWith(
executionTarget: target,
title: record.title.trim().isEmpty
? appText('新对话', 'New conversation')
: record.title.trim(),
workspacePath: record.workspacePath.trim(),
displayPath: record.displayPath.trim().isEmpty
? record.workspacePath.trim()
: record.displayPath.trim(),
workspaceKind: WorkspaceKind.remoteFs,
lifecycleStatus: record.workspacePath.trim().isEmpty
? 'needs_workspace'
: 'ready',
workspaceBinding: WorkspaceBinding(
workspaceId: record.threadId,
workspaceKind: WorkspaceKind.remoteFs,
workspacePath: workspacePath,
displayPath: record.displayPath.trim().isEmpty
? workspacePath
: record.displayPath.trim(),
writable: record.workspaceBinding.writable,
),
lifecycleState: record.lifecycleState.copyWith(status: 'ready'),
);
}
@ -146,6 +149,8 @@ extension AppControllerWebHelpers on AppController {
AssistantExecutionTarget.remote => 'remote',
};
final threadId = '$prefix:$timestamp';
final workspacePath =
'/owners/${ThreadRealm.remote.name}/${ThreadSubjectType.user.name}/threads/$threadId';
return TaskThread(
threadId: threadId,
createdAtMs: timestamp.toDouble(),
@ -160,8 +165,8 @@ extension AppControllerWebHelpers on AppController {
workspaceBinding: WorkspaceBinding(
workspaceId: threadId,
workspaceKind: WorkspaceKind.remoteFs,
workspacePath: '',
displayPath: '',
workspacePath: workspacePath,
displayPath: workspacePath,
writable: true,
),
executionBinding: ExecutionBinding(
@ -188,7 +193,7 @@ extension AppControllerWebHelpers on AppController {
),
lifecycleState: const ThreadLifecycleState(
archived: false,
status: 'needs_workspace',
status: 'ready',
lastRunAtMs: null,
lastResultCode: null,
),
@ -281,7 +286,7 @@ extension AppControllerWebHelpers on AppController {
);
threadRecordsInternal[key] =
(existing ?? newRecordInternal(target: resolvedTarget)).copyWith(
sessionKey: key,
threadId: key,
ownerScope: ownerScope,
workspaceBinding: workspaceBinding,
executionBinding: buildWebExecutionBindingInternal(
@ -462,6 +467,12 @@ extension AppControllerWebHelpers on AppController {
assistantExecutionTargetForSession(key);
final existing =
threadRecordsInternal[key] ?? newRecordInternal(target: resolvedTarget);
final nextWorkspaceBinding = existing.workspaceBinding;
if (!nextWorkspaceBinding.isComplete) {
throw StateError(
'TaskThread $key is missing a complete workspaceBinding.',
);
}
threadRecordsInternal[key] = existing.copyWith(
threadId: key,
messages: messages ?? existing.messages,
@ -485,8 +496,15 @@ extension AppControllerWebHelpers on AppController {
selectedSkillsSource ?? existing.contextState.selectedSkillsSource,
gatewayEntryState: gatewayEntryState ?? existing.gatewayEntryState,
clearGatewayEntryState: clearGatewayEntryState,
workspacePath: workspacePath ?? existing.workspacePath,
workspaceKind: workspaceKind ?? existing.workspaceKind,
workspaceBinding:
(workspacePath != null || workspaceKind != null)
? nextWorkspaceBinding.copyWith(
workspacePath: workspacePath,
displayPath: workspacePath ?? nextWorkspaceBinding.displayPath,
workspaceKind: workspaceKind,
)
: nextWorkspaceBinding,
lifecycleState: existing.lifecycleState.copyWith(status: 'ready'),
);
recomputeDerivedWorkspaceStateInternal();
}

View File

@ -97,48 +97,81 @@ class _AppShellState extends State<AppShell> {
animation: widget.controller,
builder: (context, _) {
final controller = widget.controller;
final palette = context.palette;
return Scaffold(
body: SafeArea(
bottom: false,
child: LayoutBuilder(
builder: (context, constraints) {
final palette = context.palette;
final platform = Theme.of(context).platform;
final isCompactMobile =
(platform == TargetPlatform.iOS ||
platform == TargetPlatform.android) &&
constraints.maxWidth < 900;
final isMobile = constraints.maxWidth < 900;
final sidebarState = controller.sidebarState;
final showSidebar = sidebarState != AppSidebarState.hidden;
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final sidebarTaskItems = _buildSidebarTaskItems(controller);
final expandedSidebarWidth = _clampSidebarWidth(
_sidebarExpandedWidth ??
_defaultSidebarWidth(
controller.appLanguage,
constraints.maxWidth,
child: Column(
children: [
if ((controller.startupTaskThreadWarning ?? '').trim().isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: palette.accentMuted,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.warning),
),
constraints.maxWidth,
);
final showPinnedDetail =
controller.detailPanel != null &&
constraints.maxWidth > 1280;
final mobileDestination =
controller.destination == WorkspaceDestination.account
? WorkspaceDestination.assistant
: controller.destination;
final availableMobileDestinations = _mobileDestinations
.where(controller.capabilities.supportsDestination)
.toList(growable: false);
final resolvedMobileDestination =
availableMobileDestinations.contains(mobileDestination)
? mobileDestination
: (availableMobileDestinations.isEmpty
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
controller.startupTaskThreadWarning!,
style: Theme.of(context).textTheme.bodyMedium,
),
),
const SizedBox(width: 12),
TextButton(
onPressed: controller.dismissStartupTaskThreadWarning,
child: Text(appText('关闭', 'Dismiss')),
),
],
),
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final palette = context.palette;
final platform = Theme.of(context).platform;
final isCompactMobile =
(platform == TargetPlatform.iOS ||
platform == TargetPlatform.android) &&
constraints.maxWidth < 900;
final isMobile = constraints.maxWidth < 900;
final sidebarState = controller.sidebarState;
final showSidebar = sidebarState != AppSidebarState.hidden;
final uiFeatures = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final sidebarTaskItems = _buildSidebarTaskItems(controller);
final expandedSidebarWidth = _clampSidebarWidth(
_sidebarExpandedWidth ??
_defaultSidebarWidth(
controller.appLanguage,
constraints.maxWidth,
),
constraints.maxWidth,
);
final showPinnedDetail =
controller.detailPanel != null &&
constraints.maxWidth > 1280;
final mobileDestination =
controller.destination == WorkspaceDestination.account
? WorkspaceDestination.assistant
: controller.destination;
final availableMobileDestinations = _mobileDestinations
.where(controller.capabilities.supportsDestination)
.toList(growable: false);
final resolvedMobileDestination =
availableMobileDestinations.contains(mobileDestination)
? mobileDestination
: availableMobileDestinations.first);
: (availableMobileDestinations.isEmpty
? mobileDestination
: availableMobileDestinations.first);
void openMobileDetail(DetailPanelData detail) {
showModalBottomSheet<void>(
@ -404,7 +437,10 @@ class _AppShellState extends State<AppShell> {
),
],
);
},
},
),
),
],
),
),
);

View File

@ -588,6 +588,9 @@ class WorkspaceBinding {
final String displayPath;
final bool writable;
bool get isComplete =>
workspaceId.trim().isNotEmpty && workspacePath.trim().isNotEmpty;
WorkspaceBinding copyWith({
String? workspaceId,
WorkspaceKind? workspaceKind,
@ -616,11 +619,17 @@ class WorkspaceBinding {
factory WorkspaceBinding.fromJson(Map<String, dynamic> json) {
final path = json['workspacePath']?.toString() ?? '';
final workspaceId = json['workspaceId']?.toString() ?? '';
final workspaceKindValue = json['workspaceKind']?.toString();
if (workspaceId.trim().isEmpty ||
path.trim().isEmpty ||
workspaceKindValue == null ||
workspaceKindValue.trim().isEmpty) {
throw const FormatException('TaskThread.workspaceBinding is incomplete.');
}
return WorkspaceBinding(
workspaceId: json['workspaceId']?.toString() ?? '',
workspaceKind: WorkspaceKindCopy.fromJsonValue(
json['workspaceKind']?.toString(),
),
workspaceId: workspaceId,
workspaceKind: WorkspaceKindCopy.fromJsonValue(workspaceKindValue),
workspacePath: path,
displayPath: json['displayPath']?.toString() ?? path,
writable: json['writable'] as bool? ?? true,
@ -877,11 +886,10 @@ class ThreadLifecycleState {
class TaskThread {
TaskThread({
String? threadId,
String? sessionKey,
required String threadId,
String? title,
ThreadOwnerScope? ownerScope,
WorkspaceBinding? workspaceBinding,
required WorkspaceBinding workspaceBinding,
ExecutionBinding? executionBinding,
ThreadContextState? contextState,
ThreadLifecycleState? lifecycleState,
@ -896,15 +904,11 @@ class TaskThread {
String? assistantModelId,
SingleAgentProvider? singleAgentProvider,
String? gatewayEntryState,
String? workspaceRef,
WorkspaceRefKind? workspaceRefKind,
String? displayPath,
AssistantPermissionLevel? permissionLevel,
String? latestResolvedRuntimeModel,
String? lifecycleStatus,
double? lastRunAtMs,
String? lastResultCode,
}) : threadId = _resolveThreadId(threadId, sessionKey),
}) : threadId = _resolveThreadId(threadId),
title = title ?? '',
ownerScope =
ownerScope ??
@ -914,15 +918,7 @@ class TaskThread {
subjectId: '',
displayName: '',
),
workspaceBinding =
workspaceBinding ??
WorkspaceBinding(
workspaceId: _resolveThreadId(threadId, sessionKey),
workspaceKind: _workspaceKindFromLegacy(workspaceRefKind),
workspacePath: workspaceRef?.trim() ?? '',
displayPath: (displayPath ?? workspaceRef ?? '').trim(),
writable: true,
),
workspaceBinding = _validateWorkspaceBinding(workspaceBinding),
executionBinding =
executionBinding ??
ExecutionBinding(
@ -953,11 +949,7 @@ class TaskThread {
lifecycleState ??
ThreadLifecycleState(
archived: archived ?? false,
status:
lifecycleStatus ??
((workspaceRef?.trim().isEmpty ?? true)
? 'needs_workspace'
: 'ready'),
status: 'ready',
lastRunAtMs: lastRunAtMs,
lastResultCode: lastResultCode?.trim(),
),
@ -1016,7 +1008,6 @@ class TaskThread {
TaskThread copyWith({
String? threadId,
String? sessionKey,
String? title,
ThreadOwnerScope? ownerScope,
WorkspaceBinding? workspaceBinding,
@ -1040,13 +1031,6 @@ class TaskThread {
ThreadSelectionSource? selectedSkillsSource,
String? gatewayEntryState,
bool clearGatewayEntryState = false,
String? workspaceRef,
WorkspaceRefKind? workspaceRefKind,
String? workspacePath,
String? displayPath,
WorkspaceKind? workspaceKind,
bool? writable,
String? lifecycleStatus,
String? latestResolvedRuntimeModel,
}) {
final nextExecutionBinding = executionBinding ?? this.executionBinding;
@ -1063,19 +1047,10 @@ class TaskThread {
ThreadExecutionMode.gatewayRemote,
};
return TaskThread(
threadId: threadId ?? sessionKey ?? this.threadId,
threadId: threadId ?? this.threadId,
title: title ?? this.title,
ownerScope: ownerScope ?? this.ownerScope,
workspaceBinding: (workspaceBinding ?? this.workspaceBinding).copyWith(
workspacePath: workspacePath ?? workspaceRef,
displayPath: displayPath,
workspaceKind:
workspaceKind ??
(workspaceRefKind == null
? null
: _workspaceKindFromLegacy(workspaceRefKind)),
writable: writable,
),
workspaceBinding: workspaceBinding ?? this.workspaceBinding,
executionBinding: nextExecutionBinding.copyWith(
executionMode: nextExecutionMode,
executorId: singleAgentProvider?.providerId,
@ -1097,23 +1072,25 @@ class TaskThread {
),
lifecycleState: (lifecycleState ?? this.lifecycleState).copyWith(
archived: archived,
status: lifecycleStatus,
),
createdAtMs: createdAtMs ?? this.createdAtMs,
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
);
}
static String _resolveThreadId(String? threadId, String? sessionKey) {
return (threadId ?? sessionKey ?? '').trim();
static String _resolveThreadId(String threadId) {
return threadId.trim();
}
static WorkspaceKind _workspaceKindFromLegacy(WorkspaceRefKind? kind) {
return switch (kind) {
WorkspaceRefKind.remotePath ||
WorkspaceRefKind.objectStore => WorkspaceKind.remoteFs,
_ => WorkspaceKind.localFs,
};
static WorkspaceBinding _validateWorkspaceBinding(
WorkspaceBinding workspaceBinding,
) {
if (!workspaceBinding.isComplete) {
throw StateError(
'TaskThread requires a complete workspaceBinding at create/load time.',
);
}
return workspaceBinding;
}
static ThreadExecutionMode _executionModeFromLegacy(

View File

@ -79,6 +79,9 @@ class SecureConfigStore {
return _settingsStore.loadTaskThreads();
}
List<String> get lastSkippedInvalidTaskThreadIds =>
_settingsStore.lastSkippedInvalidTaskThreadIds;
Future<void> saveTaskThreads(List<TaskThread> records) {
return _settingsStore.saveTaskThreads(records);
}

View File

@ -58,10 +58,13 @@ class SettingsStore {
PersistentWriteFailure? _tasksWriteFailure;
PersistentWriteFailure? _auditWriteFailure;
bool _taskThreadStateResetRequired = false;
List<String> _lastSkippedInvalidTaskThreadIds = const <String>[];
PersistentWriteFailure? get settingsWriteFailure => _settingsWriteFailure;
PersistentWriteFailure? get tasksWriteFailure => _tasksWriteFailure;
PersistentWriteFailure? get auditWriteFailure => _auditWriteFailure;
List<String> get lastSkippedInvalidTaskThreadIds =>
List<String>.unmodifiable(_lastSkippedInvalidTaskThreadIds);
Future<void> initialize() async {
if (_initialized) {
@ -426,8 +429,25 @@ class SettingsStore {
return const <TaskThread>[];
}
_taskThreadStateResetRequired = false;
_lastSkippedInvalidTaskThreadIds = const <String>[];
final orderedKeys = index.sessions;
final recordsByKey = <String, TaskThread>{};
final skippedIds = <String>{};
String inferThreadIdFromTaskFile(File file) {
final name = file.uri.pathSegments.isEmpty
? file.path
: file.uri.pathSegments.last;
final encoded = name.endsWith('.json')
? name.substring(0, name.length - 5)
: name;
try {
return utf8.decode(base64Url.decode(base64Url.normalize(encoded)));
} catch (_) {
return encoded;
}
}
try {
await for (final entity in layout.tasksDirectory.list()) {
if (entity is! File ||
@ -452,6 +472,7 @@ class SettingsStore {
}
}
} catch (_) {
skippedIds.add(inferThreadIdFromTaskFile(entity));
continue;
}
}
@ -472,6 +493,7 @@ class SettingsStore {
ordered.add(record);
}
}
_lastSkippedInvalidTaskThreadIds = skippedIds.toList()..sort();
return ordered;
}

View File

@ -254,26 +254,36 @@ void registerAssistantPageSuiteComposerTestsInternal() {
).create(recursive: true);
await store.saveTaskThreads(<TaskThread>[
TaskThread(
sessionKey: 'main',
threadId: 'main',
workspaceBinding: WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '${tempDirectory.path}/thread-main',
displayPath: '${tempDirectory.path}/thread-main',
writable: true,
),
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: 'Main',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: '${tempDirectory.path}/thread-main',
workspaceRefKind: WorkspaceRefKind.localPath,
),
TaskThread(
sessionKey: 'draft:artifact-thread',
threadId: 'draft:artifact-thread',
workspaceBinding: WorkspaceBinding(
workspaceId: 'draft:artifact-thread',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '${tempDirectory.path}/thread-task',
displayPath: '${tempDirectory.path}/thread-task',
writable: true,
),
messages: const <GatewayChatMessage>[],
updatedAtMs: 2,
title: 'Artifact Thread',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: '${tempDirectory.path}/thread-task',
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);
controller = CaptureSendAppControllerInternal(
@ -823,7 +833,14 @@ void registerAssistantPageSuiteComposerTestsInternal() {
final controller = await createControllerWithThreadRecordsInternal(
records: <TaskThread>[
TaskThread(
sessionKey: 'main',
threadId: 'main',
workspaceBinding: const WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '/tmp/main-thread',
displayPath: '/tmp/main-thread',
writable: true,
),
title: '研发任务',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
@ -937,7 +954,14 @@ void registerAssistantPageSuiteComposerTestsInternal() {
final controller = await createControllerWithThreadRecordsInternal(
records: <TaskThread>[
TaskThread(
sessionKey: 'main',
threadId: 'main',
workspaceBinding: const WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '/tmp/main-thread',
displayPath: '/tmp/main-thread',
writable: true,
),
title: '研发任务',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,

View File

@ -530,15 +530,20 @@ void registerAppControllerAiGatewayChatSuiteSingleAgentTestsInternal() {
);
await store.saveTaskThreads(<TaskThread>[
TaskThread(
sessionKey: 'main',
threadId: 'main',
workspaceBinding: WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: threadWorkspace.path,
displayPath: threadWorkspace.path,
writable: true,
),
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: 'Main',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: threadWorkspace.path,
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);

View File

@ -89,15 +89,20 @@ void registerThreadSkillsAcpTests() {
);
await store.saveTaskThreads(<TaskThread>[
TaskThread(
sessionKey: 'main',
threadId: 'main',
workspaceBinding: WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: workspaceRoot.path,
displayPath: workspaceRoot.path,
writable: true,
),
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: '',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: workspaceRoot.path,
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);
@ -281,15 +286,20 @@ void registerThreadSkillsAcpTests() {
);
await store.saveTaskThreads(<TaskThread>[
TaskThread(
sessionKey: 'main',
threadId: 'main',
workspaceBinding: WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '${tempDirectory.path}/missing-workspace',
displayPath: '${tempDirectory.path}/missing-workspace',
writable: true,
),
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: '',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: '${tempDirectory.path}/missing-workspace',
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);

View File

@ -54,15 +54,20 @@ void registerThreadSkillsWorkspaceFallbackTests() {
);
await store.saveTaskThreads(<TaskThread>[
TaskThread(
sessionKey: 'main',
threadId: 'main',
workspaceBinding: WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: workspaceRoot.path,
displayPath: workspaceRoot.path,
writable: true,
),
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: '',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: workspaceRoot.path,
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);
@ -146,15 +151,20 @@ void registerThreadSkillsWorkspaceFallbackTests() {
);
await store.saveTaskThreads(<TaskThread>[
TaskThread(
sessionKey: 'main',
threadId: 'main',
workspaceBinding: WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: workspaceRoot.path,
displayPath: workspaceRoot.path,
writable: true,
),
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: '',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: workspaceRoot.path,
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);
@ -235,15 +245,20 @@ void registerThreadSkillsWorkspaceFallbackTests() {
);
await store.saveTaskThreads(<TaskThread>[
TaskThread(
sessionKey: 'main',
threadId: 'main',
workspaceBinding: WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.localFs,
workspacePath: workspaceRoot.path,
displayPath: workspaceRoot.path,
writable: true,
),
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: '',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: workspaceRoot.path,
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);

View File

@ -58,7 +58,15 @@ void registerSecureConfigStoreSuiteCompatibilityTestsInternal() {
);
final legacyRecords = <TaskThread>[
TaskThread(
sessionKey: 'draft:legacy-1',
threadId: 'draft:legacy-1',
workspaceBinding: const WorkspaceBinding(
workspaceId: 'draft:legacy-1',
workspaceKind: WorkspaceKind.remoteFs,
workspacePath: '/owners/remote/user/legacy/threads/draft:legacy-1',
displayPath:
'/owners/remote/user/legacy/threads/draft:legacy-1',
writable: true,
),
title: 'Legacy thread',
archived: false,
executionTarget: AssistantExecutionTarget.local,
@ -255,52 +263,51 @@ void registerSecureConfigStoreSuiteCompatibilityTestsInternal() {
expect(decoded.lifecycleState.status, 'ready');
});
test('TaskThread defaults lifecycle status to needs_workspace', () {
final decoded = TaskThread.fromJson(<String, dynamic>{
'schemaVersion': taskThreadSchemaVersion,
'threadId': 'thread-legacy',
'title': 'Needs Workspace',
'ownerScope': const <String, Object?>{
'realm': 'local',
'subjectType': 'user',
'subjectId': 'device-1',
'displayName': 'device-1',
},
'workspaceBinding': const <String, Object?>{
'workspaceId': 'thread-legacy',
'workspaceKind': 'localFs',
'workspacePath': '',
'displayPath': '',
'writable': true,
},
'executionBinding': const <String, Object?>{
'executionMode': 'localAgent',
'executorId': 'auto',
'providerId': 'auto',
'endpointId': '',
},
'contextState': const <String, Object?>{
'messages': <Object>[],
'selectedModelId': '',
'selectedSkillKeys': <Object>[],
'importedSkills': <Object>[],
'permissionLevel': 'defaultAccess',
'messageViewMode': 'rendered',
'latestResolvedRuntimeModel': '',
},
'lifecycleState': const <String, Object?>{
'archived': false,
'status': 'needs_workspace',
'lastRunAtMs': null,
'lastResultCode': null,
},
'createdAtMs': 1700000000000,
'updatedAtMs': 1700000000000,
});
expect(decoded.workspaceRef, isEmpty);
expect(decoded.workspaceKind, WorkspaceKind.localFs);
expect(decoded.lifecycleState.status, 'needs_workspace');
test('TaskThread rejects persisted records without a complete binding', () {
expect(
() => TaskThread.fromJson(<String, dynamic>{
'schemaVersion': taskThreadSchemaVersion,
'threadId': 'thread-legacy',
'title': 'Needs Workspace',
'ownerScope': const <String, Object?>{
'realm': 'local',
'subjectType': 'user',
'subjectId': 'device-1',
'displayName': 'device-1',
},
'workspaceBinding': const <String, Object?>{
'workspaceId': 'thread-legacy',
'workspaceKind': 'localFs',
'workspacePath': '',
'displayPath': '',
'writable': true,
},
'executionBinding': const <String, Object?>{
'executionMode': 'localAgent',
'executorId': 'auto',
'providerId': 'auto',
'endpointId': '',
},
'contextState': const <String, Object?>{
'messages': <Object>[],
'selectedModelId': '',
'selectedSkillKeys': <Object>[],
'importedSkills': <Object>[],
'permissionLevel': 'defaultAccess',
'messageViewMode': 'rendered',
'latestResolvedRuntimeModel': '',
},
'lifecycleState': const <String, Object?>{
'archived': false,
'status': 'needs_workspace',
'lastRunAtMs': null,
'lastResultCode': null,
},
'createdAtMs': 1700000000000,
'updatedAtMs': 1700000000000,
}),
throwsFormatException,
);
});
});
}

View File

@ -30,7 +30,14 @@ void registerSecureConfigStoreSuiteLifecycleTestsInternal() {
);
final records = <TaskThread>[
TaskThread(
sessionKey: 'main',
threadId: 'main',
workspaceBinding: const WorkspaceBinding(
workspaceId: 'main',
workspaceKind: WorkspaceKind.remoteFs,
workspacePath: '/owners/remote/user/main/threads/main',
displayPath: '/owners/remote/user/main/threads/main',
writable: true,
),
title: '研发任务',
archived: true,
executionTarget: AssistantExecutionTarget.remote,
@ -132,7 +139,14 @@ void registerSecureConfigStoreSuiteLifecycleTestsInternal() {
);
final records = <TaskThread>[
TaskThread(
sessionKey: 'draft:backup-1',
threadId: 'draft:backup-1',
workspaceBinding: const WorkspaceBinding(
workspaceId: 'draft:backup-1',
workspaceKind: WorkspaceKind.localFs,
workspacePath: '/tmp/draft-backup-1',
displayPath: '/tmp/draft-backup-1',
writable: true,
),
title: '备份线程',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
@ -202,7 +216,15 @@ void registerSecureConfigStoreSuiteLifecycleTestsInternal() {
);
final records = <TaskThread>[
TaskThread(
sessionKey: 'draft:clear-1',
threadId: 'draft:clear-1',
workspaceBinding: const WorkspaceBinding(
workspaceId: 'draft:clear-1',
workspaceKind: WorkspaceKind.remoteFs,
workspacePath: '/owners/remote/user/clear/threads/draft:clear-1',
displayPath:
'/owners/remote/user/clear/threads/draft:clear-1',
writable: true,
),
title: '清理线程',
archived: false,
executionTarget: AssistantExecutionTarget.local,

View File

@ -187,7 +187,16 @@ void registerSecureConfigStoreSuiteSettingsTestsInternal() {
await store.saveTaskThreads(<TaskThread>[
TaskThread(
sessionKey: 'draft:memory-only',
threadId: 'draft:memory-only',
workspaceBinding: const WorkspaceBinding(
workspaceId: 'draft:memory-only',
workspaceKind: WorkspaceKind.remoteFs,
workspacePath:
'/owners/remote/user/memory/threads/draft:memory-only',
displayPath:
'/owners/remote/user/memory/threads/draft:memory-only',
writable: true,
),
title: 'Memory only',
archived: false,
executionTarget: AssistantExecutionTarget.local,

View File

@ -45,7 +45,14 @@ void main() {
final bodies = <String>[];
final records = <TaskThread>[
TaskThread(
sessionKey: 'direct:1',
threadId: 'direct:1',
workspaceBinding: const WorkspaceBinding(
workspaceId: 'direct:1',
workspaceKind: WorkspaceKind.remoteFs,
workspacePath: '/owners/remote/user/direct/threads/direct:1',
displayPath: '/owners/remote/user/direct/threads/direct:1',
writable: true,
),
messages: const <GatewayChatMessage>[
GatewayChatMessage(
id: 'm1',

View File

@ -131,7 +131,16 @@ void main() {
);
await store.saveTaskThreads(<TaskThread>[
TaskThread(
sessionKey: 'direct:stale-browser-cache',
threadId: 'direct:stale-browser-cache',
workspaceBinding: const WorkspaceBinding(
workspaceId: 'direct:stale-browser-cache',
workspaceKind: WorkspaceKind.remoteFs,
workspacePath:
'/owners/remote/user/direct/threads/direct:stale-browser-cache',
displayPath:
'/owners/remote/user/direct/threads/direct:stale-browser-cache',
writable: true,
),
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: 'stale browser cache',