From 12f0ee662ae99bc5dc67a306d84a57a823c6e371 Mon Sep 17 00:00:00 2001 From: antra-tess Date: Tue, 1 Sep 2026 18:00:56 -0700 Subject: [PATCH] fix(routing): world say/whisper silence adjacent prose in every mode An explicit Eidoverse `say` / `whisper` is the resident's chosen public utterance for that round, but the world-verb set was consulted only in hybrid prose mode. In locus mode a round of ordinary text + `say` published twice: the say text, then the adjacent prose auto-routed to the same world locus (Cairn 2026-09-01, world seq 15146/15147 byte-for-byte; Mica's incident artifact af-world-say-prose-leak-20260901.md). Fold the world verbs into one `isSilencingTool` predicate used at both silencing sites (live round + trailing scan), so they behave exactly like channel sends: sticky from that round on, suppression visible in the [delivered] receipt. Discord send/reply/DM, skip_reply, think() privacy, text-only turns and non-publishing tool rounds are unchanged. Tests: locus-mode say and whisper (nothing auto-routed, receipt reports 2 suppressed), plus a `move` negative control. Mutation control (world verbs removed from the predicate) fails both new tests and the existing hybrid say test. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017op4YjCZABYS9vWBXxeRwH --- changelog.d/world-say-silences-prose.fixed.md | 9 +++ src/framework.ts | 30 +++++--- test/present-while-acting.test.ts | 76 +++++++++++++++++++ 3 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 changelog.d/world-say-silences-prose.fixed.md diff --git a/changelog.d/world-say-silences-prose.fixed.md b/changelog.d/world-say-silences-prose.fixed.md new file mode 100644 index 0000000..5dce81e --- /dev/null +++ b/changelog.d/world-say-silences-prose.fixed.md @@ -0,0 +1,9 @@ +- **World `say` / `whisper` now silence adjacent auto-routed prose in every + prose-routing mode**, not only `hybrid`. In `locus` mode a round of ordinary + text plus an explicit Eidoverse `say` published twice — the say text, then the + adjacent prose auto-routed to the same world locus (Cairn, 2026-09-01, world + seq 15146/15147 byte-for-byte). An explicit world utterance is the resident's + chosen public speech for that round and is treated exactly like a channel + send: sticky silencing from that round on, suppression visible in the + `[delivered]` receipt. Discord send/reply/DM, `skip_reply`, `think()` privacy, + text-only turns and non-publishing tool rounds are unchanged. diff --git a/src/framework.ts b/src/framework.ts index 311d884..9b3c1bc 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -126,8 +126,24 @@ const SILENCING_TOOLS = new Set([ 'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm', ]); -/** World-surface publication names that outrank hybrid prose envelopes. */ -const HYBRID_PUBLICATION_TOOLS = new Set(['say', 'whisper']); +/** + * World-surface publication tools (Eidoverse `say` / `whisper`). An explicit + * world utterance is the resident's chosen public speech for that round, so + * it silences adjacent auto-routed prose exactly like a channel send — in + * EVERY prose-routing mode. Until 2026-09-01 this set was consulted only in + * hybrid mode; in locus mode a round of ordinary text + `say` published twice + * (Cairn world seq 15146 = the say text, 15147 = the adjacent prose, + * byte-for-byte). Hybrid additionally lets these outrank a same-round + * `>>>destination` envelope. + */ +const WORLD_PUBLICATION_TOOLS = new Set(['say', 'whisper']); + +/** True when a tool call (possibly MCPL-prefixed) is an explicit delivery + * that silences the round's auto-routed prose, regardless of mode. */ +const isSilencingTool = (name: string): boolean => { + const bare = bareToolName(name); + return SILENCING_TOOLS.has(bare) || WORLD_PUBLICATION_TOOLS.has(bare); +}; /** * True when an injected message is real conversational input — something the @@ -6257,10 +6273,7 @@ export class AgentFramework { const hasSameRoundPrivateThink = roundToolNames.includes('think') && requestSnapshot.sameRoundThinkTextPolicy === 'private'; - if (roundToolNames.some((n) => - SILENCING_TOOLS.has(bareToolName(n)) || - (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))) - )) { + if (roundToolNames.some(isSilencingTool)) { turnSilenced = true; } if (roundContent && roundContent.length > 0) { @@ -6675,10 +6688,7 @@ export class AgentFramework { .filter((n): n is string => typeof n === 'string'); const silenced = liveProseRouting ? turnSilenced - : turnSilenced || toolNames.some((n) => - SILENCING_TOOLS.has(bareToolName(n)) || - (agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))) - ); + : turnSilenced || toolNames.some(isSilencingTool); const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index 446a681..c5c3157 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -69,6 +69,18 @@ class RobotModule implements Module { description: 'Explicitly send a message', inputSchema: { type: 'object', properties: { text: { type: 'string' } } }, }, + // World-surface publication verbs (Eidoverse). Bare names matter: the + // framework strips the `robot--` prefix before consulting its sets. + { + name: 'say', + description: 'Say something aloud in the world', + inputSchema: { type: 'object', properties: { text: { type: 'string' } } }, + }, + { + name: 'whisper', + description: 'Whisper to someone in the world', + inputSchema: { type: 'object', properties: { to: { type: 'string' }, text: { type: 'string' } } }, + }, ]; } @@ -619,6 +631,70 @@ describe('present while acting', () => { await framework.stop(); }); + for (const verb of ['say', 'whisper'] as const) { + it(`world \`${verb}\` silences adjacent auto-routed prose in locus mode (no double-publish)`, async () => { + // 2026-09-01 (Cairn, locus mode): a round of ordinary text + explicit + // world `say` published TWICE — seq 15146 was the say text, seq 15147 + // the adjacent prose auto-routed to the same world locus, byte-for-byte. + // World publication verbs silenced only in hybrid mode; Discord sends + // silenced everywhere. An explicit world utterance is the resident's + // chosen speech for the round in every mode. + const input = verb === 'say' + ? { text: 'the intended utterance' } + : { to: 'sill', text: 'the intended utterance' }; + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'adjacent prose that must NOT auto-publish' }, + { type: 'tool_use', id: 'c1', name: `robot--${verb}`, input }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'trailing prose, also suppressed (sticky)' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed, [], `${verb}: nothing auto-routed beside the explicit world utterance`); + const cm = (framework as unknown as { + agents: Map }> } }>; + }).agents.get('assistant')!.getContextManager(); + const receipts = cm.getAllMessages() + .flatMap((m) => m.content) + .filter((b) => b.type === 'text') + .map((b) => b.text ?? '') + .filter((t) => t.startsWith('[delivered]')); + assert.deepEqual(receipts, [ + '[delivered] nothing — 2 plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)', + ], `${verb}: suppression is visible in the receipt`); + + await framework.stop(); + }); + } + + it('a non-publishing world tool (move) does not silence — speak-while-acting unchanged', async () => { + // Negative control for the world-verb silencing: only publication verbs + // silence. Ordinary acting tools still narrate live to the locus. + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'walking over' }, + { type: 'tool_use', id: 'c1', name: 'robot--move', input: { dir: 'north' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([ + { type: 'text', text: 'there' }, + ] as ContentBlock[])); + + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + + trigger(framework); + await framework.runUntilIdle(); + + assert.deepEqual(routed.map((r) => r.text), ['walking over', 'there']); + + await framework.stop(); + }); + it('channel_open moves the pin mid-turn and announces in its own tool result', async () => { // The agent's own deliberate open is the strongest "my next words go // here" signal — stronger than any injection. The original 2026-07-21