- sentinel-summaries.ts: runCapHitSummary, insertCapHitSentinel, runDoomLoopSummary, insertDoomLoopSentinel - inference.ts → inference/turn.ts: residue is runAssistantTurn, runInference, createInferenceRunner orchestration only - inference/index.ts: re-export shim preserves the public surface (createInferenceRunner, runInference, runAssistantTurn, detectDoomLoop, DOOM_LOOP_THRESHOLD, buildMessagesPayload, plus type-side InferenceContext/InferenceFrame/StreamResult/TurnArgs/ FramePublisher) - src/index.ts + auto_name.ts + the two vitest test files updated to import from ./services/inference/index.js explicitly (NodeNext ESM doesn't honor directory-index resolution) Final tally: 11 files under services/inference/, the largest being sentinel-summaries.ts at 523 LoC (two near-clone summary paths kept side-by-side until a third sentinel justifies factoring out a shared runWrapUpSummary). turn.ts is now 326 LoC, the next-largest is stream-phase.ts at 380. Public import surface unchanged. tool-phase.ts → turn.ts back-edge for runAssistantTurn remains (cycle is safe; resolved at call time). Prepares the file structure for v1.13 AI SDK migration — streamText swap targets stream-phase.ts only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.9 KiB
TypeScript
131 lines
4.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { DOOM_LOOP_THRESHOLD, detectDoomLoop } from '../inference/index.js';
|
|
import type { ToolCall } from '../../types/api.js';
|
|
|
|
// ---- fixture ----------------------------------------------------------------
|
|
// Tiny helper. `id` is required on ToolCall but irrelevant to detection —
|
|
// detectDoomLoop compares name + JSON.stringify(args). Counter-based id keeps
|
|
// each call unique so we don't accidentally test id-based equality.
|
|
|
|
let counter = 0;
|
|
function mkCall(name: string, args: Record<string, unknown> = {}): ToolCall {
|
|
counter += 1;
|
|
return { id: `c${counter}`, name, args };
|
|
}
|
|
|
|
// ---- below-threshold -------------------------------------------------------
|
|
|
|
describe('detectDoomLoop — below threshold', () => {
|
|
it('returns null for an empty array', () => {
|
|
expect(detectDoomLoop([])).toBeNull();
|
|
});
|
|
|
|
it('returns null when fewer than DOOM_LOOP_THRESHOLD calls exist', () => {
|
|
// 2 < 3 — sliding-window can't form even if both match.
|
|
const a = mkCall('view_file', { path: 'a.ts' });
|
|
const b = mkCall('view_file', { path: 'a.ts' });
|
|
expect(detectDoomLoop([a, b])).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---- positive detection ----------------------------------------------------
|
|
|
|
describe('detectDoomLoop — positive matches', () => {
|
|
it('returns name + args when exactly DOOM_LOOP_THRESHOLD identical calls land', () => {
|
|
const calls = [
|
|
mkCall('grep', { pattern: 'TODO', path: 'src' }),
|
|
mkCall('grep', { pattern: 'TODO', path: 'src' }),
|
|
mkCall('grep', { pattern: 'TODO', path: 'src' }),
|
|
];
|
|
const result = detectDoomLoop(calls);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.name).toBe('grep');
|
|
expect(result!.args).toEqual({ pattern: 'TODO', path: 'src' });
|
|
});
|
|
|
|
it('matches sliding window — last DOOM_LOOP_THRESHOLD match even with earlier non-matching calls', () => {
|
|
// 4 calls: first differs, last 3 are identical → fire.
|
|
const calls = [
|
|
mkCall('list_dir', { path: '/' }),
|
|
mkCall('view_file', { path: 'a.ts' }),
|
|
mkCall('view_file', { path: 'a.ts' }),
|
|
mkCall('view_file', { path: 'a.ts' }),
|
|
];
|
|
const result = detectDoomLoop(calls);
|
|
expect(result).not.toBeNull();
|
|
expect(result!.name).toBe('view_file');
|
|
});
|
|
|
|
it('matches identical empty-args calls (defense against {} !== {} reference bug)', () => {
|
|
// JSON.stringify on two distinct {} both produce '{}'. Confirms the
|
|
// detector uses value-equality not reference-equality.
|
|
const calls = [mkCall('ping', {}), mkCall('ping', {}), mkCall('ping', {})];
|
|
expect(detectDoomLoop(calls)).not.toBeNull();
|
|
});
|
|
|
|
it('matches calls with nested args of equal shape', () => {
|
|
// Deep-equal via JSON.stringify. If the model emits the same nested
|
|
// object three times, that's still a loop.
|
|
const nested = { filter: { glob: '*.ts', case: 'sensitive' }, limit: 50 };
|
|
const calls = [
|
|
mkCall('find_files', { ...nested }),
|
|
mkCall('find_files', { ...nested }),
|
|
mkCall('find_files', { ...nested }),
|
|
];
|
|
expect(detectDoomLoop(calls)).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---- negative detection ----------------------------------------------------
|
|
|
|
describe('detectDoomLoop — negative cases', () => {
|
|
it('returns null when 3 calls share name but differ in args', () => {
|
|
const calls = [
|
|
mkCall('view_file', { path: 'a.ts' }),
|
|
mkCall('view_file', { path: 'b.ts' }),
|
|
mkCall('view_file', { path: 'c.ts' }),
|
|
];
|
|
expect(detectDoomLoop(calls)).toBeNull();
|
|
});
|
|
|
|
it('returns null when 3 calls share args but differ in name', () => {
|
|
const calls = [
|
|
mkCall('view_file', { path: 'a.ts' }),
|
|
mkCall('grep', { path: 'a.ts' }),
|
|
mkCall('list_dir', { path: 'a.ts' }),
|
|
];
|
|
expect(detectDoomLoop(calls)).toBeNull();
|
|
});
|
|
|
|
it('returns null when the FIRST three of four match but the latest differs', () => {
|
|
// Critical sliding-window edge: detector must ONLY look at the last
|
|
// DOOM_LOOP_THRESHOLD entries. Earlier matches don't count if the
|
|
// model has since moved on.
|
|
const calls = [
|
|
mkCall('grep', { pattern: 'X' }),
|
|
mkCall('grep', { pattern: 'X' }),
|
|
mkCall('grep', { pattern: 'X' }),
|
|
mkCall('view_file', { path: 'a.ts' }),
|
|
];
|
|
expect(detectDoomLoop(calls)).toBeNull();
|
|
});
|
|
|
|
it('returns null when args have same keys but different values', () => {
|
|
const calls = [
|
|
mkCall('grep', { pattern: 'TODO', path: 'src' }),
|
|
mkCall('grep', { pattern: 'TODO', path: 'src' }),
|
|
mkCall('grep', { pattern: 'TODO', path: 'apps' }),
|
|
];
|
|
expect(detectDoomLoop(calls)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ---- threshold contract ----------------------------------------------------
|
|
|
|
describe('DOOM_LOOP_THRESHOLD', () => {
|
|
it('is a positive integer (the public contract — tests assume 3)', () => {
|
|
expect(DOOM_LOOP_THRESHOLD).toBeGreaterThan(0);
|
|
expect(Number.isInteger(DOOM_LOOP_THRESHOLD)).toBe(true);
|
|
});
|
|
});
|