Files
wangjia 60265656e2 feat: MCP/CLI 追平 Phase 2 后端能力(score/复审/合并/额度/归档)
任务 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>
2026-06-13 10:35:34 +08:00

211 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, /非法状态流转/);
});