basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2026 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import * as fs from 'node:fs/promises';8import * as os from 'node:os';9import * as path from 'node:path';10import { afterEach, beforeEach, describe, expect, it } from 'vitest';11import { Storage } from '../config/storage.js';12import { CronScheduler } from '../services/cronScheduler.js';13import type { Config } from '../config/config.js';14import { LoopWakeupTool } from './loop-wakeup.js';15 16// The scheduling math (clamp / wasClamped / second-precise fire time) is17// covered in cronScheduler.test.ts `session wakeups`. These tests cover the18// tool surface: it delegates to scheduleWakeup and reports the outcome.19describe('LoopWakeupTool', () => {20 let tmpDir: string;21 let scheduler: CronScheduler;22 let tool: LoopWakeupTool;23 24 function makeConfig(): Config {25 scheduler = new CronScheduler(tmpDir);26 return {27 getCronScheduler: () => scheduler,28 getProjectRoot: () => tmpDir,29 } as unknown as Config;30 }31 32 beforeEach(async () => {33 tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'loop-wakeup-test-'));34 Storage.setRuntimeBaseDir(tmpDir);35 tool = new LoopWakeupTool(makeConfig());36 scheduler.start(() => {});37 });38 39 afterEach(async () => {40 scheduler.destroy();41 Storage.setRuntimeBaseDir(null);42 await fs.rm(tmpDir, { recursive: true, force: true });43 });44 45 it('has the correct name', () => {46 expect(tool.name).toBe('loop_wakeup');47 });48 49 it('documents the fallback-heartbeat semantics for monitor/background work', () => {50 expect(tool.description).toContain('fallback heartbeat');51 expect(tool.description).toContain('<task-notification>');52 expect(tool.description).toContain('terminal `<task-notification>`');53 expect(tool.description).not.toContain('per stdout line');54 const params = tool.schema.parametersJsonSchema as {55 properties: { delaySeconds: { description: string } };56 };57 const delay = params.properties.delaySeconds.description;58 // Both sides of the rule: long fallback when something else wakes you,59 // short poll only when you are the sole watcher.60 expect(delay).toContain('1200-1800s');61 expect(delay).toContain('60-270s');62 expect(delay).toContain('Monitor');63 });64 65 it('uses ask permission because it schedules future model input', async () => {66 const invocation = tool.build({67 delaySeconds: 300,68 prompt: 'continue loop',69 });70 await expect(invocation.getDefaultPermission()).resolves.toBe('ask');71 });72 73 it('shows the clamped delay in the permission description', () => {74 const invocation = tool.build({ delaySeconds: 5, prompt: 'continue loop' });75 76 expect(invocation.getDescription()).toBe(77 '60s (requested 5s): continue loop',78 );79 });80 81 it('shows the plain delay in the permission description when in range', () => {82 const invocation = tool.build({83 delaySeconds: 300,84 prompt: 'continue loop',85 });86 87 expect(invocation.getDescription()).toBe('300s: continue loop');88 });89 90 it('does not show rounded in-range delays as requested values', () => {91 const invocation = tool.build({92 delaySeconds: 60.4,93 prompt: 'continue loop',94 });95 96 expect(invocation.getDescription()).toBe('60s: continue loop');97 });98 99 it('schedules a session-only one-shot wakeup on the scheduler', async () => {100 const invocation = tool.build({101 delaySeconds: 300,102 prompt: 'continue loop',103 reason: 'CI is still running',104 });105 106 const result = await invocation.execute(new AbortController().signal);107 108 expect(result.error).toBeUndefined();109 expect(result.llmContent).toContain('Session-only one-shot');110 expect(result.llmContent).toContain('Scheduled for:');111 // Registered as a wakeup: it holds the session open and is manageable112 // through CronList/CronDelete, but does not count against cron capacity.113 expect(scheduler.sessionSize).toBe(1);114 expect(scheduler.list()[0]).toMatchObject({115 cronExpr: '@wakeup',116 prompt: 'continue loop',117 });118 expect(scheduler.size).toBe(1);119 });120 121 it('rejects scheduling when the scheduler is disabled', async () => {122 scheduler.disable();123 const invocation = tool.build({124 delaySeconds: 300,125 prompt: 'continue loop',126 });127 128 const result = await invocation.execute(new AbortController().signal);129 130 expect(result.error?.message).toBe(131 'Loop wakeups are disabled for the rest of this session ' +132 '(token limit reached). Restart the session to re-enable.',133 );134 expect(scheduler.sessionSize).toBe(0);135 });136 137 it('schedules even when the scheduler is stopped but not disabled', async () => {138 // The first self-paced /loop in a session with no cron jobs arms a139 // wakeup before the scheduler has started — the post-prompt hook starts140 // the tick afterwards. A merely-stopped scheduler must not reject.141 scheduler.stop();142 const invocation = tool.build({143 delaySeconds: 300,144 prompt: 'continue loop',145 });146 147 const result = await invocation.execute(new AbortController().signal);148 149 expect(result.error).toBeUndefined();150 expect(result.llmContent).toContain('Scheduled loop wakeup');151 expect(scheduler.sessionSize).toBe(1);152 });153 154 it('tells the model to re-arm to keep the loop alive', async () => {155 const invocation = tool.build({156 delaySeconds: 300,157 prompt: 'continue loop',158 });159 const result = await invocation.execute(new AbortController().signal);160 expect(result.llmContent).toContain('keep the loop alive');161 });162 163 it('reports the clamp when the requested delay is out of range', async () => {164 const invocation = tool.build({ delaySeconds: 5, prompt: 'continue loop' });165 const result = await invocation.execute(new AbortController().signal);166 167 expect(result.llmContent).toContain('clamped');168 expect(result.llmContent).toContain('Scheduled for:');169 expect(result.llmContent).toContain('(in 60s).');170 expect(result.llmContent).toContain(171 'Requested 5s was clamped to the [60, 3600] s range.',172 );173 });174 175 it('reports when a wakeup replaces an earlier pending wakeup', async () => {176 const first = await tool177 .build({ delaySeconds: 300, prompt: 'first' })178 .execute(new AbortController().signal);179 const firstId = String(first.llmContent).match(/wakeup ([a-z0-9]+)\./)?.[1];180 181 const second = await tool182 .build({ delaySeconds: 300, prompt: 'second' })183 .execute(new AbortController().signal);184 185 expect(firstId).toBeDefined();186 expect(second.llmContent).toContain(`Replaced pending wakeup ${firstId}.`);187 });188 189 it('does not report a clamp when the delay is in range', async () => {190 const invocation = tool.build({191 delaySeconds: 300,192 prompt: 'continue loop',193 });194 const result = await invocation.execute(new AbortController().signal);195 196 expect(result.llmContent).not.toContain('clamped');197 });198 199 it('echoes the reason back to the user', async () => {200 const invocation = tool.build({201 delaySeconds: 300,202 prompt: 'continue loop',203 reason: 'waiting on the deploy',204 });205 const result = await invocation.execute(new AbortController().signal);206 207 expect(result.llmContent).toContain('waiting on the deploy');208 expect(result.returnDisplay).toContain('waiting on the deploy');209 });210 211 it('rejects an empty continuation prompt', async () => {212 const invocation = tool.build({ delaySeconds: 300, prompt: ' ' });213 const result = await invocation.execute(new AbortController().signal);214 215 expect(result.error?.message).toBe('Loop wakeup prompt must not be empty.');216 expect(scheduler.sessionSize).toBe(0);217 });218 219 it('projects scheduling details into AUTO classifier input', () => {220 expect(221 tool.toAutoClassifierInput({222 delaySeconds: 300,223 prompt: 'continue loop',224 reason: 'CI is still running',225 }),226 ).toEqual({227 delaySeconds: 300,228 prompt: 'continue loop',229 reason: 'CI is still running',230 });231 });232 233 it('projects the clamped delay into AUTO classifier input', () => {234 expect(235 tool.toAutoClassifierInput({236 delaySeconds: 5,237 prompt: 'continue loop',238 }),239 ).toMatchObject({240 delaySeconds: 60,241 prompt: 'continue loop',242 });243 });244 245 it('defaults reason to an empty string in classifier input when omitted', () => {246 expect(247 tool.toAutoClassifierInput({248 delaySeconds: 300,249 prompt: 'continue loop',250 }),251 ).toEqual({252 delaySeconds: 300,253 prompt: 'continue loop',254 reason: '',255 });256 });257 258 it('surfaces a scheduler failure as a structured tool error', async () => {259 const failingConfig = {260 getCronScheduler: () => ({261 disabled: false,262 scheduleWakeup: () => {263 throw new Error('scheduler boom', {264 cause: new Error('clock unavailable'),265 });266 },267 }),268 getProjectRoot: () => tmpDir,269 } as unknown as Config;270 const failingTool = new LoopWakeupTool(failingConfig);271 const invocation = failingTool.build({272 delaySeconds: 300,273 prompt: 'continue loop',274 });275 276 const result = await invocation.execute(new AbortController().signal);277 278 expect(result.error?.message).toBe(279 'scheduler boom (cause: clock unavailable)',280 );281 expect(result.llmContent).toContain('Error scheduling loop wakeup:');282 });283});284 