|
|
@@ -21,9 +21,11 @@
|
|
|
import { afterEach, describe, expect, setSystemTime, test } from 'bun:test';
|
|
|
import { readFileSync } from 'node:fs';
|
|
|
import path from 'node:path';
|
|
|
+import { BackgroundJobsConfigSchema } from '../config';
|
|
|
import { isVolatileTaggedMessage } from './cache-safe-injection';
|
|
|
import {
|
|
|
assistantTurn,
|
|
|
+ type BoardStrategy,
|
|
|
buildHistory,
|
|
|
createPipeline,
|
|
|
FIXTURE_NOW,
|
|
|
@@ -34,49 +36,116 @@ import {
|
|
|
turnEndIndices,
|
|
|
} from './cache-safety-harness.test';
|
|
|
import { BACKGROUND_JOB_BOARD_METADATA_KEY } from './task-session-manager';
|
|
|
+import type { MessageWithParts } from './types';
|
|
|
|
|
|
afterEach(() => {
|
|
|
setSystemTime();
|
|
|
});
|
|
|
|
|
|
-describe('cache-safety: turn-over-turn prefix stability', () => {
|
|
|
- test('re-rendering a growing conversation reproduces byte-identical history', async () => {
|
|
|
- const pipeline = createPipeline();
|
|
|
- const history = buildHistory();
|
|
|
- const turns = turnEndIndices(history);
|
|
|
-
|
|
|
- let previous: string[] | undefined;
|
|
|
- for (const [turnNumber, endIndex] of turns.entries()) {
|
|
|
- // Exercise cross-turn hook state: a file-tool nudge fires before the
|
|
|
- // second turn, and background jobs churn (launch, then drop) while
|
|
|
- // later turns render — none of it may touch stable bytes.
|
|
|
- if (turnNumber === 1) pipeline.markFileToolPending();
|
|
|
- if (turnNumber === 2) {
|
|
|
- pipeline.board.registerLaunch({
|
|
|
- taskID: 'task-alpha',
|
|
|
- parentSessionID: SESSION_ID,
|
|
|
- agent: 'explorer',
|
|
|
- description: 'churn fixture',
|
|
|
- now: FIXTURE_NOW,
|
|
|
- });
|
|
|
- }
|
|
|
- if (turnNumber === 3) pipeline.board.drop('task-alpha');
|
|
|
+/**
|
|
|
+ * Per-strategy definition of "the bytes that must never be rewritten".
|
|
|
+ *
|
|
|
+ * - `latest`: board state lives in a single volatile trailing message that
|
|
|
+ * is stripped and re-appended every request, so the stable prefix is
|
|
|
+ * every non-volatile message.
|
|
|
+ * - `checkpoint-compatible`: board snapshots are append-only stable bytes
|
|
|
+ * by design — that is the strategy's entire purpose — so the stable
|
|
|
+ * prefix is the WHOLE provider-visible payload. Filtering tagged messages
|
|
|
+ * here would hide exactly the snapshot drop/reinsert rewrite that shipped
|
|
|
+ * in v2.2.5. Replayed snapshot messages are rebuilt each request from a
|
|
|
+ * varying base message, so only provider-visible fields (role, agent,
|
|
|
+ * parts) participate — `info` never reaches the provider.
|
|
|
+ *
|
|
|
+ * A new `BackgroundJobsConfigSchema` strategy must add an entry here (the
|
|
|
+ * drift guard below fails until it does), forcing an explicit decision
|
|
|
+ * about its cache-safety semantics before it can ship.
|
|
|
+ */
|
|
|
+const STRATEGY_STABLE_FINGERPRINTS: Record<
|
|
|
+ BoardStrategy,
|
|
|
+ (messages: unknown[]) => string[]
|
|
|
+> = {
|
|
|
+ latest: stableFingerprints,
|
|
|
+ 'checkpoint-compatible': (messages) =>
|
|
|
+ (messages as MessageWithParts[]).map((message) =>
|
|
|
+ JSON.stringify({
|
|
|
+ role: message.info.role,
|
|
|
+ agent: message.info.agent,
|
|
|
+ parts: message.parts,
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+};
|
|
|
+
|
|
|
+const BOARD_STRATEGIES = Object.keys(
|
|
|
+ STRATEGY_STABLE_FINGERPRINTS,
|
|
|
+) as BoardStrategy[];
|
|
|
+
|
|
|
+describe('cache-safety: board strategy coverage drift guard', () => {
|
|
|
+ test('every configurable board strategy has property coverage', () => {
|
|
|
+ const schemaStrategies =
|
|
|
+ BackgroundJobsConfigSchema.shape.strategy.unwrap().options;
|
|
|
+ expect([...BOARD_STRATEGIES].sort()).toEqual([...schemaStrategies].sort());
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe.each(BOARD_STRATEGIES)(
|
|
|
+ 'cache-safety: turn-over-turn prefix stability (%s)',
|
|
|
+ (strategy) => {
|
|
|
+ test('re-rendering a growing conversation reproduces byte-identical history', async () => {
|
|
|
+ const pipeline = createPipeline({ strategy });
|
|
|
+ const history = buildHistory();
|
|
|
+ const turns = turnEndIndices(history);
|
|
|
+ const fingerprintsFor = STRATEGY_STABLE_FINGERPRINTS[strategy];
|
|
|
|
|
|
- const output = await renderTurn(pipeline, history, endIndex);
|
|
|
- const fingerprints = stableFingerprints(output.messages);
|
|
|
+ let previous: string[] | undefined;
|
|
|
+ for (const [turnNumber, endIndex] of turns.entries()) {
|
|
|
+ // Exercise cross-turn hook state: a file-tool nudge fires and a
|
|
|
+ // background job launches before the second turn (a real user turn,
|
|
|
+ // so checkpoint mode creates a snapshot), the job is dropped before
|
|
|
+ // the internal-initiator turn renders with an empty board, and a
|
|
|
+ // second job launches before the fourth turn. Snapshot creation,
|
|
|
+ // replay across internal-initiator and empty-board turns, and
|
|
|
+ // unchanged-board dedupe all must leave stable bytes untouched —
|
|
|
+ // the v2.2.5 checkpoint regression rewrote them on exactly these
|
|
|
+ // transitions.
|
|
|
+ if (turnNumber === 1) {
|
|
|
+ pipeline.markFileToolPending();
|
|
|
+ pipeline.board.registerLaunch({
|
|
|
+ taskID: 'task-alpha',
|
|
|
+ parentSessionID: SESSION_ID,
|
|
|
+ agent: 'explorer',
|
|
|
+ description: 'churn fixture',
|
|
|
+ now: FIXTURE_NOW,
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (turnNumber === 2) pipeline.board.drop('task-alpha');
|
|
|
+ if (turnNumber === 3) {
|
|
|
+ pipeline.board.registerLaunch({
|
|
|
+ taskID: 'task-beta',
|
|
|
+ parentSessionID: SESSION_ID,
|
|
|
+ agent: 'fixer',
|
|
|
+ description: 'second churn fixture',
|
|
|
+ now: FIXTURE_NOW,
|
|
|
+ });
|
|
|
+ }
|
|
|
|
|
|
- if (previous) {
|
|
|
- if (fingerprints.length < previous.length) {
|
|
|
- throw new Error(
|
|
|
- 'A transform removed stable messages between turns — this rewrites the cached prefix. Route the content through src/hooks/cache-safe-injection.ts instead.',
|
|
|
- );
|
|
|
+ const output = await renderTurn(pipeline, history, endIndex);
|
|
|
+ const fingerprints = fingerprintsFor(output.messages);
|
|
|
+
|
|
|
+ if (previous) {
|
|
|
+ if (fingerprints.length < previous.length) {
|
|
|
+ throw new Error(
|
|
|
+ 'A transform removed stable messages between turns — this rewrites the cached prefix. Route the content through src/hooks/cache-safe-injection.ts instead.',
|
|
|
+ );
|
|
|
+ }
|
|
|
+ expect(fingerprints.slice(0, previous.length)).toEqual(previous);
|
|
|
}
|
|
|
- expect(fingerprints.slice(0, previous.length)).toEqual(previous);
|
|
|
+ previous = fingerprints;
|
|
|
}
|
|
|
- previous = fingerprints;
|
|
|
- }
|
|
|
- });
|
|
|
+ });
|
|
|
+ },
|
|
|
+);
|
|
|
|
|
|
+describe('cache-safety: turn-over-turn prefix stability', () => {
|
|
|
test('a consumed file-tool nudge is reproduced by the phase reminder on the next turn', async () => {
|
|
|
const pipeline = createPipeline();
|
|
|
const history = buildHistory();
|
|
|
@@ -97,41 +166,44 @@ describe('cache-safety: turn-over-turn prefix stability', () => {
|
|
|
});
|
|
|
});
|
|
|
|
|
|
-describe('cache-safety: specialist sessions', () => {
|
|
|
- test('non-orchestrator payloads pass through byte-identical', async () => {
|
|
|
- const pipeline = createPipeline();
|
|
|
- const specialistSession = 'ses_specialist_fixture';
|
|
|
- const history = [
|
|
|
- {
|
|
|
- info: {
|
|
|
- role: 'user',
|
|
|
- agent: 'explorer',
|
|
|
- sessionID: specialistSession,
|
|
|
- id: 's01',
|
|
|
+describe.each(BOARD_STRATEGIES)(
|
|
|
+ 'cache-safety: specialist sessions (%s)',
|
|
|
+ (strategy) => {
|
|
|
+ test('non-orchestrator payloads pass through byte-identical', async () => {
|
|
|
+ const pipeline = createPipeline({ strategy });
|
|
|
+ const specialistSession = 'ses_specialist_fixture';
|
|
|
+ const history = [
|
|
|
+ {
|
|
|
+ info: {
|
|
|
+ role: 'user',
|
|
|
+ agent: 'explorer',
|
|
|
+ sessionID: specialistSession,
|
|
|
+ id: 's01',
|
|
|
+ },
|
|
|
+ parts: [{ type: 'text', text: 'find the config loader' }],
|
|
|
},
|
|
|
- parts: [{ type: 'text', text: 'find the config loader' }],
|
|
|
- },
|
|
|
- assistantTurn('s02', 'Searching now.'),
|
|
|
- {
|
|
|
- info: {
|
|
|
- role: 'user',
|
|
|
- agent: 'explorer',
|
|
|
- sessionID: specialistSession,
|
|
|
- id: 's03',
|
|
|
+ assistantTurn('s02', 'Searching now.'),
|
|
|
+ {
|
|
|
+ info: {
|
|
|
+ role: 'user',
|
|
|
+ agent: 'explorer',
|
|
|
+ sessionID: specialistSession,
|
|
|
+ id: 's03',
|
|
|
+ },
|
|
|
+ parts: [{ type: 'text', text: 'summarize what you found' }],
|
|
|
},
|
|
|
- parts: [{ type: 'text', text: 'summarize what you found' }],
|
|
|
- },
|
|
|
- ];
|
|
|
- const before = history.map((message) => JSON.stringify(message));
|
|
|
+ ];
|
|
|
+ const before = history.map((message) => JSON.stringify(message));
|
|
|
|
|
|
- const output: TransformOutput = { messages: structuredClone(history) };
|
|
|
- await pipeline.run(output);
|
|
|
+ const output: TransformOutput = { messages: structuredClone(history) };
|
|
|
+ await pipeline.run(output);
|
|
|
|
|
|
- expect(output.messages.map((message) => JSON.stringify(message))).toEqual(
|
|
|
- before,
|
|
|
- );
|
|
|
- });
|
|
|
-});
|
|
|
+ expect(output.messages.map((message) => JSON.stringify(message))).toEqual(
|
|
|
+ before,
|
|
|
+ );
|
|
|
+ });
|
|
|
+ },
|
|
|
+);
|
|
|
|
|
|
describe('cache-safety: volatile content isolation', () => {
|
|
|
test('background-job state only ever changes the tagged trailing message', async () => {
|
|
|
@@ -167,41 +239,81 @@ describe('cache-safety: volatile content isolation', () => {
|
|
|
),
|
|
|
).toBe(false);
|
|
|
});
|
|
|
-});
|
|
|
|
|
|
-describe('cache-safety: determinism under ambient inputs', () => {
|
|
|
- test('wall clock and randomness never leak into the payload', async () => {
|
|
|
+ test('checkpoint-compatible board state only ever adds tagged snapshot messages', async () => {
|
|
|
const history = buildHistory();
|
|
|
const lastTurn = history.length - 1;
|
|
|
- const originalRandom = Math.random;
|
|
|
-
|
|
|
- const render = async (time: number, random: number): Promise<string[]> => {
|
|
|
- setSystemTime(new Date(time));
|
|
|
- Math.random = () => random;
|
|
|
- try {
|
|
|
- const pipeline = createPipeline();
|
|
|
- pipeline.board.registerLaunch({
|
|
|
- taskID: 'task-gamma',
|
|
|
- parentSessionID: SESSION_ID,
|
|
|
- agent: 'oracle',
|
|
|
- description: 'determinism fixture',
|
|
|
- now: FIXTURE_NOW,
|
|
|
- });
|
|
|
- const output = await renderTurn(pipeline, history, lastTurn);
|
|
|
- return output.messages.map((message) => JSON.stringify(message));
|
|
|
- } finally {
|
|
|
- Math.random = originalRandom;
|
|
|
- setSystemTime();
|
|
|
- }
|
|
|
- };
|
|
|
|
|
|
- const first = await render(FIXTURE_NOW, 0.1234);
|
|
|
- const second = await render(FIXTURE_NOW + 987_654_321, 0.9876);
|
|
|
+ const emptyBoard = createPipeline({ strategy: 'checkpoint-compatible' });
|
|
|
+ const busyBoard = createPipeline({ strategy: 'checkpoint-compatible' });
|
|
|
+ busyBoard.board.registerLaunch({
|
|
|
+ taskID: 'task-beta',
|
|
|
+ parentSessionID: SESSION_ID,
|
|
|
+ agent: 'fixer',
|
|
|
+ description: 'checkpoint isolation fixture',
|
|
|
+ now: FIXTURE_NOW,
|
|
|
+ });
|
|
|
|
|
|
- expect(second).toEqual(first);
|
|
|
+ const withoutJobs = await renderTurn(emptyBoard, history, lastTurn);
|
|
|
+ const withJobs = await renderTurn(busyBoard, history, lastTurn);
|
|
|
+
|
|
|
+ // Real message bytes must be identical; board content may only appear
|
|
|
+ // as tagged snapshot messages (append-only by design, so they are part
|
|
|
+ // of the stable prefix rather than a volatile tail).
|
|
|
+ expect(stableFingerprints(withJobs.messages)).toEqual(
|
|
|
+ stableFingerprints(withoutJobs.messages),
|
|
|
+ );
|
|
|
+ const snapshots = withJobs.messages.filter((message) =>
|
|
|
+ isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
|
|
|
+ );
|
|
|
+ expect(snapshots.length).toBeGreaterThan(0);
|
|
|
+ expect(
|
|
|
+ withoutJobs.messages.some((message) =>
|
|
|
+ isVolatileTaggedMessage(message, BACKGROUND_JOB_BOARD_METADATA_KEY),
|
|
|
+ ),
|
|
|
+ ).toBe(false);
|
|
|
});
|
|
|
});
|
|
|
|
|
|
+describe.each(BOARD_STRATEGIES)(
|
|
|
+ 'cache-safety: determinism under ambient inputs (%s)',
|
|
|
+ (strategy) => {
|
|
|
+ test('wall clock and randomness never leak into the payload', async () => {
|
|
|
+ const history = buildHistory();
|
|
|
+ const lastTurn = history.length - 1;
|
|
|
+ const originalRandom = Math.random;
|
|
|
+
|
|
|
+ const render = async (
|
|
|
+ time: number,
|
|
|
+ random: number,
|
|
|
+ ): Promise<string[]> => {
|
|
|
+ setSystemTime(new Date(time));
|
|
|
+ Math.random = () => random;
|
|
|
+ try {
|
|
|
+ const pipeline = createPipeline({ strategy });
|
|
|
+ pipeline.board.registerLaunch({
|
|
|
+ taskID: 'task-gamma',
|
|
|
+ parentSessionID: SESSION_ID,
|
|
|
+ agent: 'oracle',
|
|
|
+ description: 'determinism fixture',
|
|
|
+ now: FIXTURE_NOW,
|
|
|
+ });
|
|
|
+ const output = await renderTurn(pipeline, history, lastTurn);
|
|
|
+ return output.messages.map((message) => JSON.stringify(message));
|
|
|
+ } finally {
|
|
|
+ Math.random = originalRandom;
|
|
|
+ setSystemTime();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const first = await render(FIXTURE_NOW, 0.1234);
|
|
|
+ const second = await render(FIXTURE_NOW + 987_654_321, 0.9876);
|
|
|
+
|
|
|
+ expect(second).toEqual(first);
|
|
|
+ });
|
|
|
+ },
|
|
|
+);
|
|
|
+
|
|
|
describe('cache-safety: pipeline drift guard', () => {
|
|
|
const srcRoot = path.resolve(import.meta.dir, '..');
|
|
|
|