@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.
- 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.d.ts +20 -15
- package/dist/components/views/PlanComparisonPage.js +221 -82
- package/dist/components/views/PlanComparisonPage.test.js +268 -14
- package/dist/components/views/PlansPage.js +16 -10
- package/dist/components/views/PlansPage.test.js +4 -2
- 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 +30 -0
- package/dist/core/GlobalSettingsService.test.d.ts +1 -0
- package/dist/core/GlobalSettingsService.test.js +45 -0
- package/dist/core/McpTokenService.d.ts +12 -0
- package/dist/core/McpTokenService.js +44 -1
- package/dist/core/PlanEntitlementService.d.ts +20 -0
- package/dist/core/PlanEntitlementService.js +215 -19
- package/dist/core/PlanFeatureCatalog.test.js +55 -0
- 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 +204 -26
- package/dist/core/Taskforce.ids.test.js +58 -0
- package/dist/core/Taskforce.js +781 -71
- package/dist/core/Taskforce.mcpAuth.test.js +109 -0
- package/dist/core/types.d.ts +10 -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 +58 -15
- 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 +328 -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 +5 -0
- package/dist/server/routes/authSupport.js +13 -1
- package/dist/server/routes/billing.d.ts +1 -0
- package/dist/server/routes/billing.js +116 -63
- package/dist/server/routes/billing.test.js +162 -11
- 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 +972 -23
- 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/agent-logos/ChatGPT.png +0 -0
- package/dist/ui/agent-logos/Gemini CLI.jpeg +0 -0
- package/dist/ui/agent-logos/antigravity.jpeg +0 -0
- package/dist/ui/agent-logos/claude-code.jpeg +0 -0
- package/dist/ui/agent-logos/codex.jpeg +0 -0
- package/dist/ui/agent-logos/cursor.png +0 -0
- package/dist/ui/agent-logos/openclaw.jpeg +0 -0
- package/dist/ui/agent-logos/windsurf.png +0 -0
- package/dist/ui/assets/AgentsModule-BS4Pi_nC.js +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-BVKew-mH.css +1 -0
- package/dist/ui/assets/AnnotatedAttachmentWorkspace-CCckzWYZ.js +3 -0
- package/dist/ui/assets/ContextAttachmentManager-CfG_ER7C.js +3 -0
- package/dist/ui/assets/DocumentWorkspace-BVaWPA25.js +252 -0
- package/dist/ui/assets/DocumentWorkspace-D4rJKXzd.css +1 -0
- package/dist/ui/assets/EntityActivityTimeline-DSj0ThaK.js +1 -0
- package/dist/ui/assets/EntityActivityTimeline-DyqvvhuF.css +1 -0
- package/dist/ui/assets/{InitiativesModule-9E6ParrY.js → InitiativesModule-AxMkWtxH.js} +1 -1
- package/dist/ui/assets/PlansPage-B_z-D6Nh.css +1 -0
- package/dist/ui/assets/PlansPage-Ndt7mYfo.js +1 -0
- package/dist/ui/assets/TaskContextUpload-Dw4rTF4i.js +1 -0
- package/dist/ui/assets/TaskSettings-f2ga42_V.js +9 -0
- package/dist/ui/assets/{WorkflowsModule-BA7jcEpH.js → WorkflowsModule-E4UL3mov.js} +4 -4
- package/dist/ui/assets/documentReferences-BOUcJm6-.js +1 -0
- package/dist/ui/assets/index-BvAbqx5Q.css +1 -0
- package/dist/ui/assets/index-MAHKvFqp.js +4 -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/og-image.png +0 -0
- 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
package/dist/mcp/runtime.test.js
CHANGED
|
@@ -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('
|
|
456
|
-
expect(resolveProfile.inputSchema?.properties?.surfaceType?.description).toContain('
|
|
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
|
|
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
|
|
1362
|
-
title: 'Start helper
|
|
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
|
|
1368
|
-
id:
|
|
1770
|
+
const doneResult = await callTool(projectRoot, 'local', 'start_task', {
|
|
1771
|
+
id: doneTask.referenceLabel
|
|
1369
1772
|
});
|
|
1370
|
-
|
|
1371
|
-
|
|
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 {
|