/** * task_manager 工具(v0.7.0 覆盖补齐 → v0.7.5 大幅扩充) * * - TaskManagerTool:SQLite 持久化 CRUD / 会话隔离 / 父子级联递归删除 / * order_idx 递增 / 枚举校验 / onTaskChanged 回调 * (better-sqlite3 ABI 门控:系统 Node 自动跳过,test:electron 全执行) */ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; // ===== task_manager(ABI 门控)===== let dbAvailable = false; try { // eslint-disable-next-line @typescript-eslint/no-require-imports const Probe = require('better-sqlite3'); const p = new Probe(':memory:'); p.close(); dbAvailable = true; } catch { dbAvailable = false; } // 工具返回的是 task-manager.ts 的 Task 形状(camelCase,见 mapRow()), // 非数据库行 snake_case。此接口仅供测试内类型标注,需与真实返回对齐。 interface TaskRowLike { id: string; sessionId?: string; title?: string; status?: string; priority?: string; parentId?: string | null; order?: number; completedAt?: number | null; } describe.skipIf(!dbAvailable)('task_manager — CRUD / 会话隔离 / 回调联动', () => { let db: any; let wsDir: string; let tool: { execute(args: Record, ctx: unknown): Promise }; let notifyCalls: Array<{ sessionId?: string }> = []; function ctxFor(sessionId?: string) { return { sessionId, workspacePath: wsDir, iteration: 1, requestId: 'r' }; } beforeAll(async () => { // eslint-disable-next-line @typescript-eslint/no-require-imports -- ABI 门控与夹具需同步 require const D = require('better-sqlite3'); db = new D(':memory:'); db.exec(` CREATE TABLE sessions ( id TEXT PRIMARY KEY, title TEXT DEFAULT '新会话', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, message_count INTEGER DEFAULT 0, pinned INTEGER DEFAULT 0, archived INTEGER DEFAULT 0, metadata TEXT DEFAULT '{}' ); CREATE TABLE tasks ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','in_progress','completed','blocked','cancelled')), priority TEXT NOT NULL DEFAULT 'medium' CHECK(priority IN ('low','medium','high','critical')), parent_id TEXT, assigned_to TEXT, order_idx INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000), completed_at INTEGER, FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE, FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE ); INSERT INTO sessions (id, created_at, updated_at) VALUES ('s_task', ${Date.now()}, ${Date.now()}); INSERT INTO sessions (id, created_at, updated_at) VALUES ('s_other', ${Date.now()}, ${Date.now()}); `); const { TaskManagerTool } = await import('../task-manager'); notifyCalls = []; const manager = new TaskManagerTool( () => db, (sessionId?: string) => notifyCalls.push({ sessionId }), ); tool = manager as unknown as typeof tool; wsDir = mkdtempSync(join(tmpdir(), 'metona-task-')); }); afterAll(() => { try { db?.close(); } catch { /* ignore */ } try { rmSync(wsDir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('create → list → complete → update → delete 全链路;回调每次触发', async () => { const created = (await tool.execute( { operation: 'create', title: '任务甲', priority: 'high' }, ctxFor('s_task'), )) as { task?: TaskRowLike; id?: string; success?: boolean }; const taskId = created.task?.id ?? (created.id as string); expect(taskId).toBeTruthy(); const list = (await tool.execute({ operation: 'list' }, ctxFor('s_task'))) as { tasks?: Array; rows?: Array; }; const listRows = (list.tasks ?? list.rows ?? []) as Array; expect(listRows.some((r) => r.title === '任务甲')).toBe(true); const doneRes = await tool.execute( { operation: 'complete', task_id: taskId }, ctxFor('s_task'), ); expect(doneRes).toBeDefined(); const updRes = await tool.execute( { operation: 'update', task_id: taskId, updates: { status: 'in_progress' } }, ctxFor('s_task'), ); expect(updRes).toBeDefined(); const delRes = await tool.execute({ operation: 'delete', task_id: taskId }, ctxFor('s_task')); expect(delRes).toBeDefined(); expect(notifyCalls.length).toBeGreaterThanOrEqual(1); }); it('会话隔离:列表按 session 过滤,跨会话不可见(真实断言,替代恒真)', async () => { await tool.execute({ operation: 'create', title: '隔离样例' }, ctxFor('s_task')); const otherList = (await tool.execute({ operation: 'list' }, ctxFor('s_other'))) as { tasks?: Array; rows?: Array; }; const rows = otherList.tasks ?? otherList.rows ?? []; // 修正:原断言 `r.title !== '隔离样例' || ... || true` 恒真。真实契约是 // listTasks 按 session_id 过滤 —— s_other 列表绝不包含 s_task 创建的任务。 expect(rows.some((r) => r.title === '隔离样例')).toBe(false); // 双向验证:s_task 自己能看到该任务 const ownList = (await tool.execute({ operation: 'list' }, ctxFor('s_task'))) as { tasks?: Array; }; expect((ownList.tasks ?? []).some((r) => r.title === '隔离样例')).toBe(true); }); it('非法 operation 枚举失败;缺 title 的 create 失败', async () => { const badOp = await tool.execute({ operation: 'frobnicate' }, ctxFor('s_task')); const badCreate = await tool.execute({ operation: 'create' }, ctxFor('s_task')); const badSignal = JSON.stringify(badOp).includes('"success":false') || JSON.stringify(badOp).includes('error'); expect(badSignal).toBe(true); expect(JSON.stringify(badCreate)).toContain('"success":false'); }); it('create 校验 priority 枚举:非法值拒绝', async () => { const bad = (await tool.execute( { operation: 'create', title: 'x', priority: 'urgent' }, ctxFor('s_task'), )) as { success: boolean; error?: string }; expect(bad.success).toBe(false); expect(String(bad.error)).toContain('Invalid priority'); const ok = (await tool.execute( { operation: 'create', title: 'pri-ok', priority: 'critical' }, ctxFor('s_task'), )) as { success: boolean }; expect(ok.success).toBe(true); }); it('order_idx 同 session 同 parent 下递增', async () => { await tool.execute({ operation: 'create', title: 'o1' }, ctxFor('s_task')); await tool.execute({ operation: 'create', title: 'o2' }, ctxFor('s_task')); const list = (await tool.execute({ operation: 'list' }, ctxFor('s_task'))) as { tasks: Array; }; const orders = list.tasks .filter((t) => ['o1', 'o2'].includes(String(t.title))) .map((t) => Number(t.order)) .sort((a, b) => a - b); expect(orders).toEqual([orders[0], orders[0] + 1]); // 连续递增 }); it('create 支持 parent_id 建立父子关系', async () => { const parent = (await tool.execute( { operation: 'create', title: '父任务' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const parentId = parent.task!.id; const child = (await tool.execute( { operation: 'create', title: '子任务', parent_id: parentId }, ctxFor('s_task'), )) as { task?: TaskRowLike }; expect(child.task!.parentId).toBe(parentId); expect(child.task!.order).toBe(0); // 子任务独立 order 序列 }); it('get 返回任务与其子任务(仅限本会话)', async () => { const parent = (await tool.execute( { operation: 'create', title: 'get-父' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const parentId = parent.task!.id; await tool.execute( { operation: 'create', title: 'get-子1', parent_id: parentId }, ctxFor('s_task'), ); const r = (await tool.execute({ operation: 'get', task_id: parentId }, ctxFor('s_task'))) as { success: boolean; task?: TaskRowLike; subtasks?: Array; }; expect(r.success).toBe(true); expect(r.task?.id).toBe(parentId); expect((r.subtasks ?? []).map((s) => String(s.title))).toContain('get-子1'); }); it('get 跨会话访问不存在 → 失败(会话隔离)', async () => { const parent = (await tool.execute( { operation: 'create', title: 'get-隔离' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const r = (await tool.execute( { operation: 'get', task_id: parent.task!.id }, ctxFor('s_other'), )) as { success: boolean }; expect(r.success).toBe(false); }); it('complete 标记 completed_at 且状态正确', async () => { const created = (await tool.execute( { operation: 'create', title: 'complete-me' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const id = created.task!.id; const r = (await tool.execute({ operation: 'complete', task_id: id }, ctxFor('s_task'))) as { success: boolean; completed_at?: number; }; expect(r.success).toBe(true); expect(typeof r.completed_at).toBe('number'); const got = (await tool.execute({ operation: 'get', task_id: id }, ctxFor('s_task'))) as { task?: TaskRowLike; }; expect(got.task?.status).toBe('completed'); expect(got.task?.completedAt).not.toBeNull(); }); it('complete 跨会话任务 → 失败(会话隔离)', async () => { const created = (await tool.execute( { operation: 'create', title: 'complete-隔离' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const r = (await tool.execute( { operation: 'complete', task_id: created.task!.id }, ctxFor('s_other'), )) as { success: boolean }; expect(r.success).toBe(false); }); it('update 非法 status 经顶层参数拒绝', async () => { const created = (await tool.execute( { operation: 'create', title: 'update-enum' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const id = created.task!.id; const badStatus = (await tool.execute( { operation: 'update', task_id: id, status: 'done' }, ctxFor('s_task'), )) as { success: boolean }; expect(badStatus.success).toBe(false); const badPri = (await tool.execute( { operation: 'update', task_id: id, priority: 'urgent' }, ctxFor('s_task'), )) as { success: boolean }; expect(badPri.success).toBe(false); }); it('update 可同时改多个字段(顶层字段语义,实况契约:更新字段非 updates 包)', async () => { const created = (await tool.execute( { operation: 'create', title: 'multi-update' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const id = created.task!.id; const r = (await tool.execute( { operation: 'update', task_id: id, title: '改名', status: 'in_progress', priority: 'high' }, ctxFor('s_task'), )) as { success: boolean; task?: TaskRowLike }; expect(r.success).toBe(true); expect(r.task?.title).toBe('改名'); expect(r.task?.status).toBe('in_progress'); expect(r.task?.priority).toBe('high'); }); it('update 通过 updates 包装字段 → 无可更新字段而失败(实况契约:参数在顶层)', async () => { const created = (await tool.execute( { operation: 'create', title: 'updates-wrapper' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const r = (await tool.execute( { operation: 'update', task_id: created.task!.id, updates: { title: '被忽略' } }, ctxFor('s_task'), )) as { success: boolean }; expect(r.success).toBe(false); }); it('update 无可更新字段 → 失败', async () => { const created = (await tool.execute( { operation: 'create', title: 'no-op-update' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const r = (await tool.execute( { operation: 'update', task_id: created.task!.id, updates: {} }, ctxFor('s_task'), )) as { success: boolean }; expect(r.success).toBe(false); expect(String((r as { error?: string }).error)).toContain('No fields to update'); }); it('update 跨会话任务 → 失败(会话隔离)', async () => { const created = (await tool.execute( { operation: 'create', title: 'update-隔离' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const r = (await tool.execute( { operation: 'update', task_id: created.task!.id, updates: { title: 'hack' } }, ctxFor('s_other'), )) as { success: boolean }; expect(r.success).toBe(false); }); it('缺 task_id 的 update/complete/delete/get 各自失败', async () => { for (const op of ['update', 'complete', 'delete', 'get']) { const r = (await tool.execute({ operation: op }, ctxFor('s_task'))) as { success: boolean }; expect(r.success, `expected ${op} to reject missing task_id`).toBe(false); } }); it('delete 父任务递归删除全部子任务(级联)', async () => { const parent = (await tool.execute( { operation: 'create', title: 'del-父' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const parentId = parent.task!.id; const child1 = (await tool.execute( { operation: 'create', title: 'del-子1', parent_id: parentId }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const child2 = (await tool.execute( { operation: 'create', title: 'del-子2', parent_id: parentId }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const r = (await tool.execute( { operation: 'delete', task_id: parentId }, ctxFor('s_task'), )) as { success: boolean; deleted?: number; }; expect(r.success).toBe(true); expect(r.deleted).toBe(3); // 父 + 2 子 for (const cid of [parentId, child1.task!.id, child2.task!.id]) { const got = (await tool.execute({ operation: 'get', task_id: cid }, ctxFor('s_task'))) as { success: boolean; }; expect(got.success).toBe(false); } }); it('delete 跨会话任务 → 失败(会话隔离)', async () => { const created = (await tool.execute( { operation: 'create', title: 'delete-隔离' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const r = (await tool.execute( { operation: 'delete', task_id: created.task!.id }, ctxFor('s_other'), )) as { success: boolean }; expect(r.success).toBe(false); }); it('list 支持 status 过滤与 by_status 统计', async () => { await tool.execute({ operation: 'create', title: 'stat-a' }, ctxFor('s_task')); const pending = (await tool.execute( { operation: 'list', status: 'pending' }, ctxFor('s_task'), )) as { tasks: Array; by_status: Record }; expect(pending.tasks.every((t) => t.status === 'pending')).toBe(true); expect(typeof pending.by_status.pending).toBe('number'); expect(pending.by_status.pending).toBeGreaterThanOrEqual(1); }); it('list 返回 count 与各状态计数键', async () => { const r = (await tool.execute({ operation: 'list' }, ctxFor('s_task'))) as { count: number; tasks: Array; by_status: Record; }; expect(r.count).toBe(r.tasks.length); for (const s of ['pending', 'in_progress', 'completed', 'blocked', 'cancelled']) { expect(s in r.by_status).toBe(true); } }); it('list 按 order_idx 升序排列', async () => { const r = (await tool.execute({ operation: 'list' }, ctxFor('s_task'))) as { tasks: Array; }; const orders = r.tasks.map((t) => Number(t.order)); const sorted = [...orders].sort((a, b) => a - b); expect(orders).toEqual(sorted); }); it('notify 回调仅对写操作触发(create/update/complete/delete),list/get 不触发', async () => { const before = notifyCalls.length; await tool.execute({ operation: 'list' }, ctxFor('s_task')); const created = (await tool.execute( { operation: 'create', title: 'notify-probe' }, ctxFor('s_task'), )) as { task?: TaskRowLike }; const got = await tool.execute( { operation: 'get', task_id: created.task!.id }, ctxFor('s_task'), ); void got; // 只有 create 触发;list/get 不触发 expect(notifyCalls.length).toBe(before + 1); }); it('notify 回调异常不影响工具主流程', async () => { // 单独构造一个回调抛错的 manager const { TaskManagerTool } = await import('../task-manager'); const badManager = new TaskManagerTool( () => db, () => { throw new Error('callback boom'); }, ); const badTool = badManager as unknown as typeof tool; const r = (await badTool.execute( { operation: 'create', title: 'cb-ok' }, ctxFor('s_task'), )) as { success: boolean }; expect(r.success).toBe(true); // 回调失败不阻断创建 }); it('create 返回完整 task 结构(camelCase sessionId/parentId/assignedTo/order)', async () => { const r = (await tool.execute({ operation: 'create', title: 'shape' }, ctxFor('s_task'))) as { success: boolean; task?: Record; }; expect(r.success).toBe(true); const t = r.task!; expect(t.sessionId).toBe('s_task'); expect(t.parentId).toBeNull(); expect(typeof t.order).toBe('number'); expect(t.status).toBe('pending'); expect(t.priority).toBe('medium'); // 默认值 expect(t.completedAt).toBeNull(); expect(typeof t.id).toBe('string'); }); it('不同 session 的 order_idx 各自独立', async () => { await tool.execute({ operation: 'create', title: 'ord-a' }, ctxFor('s_other')); await tool.execute({ operation: 'create', title: 'ord-b' }, ctxFor('s_other')); const r = (await tool.execute({ operation: 'list' }, ctxFor('s_other'))) as { tasks: Array; }; const orders = r.tasks.map((t) => Number(t.order)); expect(orders).toEqual([0, 1]); // s_other 独立从 0 开始 }); });