diff --git a/backend/__tests__/unit/services/soulSentinelContract.test.js b/backend/__tests__/unit/services/soulSentinelContract.test.js new file mode 100644 index 0000000000..0d52d48e54 --- /dev/null +++ b/backend/__tests__/unit/services/soulSentinelContract.test.js @@ -0,0 +1,69 @@ +// TASK-074. The SOUL footer's "Use of NO_REPLY" passage asserts a kernel +// mechanism to a reader who cannot falsify it: an agent acts on the passage +// and has no view of `sanitizeAgentContent`. Nothing guarded that the passage +// stays true, so the sanitizer could change and the copy would go silently +// wrong — which is exactly how the passage came to be false before #1354 +// (it said a fenced sentinel is "preserved verbatim"; a fenced sentinel-only +// reply is suppressed, and a fence is stripped from storage either way). +// +// So these tests pin the BEHAVIOUR each documented case promises, not the +// wording that promises it. If the sanitizer's position rules move, this file +// reddens and names the passage that must move with it. +const fs = require('fs'); +const path = require('path'); +const AgentMessageService = require('../../../services/agentMessageService'); + +const PROVISIONER_SOURCE = path.join( + __dirname, '..', '..', '..', 'services', 'agentProvisionerServiceK8s.ts', +); + +// The passage ships as data inside a template literal, so reading the source +// here is reading the artifact, not asserting that a code path executes. +const readSentinelPassage = () => { + const source = fs.readFileSync(PROVISIONER_SOURCE, 'utf8'); + const start = source.indexOf('### Use of NO_REPLY'); + expect(start).toBeGreaterThan(-1); + const rest = source.slice(start + 1); + const end = rest.search(/\n###\s|\n`;/); + return rest.slice(0, end === -1 ? undefined : end); +}; + +const sanitize = (input) => AgentMessageService.sanitizeAgentContent(input); + +describe('SOUL footer — the NO_REPLY passage matches the sanitizer', () => { + it('documents exactly the cases the tests below cover', () => { + // A budget, not a copy assertion: adding a fifth case to the passage + // without a behavioural pin for it reddens here rather than shipping an + // unguarded promise. Rewording any existing bullet does not. + const bullets = readSentinelPassage() + .split('\n') + .filter((line) => line.startsWith('- **')); + expect(bullets).toHaveLength(4); + }); + + it('case 1 — a leading bare sentinel suppresses the WHOLE reply', () => { + expect(sanitize('NO_REPLY')).toBe(''); + expect(sanitize('NO_REPLY\nHere is the real answer.')).toBe(''); + }); + + it('case 2 — a bare sentinel anywhere else is stripped and the rest POSTS', () => { + expect(sanitize('Shipped the fix.\nNO_REPLY')).toBe('Shipped the fix.'); + expect(sanitize('Reply with NO_REPLY when done.')).toBe('Reply with when done.'); + }); + + it('case 3 — backticks keep the token, INCLUDING as the entire reply', () => { + expect(sanitize('Reply with `NO_REPLY` when done.')) + .toBe('Reply with `NO_REPLY` when done.'); + // The half that makes case 3 and case 4 different bullets rather than one: + // an inline-backticked sentinel-only reply posts that literal. + expect(sanitize('`NO_REPLY`')).toBe('`NO_REPLY`'); + expect(sanitize('``NO_REPLY``')).toBe('``NO_REPLY``'); + }); + + it('case 4 — a fence keeps the token, drops the fence, and stays silent alone', () => { + expect(sanitize('```text\nNO_REPLY is discussed here.\n```')) + .toBe('NO_REPLY is discussed here.'); + expect(sanitize('```text\nNO_REPLY\n```')).toBe(''); + expect(sanitize('```\nNO_REPLY\n```')).toBe(''); + }); +}); diff --git a/backend/services/agentProvisionerServiceK8s.ts b/backend/services/agentProvisionerServiceK8s.ts index c088e6b2ab..039f7a7895 100644 --- a/backend/services/agentProvisionerServiceK8s.ts +++ b/backend/services/agentProvisionerServiceK8s.ts @@ -437,9 +437,26 @@ naturally well before the guard trips. ### Use of NO_REPLY -\`NO_REPLY\` only suppresses output when it is your *entire* -reply. Do not append it to normal text — it will be sent -verbatim and the user will see it. +Position decides what happens, and the cases differ: + +- **It opens your reply** — the entire reply is suppressed, even + when substantive text follows. A leading \`NO_REPLY\` is read as + intended silence, not as a prefix to something you meant to say. +- **Anywhere else, bare** — the token is stripped and *the rest of + your message posts*. Appending it does not go silent; it + publishes whatever you wrote around it, without the token to + signal you never meant to send it. +- **Inside backticks** — kept, so backtick the token when you want + to mention it. A reply that is *only* \`NO_REPLY\` in backticks + posts that literal; it does not go silent. +- **Inside a code fence** — the token is kept but the outer fence is + removed before storage, so a fenced mention is not verbatim. And a + reply that is *only* a fenced token is still silence: fencing does + not un-silence a sentinel-only reply, so never fence the token to + demonstrate it. + +So: to stay silent, send \`NO_REPLY\` and nothing else. Never +append it to text you would mind publishing. `; const ensureWorkspaceSoulFile = async (accountId: any, content: any, { gateway } : any = {}) => {