@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.
- package/README.md +0 -2
- package/dist/Taskforce.module.css +317 -27
- package/dist/TaskforceCore.js +1 -0
- package/dist/TaskforceCore.test.js +178 -0
- package/dist/components/activity/EntityActivityTimeline.d.ts +34 -0
- package/dist/components/activity/EntityActivityTimeline.js +81 -0
- package/dist/components/context/ContextAttachmentManager.js +15 -3
- package/dist/components/features/AgentsModule.d.ts +5 -1
- package/dist/components/features/AgentsModule.js +142 -39
- package/dist/components/features/AgentsModule.test.js +260 -11
- package/dist/components/features/AnnotatedAttachmentWorkspace.d.ts +3 -1
- package/dist/components/features/AnnotatedAttachmentWorkspace.js +118 -4
- package/dist/components/features/AnnotatedAttachmentWorkspace.test.js +52 -0
- package/dist/components/features/DocumentIndex.d.ts +2 -2
- package/dist/components/features/DocumentIndex.js +3 -4
- package/dist/components/features/DocumentViewer.d.ts +3 -1
- package/dist/components/features/DocumentViewer.js +6 -5
- package/dist/components/features/DocumentWorkspace.d.ts +10 -2
- package/dist/components/features/DocumentWorkspace.js +15 -4
- package/dist/components/features/DocumentWorkspace.test.js +20 -3
- package/dist/components/features/TaskSettings.d.ts +2 -0
- package/dist/components/features/TaskSettings.js +83 -78
- package/dist/components/features/TaskSettings.test.js +171 -82
- package/dist/components/task/TaskActionHeader.js +11 -5
- package/dist/components/task/TaskActionHeader.test.js +10 -0
- package/dist/components/task/TaskCard.js +6 -2
- package/dist/components/task/TaskContextUpload.test.js +3 -3
- package/dist/components/task/TaskForm.js +14 -64
- package/dist/components/task/TaskKanban.js +0 -8
- package/dist/components/task/TaskList.js +5 -1
- package/dist/components/task/TaskList.test.js +4 -0
- package/dist/components/ui/HierarchyActionBadge.d.ts +10 -0
- package/dist/components/ui/HierarchyActionBadge.js +5 -0
- package/dist/components/views/AppShellHeader.d.ts +3 -1
- package/dist/components/views/AppShellHeader.js +27 -11
- package/dist/components/views/AppShellHeader.test.js +18 -1
- package/dist/components/views/PlanComparisonPage.js +9 -1
- package/dist/components/views/PlanComparisonPage.test.js +19 -0
- package/dist/components/views/PlansPage.js +1 -1
- package/dist/components/views/StandaloneLayout.js +256 -69
- package/dist/components/views/WidgetView.js +10 -8
- package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
- package/dist/components/views/panels/PlanningDrawer.js +259 -51
- package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
- package/dist/config/envSchema.js +1 -0
- package/dist/core/AiProfileService.d.ts +7 -0
- package/dist/core/AiProfileService.js +460 -35
- package/dist/core/AiProfiles.test.js +648 -2
- package/dist/core/AttachmentLinkService.d.ts +3 -0
- package/dist/core/AttachmentLinkService.js +58 -13
- package/dist/core/GlobalSettingsService.js +12 -0
- package/dist/core/McpTokenService.d.ts +12 -0
- package/dist/core/McpTokenService.js +44 -1
- package/dist/core/PlanningEntities.test.js +366 -4
- package/dist/core/TaskActivityService.d.ts +28 -1
- package/dist/core/TaskActivityService.js +227 -46
- package/dist/core/TaskActivitySync.test.d.ts +1 -0
- package/dist/core/TaskActivitySync.test.js +90 -0
- package/dist/core/TaskAuditTimeline.test.js +96 -0
- package/dist/core/Taskforce.d.ts +190 -26
- package/dist/core/Taskforce.ids.test.js +58 -0
- package/dist/core/Taskforce.js +775 -71
- package/dist/core/Taskforce.mcpAuth.test.js +109 -0
- package/dist/core/types.d.ts +7 -0
- package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
- package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
- package/dist/hooks/bootstrapTimeouts.js +8 -0
- package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
- package/dist/hooks/bootstrapTimeouts.test.js +12 -0
- package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
- package/dist/hooks/sync/orchestratorShared.js +61 -1
- package/dist/hooks/sync/recovery.js +8 -0
- package/dist/hooks/sync/recovery.test.js +5 -5
- package/dist/hooks/sync/transfers.d.ts +2 -1
- package/dist/hooks/sync/transfers.js +36 -9
- package/dist/hooks/sync/transfers.test.js +534 -2
- package/dist/hooks/useFilterSort.js +13 -13
- package/dist/hooks/useFilterSort.test.js +87 -1
- package/dist/hooks/useSyncOrchestrator.js +50 -5
- package/dist/hooks/useTaskforce.d.ts +85 -50
- package/dist/hooks/useTaskforce.js +44 -14
- package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
- package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
- package/dist/hooks/useWorkspaceSyncController.js +7 -1
- package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
- package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
- package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
- package/dist/mcp/canonicalAssetHelpers.js +18 -10
- package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
- package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
- package/dist/mcp/clientIntegrations.d.ts +1 -0
- package/dist/mcp/clientIntegrations.js +25 -0
- package/dist/mcp/clientIntegrations.test.js +14 -3
- package/dist/mcp/clientProfileDefaults.js +3 -3
- package/dist/mcp/collaborationRegistrar.js +7 -7
- package/dist/mcp/documentAssetRegistrar.js +113 -7
- package/dist/mcp/runtime.d.ts +1 -0
- package/dist/mcp/runtime.js +969 -122
- package/dist/mcp/runtime.test.js +1083 -11
- package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
- package/dist/mcp/taskPlanningRegistrar.js +48 -0
- package/dist/mcp/toolRegistry.d.ts +56 -0
- package/dist/mcp/toolRegistry.js +8 -0
- package/dist/mcp/toolRegistry.test.js +4 -0
- package/dist/migrations/taskSchemaMigrations.js +21 -0
- package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
- package/dist/server/aiProfileAvatarAssets.js +319 -0
- package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
- package/dist/server/aiProfileAvatarAssets.test.js +213 -0
- package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
- package/dist/server/index.js +4 -0
- package/dist/server/index.test.js +6 -1
- package/dist/server/routes/admin.js +187 -9
- package/dist/server/routes/annotatedAttachments.js +51 -0
- package/dist/server/routes/auth.d.ts +11 -0
- package/dist/server/routes/auth.js +189 -27
- package/dist/server/routes/authSupport.d.ts +4 -0
- package/dist/server/routes/authSupport.js +12 -1
- package/dist/server/routes/billing.js +11 -0
- package/dist/server/routes/billing.test.js +3 -0
- package/dist/server/routes/documents.js +10 -0
- package/dist/server/routes/shared.js +0 -10
- package/dist/server/routes/sync.integration.test.js +221 -0
- package/dist/server/routes/sync.js +13 -3
- package/dist/server/routes/syncSnapshotRoutes.js +2 -0
- package/dist/server/routes/tasks.js +12 -9
- package/dist/server/routes.js +87 -8
- package/dist/server/routes.test.js +890 -22
- package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
- package/dist/shared/aiProfileSurfaceType.js +10 -8
- package/dist/storage/objectStorage.d.ts +1 -0
- package/dist/storage/objectStorage.js +2 -1
- package/dist/storage/objectStorageClient.d.ts +1 -0
- package/dist/storage/objectStorageClient.js +41 -8
- package/dist/storage/objectStorageClient.test.js +26 -1
- package/dist/storage/objectStorageConfig.js +3 -1
- package/dist/storage/objectStorageConfig.test.js +13 -1
- package/dist/sync/collaborationSyncPayload.js +2 -0
- package/dist/sync/collaborationSyncPayload.test.js +1 -0
- package/dist/sync/collaborationSyncState.d.ts +3 -3
- package/dist/sync/collaborationSyncState.js +19 -12
- package/dist/sync/contentSyncPayload.d.ts +5 -1
- package/dist/sync/contentSyncPayload.js +9 -0
- package/dist/sync/contentSyncPayload.test.js +39 -0
- package/dist/sync/contentSyncState.d.ts +5 -1
- package/dist/sync/contentSyncState.js +231 -37
- package/dist/sync/planningSyncPayload.js +5 -0
- package/dist/sync/planningSyncPayload.test.js +24 -0
- package/dist/sync/syncApplyHandlers.d.ts +4 -4
- package/dist/sync/syncApplyHandlers.js +89 -31
- package/dist/sync/syncApplyHandlers.test.js +184 -4
- package/dist/sync/syncResourceAdapters.d.ts +2 -0
- package/dist/sync/syncResourceAdapters.js +2 -0
- package/dist/sync/syncService.d.ts +1 -0
- package/dist/sync/syncService.js +13 -1
- package/dist/sync/syncService.test.js +39 -0
- package/dist/sync/workspacePullFeed.d.ts +4 -0
- package/dist/sync/workspacePullFeed.js +129 -70
- package/dist/sync/workspacePullFeed.test.js +49 -0
- package/dist/sync/workspaceSyncModel.d.ts +28 -6
- package/dist/sync/workspaceSyncModel.js +159 -45
- package/dist/sync/workspaceSyncModel.test.js +269 -3
- package/dist/types.d.ts +33 -2
- package/dist/ui/assets/AgentsModule-2y82_3DO.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BTLZZCiQ.js +3 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
- package/dist/ui/assets/ContextAttachmentManager-D2epuNnG.js +3 -0
- package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
- package/dist/ui/assets/DocumentWorkspace-sVa-3Do6.js +252 -0
- package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
- package/dist/ui/assets/EntityActivityTimeline-Zods27y_.js +1 -0
- package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-D4XseTwW.js} +1 -1
- package/dist/ui/assets/PlansPage-D5AcbO0L.js +1 -0
- package/dist/ui/assets/PlansPage-Dvqsz5zc.css +1 -0
- package/dist/ui/assets/TaskContextUpload-uC5qx4Bv.js +1 -0
- package/dist/ui/assets/TaskSettings-CqvPAsTv.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-oOA9bcdK.js} +4 -4
- package/dist/ui/assets/documentReferences-DPZuTgHh.js +1 -0
- package/dist/ui/assets/index-607WPo_X.js +4 -0
- package/dist/ui/assets/index-CYyodXsg.css +1 -0
- package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
- package/dist/ui/index.html +3 -3
- package/dist/ui/product/agent manager.png +0 -0
- package/dist/ui/product/agent_manager_02.png +0 -0
- package/dist/utils/aiProfileEvents.d.ts +7 -0
- package/dist/utils/aiProfileEvents.js +6 -0
- package/dist/utils/assignees.d.ts +6 -0
- package/dist/utils/assignees.js +8 -1
- package/dist/utils/assignees.test.d.ts +1 -0
- package/dist/utils/assignees.test.js +25 -0
- package/dist/utils/avatarDisplay.d.ts +1 -0
- package/dist/utils/avatarDisplay.js +15 -0
- package/dist/utils/contextFiles.d.ts +3 -1
- package/dist/utils/contextFiles.js +2 -1
- package/dist/utils/taskActivity.d.ts +7 -1
- package/dist/utils/taskActivity.js +63 -1
- package/dist/utils/taskActivity.test.js +23 -1
- package/dist/utils/taskSearch.d.ts +22 -0
- package/dist/utils/taskSearch.js +87 -0
- package/dist/utils/taskSearch.test.d.ts +1 -0
- package/dist/utils/taskSearch.test.js +69 -0
- package/dist/utils/taskUtils.js +10 -3
- package/dist/utils/taskUtils.test.d.ts +1 -0
- package/dist/utils/taskUtils.test.js +19 -0
- package/package.json +1 -1
- package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
- package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
- package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
- package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
- package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
- package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
- package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
- 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('
|
|
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
|
|
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
|
});
|