From a8257b3f0cf5c91ad534e6c548804e837d04cd50 Mon Sep 17 00:00:00 2001 From: rufalupa666-netizen Date: Mon, 28 Sep 2026 23:15:09 +0300 Subject: [PATCH 1/3] fix(routing): a turn from a non-channel surface pins no inferred locus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A WebUI/TUI/CLI/headless/API turn has no channel. The freeze at turn start treated every no-channel turn as ambient and fell through to home ?? activeChannel ?? defaultPublishChannel, where the last is the process-global most recent incoming channel. So a private WebUI reply inherited whichever Discord channel last spoke. Three incidents in one week on a production resident (2026-09-15, 09-22, 09-23). Freeze null before resolveLocus when the trigger is an external-message from a non-channel source (tui/cli/headless/api); the source is already on the InferenceRequest. Channel-triggered and heartbeat turns are unchanged. No gate/wake provenance involved. channel_open's current-turn repin (the 2026-07-31 call) is deliberately untouched — separate question for its owner. Tests: four cases in test/trunk-channel-routing.test.ts. Full suite 971 (967 pass, 4 skipped), tsc --noEmit clean, git diff --check clean. Co-developed with qa-engineer (connectome-host resident). --- .../non-channel-origin-locus.changed.md | 7 ++ src/framework.ts | 12 +- test/trunk-channel-routing.test.ts | 103 ++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 changelog.d/non-channel-origin-locus.changed.md diff --git a/changelog.d/non-channel-origin-locus.changed.md b/changelog.d/non-channel-origin-locus.changed.md new file mode 100644 index 00000000..5c4f0029 --- /dev/null +++ b/changelog.d/non-channel-origin-locus.changed.md @@ -0,0 +1,7 @@ +- **Residents operated from WebUI/TUI/CLI/headless/API while also present in + channels:** a turn triggered from a non-channel surface no longer infers a + channel locus (home, active, or process-global last-inbound). Plain prose on + such a turn stays with the surface that triggered it; reaching a channel + requires an explicit send tool. Channel-triggered and heartbeat turns are + unchanged. Closes the case where a private WebUI reply was published to the + Discord channel that last spoke. diff --git a/src/framework.ts b/src/framework.ts index dea6bc28..6b44aad5 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -8909,7 +8909,17 @@ export class AgentFramework { this.midTurnInputSignals.delete(agent.name); this.turnLocusPins.delete(agent.name); } else { - const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null; + // A non-channel surface is the turn's destination. Its source is + // preserved on the InferenceRequest at enqueue (applyProcessResponse), + // so fail closed BEFORE home/active/global channel resolution. WebUI + // already receives the stream; routing it elsewhere would be a leak. + const nonChannelSurfaceTurn = + trigger?.reason === 'external-message' && + trigger.channelId === undefined && + ['tui', 'cli', 'headless', 'api'].includes(trigger.source); + const locus = nonChannelSurfaceTurn + ? null + : this.channelRegistry?.resolveLocus(agent.name) ?? null; if (locus !== null) this.turnLocusPins.set(agent.name, locus); else this.turnLocusPins.delete(agent.name); this.midTurnInputSignals.delete(agent.name); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index d9108df9..9a01632a 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -29,6 +29,7 @@ import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; function internals(framework: AgentFramework) { return framework as unknown as { activeTriggerChannels: Map; + turnLocusPins: Map; pendingRequests: Array<{ agentName: string; reason: string; source: string; timestamp: number; channelId?: string }>; derivePushEventChannel( origin: Record | undefined, @@ -290,6 +291,108 @@ describe('Trunk channel routing (item-3 redux)', () => { await framework.stop(); }); + it('a WebUI turn after Discord traffic pins null and routes with no guess', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'private WebUI reply' }])); + const framework = await makeFramework(); + const i = internals(framework); + let resolveCalls = 0; + const routedLoci: Array = []; + i.channelRegistry = { + resolveLocus: () => { resolveCalls++; return 'discord:guild:last-room'; }, + routeSpeech: async (_agent: string, _text: string, locus: string | null) => { + routedLoci.push(locus); + return null; + }, + getDescriptor: () => undefined, + sendOutgoingChunk: () => {}, sendOutgoingComplete: () => {}, sendOutgoingLifecycle: () => {}, + startTyping: () => {}, stopTyping: () => {}, stopAll: () => {}, getChannelTools: () => [], + }; + + const scout = framework.getAgent('scout')!; + await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise }) + .startAgentStream(scout, { + agentName: 'scout', reason: 'external-message', source: 'tui', timestamp: Date.now(), + }); + await framework.runUntilIdle(); + + assert.equal(resolveCalls, 0, 'non-channel origin must bypass global last-inbound'); + assert.equal(i.turnLocusPins.has('scout'), false, 'WebUI turn must freeze no Discord locus'); + assert.deepEqual(routedLoci, [null], 'routeSpeech receives null and can emit its no-locus marker'); + await framework.stop(); + }); + + it('a WebUI turn overrides even a resident home channel', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'private resident reply' }])); + const framework = await makeFramework(); + const i = internals(framework); + let resolveCalls = 0; + i.channelRegistry = { + resolveLocus: () => { resolveCalls++; return 'discord:guild:resident-home'; }, + routeSpeech: async () => null, + getDescriptor: () => undefined, + sendOutgoingChunk: () => {}, sendOutgoingComplete: () => {}, sendOutgoingLifecycle: () => {}, + startTyping: () => {}, stopTyping: () => {}, stopAll: () => {}, getChannelTools: () => [], + }; + + const scout = framework.getAgent('scout')!; + await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise }) + .startAgentStream(scout, { + agentName: 'scout', reason: 'external-message', source: 'tui', timestamp: Date.now(), + }); + await framework.runUntilIdle(); + + assert.equal(resolveCalls, 0, 'non-channel origin must bypass home and active resolvers too'); + assert.equal(i.turnLocusPins.has('scout'), false); + await framework.stop(); + }); + + it('a channel-triggered turn still pins its channel', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'channel reply' }])); + const framework = await makeFramework(); + const i = internals(framework); + i.channelRegistry = { + resolveLocus: () => i.activeTriggerChannels.get('scout') ?? null, + routeSpeech: async () => null, + getDescriptor: () => undefined, + sendOutgoingChunk: () => {}, sendOutgoingComplete: () => {}, sendOutgoingLifecycle: () => {}, + startTyping: () => {}, stopTyping: () => {}, stopAll: () => {}, getChannelTools: () => [], + }; + + const scout = framework.getAgent('scout')!; + await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise }) + .startAgentStream(scout, { + agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: Date.now(), + channelId: 'discord:guild:chanA', + }); + await framework.runUntilIdle(); + + assert.equal(i.turnLocusPins.get('scout'), 'discord:guild:chanA'); + await framework.stop(); + }); + + it('a heartbeat turn still uses the global fallback', async () => { + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'heartbeat reply' }])); + const framework = await makeFramework(); + const i = internals(framework); + i.channelRegistry = { + resolveLocus: () => 'discord:guild:last-room', + routeSpeech: async () => null, + getDescriptor: () => undefined, + sendOutgoingChunk: () => {}, sendOutgoingComplete: () => {}, sendOutgoingLifecycle: () => {}, + startTyping: () => {}, stopTyping: () => {}, stopAll: () => {}, getChannelTools: () => [], + }; + + const scout = framework.getAgent('scout')!; + await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise }) + .startAgentStream(scout, { + agentName: 'scout', reason: 'heartbeat', source: 'timer', timestamp: Date.now(), + }); + await framework.runUntilIdle(); + + assert.equal(i.turnLocusPins.get('scout'), 'discord:guild:last-room'); + await framework.stop(); + }); + it('startAgentStream clears the triggering channel for a no-channel (heartbeat) turn', async () => { membrane.pushResponse(createMockResponse([{ type: 'text', text: 'tick' }])); const framework = await makeFramework(); From 5dbbc8d4e9072cad71a0bd4630e62801329086dc Mon Sep 17 00:00:00 2001 From: rufalupa666-netizen Date: Tue, 29 Sep 2026 23:52:35 +0300 Subject: [PATCH 2/3] fix(routing): carry nonChannelOrigin from enqueue; cover api:message, mixed batches, mid-turn re-pin, and the false send-failed marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the four review findings on #190: 1. The guard matched WebUI chat (external-message/tui) but missed the API message.send ingress (api:message, source unknown). Classify private origin once at enqueue as InferenceRequest.nonChannelOrigin and read the flag at the freeze instead of reconstructing it from reason/source strings. 2. A private trigger could borrow a sibling channel request's channelId and addressed flag from the same batch (pre-existing coalescer semantics, framework.ts ~7576). A private trigger now keeps channelId undefined and addressed false. Regression test in turn-trigger-provenance.test.ts. 3. A mid-turn addressed injection could re-pin a private turn's null locus (framework.ts ~6471). Skipped when the active trigger is nonChannelOrigin; channel-origin turns keep the ratified re-pin. 4. routeSpeech(null) produced a false [discord-send-failed] marker for a reply the WebUI had already shown. Private turns now use the existing prose suppression accounting (as proseRouting=disabled does) and receive a '[delivered] nothing — kept private (non-channel turn)' receipt; routeSpeech is not called. Full suite 972 (968 pass, 4 skipped), tsc --noEmit clean, git diff --check clean. --- src/framework.ts | 40 +++++++++++++++++++--------- src/types/agent.ts | 2 ++ test/trunk-channel-routing.test.ts | 9 ++++--- test/turn-trigger-provenance.test.ts | 34 +++++++++++++++++++++++ 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 6b44aad5..317c1067 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -6588,6 +6588,7 @@ export class AgentFramework { // updates lastAnnouncedLocus so the next turn's announce-on-change // diffs against what the agent was actually last told. if ( + this.activeTurnTriggers.get(agent.name)?.nonChannelOrigin !== true && (agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') && !shouldEndTurn && !overBudget && currentState.stream ) { @@ -6858,6 +6859,9 @@ export class AgentFramework { reason: event.type, source, timestamp: Date.now(), + nonChannelOrigin: + (event.type === 'external-message' && ['tui', 'cli', 'headless'].includes(source)) + || event.type === 'api:message', }); } } @@ -8219,8 +8223,8 @@ export class AgentFramework { ...trigger, suppressProse: silentOnly ? trigger?.suppressProse : undefined, ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined, - channelId: channelReq?.channelId, - addressed: addressedReq !== undefined, + channelId: trigger.nonChannelOrigin ? undefined : channelReq?.channelId, + addressed: trigger.nonChannelOrigin ? false : addressedReq !== undefined, // A context-budget restart continues the same logical turn: it keeps // the channel for routing but names no author — the restart is its // own cause, and borrowing another request's author would be false @@ -8238,6 +8242,13 @@ export class AgentFramework { this.turnProseSuppressed.set(agentName, (this.turnProseSuppressed.get(agentName) ?? 0) + count); } + /** Keep prose from a private non-channel turn in Chronicle/WebUI only. */ + private suppressPrivateTurnProse(agentName: string, count: number): void { + if (count <= 0) return; + console.error(`[routing] ${agentName}: prose NOT auto-published (private non-channel turn)`); + this.recordProseSuppression(agentName, count); + } + /** Record a successful plain-prose delivery for this turn's receipt. */ private recordProseDelivery( agentName: string, @@ -8265,7 +8276,7 @@ export class AgentFramework { * delivered nothing. Failures are already marked separately * ([discord-send-failed]); this is the success half. */ - private appendProseDeliveryReceipt(agent: Agent): void { + private appendProseDeliveryReceipt(agent: Agent, privateTurn = false): void { const list = this.turnProseDeliveries.get(agent.name); const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0; if ((!list || list.length === 0) && suppressed === 0) return; @@ -8285,9 +8296,11 @@ export class AgentFramework { } const suppressedNote = suppressed > 0 - ? agent.proseRouting === 'disabled' - ? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)` - : `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)` + ? privateTurn + ? `${suppressed} plain-speech segment(s) kept private (non-channel turn — publish only with an explicit send tool)` + : agent.proseRouting === 'disabled' + ? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)` + : `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)` : ''; const text = shown.length > 0 @@ -8913,10 +8926,7 @@ export class AgentFramework { // preserved on the InferenceRequest at enqueue (applyProcessResponse), // so fail closed BEFORE home/active/global channel resolution. WebUI // already receives the stream; routing it elsewhere would be a leak. - const nonChannelSurfaceTurn = - trigger?.reason === 'external-message' && - trigger.channelId === undefined && - ['tui', 'cli', 'headless', 'api'].includes(trigger.source); + const nonChannelSurfaceTurn = trigger?.nonChannelOrigin === true; const locus = nonChannelSurfaceTurn ? null : this.channelRegistry?.resolveLocus(agent.name) ?? null; @@ -9521,6 +9531,8 @@ export class AgentFramework { console.error( `[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (same_round_think_text_policy=private)`, ); + } else if (trigger?.nonChannelOrigin) { + this.suppressPrivateTurnProse(agent.name, roundSegments.length); } else { const locus = resolveTurnLocus(); console.error( @@ -9887,6 +9899,8 @@ export class AgentFramework { } catch (err) { console.error('text-only prose delivery failed:', err); } + } else if (trigger?.nonChannelOrigin) { + this.suppressPrivateTurnProse(agent.name, 1); } else { // Route to the TURN-FROZEN locus, like every other speech // path. This dispatch runs AFTER the agent is idle, so a live @@ -9969,6 +9983,8 @@ export class AgentFramework { } } } + } else if (trigger?.nonChannelOrigin) { + this.suppressPrivateTurnProse(agent.name, segments.length); } else if (silenced || segments.length === 0) { console.error( `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` + @@ -10010,7 +10026,7 @@ export class AgentFramework { // segments were awaited in-loop. Locus mode only; explicit-mode // envelopes acknowledge themselves through the prose gateway. if (!trigger?.suppressProse && turnProseRouting !== 'explicit') { - this.appendProseDeliveryReceipt(agent); + this.appendProseDeliveryReceipt(agent, trigger?.nonChannelOrigin === true); } // Explicit-prose `!` continuation: a prose segment this turn asked @@ -10180,7 +10196,7 @@ export class AgentFramework { // receipt for the whole turn. if (cancelKind === 'turn_ended' && !trigger?.suppressProse && turnProseRouting !== 'explicit') { await turnSpeechChain; - this.appendProseDeliveryReceipt(agent); + this.appendProseDeliveryReceipt(agent, trigger?.nonChannelOrigin === true); } return; } diff --git a/src/types/agent.ts b/src/types/agent.ts index a42e1864..3623a6b3 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -303,6 +303,8 @@ export interface InferenceRequest { reason: string; source: string; timestamp: number; + /** True when a private non-channel surface requested this inference. */ + nonChannelOrigin?: boolean; /** * The MCPL channel whose message triggered this inference, if any (composite * id, e.g. `discord:guild:channel` / `discord:dm:id`). The framework routes diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index 9a01632a..90b61ef8 100644 --- a/test/trunk-channel-routing.test.ts +++ b/test/trunk-channel-routing.test.ts @@ -311,13 +311,16 @@ describe('Trunk channel routing (item-3 redux)', () => { const scout = framework.getAgent('scout')!; await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise }) .startAgentStream(scout, { - agentName: 'scout', reason: 'external-message', source: 'tui', timestamp: Date.now(), + agentName: 'scout', reason: 'external-message', source: 'tui', timestamp: Date.now(), nonChannelOrigin: true, }); await framework.runUntilIdle(); assert.equal(resolveCalls, 0, 'non-channel origin must bypass global last-inbound'); assert.equal(i.turnLocusPins.has('scout'), false, 'WebUI turn must freeze no Discord locus'); - assert.deepEqual(routedLoci, [null], 'routeSpeech receives null and can emit its no-locus marker'); + assert.deepEqual(routedLoci, [], 'private prose is retained without calling routeSpeech'); + const texts = scout.getContextManager().getAllMessages().flatMap((m) => m.content) + .filter((b): b is { type: 'text'; text: string } => b.type === 'text').map((b) => b.text); + assert.ok(texts.some((t) => t.includes('[delivered] nothing') && t.includes('kept private'))); await framework.stop(); }); @@ -337,7 +340,7 @@ describe('Trunk channel routing (item-3 redux)', () => { const scout = framework.getAgent('scout')!; await (framework as unknown as { startAgentStream(agent: unknown, trigger?: unknown): Promise }) .startAgentStream(scout, { - agentName: 'scout', reason: 'external-message', source: 'tui', timestamp: Date.now(), + agentName: 'scout', reason: 'external-message', source: 'tui', timestamp: Date.now(), nonChannelOrigin: true, }); await framework.runUntilIdle(); diff --git a/test/turn-trigger-provenance.test.ts b/test/turn-trigger-provenance.test.ts index 793dc4b1..2a029027 100644 --- a/test/turn-trigger-provenance.test.ts +++ b/test/turn-trigger-provenance.test.ts @@ -122,3 +122,37 @@ describe('Turn trigger provenance', () => { await framework.stop(); }); }); + +describe('private non-channel trigger batching', () => { + let tempDir: string; + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), 'private-batch-test-')); }); + afterEach(() => { rmSync(tempDir, { recursive: true, force: true }); }); + + it('does not borrow a sibling channel request from the same batch', async () => { + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'private reply' }])); + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'scout' }], modules: [], + }); + const i = internals(framework); + const captured: { handed?: InferenceRequest } = {}; + const orig = i.startAgentStream.bind(framework); + i.startAgentStream = async (agent: unknown, trigger?: InferenceRequest) => { + captured.handed = trigger; + return orig(agent, trigger); + }; + const t = Date.now(); + i.pendingRequests.push( + { agentName: 'scout', reason: 'external-message', source: 'tui', timestamp: t, nonChannelOrigin: true }, + { agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: t + 1, + channelId: 'discord:g:room', addressed: true }, + ); + await i.processInferenceRequests(); + await framework.runUntilIdle(); + assert.equal(captured.handed?.nonChannelOrigin, true); + assert.equal(captured.handed?.channelId, undefined); + assert.equal(captured.handed?.addressed, false); + await framework.stop(); + }); +}); From e3054d07ddfa1a7260dc0179e2d95780c870190d Mon Sep 17 00:00:00 2001 From: qa-engineer Date: Wed, 30 Sep 2026 19:16:24 +0000 Subject: [PATCH 3/3] chore: move nonChannelOrigin to the end of InferenceRequest to ease merges --- src/framework.ts | 45 ++++++++++++++++----- src/types/agent.ts | 4 +- test/present-while-acting.test.ts | 20 ++++++++++ test/turn-trigger-provenance.test.ts | 58 +++++++++++++++++++++++----- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/src/framework.ts b/src/framework.ts index 317c1067..6349f87e 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -1051,6 +1051,8 @@ export class AgentFramework { * author sees the segment's fate one turn later. Cleared each fresh * turn; budget restarts keep it. */ private turnProseSuppressed: Map = new Map(); + /** Subset suppressed specifically by a private non-channel turn. */ + private turnPrivateProseSuppressed: Map = new Map(); /** A tool boundary injected fresh CONVERSATIONAL input (a real message — * not a reaction or a system marker) into the live stream. Tells * driveStream to clear sticky explicit-send suppression before handling @@ -8182,6 +8184,14 @@ export class AgentFramework { // requests[0] in that mixed batch bypassed the turn lock because a // restart existed, then treated the continuation as a fresh turn. const trigger = budgetRestart ?? requests[0]; + if (trigger.nonChannelOrigin) { + // A private surface wake is its own turn. Channel-bearing siblings are + // not merely stripped: requeue them so their addressed/channel context + // receives a distinct subsequent turn. + const channelSiblings = requests.filter((r) => r !== trigger && !!r.channelId); + if (channelSiblings.length > 0) this.pendingRequests.push(...channelSiblings); + requests = requests.filter((r) => r === trigger || !r.channelId); + } // Route this turn's auto-published speech to the channel that triggered // it (item-3 redux). A batched wake may carry several triggering channels // (messages arrived in >1 channel while the agent was busy/idle): @@ -8247,6 +8257,9 @@ export class AgentFramework { if (count <= 0) return; console.error(`[routing] ${agentName}: prose NOT auto-published (private non-channel turn)`); this.recordProseSuppression(agentName, count); + this.turnPrivateProseSuppressed.set( + agentName, (this.turnPrivateProseSuppressed.get(agentName) ?? 0) + count, + ); } /** Record a successful plain-prose delivery for this turn's receipt. */ @@ -8279,9 +8292,11 @@ export class AgentFramework { private appendProseDeliveryReceipt(agent: Agent, privateTurn = false): void { const list = this.turnProseDeliveries.get(agent.name); const suppressed = this.turnProseSuppressed.get(agent.name) ?? 0; + const privateSuppressed = this.turnPrivateProseSuppressed.get(agent.name) ?? 0; if ((!list || list.length === 0) && suppressed === 0) return; this.turnProseDeliveries.delete(agent.name); this.turnProseSuppressed.delete(agent.name); + this.turnPrivateProseSuppressed.delete(agent.name); const seen = new Set(); const shown: string[] = []; for (const id of list ?? []) { @@ -8294,14 +8309,17 @@ export class AgentFramework { : id, ); } - const suppressedNote = - suppressed > 0 - ? privateTurn - ? `${suppressed} plain-speech segment(s) kept private (non-channel turn — publish only with an explicit send tool)` - : agent.proseRouting === 'disabled' - ? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)` - : `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)` - : ''; + const notes: string[] = []; + if (privateSuppressed > 0) { + notes.push(`${privateSuppressed} plain-speech segment(s) kept private (non-channel turn — publish only with an explicit send tool)`); + } + const otherSuppressed = Math.max(0, suppressed - privateSuppressed); + if (otherSuppressed > 0) { + notes.push(agent.proseRouting === 'disabled' + ? `${otherSuppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)` + : `${otherSuppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`); + } + const suppressedNote = notes.join(' · '); const text = shown.length > 0 ? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}` @@ -8911,6 +8929,7 @@ export class AgentFramework { this.turnEngagedChannels.delete(agent.name); this.turnProseDeliveries.delete(agent.name); this.turnProseSuppressed.delete(agent.name); + this.turnPrivateProseSuppressed.delete(agent.name); this.proseHybridSuppressed.delete(agent.name); if (turnProseRouting === 'hybrid') this.proseTargetPins.delete(agent.name); if (turnProseRouting === 'explicit' || turnProseRouting === 'disabled') { @@ -14394,6 +14413,10 @@ export class AgentFramework { this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input }); const startTime = Date.now(); + // Bind async channel_open completion to the logical turn that issued it. + // A stale completion may update next-turn active state, but must never + // rewrite a newer turn's frozen pin/privacy. + const issuingTurnToken = this.activeTurnTokens.get(agentName); this.channelRegistry!.handleChannelToolCall(call.name, call.input, { kind: 'agent', agentName }) .then((result) => { @@ -14419,9 +14442,13 @@ export class AgentFramework { if (opened) { this.activeTriggerChannels.set(agentName, opened); const openerAgent = this.agents.get(agentName); - if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { + const stillIssuingTurn = issuingTurnToken !== undefined + && this.activeTurnTokens.get(agentName) === issuingTurnToken; + if (stillIssuingTurn && openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) { this.turnLocusPins.set(agentName, opened); this.lastAnnouncedLocus.set(agentName, opened); + const activeTrigger = this.activeTurnTriggers.get(agentName); + if (activeTrigger?.nonChannelOrigin) activeTrigger.nonChannelOrigin = false; result = { ...result, data: { diff --git a/src/types/agent.ts b/src/types/agent.ts index 3623a6b3..c07844c8 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -303,8 +303,6 @@ export interface InferenceRequest { reason: string; source: string; timestamp: number; - /** True when a private non-channel surface requested this inference. */ - nonChannelOrigin?: boolean; /** * The MCPL channel whose message triggered this inference, if any (composite * id, e.g. `discord:guild:channel` / `discord:dm:id`). The framework routes @@ -347,4 +345,6 @@ export interface InferenceRequest { /** Ephemeral system-position prompt for this turn only. Never written to * Chronicle; callers must supply bounded non-secret control text. */ ephemeralSystemPrompt?: string; + /** True when a private non-channel surface requested this inference. */ + nonChannelOrigin?: boolean; } diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index c5c3157b..5fe31273 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -731,6 +731,26 @@ describe('present while acting', () => { await framework.stop(); }); + it('channel_open deliberately lifts private-turn routing into the opened channel', async () => { + membrane.pushResponse(createMockResponse([ + { type: 'tool_use', id: 'c-private', name: 'channel_open', input: { channelId: 'discord:guild:observatory' } }, + ] as ContentBlock[], 'tool_use')); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'Public after deliberate open.' }] as ContentBlock[])); + const framework = await createFramework(); + const routed = stubChannelRegistry(framework); + const registry = (framework as unknown as { channelRegistry: Record }).channelRegistry; + (registry as { handleChannelToolCall?: unknown }).handleChannelToolCall = async () => + ({ success: true, data: { channelId: 'discord:guild:observatory', opened: true } }); + const agent = framework.getAgent('assistant')!; + await (framework as unknown as { startAgentStream(agent: unknown, trigger: unknown): Promise }) + .startAgentStream(agent, { + agentName: 'assistant', reason: 'external-message', source: 'tui', timestamp: Date.now(), nonChannelOrigin: true, + }); + await framework.runUntilIdle(); + assert.deepEqual(routed, [{ text: 'Public after deliberate open.', locus: 'discord:guild:observatory' }]); + await framework.stop(); + }); + it('reactions and system markers injected mid-turn do not clear send suppression', async () => { // A reaction (`chat:reaction` tag) or a `system: true` marker is not // conversational input: prose following an explicit send stays suppressed, diff --git a/test/turn-trigger-provenance.test.ts b/test/turn-trigger-provenance.test.ts index 2a029027..dcfc58e3 100644 --- a/test/turn-trigger-provenance.test.ts +++ b/test/turn-trigger-provenance.test.ts @@ -130,17 +130,14 @@ describe('private non-channel trigger batching', () => { it('does not borrow a sibling channel request from the same batch', async () => { const membrane = new MockMembrane(); - membrane.pushResponse(createMockResponse([{ type: 'text', text: 'private reply' }])); const framework = await AgentFramework.create({ storePath: join(tempDir, 'test.chronicle'), membrane: membrane.asMembrane(), agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'scout' }], modules: [], }); const i = internals(framework); - const captured: { handed?: InferenceRequest } = {}; - const orig = i.startAgentStream.bind(framework); - i.startAgentStream = async (agent: unknown, trigger?: InferenceRequest) => { - captured.handed = trigger; - return orig(agent, trigger); + const captured: InferenceRequest[] = []; + i.startAgentStream = async (_agent: unknown, trigger?: InferenceRequest) => { + if (trigger) captured.push(trigger); }; const t = Date.now(); i.pendingRequests.push( @@ -149,10 +146,51 @@ describe('private non-channel trigger batching', () => { channelId: 'discord:g:room', addressed: true }, ); await i.processInferenceRequests(); - await framework.runUntilIdle(); - assert.equal(captured.handed?.nonChannelOrigin, true); - assert.equal(captured.handed?.channelId, undefined); - assert.equal(captured.handed?.addressed, false); + assert.ok(i.pendingRequests.some((r) => r.channelId === 'discord:g:room')); + await i.processInferenceRequests(); + assert.equal(captured[0]?.nonChannelOrigin, true); + assert.equal(captured[0]?.channelId, undefined); + assert.equal(captured[0]?.addressed, false); + assert.equal(captured[1]?.channelId, 'discord:g:room', 'requeued channel request gets its own turn'); + assert.equal(captured[1]?.addressed, true); + await framework.stop(); + }); +}); + +describe('channel_open turn binding', () => { + it('a stale completion updates active channel but not a newer private turn pin', async () => { + const tempDir = mkdtempSync(join(tmpdir(), 'stale-open-test-')); + const framework = await AgentFramework.create({ + storePath: join(tempDir, 'test.chronicle'), membrane: new MockMembrane().asMembrane(), + agents: [{ name: 'scout', model: 'test', systemPrompt: 'scout' }], modules: [], + }); + let resolveOpen!: (value: unknown) => void; + const pending = new Promise((resolve) => { resolveOpen = resolve; }); + const i = framework as unknown as { + activeTurnTokens: Map; activeTriggerChannels: Map; + turnLocusPins: Map; activeTurnTriggers: Map; + channelRegistry: { handleChannelToolCall(...args: unknown[]): Promise }; + dispatchChannelToolCall(agent: string, call: unknown): void; + }; + i.channelRegistry = new Proxy({ + handleChannelToolCall: async () => pending, + }, { get: (target, prop: string) => prop in target ? target[prop as keyof typeof target] : () => undefined }) as never; + i.activeTurnTokens.set('scout', 1); + i.activeTurnTriggers.set('scout', { agentName: 'scout', reason: 'mcpl:channel-incoming', source: 'discord', timestamp: 1 }); + i.turnLocusPins.set('scout', 'discord:g:old'); + i.dispatchChannelToolCall('scout', { id: 'open-1', name: 'channel_open', input: { channelId: 'discord:g:opened' } }); + + // A new private turn starts before the old tool resolves. + i.activeTurnTokens.set('scout', 2); + i.activeTurnTriggers.set('scout', { agentName: 'scout', reason: 'external-message', source: 'tui', timestamp: 2, nonChannelOrigin: true }); + i.turnLocusPins.delete('scout'); + resolveOpen({ success: true, data: { channelId: 'discord:g:opened', opened: true } }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(i.activeTriggerChannels.get('scout'), 'discord:g:opened'); + assert.equal(i.turnLocusPins.has('scout'), false, 'stale completion cannot redirect the new private turn'); + assert.equal(i.activeTurnTriggers.get('scout')!.nonChannelOrigin, true, 'new turn privacy remains set'); await framework.stop(); + rmSync(tempDir, { recursive: true, force: true }); }); });