Files
thedotmack__claude-mem/tests/shared/observer-recycle.test.ts
T
Alex Newman c26c291422 fix(observer): brief a recycled generation from the real session-start context
The recycle path never briefed its replacement. `loadSessionSoFar` looked up
prior observations by `session.memorySessionId`, but `ClaudeProvider.startSession`
blanks that id on every generator start (ClaudeProvider.ts:216-221) and the
lookup runs lazily inside the async generator body (:502), i.e. after the blanking.
So the guard `if (!session.memorySessionId) return []` was taken every time and
each fresh generation started blind. Instrumented live: 4 recycles, 4 empty seeds.

Use `generateContext` instead — the builder the SessionStart hook already uses.
It keys on project + cwd rather than the SDK session id, so the blanking cannot
reach it. This deletes the parallel `buildSessionSoFar` / `PriorObservation` /
`loadSessionSoFar` path rather than repairing it. Verified live at the default
400k budget: recycle at 404,297 chars, replacement briefed with 1,371 chars,
observations continuing forward across the boundary with no re-recording.

Also:

- The exhausted branch aborted without clearing `conversationHistory` and without
  gating restarts, so the next tool call spawned another generator that failed on
  the same budget check — one spawn and abort per captured tool call, with history
  growing 21k -> 59k chars. It now drops the conversation and withholds restarts
  for a cooldown, mirroring the quota breaker.
- `consecutiveContextOverflows` reset only on a valid XML parse, so two ordinary
  "nothing to record" replies could trip the exhausted branch. Overflow, quota and
  auth all return before that point, so reaching it proves the prompt fit — reset
  there instead.
- Oversized fields are condensed by a bounded model pass rather than head/tail cut
  with the middle deleted. One attempt, 30s timeout, falls back to the existing
  truncation on any failure. Live: 81,521 chars -> 184, preserving the FATAL line,
  the pool max, and a latency progression that only the discarded middle contained.

tests/gemini_provider.test.ts assumed the first request went out within a fixed
number of microtasks; the context lookup adds an await, so it now waits on the
condition instead of the timing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DC1PFXGZJk7BQ2PNW2foz2
2026-08-31 11:40:24 -07:00

93 lines
4.2 KiB
TypeScript

import { describe, it, expect } from 'bun:test';
import {
conversationChars,
shouldRecycleConversation,
resolveConversationMaxChars,
OBSERVER_CONVERSATION_MAX_CHARS,
} from '../../src/shared/observer-recycle.js';
import { wrapPriorContext } from '../../src/sdk/prompts.js';
import type { ConversationMessage } from '../../src/services/worker-types.js';
function windowOf(count: number, charsEach: number): ConversationMessage[] {
return Array.from({ length: count }, (_, i) => ({
role: (i % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant',
content: 'x'.repeat(charsEach),
}));
}
describe('observer generation budget (#3800)', () => {
it('does not recycle a conversation that is within budget', () => {
expect(shouldRecycleConversation(windowOf(10, 1_000))).toBe(false);
});
it('recycles once the generation has spent its budget', () => {
const history = windowOf(50, 10_000); // 500k chars > 400k default
expect(conversationChars(history)).toBeGreaterThan(OBSERVER_CONVERSATION_MAX_CHARS);
expect(shouldRecycleConversation(history)).toBe(true);
});
it('checks the budget before a send, so the over-ceiling request is never paid for', () => {
// Exactly at the budget must already trigger: the next observation would
// push it past, and that request is the one that fails.
const atBudget = [{ role: 'user' as const, content: 'x'.repeat(OBSERVER_CONVERSATION_MAX_CHARS) }];
expect(shouldRecycleConversation(atBudget)).toBe(true);
});
it('honours an operator override and falls back on a malformed one', () => {
expect(resolveConversationMaxChars('120000')).toBe(120_000);
expect(resolveConversationMaxChars(undefined)).toBe(OBSERVER_CONVERSATION_MAX_CHARS);
expect(resolveConversationMaxChars('')).toBe(OBSERVER_CONVERSATION_MAX_CHARS);
expect(resolveConversationMaxChars('nonsense')).toBe(OBSERVER_CONVERSATION_MAX_CHARS);
expect(resolveConversationMaxChars('0')).toBe(OBSERVER_CONVERSATION_MAX_CHARS);
expect(resolveConversationMaxChars('-5')).toBe(OBSERVER_CONVERSATION_MAX_CHARS);
});
it('bounds per-session cost regardless of session length', () => {
// The point of generations: a session 20x longer must not make any single
// request 20x bigger. Each generation is capped, so cost is linear in
// observations rather than quadratic.
const long = windowOf(2_000, 10_000);
let generationSize = 0;
const generation: ConversationMessage[] = [];
for (const message of long) {
if (shouldRecycleConversation(generation)) {
generation.length = 0; // retire and start fresh
}
generation.push(message);
generationSize = Math.max(generationSize, conversationChars(generation));
}
// No request ever exceeds one budget plus the message that tripped it.
expect(generationSize).toBeLessThanOrEqual(OBSERVER_CONVERSATION_MAX_CHARS + 10_000);
});
});
describe('wrapPriorContext — continuity across generations (#3800)', () => {
it('is empty for a first generation, leaving the prompt unchanged', () => {
expect(wrapPriorContext('')).toBe('');
expect(wrapPriorContext(' \n ')).toBe('');
});
it('carries the session-start context so a fresh generation is not blind', () => {
const block = wrapPriorContext('111 2:18p bugfix Fixed the auth redirect');
expect(block).toContain('Fixed the auth redirect');
expect(block).toContain('<session_start_context>');
expect(block).toContain('</session_start_context>');
});
it('tells the observer not to re-record what it already captured', () => {
expect(wrapPriorContext('something')).toContain('do not re-record');
});
it('does not let the observer read the gap as "the work did not happen"', () => {
expect(wrapPriorContext('something')).toContain('do not treat its absence');
});
it('passes the context builder output through verbatim rather than re-rendering it', () => {
// The whole point of the change: one renderer, the one the SessionStart
// hook already uses. This block must not reformat what it is given.
const generated = '### Aug 19\n111056 2:18p discovery Tool search returns same results';
expect(wrapPriorContext(generated)).toContain(generated);
});
});