Skip to content

Commit 202fcaa

Browse files
committed
Limit output history and reset test state
1 parent 3f63ed1 commit 202fcaa

File tree

6 files changed

+101
-7
lines changed

6 files changed

+101
-7
lines changed

jest.config.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,6 @@ module.exports = {
2525
detectOpenHandles: true,
2626
// Speed up test execution
2727
maxWorkers: process.env.CI ? 2 : '50%',
28-
};
28+
// Recycle workers if they retain too much memory
29+
workerIdleMemoryLimit: process.env.CI ? '1024MB' : undefined,
30+
};

src/state-machine/dispatch/history-snapshot.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import type { EngineContext } from '../../types/engine';
22
import { logger } from '../../logger';
33

4+
function getHistoryLimit(): number | undefined {
5+
const raw = process.env.VISOR_TEST_HISTORY_LIMIT || process.env.VISOR_OUTPUT_HISTORY_LIMIT;
6+
if (!raw) return undefined;
7+
const n = parseInt(raw, 10);
8+
return Number.isFinite(n) && n > 0 ? n : undefined;
9+
}
10+
411
/**
512
* Build output history Map from journal for template rendering
613
* This matches the format expected by AI providers.
714
* Moved from LevelDispatch to reduce file size and improve reuse.
815
*/
916
export function buildOutputHistoryFromJournal(context: EngineContext): Map<string, unknown[]> {
1017
const outputHistory = new Map<string, unknown[]>();
18+
const limit = getHistoryLimit();
1119

1220
try {
1321
const snapshot = context.journal.beginSnapshot();
@@ -41,7 +49,13 @@ export function buildOutputHistoryFromJournal(context: EngineContext): Map<strin
4149
continue;
4250
}
4351
} catch {}
44-
if (payload !== undefined) outputHistory.get(checkId)!.push(payload);
52+
if (payload !== undefined) {
53+
const arr = outputHistory.get(checkId)!;
54+
arr.push(payload);
55+
if (limit && arr.length > limit) {
56+
arr.splice(0, arr.length - limit);
57+
}
58+
}
4559
}
4660
} catch (error) {
4761
logger.debug(`[LevelDispatch] Error building output history: ${error}`);

src/state-machine/states/level-dispatch.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,20 @@ function recordOnFinishRoutingEvent(args: {
7272
addEvent('visor.routing', attrs);
7373
}
7474

75+
function getHistoryLimit(): number | undefined {
76+
const raw = process.env.VISOR_TEST_HISTORY_LIMIT || process.env.VISOR_OUTPUT_HISTORY_LIMIT;
77+
if (!raw) return undefined;
78+
const n = parseInt(raw, 10);
79+
return Number.isFinite(n) && n > 0 ? n : undefined;
80+
}
81+
7582
/**
7683
* Build output history Map from journal for template rendering
7784
* This matches the format expected by AI providers
7885
*/
7986
function buildOutputHistoryFromJournal(context: EngineContext): Map<string, unknown[]> {
8087
const outputHistory = new Map<string, unknown[]>();
88+
const limit = getHistoryLimit();
8189

8290
try {
8391
const snapshot = context.journal.beginSnapshot();
@@ -94,7 +102,13 @@ function buildOutputHistoryFromJournal(context: EngineContext): Map<string, unkn
94102
// outputs_history['security'].last.issues[...] works in prompts and tests.
95103
const payload =
96104
entry.result.output !== undefined ? entry.result.output : (entry.result as unknown);
97-
if (payload !== undefined) outputHistory.get(checkId)!.push(payload);
105+
if (payload !== undefined) {
106+
const arr = outputHistory.get(checkId)!;
107+
arr.push(payload);
108+
if (limit && arr.length > limit) {
109+
arr.splice(0, arr.length - limit);
110+
}
111+
}
98112
}
99113
} catch (error) {
100114
// Silently fail - return empty map

src/state-machine/states/routing.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ function createMemoryHelpers() {
154154
};
155155
}
156156

157+
function getHistoryLimit(): number | undefined {
158+
const raw = process.env.VISOR_TEST_HISTORY_LIMIT || process.env.VISOR_OUTPUT_HISTORY_LIMIT;
159+
if (!raw) return undefined;
160+
const n = parseInt(raw, 10);
161+
return Number.isFinite(n) && n > 0 ? n : undefined;
162+
}
163+
157164
type RoutingTrigger = 'on_success' | 'on_fail' | 'on_finish';
158165
type RoutingAction = 'run' | 'goto' | 'retry';
159166
type RoutingSource = 'run' | 'run_js' | 'goto' | 'goto_js' | 'transitions' | 'retry';
@@ -1252,6 +1259,7 @@ async function evaluateRunJs(
12521259
): Promise<string[]> {
12531260
try {
12541261
const sandbox = createSecureSandbox();
1262+
const historyLimit = getHistoryLimit();
12551263

12561264
// Build outputs record and outputs_history
12571265
const snapshotId = context.journal.beginSnapshot();
@@ -1287,8 +1295,12 @@ async function evaluateRunJs(
12871295
try {
12881296
const history = contextView.getHistory(checkIdFromJournal);
12891297
if (history && history.length > 0) {
1298+
const trimmed =
1299+
historyLimit && history.length > historyLimit
1300+
? history.slice(history.length - historyLimit)
1301+
: history;
12901302
// Extract outputs from history (prefer output field if available)
1291-
outputsHistory[checkIdFromJournal] = history.map((r: any) =>
1303+
outputsHistory[checkIdFromJournal] = trimmed.map((r: any) =>
12921304
r.output !== undefined ? r.output : r
12931305
);
12941306
}
@@ -1392,6 +1404,7 @@ export async function evaluateGoto(
13921404
if (gotoJs) {
13931405
try {
13941406
const sandbox = createSecureSandbox();
1407+
const historyLimit = getHistoryLimit();
13951408

13961409
// Build outputs record and outputs_history from the full session snapshot.
13971410
// Do not filter by event here — on_finish (especially forEach post-children) may
@@ -1429,8 +1442,12 @@ export async function evaluateGoto(
14291442
try {
14301443
const history = contextView.getHistory(checkIdFromJournal);
14311444
if (history && history.length > 0) {
1445+
const trimmed =
1446+
historyLimit && history.length > historyLimit
1447+
? history.slice(history.length - historyLimit)
1448+
: history;
14321449
// Extract outputs from history (prefer output field if available)
1433-
outputsHistory[checkIdFromJournal] = history.map((r: any) =>
1450+
outputsHistory[checkIdFromJournal] = trimmed.map((r: any) =>
14341451
r.output !== undefined ? r.output : r
14351452
);
14361453
}
@@ -1572,6 +1589,7 @@ export async function evaluateTransitions(
15721589
if (!transitions || transitions.length === 0) return undefined;
15731590
try {
15741591
const sandbox = createSecureSandbox();
1592+
const historyLimit = getHistoryLimit();
15751593

15761594
// Build outputs record and outputs_history from the full session snapshot
15771595
const snapshotId = context.journal.beginSnapshot();
@@ -1590,7 +1608,13 @@ export async function evaluateTransitions(
15901608
try {
15911609
const hist = view.getHistory(cid);
15921610
if (hist && hist.length > 0) {
1593-
outputsHistory[cid] = hist.map((r: any) => (r.output !== undefined ? r.output : r));
1611+
const trimmed =
1612+
historyLimit && hist.length > historyLimit
1613+
? hist.slice(hist.length - historyLimit)
1614+
: hist;
1615+
outputsHistory[cid] = trimmed.map((r: any) =>
1616+
r.output !== undefined ? r.output : r
1617+
);
15941618
}
15951619
} catch {}
15961620
}

src/test-runner/index.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { StateMachineExecutionEngine } from '../state-machine-execution-engine';
66
import type { PRInfo } from '../pr-analyzer';
77
import { RecordingOctokit } from './recorders/github-recorder';
88
import { MemoryStore } from '../memory-store';
9+
import { SessionRegistry } from '../session-registry';
910
import { setGlobalRecorder } from './recorders/global-recorder';
1011
// import { FixtureLoader } from './fixture-loader';
1112
import { type ExpectBlock } from './assertions';
@@ -35,6 +36,15 @@ export interface DiscoverOptions {
3536
testsPath?: string; // File, directory, or glob pattern
3637
cwd?: string;
3738
}
39+
function ensureTestEnvDefaults(): void {
40+
if (!process.env.VISOR_TEST_MODE) process.env.VISOR_TEST_MODE = 'true';
41+
if (!process.env.VISOR_TEST_PROMPT_MAX_CHARS) {
42+
process.env.VISOR_TEST_PROMPT_MAX_CHARS = process.env.CI === 'true' ? '4000' : '8000';
43+
}
44+
if (!process.env.VISOR_TEST_HISTORY_LIMIT) {
45+
process.env.VISOR_TEST_HISTORY_LIMIT = process.env.CI === 'true' ? '200' : '500';
46+
}
47+
}
3848
/**
3949
* Very small glob-to-RegExp converter supporting **, *, and ?
4050
* - ** matches across path separators
@@ -180,6 +190,7 @@ export async function runSuites(
180190
}>;
181191
}>;
182192
}> {
193+
ensureTestEnvDefaults();
183194
const perSuite: Array<{
184195
file: string;
185196
failures: number;
@@ -339,6 +350,10 @@ export class VisorTestRunner {
339350
try {
340351
MemoryStore.resetInstance();
341352
} catch {}
353+
// Always clear AI sessions between cases to prevent cross-case leakage
354+
try {
355+
SessionRegistry.getInstance().clearAllSessions();
356+
} catch {}
342357
// Always use StateMachineExecutionEngine
343358
const engine = new StateMachineExecutionEngine(undefined as any, recorder as unknown as any);
344359
try {
@@ -751,8 +766,11 @@ export class VisorTestRunner {
751766
const ghRec = defaultsAny?.github_recorder as
752767
| { error_code?: number; timeout_ms?: number }
753768
| undefined;
769+
const envPromptCapRaw = process.env.VISOR_TEST_PROMPT_MAX_CHARS;
770+
const envPromptCap = envPromptCapRaw ? parseInt(envPromptCapRaw, 10) : undefined;
754771
const defaultPromptCap: number | undefined =
755-
options.promptMaxChars ||
772+
options.promptMaxChars ??
773+
(Number.isFinite(envPromptCap as number) ? (envPromptCap as number) : undefined) ??
756774
(typeof defaultsAny?.prompt_max_chars === 'number'
757775
? defaultsAny.prompt_max_chars
758776
: undefined);
@@ -1224,6 +1242,10 @@ export class VisorTestRunner {
12241242
try {
12251243
MemoryStore.resetInstance();
12261244
} catch {}
1245+
// Clear AI sessions before each stage to avoid leakage across stages
1246+
try {
1247+
SessionRegistry.getInstance().clearAllSessions();
1248+
} catch {}
12271249
// Prepare default tag filters for this flow (inherit suite defaults)
12281250
const parseTags = (v: unknown): string[] | undefined => {
12291251
if (!v) return undefined;

tests/setup.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
11
// Test setup file to configure mocks and prevent external API calls
2+
import { MemoryStore } from '../src/memory-store';
3+
import { SessionRegistry } from '../src/session-registry';
4+
5+
// Default test caps to reduce memory pressure (can be overridden by env)
6+
if (!process.env.VISOR_TEST_PROMPT_MAX_CHARS) {
7+
process.env.VISOR_TEST_PROMPT_MAX_CHARS = process.env.CI === 'true' ? '4000' : '8000';
8+
}
9+
if (!process.env.VISOR_TEST_HISTORY_LIMIT) {
10+
process.env.VISOR_TEST_HISTORY_LIMIT = process.env.CI === 'true' ? '200' : '500';
11+
}
212

313
// Mock child_process.spawn globally to prevent real process spawns while leaving other methods intact
414
jest.mock('child_process', () => {
@@ -62,6 +72,14 @@ afterEach(() => {
6272
// Ensure leaked git vars are cleared after each test
6373
const gitVars = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_PREFIX', 'GIT_COMMON_DIR'];
6474
for (const k of gitVars) delete (process.env as NodeJS.ProcessEnv)[k];
75+
76+
// Clear global singletons between tests to avoid cross-test memory leaks
77+
try {
78+
MemoryStore.resetInstance();
79+
} catch {}
80+
try {
81+
SessionRegistry.getInstance().clearAllSessions();
82+
} catch {}
6583
});
6684

6785
// Set global Jest timeout for all tests

0 commit comments

Comments
 (0)