magnitude-core
Version:
Magnitude e2e testing agent
195 lines (194 loc) • 7 kB
JavaScript
import { describe, expect, test } from 'bun:test';
import EventEmitter from 'eventemitter3';
// Minimal Agent-like class that isolates pause/resume logic for testing
// without requiring LLM clients, connectors, or BAML dependencies
class PauseableLoop {
events = new EventEmitter();
doneActing = false;
_paused = false;
_pauseResolve = null;
async _waitIfPaused() {
if (!this._paused)
return;
this.events.emit('pause');
await new Promise((resolve) => {
this._pauseResolve = resolve;
});
}
pause() {
this._paused = true;
}
resume() {
this._paused = false;
if (this._pauseResolve) {
this._pauseResolve();
this._pauseResolve = null;
}
this.events.emit('resume');
}
get paused() {
return this._paused;
}
queueDone() {
this.doneActing = true;
}
stop() {
this.doneActing = true;
if (this._paused) {
this.resume();
}
}
/**
* Simulates the Agent._act() loop structure.
* Each "batch" is an array of action labels. The loop processes batches
* until doneActing is set, mirroring the real while(true) loop.
*/
async runLoop(batches, onAction) {
this.doneActing = false;
let batchIndex = 0;
while (true) {
const actions = batches[batchIndex % batches.length];
batchIndex++;
for (const action of actions) {
await this._waitIfPaused();
if (this.doneActing)
return;
onAction(action);
}
await this._waitIfPaused();
if (this.doneActing)
return;
}
}
}
describe('Agent pause/resume', () => {
test('pause() sets paused flag', () => {
const loop = new PauseableLoop();
expect(loop.paused).toBe(false);
loop.pause();
expect(loop.paused).toBe(true);
});
test('resume() clears paused flag', () => {
const loop = new PauseableLoop();
loop.pause();
loop.resume();
expect(loop.paused).toBe(false);
});
test('resume() when not paused is a no-op (no error)', () => {
const loop = new PauseableLoop();
expect(() => loop.resume()).not.toThrow();
expect(loop.paused).toBe(false);
});
test('loop pauses before action and resumes on resume()', async () => {
const loop = new PauseableLoop();
const executed = [];
// Pause immediately so first action blocks
loop.pause();
const loopPromise = loop.runLoop([['a', 'b']], (label) => {
executed.push(label);
// After executing both actions, stop
if (executed.length === 2)
loop.queueDone();
});
// Give microtasks a chance to settle — loop should be blocked
await new Promise(r => setTimeout(r, 20));
expect(executed).toEqual([]);
// Resume — loop should execute actions then stop
loop.resume();
await loopPromise;
expect(executed).toEqual(['a', 'b']);
});
test('pause mid-batch stops before next action', async () => {
const loop = new PauseableLoop();
const executed = [];
const loopPromise = loop.runLoop([['a', 'b', 'c']], (label) => {
executed.push(label);
if (label === 'a')
loop.pause(); // pause after first action
});
// Wait for loop to pause after 'a'
await new Promise(r => setTimeout(r, 20));
expect(executed).toEqual(['a']);
// Resume and let it finish
loop.resume();
await new Promise(r => setTimeout(r, 20));
// 'b' should now execute, pause again since flag was cleared by resume
// Actually after resume, _paused is false, so b and c run
expect(executed).toContain('b');
expect(executed).toContain('c');
// Stop the loop
loop.stop();
await loopPromise;
});
test('pause emits pause event, resume emits resume event', async () => {
const loop = new PauseableLoop();
const events = [];
loop.events.on('pause', () => events.push('pause'));
loop.events.on('resume', () => events.push('resume'));
loop.pause();
const loopPromise = loop.runLoop([['a']], () => loop.queueDone());
// Wait for loop to hit _waitIfPaused and emit 'pause'
await new Promise(r => setTimeout(r, 20));
expect(events).toEqual(['pause']);
loop.resume();
await loopPromise;
expect(events).toEqual(['pause', 'resume']);
});
test('stop() while paused unblocks the loop', async () => {
const loop = new PauseableLoop();
const executed = [];
loop.pause();
const loopPromise = loop.runLoop([['a', 'b']], (label) => executed.push(label));
await new Promise(r => setTimeout(r, 20));
expect(executed).toEqual([]);
// stop() should set doneActing and resume so loop exits
loop.stop();
await loopPromise;
// No actions should have executed — loop exits immediately after unblocking
expect(executed).toEqual([]);
});
test('pause between batches blocks next iteration', async () => {
const loop = new PauseableLoop();
const executed = [];
let batchCount = 0;
const loopPromise = loop.runLoop([['a'], ['b']], (label) => {
executed.push(label);
batchCount++;
// After first batch completes, pause before second batch
if (batchCount === 1)
loop.pause();
if (batchCount === 2)
loop.queueDone();
});
// Let first batch run, then loop should pause at between-batch check
await new Promise(r => setTimeout(r, 20));
expect(executed).toEqual(['a']);
loop.resume();
await loopPromise;
expect(executed).toEqual(['a', 'b']);
});
test('multiple pause/resume cycles work correctly', async () => {
const loop = new PauseableLoop();
const executed = [];
let actionCount = 0;
const loopPromise = loop.runLoop([['x']], (label) => {
actionCount++;
executed.push(`${label}${actionCount}`);
if (actionCount < 3) {
loop.pause(); // pause after each action
}
else {
loop.queueDone(); // done after 3rd
}
});
// First action runs, then pauses
await new Promise(r => setTimeout(r, 20));
expect(executed).toEqual(['x1']);
loop.resume();
await new Promise(r => setTimeout(r, 20));
expect(executed).toEqual(['x1', 'x2']);
loop.resume();
await loopPromise;
expect(executed).toEqual(['x1', 'x2', 'x3']);
});
});