60265656e2
任务 ID:tsk_sjzC4B9ctg6W MCP(src/mcp/index.ts,重构为可测的 createTools(apiFn)): - 读:新增 get_agents(自治/并发/调度/模型/在途)、get_usage(订阅额度)、 list_archived(终态分页);get_task 补 score(自身分+链条惯性+解锁加权) 与 review(summary/verdict/securitySummary/securityVerdict)显式字段。 - 写:新增 patch_task(复用 store.patchTask 校验)、patch_project、sync_project、 decide(action + merge:通过并合并 / 仅通过;reject 必带 reason)。 - 复核全部工具 description 与状态机语义一致;entry-guard 便于 import 测试。 CLI(src/cli/index.ts)对齐子命令: - project set / task patch / decide <id> accept|reject [--no-merge] / agents / usage / archive。两端均薄封装走 REST,不重复实现。 API(src/api/server.ts):新增 GET /api/usage 单独透传订阅额度。 测试:新增 test/mcp.test.ts(zod schema 边界 + 对 mock api 的 method/path/body 断言 + score/分页/错误透传),npm test 103 全过、typecheck/build 通过。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
211 lines
10 KiB
TypeScript
211 lines
10 KiB
TypeScript
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 调用的 mock;responder 决定返回值(默认 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 schema:priority 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 schema:action 枚举、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 schema:page/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/:id,body 仅含 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, /非法状态流转/);
|
||
});
|