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 349bdd69..18d0f0f3 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -484,6 +484,17 @@ function isTurnContinuation(reason: string): boolean { return reason === 'context_budget_restart' || reason === 'tool_results_ready' || reason === 'tool_result_guard_retry'; } + +/** Positive classification for operator/local surfaces: future source names + * stay private without extending a fragile allow-list. MCPL events are never + * classified here, and any explicit channel id makes the event channel-bound. */ +export function isNonChannelSurfaceEvent(event: ProcessEvent): boolean { + const type = event.type; + const channelId = (event as unknown as { channelId?: unknown }).channelId; + return !type.startsWith('mcpl:') + && (type === 'external-message' || type === 'api:message') + && channelId === undefined; +} const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router'; const INFERENCE_LOG_ID = 'framework/inference-log'; const PROCESS_LOG_ID = 'framework/process-log'; @@ -1062,6 +1073,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 @@ -6793,6 +6806,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 ) { @@ -6908,6 +6922,7 @@ export class AgentFramework { suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse, ephemeralSystemPrompt: this.activeTurnTriggers.get(agent.name)?.ephemeralSystemPrompt, silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat, + nonChannelOrigin: this.activeTurnTriggers.get(agent.name)?.nonChannelOrigin, }); } else if (currentState.stream) { // Streaming path: convert results and resume the stream. @@ -7064,6 +7079,7 @@ export class AgentFramework { reason: event.type, source, timestamp: Date.now(), + nonChannelOrigin: isNonChannelSurfaceEvent(event), }); } } @@ -8438,6 +8454,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): @@ -8500,8 +8524,8 @@ export class AgentFramework { suppressProse: silentOnly ? trigger?.suppressProse : undefined, ephemeralSystemPrompt: silentOnly ? trigger?.ephemeralSystemPrompt : undefined, silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : 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 @@ -8536,6 +8560,16 @@ 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); + this.turnPrivateProseSuppressed.set( + agentName, (this.turnPrivateProseSuppressed.get(agentName) ?? 0) + count, + ); + } + /** Record a successful plain-prose delivery for this turn's receipt. */ private recordProseDelivery( agentName: string, @@ -8563,12 +8597,14 @@ 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; + 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 ?? []) { @@ -8581,12 +8617,17 @@ export class AgentFramework { : id, ); } - 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)` - : ''; + 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}` : ''}` @@ -9232,6 +9273,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') { @@ -9243,7 +9285,14 @@ 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?.nonChannelOrigin === true; + 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); @@ -9881,6 +9930,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( @@ -10340,6 +10391,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 @@ -10422,6 +10475,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 ` + @@ -10463,7 +10518,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 @@ -10633,7 +10688,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; } @@ -15026,6 +15081,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) => { @@ -15051,9 +15110,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 bbf4971c..d0c91118 100644 --- a/src/types/agent.ts +++ b/src/types/agent.ts @@ -367,4 +367,6 @@ export interface InferenceRequest { * turn stores is stamped `metadata.silentHeartbeat` with it, and request * builds render a request-only separator before the tick's first row. */ silentHeartbeat?: SilentHeartbeatTick; + /** True when a private non-channel surface requested this inference. */ + nonChannelOrigin?: boolean; } diff --git a/test/explicit-prose-routing.test.ts b/test/explicit-prose-routing.test.ts index 98a00006..7d13cd7f 100644 --- a/test/explicit-prose-routing.test.ts +++ b/test/explicit-prose-routing.test.ts @@ -132,6 +132,7 @@ describe('explicit prose routing', () => { framework.pushEvent({ type: 'external-message', source: 'test', + channelId: 'test:channel', content: 'go', metadata: {}, } as unknown as ProcessEvent); diff --git a/test/present-while-acting.test.ts b/test/present-while-acting.test.ts index c5c3157b..31ca7431 100644 --- a/test/present-while-acting.test.ts +++ b/test/present-while-acting.test.ts @@ -191,6 +191,7 @@ describe('present while acting', () => { framework.pushEvent({ type: 'external-message', source: 'test', + channelId: 'test:channel', content: 'go', metadata: {}, } as unknown as ProcessEvent); @@ -731,6 +732,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/same-round-think-policy.test.ts b/test/same-round-think-policy.test.ts index b3b3f252..06194052 100644 --- a/test/same-round-think-policy.test.ts +++ b/test/same-round-think-policy.test.ts @@ -168,6 +168,7 @@ function trigger(framework: AgentFramework): void { framework.pushEvent({ type: 'external-message', source: 'test', + channelId: 'test:channel', content: 'go', metadata: {}, } as unknown as ProcessEvent); diff --git a/test/tool-result-guard.test.ts b/test/tool-result-guard.test.ts index 71ad0787..ed01f360 100644 --- a/test/tool-result-guard.test.ts +++ b/test/tool-result-guard.test.ts @@ -88,7 +88,7 @@ async function harness(scripts: NormalizedResponse[][], config: Partial { - framework.pushEvent({ type: 'external-message', source: 'test', content: 'read', metadata: {} }); + framework.pushEvent({ type: 'external-message', source: 'test', channelId: 'test:channel', content: 'read', metadata: {} } as never); await framework.runUntilIdle(); }; return { framework, membrane, module, base, run }; @@ -329,7 +329,7 @@ test('native Membrane observes the first refusal even when guard is enabled by a if (event.type === 'inference:tokens') tokenTraces.push(String((event as { content?: unknown }).content)); }); try { - framework.pushEvent({ type: 'external-message', source: 'test', content: 'read', metadata: {} }); + framework.pushEvent({ type: 'external-message', source: 'test', channelId: 'test:channel', content: 'read', metadata: {} } as never); await framework.runUntilIdle(); assert.equal(requests.length, 3); assert.match(JSON.stringify(requests[1]), /payload-one/); diff --git a/test/tool-wrapper-prose-guard-integration.test.ts b/test/tool-wrapper-prose-guard-integration.test.ts index 5d8010f0..64774d45 100644 --- a/test/tool-wrapper-prose-guard-integration.test.ts +++ b/test/tool-wrapper-prose-guard-integration.test.ts @@ -125,7 +125,7 @@ async function run(enabled?: boolean, allowedTools?: string[]) { sendOutgoingChunk: (_channel: string, _agent: string, _id: string, _index: number, delta: string) => { outgoing.push(delta); }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); - framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); + framework.pushEvent({ type: 'external-message', source: 'test', channelId: 'test:channel', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; await framework.stop(); @@ -176,7 +176,7 @@ describe('tool wrapper prose guard integration', () => { sendOutgoingChunk: (_c: string, _a: string, _id: string, _i: number, delta: string) => { outgoing.push(delta); }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); - framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); + framework.pushEvent({ type: 'external-message', source: 'test', channelId: 'test:channel', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; await framework.stop(); @@ -193,7 +193,7 @@ describe('tool wrapper prose guard integration', () => { resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); - framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); + framework.pushEvent({ type: 'external-message', source: 'test', channelId: 'test:channel', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; await framework.stop(); assert.equal(module.calls, 1); assert.equal(membrane.calls, 2); assert.deepEqual(routed, ['\n']); @@ -212,7 +212,7 @@ describe('tool wrapper prose guard integration', () => { resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); - framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); + framework.pushEvent({ type: 'external-message', source: 'test', channelId: 'test:channel', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; await framework.stop(); assert.equal(module.calls, 1); assert.equal(membrane.calls, 2); assert.deepEqual(routed, ['\n']); @@ -235,7 +235,7 @@ describe('tool wrapper prose guard integration', () => { getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); (framework as unknown as Record).channelEventModule = { getChannelId: () => 'world:test' }; - framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); + framework.pushEvent({ type: 'external-message', source: 'test', channelId: 'test:channel', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; await framework.stop(); @@ -264,7 +264,7 @@ describe('tool wrapper prose guard integration', () => { resolveLocus: () => 'world:test', routeSpeech: async (_a: string, text: string) => { routed.push(text); return { delivered: true, channelId: 'world:test' }; }, getDefaultPublishChannel: () => null, isChannelOpen: () => true, getDescriptor: () => undefined, getChannelTools: () => [], }, { get: (target, prop: string) => (prop in target ? (target as Record)[prop] : () => undefined) }); - framework.pushEvent({ type: 'external-message', source: 'test', content: 'go', metadata: {} } as unknown as ProcessEvent); + framework.pushEvent({ type: 'external-message', source: 'test', channelId: 'test:channel', content: 'go', metadata: {} } as unknown as ProcessEvent); await framework.runUntilIdle(); const all = framework.getAgent('assistant')!.getContextManager().getAllMessages() as Array<{ content: ContentBlock[]; metadata?: Record }>; await framework.stop(); diff --git a/test/trunk-channel-routing.test.ts b/test/trunk-channel-routing.test.ts index dc61fc56..d941767e 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, @@ -362,6 +363,111 @@ 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(), 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, [], '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(); + }); + + 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(), nonChannelOrigin: true, + }); + 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(); diff --git a/test/turn-trigger-provenance.test.ts b/test/turn-trigger-provenance.test.ts index df75affc..c9a2db83 100644 --- a/test/turn-trigger-provenance.test.ts +++ b/test/turn-trigger-provenance.test.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { AgentFramework } from '../src/index.js'; +import { isNonChannelSurfaceEvent } from '../src/framework.js'; import type { InferenceRequest } from '../src/index.js'; import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; @@ -22,6 +23,19 @@ function internals(framework: AgentFramework) { }; } +describe('non-channel surface classification', () => { + it('treats an unknown future external-message source as private', () => { + assert.equal(isNonChannelSurfaceEvent({ + type: 'external-message', source: 'future-operator-console', content: 'hello', metadata: {}, + } as never), true); + assert.equal(isNonChannelSurfaceEvent({ + type: 'external-message', source: 'future-operator-console', content: 'hello', metadata: {}, + channelId: 'discord:g:room', + } as never), false); + assert.equal(isNonChannelSurfaceEvent({ type: 'mcpl:channel-incoming', channelId: 'room' } as never), false); + }); +}); + describe('Turn trigger provenance', () => { let tempDir: string; let membrane: MockMembrane; @@ -164,3 +178,75 @@ 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(); + 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: InferenceRequest[] = []; + i.startAgentStream = async (_agent: unknown, trigger?: InferenceRequest) => { + if (trigger) captured.push(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(); + 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 }); + }); +});