Files
gbrain/test/subagent-prompt-too-long.test.ts
T
410c6978a4 v0.30.2 feat: dream synthesize stops dropping fat transcripts (#754)
* feat: classify Anthropic prompt-too-long as UnrecoverableError

The subagent handler now detects 400 "prompt is too long" responses
from the Anthropic SDK and rethrows as UnrecoverableError. The worker
already routes UnrecoverableError straight to `dead`, so doomed jobs
fail terminally on first attempt instead of stalling 3x with the same
oversized prompt.

isPromptTooLongError matches the production message verbatim
("prompt is too long: N tokens > N maximum"), case-insensitive, on
both the outer message and inner error.message paths. Defensive
secondary match for status=400 + invalid_request_error/request_too_large
with the words "too long"/"exceed"/"maximum".

9 unit cases pin the detection: production wording, case folding,
nested SDK shape, defensive 400 paths, unrelated 400s, transient
errors, null/empty inputs.

* feat: model-aware chunking + slug-rewrite for dream synthesize

The synthesize phase now chunks oversized transcripts at paragraph
boundaries instead of submitting one giant prompt that 400s on
Anthropic. Closes the v0.30 dream-cycle queue clog where 1.7M-token
transcripts dead-lettered after 3 stalls and re-discovered every
cycle.

D1: per-chunk budget = floor(model_context_tokens × 0.9 × 3.5).
MODEL_CONTEXT_TOKENS keys on resolved Anthropic ids (Opus 4.7 = 1M,
Sonnet 4.6 = 200K, Haiku = 200K). Non-Anthropic models fall back to
180K-token safe default with a once-per-process stderr warning.
dream.synthesize.max_prompt_tokens overrides the model lookup
(token-shaped, name from PR #748, floor 100K).

D5: on max_chunks_per_transcript cap hit, log + skip; do NOT write to
dream_verdicts. Closes the cache-poisoning class — next cycle
re-attempts under whatever budget is then current.

D6: orchestrator-side deterministic slug rewrite, zero Sonnet trust.
collectChildPutPageSlugs raw-fetches every (job_id, slug) pair (no
SELECT DISTINCT — that erased the collision evidence the audit
claimed to detect) and rewrites bare-hash6 slugs to <hash6>-c<idx>
for chunked children.

D8: pre-fan-out lookup of completed legacy `dream:synth:<filePath>:
<hash16>` jobs. Transcripts already synthesized under the
single-chunk shape skip submission with `already_synthesized_legacy_
single_chunk` instead of resubmitting under chunked keys.

D9: hash-deterministic chunk boundaries. The 3-tier ladder lifted
from PR #748 (## Topic: > --- > nearest \\n) is fed a back-half
search-window offset derived from contentHash. Same content always
chunks identically across runs; chunk N of a previously-failed
transcript produces byte-identical content on retry.

D10: 24-chunk default cap, operator-configurable via
dream.synthesize.max_chunks_per_transcript.

18 unit cases pin the chunker (boundary ladder, hash determinism,
hard fallback, slug rewrite all 7 shapes). 4 PGLite E2E cases pin
fan-out shape (single-chunk legacy key parity, multi-chunk chunked
key shape) + skip paths (D5 cap hit no verdict-cache write, D8
legacy-key skip).

Credits PR #748 (Wintermute) for the boundary ladder, config key
naming, and 3.5 chars/token estimator. This branch supersedes #748
with the structural safeguards (model-aware budget, terminal-error
classify, slug rewrite, hash-determinism, doctor surfacing).

* feat: surface dead-lettered prompt_too_long jobs in doctor queue_health

queue_health gains a 4th subcheck counting dead `subagent` jobs in
the last 24h whose error_text starts with `prompt_too_long:`. When
present, prints a fix hint pointing at
`gbrain dream --phase synthesize --dry-run --json` to identify the
fat transcripts and naming the two operator escape hatches
(`dream.synthesize.max_prompt_tokens` for budget tuning,
larger-context model for capacity).

Operators now see the chunking failure mode without grepping
minion_jobs by hand.

* chore: bump version and changelog (v0.30.2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update README + CLAUDE.md for v0.30.2

- README dream help: 8-phase → 9-phase, mention v0.30.2 chunking + config keys
- CLAUDE.md synthesize.ts: chunker + per-chunk idempotency + D6 slug rewrite + D7 scope + D8 legacy-key
- CLAUDE.md subagent.ts: prompt_too_long terminal classification
- CLAUDE.md doctor.ts: queue_health subcheck 4 (dead-lettered prompt_too_long)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: regenerate llms-full.txt after v0.30.2 CLAUDE.md updates

The docs/ pass extended three Key Files entries in CLAUDE.md
(synthesize.ts, subagent.ts, doctor.ts). The auto-derived
llms-full.txt bundle picks up those CLAUDE.md changes via
build-llms; the build-llms test caught the drift in CI.

Generated by: bun run build:llms

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:07:51 -07:00

92 lines
3.4 KiB
TypeScript

/**
* Unit tests for the v0.30.2 terminal-error classification of Anthropic's
* "prompt is too long" 400 in the subagent handler.
*
* The handler converts these to UnrecoverableError so the worker routes the
* job straight to `dead`, bypassing max_stalled retries (the prior 3x-retry
* pathology that clogged the queue).
*
* Pure function tests of `isPromptTooLongError`. End-to-end coverage of the
* client.create() try/catch lives in the synthesize E2E test.
*/
import { describe, test, expect } from 'bun:test';
import { isPromptTooLongError } from '../src/core/minions/handlers/subagent.ts';
describe('isPromptTooLongError', () => {
test('matches the production message verbatim', () => {
const err = new Error('prompt is too long: 1707509 tokens > 1000000 maximum');
expect(isPromptTooLongError(err)).toBe(true);
});
test('matches case-insensitively', () => {
expect(isPromptTooLongError(new Error('Prompt Is Too Long'))).toBe(true);
expect(isPromptTooLongError(new Error('PROMPT IS TOO LONG'))).toBe(true);
});
test('matches when message is on the inner .error.message field', () => {
// Mimic Anthropic SDK error wrapping shape.
const err = {
status: 400,
error: {
type: 'invalid_request_error',
message: 'prompt is too long: 1234567 tokens > 1000000 maximum',
},
message: 'BadRequestError',
};
expect(isPromptTooLongError(err)).toBe(true);
});
test('matches 400 + invalid_request_error + "exceed" wording (defensive)', () => {
// Defensive against future SDK message-wording changes.
const err = {
status: 400,
error: { type: 'invalid_request_error', message: 'request exceeds maximum context' },
message: 'BadRequestError',
};
expect(isPromptTooLongError(err)).toBe(true);
});
test('matches 400 + request_too_large type', () => {
const err = {
status: 400,
error: { type: 'request_too_large', message: 'too long' },
message: 'BadRequestError',
};
expect(isPromptTooLongError(err)).toBe(true);
});
test('does NOT match unrelated 400 errors', () => {
const err = {
status: 400,
error: { type: 'invalid_request_error', message: 'malformed JSON' },
message: 'BadRequestError',
};
expect(isPromptTooLongError(err)).toBe(false);
});
test('does NOT match unrelated transient errors', () => {
expect(isPromptTooLongError(new Error('network timeout'))).toBe(false);
expect(isPromptTooLongError(new Error('rate limit exceeded'))).toBe(false);
expect(isPromptTooLongError({ status: 500, message: 'internal error' })).toBe(false);
expect(isPromptTooLongError({ status: 429, message: 'overloaded' })).toBe(false);
});
test('does NOT match null / undefined / non-error inputs', () => {
expect(isPromptTooLongError(null)).toBe(false);
expect(isPromptTooLongError(undefined)).toBe(false);
expect(isPromptTooLongError(0)).toBe(false);
expect(isPromptTooLongError('plain string')).toBe(false);
expect(isPromptTooLongError({})).toBe(false);
});
test('matches synthetic SDK shape with status 400 + message containing the phrase', () => {
// Some SDK versions surface the phrase only on the outer .message.
const err = {
status: 400,
message: 'Error: prompt is too long: 2000000 tokens > 1000000 maximum',
};
expect(isPromptTooLongError(err)).toBe(true);
});
});