@taskforcehq/taskforce 0.3.306 → 0.3.307

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/README.md +0 -2
  2. package/dist/Taskforce.module.css +317 -27
  3. package/dist/TaskforceCore.js +1 -0
  4. package/dist/TaskforceCore.test.js +178 -0
  5. package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
  6. package/dist/components/activity/EntityActivityTimeline.js +81 -0
  7. package/dist/components/context/ContextAttachmentManager.js +15 -3
  8. package/dist/components/features/AgentsModule.d.ts +5 -1
  9. package/dist/components/features/AgentsModule.js +142 -39
  10. package/dist/components/features/AgentsModule.test.js +260 -11
  11. package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
  12. package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
  13. package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
  14. package/dist/components/features/DocumentIndex.d.ts +2 -2
  15. package/dist/components/features/DocumentIndex.js +3 -4
  16. package/dist/components/features/DocumentViewer.d.ts +3 -1
  17. package/dist/components/features/DocumentViewer.js +6 -5
  18. package/dist/components/features/DocumentWorkspace.d.ts +10 -2
  19. package/dist/components/features/DocumentWorkspace.js +15 -4
  20. package/dist/components/features/DocumentWorkspace.test.js +20 -3
  21. package/dist/components/features/TaskSettings.d.ts +2 -0
  22. package/dist/components/features/TaskSettings.js +83 -78
  23. package/dist/components/features/TaskSettings.test.js +171 -82
  24. package/dist/components/task/TaskActionHeader.js +11 -5
  25. package/dist/components/task/TaskActionHeader.test.js +10 -0
  26. package/dist/components/task/TaskCard.js +6 -2
  27. package/dist/components/task/TaskContextUpload.test.js +3 -3
  28. package/dist/components/task/TaskForm.js +14 -64
  29. package/dist/components/task/TaskKanban.js +0 -8
  30. package/dist/components/task/TaskList.js +5 -1
  31. package/dist/components/task/TaskList.test.js +4 -0
  32. package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
  33. package/dist/components/ui/HierarchyActionBadge.js +5 -0
  34. package/dist/components/views/AppShellHeader.d.ts +3 -1
  35. package/dist/components/views/AppShellHeader.js +27 -11
  36. package/dist/components/views/AppShellHeader.test.js +18 -1
  37. package/dist/components/views/PlanComparisonPage.js +9 -1
  38. package/dist/components/views/PlanComparisonPage.test.js +19 -0
  39. package/dist/components/views/PlansPage.js +1 -1
  40. package/dist/components/views/StandaloneLayout.js +256 -69
  41. package/dist/components/views/WidgetView.js +10 -8
  42. package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
  43. package/dist/components/views/panels/PlanningDrawer.js +259 -51
  44. package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
  45. package/dist/config/envSchema.js +1 -0
  46. package/dist/core/AiProfileService.d.ts +7 -0
  47. package/dist/core/AiProfileService.js +460 -35
  48. package/dist/core/AiProfiles.test.js +648 -2
  49. package/dist/core/AttachmentLinkService.d.ts +3 -0
  50. package/dist/core/AttachmentLinkService.js +58 -13
  51. package/dist/core/GlobalSettingsService.js +12 -0
  52. package/dist/core/McpTokenService.d.ts +12 -0
  53. package/dist/core/McpTokenService.js +44 -1
  54. package/dist/core/PlanningEntities.test.js +366 -4
  55. package/dist/core/TaskActivityService.d.ts +28 -1
  56. package/dist/core/TaskActivityService.js +227 -46
  57. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  58. package/dist/core/TaskActivitySync.test.js +90 -0
  59. package/dist/core/TaskAuditTimeline.test.js +96 -0
  60. package/dist/core/Taskforce.d.ts +190 -26
  61. package/dist/core/Taskforce.ids.test.js +58 -0
  62. package/dist/core/Taskforce.js +775 -71
  63. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  64. package/dist/core/types.d.ts +7 -0
  65. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
  66. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  67. package/dist/hooks/bootstrapTimeouts.js +8 -0
  68. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  69. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  70. package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
  71. package/dist/hooks/sync/orchestratorShared.js +61 -1
  72. package/dist/hooks/sync/recovery.js +8 -0
  73. package/dist/hooks/sync/recovery.test.js +5 -5
  74. package/dist/hooks/sync/transfers.d.ts +2 -1
  75. package/dist/hooks/sync/transfers.js +36 -9
  76. package/dist/hooks/sync/transfers.test.js +534 -2
  77. package/dist/hooks/useFilterSort.js +13 -13
  78. package/dist/hooks/useFilterSort.test.js +87 -1
  79. package/dist/hooks/useSyncOrchestrator.js +50 -5
  80. package/dist/hooks/useTaskforce.d.ts +85 -50
  81. package/dist/hooks/useTaskforce.js +44 -14
  82. package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
  83. package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
  84. package/dist/hooks/useWorkspaceSyncController.js +7 -1
  85. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  86. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
  87. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  88. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  89. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  90. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  91. package/dist/mcp/clientIntegrations.d.ts +1 -0
  92. package/dist/mcp/clientIntegrations.js +25 -0
  93. package/dist/mcp/clientIntegrations.test.js +14 -3
  94. package/dist/mcp/clientProfileDefaults.js +3 -3
  95. package/dist/mcp/collaborationRegistrar.js +7 -7
  96. package/dist/mcp/documentAssetRegistrar.js +113 -7
  97. package/dist/mcp/runtime.d.ts +1 -0
  98. package/dist/mcp/runtime.js +969 -122
  99. package/dist/mcp/runtime.test.js +1083 -11
  100. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  101. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  102. package/dist/mcp/toolRegistry.d.ts +56 -0
  103. package/dist/mcp/toolRegistry.js +8 -0
  104. package/dist/mcp/toolRegistry.test.js +4 -0
  105. package/dist/migrations/taskSchemaMigrations.js +21 -0
  106. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  107. package/dist/server/aiProfileAvatarAssets.js +319 -0
  108. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  109. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  110. package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
  111. package/dist/server/index.js +4 -0
  112. package/dist/server/index.test.js +6 -1
  113. package/dist/server/routes/admin.js +187 -9
  114. package/dist/server/routes/annotatedAttachments.js +51 -0
  115. package/dist/server/routes/auth.d.ts +11 -0
  116. package/dist/server/routes/auth.js +189 -27
  117. package/dist/server/routes/authSupport.d.ts +4 -0
  118. package/dist/server/routes/authSupport.js +12 -1
  119. package/dist/server/routes/billing.js +11 -0
  120. package/dist/server/routes/billing.test.js +3 -0
  121. package/dist/server/routes/documents.js +10 -0
  122. package/dist/server/routes/shared.js +0 -10
  123. package/dist/server/routes/sync.integration.test.js +221 -0
  124. package/dist/server/routes/sync.js +13 -3
  125. package/dist/server/routes/syncSnapshotRoutes.js +2 -0
  126. package/dist/server/routes/tasks.js +12 -9
  127. package/dist/server/routes.js +87 -8
  128. package/dist/server/routes.test.js +890 -22
  129. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  130. package/dist/shared/aiProfileSurfaceType.js +10 -8
  131. package/dist/storage/objectStorage.d.ts +1 -0
  132. package/dist/storage/objectStorage.js +2 -1
  133. package/dist/storage/objectStorageClient.d.ts +1 -0
  134. package/dist/storage/objectStorageClient.js +41 -8
  135. package/dist/storage/objectStorageClient.test.js +26 -1
  136. package/dist/storage/objectStorageConfig.js +3 -1
  137. package/dist/storage/objectStorageConfig.test.js +13 -1
  138. package/dist/sync/collaborationSyncPayload.js +2 -0
  139. package/dist/sync/collaborationSyncPayload.test.js +1 -0
  140. package/dist/sync/collaborationSyncState.d.ts +3 -3
  141. package/dist/sync/collaborationSyncState.js +19 -12
  142. package/dist/sync/contentSyncPayload.d.ts +5 -1
  143. package/dist/sync/contentSyncPayload.js +9 -0
  144. package/dist/sync/contentSyncPayload.test.js +39 -0
  145. package/dist/sync/contentSyncState.d.ts +5 -1
  146. package/dist/sync/contentSyncState.js +231 -37
  147. package/dist/sync/planningSyncPayload.js +5 -0
  148. package/dist/sync/planningSyncPayload.test.js +24 -0
  149. package/dist/sync/syncApplyHandlers.d.ts +4 -4
  150. package/dist/sync/syncApplyHandlers.js +89 -31
  151. package/dist/sync/syncApplyHandlers.test.js +184 -4
  152. package/dist/sync/syncResourceAdapters.d.ts +2 -0
  153. package/dist/sync/syncResourceAdapters.js +2 -0
  154. package/dist/sync/syncService.d.ts +1 -0
  155. package/dist/sync/syncService.js +13 -1
  156. package/dist/sync/syncService.test.js +39 -0
  157. package/dist/sync/workspacePullFeed.d.ts +4 -0
  158. package/dist/sync/workspacePullFeed.js +129 -70
  159. package/dist/sync/workspacePullFeed.test.js +49 -0
  160. package/dist/sync/workspaceSyncModel.d.ts +28 -6
  161. package/dist/sync/workspaceSyncModel.js +159 -45
  162. package/dist/sync/workspaceSyncModel.test.js +269 -3
  163. package/dist/types.d.ts +33 -2
  164. package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
  165. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
  166. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  167. package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
  168. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  169. package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
  170. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  171. package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
  172. package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-D4XseTwW.js} +1 -1
  173. package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
  174. package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
  175. package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
  176. package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
  177. package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-oOA9bcdK.js} +4 -4
  178. package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
  179. package/dist/ui/assets/index-607WPo_X.js +4 -0
  180. package/dist/ui/assets/index-CYyodXsg.css +1 -0
  181. package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
  182. package/dist/ui/index.html +3 -3
  183. package/dist/ui/product/agent manager.png +0 -0
  184. package/dist/ui/product/agent_manager_02.png +0 -0
  185. package/dist/utils/aiProfileEvents.d.ts +7 -0
  186. package/dist/utils/aiProfileEvents.js +6 -0
  187. package/dist/utils/assignees.d.ts +6 -0
  188. package/dist/utils/assignees.js +8 -1
  189. package/dist/utils/assignees.test.d.ts +1 -0
  190. package/dist/utils/assignees.test.js +25 -0
  191. package/dist/utils/avatarDisplay.d.ts +1 -0
  192. package/dist/utils/avatarDisplay.js +15 -0
  193. package/dist/utils/contextFiles.d.ts +3 -1
  194. package/dist/utils/contextFiles.js +2 -1
  195. package/dist/utils/taskActivity.d.ts +7 -1
  196. package/dist/utils/taskActivity.js +63 -1
  197. package/dist/utils/taskActivity.test.js +23 -1
  198. package/dist/utils/taskSearch.d.ts +22 -0
  199. package/dist/utils/taskSearch.js +87 -0
  200. package/dist/utils/taskSearch.test.d.ts +1 -0
  201. package/dist/utils/taskSearch.test.js +69 -0
  202. package/dist/utils/taskUtils.js +10 -3
  203. package/dist/utils/taskUtils.test.d.ts +1 -0
  204. package/dist/utils/taskUtils.test.js +19 -0
  205. package/package.json +1 -1
  206. package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
  207. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  208. package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
  209. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
  210. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
  211. package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
  212. package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
  213. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
  214. package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
  215. package/dist/ui/assets/index-C6k75jr8.js +0 -6
@@ -73,6 +73,9 @@ describe('TaskforceCore ai profiles', () => {
73
73
  expect(fetched?.assigneeActor?.label).toBe('Codex');
74
74
  expect(fetched?.createdByActor?.label).toBe('Codex');
75
75
  expect(fetched?.comments?.[0]?.actor?.label).toBe('Codex');
76
+ expect(fetched?.assigneeActor?.meta).not.toHaveProperty('profileToken');
77
+ expect(fetched?.createdByActor?.meta).not.toHaveProperty('profileToken');
78
+ expect(fetched?.comments?.[0]?.actor?.meta).not.toHaveProperty('profileToken');
76
79
  });
77
80
  it('updates existing ai profile presentation fields when profileToken is presented again', () => {
78
81
  const projectRoot = createProjectRoot();
@@ -119,9 +122,13 @@ describe('TaskforceCore ai profiles', () => {
119
122
  });
120
123
  expect(updated.avatarUrl).toBe(avatarUrl);
121
124
  expect(updated.avatarSourceUrl).toBe(avatarSourceUrl);
125
+ expect(updated.avatarRevision).toBe(1);
126
+ expect(updated.avatarUpdatedAt).toBe(updated.updatedAt);
122
127
  expect(updated.icon).toBe('Bot');
123
128
  expect(core.listAiProfiles('workspace-1')[0]?.avatarUrl).toBe(avatarUrl);
124
129
  expect(core.listAiProfiles('workspace-1')[0]?.avatarSourceUrl).toBe(avatarSourceUrl);
130
+ expect(core.listAiProfiles('workspace-1')[0]?.avatarRevision).toBe(1);
131
+ expect(core.listAiProfiles('workspace-1')[0]?.avatarUpdatedAt).toBe(updated.updatedAt);
125
132
  const cleared = core.updateAiProfileAvatar({
126
133
  workspaceId: 'workspace-1',
127
134
  profileId: resolved.profile.id,
@@ -130,6 +137,84 @@ describe('TaskforceCore ai profiles', () => {
130
137
  });
131
138
  expect(cleared.avatarUrl).toBeNull();
132
139
  expect(cleared.avatarSourceUrl).toBeNull();
140
+ expect(cleared.avatarRevision).toBe(2);
141
+ expect(cleared.avatarUpdatedAt).toBe(cleared.updatedAt);
142
+ });
143
+ it('seeds blank same-name runtime profiles when applying an ai profile avatar', () => {
144
+ const projectRoot = createProjectRoot();
145
+ createdRoots.push(projectRoot);
146
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
147
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
148
+ const local = core.resolveAiProfile({
149
+ workspaceId: 'workspace-1',
150
+ name: 'Codex',
151
+ icon: 'Bot',
152
+ color: '#0ea5e9'
153
+ }, { trustedSeatScope: 'local_unmetered' });
154
+ const cloud = core.resolveAiProfile({
155
+ workspaceId: 'workspace-1',
156
+ name: 'Codex',
157
+ icon: 'Bot',
158
+ color: '#0ea5e9'
159
+ }, { trustedSeatScope: 'cloud_metered' });
160
+ const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
161
+ const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
162
+ const updated = core.updateAiProfileAvatar({
163
+ workspaceId: 'workspace-1',
164
+ profileId: local.profile.id,
165
+ avatarUrl,
166
+ avatarSourceUrl
167
+ });
168
+ const profiles = core.listAiProfiles('workspace-1');
169
+ const refreshedLocal = profiles.find((profile) => profile.id === local.profile.id);
170
+ const refreshedCloud = profiles.find((profile) => profile.id === cloud.profile.id);
171
+ expect(refreshedLocal?.avatarUrl).toBe(avatarUrl);
172
+ expect(refreshedCloud?.avatarUrl).toBe(avatarUrl);
173
+ expect(refreshedCloud?.avatarSourceUrl).toBe(avatarSourceUrl);
174
+ expect(refreshedCloud?.avatarRevision).toBe(updated.avatarRevision);
175
+ expect(refreshedCloud?.avatarUpdatedAt).toBe(updated.avatarUpdatedAt);
176
+ });
177
+ it('repairs blank same-name runtime profile avatars on token lookup', () => {
178
+ const projectRoot = createProjectRoot();
179
+ createdRoots.push(projectRoot);
180
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
181
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
182
+ const local = core.resolveAiProfile({
183
+ workspaceId: 'workspace-1',
184
+ name: 'Codex',
185
+ icon: 'Bot',
186
+ color: '#0ea5e9'
187
+ }, { trustedSeatScope: 'local_unmetered' });
188
+ const cloud = core.resolveAiProfile({
189
+ workspaceId: 'workspace-1',
190
+ name: 'Codex',
191
+ icon: 'Bot',
192
+ color: '#0ea5e9'
193
+ }, { trustedSeatScope: 'cloud_metered' });
194
+ const avatarUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp';
195
+ const avatarSourceUrl = '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp';
196
+ const updated = core.updateAiProfileAvatar({
197
+ workspaceId: 'workspace-1',
198
+ profileId: local.profile.id,
199
+ avatarUrl,
200
+ avatarSourceUrl
201
+ });
202
+ core.db.prepare(`
203
+ UPDATE ai_profiles
204
+ SET avatar_url = NULL,
205
+ avatar_source_url = NULL,
206
+ avatar_revision = ?
207
+ WHERE id = ?
208
+ `).run(updated.avatarRevision, cloud.profile.id);
209
+ const lookedUp = core.getAiProfileByToken({
210
+ workspaceId: 'workspace-1',
211
+ profileToken: cloud.profile.profileToken,
212
+ seatScope: 'cloud_metered'
213
+ });
214
+ expect(lookedUp?.id).toBe(cloud.profile.id);
215
+ expect(lookedUp?.avatarUrl).toBe(avatarUrl);
216
+ expect(lookedUp?.avatarSourceUrl).toBe(avatarSourceUrl);
217
+ expect(lookedUp?.avatarRevision).toBe(updated.avatarRevision);
133
218
  });
134
219
  it('preserves synced ai profile avatars when metadata-only sync payloads omit avatar fields', () => {
135
220
  const projectRoot = createProjectRoot();
@@ -162,7 +247,69 @@ describe('TaskforceCore ai profiles', () => {
162
247
  expect(replayed.avatarUrl).toBe(avatarUrl);
163
248
  expect(replayed.avatarSourceUrl).toBe(avatarSourceUrl);
164
249
  });
165
- it('clears synced ai profile avatars when sync payloads explicitly send null avatar fields', () => {
250
+ it('does not clear synced ai profile avatars from legacy stale null sync payloads', () => {
251
+ const projectRoot = createProjectRoot();
252
+ createdRoots.push(projectRoot);
253
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
254
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
255
+ const resolved = core.resolveAiProfile({
256
+ workspaceId: 'workspace-1',
257
+ name: 'Codex',
258
+ icon: 'Bot',
259
+ color: '#0ea5e9'
260
+ });
261
+ const updated = core.updateAiProfileAvatar({
262
+ workspaceId: 'workspace-1',
263
+ profileId: resolved.profile.id,
264
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp',
265
+ avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp'
266
+ });
267
+ const clearedAt = new Date(Date.parse(updated.updatedAt) + 1000).toISOString();
268
+ const { avatarRevision: _avatarRevision, avatarUpdatedAt: _avatarUpdatedAt, ...legacyUpdated } = updated;
269
+ const cleared = core.upsertAiProfileForSync({
270
+ ...legacyUpdated,
271
+ avatarUrl: null,
272
+ avatarSourceUrl: null,
273
+ updatedAt: clearedAt,
274
+ lastActiveAt: clearedAt
275
+ });
276
+ expect(cleared.avatarUrl).toBe(updated.avatarUrl);
277
+ expect(cleared.avatarSourceUrl).toBe(updated.avatarSourceUrl);
278
+ expect(cleared.avatarRevision).toBe(updated.avatarRevision);
279
+ });
280
+ it('does not clear synced ai profile avatars from blank revisions without avatar update timestamps', () => {
281
+ const projectRoot = createProjectRoot();
282
+ createdRoots.push(projectRoot);
283
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
284
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
285
+ const resolved = core.resolveAiProfile({
286
+ workspaceId: 'workspace-1',
287
+ name: 'Codex',
288
+ icon: 'Bot',
289
+ color: '#0ea5e9'
290
+ });
291
+ const updated = core.updateAiProfileAvatar({
292
+ workspaceId: 'workspace-1',
293
+ profileId: resolved.profile.id,
294
+ avatarUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Favatar.webp',
295
+ avatarSourceUrl: '/api/taskforce/context/workspaces%2Fworkspace-1%2Fassets%2Fimages%2Fai-profiles%2Fprofile%2Favatar%2Fsource.webp'
296
+ });
297
+ const refreshedAt = new Date(Date.parse(updated.updatedAt) + 1000).toISOString();
298
+ const replayed = core.upsertAiProfileForSync({
299
+ ...updated,
300
+ avatarUrl: null,
301
+ avatarSourceUrl: null,
302
+ avatarRevision: (updated.avatarRevision ?? 0) + 1,
303
+ avatarUpdatedAt: null,
304
+ updatedAt: refreshedAt,
305
+ lastActiveAt: refreshedAt
306
+ });
307
+ expect(replayed.avatarUrl).toBe(updated.avatarUrl);
308
+ expect(replayed.avatarSourceUrl).toBe(updated.avatarSourceUrl);
309
+ expect(replayed.avatarRevision).toBe(updated.avatarRevision);
310
+ expect(replayed.avatarUpdatedAt).toBe(updated.avatarUpdatedAt);
311
+ });
312
+ it('clears synced ai profile avatars when sync payloads send a newer avatar revision', () => {
166
313
  const projectRoot = createProjectRoot();
167
314
  createdRoots.push(projectRoot);
168
315
  const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
@@ -184,11 +331,58 @@ describe('TaskforceCore ai profiles', () => {
184
331
  ...updated,
185
332
  avatarUrl: null,
186
333
  avatarSourceUrl: null,
334
+ avatarRevision: (updated.avatarRevision ?? 0) + 1,
335
+ avatarUpdatedAt: clearedAt,
187
336
  updatedAt: clearedAt,
188
337
  lastActiveAt: clearedAt
189
338
  });
190
339
  expect(cleared.avatarUrl).toBeNull();
191
340
  expect(cleared.avatarSourceUrl).toBeNull();
341
+ expect(cleared.avatarRevision).toBe((updated.avatarRevision ?? 0) + 1);
342
+ expect(cleared.avatarUpdatedAt).toBe(clearedAt);
343
+ });
344
+ it('repairs blank legacy ai profile avatar pointers from workspace asset metadata', () => {
345
+ const projectRoot = createProjectRoot();
346
+ createdRoots.push(projectRoot);
347
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
348
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
349
+ const resolved = core.resolveAiProfile({
350
+ workspaceId: 'workspace-1',
351
+ name: 'Felix Leiter',
352
+ icon: 'Bot',
353
+ color: '#0ea5e9'
354
+ });
355
+ const db = core.db;
356
+ const croppedKey = `workspaces/workspace-1/assets/images/ai-profiles/${resolved.profile.id}/avatar/asset-cropped-Felix.webp`;
357
+ const sourceKey = `workspaces/workspace-1/assets/images/ai-profiles/${resolved.profile.id}/avatar/asset-source-Felix.webp`;
358
+ db.prepare(`
359
+ INSERT INTO workspace_assets (
360
+ asset_id, tenant_id, workspace_id, kind, logical_name, original_filename, mime_type,
361
+ storage_provider, storage_key, content_sha256, size_bytes, is_latest, scan_status,
362
+ created_at, updated_at
363
+ ) VALUES (?, 'default', 'workspace-1', 'image', ?, ?, 'image/webp', 'local', ?, ?, 100, 1, 'clean', ?, ?)
364
+ `).run('asset-cropped', 'Felix avatar', 'cropped-Felix.webp', croppedKey, 'sha-cropped', '2026-04-28T15:00:00.000Z', '2026-04-28T15:00:00.000Z');
365
+ db.prepare(`
366
+ INSERT INTO workspace_assets (
367
+ asset_id, tenant_id, workspace_id, kind, logical_name, original_filename, mime_type,
368
+ storage_provider, storage_key, content_sha256, size_bytes, is_latest, scan_status,
369
+ created_at, updated_at
370
+ ) VALUES (?, 'default', 'workspace-1', 'image', ?, ?, 'image/webp', 'local', ?, ?, 100, 1, 'clean', ?, ?)
371
+ `).run('asset-source', 'Felix source', 'source-Felix.webp', sourceKey, 'sha-source', '2026-04-28T15:01:00.000Z', '2026-04-28T15:01:00.000Z');
372
+ const repaired = core.listAiProfiles('workspace-1').find((profile) => profile.id === resolved.profile.id);
373
+ expect(repaired?.avatarUrl).toBe(`/api/taskforce/context/${encodeURIComponent(croppedKey)}`);
374
+ expect(repaired?.avatarSourceUrl).toBe(`/api/taskforce/context/${encodeURIComponent(sourceKey)}`);
375
+ expect(repaired?.avatarRevision).toBe(1);
376
+ expect(repaired?.avatarUpdatedAt).toBe('2026-04-28T15:00:00.000Z');
377
+ core.updateAiProfileAvatar({
378
+ workspaceId: 'workspace-1',
379
+ profileId: resolved.profile.id,
380
+ avatarUrl: null,
381
+ avatarSourceUrl: null
382
+ });
383
+ const cleared = core.listAiProfiles('workspace-1').find((profile) => profile.id === resolved.profile.id);
384
+ expect(cleared?.avatarUrl).toBeNull();
385
+ expect(cleared?.avatarSourceUrl).toBeNull();
192
386
  });
193
387
  it('repairs missing synced ai profile avatars when timestamps match', () => {
194
388
  const projectRoot = createProjectRoot();
@@ -461,6 +655,73 @@ describe('TaskforceCore ai profiles', () => {
461
655
  });
462
656
  expect(second.profile.surfaceType).toBe('coding_tool');
463
657
  });
658
+ it('supports cli and maps legacy other surfaceType values to agent', () => {
659
+ const projectRoot = createProjectRoot();
660
+ createdRoots.push(projectRoot);
661
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
662
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
663
+ const cliProfile = core.resolveAiProfile({
664
+ workspaceId: 'workspace-1',
665
+ name: 'Codex',
666
+ surfaceType: 'cli'
667
+ });
668
+ expect(cliProfile.profile.surfaceType).toBe('cli');
669
+ expect(cliProfile.profile.role).toBe('CLI Coding Agent');
670
+ const legacyOtherProfile = core.resolveAiProfile({
671
+ workspaceId: 'workspace-1',
672
+ name: 'Legacy Agent',
673
+ surfaceType: 'other'
674
+ });
675
+ expect(legacyOtherProfile.profile.surfaceType).toBe('agent');
676
+ });
677
+ it('keeps a manually updated ai profile surfaceType from being overwritten by future registration', () => {
678
+ const projectRoot = createProjectRoot();
679
+ createdRoots.push(projectRoot);
680
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
681
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
682
+ const first = core.resolveAiProfile({
683
+ workspaceId: 'workspace-1',
684
+ name: 'ChatGPT',
685
+ surfaceType: 'chat_app'
686
+ });
687
+ const updated = core.updateAiProfileSurfaceType({
688
+ workspaceId: 'workspace-1',
689
+ profileId: first.profile.id,
690
+ surfaceType: 'coding_tool'
691
+ });
692
+ expect(updated.surfaceType).toBe('coding_tool');
693
+ const replayedRegistration = core.resolveAiProfile({
694
+ workspaceId: 'workspace-1',
695
+ name: 'ChatGPT',
696
+ surfaceType: 'chat_app',
697
+ profileToken: first.profile.profileToken
698
+ });
699
+ expect(replayedRegistration.profile.surfaceType).toBe('coding_tool');
700
+ });
701
+ it('treats unclassified ai profile category updates as clearing the manual override', () => {
702
+ const projectRoot = createProjectRoot();
703
+ createdRoots.push(projectRoot);
704
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
705
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
706
+ const first = core.resolveAiProfile({
707
+ workspaceId: 'workspace-1',
708
+ name: 'ChatGPT',
709
+ surfaceType: 'chat_app'
710
+ });
711
+ const cleared = core.updateAiProfileSurfaceType({
712
+ workspaceId: 'workspace-1',
713
+ profileId: first.profile.id,
714
+ surfaceType: null
715
+ });
716
+ expect(cleared.surfaceType).toBeNull();
717
+ const replayedRegistration = core.resolveAiProfile({
718
+ workspaceId: 'workspace-1',
719
+ name: 'ChatGPT',
720
+ surfaceType: 'coding_tool',
721
+ profileToken: first.profile.profileToken
722
+ });
723
+ expect(replayedRegistration.profile.surfaceType).toBe('coding_tool');
724
+ });
464
725
  it('rejects invalid ai profile surfaceType values', () => {
465
726
  const projectRoot = createProjectRoot();
466
727
  createdRoots.push(projectRoot);
@@ -576,7 +837,7 @@ describe('TaskforceCore ai profiles', () => {
576
837
  name: 'Claude',
577
838
  color: '#f59e0b'
578
839
  });
579
- // Resolve by name (e.g. agent passed "Claude" as author instead of profile ID)
840
+ // Resolve by name only when the alias is uniquely attributable.
580
841
  const actorByName = core.resolveWorkspaceActor('workspace-1', 'Claude');
581
842
  expect(actorByName).not.toBeNull();
582
843
  expect(actorByName?.id).toBe(profile.id);
@@ -586,6 +847,35 @@ describe('TaskforceCore ai profiles', () => {
586
847
  const actorLower = core.resolveWorkspaceActor('workspace-1', 'claude');
587
848
  expect(actorLower?.id).toBe(profile.id);
588
849
  });
850
+ it('leaves duplicate ai profile aliases unresolved instead of picking one profile', () => {
851
+ const projectRoot = createProjectRoot();
852
+ createdRoots.push(projectRoot);
853
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
854
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
855
+ const first = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
856
+ const second = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude 2', color: '#0ea5e9' });
857
+ const db = core.db;
858
+ db.prepare(`UPDATE ai_profiles SET name = 'Claude', username = 'claude' WHERE tenant_id = ? AND id = ?`)
859
+ .run('default', second.profile.id);
860
+ const actor = core.resolveWorkspaceActor('workspace-1', 'Claude');
861
+ expect(actor?.id).toBe('Claude');
862
+ expect(actor?.kind).toBe('ai');
863
+ expect(actor?.meta?.unresolved).toBe(true);
864
+ const task = core.createTask({
865
+ title: 'Ambiguous legacy comment author',
866
+ comments: [{
867
+ id: 'comment-1',
868
+ author: 'Claude',
869
+ text: 'Keep the original ambiguous alias.',
870
+ timestamp: '2026-03-22T10:00:00.000Z'
871
+ }]
872
+ }, { workspaceId: 'workspace-1' });
873
+ const fetched = core.getTask(task.id, 'workspace-1');
874
+ expect(fetched?.comments?.[0]?.author).toBe('Claude');
875
+ expect(fetched?.comments?.[0]?.actor?.id).toBe('Claude');
876
+ expect(fetched?.comments?.[0]?.actor?.meta?.unresolved).toBe(true);
877
+ expect(first.profile.id).not.toBe(second.profile.id);
878
+ });
589
879
  it('classifies well-known AI model names as ai-kind even without a registered profile', () => {
590
880
  const projectRoot = createProjectRoot();
591
881
  createdRoots.push(projectRoot);
@@ -614,6 +904,171 @@ describe('TaskforceCore ai profiles', () => {
614
904
  expect(second.profile.id).toBe(first.profile.id);
615
905
  expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
616
906
  });
907
+ it('reuses a stateless ai profile by name and provider while treating model changes as metadata updates', () => {
908
+ const projectRoot = createProjectRoot();
909
+ createdRoots.push(projectRoot);
910
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
911
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
912
+ const first = core.resolveAiProfile({
913
+ workspaceId: 'workspace-1',
914
+ name: 'Claude',
915
+ provider: 'Anthropic',
916
+ model: 'claude-sonnet-4-5',
917
+ surfaceType: 'chat_app'
918
+ });
919
+ const second = core.resolveAiProfile({
920
+ workspaceId: 'workspace-1',
921
+ name: 'claude',
922
+ provider: 'anthropic',
923
+ model: 'claude-sonnet-4-6',
924
+ surfaceType: 'chat_app'
925
+ });
926
+ expect(second.created).toBe(false);
927
+ expect(second.profile.id).toBe(first.profile.id);
928
+ expect(second.profile.model).toBe('claude-sonnet-4-6');
929
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
930
+ });
931
+ it('reuses a provider-bearing stateless ai profile when a later resolve omits provider and model', () => {
932
+ const projectRoot = createProjectRoot();
933
+ createdRoots.push(projectRoot);
934
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
935
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
936
+ const first = core.resolveAiProfile({
937
+ workspaceId: 'workspace-1',
938
+ name: 'Claude',
939
+ provider: 'Anthropic',
940
+ model: 'claude-sonnet-4-5',
941
+ surfaceType: 'chat_app'
942
+ });
943
+ const second = core.resolveAiProfile({
944
+ workspaceId: 'workspace-1',
945
+ name: 'claude'
946
+ });
947
+ expect(second.created).toBe(false);
948
+ expect(second.profile.id).toBe(first.profile.id);
949
+ expect(second.profile.provider).toBe('Anthropic');
950
+ expect(second.profile.model).toBe('claude-sonnet-4-5');
951
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
952
+ });
953
+ it('reuses same-scope ai profiles across provider drift', () => {
954
+ const projectRoot = createProjectRoot();
955
+ createdRoots.push(projectRoot);
956
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
957
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
958
+ const original = core.resolveAiProfile({
959
+ workspaceId: 'workspace-1',
960
+ name: 'Felix Leiter',
961
+ provider: 'OpenAI',
962
+ model: 'openai-codex/gpt-5.4',
963
+ surfaceType: 'agent'
964
+ });
965
+ const drifted = core.resolveAiProfile({
966
+ workspaceId: 'workspace-1',
967
+ name: 'Felix Leiter',
968
+ provider: 'OpenClaw',
969
+ model: 'gpt-5.4',
970
+ surfaceType: 'agent'
971
+ });
972
+ expect(drifted.created).toBe(false);
973
+ expect(drifted.profile.id).toBe(original.profile.id);
974
+ expect(drifted.profile.provider).toBe('OpenClaw');
975
+ expect(drifted.profile.model).toBe('gpt-5.4');
976
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
977
+ });
978
+ it('reuses same-scope ai profiles across surface type drift', () => {
979
+ const projectRoot = createProjectRoot();
980
+ createdRoots.push(projectRoot);
981
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
982
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
983
+ const chat = core.resolveAiProfile({
984
+ workspaceId: 'workspace-1',
985
+ name: 'Claude',
986
+ provider: 'Anthropic',
987
+ model: 'claude-sonnet-4-6',
988
+ surfaceType: 'chat_app'
989
+ });
990
+ const codingTool = core.resolveAiProfile({
991
+ workspaceId: 'workspace-1',
992
+ name: 'Claude',
993
+ provider: 'Anthropic',
994
+ model: 'claude-sonnet-4-6',
995
+ surfaceType: 'coding_tool'
996
+ });
997
+ expect(codingTool.created).toBe(false);
998
+ expect(codingTool.profile.id).toBe(chat.profile.id);
999
+ expect(codingTool.profile.surfaceType).toBe('coding_tool');
1000
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1001
+ });
1002
+ it('reuses same-scope ai profiles when reconnecting with a stale token', () => {
1003
+ const projectRoot = createProjectRoot();
1004
+ createdRoots.push(projectRoot);
1005
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1006
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1007
+ const original = core.resolveAiProfile({
1008
+ workspaceId: 'workspace-1',
1009
+ name: 'Tiffany Case',
1010
+ provider: 'openai-codex',
1011
+ model: 'gpt-5.4',
1012
+ surfaceType: 'agent'
1013
+ }, { trustedSeatScope: 'cloud_metered' });
1014
+ const reconnected = core.resolveAiProfile({
1015
+ workspaceId: 'workspace-1',
1016
+ name: 'Tiffany Case',
1017
+ profileToken: 'tfp_stale_tiffany_case_token',
1018
+ provider: 'OpenAI Codex',
1019
+ model: 'openai-codex/gpt-5.4',
1020
+ surfaceType: 'agent'
1021
+ }, { trustedSeatScope: 'cloud_metered' });
1022
+ expect(reconnected.created).toBe(false);
1023
+ expect(reconnected.profile.id).toBe(original.profile.id);
1024
+ expect(reconnected.profile.provider).toBe('OpenAI Codex');
1025
+ expect(reconnected.profile.model).toBe('openai-codex/gpt-5.4');
1026
+ expect(core.listAiProfiles('workspace-1')).toHaveLength(1);
1027
+ });
1028
+ it('prefers the most recently active matching ai profile when stateless candidates are duplicated', () => {
1029
+ const projectRoot = createProjectRoot();
1030
+ createdRoots.push(projectRoot);
1031
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1032
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1033
+ const older = core.resolveAiProfile({
1034
+ workspaceId: 'workspace-1',
1035
+ name: 'Claude',
1036
+ provider: 'Anthropic',
1037
+ model: 'claude-sonnet-4-5',
1038
+ surfaceType: 'chat_app'
1039
+ });
1040
+ const newer = core.resolveAiProfile({
1041
+ workspaceId: 'workspace-1',
1042
+ name: 'Claude Duplicate',
1043
+ provider: 'Anthropic',
1044
+ model: 'claude-sonnet-4-6',
1045
+ surfaceType: 'chat_app'
1046
+ });
1047
+ core.db.prepare(`
1048
+ UPDATE ai_profiles
1049
+ SET name = 'Claude',
1050
+ username = 'claude',
1051
+ last_active_at = CASE id
1052
+ WHEN ? THEN '2026-04-01T00:00:00.000Z'
1053
+ ELSE '2026-04-02T00:00:00.000Z'
1054
+ END,
1055
+ updated_at = CASE id
1056
+ WHEN ? THEN '2026-04-01T00:00:00.000Z'
1057
+ ELSE '2026-04-02T00:00:00.000Z'
1058
+ END
1059
+ WHERE id IN (?, ?)
1060
+ `).run(older.profile.id, older.profile.id, older.profile.id, newer.profile.id);
1061
+ const resolved = core.resolveAiProfile({
1062
+ workspaceId: 'workspace-1',
1063
+ name: 'Claude',
1064
+ provider: 'Anthropic',
1065
+ model: 'claude-sonnet-4-7',
1066
+ surfaceType: 'chat_app'
1067
+ });
1068
+ expect(resolved.created).toBe(false);
1069
+ expect(resolved.profile.id).toBe(newer.profile.id);
1070
+ expect(resolved.duplicateWarning?.existingProfileId).toBe(newer.profile.id);
1071
+ });
617
1072
  it('reuses Codex by exact name without leaking across workspaces', () => {
618
1073
  const projectRoot = createProjectRoot();
619
1074
  createdRoots.push(projectRoot);
@@ -885,6 +1340,28 @@ describe('TaskforceCore ai profiles', () => {
885
1340
  // Comment author should point to the kept profile
886
1341
  expect(updatedTask?.comments?.[0]?.author).toBe(first.profile.id);
887
1342
  });
1343
+ it('does not merge separate local and cloud AI profile registrations', () => {
1344
+ const projectRoot = createProjectRoot();
1345
+ createdRoots.push(projectRoot);
1346
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1347
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1348
+ const cloud = core.resolveAiProfile({
1349
+ workspaceId: 'workspace-1',
1350
+ name: 'Codex',
1351
+ surfaceType: 'coding_tool'
1352
+ }, { trustedSeatScope: 'cloud_metered' });
1353
+ const local = core.resolveAiProfile({
1354
+ workspaceId: 'workspace-1',
1355
+ name: 'Codex',
1356
+ surfaceType: 'coding_tool'
1357
+ }, { trustedSeatScope: 'local_unmetered' });
1358
+ expect(() => core.mergeAiProfiles({
1359
+ workspaceId: 'workspace-1',
1360
+ keepId: local.profile.id,
1361
+ mergeId: cloud.profile.id
1362
+ })).toThrow(/separate registrations/);
1363
+ expect(core.listAiProfiles('workspace-1').map((profile) => profile.id).sort()).toEqual([cloud.profile.id, local.profile.id].sort());
1364
+ });
888
1365
  it('archives AI profiles out of the active roster while preserving history and freeing seats', () => {
889
1366
  const projectRoot = createProjectRoot();
890
1367
  createdRoots.push(projectRoot);
@@ -1187,6 +1664,145 @@ describe('TaskforceCore ai profiles', () => {
1187
1664
  expect(fetched?.assigneeActor?.label).toBe('ChatGPT');
1188
1665
  expect(fetched?.comments?.[0]?.actor?.label).toBe('ChatGPT');
1189
1666
  });
1667
+ it('keeps ambiguous legacy AI comment aliases unresolved instead of rebinding them', () => {
1668
+ const projectRoot = createProjectRoot();
1669
+ createdRoots.push(projectRoot);
1670
+ const core = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1671
+ core.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1672
+ const cloud = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'cloud_metered' });
1673
+ const local = core.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'local_unmetered' });
1674
+ const task = core.createTask({
1675
+ title: 'Ambiguous legacy comment alias',
1676
+ comments: [{
1677
+ id: 'legacy-codex',
1678
+ author: 'codex',
1679
+ text: 'Leave me unresolved',
1680
+ timestamp: '2026-03-22T10:00:00.000Z'
1681
+ }]
1682
+ }, { workspaceId: 'workspace-1' });
1683
+ const fetched = core.getTask(task.id, 'workspace-1');
1684
+ expect(fetched?.comments?.[0]?.author).toBe('codex');
1685
+ expect(fetched?.comments?.[0]?.actor).toEqual(expect.objectContaining({
1686
+ id: 'codex',
1687
+ kind: 'ai',
1688
+ label: 'Codex',
1689
+ meta: expect.objectContaining({ unresolved: true })
1690
+ }));
1691
+ expect(fetched?.comments?.[0]?.actor?.id).not.toBe(cloud.profile.id);
1692
+ expect(fetched?.comments?.[0]?.actor?.id).not.toBe(local.profile.id);
1693
+ });
1694
+ it('repairs legacy comments from submittedAuthor under the v2 migration even after v1 completed', () => {
1695
+ const projectRoot = createProjectRoot();
1696
+ createdRoots.push(projectRoot);
1697
+ const firstCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1698
+ firstCore.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1699
+ const resolved = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
1700
+ const task = firstCore.createTask({
1701
+ title: 'Repair legacy submitted author',
1702
+ comments: [{
1703
+ id: 'legacy-agent',
1704
+ author: 'agent',
1705
+ submittedAuthor: 'Claude',
1706
+ text: 'Legacy comment should repair forward',
1707
+ timestamp: '2026-03-22T10:00:00.000Z'
1708
+ }]
1709
+ }, { workspaceId: 'workspace-1' });
1710
+ const firstDb = firstCore.db;
1711
+ const originalTaskWatermark = '2026-03-22T10:00:00.000Z';
1712
+ firstDb.prepare(`
1713
+ UPDATE tasks
1714
+ SET updated_at = ?
1715
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
1716
+ `).run(originalTaskWatermark, 'default', 'workspace-1', task.id);
1717
+ const completedAt = '2026-03-22T11:00:00.000Z';
1718
+ firstDb.prepare(`
1719
+ INSERT INTO system_settings (tenant_id, settings_key, payload, updated_at)
1720
+ VALUES (?, ?, ?, ?)
1721
+ ON CONFLICT (tenant_id, settings_key) DO UPDATE SET
1722
+ payload = excluded.payload,
1723
+ updated_at = excluded.updated_at
1724
+ `).run('default', 'migration.actor_refs.canonicalize.v1', JSON.stringify({ completedAt }), completedAt);
1725
+ firstDb.prepare(`
1726
+ DELETE FROM system_settings
1727
+ WHERE tenant_id = ? AND settings_key = ?
1728
+ `).run('default', 'migration.actor_refs.canonicalize_comments.v2');
1729
+ firstCore.close();
1730
+ const secondCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1731
+ const fetched = secondCore.getTask(task.id, 'workspace-1');
1732
+ expect(fetched?.comments?.[0]?.author).toBe(resolved.profile.id);
1733
+ expect(fetched?.comments?.[0]?.submittedAuthor).toBe('Claude');
1734
+ expect(fetched?.comments?.[0]?.actor?.id).toBe(resolved.profile.id);
1735
+ expect(Date.parse(String(fetched?.updatedAt || ''))).toBeGreaterThan(Date.parse(originalTaskWatermark));
1736
+ const syncRows = secondCore.listTasksForSync('workspace-1', {
1737
+ watermark: originalTaskWatermark,
1738
+ id: task.id,
1739
+ }, 10);
1740
+ expect(syncRows).toHaveLength(1);
1741
+ expect(syncRows[0]?.task?.id).toBe(task.id);
1742
+ expect(syncRows[0]?.task?.comments?.[0]?.author).toBe(resolved.profile.id);
1743
+ const secondDb = secondCore.db;
1744
+ const migrationRow = secondDb.prepare(`
1745
+ SELECT payload
1746
+ FROM system_settings
1747
+ WHERE tenant_id = ? AND settings_key = ?
1748
+ LIMIT 1
1749
+ `).get('default', 'migration.actor_refs.canonicalize_comments.v2');
1750
+ expect(JSON.parse(String(migrationRow?.payload || '{}'))).toEqual(expect.objectContaining({
1751
+ repairedCount: 1,
1752
+ ambiguousCount: 0,
1753
+ skippedCount: expect.any(Number)
1754
+ }));
1755
+ secondCore.close();
1756
+ });
1757
+ it('keeps repaired legacy comment sync watermarks monotonic when a task watermark is already ahead of local now', () => {
1758
+ const projectRoot = createProjectRoot();
1759
+ createdRoots.push(projectRoot);
1760
+ const firstCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1761
+ firstCore.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1762
+ const resolved = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
1763
+ const task = firstCore.createTask({
1764
+ title: 'Repair legacy submitted author with future watermark',
1765
+ comments: [{
1766
+ id: 'legacy-agent-future',
1767
+ author: 'agent',
1768
+ submittedAuthor: 'Claude',
1769
+ text: 'Legacy comment should repair without moving sync backwards',
1770
+ timestamp: '2026-03-22T10:00:00.000Z'
1771
+ }]
1772
+ }, { workspaceId: 'workspace-1' });
1773
+ const firstDb = firstCore.db;
1774
+ const originalTaskWatermark = '2099-03-22T10:00:00.000Z';
1775
+ firstDb.prepare(`
1776
+ UPDATE tasks
1777
+ SET updated_at = ?
1778
+ WHERE tenant_id = ? AND workspace_id = ? AND id = ?
1779
+ `).run(originalTaskWatermark, 'default', 'workspace-1', task.id);
1780
+ const completedAt = '2026-03-22T11:00:00.000Z';
1781
+ firstDb.prepare(`
1782
+ INSERT INTO system_settings (tenant_id, settings_key, payload, updated_at)
1783
+ VALUES (?, ?, ?, ?)
1784
+ ON CONFLICT (tenant_id, settings_key) DO UPDATE SET
1785
+ payload = excluded.payload,
1786
+ updated_at = excluded.updated_at
1787
+ `).run('default', 'migration.actor_refs.canonicalize.v1', JSON.stringify({ completedAt }), completedAt);
1788
+ firstDb.prepare(`
1789
+ DELETE FROM system_settings
1790
+ WHERE tenant_id = ? AND settings_key = ?
1791
+ `).run('default', 'migration.actor_refs.canonicalize_comments.v2');
1792
+ firstCore.close();
1793
+ const secondCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1794
+ const fetched = secondCore.getTask(task.id, 'workspace-1');
1795
+ expect(fetched?.comments?.[0]?.author).toBe(resolved.profile.id);
1796
+ expect(Date.parse(String(fetched?.updatedAt || ''))).toBeGreaterThan(Date.parse(originalTaskWatermark));
1797
+ const syncRows = secondCore.listTasksForSync('workspace-1', {
1798
+ watermark: originalTaskWatermark,
1799
+ id: task.id,
1800
+ }, 10);
1801
+ expect(syncRows).toHaveLength(1);
1802
+ expect(syncRows[0]?.task?.id).toBe(task.id);
1803
+ expect(syncRows[0]?.task?.comments?.[0]?.author).toBe(resolved.profile.id);
1804
+ secondCore.close();
1805
+ });
1190
1806
  it('repairs legacy actor refs and merges duplicate ai profiles on startup', () => {
1191
1807
  const projectRoot = createProjectRoot();
1192
1808
  createdRoots.push(projectRoot);
@@ -1224,4 +1840,34 @@ describe('TaskforceCore ai profiles', () => {
1224
1840
  expect(reloaded?.comments?.[0]?.author).toBe(claude.profile.id);
1225
1841
  expect(reloaded?.comments?.[0]?.actor?.label).toBe('Claude');
1226
1842
  });
1843
+ it('does not repair ambiguous legacy comment authors on startup', () => {
1844
+ const projectRoot = createProjectRoot();
1845
+ createdRoots.push(projectRoot);
1846
+ const firstCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1847
+ firstCore.createWorkspace({ workspaceId: 'workspace-1', name: 'Workspace 1' });
1848
+ const firstDb = firstCore.db;
1849
+ const first = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude', color: '#f59e0b' });
1850
+ const second = firstCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Claude 2', color: '#0ea5e9' });
1851
+ const task = firstCore.createTask({
1852
+ title: 'Ambiguous legacy actor refs',
1853
+ comments: [{
1854
+ id: 'legacy-comment',
1855
+ author: first.profile.id,
1856
+ text: 'Leave ambiguous alias untouched.',
1857
+ timestamp: '2026-03-22T10:00:00.000Z'
1858
+ }]
1859
+ }, { workspaceId: 'workspace-1' });
1860
+ firstDb.prepare(`UPDATE ai_profiles SET name = 'Claude', username = 'claude' WHERE tenant_id = ? AND id = ?`)
1861
+ .run('default', second.profile.id);
1862
+ firstDb.prepare(`UPDATE comments SET author = 'claude', submitted_author = 'claude' WHERE tenant_id = ? AND task_id = ? AND id = ?`)
1863
+ .run('default', task.id, 'legacy-comment');
1864
+ firstDb.prepare(`DELETE FROM system_settings WHERE tenant_id = ? AND settings_key = ?`)
1865
+ .run('default', 'migration.actor_refs.canonicalize.v1');
1866
+ const reloadedCore = new TaskforceCore({ projectRoot, storagePath: '.taskforce' });
1867
+ const reloaded = reloadedCore.getTask(task.id, 'workspace-1');
1868
+ expect(reloaded?.comments?.[0]?.author).toBe('claude');
1869
+ expect(reloaded?.comments?.[0]?.submittedAuthor).toBe('claude');
1870
+ expect(reloaded?.comments?.[0]?.actor?.meta?.unresolved).toBe(true);
1871
+ expect(reloaded?.comments?.[0]?.actor?.label).toBe('Claude');
1872
+ });
1227
1873
  });