@taskforcehq/taskforce 0.3.306 → 0.3.308

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 (232) 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.d.ts +20 -15
  38. package/dist/components/views/PlanComparisonPage.js +221 -82
  39. package/dist/components/views/PlanComparisonPage.test.js +268 -14
  40. package/dist/components/views/PlansPage.js +16 -10
  41. package/dist/components/views/PlansPage.test.js +4 -2
  42. package/dist/components/views/StandaloneLayout.js +256 -69
  43. package/dist/components/views/WidgetView.js +10 -8
  44. package/dist/components/views/panels/PlanningDrawer.d.ts +19 -6
  45. package/dist/components/views/panels/PlanningDrawer.js +259 -51
  46. package/dist/components/views/panels/PlanningDrawer.test.js +392 -4
  47. package/dist/config/envSchema.js +1 -0
  48. package/dist/core/AiProfileService.d.ts +7 -0
  49. package/dist/core/AiProfileService.js +460 -35
  50. package/dist/core/AiProfiles.test.js +648 -2
  51. package/dist/core/AttachmentLinkService.d.ts +3 -0
  52. package/dist/core/AttachmentLinkService.js +58 -13
  53. package/dist/core/GlobalSettingsService.js +30 -0
  54. package/dist/core/GlobalSettingsService.test.d.ts +1 -0
  55. package/dist/core/GlobalSettingsService.test.js +45 -0
  56. package/dist/core/McpTokenService.d.ts +12 -0
  57. package/dist/core/McpTokenService.js +44 -1
  58. package/dist/core/PlanEntitlementService.d.ts +20 -0
  59. package/dist/core/PlanEntitlementService.js +215 -19
  60. package/dist/core/PlanFeatureCatalog.test.js +55 -0
  61. package/dist/core/PlanningEntities.test.js +366 -4
  62. package/dist/core/TaskActivityService.d.ts +28 -1
  63. package/dist/core/TaskActivityService.js +227 -46
  64. package/dist/core/TaskActivitySync.test.d.ts +1 -0
  65. package/dist/core/TaskActivitySync.test.js +90 -0
  66. package/dist/core/TaskAuditTimeline.test.js +96 -0
  67. package/dist/core/Taskforce.d.ts +204 -26
  68. package/dist/core/Taskforce.ids.test.js +58 -0
  69. package/dist/core/Taskforce.js +781 -71
  70. package/dist/core/Taskforce.mcpAuth.test.js +109 -0
  71. package/dist/core/types.d.ts +10 -0
  72. package/dist/hooks/auth/useTaskforceAuthBootstrap.js +2 -1
  73. package/dist/hooks/bootstrapTimeouts.d.ts +4 -0
  74. package/dist/hooks/bootstrapTimeouts.js +8 -0
  75. package/dist/hooks/bootstrapTimeouts.test.d.ts +1 -0
  76. package/dist/hooks/bootstrapTimeouts.test.js +12 -0
  77. package/dist/hooks/sync/orchestratorShared.d.ts +3 -3
  78. package/dist/hooks/sync/orchestratorShared.js +61 -1
  79. package/dist/hooks/sync/recovery.js +8 -0
  80. package/dist/hooks/sync/recovery.test.js +5 -5
  81. package/dist/hooks/sync/transfers.d.ts +2 -1
  82. package/dist/hooks/sync/transfers.js +36 -9
  83. package/dist/hooks/sync/transfers.test.js +534 -2
  84. package/dist/hooks/useFilterSort.js +13 -13
  85. package/dist/hooks/useFilterSort.test.js +87 -1
  86. package/dist/hooks/useSyncOrchestrator.js +50 -5
  87. package/dist/hooks/useTaskforce.d.ts +85 -50
  88. package/dist/hooks/useTaskforce.js +44 -14
  89. package/dist/hooks/useTaskforce.runtime-routing.test.js +94 -0
  90. package/dist/hooks/useTaskforce.sync-behavior.test.js +99 -0
  91. package/dist/hooks/useWorkspaceSyncController.js +7 -1
  92. package/dist/hooks/useWorkspaceSyncController.test.js +48 -0
  93. package/dist/hooks/workspace/useTaskforceWorkspaceBootstrap.js +14 -7
  94. package/dist/mcp/canonicalAssetHelpers.d.ts +16 -4
  95. package/dist/mcp/canonicalAssetHelpers.js +18 -10
  96. package/dist/mcp/canonicalAssetHelpers.test.d.ts +1 -0
  97. package/dist/mcp/canonicalAssetHelpers.test.js +67 -0
  98. package/dist/mcp/clientIntegrations.d.ts +1 -0
  99. package/dist/mcp/clientIntegrations.js +25 -0
  100. package/dist/mcp/clientIntegrations.test.js +14 -3
  101. package/dist/mcp/clientProfileDefaults.js +3 -3
  102. package/dist/mcp/collaborationRegistrar.js +7 -7
  103. package/dist/mcp/documentAssetRegistrar.js +113 -7
  104. package/dist/mcp/runtime.d.ts +1 -0
  105. package/dist/mcp/runtime.js +969 -122
  106. package/dist/mcp/runtime.test.js +1083 -11
  107. package/dist/mcp/taskAttachmentTypes.d.ts +1 -1
  108. package/dist/mcp/taskPlanningRegistrar.js +48 -0
  109. package/dist/mcp/toolRegistry.d.ts +56 -0
  110. package/dist/mcp/toolRegistry.js +8 -0
  111. package/dist/mcp/toolRegistry.test.js +4 -0
  112. package/dist/migrations/taskSchemaMigrations.js +58 -15
  113. package/dist/server/aiProfileAvatarAssets.d.ts +49 -0
  114. package/dist/server/aiProfileAvatarAssets.js +319 -0
  115. package/dist/server/aiProfileAvatarAssets.test.d.ts +1 -0
  116. package/dist/server/aiProfileAvatarAssets.test.js +213 -0
  117. package/dist/server/annotatedAttachmentsRoutes.test.js +66 -7
  118. package/dist/server/index.js +4 -0
  119. package/dist/server/index.test.js +6 -1
  120. package/dist/server/routes/admin.js +328 -9
  121. package/dist/server/routes/annotatedAttachments.js +51 -0
  122. package/dist/server/routes/auth.d.ts +11 -0
  123. package/dist/server/routes/auth.js +189 -27
  124. package/dist/server/routes/authSupport.d.ts +5 -0
  125. package/dist/server/routes/authSupport.js +13 -1
  126. package/dist/server/routes/billing.d.ts +1 -0
  127. package/dist/server/routes/billing.js +116 -63
  128. package/dist/server/routes/billing.test.js +162 -11
  129. package/dist/server/routes/documents.js +10 -0
  130. package/dist/server/routes/shared.js +0 -10
  131. package/dist/server/routes/sync.integration.test.js +221 -0
  132. package/dist/server/routes/sync.js +13 -3
  133. package/dist/server/routes/syncSnapshotRoutes.js +2 -0
  134. package/dist/server/routes/tasks.js +12 -9
  135. package/dist/server/routes.js +87 -8
  136. package/dist/server/routes.test.js +972 -23
  137. package/dist/shared/aiProfileSurfaceType.d.ts +1 -1
  138. package/dist/shared/aiProfileSurfaceType.js +10 -8
  139. package/dist/storage/objectStorage.d.ts +1 -0
  140. package/dist/storage/objectStorage.js +2 -1
  141. package/dist/storage/objectStorageClient.d.ts +1 -0
  142. package/dist/storage/objectStorageClient.js +41 -8
  143. package/dist/storage/objectStorageClient.test.js +26 -1
  144. package/dist/storage/objectStorageConfig.js +3 -1
  145. package/dist/storage/objectStorageConfig.test.js +13 -1
  146. package/dist/sync/collaborationSyncPayload.js +2 -0
  147. package/dist/sync/collaborationSyncPayload.test.js +1 -0
  148. package/dist/sync/collaborationSyncState.d.ts +3 -3
  149. package/dist/sync/collaborationSyncState.js +19 -12
  150. package/dist/sync/contentSyncPayload.d.ts +5 -1
  151. package/dist/sync/contentSyncPayload.js +9 -0
  152. package/dist/sync/contentSyncPayload.test.js +39 -0
  153. package/dist/sync/contentSyncState.d.ts +5 -1
  154. package/dist/sync/contentSyncState.js +231 -37
  155. package/dist/sync/planningSyncPayload.js +5 -0
  156. package/dist/sync/planningSyncPayload.test.js +24 -0
  157. package/dist/sync/syncApplyHandlers.d.ts +4 -4
  158. package/dist/sync/syncApplyHandlers.js +89 -31
  159. package/dist/sync/syncApplyHandlers.test.js +184 -4
  160. package/dist/sync/syncResourceAdapters.d.ts +2 -0
  161. package/dist/sync/syncResourceAdapters.js +2 -0
  162. package/dist/sync/syncService.d.ts +1 -0
  163. package/dist/sync/syncService.js +13 -1
  164. package/dist/sync/syncService.test.js +39 -0
  165. package/dist/sync/workspacePullFeed.d.ts +4 -0
  166. package/dist/sync/workspacePullFeed.js +129 -70
  167. package/dist/sync/workspacePullFeed.test.js +49 -0
  168. package/dist/sync/workspaceSyncModel.d.ts +28 -6
  169. package/dist/sync/workspaceSyncModel.js +159 -45
  170. package/dist/sync/workspaceSyncModel.test.js +269 -3
  171. package/dist/types.d.ts +33 -2
  172. package/dist/ui/agent-logos/ChatGPT.png +0 -0
  173. package/dist/ui/agent-logos/Gemini CLI.jpeg +0 -0
  174. package/dist/ui/agent-logos/antigravity.jpeg +0 -0
  175. package/dist/ui/agent-logos/claude-code.jpeg +0 -0
  176. package/dist/ui/agent-logos/codex.jpeg +0 -0
  177. package/dist/ui/agent-logos/cursor.png +0 -0
  178. package/dist/ui/agent-logos/openclaw.jpeg +0 -0
  179. package/dist/ui/agent-logos/windsurf.png +0 -0
  180. package/dist/ui/assets/AgentsModule-BS4Pi_nC.js +1 -0
  181. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
  182. package/dist/ui/assets/AnnotatedAttachmentWorkspace-CCckzWYZ.js +3 -0
  183. package/dist/ui/assets/ContextAttachmentManager-CfG_ER7C.js +3 -0
  184. package/dist/ui/assets/DocumentWorkspace-BVaWPA25.js +252 -0
  185. package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
  186. package/dist/ui/assets/EntityActivityTimeline-DSj0ThaK.js +1 -0
  187. package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
  188. package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-AxMkWtxH.js} +1 -1
  189. package/dist/ui/assets/PlansPage-B_z-D6Nh.css +1 -0
  190. package/dist/ui/assets/PlansPage-Ndt7mYfo.js +1 -0
  191. package/dist/ui/assets/TaskContextUpload-Dw4rTF4i.js +1 -0
  192. package/dist/ui/assets/TaskSettings-f2ga42_V.js +9 -0
  193. package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-E4UL3mov.js} +4 -4
  194. package/dist/ui/assets/documentReferences-BOUcJm6-.js +1 -0
  195. package/dist/ui/assets/index-BvAbqx5Q.css +1 -0
  196. package/dist/ui/assets/index-MAHKvFqp.js +4 -0
  197. package/dist/ui/assets/{vendor-icons-DuEd_65c.js → vendor-icons-B32hGuF2.js} +1 -1
  198. package/dist/ui/index.html +3 -3
  199. package/dist/ui/og-image.png +0 -0
  200. package/dist/ui/product/agent manager.png +0 -0
  201. package/dist/ui/product/agent_manager_02.png +0 -0
  202. package/dist/utils/aiProfileEvents.d.ts +7 -0
  203. package/dist/utils/aiProfileEvents.js +6 -0
  204. package/dist/utils/assignees.d.ts +6 -0
  205. package/dist/utils/assignees.js +8 -1
  206. package/dist/utils/assignees.test.d.ts +1 -0
  207. package/dist/utils/assignees.test.js +25 -0
  208. package/dist/utils/avatarDisplay.d.ts +1 -0
  209. package/dist/utils/avatarDisplay.js +15 -0
  210. package/dist/utils/contextFiles.d.ts +3 -1
  211. package/dist/utils/contextFiles.js +2 -1
  212. package/dist/utils/taskActivity.d.ts +7 -1
  213. package/dist/utils/taskActivity.js +63 -1
  214. package/dist/utils/taskActivity.test.js +23 -1
  215. package/dist/utils/taskSearch.d.ts +22 -0
  216. package/dist/utils/taskSearch.js +87 -0
  217. package/dist/utils/taskSearch.test.d.ts +1 -0
  218. package/dist/utils/taskSearch.test.js +69 -0
  219. package/dist/utils/taskUtils.js +10 -3
  220. package/dist/utils/taskUtils.test.d.ts +1 -0
  221. package/dist/utils/taskUtils.test.js +19 -0
  222. package/package.json +1 -1
  223. package/dist/ui/assets/AgentsModule-DrLawwNl.js +0 -1
  224. package/dist/ui/assets/AnnotatedAttachmentWorkspace-BaS2VwIr.css +0 -1
  225. package/dist/ui/assets/AnnotatedAttachmentWorkspace-Z9_whf7F.js +0 -3
  226. package/dist/ui/assets/DocumentWorkspace-B03MaSkw.js +0 -252
  227. package/dist/ui/assets/DocumentWorkspace-Dhdso07p.css +0 -1
  228. package/dist/ui/assets/PlansPage-CvfnMiWq.css +0 -1
  229. package/dist/ui/assets/PlansPage-DRXscYxH.js +0 -1
  230. package/dist/ui/assets/TaskSettings-DZX4jk7e.js +0 -9
  231. package/dist/ui/assets/index-9eT8e0Lu.css +0 -1
  232. package/dist/ui/assets/index-C6k75jr8.js +0 -6
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import fs from 'fs';
3
3
  import os from 'os';
4
4
  import path from 'path';
5
+ import { createHash, randomBytes } from 'crypto';
5
6
  import { createTaskforceMcpServer } from './runtime';
6
7
  import { MCP_TOOL_REGISTRY } from './toolRegistry';
7
8
  import { TaskforceCore, TaskforceRuleError } from '../core/Taskforce';
@@ -17,6 +18,7 @@ function createRuntime(projectRoot, runtimeMode, options) {
17
18
  ? {
18
19
  scopes: ['tasks:read', 'tasks:write', 'workspace:read'],
19
20
  userId: options?.authUserId || 'owner-1',
21
+ tokenId: options?.authTokenId || null,
20
22
  actorType: 'oauth',
21
23
  aiProfileToken: options?.authAiProfileToken || null,
22
24
  aiProfileName: options?.authAiProfileName || null,
@@ -160,6 +162,34 @@ function parseToolJson(result) {
160
162
  expect(text).toBeTruthy();
161
163
  return JSON.parse(String(text));
162
164
  }
165
+ function createOauthGrantToken(core, userId, workspaceId) {
166
+ const redirectUri = 'https://chat.openai.com/aip/callback';
167
+ const client = core.registerMcpOAuthClient({
168
+ provider: 'chatgpt',
169
+ clientName: 'ChatGPT Connector',
170
+ redirectUris: [redirectUri],
171
+ });
172
+ const verifier = randomBytes(32).toString('base64url');
173
+ const codeChallenge = createHash('sha256').update(verifier).digest('base64url');
174
+ const issued = core.issueMcpOAuthAuthorizationCode({
175
+ provider: 'chatgpt',
176
+ clientId: client.id,
177
+ userId,
178
+ workspaceId,
179
+ scopes: ['tasks:read', 'tasks:write', 'workspace:read'],
180
+ redirectUri,
181
+ codeChallenge,
182
+ codeChallengeMethod: 'S256',
183
+ });
184
+ const exchanged = core.exchangeMcpOAuthAuthorizationCode({
185
+ provider: 'chatgpt',
186
+ clientId: client.id,
187
+ code: issued.code,
188
+ redirectUri,
189
+ codeVerifier: verifier,
190
+ });
191
+ return exchanged.accessToken;
192
+ }
163
193
  function seedAnnotatedAttachmentFixture(projectRoot) {
164
194
  const core = new TaskforceCore({
165
195
  projectRoot,
@@ -385,6 +415,14 @@ describe('Taskforce MCP tool descriptions', () => {
385
415
  const getTaskAttachments = getTool(result, 'get_task_attachments');
386
416
  const readTaskAttachment = getTool(result, 'read_task_attachment');
387
417
  const deleteTaskAttachment = getTool(result, 'delete_task_attachment');
418
+ const getWorkstreamAttachments = getTool(result, 'get_workstream_attachments');
419
+ const readWorkstreamAttachment = getTool(result, 'read_workstream_attachment');
420
+ const saveWorkstreamAttachment = getTool(result, 'save_workstream_attachment');
421
+ const deleteWorkstreamAttachment = getTool(result, 'delete_workstream_attachment');
422
+ const getInitiativeAttachments = getTool(result, 'get_initiative_attachments');
423
+ const readInitiativeAttachment = getTool(result, 'read_initiative_attachment');
424
+ const saveInitiativeAttachment = getTool(result, 'save_initiative_attachment');
425
+ const deleteInitiativeAttachment = getTool(result, 'delete_initiative_attachment');
388
426
  const deleteAnnotatedAttachmentSession = getTool(result, 'delete_annotated_attachment_session');
389
427
  const archiveTask = getTool(result, 'archive_task');
390
428
  const startTask = getTool(result, 'start_task');
@@ -403,10 +441,14 @@ describe('Taskforce MCP tool descriptions', () => {
403
441
  expect(getTask.description).toContain('Do not fetch attachment path URLs directly');
404
442
  expect(getTask.description).toContain('Do not use this for image references like I-123 or document references like D-123.');
405
443
  expect(searchImages.description).toContain('get_image');
444
+ expect(getImage.inputSchema?.properties?.id).toBeDefined();
445
+ expect(getImage.inputSchema?.properties?.imageId).toBeDefined();
406
446
  expect(getImage.description).toContain('Use this first when the user references an image');
407
447
  expect(getImage.description).toContain('read the saved annotation payload before making changes');
408
448
  expect(searchDocuments.description).toContain('get_document');
409
449
  expect(searchDocuments.description).toContain('read_document_content');
450
+ expect(getDocument.inputSchema?.properties?.id).toBeDefined();
451
+ expect(getDocument.inputSchema?.properties?.documentId).toBeDefined();
410
452
  expect(getDocument.description).toContain('Use this first when the user references a document');
411
453
  expect(getDocument.description).toContain('read_document_content');
412
454
  expect(getCurrentProfile.description).toContain('current profile id');
@@ -422,9 +464,24 @@ describe('Taskforce MCP tool descriptions', () => {
422
464
  expect(deleteAnnotatedAttachmentSession.description).toContain('whole session was created by mistake');
423
465
  expect(getTaskAttachments.description).toContain('use read_task_attachment for file contents');
424
466
  expect(getTaskAttachments.description).toContain('prefer get_document and read_document_content');
467
+ expect(getTaskAttachments.inputSchema?.properties?.taskId).toBeDefined();
468
+ expect(saveTaskAttachment.description).toContain('staged remote uploads');
469
+ expect(saveTaskAttachment.inputSchema?.properties?.uploadedPath).toBeDefined();
470
+ expect(saveTaskAttachment.inputSchema?.properties?.uploadDraftId).toBeDefined();
471
+ expect(saveTaskAttachment.inputSchema?.properties?.mimeType).toBeDefined();
472
+ expect(saveTaskAttachment.inputSchema?.properties?.sizeBytes).toBeDefined();
473
+ expect(getTaskAttachments.inputSchema?.properties?.id).toBeDefined();
425
474
  expect(readTaskAttachment.description).toContain('Prefer this tool over opening attachment path URLs directly');
426
475
  expect(readTaskAttachment.description).toContain('canonical document');
427
476
  expect(deleteTaskAttachment.description).toContain('Use get_task_attachments first');
477
+ expect(getWorkstreamAttachments.description).toContain('use read_workstream_attachment for file contents');
478
+ expect(readWorkstreamAttachment.description).toContain('Prefer this tool over opening attachment path URLs directly');
479
+ expect(saveWorkstreamAttachment.description).toContain('attach it to a workstream');
480
+ expect(deleteWorkstreamAttachment.description).toContain('Use get_workstream_attachments first');
481
+ expect(getInitiativeAttachments.description).toContain('use read_initiative_attachment for file contents');
482
+ expect(readInitiativeAttachment.description).toContain('Prefer this tool over opening attachment path URLs directly');
483
+ expect(saveInitiativeAttachment.description).toContain('attach it to an initiative');
484
+ expect(deleteInitiativeAttachment.description).toContain('Use get_initiative_attachments first');
428
485
  expect(archiveTask.description).toContain('active tasks may still use status=done for final lookover');
429
486
  expect(startTask.description).toContain('preferred AI start-of-task workflow');
430
487
  expect(startTask.description).toContain('will not steal a task from a different assignee');
@@ -433,6 +490,8 @@ describe('Taskforce MCP tool descriptions', () => {
433
490
  expect(finishTask.description).toContain('outcome=\'review\' when human validation or approval is needed');
434
491
  expect(finishTask.description).toContain('either finish it with this tool or explain why it remains open');
435
492
  expect(finishTask.description).toContain('This tool never archives automatically');
493
+ expect(reviewDocument.inputSchema?.properties?.prompt).toBeDefined();
494
+ expect(reviewDocument.inputSchema?.properties?.comments).toBeDefined();
436
495
  expect(updateTask.description).toContain('Use start_task when beginning active work on a tracked task');
437
496
  expect(updateTask.description).toContain('prefer finish_task for the final completion or review handoff');
438
497
  expect(assignTask.description).toContain('For assigning 3 or more tasks at once, use bulk_assign_tasks instead.');
@@ -446,21 +505,25 @@ describe('Taskforce MCP tool descriptions', () => {
446
505
  const saveTaskAttachmentSchema = saveTaskAttachment.inputSchema;
447
506
  expect(saveTaskAttachmentSchema.properties.content.description).toContain('use this for AI-authored markdown/text attachments');
448
507
  expect(saveTaskAttachmentSchema.properties.sourcePath.description).toContain('approved agent workspace root');
508
+ expect(saveTaskAttachmentSchema.properties.profileToken.description).toContain('does not preserve session identity');
449
509
  expect(resolveProfile.description).toContain('AI collaborator profile');
510
+ expect(resolveProfile.description).toContain('private agent/client state');
511
+ expect(resolveProfile.description).toContain('Never write profileToken values');
450
512
  expect(resolveProfile.description).toContain('Update semantics');
451
513
  expect(resolveProfile.description).toContain('empty strings are ignored rather than clearing');
452
514
  expect(resolveProfile.inputSchema?.properties?.surfaceType).toBeDefined();
453
515
  expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('self-declared AI surface type');
454
516
  expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('agent for a persistent named AI collaborator in Taskforce');
455
- expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('coding_tool for the coding surface or terminal tool itself');
456
- expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('If you are unsure and you are registering a named collaborator');
517
+ expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('cli for terminal-native coding agents');
518
+ expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('coding_tool for coding surfaces that are not specifically CLI or IDE-native');
519
+ expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('If you are unsure or none of those fit');
457
520
  const surfaceTypeSchema = resolveProfile.inputSchema?.properties?.surfaceType;
458
521
  expect(surfaceTypeSchema?.anyOf?.[0]?.enum).toEqual([
459
522
  'chat_app',
460
523
  'ide',
524
+ 'cli',
461
525
  'coding_tool',
462
- 'agent',
463
- 'other'
526
+ 'agent'
464
527
  ]);
465
528
  }
466
529
  finally {
@@ -641,6 +704,32 @@ describe('Taskforce MCP tool descriptions', () => {
641
704
  })
642
705
  }));
643
706
  });
707
+ it('returns structured transient MCP timeout details for recoverable read failures', async () => {
708
+ const { buildRecoverableToolErrorResult } = await import('./runtime');
709
+ const result = buildRecoverableToolErrorResult(new Error('Postgres worker timed out while reading workstream attachments'), 'req-timeout-1');
710
+ const payload = parseToolJson(result);
711
+ expect(payload.success).toBe(false);
712
+ expect(payload.code).toBe('MCP_BACKEND_TIMEOUT');
713
+ expect(payload.kind).toBe('backend_timeout');
714
+ expect(payload.retryable).toBe(true);
715
+ expect(payload.requestId).toBe('req-timeout-1');
716
+ expect(payload.retryHint).toContain('backend');
717
+ expect(payload.details).toEqual(expect.objectContaining({
718
+ kind: 'backend_timeout',
719
+ retryable: true
720
+ }));
721
+ });
722
+ it('returns structured transient MCP transport details for recoverable transport failures', async () => {
723
+ const { buildRecoverableToolErrorResult } = await import('./runtime');
724
+ const result = buildRecoverableToolErrorResult(new Error('fetch failed'), 'req-fetch-1');
725
+ const payload = parseToolJson(result);
726
+ expect(payload.success).toBe(false);
727
+ expect(payload.code).toBe('MCP_TRANSPORT_FAILURE');
728
+ expect(payload.kind).toBe('transport_failure');
729
+ expect(payload.retryable).toBe(true);
730
+ expect(payload.requestId).toBe('req-fetch-1');
731
+ expect(payload.retryHint).toContain('transport');
732
+ });
644
733
  it('blocks mutating MCP tools until resolve_profile is called', async () => {
645
734
  const projectRoot = createProjectRoot();
646
735
  try {
@@ -813,6 +902,126 @@ describe('Taskforce MCP tool descriptions', () => {
813
902
  id: resolved.profile.id,
814
903
  username: resolved.profile.username
815
904
  }));
905
+ expect(created.createdByActor.meta).not.toHaveProperty('profileToken');
906
+ }
907
+ finally {
908
+ fs.rmSync(projectRoot, { recursive: true, force: true });
909
+ }
910
+ });
911
+ it('persists resolved OAuth connector identity for later stateless writes', async () => {
912
+ const projectRoot = createProjectRoot();
913
+ try {
914
+ const seedCore = new TaskforceCore({
915
+ projectRoot,
916
+ storagePath: path.join(projectRoot, '.taskforce')
917
+ });
918
+ Object.defineProperty(seedCore, 'getAccountAccessStatus', {
919
+ configurable: true,
920
+ value: () => ({
921
+ gate: 'ok',
922
+ hasEntitlement: true,
923
+ canAccessApp: true,
924
+ canAccessPlans: true,
925
+ planSelectionRequired: false,
926
+ message: null,
927
+ entitlement: null
928
+ })
929
+ });
930
+ const task = seedCore.createTask({ title: 'OAuth connector identity task' }, { workspaceId: 'workspace-1' });
931
+ const accessToken = createOauthGrantToken(seedCore, 'owner-1', 'workspace-1');
932
+ const initialAuth = seedCore.authenticateMcpOAuthAccessToken(accessToken);
933
+ expect(initialAuth?.grantId).toBeTruthy();
934
+ expect(initialAuth?.aiProfileToken).toBeFalsy();
935
+ seedCore.close();
936
+ const resolveRuntime = createRuntime(projectRoot, 'cloud', {
937
+ authTokenId: initialAuth.grantId,
938
+ });
939
+ const resolved = parseToolJson(await callToolWithRuntime(resolveRuntime, 'resolve_profile', {
940
+ name: 'Codex Connector',
941
+ icon: 'Code',
942
+ color: '#0ea5e9',
943
+ }, { resolveProfile: false }));
944
+ const authCore = new TaskforceCore({
945
+ projectRoot,
946
+ storagePath: path.join(projectRoot, '.taskforce')
947
+ });
948
+ Object.defineProperty(authCore, 'getAccountAccessStatus', {
949
+ configurable: true,
950
+ value: () => ({
951
+ gate: 'ok',
952
+ hasEntitlement: true,
953
+ canAccessApp: true,
954
+ canAccessPlans: true,
955
+ planSelectionRequired: false,
956
+ message: null,
957
+ entitlement: null
958
+ })
959
+ });
960
+ const persistedAuth = authCore.authenticateMcpOAuthAccessToken(accessToken);
961
+ expect(persistedAuth).toEqual(expect.objectContaining({
962
+ grantId: initialAuth.grantId,
963
+ aiProfileId: resolved.profile.id,
964
+ aiProfileToken: resolved.profile.profileToken,
965
+ aiProfileName: 'Codex Connector',
966
+ }));
967
+ authCore.close();
968
+ const writeRuntime = createRuntime(projectRoot, 'cloud', {
969
+ authTokenId: persistedAuth.grantId,
970
+ authAiProfileToken: persistedAuth.aiProfileToken || undefined,
971
+ authAiProfileName: persistedAuth.aiProfileName || undefined,
972
+ });
973
+ const result = await callToolWithRuntime(writeRuntime, 'add_comment', {
974
+ id: task.id,
975
+ text: 'Stateless connector write after profile resolution',
976
+ }, { resolveProfile: false });
977
+ expect(result.isError).toBeFalsy();
978
+ expect(String(result.content?.[0]?.text || '')).toContain('Comment added');
979
+ }
980
+ finally {
981
+ fs.rmSync(projectRoot, { recursive: true, force: true });
982
+ }
983
+ });
984
+ it('allows save_task_attachment in a fresh runtime when profileToken is passed', async () => {
985
+ const projectRoot = createProjectRoot();
986
+ try {
987
+ const core = new TaskforceCore({
988
+ projectRoot,
989
+ storagePath: path.join(projectRoot, '.taskforce')
990
+ });
991
+ const task = core.createTask({
992
+ title: 'Stateless attachment token write'
993
+ }, {
994
+ workspaceId: 'workspace-1'
995
+ });
996
+ core.close();
997
+ const resolved = parseToolJson(await callTool(projectRoot, 'local', 'resolve_profile', {
998
+ name: 'Codex',
999
+ icon: 'Bot',
1000
+ color: '#ef6c00'
1001
+ }, { resolveProfile: false }));
1002
+ const result = await callTool(projectRoot, 'local', 'save_task_attachment', {
1003
+ id: task.referenceLabel,
1004
+ filename: 'stateless-note.txt',
1005
+ content: 'hello from a stateless attachment write',
1006
+ profileToken: resolved.profile.profileToken,
1007
+ }, { resolveProfile: false });
1008
+ expect(result.isError).not.toBe(true);
1009
+ const after = new TaskforceCore({
1010
+ projectRoot,
1011
+ storagePath: path.join(projectRoot, '.taskforce')
1012
+ });
1013
+ try {
1014
+ const updated = after.getTask(task.id, 'workspace-1');
1015
+ expect(updated?.attachments).toHaveLength(1);
1016
+ const attachment = updated?.attachments?.[0];
1017
+ expect(attachment?.assetId).toMatch(/^asset-/);
1018
+ const store = new WorkspaceAssetStore(after.getDatabaseAdapter(), after.getTenantId());
1019
+ const asset = store.get(String(attachment?.assetId || ''), 'workspace-1');
1020
+ expect(asset?.createdByUserId).toBe(resolved.profile.id);
1021
+ }
1022
+ finally {
1023
+ after.close();
1024
+ }
816
1025
  }
817
1026
  finally {
818
1027
  fs.rmSync(projectRoot, { recursive: true, force: true });
@@ -1268,6 +1477,168 @@ describe('Taskforce MCP tool descriptions', () => {
1268
1477
  fs.rmSync(projectRoot, { recursive: true, force: true });
1269
1478
  }
1270
1479
  });
1480
+ it('attributes system-authored MCP comments to the current AI profile', async () => {
1481
+ const projectRoot = createProjectRoot();
1482
+ try {
1483
+ const runtime = createTaskforceMcpServer({
1484
+ projectRoot,
1485
+ runtimeMode: 'local',
1486
+ workspaceId: 'workspace-1',
1487
+ logStartup: false,
1488
+ });
1489
+ const profilePayload = parseToolJson(await callToolWithRuntime(runtime, 'resolve_profile', {
1490
+ name: 'Codex',
1491
+ icon: 'Bot',
1492
+ surfaceType: 'coding_tool',
1493
+ }, {
1494
+ resolveProfile: false,
1495
+ }));
1496
+ const created = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
1497
+ title: 'Comment attribution task',
1498
+ }));
1499
+ await callToolWithRuntime(runtime, 'add_comment', {
1500
+ id: created.referenceLabel,
1501
+ text: 'Completed the work.',
1502
+ author: 'system',
1503
+ });
1504
+ const task = parseToolJson(await callToolWithRuntime(runtime, 'get_task', {
1505
+ id: created.referenceLabel,
1506
+ }));
1507
+ const comment = task.comments.find((entry) => entry.text === 'Completed the work.');
1508
+ const event = task.taskEvents.find((entry) => entry.details?.commentId === comment?.id);
1509
+ expect(comment.author).toBe(profilePayload.profile.id);
1510
+ expect(comment.submittedAuthor).toBe(profilePayload.profile.id);
1511
+ expect(comment.actor.label).toBe('Codex');
1512
+ expect(event.actor).toBe(profilePayload.profile.id);
1513
+ expect(event.actorType).toBe('ai');
1514
+ expect(event.actorProfile.label).toBe('Codex');
1515
+ }
1516
+ finally {
1517
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1518
+ }
1519
+ });
1520
+ it('canonicalizes ambiguous AI display-name comment authors back to the bound profile', async () => {
1521
+ const projectRoot = createProjectRoot();
1522
+ try {
1523
+ const seedCore = new TaskforceCore({
1524
+ projectRoot,
1525
+ storagePath: path.join(projectRoot, '.taskforce')
1526
+ });
1527
+ seedCore.resolveAiProfile({ workspaceId: 'workspace-1', name: 'Codex', surfaceType: 'coding_tool' }, { trustedSeatScope: 'cloud_metered' });
1528
+ seedCore.close();
1529
+ const runtime = createTaskforceMcpServer({
1530
+ projectRoot,
1531
+ runtimeMode: 'local',
1532
+ workspaceId: 'workspace-1',
1533
+ logStartup: false,
1534
+ });
1535
+ const profilePayload = parseToolJson(await callToolWithRuntime(runtime, 'resolve_profile', {
1536
+ name: 'Codex',
1537
+ icon: 'Bot',
1538
+ surfaceType: 'coding_tool',
1539
+ }, {
1540
+ resolveProfile: false,
1541
+ }));
1542
+ const created = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
1543
+ title: 'Ambiguous comment attribution task',
1544
+ }));
1545
+ await callToolWithRuntime(runtime, 'add_comment', {
1546
+ id: created.referenceLabel,
1547
+ text: 'Bound actor should win.',
1548
+ author: 'Codex',
1549
+ });
1550
+ const task = parseToolJson(await callToolWithRuntime(runtime, 'get_task', {
1551
+ id: created.referenceLabel,
1552
+ }));
1553
+ const comment = task.comments.find((entry) => entry.text === 'Bound actor should win.');
1554
+ const event = task.taskEvents.find((entry) => entry.details?.commentId === comment?.id);
1555
+ expect(comment.author).toBe(profilePayload.profile.id);
1556
+ expect(comment.submittedAuthor).toBe(profilePayload.profile.id);
1557
+ expect(comment.actor.id).toBe(profilePayload.profile.id);
1558
+ expect(event.actor).toBe(profilePayload.profile.id);
1559
+ expect(event.actorType).toBe('ai');
1560
+ }
1561
+ finally {
1562
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1563
+ }
1564
+ });
1565
+ it('preserves explicit human comment overrides for MCP writes', async () => {
1566
+ const projectRoot = createProjectRoot();
1567
+ try {
1568
+ const runtime = createTaskforceMcpServer({
1569
+ projectRoot,
1570
+ runtimeMode: 'local',
1571
+ workspaceId: 'workspace-1',
1572
+ logStartup: false,
1573
+ });
1574
+ const created = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
1575
+ title: 'Human comment override task',
1576
+ }));
1577
+ await callToolWithRuntime(runtime, 'add_comment', {
1578
+ id: created.referenceLabel,
1579
+ text: 'Human-authored note.',
1580
+ author: 'user',
1581
+ });
1582
+ const task = parseToolJson(await callToolWithRuntime(runtime, 'get_task', {
1583
+ id: created.referenceLabel,
1584
+ }));
1585
+ const comment = task.comments.find((entry) => entry.text === 'Human-authored note.');
1586
+ const event = task.taskEvents.find((entry) => entry.details?.commentId === comment?.id);
1587
+ expect(comment.author).toBe('user');
1588
+ expect(comment.submittedAuthor).toBe('user');
1589
+ expect(comment.actor.id).toBe('user');
1590
+ expect(event.actor).toBe('user');
1591
+ expect(event.actorType).toBe('human');
1592
+ }
1593
+ finally {
1594
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1595
+ }
1596
+ });
1597
+ it('ignores mutable MCP comment author aliases when an AI profile is bound', async () => {
1598
+ const projectRoot = createProjectRoot();
1599
+ try {
1600
+ const runtime = createTaskforceMcpServer({
1601
+ projectRoot,
1602
+ runtimeMode: 'local',
1603
+ workspaceId: 'workspace-1',
1604
+ logStartup: false,
1605
+ });
1606
+ const profilePayload = parseToolJson(await callToolWithRuntime(runtime, 'resolve_profile', {
1607
+ name: 'Codex',
1608
+ icon: 'Bot',
1609
+ surfaceType: 'coding_tool',
1610
+ }, {
1611
+ resolveProfile: false,
1612
+ }));
1613
+ const created = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
1614
+ title: 'Strict comment attribution task',
1615
+ }));
1616
+ await callToolWithRuntime(runtime, 'add_comment', {
1617
+ id: created.referenceLabel,
1618
+ text: 'Alias should not win over the bound profile.',
1619
+ author: 'Claude',
1620
+ });
1621
+ await callToolWithRuntime(runtime, 'add_comment', {
1622
+ id: created.referenceLabel,
1623
+ text: 'Explicit human override stays human.',
1624
+ author: 'user',
1625
+ });
1626
+ const task = parseToolJson(await callToolWithRuntime(runtime, 'get_task', {
1627
+ id: created.referenceLabel,
1628
+ }));
1629
+ const aliasComment = task.comments.find((entry) => entry.text === 'Alias should not win over the bound profile.');
1630
+ const humanComment = task.comments.find((entry) => entry.text === 'Explicit human override stays human.');
1631
+ expect(aliasComment.author).toBe(profilePayload.profile.id);
1632
+ expect(aliasComment.submittedAuthor).toBe(profilePayload.profile.id);
1633
+ expect(aliasComment.actor.label).toBe('Codex');
1634
+ expect(humanComment.author).toBe('user');
1635
+ expect(humanComment.submittedAuthor).toBe('user');
1636
+ expect(humanComment.actor.kind).toBe('human');
1637
+ }
1638
+ finally {
1639
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1640
+ }
1641
+ });
1271
1642
  it('claims unassigned tasks and marks them in progress through start_task', async () => {
1272
1643
  const projectRoot = createProjectRoot();
1273
1644
  try {
@@ -1328,6 +1699,33 @@ describe('Taskforce MCP tool descriptions', () => {
1328
1699
  fs.rmSync(projectRoot, { recursive: true, force: true });
1329
1700
  }
1330
1701
  });
1702
+ it('allows review tasks to resume through start_task', async () => {
1703
+ const projectRoot = createProjectRoot();
1704
+ try {
1705
+ const core = new TaskforceCore({
1706
+ projectRoot,
1707
+ storagePath: path.join(projectRoot, '.taskforce')
1708
+ });
1709
+ const seeded = core.createTask({
1710
+ title: 'Start helper review resume',
1711
+ status: 'review',
1712
+ assignee: 'unassigned'
1713
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1714
+ core.close();
1715
+ const result = parseToolJson(await callTool(projectRoot, 'local', 'start_task', {
1716
+ id: seeded.referenceLabel
1717
+ }));
1718
+ expect(result.started).toBe(true);
1719
+ expect(result.claimed).toBe(true);
1720
+ expect(result.statusChanged).toBe(true);
1721
+ expect(result.previousStatus).toBe('review');
1722
+ expect(result.task.status).toBe('in-progress');
1723
+ expect(result.task.assignee).toMatch(/^ai-profile-/);
1724
+ }
1725
+ finally {
1726
+ fs.rmSync(projectRoot, { recursive: true, force: true });
1727
+ }
1728
+ });
1331
1729
  it('refuses to steal tasks already assigned to someone else through start_task', async () => {
1332
1730
  const projectRoot = createProjectRoot();
1333
1731
  try {
@@ -1351,24 +1749,34 @@ describe('Taskforce MCP tool descriptions', () => {
1351
1749
  fs.rmSync(projectRoot, { recursive: true, force: true });
1352
1750
  }
1353
1751
  });
1354
- it('rejects terminal tasks through start_task', async () => {
1752
+ it('rejects done and cancelled tasks through start_task', async () => {
1355
1753
  const projectRoot = createProjectRoot();
1356
1754
  try {
1357
1755
  const core = new TaskforceCore({
1358
1756
  projectRoot,
1359
1757
  storagePath: path.join(projectRoot, '.taskforce')
1360
1758
  });
1361
- const seeded = core.createTask({
1362
- title: 'Start helper terminal refusal',
1759
+ const doneTask = core.createTask({
1760
+ title: 'Start helper done refusal',
1363
1761
  status: 'done',
1364
1762
  assignee: 'unassigned'
1365
1763
  }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1764
+ const cancelledTask = core.createTask({
1765
+ title: 'Start helper cancelled refusal',
1766
+ status: 'cancelled',
1767
+ assignee: 'unassigned'
1768
+ }, { workspaceId: 'workspace-1', actorRef: 'owner-1' });
1366
1769
  core.close();
1367
- const result = await callTool(projectRoot, 'local', 'start_task', {
1368
- id: seeded.referenceLabel
1770
+ const doneResult = await callTool(projectRoot, 'local', 'start_task', {
1771
+ id: doneTask.referenceLabel
1369
1772
  });
1370
- expect(result.isError).toBe(true);
1371
- expect(result.content?.[0]?.text).toContain('must be reopened explicitly');
1773
+ const cancelledResult = await callTool(projectRoot, 'local', 'start_task', {
1774
+ id: cancelledTask.referenceLabel
1775
+ });
1776
+ expect(doneResult.isError).toBe(true);
1777
+ expect(doneResult.content?.[0]?.text).toContain('must be reopened explicitly');
1778
+ expect(cancelledResult.isError).toBe(true);
1779
+ expect(cancelledResult.content?.[0]?.text).toContain('must be reopened explicitly');
1372
1780
  }
1373
1781
  finally {
1374
1782
  fs.rmSync(projectRoot, { recursive: true, force: true });
@@ -1605,6 +2013,7 @@ describe('Taskforce MCP tool descriptions', () => {
1605
2013
  isCurrentProfile: true
1606
2014
  })
1607
2015
  ]));
2016
+ expect(JSON.stringify(payload.assignees)).not.toContain(resolved.profile.profileToken);
1608
2017
  }
1609
2018
  finally {
1610
2019
  fs.rmSync(projectRoot, { recursive: true, force: true });
@@ -1716,6 +2125,50 @@ describe('Taskforce MCP tool descriptions', () => {
1716
2125
  fs.rmSync(projectRoot, { recursive: true, force: true });
1717
2126
  }
1718
2127
  });
2128
+ it('auto-binds a configured cloud AI name to an existing profile when the token is stale', async () => {
2129
+ const projectRoot = createProjectRoot();
2130
+ try {
2131
+ const seedCore = new TaskforceCore({
2132
+ projectRoot,
2133
+ storagePath: path.join(projectRoot, '.taskforce')
2134
+ });
2135
+ const existing = seedCore.resolveAiProfile({
2136
+ workspaceId: 'workspace-1',
2137
+ name: 'Felix Leiter',
2138
+ provider: 'OpenAI',
2139
+ model: 'openai-codex/gpt-5.4',
2140
+ surfaceType: 'agent',
2141
+ }, { trustedSeatScope: 'cloud_metered' });
2142
+ seedCore.close();
2143
+ const payload = parseToolJson(await callTool(projectRoot, 'cloud', 'get_current_profile', {}, {
2144
+ workspaceId: 'workspace-1',
2145
+ resolveProfile: false,
2146
+ authAiProfileToken: 'tfp_stale_felix_leiter_token',
2147
+ authAiProfileName: 'Felix Leiter',
2148
+ authAiProfileProvider: 'OpenClaw',
2149
+ authAiProfileModel: 'gpt-5.4',
2150
+ authAiProfileSurfaceType: 'agent'
2151
+ }));
2152
+ expect(payload.identityBound).toBe(true);
2153
+ expect(payload.bindingSource).toBe('connection_bootstrap');
2154
+ expect(payload.profile).toEqual(expect.objectContaining({
2155
+ id: existing.profile.id,
2156
+ name: 'Felix Leiter',
2157
+ provider: 'OpenClaw',
2158
+ model: 'gpt-5.4',
2159
+ seatScope: 'cloud_metered',
2160
+ }));
2161
+ const verifyCore = new TaskforceCore({
2162
+ projectRoot,
2163
+ storagePath: path.join(projectRoot, '.taskforce')
2164
+ });
2165
+ expect(verifyCore.listAiProfiles('workspace-1')).toHaveLength(1);
2166
+ verifyCore.close();
2167
+ }
2168
+ finally {
2169
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2170
+ }
2171
+ });
1719
2172
  it('searches tasks by T-XXX reference label via reference_number', async () => {
1720
2173
  const projectRoot = createProjectRoot();
1721
2174
  try {
@@ -1903,6 +2356,212 @@ describe('Taskforce MCP tool descriptions', () => {
1903
2356
  fs.rmSync(projectRoot, { recursive: true, force: true });
1904
2357
  }
1905
2358
  });
2359
+ it('keeps the MCP runtime usable after linking a task to a workstream', async () => {
2360
+ const projectRoot = createProjectRoot();
2361
+ const runtime = createRuntime(projectRoot, 'local');
2362
+ try {
2363
+ const createdWorkstream = parseToolJson(await callToolWithRuntime(runtime, 'create_workstream', {
2364
+ title: 'Transport-safe workstream'
2365
+ }));
2366
+ const createdTask = parseToolJson(await callToolWithRuntime(runtime, 'create_task', {
2367
+ title: 'Transport-safe task'
2368
+ }));
2369
+ const taskLinked = parseToolJson(await callToolWithRuntime(runtime, 'set_task_workstream', {
2370
+ taskId: createdTask.referenceLabel,
2371
+ workstreamId: createdWorkstream.workstream.referenceLabel
2372
+ }));
2373
+ expect(taskLinked.workstreamId).toBe(createdWorkstream.workstream.id);
2374
+ const workstreamRead = parseToolJson(await callToolWithRuntime(runtime, 'get_workstream', {
2375
+ id: createdWorkstream.workstream.referenceLabel
2376
+ }));
2377
+ expect(workstreamRead.workstream.id).toBe(createdWorkstream.workstream.id);
2378
+ expect(workstreamRead.tasks).toEqual(expect.arrayContaining([
2379
+ expect.objectContaining({
2380
+ id: createdTask.id,
2381
+ workstreamId: createdWorkstream.workstream.id
2382
+ })
2383
+ ]));
2384
+ }
2385
+ finally {
2386
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2387
+ }
2388
+ });
2389
+ it('retries transient planning read failures before returning get_workstream results', async () => {
2390
+ const projectRoot = createProjectRoot();
2391
+ const runtime = createRuntime(projectRoot, 'cloud');
2392
+ try {
2393
+ const workstream = runtime.core.createWorkstream({ title: 'Retryable read workstream' }, 'workspace-1');
2394
+ const originalListWorkstreams = runtime.core.listWorkstreams.bind(runtime.core);
2395
+ let attempts = 0;
2396
+ const listWorkstreamsSpy = vi.spyOn(runtime.core, 'listWorkstreams').mockImplementation((workspaceId) => {
2397
+ attempts += 1;
2398
+ if (attempts === 1) {
2399
+ throw new Error('Postgres worker timed out while reading workstreams');
2400
+ }
2401
+ return originalListWorkstreams(workspaceId);
2402
+ });
2403
+ const workstreamRead = parseToolJson(await callToolWithRuntime(runtime, 'get_workstream', {
2404
+ id: workstream.referenceLabel
2405
+ }));
2406
+ expect(attempts).toBe(2);
2407
+ expect(workstreamRead.workstream.id).toBe(workstream.id);
2408
+ listWorkstreamsSpy.mockRestore();
2409
+ }
2410
+ finally {
2411
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2412
+ }
2413
+ });
2414
+ it('returns the created workstream when post-write workstream hydration times out', async () => {
2415
+ const projectRoot = createProjectRoot();
2416
+ const originalGetWorkstream = TaskforceCore.prototype.getWorkstream;
2417
+ let injected = false;
2418
+ const getWorkstreamSpy = vi.spyOn(TaskforceCore.prototype, 'getWorkstream').mockImplementation(function (id, workspaceId) {
2419
+ const result = originalGetWorkstream.call(this, id, workspaceId);
2420
+ if (!injected && result?.title === 'Hydration fallback workstream') {
2421
+ injected = true;
2422
+ throw new Error('Postgres worker timed out while reading workstreams');
2423
+ }
2424
+ return result;
2425
+ });
2426
+ try {
2427
+ const created = parseToolJson(await callTool(projectRoot, 'local', 'create_workstream', {
2428
+ title: 'Hydration fallback workstream'
2429
+ }));
2430
+ expect(created.workstream.referenceLabel).toMatch(/^WS-/);
2431
+ expect(created.workstream.title).toBe('Hydration fallback workstream');
2432
+ }
2433
+ finally {
2434
+ getWorkstreamSpy.mockRestore();
2435
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2436
+ }
2437
+ });
2438
+ it('returns the created workstream when post-write response hydration times out', async () => {
2439
+ const projectRoot = createProjectRoot();
2440
+ const originalListTasks = TaskforceCore.prototype.listTasks;
2441
+ let injected = false;
2442
+ const listTasksSpy = vi.spyOn(TaskforceCore.prototype, 'listTasks').mockImplementation(function (workspaceId) {
2443
+ if (!injected) {
2444
+ injected = true;
2445
+ throw new Error('Postgres worker timed out while hydrating workstream response');
2446
+ }
2447
+ return originalListTasks.call(this, workspaceId);
2448
+ });
2449
+ try {
2450
+ const created = parseToolJson(await callTool(projectRoot, 'local', 'create_workstream', {
2451
+ title: 'Response hydration fallback workstream'
2452
+ }));
2453
+ expect(created.workstream.referenceLabel).toMatch(/^WS-/);
2454
+ expect(created.workstream.title).toBe('Response hydration fallback workstream');
2455
+ expect(created.responseHydration).toEqual(expect.objectContaining({
2456
+ fallback: true,
2457
+ code: 'MCP_BACKEND_TIMEOUT',
2458
+ nextTool: 'get_workstream'
2459
+ }));
2460
+ }
2461
+ finally {
2462
+ listTasksSpy.mockRestore();
2463
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2464
+ }
2465
+ });
2466
+ it('retries transient get_initiative read failures before returning results', async () => {
2467
+ const projectRoot = createProjectRoot();
2468
+ const runtime = createRuntime(projectRoot, 'cloud');
2469
+ try {
2470
+ const initiative = runtime.core.createInitiative({ title: 'Retryable initiative read' }, 'workspace-1');
2471
+ const originalListInitiatives = runtime.core.listInitiatives.bind(runtime.core);
2472
+ let attempts = 0;
2473
+ const listInitiativesSpy = vi.spyOn(runtime.core, 'listInitiatives').mockImplementation((workspaceId) => {
2474
+ attempts += 1;
2475
+ if (attempts === 1) {
2476
+ throw new Error('Postgres worker timed out while reading initiatives');
2477
+ }
2478
+ return originalListInitiatives(workspaceId);
2479
+ });
2480
+ const initiativeRead = parseToolJson(await callToolWithRuntime(runtime, 'get_initiative', {
2481
+ id: initiative.referenceLabel
2482
+ }));
2483
+ expect(attempts).toBe(2);
2484
+ expect(initiativeRead.initiative.id).toBe(initiative.id);
2485
+ listInitiativesSpy.mockRestore();
2486
+ }
2487
+ finally {
2488
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2489
+ }
2490
+ });
2491
+ it('keeps save_initiative_attachment successful when post-write initiative hydration times out', async () => {
2492
+ const projectRoot = createProjectRoot();
2493
+ try {
2494
+ const createdInitiative = parseToolJson(await callTool(projectRoot, 'local', 'create_initiative', {
2495
+ title: 'Hydration fallback initiative'
2496
+ }));
2497
+ const originalGetInitiative = TaskforceCore.prototype.getInitiative;
2498
+ let injected = false;
2499
+ const getInitiativeSpy = vi.spyOn(TaskforceCore.prototype, 'getInitiative').mockImplementation(function (id, workspaceId) {
2500
+ const result = originalGetInitiative.call(this, id, workspaceId);
2501
+ if (!injected
2502
+ && result
2503
+ && result.id === createdInitiative.initiative.id
2504
+ && Array.isArray(result.attachments)
2505
+ && result.attachments.some((item) => item && typeof item === 'object' && item.displayName === 'hydration-note.md')) {
2506
+ injected = true;
2507
+ throw new Error('Postgres worker timed out while reading initiatives');
2508
+ }
2509
+ return result;
2510
+ });
2511
+ try {
2512
+ const saveResult = await callTool(projectRoot, 'local', 'save_initiative_attachment', {
2513
+ id: createdInitiative.initiative.referenceLabel,
2514
+ filename: 'hydration-note.md',
2515
+ content: '# Hydration fallback\n'
2516
+ });
2517
+ expect(String(saveResult.content?.[0]?.text || '')).toContain('Initiative attachment saved and attached');
2518
+ }
2519
+ finally {
2520
+ getInitiativeSpy.mockRestore();
2521
+ }
2522
+ const attachments = parseToolJson(await callTool(projectRoot, 'local', 'get_initiative_attachments', {
2523
+ id: createdInitiative.initiative.referenceLabel
2524
+ }));
2525
+ expect(attachments.attachments).toEqual(expect.arrayContaining([
2526
+ expect.objectContaining({
2527
+ displayName: 'hydration-note.md'
2528
+ })
2529
+ ]));
2530
+ }
2531
+ finally {
2532
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2533
+ }
2534
+ });
2535
+ it('returns the archived workstream when the post-archive refresh times out', async () => {
2536
+ const projectRoot = createProjectRoot();
2537
+ try {
2538
+ const created = parseToolJson(await callTool(projectRoot, 'local', 'create_workstream', {
2539
+ title: 'Archivable hydration fallback workstream'
2540
+ }));
2541
+ const originalGetWorkstream = TaskforceCore.prototype.getWorkstream;
2542
+ let injected = false;
2543
+ const getWorkstreamSpy = vi.spyOn(TaskforceCore.prototype, 'getWorkstream').mockImplementation(function (id, workspaceId) {
2544
+ const result = originalGetWorkstream.call(this, id, workspaceId);
2545
+ if (!injected && result && result.id === created.workstream.id && result.isArchived === true) {
2546
+ injected = true;
2547
+ throw new Error('Postgres worker timed out while reading workstreams');
2548
+ }
2549
+ return result;
2550
+ });
2551
+ try {
2552
+ const archived = parseToolJson(await callTool(projectRoot, 'local', 'archive_workstream', {
2553
+ id: created.workstream.referenceLabel
2554
+ }));
2555
+ expect(archived.workstream.isArchived).toBe(true);
2556
+ }
2557
+ finally {
2558
+ getWorkstreamSpy.mockRestore();
2559
+ }
2560
+ }
2561
+ finally {
2562
+ fs.rmSync(projectRoot, { recursive: true, force: true });
2563
+ }
2564
+ });
1906
2565
  it('builds workstream listings from one historical task snapshot and returns task summaries', async () => {
1907
2566
  const projectRoot = createProjectRoot();
1908
2567
  const core = new TaskforceCore({
@@ -2232,6 +2891,222 @@ describe('Taskforce MCP tool descriptions', () => {
2232
2891
  fs.rmSync(projectRoot, { recursive: true, force: true });
2233
2892
  }
2234
2893
  });
2894
+ it('supports staged remote image uploads for save_task_attachment in cloud runtime', async () => {
2895
+ const projectRoot = createProjectRoot();
2896
+ const previousEnv = {
2897
+ provider: process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER,
2898
+ accountId: process.env.R2_ACCOUNT_ID,
2899
+ bucket: process.env.R2_BUCKET,
2900
+ accessKeyId: process.env.R2_ACCESS_KEY_ID,
2901
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
2902
+ endpoint: process.env.R2_ENDPOINT,
2903
+ uploadTtl: process.env.R2_SIGNED_UPLOAD_TTL_SECONDS,
2904
+ };
2905
+ try {
2906
+ process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER = 'r2';
2907
+ process.env.R2_ACCOUNT_ID = 'acct-1';
2908
+ process.env.R2_BUCKET = 'taskforce-test';
2909
+ process.env.R2_ACCESS_KEY_ID = 'access-key';
2910
+ process.env.R2_SECRET_ACCESS_KEY = 'secret-key';
2911
+ process.env.R2_ENDPOINT = 'https://example.r2.invalid';
2912
+ process.env.R2_SIGNED_UPLOAD_TTL_SECONDS = '600';
2913
+ const core = new TaskforceCore({
2914
+ projectRoot,
2915
+ storagePath: path.join(projectRoot, '.taskforce')
2916
+ });
2917
+ const task = core.createTask({ title: 'Cloud staged image attachment test' }, { workspaceId: 'workspace-1' });
2918
+ core.close();
2919
+ const fetchMock = vi.fn(async (input, init) => {
2920
+ const method = String(init?.method || 'GET').toUpperCase();
2921
+ if (method === 'GET') {
2922
+ return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
2923
+ status: 200,
2924
+ headers: { 'content-type': 'image/png' }
2925
+ });
2926
+ }
2927
+ return new Response('', { status: 200 });
2928
+ });
2929
+ vi.stubGlobal('fetch', fetchMock);
2930
+ const staged = parseToolJson(await callTool(projectRoot, 'cloud', 'save_task_attachment', {
2931
+ id: task.referenceLabel,
2932
+ filename: 'proof.png',
2933
+ mimeType: 'image/png',
2934
+ sizeBytes: 4
2935
+ }));
2936
+ expect(staged.mode).toBe('staged_upload');
2937
+ expect(staged.status).toBe('upload_required');
2938
+ expect(staged.filename).toBe('proof.png');
2939
+ expect(staged.upload.method).toBe('PUT');
2940
+ expect(staged.upload.headers['Content-Type']).toBe('image/png');
2941
+ expect(staged.upload.uploadUrl).toContain('X-Amz-Signature=');
2942
+ expect(staged.upload.uploadedPath).toContain('workspaces/workspace-1/assets/images/');
2943
+ expect(staged.uploadDraftId).toContain('task-attachment-draft-');
2944
+ expect(staged.finalize.tool).toBe('save_task_attachment');
2945
+ expect(staged.finalize.arguments.uploadDraftId).toBe(staged.uploadDraftId);
2946
+ expect(staged.finalize.arguments.uploadedPath).toBe(staged.upload.uploadedPath);
2947
+ await callTool(projectRoot, 'cloud', 'save_task_attachment', {
2948
+ id: task.referenceLabel,
2949
+ filename: 'proof.png',
2950
+ mimeType: 'image/png',
2951
+ uploadDraftId: staged.uploadDraftId,
2952
+ uploadedPath: staged.upload.uploadedPath
2953
+ });
2954
+ const listed = parseToolJson(await callTool(projectRoot, 'cloud', 'get_task_attachments', { id: task.referenceLabel }));
2955
+ expect(listed.attachments).toHaveLength(1);
2956
+ expect(listed.attachments[0]).toMatchObject({
2957
+ kind: 'image',
2958
+ mimeType: 'image/png',
2959
+ originalFilename: 'proof.png',
2960
+ readableAsText: false,
2961
+ });
2962
+ const verifyCore = new TaskforceCore({
2963
+ projectRoot,
2964
+ storagePath: path.join(projectRoot, '.taskforce')
2965
+ });
2966
+ try {
2967
+ const updated = verifyCore.getTask(task.id, 'workspace-1');
2968
+ const store = new WorkspaceAssetStore(verifyCore.getDatabaseAdapter(), verifyCore.getTenantId());
2969
+ const asset = store.get(String(listed.attachments[0]?.assetId || ''), 'workspace-1');
2970
+ expect(updated?.attachments).toHaveLength(1);
2971
+ expect(updated?.attachments?.[0]).toMatchObject({
2972
+ path: expect.stringMatching(/^\/api\/taskforce\/context\//),
2973
+ originalFilename: 'proof.png',
2974
+ });
2975
+ expect(asset).toEqual(expect.objectContaining({
2976
+ kind: 'image',
2977
+ mimeType: 'image/png',
2978
+ storageProvider: 'r2',
2979
+ storageKey: staged.upload.uploadedPath,
2980
+ }));
2981
+ }
2982
+ finally {
2983
+ verifyCore.close();
2984
+ }
2985
+ }
2986
+ finally {
2987
+ vi.unstubAllGlobals();
2988
+ if (previousEnv.provider === undefined)
2989
+ delete process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER;
2990
+ else
2991
+ process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER = previousEnv.provider;
2992
+ if (previousEnv.accountId === undefined)
2993
+ delete process.env.R2_ACCOUNT_ID;
2994
+ else
2995
+ process.env.R2_ACCOUNT_ID = previousEnv.accountId;
2996
+ if (previousEnv.bucket === undefined)
2997
+ delete process.env.R2_BUCKET;
2998
+ else
2999
+ process.env.R2_BUCKET = previousEnv.bucket;
3000
+ if (previousEnv.accessKeyId === undefined)
3001
+ delete process.env.R2_ACCESS_KEY_ID;
3002
+ else
3003
+ process.env.R2_ACCESS_KEY_ID = previousEnv.accessKeyId;
3004
+ if (previousEnv.secretAccessKey === undefined)
3005
+ delete process.env.R2_SECRET_ACCESS_KEY;
3006
+ else
3007
+ process.env.R2_SECRET_ACCESS_KEY = previousEnv.secretAccessKey;
3008
+ if (previousEnv.endpoint === undefined)
3009
+ delete process.env.R2_ENDPOINT;
3010
+ else
3011
+ process.env.R2_ENDPOINT = previousEnv.endpoint;
3012
+ if (previousEnv.uploadTtl === undefined)
3013
+ delete process.env.R2_SIGNED_UPLOAD_TTL_SECONDS;
3014
+ else
3015
+ process.env.R2_SIGNED_UPLOAD_TTL_SECONDS = previousEnv.uploadTtl;
3016
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3017
+ }
3018
+ });
3019
+ it('rejects forged staged upload finalization for save_task_attachment in cloud runtime', async () => {
3020
+ const projectRoot = createProjectRoot();
3021
+ const previousEnv = {
3022
+ provider: process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER,
3023
+ accountId: process.env.R2_ACCOUNT_ID,
3024
+ bucket: process.env.R2_BUCKET,
3025
+ accessKeyId: process.env.R2_ACCESS_KEY_ID,
3026
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
3027
+ endpoint: process.env.R2_ENDPOINT,
3028
+ uploadTtl: process.env.R2_SIGNED_UPLOAD_TTL_SECONDS,
3029
+ };
3030
+ try {
3031
+ process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER = 'r2';
3032
+ process.env.R2_ACCOUNT_ID = 'acct-1';
3033
+ process.env.R2_BUCKET = 'taskforce-test';
3034
+ process.env.R2_ACCESS_KEY_ID = 'access-key';
3035
+ process.env.R2_SECRET_ACCESS_KEY = 'secret-key';
3036
+ process.env.R2_ENDPOINT = 'https://example.r2.invalid';
3037
+ process.env.R2_SIGNED_UPLOAD_TTL_SECONDS = '600';
3038
+ const core = new TaskforceCore({
3039
+ projectRoot,
3040
+ storagePath: path.join(projectRoot, '.taskforce')
3041
+ });
3042
+ const task = core.createTask({ title: 'Cloud staged image attachment reject test' }, { workspaceId: 'workspace-1' });
3043
+ core.close();
3044
+ const fetchMock = vi.fn(async (input, init) => {
3045
+ const method = String(init?.method || 'GET').toUpperCase();
3046
+ if (method === 'GET') {
3047
+ return new Response(Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
3048
+ status: 200,
3049
+ headers: { 'content-type': 'image/png' }
3050
+ });
3051
+ }
3052
+ return new Response('', { status: 200 });
3053
+ });
3054
+ vi.stubGlobal('fetch', fetchMock);
3055
+ const staged = parseToolJson(await callTool(projectRoot, 'cloud', 'save_task_attachment', {
3056
+ id: task.referenceLabel,
3057
+ filename: 'proof.png',
3058
+ mimeType: 'image/png',
3059
+ sizeBytes: 4
3060
+ }));
3061
+ const bareResult = await callTool(projectRoot, 'cloud', 'save_task_attachment', {
3062
+ id: task.referenceLabel,
3063
+ filename: 'proof.png',
3064
+ mimeType: 'image/png',
3065
+ uploadedPath: staged.upload.uploadedPath
3066
+ });
3067
+ expect(bareResult.content?.[0]?.text).toContain('uploadedPath finalization requires uploadDraftId');
3068
+ const forgedResult = await callTool(projectRoot, 'cloud', 'save_task_attachment', {
3069
+ id: task.referenceLabel,
3070
+ filename: 'proof.png',
3071
+ mimeType: 'image/png',
3072
+ uploadDraftId: staged.uploadDraftId,
3073
+ uploadedPath: 'workspaces/workspace-1/assets/images/asset-deadbeefdeadbeefdeadbeefdeadbeef-proof.png'
3074
+ });
3075
+ expect(forgedResult.content?.[0]?.text).toContain('uploadedPath does not match the staged upload draft');
3076
+ }
3077
+ finally {
3078
+ vi.unstubAllGlobals();
3079
+ if (previousEnv.provider === undefined)
3080
+ delete process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER;
3081
+ else
3082
+ process.env.TASKFORCE_OBJECT_STORAGE_PROVIDER = previousEnv.provider;
3083
+ if (previousEnv.accountId === undefined)
3084
+ delete process.env.R2_ACCOUNT_ID;
3085
+ else
3086
+ process.env.R2_ACCOUNT_ID = previousEnv.accountId;
3087
+ if (previousEnv.bucket === undefined)
3088
+ delete process.env.R2_BUCKET;
3089
+ else
3090
+ process.env.R2_BUCKET = previousEnv.bucket;
3091
+ if (previousEnv.accessKeyId === undefined)
3092
+ delete process.env.R2_ACCESS_KEY_ID;
3093
+ else
3094
+ process.env.R2_ACCESS_KEY_ID = previousEnv.accessKeyId;
3095
+ if (previousEnv.secretAccessKey === undefined)
3096
+ delete process.env.R2_SECRET_ACCESS_KEY;
3097
+ else
3098
+ process.env.R2_SECRET_ACCESS_KEY = previousEnv.secretAccessKey;
3099
+ if (previousEnv.endpoint === undefined)
3100
+ delete process.env.R2_ENDPOINT;
3101
+ else
3102
+ process.env.R2_ENDPOINT = previousEnv.endpoint;
3103
+ if (previousEnv.uploadTtl === undefined)
3104
+ delete process.env.R2_SIGNED_UPLOAD_TTL_SECONDS;
3105
+ else
3106
+ process.env.R2_SIGNED_UPLOAD_TTL_SECONDS = previousEnv.uploadTtl;
3107
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3108
+ }
3109
+ });
2235
3110
  it('allows generic markdown review attachments without requiring the legacy review template', async () => {
2236
3111
  const projectRoot = createProjectRoot();
2237
3112
  try {
@@ -2841,6 +3716,146 @@ describe('Taskforce MCP tool descriptions', () => {
2841
3716
  fs.rmSync(projectRoot, { recursive: true, force: true });
2842
3717
  }
2843
3718
  });
3719
+ it('saves, lists, and reads workstream attachments by workstream reference', async () => {
3720
+ const projectRoot = createProjectRoot();
3721
+ try {
3722
+ const core = new TaskforceCore({
3723
+ projectRoot,
3724
+ storagePath: path.join(projectRoot, '.taskforce')
3725
+ });
3726
+ const workstream = core.createWorkstream({ title: 'Attachment workstream' }, 'workspace-1');
3727
+ core.close();
3728
+ await callTool(projectRoot, 'cloud', 'save_workstream_attachment', {
3729
+ id: workstream.referenceLabel,
3730
+ filename: 'findings.md',
3731
+ content: '# Findings\nWorkstream-specific notes',
3732
+ caption: 'Security findings'
3733
+ });
3734
+ const listed = parseToolJson(await callTool(projectRoot, 'cloud', 'get_workstream_attachments', { id: workstream.referenceLabel }));
3735
+ const readBack = parseToolJson(await callTool(projectRoot, 'cloud', 'read_workstream_attachment', {
3736
+ workstreamId: workstream.referenceLabel,
3737
+ attachmentId: listed.attachments[0]?.attachmentId
3738
+ }));
3739
+ const workstreamPayload = parseToolJson(await callTool(projectRoot, 'cloud', 'get_workstream', { id: workstream.referenceLabel }));
3740
+ const taskToolResult = await callTool(projectRoot, 'cloud', 'save_task_attachment', {
3741
+ id: workstream.referenceLabel,
3742
+ filename: 'wrong-tool.md',
3743
+ content: '# Wrong tool'
3744
+ });
3745
+ const verifyCore = new TaskforceCore({
3746
+ projectRoot,
3747
+ storagePath: path.join(projectRoot, '.taskforce')
3748
+ });
3749
+ try {
3750
+ const store = new WorkspaceAssetStore(verifyCore.getDatabaseAdapter(), verifyCore.getTenantId());
3751
+ const links = store.listLinksForAsset(String(listed.attachments[0]?.assetId || ''), 'workspace-1');
3752
+ expect(listed.workstreamId).toBe(workstream.id);
3753
+ expect(listed.referenceLabel).toBe(workstream.referenceLabel);
3754
+ expect(listed.attachments).toHaveLength(1);
3755
+ expect(listed.attachments[0]?.kind).toBe('document');
3756
+ expect(listed.attachments[0]?.readableAsText).toBe(true);
3757
+ expect(readBack.workstreamId).toBe(workstream.id);
3758
+ expect(readBack.content).toContain('Workstream-specific notes');
3759
+ expect(workstreamPayload.workstream.attachments).toHaveLength(1);
3760
+ expect(workstreamPayload.workstream.attachments[0]).toMatchObject({
3761
+ assetId: listed.attachments[0]?.assetId,
3762
+ referenceLabel: listed.attachments[0]?.referenceLabel,
3763
+ originalFilename: 'findings.md',
3764
+ });
3765
+ expect(links).toEqual([
3766
+ expect.objectContaining({
3767
+ targetType: 'workstream',
3768
+ targetId: workstream.id,
3769
+ role: 'attachment'
3770
+ })
3771
+ ]);
3772
+ expect(taskToolResult.content?.[0]?.text).toContain(`Task ${workstream.referenceLabel} not found`);
3773
+ }
3774
+ finally {
3775
+ verifyCore.close();
3776
+ }
3777
+ }
3778
+ finally {
3779
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3780
+ }
3781
+ });
3782
+ it('saves and deletes initiative attachments by initiative reference', async () => {
3783
+ const projectRoot = createProjectRoot();
3784
+ try {
3785
+ const core = new TaskforceCore({
3786
+ projectRoot,
3787
+ storagePath: path.join(projectRoot, '.taskforce')
3788
+ });
3789
+ const initiative = core.createInitiative({ title: 'Attachment initiative' }, 'workspace-1');
3790
+ core.close();
3791
+ await callTool(projectRoot, 'cloud', 'save_initiative_attachment', {
3792
+ id: initiative.referenceLabel,
3793
+ filename: 'initiative-note.txt',
3794
+ content: 'initiative attachment body'
3795
+ });
3796
+ const listed = parseToolJson(await callTool(projectRoot, 'cloud', 'get_initiative_attachments', { id: initiative.referenceLabel }));
3797
+ const readBack = parseToolJson(await callTool(projectRoot, 'cloud', 'read_initiative_attachment', {
3798
+ initiativeId: initiative.referenceLabel,
3799
+ attachmentId: listed.attachments[0]?.attachmentId
3800
+ }));
3801
+ const deleted = parseToolJson(await callTool(projectRoot, 'cloud', 'delete_initiative_attachment', {
3802
+ initiativeId: initiative.referenceLabel,
3803
+ attachmentId: listed.attachments[0]?.attachmentId
3804
+ }));
3805
+ const afterDelete = parseToolJson(await callTool(projectRoot, 'cloud', 'get_initiative_attachments', { id: initiative.referenceLabel }));
3806
+ const verifyCore = new TaskforceCore({
3807
+ projectRoot,
3808
+ storagePath: path.join(projectRoot, '.taskforce')
3809
+ });
3810
+ try {
3811
+ const store = new WorkspaceAssetStore(verifyCore.getDatabaseAdapter(), verifyCore.getTenantId());
3812
+ const asset = store.get(String(listed.attachments[0]?.assetId || ''), 'workspace-1');
3813
+ expect(listed.initiativeId).toBe(initiative.id);
3814
+ expect(listed.attachments).toHaveLength(1);
3815
+ expect(listed.attachments[0]?.mimeType).toBe('text/plain');
3816
+ expect(readBack.content).toContain('initiative attachment body');
3817
+ expect(deleted.success).toBe(true);
3818
+ expect(deleted.canonicalAssetDeleted).toBe(true);
3819
+ expect(afterDelete.attachments).toEqual([]);
3820
+ expect(asset?.deletedAt).toBeTruthy();
3821
+ }
3822
+ finally {
3823
+ verifyCore.close();
3824
+ }
3825
+ }
3826
+ finally {
3827
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3828
+ }
3829
+ });
3830
+ it('persists planning attachment descriptor payloads through update tools', async () => {
3831
+ const projectRoot = createProjectRoot();
3832
+ try {
3833
+ const core = new TaskforceCore({
3834
+ projectRoot,
3835
+ storagePath: path.join(projectRoot, '.taskforce')
3836
+ });
3837
+ const initiative = core.createInitiative({ title: 'Descriptor initiative' }, 'workspace-1');
3838
+ const workstream = core.createWorkstream({ title: 'Descriptor workstream' }, 'workspace-1');
3839
+ core.close();
3840
+ const initiativeAttachment = { path: 'https://example.com/initiative-register.md', caption: 'Initiative register' };
3841
+ const workstreamAttachment = { path: 'https://example.com/workstream-register.md', caption: 'Workstream register' };
3842
+ await callTool(projectRoot, 'cloud', 'update_initiative', {
3843
+ id: initiative.referenceLabel,
3844
+ attachments: [initiativeAttachment]
3845
+ });
3846
+ await callTool(projectRoot, 'cloud', 'update_workstream', {
3847
+ id: workstream.referenceLabel,
3848
+ attachments: [workstreamAttachment]
3849
+ });
3850
+ const initiativePayload = parseToolJson(await callTool(projectRoot, 'cloud', 'get_initiative', { id: initiative.referenceLabel }));
3851
+ const workstreamPayload = parseToolJson(await callTool(projectRoot, 'cloud', 'get_workstream', { id: workstream.referenceLabel }));
3852
+ expect(initiativePayload.initiative.attachments).toEqual([expect.objectContaining(initiativeAttachment)]);
3853
+ expect(workstreamPayload.workstream.attachments).toEqual([expect.objectContaining(workstreamAttachment)]);
3854
+ }
3855
+ finally {
3856
+ fs.rmSync(projectRoot, { recursive: true, force: true });
3857
+ }
3858
+ });
2844
3859
  it('accepts task references for write and batch update tools', async () => {
2845
3860
  const projectRoot = createProjectRoot();
2846
3861
  try {
@@ -3010,6 +4025,7 @@ describe('Taskforce MCP tool descriptions', () => {
3010
4025
  label: 'Codex',
3011
4026
  username: resolved.profile.username
3012
4027
  }));
4028
+ expect(created.createdByActor.meta).not.toHaveProperty('profileToken');
3013
4029
  expect(created.taskEvents).toEqual(expect.arrayContaining([
3014
4030
  expect.objectContaining({
3015
4031
  action: 'task-created',
@@ -3022,6 +4038,7 @@ describe('Taskforce MCP tool descriptions', () => {
3022
4038
  })
3023
4039
  })
3024
4040
  ]));
4041
+ expect(created.taskEvents[0].actorProfile.meta).not.toHaveProperty('profileToken');
3025
4042
  }
3026
4043
  finally {
3027
4044
  fs.rmSync(projectRoot, { recursive: true, force: true });
@@ -3495,6 +4512,61 @@ describe('Taskforce MCP annotated attachment tools', () => {
3495
4512
  fs.rmSync(projectRoot, { recursive: true, force: true });
3496
4513
  }
3497
4514
  });
4515
+ it('accepts exposed document and image helper argument names', async () => {
4516
+ const projectRoot = createProjectRoot();
4517
+ try {
4518
+ const { task, session } = seedAnnotatedAttachmentFixture(projectRoot);
4519
+ const { asset } = seedDocumentReviewFixture(projectRoot);
4520
+ const taskAttachments = parseToolJson(await callTool(projectRoot, 'cloud', 'get_task_attachments', {
4521
+ taskId: task.referenceLabel,
4522
+ }, { workspaceId: 'workspace-1' }));
4523
+ expect(taskAttachments.taskId).toBe(task.id);
4524
+ expect(taskAttachments.referenceLabel).toBe(task.referenceLabel);
4525
+ const document = parseToolJson(await callTool(projectRoot, 'cloud', 'get_document', {
4526
+ id: 'D-123',
4527
+ }, { workspaceId: 'workspace-1' }));
4528
+ expect(document.document).toMatchObject({
4529
+ assetId: asset.assetId,
4530
+ referenceLabel: 'D-123',
4531
+ });
4532
+ const review = parseToolJson(await callTool(projectRoot, 'cloud', 'review_document', {
4533
+ documentId: 'D-123',
4534
+ prompt: 'Capture this as a first-pass review note.',
4535
+ }, { workspaceId: 'workspace-1' }));
4536
+ expect(review.document.assetId).toBe(asset.assetId);
4537
+ expect(review.comments).toEqual(expect.arrayContaining([
4538
+ expect.objectContaining({
4539
+ body: 'Capture this as a first-pass review note.',
4540
+ }),
4541
+ ]));
4542
+ expect(review.session.comments.some((comment) => comment.body === 'Capture this as a first-pass review note.')).toBe(true);
4543
+ const image = parseToolJson(await callTool(projectRoot, 'cloud', 'get_image', {
4544
+ id: 'I-1',
4545
+ }, { workspaceId: 'workspace-1' }));
4546
+ expect(image.image).toMatchObject({
4547
+ assetId: 'asset-annotated-1',
4548
+ referenceLabel: 'I-1',
4549
+ });
4550
+ const marker = parseToolJson(await callTool(projectRoot, 'cloud', 'add_annotated_attachment_marker', {
4551
+ sessionId: session.id,
4552
+ type: 'note',
4553
+ text: 'Schema names should work end to end.',
4554
+ x: 0.4,
4555
+ y: 0.5,
4556
+ }, { workspaceId: 'workspace-1' }));
4557
+ expect(marker.marker).toMatchObject({
4558
+ kind: 'text-note',
4559
+ instruction: 'Schema names should work end to end.',
4560
+ });
4561
+ expect(marker.session.annotations.at(-1)).toMatchObject({
4562
+ kind: 'text-note',
4563
+ instruction: 'Schema names should work end to end.',
4564
+ });
4565
+ }
4566
+ finally {
4567
+ fs.rmSync(projectRoot, { recursive: true, force: true });
4568
+ }
4569
+ });
3498
4570
  it('creates image-note sessions and mutates markers through MCP', async () => {
3499
4571
  const projectRoot = createProjectRoot();
3500
4572
  try {