CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
loop-wakeup.test.ts284 linesDownload Raw Back to tools
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 
basant307/AI_Governance_Project · CoolFace