mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix: dream --source/--source-id plumbs sourceId to runCycle (supersedes #1559) Closes the silent-no-op class where `gbrain dream --source <id>` ran the cycle but never wrote `last_full_cycle_at`, leaving `gbrain doctor`'s cycle_freshness check stuck red forever. Changes to src/commands/dream.ts: - DreamArgs.source field; parseArgs recognizes --source <id> AND the --source-id alias (matches v0.37.7.0 #1167 naming across import/extract/graph-query) - Argv validation: missing value → exit 2; repeated different values → exit 2; --source X --source-id Y conflict → exit 2; same-value repetition → accepted - --help short-circuit ordering preserved with IRON-RULE comment + structural test guard - runDream engine-null guard: --source requires a connected brain - runDream resolveSourceId → archived-source guard via fetchSource from src/core/sources-load.ts (single-row SELECT that projects archived + handles pre-v0.26.5 schema via isUndefinedColumnError) - Typed-error try/catch via isResolverUserError predicate: only swallows known resolver-user errors; TypeError / postgres errors propagate uncaught with stack trace so genuine programmer bugs aren't hidden behind operator-error UX - Forwarded sourceId to runCycle; existing v0.38 writeback at cycle.ts:1947-1967 now actually fires - --help text documents both flag names Tests: - test/dream-cli-flags.test.ts: structural assertions for new flags, help text, IRON-RULE comment guard, resolver/predicate wiring - test/dream.test.ts: 13 PGLite integration cases covering happy path (the regression that closes PR #1559), back-compat, alias equivalence, all argv edge cases, engine-null, archived, --help short-circuit ordering, T3 typed-error propagation, and D5 end-to-end dream→checkCycleFreshness column-name drift guard Plan + 11 decisions: ~/.claude/plans/system-instruction-you-are-working-starry-papert.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: judgeSignificance uses canonical safeSplitIndex (closes #1559/#1561 emoji crash) Closes the 2026-05-24 production SYNTH_PHASE_FAIL: 🤖 (U+1F916, surrogate pair U+D83E U+DD16) at offset 3999 in a long telegram transcript made the raw 4000-char slice produce a lone high surrogate; Anthropic's JSON parser rejected the payload with "no low surrogate in string"; the synthesize phase failed. Changes to src/core/cycle/synthesize.ts: - judgeSignificance head+tail slice routed through safeSplitIndex from src/core/text-safe.ts (already imported) - Did NOT introduce safeSliceEnd from PRs #1559+#1561 — that helper re-introduces the case-3 bug src/core/text-safe.ts:18-21 documents - Did NOT touch findBoundary — master already routes through safeSplitIndex per the v0.42.0.0 wave Tests in test/cycle-synthesize.test.ts: - New describe('judgeSignificance — UTF-16 safety') block - test.each over head boundaries (offsets 3998-4001) AND tail boundaries (offsets 3999-4002) for an 8001-char content with the robot emoji placed at each - Primary assertion: explicit unpaired-surrogate scan over the captured prompt (NOT JSON.stringify per codex C-11 — V8/JSCore do not throw on lone surrogates, so that assertion was weak) - Sub-8000 short-content branch case: no slicing, emoji passes through unchanged Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: expand error_page_title + add cloudflare_challenge_title (supersedes #1561) Closes the bug class where scraper error pages with titles like "Forbidden", "Access Denied", "Service Unavailable", "Robot Check", and "Just a moment..." were slipping through the ingest gate because the matcher only caught bare numeric codes (403/404/500...) and "page not found". 232+ pages observed (202+ from straylight- brain) were inflating page counts and tripping content_sanity_audit_recent on every doctor run. Changes to src/core/content-sanity.ts BUILT_IN_JUNK_PATTERNS: - Expanded error_page_title regex to also catch forbidden, access denied, service unavailable, robot check, verify you are human (case-insensitive, anchored — so long-form essays about these topics still ingest fine) - New cloudflare_challenge_title pattern with DISTINCT name from error_page_title (PR #1561 collapsed both into one name and lost audit signal — the new name preserves diagnosability in ~/.gbrain/audit/content-sanity-YYYY-Www.jsonl and doctor's content_sanity_audit_recent aggregation) - Dropped PR #1561's bare-`error` matcher — too aggressive on legitimate concept/taxonomy pages titled exactly "Error" Tests: - test/content-sanity.test.ts: pattern-count locked at 7, new matches via test.each, over-match regression guard (legitimate prose titled "How to Handle Access Denied Errors" / "Error Boundary in React" etc. must pass), audit-name distinctness pinned - test/import-file-content-sanity.test.ts: end-to-end ContentSanityBlockError via importFromContent for each new pattern family (D6 — assessor wiring coverage, not just regex) Out of scope, filed in TODOS.md as TODO-V13-C: gbrain pages audit-junk-titles legacy-cleanup command. Dropped from this PR per codex outside-voice tension (T1) for ship-and-validate- matchers-first discipline. The 200+ pre-existing scraper pages already in the DB will get the destructive-cleanup operator surface after ~1 week of production observation against this matcher. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump v0.41.23.0 + CHANGELOG + follow-up TODOs VERSION + package.json bump to 0.41.23.0. CHANGELOG voice: ELI10 lead naming the bug ("`gbrain dream --source <id>` finally counts as a cycle"), then per-fix detail, then a "To take advantage of v0.41.23.0" operator-action block and itemized changes. TODOS.md v0.41.23.x follow-ups: - TODO-V13-A (P2): --max-pages plumbing (PR #1559's flag, deferred because CycleOpts has no maxPages field today) - TODO-V13-B (P3): --source vs --source-id flag-name unification across all CLI commands - TODO-V13-C (P2): gbrain pages audit-junk-titles legacy cleanup (deferred for ~1 week of matcher production observation) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump v0.41.25.0 → v0.41.26.0 (leave headroom for in-flight PR) Master shipped v0.41.23.0 + v0.41.24.0 mid-review; this branch originally bumped to v0.41.25.0 post-merge. User flagged v0.41.26.0 to leave a slot open for another in-flight PR. No code changes; VERSION + package.json + CHANGELOG header + "To take advantage" section updated in lockstep. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
462 lines
19 KiB
TypeScript
462 lines
19 KiB
TypeScript
/**
|
|
* Unit tests for the synthesize phase scaffolding.
|
|
*
|
|
* Covers transcript-discovery branches (date filters, exclude regex,
|
|
* minChars, multiple sources) and the compileExcludePatterns word-
|
|
* boundary heuristic. Doesn't drive a real Anthropic call — full
|
|
* cycle E2E lives in test/e2e/.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach } from 'bun:test';
|
|
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import {
|
|
discoverTranscripts,
|
|
readSingleTranscript,
|
|
compileExcludePatterns,
|
|
isDreamOutput,
|
|
DREAM_OUTPUT_MARKER_RE,
|
|
} from '../src/core/cycle/transcript-discovery.ts';
|
|
import { judgeSignificance, renderPageToMarkdown, type JudgeClient } from '../src/core/cycle/synthesize.ts';
|
|
|
|
let tmpDir: string;
|
|
|
|
function makeTranscript(name: string, body: string): string {
|
|
const path = join(tmpDir, name);
|
|
writeFileSync(path, body, 'utf8');
|
|
return path;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-synth-test-'));
|
|
});
|
|
|
|
describe('compileExcludePatterns', () => {
|
|
test('auto-wraps bare words in word-boundary regex (Q-3)', () => {
|
|
const res = compileExcludePatterns(['medical']);
|
|
expect(res).toHaveLength(1);
|
|
// word boundary: matches "medical" but NOT "comedical"
|
|
expect(res[0].test('medical advice')).toBe(true);
|
|
expect(res[0].test('comedical')).toBe(false);
|
|
});
|
|
|
|
test('honors raw regex when input is non-bare-word', () => {
|
|
const res = compileExcludePatterns(['^therapy:']);
|
|
expect(res[0].test('therapy: today was hard')).toBe(true);
|
|
expect(res[0].test('thinking about therapy:')).toBe(false);
|
|
});
|
|
|
|
test('skips invalid regex with warning, does not crash', () => {
|
|
const res = compileExcludePatterns(['valid', '(broken[']);
|
|
expect(res).toHaveLength(1); // only the valid one compiled
|
|
});
|
|
|
|
test('case-insensitive matching by default', () => {
|
|
const res = compileExcludePatterns(['Medical']);
|
|
expect(res[0].test('medical advice')).toBe(true);
|
|
expect(res[0].test('MEDICAL ADVICE')).toBe(true);
|
|
});
|
|
|
|
test('empty / undefined input returns empty array', () => {
|
|
expect(compileExcludePatterns(undefined)).toEqual([]);
|
|
expect(compileExcludePatterns([])).toEqual([]);
|
|
expect(compileExcludePatterns([''])).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('discoverTranscripts', () => {
|
|
test('returns empty when corpusDir does not exist', () => {
|
|
const out = discoverTranscripts({ corpusDir: '/nonexistent/path' });
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
test('returns transcripts above minChars, sorted by filePath', () => {
|
|
makeTranscript('2026-04-25-session.txt', 'a'.repeat(2500));
|
|
makeTranscript('2026-04-24-other.txt', 'b'.repeat(2500));
|
|
const out = discoverTranscripts({ corpusDir: tmpDir, minChars: 1000 });
|
|
expect(out).toHaveLength(2);
|
|
expect(out[0].basename).toBe('2026-04-24-other');
|
|
expect(out[1].basename).toBe('2026-04-25-session');
|
|
});
|
|
|
|
test('skips transcripts below minChars', () => {
|
|
makeTranscript('2026-04-25-short.txt', 'tiny');
|
|
const out = discoverTranscripts({ corpusDir: tmpDir, minChars: 2000 });
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
test('skips non-txt non-md files', () => {
|
|
// v0.30.3 (#708): .md files are now supported alongside .txt; only other
|
|
// extensions (e.g., .pdf, .doc) should be skipped by discovery.
|
|
makeTranscript('2026-04-25-foo.pdf', 'a'.repeat(3000));
|
|
const out = discoverTranscripts({ corpusDir: tmpDir, minChars: 1000 });
|
|
expect(out).toEqual([]);
|
|
});
|
|
|
|
test('discovers .md transcript files (#708)', () => {
|
|
makeTranscript('2026-04-25-foo.md', 'a'.repeat(3000));
|
|
const out = discoverTranscripts({ corpusDir: tmpDir, minChars: 1000 });
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].basename).toBe('2026-04-25-foo');
|
|
});
|
|
|
|
test('exclude_patterns filters out matched transcripts (word boundary)', () => {
|
|
makeTranscript('2026-04-25-medical.txt', 'discussing medical advice ' + 'x'.repeat(3000));
|
|
makeTranscript('2026-04-25-comedy.txt', 'comedical writing tips ' + 'x'.repeat(3000));
|
|
const out = discoverTranscripts({
|
|
corpusDir: tmpDir,
|
|
minChars: 1000,
|
|
excludePatterns: ['medical'],
|
|
});
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].basename).toBe('2026-04-25-comedy');
|
|
});
|
|
|
|
test('--date filter restricts to one specific YYYY-MM-DD basename', () => {
|
|
makeTranscript('2026-04-25-foo.txt', 'a'.repeat(3000));
|
|
makeTranscript('2026-04-26-bar.txt', 'b'.repeat(3000));
|
|
const out = discoverTranscripts({
|
|
corpusDir: tmpDir,
|
|
minChars: 1000,
|
|
date: '2026-04-25',
|
|
});
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].basename).toBe('2026-04-25-foo');
|
|
});
|
|
|
|
test('--from / --to range filters basename dates', () => {
|
|
makeTranscript('2026-04-23-a.txt', 'a'.repeat(3000));
|
|
makeTranscript('2026-04-25-b.txt', 'b'.repeat(3000));
|
|
makeTranscript('2026-04-27-c.txt', 'c'.repeat(3000));
|
|
const out = discoverTranscripts({
|
|
corpusDir: tmpDir,
|
|
minChars: 1000,
|
|
from: '2026-04-24',
|
|
to: '2026-04-26',
|
|
});
|
|
expect(out).toHaveLength(1);
|
|
expect(out[0].basename).toBe('2026-04-25-b');
|
|
});
|
|
|
|
test('multiple sources (corpus + meeting transcripts) merged', () => {
|
|
makeTranscript('2026-04-25-session.txt', 'a'.repeat(3000));
|
|
const meetDir = mkdtempSync(join(tmpdir(), 'gbrain-meet-'));
|
|
writeFileSync(join(meetDir, '2026-04-25-meeting.txt'), 'b'.repeat(3000));
|
|
const out = discoverTranscripts({
|
|
corpusDir: tmpDir,
|
|
meetingTranscriptsDir: meetDir,
|
|
minChars: 1000,
|
|
});
|
|
expect(out).toHaveLength(2);
|
|
rmSync(meetDir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('content_hash is stable for identical content, different for edits (A-3)', () => {
|
|
makeTranscript('2026-04-25-a.txt', 'identical content ' + 'x'.repeat(3000));
|
|
makeTranscript('2026-04-25-b.txt', 'identical content ' + 'x'.repeat(3000));
|
|
const out1 = discoverTranscripts({ corpusDir: tmpDir, minChars: 1000 });
|
|
expect(out1[0].contentHash).toBe(out1[1].contentHash);
|
|
|
|
// Edit one — hash changes
|
|
makeTranscript('2026-04-25-a.txt', 'edited content ' + 'x'.repeat(3000));
|
|
const out2 = discoverTranscripts({ corpusDir: tmpDir, minChars: 1000 });
|
|
expect(out2[0].contentHash).not.toBe(out2[1].contentHash);
|
|
});
|
|
});
|
|
|
|
describe('readSingleTranscript', () => {
|
|
test('returns transcript above minChars', () => {
|
|
const path = makeTranscript('hello.txt', 'a'.repeat(3000));
|
|
const t = readSingleTranscript(path, { minChars: 1000 });
|
|
expect(t).not.toBeNull();
|
|
expect(t!.basename).toBe('hello');
|
|
});
|
|
|
|
test('returns null when below minChars', () => {
|
|
const path = makeTranscript('hello.txt', 'tiny');
|
|
const t = readSingleTranscript(path, { minChars: 2000 });
|
|
expect(t).toBeNull();
|
|
});
|
|
|
|
test('returns null when content matches exclude pattern', () => {
|
|
const path = makeTranscript('hello.txt', 'medical content ' + 'x'.repeat(3000));
|
|
const t = readSingleTranscript(path, { minChars: 1000, excludePatterns: ['medical'] });
|
|
expect(t).toBeNull();
|
|
});
|
|
|
|
test('throws on missing file', () => {
|
|
expect(() => readSingleTranscript('/nonexistent/foo.txt')).toThrow();
|
|
});
|
|
|
|
test('infers date from YYYY-MM-DD basename', () => {
|
|
const path = makeTranscript('2026-04-25-thing.txt', 'a'.repeat(3000));
|
|
const t = readSingleTranscript(path, { minChars: 1000 });
|
|
expect(t!.inferredDate).toBe('2026-04-25');
|
|
});
|
|
|
|
test('inferredDate null when basename does not start with YYYY-MM-DD', () => {
|
|
const path = makeTranscript('random-basename.txt', 'a'.repeat(3000));
|
|
const t = readSingleTranscript(path, { minChars: 1000 });
|
|
expect(t!.inferredDate).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('self-consumption guard (v0.23.2 marker-based)', () => {
|
|
test('REGRESSION: catches actual reverseWriteSlugs output from a real Page', () => {
|
|
// Build a Page like the synthesize subagent would produce, run it through
|
|
// the same renderPageToMarkdown the orchestrator uses, and assert the guard
|
|
// fires. Codex finding #5: synthetic-string fixtures don't prove the guard
|
|
// catches what the synthesize phase actually produces.
|
|
const page = {
|
|
slug: 'wiki/personal/reflections/2026-04-30-test-abc123',
|
|
type: 'reflection' as const,
|
|
title: 'Test reflection',
|
|
compiled_truth: 'I learned something about [Alice](people/alice). No own-slug citation in body.',
|
|
timeline: '',
|
|
frontmatter: {},
|
|
};
|
|
const md = renderPageToMarkdown(page as any, ['dream-cycle']);
|
|
const path = makeTranscript('2026-04-30-output.txt', md + '\n' + 'x'.repeat(3000));
|
|
const result = readSingleTranscript(path, { minChars: 100 });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('does NOT fire on real conversation transcript citing a brain slug', () => {
|
|
// The exact false-positive case codex finding #1 named: a user note that
|
|
// legitimately mentions a reflection slug in plain text. Must NOT be skipped.
|
|
const path = makeTranscript('convo.txt',
|
|
'User: tell me about wiki/personal/reflections/identity-foo and how it relates to my work.\n' +
|
|
'Agent: ' + 'x'.repeat(3000));
|
|
const result = readSingleTranscript(path, { minChars: 100 });
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
test('CRLF + BOM frontmatter still triggers guard', () => {
|
|
const content = '\uFEFF---\r\ndream_generated: true\r\n---\r\n# x\r\n' + 'x'.repeat(3000);
|
|
const path = makeTranscript('crlf.txt', content);
|
|
const result = readSingleTranscript(path, { minChars: 100 });
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
test('whitespace and case tolerance: matches dream_generated: true variants', () => {
|
|
const variants = [
|
|
'---\ndream_generated:true\n---\nbody' + 'x'.repeat(3000),
|
|
'---\ndream_generated: true\n---\nbody' + 'x'.repeat(3000),
|
|
'---\ndream_generated: TRUE\n---\nbody' + 'x'.repeat(3000),
|
|
'---\ntitle: foo\ndream_generated: true\n---\nbody' + 'x'.repeat(3000),
|
|
];
|
|
for (const variant of variants) {
|
|
expect(isDreamOutput(variant)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test('does NOT fire when dream_generated is false or absent', () => {
|
|
expect(isDreamOutput('---\ntitle: foo\n---\nbody')).toBe(false);
|
|
expect(isDreamOutput('---\ndream_generated: false\n---\nbody')).toBe(false);
|
|
expect(isDreamOutput('plain text with no frontmatter')).toBe(false);
|
|
// dream_generatedfoo: true (no word boundary on the key) must NOT match
|
|
expect(isDreamOutput('---\ndream_generatedfoo: true\n---\nbody')).toBe(false);
|
|
});
|
|
|
|
test('marker buried past 2000 chars does NOT trigger guard (perf bound)', () => {
|
|
const padding = 'x'.repeat(2100);
|
|
const content = '---\ntitle: real\n---\n' + padding + '\ndream_generated: true\n' + 'x'.repeat(3000);
|
|
const path = makeTranscript('buried.txt', content);
|
|
const result = readSingleTranscript(path, { minChars: 100 });
|
|
expect(result).not.toBeNull();
|
|
});
|
|
|
|
test('bypassGuard=true overrides marker (--unsafe-bypass-dream-guard plumbing)', () => {
|
|
const md = '---\ndream_generated: true\n---\n# Page\n' + 'x'.repeat(3000);
|
|
const path = makeTranscript('marked.txt', md);
|
|
expect(readSingleTranscript(path, { minChars: 100 })).toBeNull();
|
|
expect(readSingleTranscript(path, { minChars: 100, bypassGuard: true })).not.toBeNull();
|
|
});
|
|
|
|
test('discoverTranscripts respects bypassGuard', () => {
|
|
const md = '---\ndream_generated: true\n---\n# Page\n' + 'x'.repeat(3000);
|
|
makeTranscript('2026-04-30-output.txt', md);
|
|
makeTranscript('2026-04-30-real.txt', 'real transcript ' + 'x'.repeat(3000));
|
|
|
|
const guarded = discoverTranscripts({ corpusDir: tmpDir, minChars: 100 });
|
|
expect(guarded).toHaveLength(1);
|
|
expect(guarded[0].basename).toBe('2026-04-30-real');
|
|
|
|
const bypassed = discoverTranscripts({ corpusDir: tmpDir, minChars: 100, bypassGuard: true });
|
|
expect(bypassed).toHaveLength(2);
|
|
});
|
|
|
|
test('DREAM_OUTPUT_MARKER_RE is anchored at file start (not mid-content)', () => {
|
|
// Frontmatter delimiter must be at byte 0; mid-content `---\n` does not count.
|
|
const content = 'preamble\n---\ndream_generated: true\n---\nbody' + 'x'.repeat(3000);
|
|
expect(DREAM_OUTPUT_MARKER_RE.test(content)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('judgeSignificance', () => {
|
|
function makeTranscript(): import('../src/core/cycle/transcript-discovery.ts').DiscoveredTranscript {
|
|
return {
|
|
filePath: '/tmp/x.txt',
|
|
contentHash: 'abc123',
|
|
content: 'A short conversation about something interesting.',
|
|
basename: 'x',
|
|
inferredDate: null,
|
|
};
|
|
}
|
|
|
|
function mockClient(captured: { model?: string }): JudgeClient {
|
|
return {
|
|
create: async (p: any) => {
|
|
captured.model = p.model;
|
|
return { content: [{ type: 'text', text: '{"worth_processing": true, "reasons": ["test"]}' }] } as any;
|
|
},
|
|
};
|
|
}
|
|
|
|
test('passes verdict_model override to client.create', async () => {
|
|
const captured: { model?: string } = {};
|
|
await judgeSignificance(mockClient(captured), makeTranscript(), 'claude-sonnet-4-6');
|
|
expect(captured.model).toBe('claude-sonnet-4-6');
|
|
});
|
|
|
|
test('defaults to claude-haiku-4-5-20251001 when model omitted', async () => {
|
|
const captured: { model?: string } = {};
|
|
await judgeSignificance(mockClient(captured), makeTranscript());
|
|
expect(captured.model).toBe('claude-haiku-4-5-20251001');
|
|
});
|
|
|
|
test('returns worth_processing=false when judge returns unparseable text', async () => {
|
|
const client: JudgeClient = {
|
|
create: async () => ({ content: [{ type: 'text', text: 'no json here' }] } as any),
|
|
};
|
|
const r = await judgeSignificance(client, makeTranscript());
|
|
expect(r.worth_processing).toBe(false);
|
|
expect(r.reasons[0]).toContain('unparseable');
|
|
});
|
|
});
|
|
|
|
// ─── v0.41.13: UTF-16 safety in judgeSignificance ─────────────────────
|
|
//
|
|
// Reproduces the 2026-05-24 production SYNTH_PHASE_FAIL: `🤖` (U+1F916,
|
|
// encoded as surrogate pair U+D83E U+DD16) at offset 3999 in a long
|
|
// telegram transcript made the 4000-char slice produce a lone high
|
|
// surrogate. Anthropic's JSON parser rejected the payload with "no low
|
|
// surrogate in string". Fix routes both head + tail slices through the
|
|
// canonical safeSplitIndex helper from text-safe.ts.
|
|
//
|
|
// Primary assertion: scan the captured prompt for unpaired surrogates.
|
|
// (NOT JSON.stringify, which doesn't throw on lone surrogates in V8/
|
|
// JSCore — codex C-11.)
|
|
|
|
describe('judgeSignificance — UTF-16 safety (v0.41.13)', () => {
|
|
const HIGH = '\uD83E'; // high surrogate of 🤖
|
|
const LOW = '\uDD16'; // low surrogate of 🤖
|
|
const ROBOT = HIGH + LOW; // U+1F916, 🤖, two UTF-16 code units
|
|
|
|
function isHighSurrogate(c: number): boolean { return c >= 0xD800 && c <= 0xDBFF; }
|
|
function isLowSurrogate(c: number): boolean { return c >= 0xDC00 && c <= 0xDFFF; }
|
|
|
|
/**
|
|
* Build content of exactly `length` chars with `🤖` placed so that
|
|
* the high surrogate sits at `emojiHighOffset`. The pair occupies
|
|
* positions [emojiHighOffset, emojiHighOffset+1]. Filler is plain
|
|
* ASCII so surrogate-scanning has no other true positives.
|
|
*/
|
|
function buildContentWithEmojiAt(length: number, emojiHighOffset: number): string {
|
|
if (emojiHighOffset < 0 || emojiHighOffset > length - 2) {
|
|
throw new Error(`emojiHighOffset ${emojiHighOffset} out of range for length ${length}`);
|
|
}
|
|
const head = 'a'.repeat(emojiHighOffset);
|
|
const tail = 'b'.repeat(length - emojiHighOffset - 2);
|
|
return head + ROBOT + tail;
|
|
}
|
|
|
|
/** Stub client that captures the user-message string for inspection. */
|
|
function makeCapturingClient(): { client: JudgeClient; captured: { userMessage: string | null } } {
|
|
const captured: { userMessage: string | null } = { userMessage: null };
|
|
const client: JudgeClient = {
|
|
create: async (p: any) => {
|
|
// judgeSignificance posts `Transcript ${basename}:\n\n${trimmed}` as
|
|
// the user message. Capture for post-call scanning.
|
|
const userMsg = p.messages[0]?.content;
|
|
captured.userMessage = typeof userMsg === 'string' ? userMsg : null;
|
|
return { content: [{ type: 'text', text: '{"worth_processing": false, "reasons": ["stub"]}' }] } as any;
|
|
},
|
|
};
|
|
return { client, captured };
|
|
}
|
|
|
|
/**
|
|
* Extract the trimmed payload from the captured prompt by stripping
|
|
* the prompt prefix and the `\n[...truncated...]\n` separator. We
|
|
* scan the WHOLE captured message for unpaired surrogates anyway
|
|
* (prompt prefix is pure ASCII), so the extraction is defense-in-
|
|
* depth, not the primary signal.
|
|
*/
|
|
function scanForUnpairedSurrogates(s: string): { index: number; kind: 'lone-high' | 'lone-low' } | null {
|
|
for (let i = 0; i < s.length; i++) {
|
|
const c = s.charCodeAt(i);
|
|
if (isHighSurrogate(c)) {
|
|
const next = i + 1 < s.length ? s.charCodeAt(i + 1) : -1;
|
|
if (!isLowSurrogate(next)) return { index: i, kind: 'lone-high' };
|
|
} else if (isLowSurrogate(c)) {
|
|
const prev = i > 0 ? s.charCodeAt(i - 1) : -1;
|
|
if (!isHighSurrogate(prev)) return { index: i, kind: 'lone-low' };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function makeLongTranscript(content: string): import('../src/core/cycle/transcript-discovery.ts').DiscoveredTranscript {
|
|
return {
|
|
filePath: '/tmp/long.txt',
|
|
contentHash: 'utf16-test',
|
|
content,
|
|
basename: 'long',
|
|
inferredDate: null,
|
|
};
|
|
}
|
|
|
|
// ─── Head-boundary cases (offset around 4000) ──────────────────────
|
|
|
|
test.each([3998, 3999, 4000, 4001])(
|
|
'emoji at head offset %i: captured prompt has zero unpaired surrogates',
|
|
async (offset) => {
|
|
const content = buildContentWithEmojiAt(8001, offset);
|
|
const { client, captured } = makeCapturingClient();
|
|
await judgeSignificance(client, makeLongTranscript(content));
|
|
expect(captured.userMessage).not.toBeNull();
|
|
const result = scanForUnpairedSurrogates(captured.userMessage!);
|
|
expect(result).toBeNull();
|
|
},
|
|
);
|
|
|
|
// ─── Tail-boundary cases (offset around length-4000 = 4001) ────────
|
|
|
|
test.each([3999, 4000, 4001, 4002])(
|
|
'emoji at tail offset %i: captured prompt has zero unpaired surrogates',
|
|
async (offset) => {
|
|
// 8001 - 4000 = 4001 is the tail boundary; we test around it.
|
|
const content = buildContentWithEmojiAt(8001, offset);
|
|
const { client, captured } = makeCapturingClient();
|
|
await judgeSignificance(client, makeLongTranscript(content));
|
|
expect(captured.userMessage).not.toBeNull();
|
|
const result = scanForUnpairedSurrogates(captured.userMessage!);
|
|
expect(result).toBeNull();
|
|
},
|
|
);
|
|
|
|
// ─── Sub-8000 short-content branch: no slicing, no risk ────────────
|
|
|
|
test('content <= 8000 chars: no slicing applied, emoji passes through unchanged', async () => {
|
|
const content = 'a'.repeat(100) + ROBOT + 'b'.repeat(100); // 202 chars total
|
|
const { client, captured } = makeCapturingClient();
|
|
await judgeSignificance(client, makeLongTranscript(content));
|
|
expect(captured.userMessage).not.toBeNull();
|
|
expect(scanForUnpairedSurrogates(captured.userMessage!)).toBeNull();
|
|
// Emoji's full pair must appear at least once.
|
|
expect(captured.userMessage!).toContain(ROBOT);
|
|
});
|
|
});
|