Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -139,21 +139,54 @@ describe('sanitizeAgentContent — NO_REPLY suppression and sanitization', () =>
expect(AgentMessageService.sanitizeAgentContent('NO_REPLY')).toBe('');
});

it('normalizes Unicode format-character prefixes before both sentinel checks', () => {
// U+200B defeats both the total-match and leading-run checks without the
// shared normalization below. Keep the class coupled too: a one-predicate
// fix would re-open the same private-reasoning leak.
[
it('normalizes Unicode ignorable prefixes before both sentinel checks', () => {
// U+200B defeated both checks before TASK-085. Keep the decision-copy
// normalization shared: fixing only one predicate re-opens the
// private-reasoning leak in the other.
const formatCharacters = [
'\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060',
'\u061C', '\u200E', '\u2062', '\u00AD',
].forEach((formatCharacter) => {
];
const nonFormatDefaultIgnorables = [
// TASK-089: these do not have Unicode General_Category=Cf, so the
// previous format-only class let them defeat both sentinel checks.
'\u3164', // HANGUL FILLER
'\u115F', // HANGUL CHOSEONG FILLER
'\u1160', // HANGUL JUNGSEONG FILLER
'\uFFA0', // HALFWIDTH HANGUL FILLER
'\u034F', // COMBINING GRAPHEME JOINER
'\u17B4', // KHMER VOWEL INHERENT AQ
'\u17B5', // KHMER VOWEL INHERENT AA
];

[...formatCharacters, ...nonFormatDefaultIgnorables].forEach((character) => {
expect(AgentMessageService.sanitizeAgentContent(
`${formatCharacter}NO_REPLY\n\nprivate reasoning`,
`${character}NO_REPLY\n\nprivate reasoning`,
)).toBe('');
expect(AgentMessageService.sanitizeAgentContent(`${formatCharacter}NO_REPLY`)).toBe('');
expect(AgentMessageService.sanitizeAgentContent(`${character}NO_REPLY`)).toBe('');
});
});

it('does not treat U+2800 BRAILLE PATTERN BLANK as an ignorable sentinel prefix', () => {
// U+2800 looks blank but is a real glyph rather than a Unicode default
// ignorable. Keeping it means this remains a substantive reply where the
// embedded bare sentinel follows #785's strip-and-post behavior.
const brailleBlank = '\u2800';
expect(AgentMessageService.sanitizeAgentContent(
`${brailleBlank}NO_REPLY\n\nvisible content`,
)).toBe(`${brailleBlank}\n\nvisible content`);
});

it('normalizes Cf-only prefixes before both sentinel checks', () => {
// U+0600 is Cf but not Default_Ignorable. The union is intentional: using
// Default_Ignorable alone re-opens both suppression paths for this prefix.
const arabicNumberSign = '\u0600';
expect(AgentMessageService.sanitizeAgentContent(
`${arabicNumberSign}NO_REPLY\n\nprivate reasoning`,
)).toBe('');
expect(AgentMessageService.sanitizeAgentContent(`${arabicNumberSign}NO_REPLY`)).toBe('');
});

it('keeps zero-width joiners in substantive agent output', () => {
// U+200D is also in the sentinel-normalization class, but it is
// load-bearing in emoji. Sentinel decisions may normalize a copy only.
Expand Down
10 changes: 6 additions & 4 deletions backend/services/agentMessageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,13 @@ const ATTACH_CLAIM_SCAN_LIMIT = 2000;
const BARE_RUNTIME_ARTIFACTS = new Set(['RGCTX']);

const NO_REPLY_SENTINEL = 'NO_REPLY';
// `trim()` does not remove Unicode format characters. Normalize a
// `trim()` does not remove every invisible Unicode code point. Normalize a
// decision-only copy before total-match and leading-sentinel checks so one
// invisible prefix cannot bypass both suppression paths. Do not rewrite the
// stored payload: U+200D, for example, joins emoji glyphs.
const FORMAT_CHARACTERS = /\p{Cf}/gu;
// stored payload: U+200D, for example, joins emoji glyphs. U+2800 BRAILLE
// PATTERN BLANK deliberately stays out: it is a real glyph, not an ignorable
// formatting character.
const SENTINEL_DECISION_IGNORABLES = /[\p{Cf}\p{Default_Ignorable_Code_Point}]/gu;

/**
* Word-boundary test for the sentinel scan. Deliberately not `\w` via regex:
Expand Down Expand Up @@ -1840,7 +1842,7 @@ class AgentMessageService {
const outerFence = raw.match(/^```[^\n]*\n([\s\S]*?)```\s*$/s);
const stripped = outerFence ? outerFence[1] : raw;
const trimmed = stripped.trim();
const sentinelContent = trimmed.replace(FORMAT_CHARACTERS, '');
const sentinelContent = trimmed.replace(SENTINEL_DECISION_IGNORABLES, '');

// Sentinels are total-match contracts: suppress only when the complete
// reply consists of NO_REPLY tokens. Gateways have historically joined
Expand Down
Loading