From 3b8336afd9eed1384c60b213a397ea0b29e9662d Mon Sep 17 00:00:00 2001 From: Jayson Jacobs Date: Sat, 14 Mar 2026 21:27:23 -0600 Subject: [PATCH] update for new workflows --- README.md | 69 ++++++- package.json | 2 +- src/schemas.ts | 34 ++- src/tools.ts | 289 +++++++++++++++++++++++++- test/tool-coverage.test.ts | 8 +- test/workflow-tools.test.ts | 402 ++++++++++++++++++++++++++++++++++++ 6 files changed, 785 insertions(+), 19 deletions(-) create mode 100644 test/workflow-tools.test.ts diff --git a/README.md b/README.md index 362fb24..4964c5d 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,13 @@ If no API key is available, the tool returns a user-facing error. ### Core Utilities - `msq_generate_prompt` -- `msq_run_agent_workflow` +- `msq_list_workflows` +- `msq_get_workflow` +- `msq_create_workflow` +- `msq_update_workflow` +- `msq_run_workflow` +- `msq_get_workflow_run_status` +- `msq_get_workflow_result` - `msq_get_core_config` - `msq_scrape_url` - `msq_list_tools` @@ -143,6 +149,24 @@ If no API key is available, the tool returns a user-facing error. - `msq_list_user_collections` - `msq_get_vector_store_file_details` +## Workflow Lifecycle + +Workflow management uses the persisted workflow config and workflow run endpoints rather than the deprecated legacy SSE workflow route. + +Supported workflow operations: + +- create a workflow config with `msq_create_workflow` +- update a workflow config with `msq_update_workflow` +- list workflow configs with `msq_list_workflows` +- fetch a single workflow config with `msq_get_workflow` +- start a workflow run with `msq_run_workflow` +- inspect helper/main status with `msq_get_workflow_run_status` +- fetch the final main-agent result with `msq_get_workflow_result` + +`msq_get_workflow_run_status` returns helper success/failure state without helper content. + +`msq_get_workflow_result` returns only the final main-agent response and will fail if the run is still in progress or did not complete successfully. + ## File Upload and Download Notes `msq_upload_file` accepts: @@ -211,6 +235,49 @@ await client.callTool('msq_embeddings', { }) ``` +### Workflow example + +Create a workflow: + +```ts +await client.callTool('msq_create_workflow', { + name: 'Research Workflow', + mainAgentId: 'agent_main_123', + mainPrompt: 'Summarize findings from and ', + dataPayload: '{"sourceA":"https://a.example","sourceB":"https://b.example"}', + concurrency: 2, + delimiter: '|#|', + apiKey: 'msq-...', +}) +``` + +Start a workflow run: + +```ts +await client.callTool('msq_run_workflow', { + workflowId: 'wf_123', + apiKey: 'msq-...', +}) +``` + +Get workflow status: + +```ts +await client.callTool('msq_get_workflow_run_status', { + runId: 'run_abc', + apiKey: 'msq-...', +}) +``` + +Get the final result: + +```ts +await client.callTool('msq_get_workflow_result', { + runId: 'run_abc', + apiKey: 'msq-...', +}) +``` + ## Development Scripts: diff --git a/package.json b/package.json index 6b9a7c4..3bb54ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@missionsquad/mcp-msq", - "version": "0.2.4", + "version": "0.3.0", "description": "MCP server interface for the MissionSquad API", "type": "module", "main": "dist/index.js", diff --git a/src/schemas.ts b/src/schemas.ts index b77d7c8..17e71ad 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -147,14 +147,32 @@ export const GeneratePromptSchema = z.object({ modelOptions: z.object({}).passthrough().optional(), }) -export const AgentWorkflowSchema = z.object({ - agentName: NonEmptyString, - messages: z.array(ChatMessageSchema).min(1), - data: z.object({}).passthrough().optional(), - delimiter: z.string().optional(), - concurrency: z.number().int().positive().optional(), - failureMessage: z.string().optional(), - failureInstruction: z.string().optional(), +export const WorkflowIdSchema = z.object({ + id: NonEmptyString.describe('Workflow config id.'), +}) + +export const WorkflowCreateSchema = z.object({ + id: NonEmptyString.optional().describe('Optional workflow config id. If omitted, the server generates one.'), + name: z.string().optional().describe('Workflow name. Defaults to "Untitled Workflow".'), + mainAgentId: z.string().nullable().optional().describe('Main agent id for the workflow.'), + mainPrompt: z.string().optional().describe('Main prompt containing helper agent patterns.'), + dataPayload: z.string().optional().describe('JSON string containing workflow data payload. Must be valid JSON if provided.'), + concurrency: z.number().int().positive().optional().describe('Maximum concurrent helper executions.'), + delimiter: z.string().optional().describe('Delimiter used for helper patterns. Defaults to "|#|".'), + failureMessage: z.string().optional().describe('Failure message used when a helper fails.'), + failureInstruction: z.string().optional().describe('Instruction appended for the main agent when a helper fails.'), +}) + +export const WorkflowUpdateSchema = WorkflowIdSchema.merge( + WorkflowCreateSchema.omit({ id: true }), +) + +export const WorkflowRunIdSchema = z.object({ + runId: NonEmptyString.describe('Workflow run id.'), +}) + +export const WorkflowRunCreateSchema = z.object({ + workflowId: NonEmptyString.describe('Workflow config id to execute.'), }) export const ScrapeUrlSchema = z.object({ diff --git a/src/tools.ts b/src/tools.ts index d434e2f..bdb53eb 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -9,7 +9,6 @@ import { AddModelSchema, AddProviderSchema, AddVectorStoreFileSchema, - AgentWorkflowSchema, CancelVectorStoreSchema, ChatCompletionsSchema, CoreCollectionDiagnosticsSchema, @@ -33,6 +32,11 @@ import { UploadFileSchema, VectorStoreFileSchema, VectorStoreIdSchema, + WorkflowCreateSchema, + WorkflowIdSchema, + WorkflowRunCreateSchema, + WorkflowRunIdSchema, + WorkflowUpdateSchema, } from './schemas.js' type ToolParams = z.ZodTypeAny @@ -54,6 +58,185 @@ function encodePathSegment(value: string): string { return encodeURIComponent(value) } +const TokenUsageSchema = z.object({ + promptTokens: z.number().optional(), + completionTokens: z.number().optional(), + totalTokens: z.number().optional(), +}).nullable() + +const WorkflowStatusSchema = z.enum(['queued', 'running', 'completed', 'error', 'cancelled']) +const WorkflowMainStatusSchema = z.enum(['pending', 'queued', 'running', 'completed', 'error', 'cancelled']) + +const WorkflowConfigRecordSchema = z.object({ + id: z.string(), + userId: z.string(), + name: z.string(), + mainAgentId: z.string().nullable(), + mainPrompt: z.string(), + dataPayload: z.string(), + concurrency: z.number(), + delimiter: z.string(), + failureMessage: z.string(), + failureInstruction: z.string(), + createdAt: z.number(), + updatedAt: z.number(), +}).passthrough() + +const WorkflowRunHelperRecordSchema = z.object({ + helperRunId: z.string(), + patternIndex: z.number(), + agentId: z.string(), + agentName: z.string(), + status: WorkflowStatusSchema, + startedAt: z.number().optional(), + completedAt: z.number().optional(), + errorMessage: z.string().optional(), + usage: TokenUsageSchema, +}).passthrough() + +const WorkflowRunMainRecordSchema = z.object({ + agentId: z.string().nullable(), + agentName: z.string().nullable(), + status: WorkflowMainStatusSchema, + startedAt: z.number().optional(), + completedAt: z.number().optional(), + errorMessage: z.string().optional(), + usage: TokenUsageSchema, +}).passthrough() + +const WorkflowRunRecordSchema = z.object({ + runId: z.string(), + workflowConfigId: z.string().nullable(), + workflowNameSnapshot: z.string(), + status: WorkflowStatusSchema, + startedAt: z.number(), + completedAt: z.number().optional(), + cancelledAt: z.number().optional(), + errorMessage: z.string().optional(), + aggregateUsage: TokenUsageSchema, + helpers: z.array(WorkflowRunHelperRecordSchema), + main: WorkflowRunMainRecordSchema, + resumeSnapshot: z.object({ + main: z.object({ + previewContent: z.string(), + }).passthrough(), + }).passthrough(), +}).passthrough() + +const WorkflowConfigListResponseSchema = z.object({ + success: z.boolean(), + data: z.array(WorkflowConfigRecordSchema), +}).passthrough() + +const WorkflowConfigResponseSchema = z.object({ + success: z.boolean(), + data: WorkflowConfigRecordSchema, +}).passthrough() + +const WorkflowRunResponseSchema = z.object({ + success: z.boolean(), + runId: z.string().optional(), + data: WorkflowRunRecordSchema, +}).passthrough() + +type WorkflowConfigRecord = z.infer +type WorkflowRunRecord = z.infer + +function parseWorkflowConfigListResponse(payload: unknown): WorkflowConfigRecord[] { + return WorkflowConfigListResponseSchema.parse(payload).data +} + +function parseWorkflowConfigResponse(payload: unknown): WorkflowConfigRecord { + return WorkflowConfigResponseSchema.parse(payload).data +} + +function parseWorkflowRunResponse(payload: unknown): WorkflowRunRecord { + return WorkflowRunResponseSchema.parse(payload).data +} + +function mapWorkflowList(workflows: WorkflowConfigRecord[]) { + return { workflows } +} + +function mapWorkflow(workflow: WorkflowConfigRecord) { + return { workflow } +} + +function mapWorkflowRunSummary(record: WorkflowRunRecord) { + return { + runId: record.runId, + workflowId: record.workflowConfigId, + workflowName: record.workflowNameSnapshot, + status: record.status, + startedAt: record.startedAt, + } +} + +function mapWorkflowRunStatus(record: WorkflowRunRecord) { + return { + runId: record.runId, + workflowId: record.workflowConfigId, + workflowName: record.workflowNameSnapshot, + status: record.status, + startedAt: record.startedAt, + completedAt: record.completedAt, + cancelledAt: record.cancelledAt, + errorMessage: record.errorMessage, + aggregateUsage: record.aggregateUsage, + main: { + agentId: record.main.agentId, + agentName: record.main.agentName, + status: record.main.status, + startedAt: record.main.startedAt, + completedAt: record.main.completedAt, + errorMessage: record.main.errorMessage, + usage: record.main.usage, + }, + helpers: record.helpers.map((helper) => ({ + helperRunId: helper.helperRunId, + patternIndex: helper.patternIndex, + agentId: helper.agentId, + agentName: helper.agentName, + status: helper.status, + startedAt: helper.startedAt, + completedAt: helper.completedAt, + errorMessage: helper.errorMessage, + usage: helper.usage, + })), + } +} + +function mapWorkflowRunResult(record: WorkflowRunRecord) { + if (record.status === 'queued' || record.status === 'running') { + throw new Error('Workflow result not ready. Use msq_get_workflow_run_status.') + } + + if (record.status === 'error' || record.status === 'cancelled') { + const suffix = record.errorMessage ? ` ${record.errorMessage}` : '' + throw new Error(`Workflow did not complete successfully.${suffix}`) + } + + if (record.status !== 'completed' || record.main.status !== 'completed') { + throw new Error('Workflow completed without a completed main agent state.') + } + + return { + runId: record.runId, + workflowId: record.workflowConfigId, + workflowName: record.workflowNameSnapshot, + status: 'completed' as const, + startedAt: record.startedAt, + completedAt: record.completedAt, + result: { + agentId: record.main.agentId, + agentName: record.main.agentName, + content: record.resumeSnapshot.main.previewContent, + usage: record.main.usage, + }, + aggregateUsage: record.aggregateUsage, + } +} + const msqTools = [ defineTool({ name: 'msq_list_models', @@ -239,15 +422,105 @@ const msqTools = [ }), }), defineTool({ - name: 'msq_run_agent_workflow', - description: 'Execute a MissionSquad agent workflow.', - parameters: AgentWorkflowSchema, - run: async (client, args) => - client.requestJson({ + name: 'msq_list_workflows', + description: 'List your MissionSquad workflow configs.', + parameters: EmptySchema, + run: async (client) => { + const response = await client.requestJson({ + method: 'GET', + path: 'core/workflows', + }) + + return mapWorkflowList(parseWorkflowConfigListResponse(response)) + }, + }), + defineTool({ + name: 'msq_get_workflow', + description: 'Get a MissionSquad workflow config by id.', + parameters: WorkflowIdSchema, + run: async (client, args) => { + const response = await client.requestJson({ + method: 'GET', + path: 'core/workflows', + }) + + const workflows = parseWorkflowConfigListResponse(response) + const workflow = workflows.find((candidate) => candidate.id === args.id) + if (!workflow) { + throw new Error('Workflow config not found') + } + + return mapWorkflow(workflow) + }, + }), + defineTool({ + name: 'msq_create_workflow', + description: 'Create a MissionSquad workflow config.', + parameters: WorkflowCreateSchema, + run: async (client, args) => { + const response = await client.requestJson({ method: 'POST', - path: 'core/agent-workflow', + path: 'core/workflows', body: args, - }), + }) + + return mapWorkflow(parseWorkflowConfigResponse(response)) + }, + }), + defineTool({ + name: 'msq_update_workflow', + description: 'Update a MissionSquad workflow config by id.', + parameters: WorkflowUpdateSchema, + run: async (client, args) => { + const { id, ...body } = args + const response = await client.requestJson({ + method: 'PUT', + path: `core/workflows/${encodePathSegment(id)}`, + body, + }) + + return mapWorkflow(parseWorkflowConfigResponse(response)) + }, + }), + defineTool({ + name: 'msq_run_workflow', + description: 'Start a MissionSquad workflow run in the background.', + parameters: WorkflowRunCreateSchema, + run: async (client, args) => { + const response = await client.requestJson({ + method: 'POST', + path: 'core/workflow-runs', + body: args, + }) + + return mapWorkflowRunSummary(parseWorkflowRunResponse(response)) + }, + }), + defineTool({ + name: 'msq_get_workflow_run_status', + description: 'Get workflow run status including helper success/failure state.', + parameters: WorkflowRunIdSchema, + run: async (client, args) => { + const response = await client.requestJson({ + method: 'GET', + path: `core/workflow-runs/${encodePathSegment(args.runId)}`, + }) + + return mapWorkflowRunStatus(parseWorkflowRunResponse(response)) + }, + }), + defineTool({ + name: 'msq_get_workflow_result', + description: 'Get the final main-agent result for a completed MissionSquad workflow run.', + parameters: WorkflowRunIdSchema, + run: async (client, args) => { + const response = await client.requestJson({ + method: 'GET', + path: `core/workflow-runs/${encodePathSegment(args.runId)}`, + }) + + return mapWorkflowRunResult(parseWorkflowRunResponse(response)) + }, }), defineTool({ name: 'msq_get_core_config', diff --git a/test/tool-coverage.test.ts b/test/tool-coverage.test.ts index 3950579..5fb4d93 100644 --- a/test/tool-coverage.test.ts +++ b/test/tool-coverage.test.ts @@ -17,7 +17,13 @@ const EXPECTED_TOOL_NAMES = [ 'msq_update_agent', 'msq_delete_agent', 'msq_generate_prompt', - 'msq_run_agent_workflow', + 'msq_list_workflows', + 'msq_get_workflow', + 'msq_create_workflow', + 'msq_update_workflow', + 'msq_run_workflow', + 'msq_get_workflow_run_status', + 'msq_get_workflow_result', 'msq_get_core_config', 'msq_scrape_url', 'msq_list_tools', diff --git a/test/workflow-tools.test.ts b/test/workflow-tools.test.ts new file mode 100644 index 0000000..9561cce --- /dev/null +++ b/test/workflow-tools.test.ts @@ -0,0 +1,402 @@ +import type { FastMCP } from '@missionsquad/fastmcp' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { registerMissionSquadTools } from '../src/tools.js' + +interface ToolContext { + extraArgs?: Record +} + +interface RegisteredTool { + name: string + execute: (args: unknown, context: ToolContext) => Promise +} + +class FakeServer { + public readonly tools = new Map() + + public addTool(definition: RegisteredTool): void { + this.tools.set(definition.name, definition) + } + + public getTool(name: string): RegisteredTool { + const tool = this.tools.get(name) + if (!tool) { + throw new Error(`Tool not found: ${name}`) + } + + return tool + } +} + +function jsonResponse(payload: unknown, status: number = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { + 'content-type': 'application/json', + }, + }) +} + +function buildWorkflowConfig(id: string) { + return { + id, + userId: 'user-1', + name: `Workflow ${id}`, + mainAgentId: 'agent-main', + mainPrompt: 'Prompt', + dataPayload: '{"source":"https://example.com"}', + concurrency: 2, + delimiter: '|#|', + failureMessage: 'Helper failed', + failureInstruction: 'Continue carefully', + createdAt: 100, + updatedAt: 200, + } +} + +function buildWorkflowRunRecord( + status: 'queued' | 'running' | 'completed' | 'error' | 'cancelled', + mainStatus: 'pending' | 'queued' | 'running' | 'completed' | 'error' | 'cancelled', +) { + return { + runId: 'run-123', + workflowConfigId: 'wf-123', + ownerUserId: 'user-1', + workflowNameSnapshot: 'Research Workflow', + status, + startedAt: 1000, + completedAt: status === 'completed' || status === 'error' || status === 'cancelled' ? 2000 : undefined, + cancelledAt: status === 'cancelled' ? 2000 : undefined, + errorMessage: status === 'error' ? 'main failed' : status === 'cancelled' ? 'user_cancelled' : undefined, + aggregateUsage: { + promptTokens: 10, + completionTokens: 20, + totalTokens: 30, + }, + helpers: [ + { + helperRunId: 'helper-1', + patternIndex: 0, + agentId: 'agent-helper', + agentName: 'collector', + agentSlug: 'collector', + sessionId: 'session-helper', + chatId: 'chat-helper', + resolvedInput: 'sensitive helper input', + status: 'completed', + startedAt: 1100, + completedAt: 1200, + usage: { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }, + }, + ], + main: { + sessionId: 'session-main', + chatId: 'chat-main', + agentId: 'agent-main', + agentName: 'Coordinator', + agentSlug: 'coordinator', + status: mainStatus, + startedAt: 1300, + completedAt: mainStatus === 'completed' || mainStatus === 'error' || mainStatus === 'cancelled' ? 2000 : undefined, + errorMessage: mainStatus === 'error' ? 'main failed' : undefined, + usage: { + promptTokens: 4, + completionTokens: 5, + totalTokens: 9, + }, + }, + resumeSnapshot: { + schemaVersion: 1, + phase: status === 'completed' ? 'completed' : status === 'running' ? 'main' : status, + helpers: [ + { + helperRunId: 'helper-1', + patternIndex: 0, + agentName: 'collector', + agentId: 'agent-helper', + agentSlug: 'collector', + sessionId: 'session-helper', + chatId: 'chat-helper', + status: 'completed', + previewContent: 'helper preview content', + usage: { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }, + }, + ], + main: { + agentId: 'agent-main', + agentName: 'Coordinator', + agentSlug: 'coordinator', + sessionId: 'session-main', + chatId: 'chat-main', + status: mainStatus, + previewContent: 'Final synthesized answer from the main agent', + usage: { + promptTokens: 4, + completionTokens: 5, + totalTokens: 9, + }, + }, + aggregateUsage: { + promptTokens: 10, + completionTokens: 20, + totalTokens: 30, + }, + updatedAt: 2000, + }, + createdAt: 1000, + updatedAt: 2000, + } +} + +function getRequest(fetchMock: ReturnType) { + const latestCall = fetchMock.mock.calls.at(-1) + if (!latestCall) { + throw new Error('Expected fetch to be called') + } + + const [url, init] = latestCall as [string | URL | Request, RequestInit | undefined] + const requestUrl = typeof url === 'string' ? new URL(url) : url instanceof URL ? url : new URL(url.url) + + return { + url: requestUrl, + init: init ?? {}, + } +} + +describe('MissionSquad workflow tools', () => { + const fetchMock = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>() + let server: FakeServer + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock) + server = new FakeServer() + registerMissionSquadTools(server as unknown as FastMCP) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + async function callTool(name: string, args: unknown): Promise { + const result = await server.getTool(name).execute(args, { + extraArgs: { apiKey: 'msq-test-key' }, + }) + + return JSON.parse(result) + } + + it('lists workflows using the workflow config endpoint and preserves API order', async () => { + const workflows = [buildWorkflowConfig('wf-1'), buildWorkflowConfig('wf-2')] + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, data: workflows })) + + const result = await callTool('msq_list_workflows', {}) + const { url, init } = getRequest(fetchMock) + + expect(url.pathname).toBe('/v1/core/workflows') + expect(init.method).toBe('GET') + expect(result).toEqual({ workflows }) + }) + + it('gets a workflow by exact id match and errors when missing', async () => { + const workflows = [buildWorkflowConfig('wf-1'), buildWorkflowConfig('wf-2')] + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, data: workflows })) + + const result = await callTool('msq_get_workflow', { id: 'wf-2' }) + + expect(result).toEqual({ workflow: workflows[1] }) + + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, data: workflows })) + + await expect(callTool('msq_get_workflow', { id: 'wf-missing' })).rejects.toThrow('Workflow config not found') + }) + + it('creates a workflow with the verified request body shape', async () => { + const workflow = buildWorkflowConfig('wf-created') + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, data: workflow })) + + const payload = { + id: 'wf-created', + name: 'Research Workflow', + mainAgentId: 'agent-main', + mainPrompt: 'Prompt', + dataPayload: '{"source":"https://example.com"}', + concurrency: 2, + delimiter: '|#|', + failureMessage: 'Helper failed', + failureInstruction: 'Continue carefully', + } + + const result = await callTool('msq_create_workflow', payload) + const { url, init } = getRequest(fetchMock) + + expect(url.pathname).toBe('/v1/core/workflows') + expect(init.method).toBe('POST') + expect(JSON.parse(String(init.body))).toEqual(payload) + expect(result).toEqual({ workflow }) + }) + + it('updates a workflow using id only in the URL, not in the body', async () => { + const workflow = buildWorkflowConfig('wf-updated') + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, data: workflow })) + + const result = await callTool('msq_update_workflow', { + id: 'wf-updated', + name: 'Updated Workflow', + mainPrompt: 'Updated prompt', + }) + const { url, init } = getRequest(fetchMock) + const requestBody = JSON.parse(String(init.body)) as Record + + expect(url.pathname).toBe('/v1/core/workflows/wf-updated') + expect(init.method).toBe('PUT') + expect(requestBody).toEqual({ + name: 'Updated Workflow', + mainPrompt: 'Updated prompt', + }) + expect(requestBody).not.toHaveProperty('id') + expect(result).toEqual({ workflow }) + }) + + it('starts a workflow run and returns the run summary', async () => { + const record = buildWorkflowRunRecord('queued', 'pending') + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, runId: 'run-123', data: record }, 202)) + + const result = await callTool('msq_run_workflow', { workflowId: 'wf-123' }) + const { url, init } = getRequest(fetchMock) + + expect(url.pathname).toBe('/v1/core/workflow-runs') + expect(init.method).toBe('POST') + expect(JSON.parse(String(init.body))).toEqual({ workflowId: 'wf-123' }) + expect(result).toEqual({ + runId: 'run-123', + workflowId: 'wf-123', + workflowName: 'Research Workflow', + status: 'queued', + startedAt: 1000, + }) + }) + + it('returns filtered workflow run status without helper or main content fields', async () => { + const record = buildWorkflowRunRecord('running', 'running') + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, data: record })) + + const result = await callTool('msq_get_workflow_run_status', { runId: 'run-123' }) + const { url, init } = getRequest(fetchMock) + const statusResult = result as Record + const helpers = statusResult.helpers as Array> + const main = statusResult.main as Record + + expect(url.pathname).toBe('/v1/core/workflow-runs/run-123') + expect(init.method).toBe('GET') + expect(statusResult).toMatchObject({ + runId: 'run-123', + workflowId: 'wf-123', + workflowName: 'Research Workflow', + status: 'running', + main: { + agentId: 'agent-main', + agentName: 'Coordinator', + status: 'running', + }, + }) + expect(main).not.toHaveProperty('chatId') + expect(main).not.toHaveProperty('sessionId') + expect(main).not.toHaveProperty('content') + expect(helpers[0]).not.toHaveProperty('resolvedInput') + expect(helpers[0]).not.toHaveProperty('chatId') + expect(helpers[0]).not.toHaveProperty('sessionId') + }) + + it('returns only the main-agent result for a completed run', async () => { + const record = buildWorkflowRunRecord('completed', 'completed') + fetchMock.mockResolvedValueOnce(jsonResponse({ success: true, data: record })) + + const result = await callTool('msq_get_workflow_result', { runId: 'run-123' }) + const resultRecord = result as Record + const nestedResult = resultRecord.result as Record + + expect(resultRecord).toEqual({ + runId: 'run-123', + workflowId: 'wf-123', + workflowName: 'Research Workflow', + status: 'completed', + startedAt: 1000, + completedAt: 2000, + result: { + agentId: 'agent-main', + agentName: 'Coordinator', + content: 'Final synthesized answer from the main agent', + usage: { + promptTokens: 4, + completionTokens: 5, + totalTokens: 9, + }, + }, + aggregateUsage: { + promptTokens: 10, + completionTokens: 20, + totalTokens: 30, + }, + }) + expect(resultRecord).not.toHaveProperty('helpers') + expect(nestedResult).not.toHaveProperty('helperContent') + }) + + it('errors when workflow result is not ready or not successful', async () => { + const scenarios = [ + { + status: 'queued' as const, + mainStatus: 'pending' as const, + expectedMessage: 'Workflow result not ready. Use msq_get_workflow_run_status.', + }, + { + status: 'running' as const, + mainStatus: 'running' as const, + expectedMessage: 'Workflow result not ready. Use msq_get_workflow_run_status.', + }, + { + status: 'error' as const, + mainStatus: 'error' as const, + expectedMessage: 'Workflow did not complete successfully. main failed', + }, + { + status: 'cancelled' as const, + mainStatus: 'cancelled' as const, + expectedMessage: 'Workflow did not complete successfully. user_cancelled', + }, + ] + + for (const scenario of scenarios) { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + success: true, + data: buildWorkflowRunRecord(scenario.status, scenario.mainStatus), + }), + ) + + await expect(callTool('msq_get_workflow_result', { runId: 'run-123' })).rejects.toThrow( + scenario.expectedMessage, + ) + } + }) + + it('errors when a completed run does not have a completed main agent state', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ + success: true, + data: buildWorkflowRunRecord('completed', 'running'), + })) + + await expect(callTool('msq_get_workflow_result', { runId: 'run-123' })).rejects.toThrow( + 'Workflow completed without a completed main agent state.', + ) + }) +})