diff --git a/changelog.d/disabled-console-receipt.fixed.md b/changelog.d/disabled-console-receipt.fixed.md new file mode 100644 index 000000000..80485435c --- /dev/null +++ b/changelog.d/disabled-console-receipt.fixed.md @@ -0,0 +1 @@ +- Under `proseRouting: "disabled"`, the turn-end receipt now reads `[delivered] plain speech → no channel — N plain-speech segment(s) kept in the transcript (…)` instead of `[delivered] nothing — N plain-speech segment(s) suppressed (…)`, which read to a resident talking with its operator through a console or Web UI as if its replies had reached no one (#225). diff --git a/docs/disabled-prose-routing.md b/docs/disabled-prose-routing.md index e39e3c971..7355c6687 100644 --- a/docs/disabled-prose-routing.md +++ b/docs/disabled-prose-routing.md @@ -7,8 +7,9 @@ want every external utterance to be an explicit tool action. - Ambient traffic and channel opens cannot establish a publication locus. - Outgoing prose streaming and typing indicators are disabled. - Explicit publish tools (Discord/Portal sends, Eidoverse `say`, etc.) are unchanged. -- Authored prose remains in Chronicle. At turn end, a private `[delivered] nothing` - receipt records how many prose segments were suppressed. +- Authored prose remains in Chronicle. An ordinary turn that wrote plain speech + ends with a private `[delivered] plain speech → no channel` receipt, which + records how many segments stayed in the transcript instead of going to a channel. - The mode injects no model-visible primer. - Sleep announcements remain private unless the resident sends one explicitly. diff --git a/src/framework.ts b/src/framework.ts index 394ccd81e..a086afb66 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -13483,7 +13483,7 @@ export class AgentFramework { if (kept > 0) notes.push(`${kept} plain-speech segment(s) kept private (skip_reply or a private think)`); if (suppressed > 0) { notes.push(agent.proseRouting === 'disabled' - ? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)` + ? `${suppressed} plain-speech segment(s) kept in the transcript (proseRouting=disabled — post to a channel only with an explicit send tool)` : `${suppressed} plain-speech segment(s) suppressed`); } const suppressedNote = notes.join(' · '); @@ -13492,10 +13492,17 @@ export class AgentFramework { // since the turn held it may have had such an attempt: its state is // unknown now. const uncertain = unconfirmed.length > 0 || dismissedAtRisk.length > 0 || letGo.length > 0; + // In disabled mode, routing posts no plain speech to any channel, and the + // words stay in the transcript. The receipt says only that. Who watched + // the turn's stream is for the host to know, and an explicit send + // reports itself in its tool result. Disabled mode holds no drafts, so + // it is never uncertain. const text = shown.length > 0 ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` - : `[delivered] nothing${uncertain ? ' confirmed' : ''} — ${suppressedNote}`; + : agent.proseRouting === 'disabled' + ? `[delivered] plain speech → no channel — ${suppressedNote}` + : `[delivered] nothing${uncertain ? ' confirmed' : ''} — ${suppressedNote}`; try { const mid = agent.getContextManager().addMessage( 'user', @@ -16346,9 +16353,9 @@ export class AgentFramework { // receipt. Queued deliveries and holds settled before final // dispatch, even when the last response is empty, so the receipt // and the continuation see the last rounds' deliveries; - // trailing/text-only segments were awaited in-loop. Locus and - // hybrid modes only; explicit-mode envelopes acknowledge - // themselves through the prose gateway. + // trailing/text-only segments were awaited in-loop. Every mode + // but explicit, whose envelopes acknowledge themselves through + // the prose gateway. if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { this.appendProseDeliveryReceipt(agent, streamRouting); } diff --git a/src/types/agent.ts b/src/types/agent.ts index 3e71079a2..84ec5a023 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -192,7 +192,8 @@ export interface AgentConfig { * that publication envelope through the same authorized resolver. * See docs/hybrid-prose-routing.md. * - 'disabled': generated prose is never published. Only explicit tools may - * send externally; prose remains in Chronicle with a private suppression receipt. + * send externally; prose remains in Chronicle, and a turn that wrote plain + * speech ends with a private `[delivered] plain speech → no channel` receipt. */ proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled'; diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 6658b8513..4b01e0a78 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -2202,8 +2202,8 @@ describe('explicit prose routing', () => { const texts = framework.getAgent('assistant')!.getContextManager().getAllMessages() .flatMap(m => m.content).filter(b => b.type === 'text').map(b => (b as { text: string }).text); assert.ok(texts.includes(authored), 'authored prose remains in Chronicle'); - assert.ok(texts.some(t => t.includes('[delivered] nothing') && t.includes('proseRouting=disabled')), - 'private suppression receipt explains the unsent prose'); + assert.ok(texts.includes('[delivered] plain speech → no channel — 1 plain-speech segment(s) kept in the transcript (proseRouting=disabled — post to a channel only with an explicit send tool)'), + 'the private receipt says the prose went to no channel and stays in the transcript'); await framework.stop(); }); @@ -2227,7 +2227,7 @@ describe('explicit prose routing', () => { assert.equal(module.calls[0]!.name, 'say'); const texts = framework.getAgent('assistant')!.getContextManager().getAllMessages() .flatMap(m => m.content).filter(b => b.type === 'text').map(b => (b as { text: string }).text); - assert.ok(texts.some(t => t.includes('[delivered] nothing') && t.includes('2 plain-speech segment(s) suppressed')), + assert.ok(texts.some(t => t.includes('[delivered] plain speech → no channel') && t.includes('2 plain-speech segment(s) kept in the transcript')), 'one receipt accounts for mid-turn and trailing prose'); await framework.stop(); diff --git a/test/prose-drafts.test.ts b/test/prose-drafts.test.ts index 210ced87f..031cb7996 100644 --- a/test/prose-drafts.test.ts +++ b/test/prose-drafts.test.ts @@ -557,7 +557,7 @@ describe('held prose drafts, end to end', () => { createMockResponse([text('More of it.')]), ]); assert.deepEqual(h.drafts(), []); - assert.ok(h.texts().some((t) => t.startsWith('[delivered] nothing — 2 plain-speech segment(s) suppressed (proseRouting=disabled'))); + assert.ok(h.texts().some((t) => t.startsWith('[delivered] plain speech → no channel — 2 plain-speech segment(s) kept in the transcript (proseRouting=disabled'))); } finally { await h.close(); }