merge: maestro/tsk_sjzC4B9ctg6W [tsk_sjzC4B9ctg6W]

This commit is contained in:
maestro
2026-06-13 11:35:00 +08:00
4 changed files with 781 additions and 201 deletions
+210
View File
@@ -0,0 +1,210 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { z } from 'zod';
import { createTools, type ApiFn, type ToolDef, type HttpMethod } from '../src/mcp/index.js';
/** 记录每次 REST 调用的 mockresponder 决定返回值(默认 null)。 */
function mockApi(responder?: (method: HttpMethod, path: string, body?: unknown) => unknown): {
api: ApiFn;
calls: Array<{ method: HttpMethod; path: string; body?: unknown }>;
} {
const calls: Array<{ method: HttpMethod; path: string; body?: unknown }> = [];
const api: ApiFn = (async (method: HttpMethod, path: string, body?: unknown) => {
calls.push({ method, path, body });
return responder ? responder(method, path, body) : null;
}) as ApiFn;
return { api, calls };
}
function tool(tools: ToolDef[], name: string): ToolDef {
const t = tools.find((x) => x.name === name);
assert.ok(t, `工具 ${name} 应存在`);
return t!;
}
/** 用工具自带的 zod inputSchema 校验参数(同 McpServer 注册时的校验口径) */
function parseArgs(t: ToolDef, args: unknown): Record<string, unknown> {
return z.object(t.config.inputSchema).parse(args) as Record<string, unknown>;
}
// ---------- 工具齐全性 ----------
test('createTools:读写工具齐全', () => {
const names = createTools(mockApi().api).map((t) => t.name);
for (const expected of [
// 读
'list_projects', 'list_tasks', 'get_task', 'get_next_executable', 'get_pending_approvals',
'get_agents', 'get_usage', 'list_archived',
// 写
'create_project', 'patch_project', 'sync_project', 'create_task', 'decompose_task',
'write_plan', 'write_spec', 'write_operations', 'patch_task', 'update_status',
'decide', 'requeue_task',
]) {
assert.ok(names.includes(expected), `应注册工具 ${expected}`);
}
});
// ---------- zod schema 校验(含 .min() 在 .optional() 之前的边界) ----------
test('patch_task schemapriority 0..2 边界、complexity 枚举、可选字段', () => {
const t = tool(createTools(mockApi().api), 'patch_task');
// 合法
assert.deepEqual(parseArgs(t, { taskId: 'tsk_1', priority: 0 }), { taskId: 'tsk_1', priority: 0 });
assert.deepEqual(parseArgs(t, { taskId: 'tsk_1', complexity: 'hard' }), { taskId: 'tsk_1', complexity: 'hard' });
// priority 越界 / 非整数被拒
assert.throws(() => parseArgs(t, { taskId: 'tsk_1', priority: 3 }));
assert.throws(() => parseArgs(t, { taskId: 'tsk_1', priority: -1 }));
assert.throws(() => parseArgs(t, { taskId: 'tsk_1', priority: 1.5 }));
// 非法 complexity
assert.throws(() => parseArgs(t, { taskId: 'tsk_1', complexity: 'trivial' }));
// 空 taskId 被拒(min(1)
assert.throws(() => parseArgs(t, { taskId: '' }));
// title min(1) 在 optional 之前:传则不能空,省略可以
assert.throws(() => parseArgs(t, { taskId: 'tsk_1', title: '' }));
assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1' }));
});
test('decide schemaaction 枚举、reason/merge 可选', () => {
const t = tool(createTools(mockApi().api), 'decide');
assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1', action: 'accept' }));
assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1', action: 'reject', reason: '请补测试' }));
assert.doesNotThrow(() => parseArgs(t, { taskId: 'tsk_1', action: 'accept', merge: false }));
assert.throws(() => parseArgs(t, { taskId: 'tsk_1', action: 'approve' }));
// reason 给了就不能空串(min(1) 在 optional 之前)
assert.throws(() => parseArgs(t, { taskId: 'tsk_1', action: 'reject', reason: '' }));
});
test('patch_project schema:可空字段 + 枚举校验', () => {
const t = tool(createTools(mockApi().api), 'patch_project');
assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1', verifyCmd: null, model: null, logo: null }));
assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1', autonomy: 'auto-easy', concurrency: 2, status: 'paused' }));
assert.throws(() => parseArgs(t, { projectId: 'prj_1', autonomy: 'yolo' }));
assert.throws(() => parseArgs(t, { projectId: 'prj_1', concurrency: 0 }));
assert.throws(() => parseArgs(t, { projectId: 'prj_1', status: 'stopped' }));
});
test('list_archived schemapage/size 边界', () => {
const t = tool(createTools(mockApi().api), 'list_archived');
assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1' }));
assert.doesNotThrow(() => parseArgs(t, { projectId: 'prj_1', page: 2, size: 50 }));
assert.throws(() => parseArgs(t, { projectId: 'prj_1', page: 0 }));
assert.throws(() => parseArgs(t, { projectId: 'prj_1', size: 0 }));
assert.throws(() => parseArgs(t, { projectId: 'prj_1', size: 101 }));
});
// ---------- 对 mock 的调用断言(method / path / body ----------
test('patch_task → PATCH /api/tasks/:idbody 仅含 patch 字段', async () => {
const { api, calls } = mockApi();
const t = tool(createTools(api), 'patch_task');
await t.handler({ taskId: 'tsk_abc', priority: 0, complexity: 'easy' });
assert.equal(calls.length, 1);
assert.equal(calls[0].method, 'PATCH');
assert.equal(calls[0].path, '/api/tasks/tsk_abc');
assert.deepEqual(calls[0].body, { priority: 0, complexity: 'easy' });
});
test('patch_project → PATCH /api/projects/:id', async () => {
const { api, calls } = mockApi();
const t = tool(createTools(api), 'patch_project');
await t.handler({ projectId: 'prj_x', autonomy: 'auto-approved', verifyCmd: null });
assert.equal(calls[0].method, 'PATCH');
assert.equal(calls[0].path, '/api/projects/prj_x');
assert.deepEqual(calls[0].body, { autonomy: 'auto-approved', verifyCmd: null });
});
test('sync_project → POST /api/projects/:id/sync', async () => {
const { api, calls } = mockApi();
const t = tool(createTools(api), 'sync_project');
await t.handler({ projectId: 'prj_y' });
assert.deepEqual({ method: calls[0].method, path: calls[0].path }, { method: 'POST', path: '/api/projects/prj_y/sync' });
});
test('decide → POST /api/tasks/:id/decide,透传 action/merge/reason', async () => {
const { api, calls } = mockApi();
const t = tool(createTools(api), 'decide');
await t.handler({ taskId: 'tsk_z', action: 'accept', merge: false });
assert.equal(calls[0].method, 'POST');
assert.equal(calls[0].path, '/api/tasks/tsk_z/decide');
assert.deepEqual(calls[0].body, { action: 'accept', merge: false, reason: undefined });
});
test('update_status → POST /api/tasks/:id/transition', async () => {
const { api, calls } = mockApi();
const t = tool(createTools(api), 'update_status');
await t.handler({ taskId: 'tsk_t', to: 'queued' });
assert.equal(calls[0].path, '/api/tasks/tsk_t/transition');
assert.deepEqual(calls[0].body, { to: 'queued' });
});
test('get_agents / get_usage → 对应只读端点', async () => {
const { api, calls } = mockApi();
const tools = createTools(api);
await tool(tools, 'get_agents').handler({});
await tool(tools, 'get_usage').handler({});
assert.deepEqual(calls.map((c) => `${c.method} ${c.path}`), ['GET /api/agents', 'GET /api/usage']);
});
test('get_task:返回 score 与 review 字段(summary/verdict/securityVerdict', async () => {
const taskObj = {
id: 'tsk_1', projectId: 'prj_1', parentId: null, depth: 1, title: 'T',
complexity: 'easy', status: 'exec_review', priority: 0, deps: [],
plan: null, spec: null, operations: null, approvals: [], assignee: null,
retryBaseline: 0, createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z',
result: {
branch: 'b', worktree: null, diffSummary: null, commits: [], prUrl: null,
summary: 'looks good', verdict: 'approve', securitySummary: 'no issues',
securityVerdict: 'approve', mergeTaskId: null,
},
};
const { api } = mockApi((method, path) => {
if (path === '/api/tasks/tsk_1') return taskObj;
if (path === '/api/tasks/tsk_1/children') return [];
if (path === '/api/projects/prj_1/tasks') return [taskObj];
return null;
});
const t = tool(createTools(api), 'get_task');
const res = await t.handler({ taskId: 'tsk_1' });
const payload = JSON.parse(res.content[0].text) as {
score: number; review: { summary: string; verdict: string; securityVerdict: string };
};
// P0 自身分=3,无依赖/解锁 → score=3
assert.equal(payload.score, 3);
assert.equal(payload.review.summary, 'looks good');
assert.equal(payload.review.verdict, 'approve');
assert.equal(payload.review.securityVerdict, 'approve');
});
test('list_archived:仅终态 + 分页 + 按更新时间倒序', async () => {
const mk = (id: string, status: string, updatedAt: string): Record<string, unknown> => ({
id, projectId: 'prj_1', parentId: null, depth: 1, title: id, complexity: 'easy',
status, priority: 1, deps: [], plan: null, spec: null, operations: null, approvals: [],
result: null, assignee: null, retryBaseline: 0, createdAt: updatedAt, updatedAt,
});
const tasks = [
mk('t_done1', 'done', '2026-01-01T00:00:00Z'),
mk('t_active', 'executing', '2026-01-05T00:00:00Z'),
mk('t_cancel', 'cancelled', '2026-01-03T00:00:00Z'),
mk('t_done2', 'done', '2026-01-02T00:00:00Z'),
];
const { api } = mockApi((_m, path) => (path === '/api/projects/prj_1/tasks' ? tasks : null));
const t = tool(createTools(api), 'list_archived');
const res = await t.handler({ projectId: 'prj_1', page: 1, size: 2 });
const out = JSON.parse(res.content[0].text) as { total: number; totalPages: number; tasks: Array<{ id: string }> };
assert.equal(out.total, 3); // 仅 done/cancelled
assert.equal(out.totalPages, 2);
assert.deepEqual(out.tasks.map((x) => x.id), ['t_cancel', 't_done2']); // 倒序:03 > 02 > 01
const res2 = await t.handler({ projectId: 'prj_1', page: 2, size: 2 });
const out2 = JSON.parse(res2.content[0].text) as { tasks: Array<{ id: string }> };
assert.deepEqual(out2.tasks.map((x) => x.id), ['t_done1']);
});
test('错误透传:ApiError 转为 isError 工具结果', async () => {
const { api } = mockApi(() => { throw new Error('非法状态流转'); });
const t = tool(createTools(api), 'update_status');
const res = await t.handler({ taskId: 'tsk_1', to: 'done' });
assert.equal(res.isError, true);
assert.match(res.content[0].text, /非法状态流转/);
});