basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';8import * as fs from 'node:fs/promises';9import * as os from 'node:os';10import * as path from 'node:path';11import { TaskUpdateTool } from './task-update.js';12import { createTask, getTask } from '../agents/team/tasks.js';13import type { ApprovalMode, Config } from '../config/config.js';14import { runWithTeammateIdentity } from '../agents/team/identity.js';15 16const DEFAULT_MODE = 'default' as ApprovalMode;17const PLAN_MODE = 'plan' as ApprovalMode;18 19vi.mock('../config/storage.js', () => {20 let mockDir = '/tmp/test';21 return {22 Storage: {23 getGlobalQwenDir: () => mockDir,24 },25 __setMockGlobalDir: (d: string) => {26 mockDir = d;27 },28 };29});30 31// eslint-disable-next-line @typescript-eslint/no-explicit-any32const { __setMockGlobalDir } = (await import('../config/storage.js')) as any;33 34let tmpDir: string;35const TEAM = 'test-team';36 37function makeConfig(approvalMode = DEFAULT_MODE) {38 return {39 getTeamContext: () => ({ teamName: TEAM }),40 getApprovalMode: () => approvalMode,41 } as unknown as Config;42}43 44beforeEach(async () => {45 tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-update-test-'));46 __setMockGlobalDir(tmpDir);47});48 49afterEach(async () => {50 await fs.rm(tmpDir, { recursive: true, force: true });51});52 53describe('TaskUpdateTool', () => {54 let tool: TaskUpdateTool;55 56 beforeEach(() => {57 tool = new TaskUpdateTool(makeConfig());58 });59 60 it('has the correct name', () => {61 expect(tool.name).toBe('task_update');62 });63 64 it('updates a task status', async () => {65 const task = await createTask(TEAM, {66 subject: 'Test',67 description: 'desc',68 });69 const invocation = tool.build({70 taskId: task.id,71 status: 'completed',72 });73 const result = await invocation.execute(new AbortController().signal);74 expect(result.error).toBeUndefined();75 expect(result.llmContent).toContain('completed');76 });77 78 it('deletes a task with status "deleted"', async () => {79 const task = await createTask(TEAM, {80 subject: 'Delete me',81 description: 'desc',82 });83 const invocation = tool.build({84 taskId: task.id,85 status: 'deleted',86 });87 const result = await invocation.execute(new AbortController().signal);88 expect(result.error).toBeUndefined();89 expect(result.llmContent).toContain('deleted');90 });91 92 it('returns error for non-existent task', async () => {93 const invocation = tool.build({94 taskId: '999',95 status: 'completed',96 });97 const result = await invocation.execute(new AbortController().signal);98 expect(result.error).toBeDefined();99 expect(result.llmContent).toContain('not found');100 });101 102 it('allows plan-required teammates to claim a task before approval', async () => {103 const task = await createTask(TEAM, {104 subject: 'Plan first',105 description: 'desc',106 });107 const planTool = new TaskUpdateTool(makeConfig(PLAN_MODE));108 const invocation = planTool.build({109 taskId: task.id,110 status: 'in_progress',111 });112 113 const result = await runWithTeammateIdentity(114 {115 agentName: 'planner',116 teamName: TEAM,117 agentId: 'planner@test-team',118 isTeamLead: false,119 planModeRequired: true,120 },121 () => invocation.execute(new AbortController().signal),122 );123 124 expect(result.error).toBeUndefined();125 const reloaded = await getTask(TEAM, task.id);126 expect(reloaded?.status).toBe('in_progress');127 expect(reloaded?.owner).toBe('planner');128 });129 130 it('blocks plan-required teammates from mutating tasks before approval', async () => {131 const task = await createTask(TEAM, {132 subject: 'Plan first',133 description: 'desc',134 });135 const planTool = new TaskUpdateTool(makeConfig(PLAN_MODE));136 const invocation = planTool.build({137 taskId: task.id,138 description: 'New executable instruction.',139 });140 141 const result = await runWithTeammateIdentity(142 {143 agentName: 'planner',144 teamName: TEAM,145 agentId: 'planner@test-team',146 isTeamLead: false,147 planModeRequired: true,148 },149 () => invocation.execute(new AbortController().signal),150 );151 152 expect(result.error).toBeDefined();153 expect(result.llmContent).toContain('waiting for leader approval');154 const reloaded = await getTask(TEAM, task.id);155 expect(reloaded?.description).toBe('desc');156 });157 158 it('does not let plan-required teammates reclaim non-pending tasks before approval', async () => {159 const task = await createTask(TEAM, {160 subject: 'Completed',161 description: 'desc',162 });163 await tool164 .build({ taskId: task.id, status: 'completed' })165 .execute(new AbortController().signal);166 167 const planTool = new TaskUpdateTool(makeConfig(PLAN_MODE));168 const result = await runWithTeammateIdentity(169 {170 agentName: 'planner',171 teamName: TEAM,172 agentId: 'planner@test-team',173 isTeamLead: false,174 planModeRequired: true,175 },176 () =>177 planTool178 .build({ taskId: task.id, status: 'in_progress' })179 .execute(new AbortController().signal),180 );181 182 expect(result.error).toBeDefined();183 expect(result.llmContent).toContain('unowned pending task');184 const reloaded = await getTask(TEAM, task.id);185 expect(reloaded?.status).toBe('completed');186 });187 188 it('validates required taskId', () => {189 expect(() => tool.build({} as never)).toThrow();190 });191 192 it('rejects addBlockedBy that references a missing task', async () => {193 const task = await createTask(TEAM, {194 subject: 'Test',195 description: 'desc',196 });197 const invocation = tool.build({198 taskId: task.id,199 addBlockedBy: ['999'],200 });201 const result = await invocation.execute(new AbortController().signal);202 expect(result.error).toBeDefined();203 expect(result.llmContent).toContain('not found');204 expect(result.llmContent).toContain('#999');205 206 // Primary task must remain unchanged when validation fails so207 // the model can retry with a real id.208 const { getTask } = await import('../agents/team/tasks.js');209 const reloaded = await getTask(TEAM, task.id);210 expect(reloaded?.blockedBy ?? []).toEqual([]);211 });212 213 it('rejects addBlocks that references a missing task', async () => {214 const task = await createTask(TEAM, {215 subject: 'Test',216 description: 'desc',217 });218 const invocation = tool.build({219 taskId: task.id,220 addBlocks: ['999'],221 });222 const result = await invocation.execute(new AbortController().signal);223 expect(result.error).toBeDefined();224 expect(result.llmContent).toContain('#999');225 });226 227 it('mirrors dependency edges when both ids exist', async () => {228 const a = await createTask(TEAM, { subject: 'A', description: 'a' });229 const b = await createTask(TEAM, { subject: 'B', description: 'b' });230 const invocation = tool.build({231 taskId: a.id,232 addBlockedBy: [b.id],233 });234 const result = await invocation.execute(new AbortController().signal);235 expect(result.error).toBeUndefined();236 237 const { getTask } = await import('../agents/team/tasks.js');238 const aReloaded = await getTask(TEAM, a.id);239 const bReloaded = await getTask(TEAM, b.id);240 expect(aReloaded?.blockedBy).toContain(b.id);241 expect(bReloaded?.blocks).toContain(a.id);242 });243 244 it('does not re-block a dependent when completing with addBlocks in the same call', async () => {245 // Regression (verified repro): task_update({ status:'completed',246 // addBlocks:['2'] }) merged the edge, ran completion-unblock (a247 // no-op because the reciprocal blockedBy didn't exist yet), then the248 // addBlocks reciprocal added blockedBy:['1'] back — leaving task 2249 // permanently blocked by the already-completed task 1, so auto-claim250 // would never pick it up. The tool now skips the addBlocks reciprocal251 // when the same call completes the task.252 const a = await createTask(TEAM, { subject: 'A', description: 'a' });253 const b = await createTask(TEAM, { subject: 'B', description: 'b' });254 255 const invocation = tool.build({256 taskId: a.id,257 status: 'completed',258 addBlocks: [b.id],259 });260 const result = await invocation.execute(new AbortController().signal);261 expect(result.error).toBeUndefined();262 263 const { getTask } = await import('../agents/team/tasks.js');264 const aReloaded = await getTask(TEAM, a.id);265 const bReloaded = await getTask(TEAM, b.id);266 expect(aReloaded?.status).toBe('completed');267 // The completed blocker must leave b claimable, not blocked.268 expect(bReloaded?.blockedBy ?? []).toEqual([]);269 });270 271 it('rejects a self-edge', async () => {272 // A task blocked by itself can never be auto-claimed (non-empty273 // blockedBy) and can never complete to unblock itself — a silent274 // permanent deadlock if accepted.275 const task = await createTask(TEAM, { subject: 'T', description: 'd' });276 const invocation = tool.build({277 taskId: task.id,278 addBlockedBy: [task.id],279 });280 const result = await invocation.execute(new AbortController().signal);281 expect(result.error).toBeDefined();282 expect(result.llmContent).toContain('itself');283 284 const { getTask } = await import('../agents/team/tasks.js');285 const reloaded = await getTask(TEAM, task.id);286 expect(reloaded?.blockedBy ?? []).toEqual([]);287 });288 289 it('rejects an edge that closes a dependency cycle', async () => {290 const a = await createTask(TEAM, { subject: 'A', description: 'a' });291 const b = await createTask(TEAM, { subject: 'B', description: 'b' });292 const c = await createTask(TEAM, { subject: 'C', description: 'c' });293 294 // a → b → c (blocks direction), then closing c → a must fail.295 let result = await tool296 .build({ taskId: b.id, addBlockedBy: [a.id] })297 .execute(new AbortController().signal);298 expect(result.error).toBeUndefined();299 result = await tool300 .build({ taskId: c.id, addBlockedBy: [b.id] })301 .execute(new AbortController().signal);302 expect(result.error).toBeUndefined();303 304 result = await tool305 .build({ taskId: a.id, addBlockedBy: [c.id] })306 .execute(new AbortController().signal);307 expect(result.error).toBeDefined();308 expect(result.llmContent).toContain('cycle');309 310 // The rejected edge must not be half-persisted.311 const { getTask } = await import('../agents/team/tasks.js');312 const aReloaded = await getTask(TEAM, a.id);313 expect(aReloaded?.blockedBy ?? []).toEqual([]);314 });315 316 // ─── Permission surface ───────────────────────────────────317 // Mirrors task-create: a regression back to 'allow' or the base ''318 // classifier sentinel re-opens the instruction-rewrite path.319 320 it("defaults to 'ask' permission", async () => {321 const invocation = tool.build({ taskId: '1', status: 'completed' });322 await expect(invocation.getDefaultPermission()).resolves.toBe('ask');323 });324 325 it('projects the mutating fields to the AUTO classifier', () => {326 const projected = tool.toAutoClassifierInput({327 taskId: '1',328 status: 'in_progress',329 owner: 'worker',330 description: 'rewritten instruction',331 });332 expect(projected).toMatchObject({333 taskId: '1',334 status: 'in_progress',335 owner: 'worker',336 description: 'rewritten instruction',337 });338 });339 340 it('shows an updated description in the confirmation prompt', async () => {341 const invocation = tool.build({342 taskId: '7',343 description: 'New instruction text the teammate will execute.',344 });345 const details = await invocation.getConfirmationDetails(346 new AbortController().signal,347 );348 expect(details.type).toBe('info');349 expect((details as { prompt: string }).prompt).toContain(350 'New instruction text the teammate will execute.',351 );352 });353});354 