diff --git a/changelog.d/212-passive-self-echoes.fixed.md b/changelog.d/212-passive-self-echoes.fixed.md new file mode 100644 index 000000000..fe427a8f5 --- /dev/null +++ b/changelog.d/212-passive-self-echoes.fixed.md @@ -0,0 +1,4 @@ +- **Hosts using conversation forks:** Self-authored channel echoes continue updating an existing fork's context, but cannot create a fork, request inference, or refresh its idle clock (#47). Self identity comes from `chat:from-self` or a nonempty author ID matching the message's `botUserId`; other bots and unknown identities keep normal routing. The veto precedes trigger callbacks so echoes cannot schedule delayed gate wakes. Non-channel pushes and deployments without conversation routing keep their existing behavior. +- Recovery distinguishes a vanished recipient from temporary audience-resolution failure, retaining acknowledged work and retrying transient fork creation with backoff. Restored wakes require current server binding, grants, and channel registration before any fork or turn is created; work waiting for transport/policy/registration readiness resumes at those boundaries. +- Coalescer recovery retains interrupted frozen batches alongside newer pending work, including their fallback content, activity causes, and publication targets. Recovered frozen work materializes first, and already-published work is settled against its original fork namespace before any wake is restored. Receipt recovery also preserves resolved author identity. Stored fallback-only delivery policy is separate from frozen-batch role, so repeated crashes preserve survivor order. A mixed batch's retained counterpart activation permits delivery if its binding disappears during rendering. This closes the render-in-flight crash window where a later self update could discard an unconsumed counterpart cause. +- Deferred channel batches retain the latest counterpart activation separately from their latest content. A self update preserves a pending counterpart cause; a later counterpart update replaces the coalescer's cause, including a no-wake verdict. Consumption, retraction, and plain replacement clear the batch and cause together. Snapshots and receipt recovery preserve the cause, so fresh counterpart activity still counts in mixed batches while historical rendered text cannot renew a self-only batch. Legacy snapshots have only their recorded latest occurrence; an earlier overwritten cause cannot be reconstructed. Existing EventGate debounce causes are separate: self echoes neither create nor extend those timers, while general counterpart-to-counterpart replacement still has the pre-existing delayed-wake cancellation limitation. diff --git a/src/framework.ts b/src/framework.ts index 25cdb419b..9f89d7b73 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -5,7 +5,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeF import { JsStore } from '@animalabs/chronicle'; import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane'; import { MembraneError } from '@animalabs/membrane'; -import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager'; +import { ContextManager, MessageStore, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError } from '@animalabs/context-manager'; import type { CacheWireReceipt } from './kv-unified-wire.js'; import { SUBCONSCIOUS_TOOLS, SUBCONSCIOUS_TOOL_NAMES, type SubconsciousConfig } from './tune-out/tools.js'; import { TuneOutCoordinator, TUNE_OUT_DEFAULTS } from './tune-out/coordinator.js'; @@ -81,7 +81,7 @@ import { ProseStreamRouter } from './mcpl/prose-stream-router.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js'; -import { ConversationRouter } from './mcpl/conversation-router.js'; +import { ConversationRouter, type IncomingMessageFacts } from './mcpl/conversation-router.js'; import { safeSlice } from './safe-slice.js'; import type { WorkspaceModule } from './modules/workspace/index.js'; import { @@ -267,6 +267,16 @@ const isAddressedMessage = ( _metadata?: Record, ): boolean => Array.isArray(tags) && tags.includes('chat:addressed'); +/** Identity, not automation: another bot is still a counterpart. These + * server-supplied fields classify traffic the admitted server already owns; + * they neither grant capabilities nor authenticate a separate principal. */ +const isSelfAuthoredMessage = ( + tags: unknown, + authorId: unknown, + metadata?: Record, +): boolean => (Array.isArray(tags) && tags.includes('chat:from-self')) + || (typeof authorId === 'string' && authorId.length > 0 && authorId === metadata?.botUserId); + // parseProsePrefix moved to ./mcpl/prose-grammar.ts — ONE grammar shared with // the outgoing-stream router (Spec 14.3), so streamed chunks and delivered // envelopes can never disagree on what is a prefix. @@ -6968,6 +6978,9 @@ export class AgentFramework { eventId?: string; assemblingFor?: string; deliverTo?: string; + /** Host-owned deferred batch attribution, separate from rendered content. */ + conversationActivity?: boolean; + conversationBinding?: Pick; }): Promise { const metadata: Record = { ...event.metadata, @@ -6992,15 +7005,16 @@ export class AgentFramework { const target = this.agents.get(event.deliverTo); if (!target) return undefined; if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) { - metadata.triggered = event.triggerInference ?? false; + const self = isSelfAuthoredMessage(event.tags, event.author.id, event.metadata); + metadata.triggered = !self && (event.triggerInference ?? false); const id = target.getContextManager().addMessage('user', event.content, metadata); // A correction may target an older engagement. Refresh only the // binding whose agent actually received the message. - if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) { + if ((event.conversationActivity ?? !self) && this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) { this.conversationRouter.touch(event.channelId); } this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); - if (event.triggerInference) { + if (!self && event.triggerInference) { this.pendingRequests.push({ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), channelId: event.channelId, @@ -7112,16 +7126,22 @@ export class AgentFramework { tags?: string[]; triggerInference?: boolean; coalescingSubject?: string; + conversationActivity?: boolean; + conversationBinding?: Pick; }, messageMetadata: Record, ): Promise { const router = this.conversationRouter!; + const self = isSelfAuthoredMessage(event.tags, event.author.id, event.metadata); + // A self-only echo cannot start an engagement. A rendered mixed batch + // may still carry genuine counterpart activation after its binding expired. + if (self && !event.conversationActivity && !router.getBinding(event.channelId)) return; const descriptor = this.channelRegistry?.getDescriptor(event.channelId); const decision = router.route({ channelId: event.channelId, - mentioned: event.metadata?.mentioned === true, - kind: ConversationRouter.classifyChannel(descriptor, event.metadata), + mentioned: event.conversationBinding?.mentioned ?? (event.metadata?.mentioned === true), + kind: event.conversationBinding?.kind ?? ConversationRouter.classifyChannel(descriptor, event.metadata), }); if (decision.kind === 'unbound') { @@ -7174,11 +7194,11 @@ export class AgentFramework { // Respect an explicit server-config-level veto (shouldTriggerInference) // on top of the router's own trigger policy. - const trigger = decision.trigger && event.triggerInference !== false; + const trigger = !self && decision.trigger && event.triggerInference !== false; messageMetadata.triggered = trigger; const id = agent.getContextManager().addMessage('user', event.content, messageMetadata); - router.touch(event.channelId); + if (event.conversationActivity ?? !self) router.touch(event.channelId); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (trigger) { @@ -7491,11 +7511,15 @@ export class AgentFramework { } private initializePushCoalescer(): void { + // Boot-only metadata cache for frozen batches published into forks that + // are no longer registered after restart. Avoid reading their blob bodies. + const publishedForks = new Map>(); const coalescer = new PushCoalescer({ isUnread: (p) => this.isUnreadPlacement(p), remove: (p) => this.removePlacement(p), - deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor), - wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ), + deliver: (occ, materialized, assemblingFor, activation) => this.deliverCoalesced(occ, materialized, assemblingFor, activation), + isPassive: (occ) => !!this.conversationRouter && occ.scope.kind === 'channel' && this.isSelfConversationOccurrence(occ), + wakeForBatch: (occ, preserveExisting) => this.wakeForCoalescedBatch(occ, preserveExisting), cancelWake: (subject) => this.cancelCoalescedWakes(subject), authorized: (occ) => this.coalescedAuthorized(occ), audience: (occ) => this.coalescedAudience(occ), @@ -7513,7 +7537,7 @@ export class AgentFramework { this.coalescingRecentReceipts.push(record); this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts); }, - wasPublished: (subject, eventId) => { + wasPublished: (subject, eventId, publicationAgent) => { // Boot-time only: the occurrence's durable delivery identity // (subject + eventId in message metadata) in any agent's context. for (const agent of this.agents.values()) { @@ -7522,6 +7546,28 @@ export class AgentFramework { m.metadata?.coalescingSubject === subject && m.metadata?.eventId === eventId)) return true; } catch { /* a context that cannot be read cannot prove publication */ } } + if (publicationAgent && !this.agents.has(publicationAgent)) { + let published = publishedForks.get(publicationAgent); + if (!published) { + published = new Set(); + try { + const messages = new MessageStore(this.store, { namespace: `conversations/${publicationAgent}` }); + for (let offset = 0; ;) { + const page = messages.getWindow(offset, 256, { resolveBlobs: false }); + for (const message of page.messages) { + const m = message.metadata; + if (typeof m?.coalescingSubject === 'string' && typeof m?.eventId === 'string') { + published.add(JSON.stringify([m.coalescingSubject, m.eventId])); + } + } + offset = page.startIndex + page.messages.length; + if (offset >= page.totalCount || !page.messages.length) break; + } + } catch { /* unavailable history cannot establish publication */ } + publishedForks.set(publicationAgent, published); + } + return published.has(JSON.stringify([subject, eventId])); + } return false; }, commit: () => this.commitCoalescingDurable(), @@ -7546,9 +7592,16 @@ export class AgentFramework { } catch (err) { console.error('[coalescing] could not restore coalescing state; starting with unknown history:', err); } + publishedForks.clear(); this.pushCoalescer = coalescer; - // Batches restored from a snapshot still owe the agent a wake (§14 v41). - for (const occ of coalescer.pendingBatchOccurrences()) void this.wakeForCoalescedBatch(occ); + // Restore independent batch causes in order. An older frozen counterpart + // cannot be cancelled by a newer quiet batch on the same subject, and two + // causes must not race to create the same conversation fork. + this.resumeCoalescedWork(); + } + + private resumeCoalescedWork(): void { + void this.pushCoalescer?.resumePending().catch(err => console.error('[coalescing] could not resume pending work:', err)); } /** @@ -7632,7 +7685,10 @@ export class AgentFramework { }); } - private handleCoalescedPush(serverId: string, params: PushEventParams, event: McplPushEvent): Promise { + private handleCoalescedPush( + serverId: string, params: PushEventParams, event: McplPushEvent, + triggerFilter: (content: string, metadata: Record) => boolean, + ): Promise { return this.admitCoalesced(async () => { if (!this.pushCoalescer) throw new CoalesceError('coalesce', 'coalescing unavailable', -32000); validateCoalesceMember(params.coalesce, 'push'); @@ -7643,6 +7699,33 @@ export class AgentFramework { if (c.channelId) this.assertChannelScopedPush(serverId, c.channelId); const origin = params.origin ?? {}; const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + const inheritedIdentity = c.channelId ? this.pushCoalescer.identity(coalescingSubjectKey( + serverId, this.coalescingBinding(serverId), { kind: 'channel', id: c.channelId }, c.key, + )) : undefined; + const identity = c.channelId ? { + ...inheritedIdentity, + ...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}), + ...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}), + // Freeze even an unknown author now. A later self update must not + // retroactively change the identity of this pending activation. + author: str(origin.authorId) + ? { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } + : inheritedIdentity?.author ?? { id: '', name: str(origin.authorName) ?? 'Someone' }, + } : undefined; + const prepare = identity ? () => { + // Identity is frozen under admission serialization, and effectful + // trigger policy runs only after the coalescer rules out receipt + // replay. A retry cannot reinterpret an old self event as a newer author. + const text = event.content + .filter((b): b is ContentBlock & { type: 'text'; text: string } => b.type === 'text') + .map(b => b.text).join('\n'); + event.triggerInference = triggerFilter(text, { + ...origin, serverId, featureSet: params.featureSet, eventId: params.eventId, + eventType: 'mcpl:push-event', coalesceChannelId: c.channelId, + authorId: identity.author.id, + ...(event.tags ? { tags: event.tags } : {}), + }); + } : undefined; return this.pushCoalescer.accept({ serverId, binding: this.coalescingBinding(serverId), @@ -7657,15 +7740,9 @@ export class AgentFramework { data: c.data, tags: event.tags, content: params.payload.content, - ...(c.channelId ? { - identity: { - ...(str(origin.messageId) ? { messageId: str(origin.messageId) } : {}), - ...(str(origin.threadId) ? { threadId: str(origin.threadId) } : {}), - ...(str(origin.authorId) ? { author: { id: str(origin.authorId)!, name: str(origin.authorName) ?? str(origin.authorId)! } } : {}), - }, - } : {}), + ...(identity ? { identity } : {}), event: { lane: 'push', event }, - }); + }, prepare); }); } @@ -7680,13 +7757,13 @@ export class AgentFramework { } } - private coalescedAuthorized(occ: CoalescedOccurrence): boolean { + private coalescedAuthorized(occ: CoalescedOccurrence): boolean | undefined { + // No current peer identity means this obligation cannot be transferred. + if (!this.mcplServerConfigs.has(occ.serverId) || occ.binding !== this.coalescingBinding(occ.serverId)) return false; const server = this.mcplServerRegistry?.getServer(occ.serverId); - if (!server?.isConnected || !server.policyEstablished) return false; - // §3.2: work admitted under another binding (the id since reassigned to a - // different endpoint) never acquires the replacement peer's authority — - // and its private notice data is never sent to that peer. - if (occ.binding !== this.coalescingBinding(occ.serverId)) return false; + // Startup/reconnect is not revocation. Keep work until current authority + // is known, and re-evaluate at readiness/registration boundaries. + if (!server?.isConnected || !server.policyEstablished || this.discordAwarenessBarrier) return undefined; if (occ.event.lane === 'push') { if (!server.grant.has('pushEvents')) return false; try { this.featureSetManager?.validateInbound(occ.serverId, occ.event.event.featureSet); } catch { return false; } @@ -7694,13 +7771,22 @@ export class AgentFramework { if (occ.scope.kind === 'channel' || occ.event.lane === 'channel') { // §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane. if (!server.grant.has('channels.incoming')) return false; - if (occ.event.lane === 'push') return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false; - return !!this.channelRegistry?.getDescriptor(occ.scope.id); + // A channel can register after policy establishment. Its absence is + // not authority to wake and not proof acknowledged work was revoked. + return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ? true : undefined; } return true; } - private async coalescedAudience(occ: CoalescedOccurrence): Promise { + private isSelfConversationOccurrence(occ: CoalescedOccurrence): boolean { + const metadata = occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin; + const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); + const identity = { ...this.pushCoalescer?.identity(subject), ...occ.identity }; + const authorId = identity.author?.id ?? (occ.event.lane === 'channel' ? occ.event.event.author.id : metadata?.authorId); + return isSelfAuthoredMessage(occ.tags, authorId, metadata); + } + + private async coalescedAudience(occ: CoalescedOccurrence): Promise { if (occ.deliverTo) return this.agents.has(occ.deliverTo) ? [occ.deliverTo] : []; const explicit = occ.event.event.targetAgents; if (explicit) return explicit.filter((n) => this.agents.has(n)); @@ -7708,6 +7794,7 @@ export class AgentFramework { const router = this.conversationRouter; const bound = router.getBinding(occ.scope.id)?.agentName; if (bound) return this.agents.has(bound) ? [bound] : []; + if (this.isSelfConversationOccurrence(occ)) return []; // No fork yet: the same bind decision an incoming message would get. A // batch that qualifies spawns its fork now so it has someone to wake. const metadata = (occ.event.lane === 'channel' ? occ.event.event.metadata : occ.event.event.origin) ?? {}; @@ -7724,7 +7811,7 @@ export class AgentFramework { return [decision.agentName]; } catch (err) { console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err); - return []; + return null; // temporary resolution failure, not a vanished fixed audience } } return [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n) && n !== this.subconsciousAgentName); @@ -7735,11 +7822,26 @@ export class AgentFramework { occ: CoalescedOccurrence, materialized?: McplContentBlock[], assemblingFor?: string, + activation?: CoalescedOccurrence | null, ): Promise { const coalescingSubject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); const narrowing = occ.deliverTo ? { deliverTo: occ.deliverTo } : {}; + // A rendered batch's latest content may be self-authored while a fresh + // counterpart occurrence still supplies its pending activity. Never infer + // activity from text the renderer happens to include from older history. + // Rebinding must use the cause's mention/channel-kind facts too; the + // latest self echo may omit the very facts that qualified its counterpart. + const activationMetadata = activation?.event.lane === 'channel' + ? activation.event.event.metadata : activation?.event.event.origin; + const activity = activation === undefined ? {} : { + conversationActivity: activation !== null, + ...(activation ? { conversationBinding: { + mentioned: activationMetadata?.mentioned === true, + kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), activationMetadata), + } } : {}), + }; if (occ.event.lane === 'channel') { - const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...(assemblingFor ? { assemblingFor } : {}) }; + const event = { ...occ.event.event, coalescingSubject, ...narrowing, ...activity, ...(assemblingFor ? { assemblingFor } : {}) }; const placement = await this.handleMcplChannelIncoming(event); await this.dispatchProcessEventToModules(event as unknown as ProcessEvent); return placement; @@ -7769,6 +7871,7 @@ export class AgentFramework { coalescingSubject, eventId: occ.eventId, ...narrowing, + ...activity, ...(assemblingFor ? { assemblingFor } : {}), }; const placement = await this.handleMcplChannelIncoming(event); @@ -7791,16 +7894,21 @@ export class AgentFramework { return placement; } - private async wakeForCoalescedBatch(occ: CoalescedOccurrence): Promise { + private async wakeForCoalescedBatch(occ: CoalescedOccurrence, preserveExisting = false): Promise { const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); - this.cancelCoalescedWakes(subject); + if (!preserveExisting) this.cancelCoalescedWakes(subject); if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return; + if (this.coalescedAuthorized(occ) !== true) return; + if (this.conversationRouter && occ.scope.kind === 'channel' && this.isSelfConversationOccurrence(occ)) return; const origin = occ.event.event.origin; // The wake carries the batch's channel so the turn freezes its reply // locus there (the materialized delivery at assembly wakes nobody). const channelId = occ.scope.kind === 'channel' ? occ.scope.id : this.derivePushEventChannel(origin)?.channelId; const authorId = occ.identity?.author?.id ?? (typeof origin?.authorId === 'string' ? origin.authorId : undefined); - for (const agentName of await this.coalescedAudience(occ)) { + const audience = await this.coalescedAudience(occ); + if (audience === null) return false; + if (this.coalescedAuthorized(occ) !== true) return; + for (const agentName of audience) { this.pendingRequests.push({ agentName, reason: 'mcpl:push-event', @@ -7925,6 +8033,8 @@ export class AgentFramework { */ private handleMcplPushEvent(event: McplPushEvent): CoalescingPlacement | undefined { const triggerChannel = this.derivePushEventChannel(event.origin); + const self = !!this.conversationRouter && !!triggerChannel + && isSelfAuthoredMessage(event.tags, event.origin?.authorId, event.origin); if (triggerChannel && this.channelRegistry) { this.channelRegistry.ensureChannelRegistered( event.serverId, @@ -7939,7 +8049,7 @@ export class AgentFramework { serverId: event.serverId, featureSet: event.featureSet, eventId: event.eventId, - triggered: event.triggerInference ?? false, + triggered: !self && (event.triggerInference ?? false), ...(event.tags ? { tags: event.tags } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), }; @@ -7993,7 +8103,7 @@ export class AgentFramework { console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`); } - if (event.triggerInference) { + if (!self && event.triggerInference) { // Default broadcast excludes conversation forks (channel-driven). const targetAgents = event.targetAgents ?? [...this.agents.keys()].filter( @@ -12838,8 +12948,21 @@ export class AgentFramework { // Find shouldTriggerInference callback: // Per-server callback takes precedence; fall back to EventGate; fall back to no filter. - const triggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference + const configuredTriggerFilter = serverConfigs.find(c => c.shouldTriggerInference)?.shouldTriggerInference ?? (this.eventGate ? this.eventGate.asShouldTriggerCallback() : undefined); + const triggerFilter = (content: string, metadata: Record): boolean => { + const channelEvent = metadata.eventType === 'mcpl:channel-incoming'; + const channelPush = metadata.eventType === 'mcpl:push-event' + && (typeof metadata.coalesceChannelId === 'string' || !!this.derivePushEventChannel(metadata)); + const authorId = channelEvent + ? (metadata.author as { id?: unknown } | undefined)?.id + : metadata.authorId; + // The callback may arm a delayed gate wake, so delivery-time suppression + // alone is too late. Non-channel pushes keep their existing policy. + if (this.conversationRouter && (channelEvent || channelPush) + && isSelfAuthoredMessage(metadata.tags, authorId, metadata)) return false; + return configuredTriggerFilter ? configuredTriggerFilter(content, metadata) : true; + }; // Push events handler (Step 6) this.initializePushCoalescer(); @@ -12847,8 +12970,11 @@ export class AgentFramework { this.featureSetManager, (event) => this.pushEvent(event as unknown as ProcessEvent), (event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }), - triggerFilter, - (serverId, params, event) => this.handleCoalescedPush(serverId, params, event), + // Channel-scoped coalesced pushes evaluate this inside serialized + // admission, once inherited author identity has been resolved. + (content, metadata) => typeof metadata.coalesceChannelId === 'string' + ? false : triggerFilter(content, metadata), + (serverId, params, event) => this.handleCoalescedPush(serverId, params, event, triggerFilter), ); // Server-initiated inference router (Step 6) @@ -13344,6 +13470,7 @@ export class AgentFramework { } connection.ready(); } + if (this.discordAwarenessBarrier === null) this.resumeCoalescedWork(); return this.discordAwarenessBarrier === null; } @@ -13619,6 +13746,7 @@ export class AgentFramework { impacts, }); this.handleToolsListChanged(connection.id); + this.resumeCoalescedWork(); } finally { st.inFlight = false; st.lastCompletedAt = Date.now(); @@ -13980,7 +14108,8 @@ export class AgentFramework { params: ChannelsRegisterParams, responder?: { respond: (result: unknown) => void }, ) => { - await this.channelRegistry?.handleRegister(connection.id, params, responder as never); + try { await this.channelRegistry?.handleRegister(connection.id, params, responder as never); } + finally { this.resumeCoalescedWork(); } }); // Handle channel changes (Step 7) — §14.5 dual-mode: the responder is @@ -13991,7 +14120,8 @@ export class AgentFramework { params: ChannelsChangedParams, responder?: { respond: (result: unknown) => void }, ) => { - await this.channelRegistry?.handleChanged(connection.id, params, responder as never); + try { await this.channelRegistry?.handleChanged(connection.id, params, responder as never); } + finally { this.resumeCoalescedWork(); } }); // §6.6/§12: requests that previously had no handler and hung forever diff --git a/src/mcpl/push-coalescer.ts b/src/mcpl/push-coalescer.ts index 9536962f7..5ae166d84 100644 --- a/src/mcpl/push-coalescer.ts +++ b/src/mcpl/push-coalescer.ts @@ -90,15 +90,34 @@ export interface PushRenderResult { content: McplContentBlock[]; timestamp?: str interface DeferredBatch { /** Latest notice; its content is the fallback, its tags/event drive delivery. */ latest: CoalescedOccurrence; + /** Undefined: latest drives activity/wake policy (also the legacy format). + * Null: this batch contains only passive updates. Otherwise retain the + * latest active occurrence when a passive update changes the content. */ + activation?: CoalescedOccurrence | null; notices: DeferredNotice[]; dropped: number; /** Set on a batch restored from a snapshot whose render may have started. */ noRender?: boolean; + /** Where a frozen batch would publish; used only to settle recovery. */ + publicationAgent?: string; +} + +interface StoredDeferredBatch { + latest: CoalescedOccurrence; + activation?: CoalescedOccurrence | null; + notices: DeferredNotice[]; + dropped: number; + /** Role: this was frozen, not merely awaiting fallback-only delivery. */ + rendering?: boolean; + /** Delivery policy independent of role/order: never repeat a possible render. */ + fallback?: boolean; + publicationAgent?: string; } interface Rendering { batch: DeferredBatch; cancelled: boolean; + recovered?: boolean; done: Promise; } @@ -107,6 +126,9 @@ interface SubjectState { occupant?: { eventId: string; placement: CoalescingPlacement }; batch?: DeferredBatch; rendering?: Rendering; + /** Interrupted frozen batches, oldest first, materialized as fallback before + * the newer pending batch. Further crashes must preserve every survivor. */ + recovered?: DeferredBatch[]; consumedEventId?: string; identity?: CoalescedOccurrence['identity']; /** Agent that received the last delivery; replacements and notices follow it. */ @@ -124,7 +146,9 @@ export interface CoalescingSnapshot { identity?: CoalescedOccurrence['identity']; occupant?: { eventId: string; placement: CoalescingPlacement }; audienceAgent?: string; - batch?: { latest: CoalescedOccurrence; notices: DeferredNotice[]; dropped: number; rendering?: boolean }; + batch?: StoredDeferredBatch; + /** Frozen work concurrent with batch, or additional interrupted batches. */ + frozen?: StoredDeferredBatch[]; }>; } /** One receipt, written synchronously at acceptance (bridges the snapshot throttle). */ @@ -135,12 +159,16 @@ export interface CoalescingReceiptRecord { subject: string; /** History the subject was created with, for a subject first seen by this receipt. */ born?: SubjectState['history']; + /** Resolved identity after this admission, including plain occurrences. */ + identity?: CoalescedOccurrence['identity']; /** The subject's pending batch AFTER this acceptance: the accepted work * itself (so a crash before the snapshot flush cannot lose it while keeping * the receipt that suppresses its retry), or `null` when the acceptance * left no batch — a retraction or plain replacement must be recoverable * too. Records replay in order, so the last one per subject wins. */ - batch: { latest: CoalescedOccurrence; notices: DeferredNotice[]; dropped: number } | null; + batch: StoredDeferredBatch | null; + /** All frozen work after acceptance; [] also records its cancellation. */ + frozen?: StoredDeferredBatch[]; } export interface CoalescerHost { @@ -150,16 +178,24 @@ export interface CoalescerHost { remove(placement: CoalescingPlacement): boolean; /** Ordinary delivery of the event; undefined when routing delivered nowhere. * A replacement is delivered as a fresh message after the unread prior was - * removed: it lands where a fresh event lands (§4.1 allows either place). */ - deliver(occurrence: CoalescedOccurrence, materialized?: McplContentBlock[], assemblingFor?: string): Promise; + * removed: it lands where a fresh event lands (§4.1 allows either place). + * For deferred materialization, activation names the pending activity + * cause, or null for a passive-only batch; content retains its own identity. */ + deliver(occurrence: CoalescedOccurrence, materialized?: McplContentBlock[], assemblingFor?: string, activation?: CoalescedOccurrence | null): Promise; + /** A passive deferred update changes content but cannot replace the batch's + * pending activity/wake cause. Re-evaluated for restored legacy batches. */ + isPassive?(occurrence: CoalescedOccurrence): boolean; /** Queue a wake for a batch that has no model-visible content yet. */ - wakeForBatch(occurrence: CoalescedOccurrence): Promise; + /** false means a temporary audience failure; keep work and retry with backoff. */ + wakeForBatch(occurrence: CoalescedOccurrence, preserveExisting?: boolean): Promise; /** Withdraw unstarted wakes whose sole cause is this subject. */ cancelWake(subject: string): void; - /** The batch's audience still holds the authority it was admitted under. */ - authorized(occurrence: CoalescedOccurrence): boolean; - /** Agents that read this occurrence's delivery target (may spawn a fork). */ - audience(occurrence: CoalescedOccurrence): Promise; + /** true: currently authorized; false: denied/reassigned; undefined: current + * authority is not yet known (connection, policy, or registration pending). */ + authorized(occurrence: CoalescedOccurrence): boolean | undefined; + /** [] means no recipient exists; null means resolution failed temporarily. + * May spawn a fork, so call only after current authorization succeeds. */ + audience(occurrence: CoalescedOccurrence): Promise; render(occurrence: CoalescedOccurrence, params: PushRenderParams): Promise; audit(record: Record): void; /** Persist (throttled by the host); the thunk builds the snapshot lazily. */ @@ -179,7 +215,7 @@ export interface CoalescerHost { * delivery identity — subject + eventId in message metadata — is found * there)? A published occurrence is read from recovery on (the watermark * restarts at the head), whatever a stale record says about its history. */ - wasPublished?(subject: string, eventId: string): boolean; + wasPublished?(subject: string, eventId: string, publicationAgent?: string): boolean; now?(): number; } @@ -300,7 +336,7 @@ export class PushCoalescer { pendingBatches(): number { let n = 0; - for (const s of this.subjects.values()) if (s.batch) n++; + for (const s of this.subjects.values()) n += (s.batch ? 1 : 0) + (s.recovered?.length ?? 0); return n; } @@ -346,13 +382,15 @@ export class PushCoalescer { * Admit an occurrence that has already passed the lane's ordinary checks and * the coalesce-member validation. Returns the wire result, performing the * host effects (replace / remove / deliver) synchronously so a create, edit - * and delete cannot overtake one another. + * and delete cannot overtake one another. The optional prepare callback + * computes host effect flags only after receipt replay is ruled out; it + * must leave the occurrence's subject and receipt identity unchanged. */ - accept(occurrence: CoalescedOccurrence): Promise { - return this.locked(() => this.admit(occurrence)); + accept(occurrence: CoalescedOccurrence, prepare?: () => void): Promise { + return this.locked(() => this.admit(occurrence, prepare)); } - private async admit(occurrence: CoalescedOccurrence): Promise { + private async admit(occurrence: CoalescedOccurrence, prepare?: () => void): Promise { if (this.suspended) throw new CoalesceError('serverId', 'host is stopping', -32000); const subject = coalescingSubjectKey(occurrence.serverId, occurrence.binding, occurrence.scope, occurrence.key); const duplicate = this.receiptEntry(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId)); @@ -360,9 +398,12 @@ export class PushCoalescer { // A producer retrying acknowledged work is also a chance to notice a // batch left asleep by a failed freeze: wake it now, without effects. const state = this.subjects.get(subject); - if (state?.batch && !state.rendering) { this.disarmRecovery(subject); await this.host.wakeForBatch(state.batch.latest); } + if (state && (state.batch || state.recovered?.length) && !state.rendering) { this.disarmRecovery(subject); await this.wakeSubject(subject, state); } return this.acknowledge(coalescingReceiptKey(occurrence.serverId, occurrence.binding, occurrence.eventId), duplicate, subject, occurrence.eventId); } + // Effectful host policy (for example an EventGate callback) belongs to + // new admission, never receipt replay against a newer subject identity. + prepare?.(); const born = this.subjects.has(subject) ? undefined : (occurrence.initial && !occurrence.retract ? 'none' as const : 'unknown' as const); const state = this.subjectFor(subject, occurrence); const occupantUnread = this.refreshOccupant(state); @@ -387,7 +428,9 @@ export class PushCoalescer { try { this.host.recordReceipt?.({ key: receiptKey, result: entry.result, at: entry.at, subject, ...(born ? { born } : {}), - batch: state.batch ? structuredClone({ latest: state.batch.latest as CoalescedOccurrence, notices: state.batch.notices, dropped: state.batch.dropped }) : null, + ...(state.identity ? { identity: structuredClone(state.identity) } : {}), + batch: state.batch ? structuredClone(this.storeBatch(state.batch)) : null, + frozen: structuredClone(this.frozenBatches(state).map(b => this.storeBatch(b, true))), }); } catch (error) { // Not acknowledged: the work would be lost while its receipt suppressed @@ -436,11 +479,14 @@ export class PushCoalescer { // Disarm, but keep the attempt count: a freeze that fails again backs // off further. The count resets only on success or disposal. if (this.recovery.get(subject) === entry) entry.timer = undefined; - if (this.suspended || this.subjects.get(subject) !== state || !state.batch || state.rendering) return; + if (this.suspended || this.subjects.get(subject) !== state || (!state.batch && !state.recovered?.length) || state.rendering) return; // Authority and wake policy are the host's, re-evaluated at fire time. - if (!this.host.authorized(state.batch.latest)) return; - this.host.audit({ kind: 'recovery-wake', subject, eventId: state.batch.latest.eventId, attempt, delayMs: delay }); - void this.host.wakeForBatch(state.batch.latest).catch(() => { /* audited by the host */ }); + if (!this.workBatches(state).some(b => this.host.authorized(b.latest) === true)) return; + this.host.audit({ kind: 'recovery-wake', subject, eventId: this.workBatches(state)[0]?.latest.eventId, attempt, delayMs: delay }); + void this.locked(async () => { + if (this.suspended || this.subjects.get(subject) !== state) return; + await this.wakeSubject(subject, state); + }).catch(() => { /* audited by the host */ }); }, delay); (entry.timer as { unref?: () => void }).unref?.(); this.recovery.set(subject, entry); @@ -462,6 +508,11 @@ export class PushCoalescer { private dropBatches(subject: string, state: SubjectState): boolean { this.clearRecovery(subject); let dropped = false; + if (state.recovered?.length) { + for (const batch of state.recovered) this.host.audit({ kind: 'displaced', subject, eventId: batch.latest.eventId, batch: true }); + state.recovered = undefined; + dropped = true; + } if (state.batch) { this.host.audit({ kind: 'displaced', subject, eventId: state.batch.latest.eventId, batch: true }); state.batch = undefined; dropped = true; } if (state.rendering) { this.host.audit({ kind: 'render-cancelled', subject, eventId: state.rendering.batch.latest.eventId }); @@ -525,6 +576,78 @@ export class PushCoalescer { return state.consumedEventId || state.history !== 'none' ? 'appended' : 'first'; } + /** Keep content identity separate from the pending activity/wake cause. + * A later active occurrence replaces the cause, even when it vetoes waking. */ + private activationFor(batch: DeferredBatch): CoalescedOccurrence | null { + const activation = batch.activation === undefined ? batch.latest : batch.activation; + return activation && !this.host.isPassive?.(activation) ? activation : null; + } + + private workBatches(state: SubjectState): DeferredBatch[] { + return [...(state.recovered ?? []), ...(state.batch ? [state.batch] : [])]; + } + + private frozenBatches(state: SubjectState): DeferredBatch[] { + return [...(state.rendering ? [state.rendering.batch] : []), ...(state.recovered ?? [])]; + } + + private storeBatch(batch: DeferredBatch, rendering = false): StoredDeferredBatch { + return { + latest: batch.latest as CoalescedOccurrence, notices: batch.notices, dropped: batch.dropped, + ...(batch.activation !== undefined ? { activation: batch.activation as CoalescedOccurrence | null } : {}), + ...(batch.publicationAgent ? { publicationAgent: batch.publicationAgent } : {}), + ...(batch.noRender ? { fallback: true } : {}), + ...(rendering ? { rendering: true } : {}), + }; + } + + private restoreBatch(batch: StoredDeferredBatch, noRender = !!(batch.rendering || batch.fallback)): DeferredBatch { + return { + latest: batch.latest as CoalescedOccurrence, notices: batch.notices, dropped: batch.dropped, + ...(batch.activation !== undefined ? { activation: batch.activation as CoalescedOccurrence | null } : {}), + ...(batch.publicationAgent ? { publicationAgent: batch.publicationAgent } : {}), + ...(noRender ? { noRender: true } : {}), + }; + } + + /** Re-evaluate retained work when current transport/policy/channel state changes. */ + resumePending(): Promise { + return this.locked(async () => { + if (this.suspended) return; + for (const [subject, state] of this.subjects) { + if (this.workBatches(state).length) await this.wakeSubject(subject, state); + } + }); + } + + private async resolveAudience(subject: string, state: SubjectState, batch: DeferredBatch): Promise { + try { + const audience = await this.host.audience(this.activationFor(batch) ?? batch.latest); + if (audience !== null) return audience; + } catch (error) { + this.host.audit({ kind: 'audience-unavailable', subject, eventId: batch.latest.eventId, error: String(error) }); + } + this.scheduleRecovery(subject, state); + return null; + } + + private async wakeSubject(subject: string, state: SubjectState): Promise { + this.host.cancelWake(subject); + for (const batch of this.workBatches(state)) { + const activation = this.activationFor(batch); + // Different frozen/pending batches have independent causes. A quiet + // newer batch must not cancel an older, still-unconsumed obligation. + if (!activation || this.host.authorized(batch.latest) !== true + || this.host.authorized(activation) !== true) continue; + try { + if (await this.host.wakeForBatch(activation, true) === false) this.scheduleRecovery(subject, state); + } catch (error) { + this.host.audit({ kind: 'audience-unavailable', subject, eventId: batch.latest.eventId, error: String(error) }); + this.scheduleRecovery(subject, state); + } + } + } + private async acceptDeferred(subject: string, state: SubjectState, occurrence: CoalescedOccurrence, occupantUnread: boolean): Promise { let outcome: CoalesceOutcome = 'first'; if (occupantUnread && state.occupant) { @@ -541,17 +664,20 @@ export class PushCoalescer { // Later notices stay in the batch's audience. occurrence = { ...occurrence, deliverTo: state.batch.latest.deliverTo }; } + const passive = this.host.isPassive?.(occurrence) ?? false; + const activation = passive ? (state.batch ? this.activationFor(state.batch) : null) : undefined; const notice: DeferredNotice = { eventId: occurrence.eventId, timestamp: occurrence.timestamp, ...(occurrence.data !== undefined ? { data: structuredClone(occurrence.data) } : {}) }; if (state.batch) { state.batch.notices.push(notice); if (state.batch.notices.length > this.maxNotices) { state.batch.notices.shift(); state.batch.dropped++; } state.batch.latest = occurrence; + state.batch.activation = activation; outcome = 'replaced'; } else { // Rule 1: a notice during a render opens a NEW batch ("first"). - state.batch = { latest: occurrence, notices: [notice], dropped: 0 }; + state.batch = { latest: occurrence, ...(activation !== undefined ? { activation } : {}), notices: [notice], dropped: 0 }; } - await this.host.wakeForBatch(occurrence); + await this.wakeSubject(subject, state); return outcome; } @@ -566,75 +692,117 @@ export class PushCoalescer { * materializes the admitted fallback (§5.3). */ async assemble(agentName: string): Promise { - if (this.suspended) return; - const work: Promise[] = []; - for (const subject of [...this.subjects.keys()]) { - // The handle is wrapped: a bare promise returned through `locked` would - // be flattened by `then`, holding the lock until the render settled — - // and the settlement itself needs the lock. + await Promise.all([...this.subjects.keys()].map(subject => this.assembleSubject(subject, agentName))); + this.persist(); + } + + private async assembleSubject(subject: string, agentName: string): Promise { + while (!this.suspended) { const started = await this.locked(() => this.freeze(subject, agentName)); - if (started) work.push(started.done); + if (!started) return; + await started.done; + // Recovery seals older interrupted batches as fallback, then lets the + // newer pending batch assemble in the same turn. A normal live render + // still closes only its own batch; arrivals during it await a later turn. + if (!started.recovered) return; } - await Promise.all(work); - this.persist(); } - /** - * Under the lock: decide whether `subject`'s batch renders for `agentName`, - * freeze it, make the render-start durable, re-check authority, and start - * the RPC. Returns the render's completion (shared by every assembly that - * waits on this batch, vector 25) or undefined when nothing was started. - */ - private async freeze(subject: string, agentName: string): Promise<{ done: Promise } | undefined> { + private async freeze(subject: string, agentName: string): Promise<{ done: Promise; recovered: boolean } | undefined> { const state = this.subjects.get(subject); if (!state || this.suspended) return undefined; if (state.rendering) { - // Another assembly froze this batch; share its outcome (vector 25). const rendering = state.rendering; - return (await this.host.audience(rendering.batch.latest)).includes(agentName) && state.rendering === rendering ? { done: rendering.done } : undefined; + if (this.host.authorized(rendering.batch.latest) !== true) return undefined; + return (await this.resolveAudience(subject, state, rendering.batch))?.includes(agentName) && state.rendering === rendering + ? { done: rendering.done, recovered: !!rendering.recovered } : undefined; + } + let recovered = false; + let batch: DeferredBatch | undefined; + let pruned = false; + // Preserve order within this recipient's recovered work, not a global + // head-of-line dependency on another (or vanished) recipient's batch. + for (let i = 0; i < (state.recovered?.length ?? 0);) { + const candidate = state.recovered![i]!; + const authorized = this.host.authorized(candidate.latest); + if (authorized === undefined) { i++; continue; } + const audience = authorized ? await this.resolveAudience(subject, state, candidate) : []; + if (audience === null) { i++; continue; } + if (authorized === false || audience.length === 0) { + this.host.audit({ kind: authorized ? 'audience-gone' : 'revoked', subject, eventId: candidate.latest.eventId }); + state.recovered!.splice(i, 1); + pruned = true; + continue; + } + if (audience.includes(agentName)) { + batch = candidate; + state.recovered!.splice(i, 1); + recovered = true; + break; + } + i++; + } + if (!batch && state.batch) { + const candidate = state.batch; + const authorized = this.host.authorized(candidate.latest); + if (authorized === false) { + this.host.audit({ kind: 'revoked', subject, eventId: candidate.latest.eventId }); + state.batch = undefined; + pruned = true; + } else if (authorized === true) { + const audience = await this.resolveAudience(subject, state, candidate); + if (audience?.includes(agentName) && state.batch === candidate && !state.rendering) { + batch = candidate; + state.batch = undefined; + } + } + } + if (!batch) { + if (pruned) await this.wakeSubject(subject, state); + return undefined; } - const batch = state.batch; - if (!batch) return undefined; - // The audience lookup may spawn a fork. Under the lock no admission can - // move the subject on meanwhile; the identity check guards the host. - const audience = await this.host.audience(batch.latest); - if (!audience.includes(agentName) || state.batch !== batch || state.rendering) return undefined; - state.batch = undefined; this.host.cancelWake(subject); - if (!this.host.authorized(batch.latest)) { - this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId }); + const authority = this.host.authorized(batch.latest); + if (authority === undefined) { + if (recovered) (state.recovered ??= []).unshift(batch); + else state.batch = batch; return undefined; } - const rendering: Rendering = { batch, cancelled: false, done: Promise.resolve() }; + if (authority === false) { + this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId }); + await this.wakeSubject(subject, state); + return { done: Promise.resolve(), recovered }; + } + batch.publicationAgent = agentName; + const rendering: Rendering = { batch, cancelled: false, recovered, done: Promise.resolve() }; state.rendering = rendering; - // The render-start boundary is recoverable BEFORE the RPC: a restart - // then takes the fallback instead of asking the server a second time. + const putBack = () => { + state.rendering = undefined; + if (recovered) (state.recovered ??= []).unshift(batch!); + else state.batch = batch; + }; try { if (this.host.saveNow) this.host.saveNow(this.snapshot()); else this.persist(); await this.host.commit?.(); } catch (error) { this.host.audit({ kind: 'persist-failed', subject, eventId: batch.latest.eventId, error: String(error) }); - state.rendering = undefined; - batch.noRender = true; // cannot prove a render never started → fallback - state.batch = batch; - // The wake was withdrawn above; acknowledged work must not sleep until - // unrelated traffic happens by. Re-wake with backoff, so a persistent - // storage outage does not become a tight loop of model turns. + batch.noRender = true; + putBack(); this.scheduleRecovery(subject, state); return undefined; } this.clearRecovery(subject); - if (this.suspended) { state.rendering = undefined; state.batch = batch; return undefined; } - // Authority (grant, registration, binding) is re-checked immediately - // before dispatch: a server id reassigned to another endpoint during the - // commit must not receive this batch's notices. - if (!this.host.authorized(batch.latest)) { + if (this.suspended) { putBack(); return undefined; } + const dispatchAuthority = this.host.authorized(batch.latest); + if (dispatchAuthority === undefined) { putBack(); return undefined; } + if (dispatchAuthority === false) { this.host.audit({ kind: 'revoked', subject, eventId: batch.latest.eventId }); state.rendering = undefined; - return undefined; + await this.wakeSubject(subject, state); + return { done: Promise.resolve(), recovered }; } rendering.done = this.render(subject, state, rendering, agentName); - return { done: rendering.done }; + return { done: rendering.done, recovered }; } private async render(subject: string, state: SubjectState, rendering: Rendering, assemblingFor: string): Promise { @@ -672,10 +840,18 @@ export class PushCoalescer { try { if (rendering.cancelled || state.rendering !== rendering || this.suspended) return; // Rule 4: authority is re-checked at response. - if (!this.host.authorized(occurrence)) { this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId }); return; } + const authority = this.host.authorized(occurrence); + if (authority === undefined) { + // Transport/policy readiness may return. Keep the accepted fallback + // as interrupted work; never repeat a render that may have executed. + batch.noRender = true; + (state.recovered ??= []).unshift(batch); + return; + } + if (authority === false) { this.host.audit({ kind: 'revoked', subject, eventId: occurrence.eventId }); return; } this.host.audit({ kind: 'rendered', subject, eventId: occurrence.eventId, source, empty: content.length === 0 }); if (!content.length) return; // §5.2: nothing happened - const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor); + const placement = await this.host.deliver({ ...occurrence, timestamp, content }, content, assemblingFor, this.activationFor(batch)); if (placement?.deferredId) { // Landed in another agent's deferred queue (its turn is alive): still // unread there, so it stays replaceable and withdrawable. @@ -689,7 +865,12 @@ export class PushCoalescer { state.occupant = undefined; if (placement) state.audienceAgent = placement.agent; } finally { - if (state.rendering === rendering) state.rendering = undefined; + if (state.rendering === rendering) { + state.rendering = undefined; + // This materialization consumed only its own cause. Other live + // recipients and newer pending batches still own their wakes. + if (!this.suspended) await this.wakeSubject(subject, state); + } } }); } @@ -714,12 +895,11 @@ export class PushCoalescer { audienceAgent: s.audienceAgent ?? s.occupant?.placement.agent, touchedAt: now, }; - if (s.batch) { - // An interrupted render is not replayed (§3.2): a batch that was - // RENDERING keeps its fallback; a batch that was only pending renders - // normally at the next assembly. - state.batch = { latest: s.batch.latest as CoalescedOccurrence, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.rendering ? { noRender: true } : {}) }; - } + // Legacy snapshots put a sole frozen batch in batch with rendering=true. + // New snapshots also retain concurrent frozen work alongside a pending batch. + if (s.batch?.rendering) state.recovered = [this.restoreBatch(s.batch, true)]; + else if (s.batch) state.batch = this.restoreBatch(s.batch); + if (s.frozen?.length) (state.recovered ??= []).push(...s.frozen.map(b => this.restoreBatch(b, true))); this.subjects.set(s.subject, state); } this.settlePublished(); @@ -734,13 +914,14 @@ export class PushCoalescer { private settlePublished(): void { if (!this.host.wasPublished) return; for (const [subject, state] of this.subjects) { - const batch = state.batch; - if (!batch?.noRender) continue; - if (this.host.wasPublished(subject, batch.latest.eventId)) { - state.batch = undefined; + const settled = (batch: DeferredBatch): boolean => { + if (!this.host.wasPublished!(subject, batch.latest.eventId, batch.publicationAgent)) return false; state.history = 'some'; state.consumedEventId = batch.latest.eventId; - } + return true; + }; + if (state.batch?.noRender && settled(state.batch)) state.batch = undefined; + if (state.recovered) state.recovered = state.recovered.filter(b => !settled(b)); } } @@ -769,9 +950,11 @@ export class PushCoalescer { // record was written, but the snapshot cannot say whether it did // afterwards: take the fallback. const state = this.subjects.get(r.subject) ?? { history: 'unknown' as const, touchedAt: now }; - state.batch = r.batch - ? { latest: r.batch.latest as CoalescedOccurrence, notices: r.batch.notices, dropped: r.batch.dropped, noRender: true } - : undefined; + if (r.identity) state.identity = r.identity; + else if (r.batch?.latest.identity) state.identity = mergeIdentity(state.identity, r.batch.latest.identity); + state.batch = r.batch ? this.restoreBatch(r.batch, true) : undefined; + if (r.frozen !== undefined) state.recovered = r.frozen.map(b => this.restoreBatch(b, true)); + else if (r.batch === null) state.recovered = undefined; // legacy explicit withdrawal/replacement this.subjects.set(r.subject, state); } this.settlePublished(); @@ -781,20 +964,27 @@ export class PushCoalescer { return { version: 1, receipts: [...this.receipts], - subjects: [...this.subjects].map(([subject, s]) => ({ - subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity, - ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}), - ...(s.occupant ? { occupant: s.occupant } : {}), - ...(s.batch ? { batch: { latest: s.batch.latest as CoalescedOccurrence, notices: s.batch.notices, dropped: s.batch.dropped, ...(s.batch.noRender ? { rendering: true } : {}) } } - : s.rendering ? { batch: { latest: s.rendering.batch.latest as CoalescedOccurrence, notices: s.rendering.batch.notices, dropped: s.rendering.batch.dropped, rendering: true } } - : {}), - })), + subjects: [...this.subjects].map(([subject, s]) => { + const frozen = this.frozenBatches(s).map(b => this.storeBatch(b, true)); + // Preserve the legacy representation for a sole frozen batch. + const batch = s.batch ? this.storeBatch(s.batch) : frozen.shift(); + return { + subject, history: s.history, consumedEventId: s.consumedEventId, identity: s.identity, + ...(s.audienceAgent ? { audienceAgent: s.audienceAgent } : {}), + ...(s.occupant ? { occupant: s.occupant } : {}), + ...(batch ? { batch } : {}), + ...(frozen.length ? { frozen } : {}), + }; + }), }; } - /** Batches whose wake should be re-queued after a restart (their fallback is pending). */ + /** Every still-pending cause, including interrupted frozen work. */ pendingBatchOccurrences(): CoalescedOccurrence[] { - return [...this.subjects.values()].flatMap(s => s.batch ? [s.batch.latest] : []); + return [...this.subjects.values()].flatMap(s => this.workBatches(s).flatMap(batch => { + const activation = this.activationFor(batch); + return activation ? [activation] : []; + })); } suspend(): void { @@ -815,7 +1005,7 @@ export class PushCoalescer { if (this.subjects.size <= this.maxSubjects) return; // Evict the oldest idle subjects; their history degrades to `unknown` // (an untracked subject) which is the conservative outcome (§3.3). - const idle = [...this.subjects].filter(([, s]) => !s.occupant && !s.batch && !s.rendering).sort((a, b) => a[1].touchedAt - b[1].touchedAt); + const idle = [...this.subjects].filter(([, s]) => !s.occupant && !s.batch && !s.rendering && !s.recovered?.length).sort((a, b) => a[1].touchedAt - b[1].touchedAt); for (const [key] of idle.slice(0, Math.max(0, this.subjects.size - this.maxSubjects))) this.subjects.delete(key); } } diff --git a/src/mcpl/push-handler.ts b/src/mcpl/push-handler.ts index d85df767a..e2844cd2d 100644 --- a/src/mcpl/push-handler.ts +++ b/src/mcpl/push-handler.ts @@ -266,6 +266,8 @@ export class PushHandler { eventId: params.eventId, eventType: 'mcpl:push-event', ...(params.origin ?? {}), + // Channel scope can be declared by coalesce without any origin fields. + coalesceChannelId: params.coalesce?.channelId, ...(params.tags ? { tags: params.tags } : {}), }; triggerInference = this.shouldTriggerInference(textContent, metadata); diff --git a/test/coalescer-render-recovery.test.ts b/test/coalescer-render-recovery.test.ts new file mode 100644 index 000000000..25ce707b8 --- /dev/null +++ b/test/coalescer-render-recovery.test.ts @@ -0,0 +1,294 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + PushCoalescer, coalescingSubjectKey, + type CoalescedOccurrence, type CoalescingSnapshot, type CoalescingReceiptRecord, +} from '../src/mcpl/push-coalescer.js'; + +type Event = { self: boolean; wake: boolean }; +type Occurrence = CoalescedOccurrence; +const subject = coalescingSubjectKey('server', 'binding', { kind: 'channel', id: 'chat' }, 'topic'); + +function occurrence(id: string, self = false, wake = !self): Occurrence { + return { + serverId: 'server', binding: 'binding', scope: { kind: 'channel', id: 'chat' }, + key: 'topic', eventId: id, timestamp: new Date().toISOString(), + deferred: true, retract: false, initial: true, + content: [{ type: 'text', text: 'fallback:' + id }], event: { self, wake }, + }; +} + +function harness(published = new Set()) { + const delivered: Array<{ id: string; body: string; activation: string | null }> = []; + const wakes = new Set(); + const audiences = new Map(); + const waitingAuthority = new Set(); + const audienceCalls: string[] = []; + const unauthorized = new Set(); + let saved: CoalescingSnapshot | undefined; + const receipts: CoalescingReceiptRecord[] = []; + let release: ((value: { content: Array<{ type: 'text'; text: string }> }) => void) | undefined; + let started!: () => void; + const rendering = new Promise(resolve => { started = resolve; }); + let hold = false; + let holdCommit = false; + let releaseCommit!: () => void; + let commitStarted!: () => void; + const committing = new Promise(resolve => { commitStarted = resolve; }); + const coalescer = new PushCoalescer({ + isUnread: () => false, + remove: () => false, + isPassive: o => o.event.self, + deliver: async (o, materialized, _forAgent, activation) => { + published.add(o.eventId); + delivered.push({ + id: o.eventId, body: JSON.stringify(materialized ?? o.content), + activation: activation === null ? null : (activation ?? (o.event.self ? null : o))?.eventId ?? null, + }); + return { agent: 'agent', messageId: o.eventId }; + }, + wakeForBatch: async (o, preserveExisting?: boolean) => { + if (!preserveExisting) wakes.clear(); + if (o.event.wake && !o.event.self) wakes.add(o.eventId); + }, + cancelWake: () => { wakes.clear(); }, + authorized: o => waitingAuthority.has(o.eventId) ? undefined : !unauthorized.has(o.eventId), + audience: async o => { + audienceCalls.push(o.eventId); + return audiences.has(o.eventId) ? audiences.get(o.eventId)! : [o.deliverTo ?? 'agent']; + }, + render: async (o) => { + if (!hold) return { content: [{ type: 'text', text: 'render:' + o.eventId }] }; + started(); + return new Promise(resolve => { release = resolve; }); + }, + audit: () => {}, + save: () => {}, + saveNow: snapshot => { saved = structuredClone(snapshot); receipts.length = 0; }, + recordReceipt: receipt => { receipts.push(structuredClone(receipt)); }, + commit: async () => { + if (!holdCommit) return; + commitStarted(); + await new Promise(resolve => { releaseCommit = resolve; }); + }, + wasPublished: (_subject, id) => published.has(id), + }); + return { + coalescer, delivered, wakes, published, receipts, rendering, audiences, unauthorized, waitingAuthority, audienceCalls, + hold: () => { hold = true; }, + release: () => { hold = false; release!({ content: [{ type: 'text', text: 'completed active render' }] }); }, + saved: () => structuredClone(saved!), + holdCommit: () => { holdCommit = true; }, + committing, + releaseCommit: () => { holdCommit = false; releaseCommit(); }, + }; +} + +for (const durable of ['snapshot', 'receipt bridge'] as const) { + for (const consumed of [false, true]) { + test(durable + ' preserves a frozen counterpart before consumption and settles it after publication: ' + consumed, async () => { + const live = harness(); + await live.coalescer.accept(occurrence('active')); + live.hold(); + const run = live.coalescer.assemble('agent'); + await live.rendering; + await live.coalescer.accept(occurrence('self-during-render', true)); + const snapshot = durable === 'snapshot' ? structuredClone(live.coalescer.snapshot()) : live.saved(); + const receipts = durable === 'receipt bridge' ? structuredClone(live.receipts) : []; + + if (!consumed) live.coalescer.suspend(); + live.release(); + await run; + assert.equal(live.published.has('active'), consumed); + const restored = harness(live.published); + restored.coalescer.restore(snapshot); + restored.coalescer.restoreReceipts(receipts); + const causes = restored.coalescer.pendingBatchOccurrences().map(o => o.eventId); + assert.equal(causes.includes('active'), !consumed, 'unconsumed work keeps its cause; published work clears it'); + assert(!causes.includes('self-during-render')); + await restored.coalescer.assemble('agent'); + assert.deepEqual(restored.delivered.map(d => d.id), consumed ? ['self-during-render'] : ['active', 'self-during-render']); + if (!consumed) { + assert(restored.delivered[0]!.body.includes('fallback:active'), 'the interrupted content survives with its cause'); + assert.equal(restored.delivered[0]!.activation, 'active'); + } + assert.equal(restored.delivered.at(-1)!.activation, null, 'the later self batch never borrows consumed activity'); + assert.deepEqual(restored.coalescer.pendingBatchOccurrences(), []); + await restored.coalescer.assemble('agent'); + assert.equal(restored.delivered.length, consumed ? 1 : 2, 'each batch materializes once'); + }); + } +} + +test('a newer no-wake counterpart batch cannot erase a separate interrupted batch cause', async () => { + const live = harness(); + await live.coalescer.accept(occurrence('active')); + live.hold(); + const run = live.coalescer.assemble('agent'); + await live.rendering; + await live.coalescer.accept(occurrence('quiet-counterpart', false, false)); + const snapshot = structuredClone(live.coalescer.snapshot()); + live.coalescer.suspend(); live.release(); await run; + + const restored = harness(); + restored.coalescer.restore(snapshot); + assert(restored.coalescer.pendingBatchOccurrences().some(o => o.eventId === 'active')); + await restored.coalescer.assemble('agent'); + assert.deepEqual(restored.delivered.map(d => d.id), ['active', 'quiet-counterpart']); + assert.deepEqual(restored.delivered.map(d => d.activation), ['active', 'quiet-counterpart']); +}); + +for (const operation of ['retract', 'plain'] as const) { + test(operation + ' supersedes both interrupted frozen work and the later pending batch', async () => { + const live = harness(); + await live.coalescer.accept(occurrence('active')); + live.hold(); + const run = live.coalescer.assemble('agent'); + await live.rendering; + await live.coalescer.accept(occurrence('self-during-render', true)); + const snapshot = structuredClone(live.coalescer.snapshot()); + live.coalescer.suspend(); live.release(); await run; + + const restored = harness(); + restored.coalescer.restore(snapshot); + await restored.coalescer.accept({ + ...occurrence('replacement', true), deferred: false, initial: false, + retract: operation === 'retract', content: operation === 'retract' ? [] : [{ type: 'text', text: 'replacement' }], + }); + await restored.coalescer.assemble('agent'); + assert(!restored.delivered.some(d => d.id === 'active' || d.id === 'self-during-render')); + assert.deepEqual(restored.coalescer.pendingBatchOccurrences(), []); + assert.equal(restored.wakes.size, 0); + }); +} + +test('a second interruption preserves frozen and pending batches with their separate causes', async () => { + const live = harness(); + await live.coalescer.accept(occurrence('active')); + live.hold(); + const run = live.coalescer.assemble('agent'); + await live.rendering; + await live.coalescer.accept(occurrence('self', true)); + const first = structuredClone(live.coalescer.snapshot()); + live.coalescer.suspend(); live.release(); await run; + + const once = harness(); + once.coalescer.restore(first); + await once.coalescer.accept(occurrence('new-quiet', false, false)); + const twice = harness(); + twice.coalescer.restore(structuredClone(once.coalescer.snapshot())); + assert(twice.coalescer.pendingBatchOccurrences().some(o => o.eventId === 'active')); + await twice.coalescer.assemble('agent'); + assert.deepEqual(twice.delivered.map(d => d.id), ['active', 'new-quiet']); + assert.deepEqual(twice.delivered.map(d => d.activation), ['active', 'new-quiet']); +}); + +for (const olderAudience of ['gone', 'other-live', 'unauthorized'] as const) { + test('a frozen batch for ' + olderAudience + ' cannot block a newer batch for this recipient', async () => { + const live = harness(); + await live.coalescer.accept({ ...occurrence('old'), deliverTo: 'old-fork' }); + live.hold(); + const run = live.coalescer.assemble('old-fork'); + await live.rendering; + await live.coalescer.accept(occurrence('new')); + const snapshot = structuredClone(live.coalescer.snapshot()); + live.coalescer.suspend(); live.release(); await run; + + const restored = harness(); + restored.audiences.set('old', olderAudience === 'gone' ? [] : ['old-fork']); + restored.audiences.set('new', ['new-fork']); + if (olderAudience === 'unauthorized') restored.unauthorized.add('old'); + restored.coalescer.restore(snapshot); + await restored.coalescer.assemble('new-fork'); + assert.deepEqual(restored.delivered.map(d => d.id), ['new'], 'later eligible work is not head-of-line blocked'); + if (olderAudience === 'other-live') { + assert.deepEqual(restored.coalescer.pendingBatchOccurrences().map(o => o.eventId), ['old']); + assert(restored.wakes.has('old'), 'consuming the newer batch preserves the other recipient cause'); + await restored.coalescer.assemble('old-fork'); + assert.deepEqual(restored.delivered.map(d => d.id), ['new', 'old']); + } else { + assert.deepEqual(restored.coalescer.pendingBatchOccurrences(), [], 'unreachable frozen work is settled without widening audience'); + await restored.coalescer.assemble('old-fork'); + assert.deepEqual(restored.delivered.map(d => d.id), ['new']); + } + }); +} + +for (const obstruction of ['temporary audience', 'authority not ready'] as const) { + test(obstruction + ' preserves frozen work while a different recipient progresses', async (t) => { + const live = harness(); + await live.coalescer.accept({ ...occurrence('old'), deliverTo: 'old-fork' }); + live.hold(); + const run = live.coalescer.assemble('old-fork'); + await live.rendering; + await live.coalescer.accept({ ...occurrence('new'), deliverTo: 'new-fork' }); + const snapshot = structuredClone(live.coalescer.snapshot()); + live.coalescer.suspend(); live.release(); await run; + const restored = harness(); + t.after(() => restored.coalescer.suspend()); + restored.coalescer.restore(snapshot); + if (obstruction === 'temporary audience') restored.audiences.set('old', null); + else restored.waitingAuthority.add('old'); + await restored.coalescer.assemble('new-fork'); + assert.deepEqual(restored.delivered.map(d => d.id), ['new']); + assert.deepEqual(restored.coalescer.pendingBatchOccurrences().map(o => o.eventId), ['old']); + if (obstruction === 'authority not ready') assert(!restored.audienceCalls.includes('old'), 'no audience effects before current authority'); + restored.audiences.delete('old'); + restored.waitingAuthority.delete('old'); + await restored.coalescer.assemble('old-fork'); + assert.deepEqual(restored.delivered.map(d => d.id), ['new', 'old']); + }); +} + +test('authority becoming unknown during render retains fallback until readiness returns', async (t) => { + const f = harness(); + t.after(() => f.coalescer.suspend()); + await f.coalescer.accept(occurrence('active')); + f.hold(); + const run = f.coalescer.assemble('agent'); + await f.rendering; + f.waitingAuthority.add('active'); + f.release(); await run; + assert.equal(f.delivered.length, 0, 'unready authority cannot publish'); + assert.equal(f.coalescer.pendingBatches(), 1, 'temporary readiness loss is not revocation'); + f.waitingAuthority.delete('active'); + await f.coalescer.resumePending(); + assert(f.wakes.has('active')); + await f.coalescer.assemble('agent'); + assert.equal(f.delivered.length, 1); + assert(f.delivered[0]!.body.includes('fallback:active'), 'do not repeat the interrupted render'); +}); + +for (const secondCrash of ['before recovery assembly', 'during recovery commit'] as const) { + test('receipt-restored pending fallback keeps its role and order across a second crash ' + secondCrash, async () => { + const live = harness(); + await live.coalescer.accept(occurrence('r1')); + live.hold(); + const run = live.coalescer.assemble('agent'); + await live.rendering; + await live.coalescer.accept(occurrence('r2')); + const firstSnapshot = live.saved(); + const firstReceipts = structuredClone(live.receipts); + live.coalescer.suspend(); live.release(); await run; + + const middle = harness(); + middle.coalescer.restore(firstSnapshot); + middle.coalescer.restoreReceipts(firstReceipts); + let secondSnapshot: CoalescingSnapshot; + if (secondCrash === 'during recovery commit') { + middle.holdCommit(); + const recovering = middle.coalescer.assemble('agent'); + await middle.committing; + secondSnapshot = middle.saved(); + middle.coalescer.suspend(); middle.releaseCommit(); await recovering; + } else { + secondSnapshot = structuredClone(middle.coalescer.snapshot()); + } + const final = harness(); + final.coalescer.restore(secondSnapshot); + await final.coalescer.assemble('agent'); + assert.deepEqual(final.delivered.map(d => d.id), ['r1', 'r2'], 'fallback-only policy must not promote newer pending content ahead of older frozen work'); + assert.deepEqual(final.delivered.map(d => d.body), [JSON.stringify(occurrence('r1').content), JSON.stringify(occurrence('r2').content)]); + assert.equal(final.coalescer.pendingBatches(), 0); + }); +} diff --git a/test/conversation-self-echo.test.ts b/test/conversation-self-echo.test.ts new file mode 100644 index 000000000..5c7e1979a --- /dev/null +++ b/test/conversation-self-echo.test.ts @@ -0,0 +1,658 @@ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { setTimeout as delay } from 'node:timers/promises'; +import type { FrameworkConfig } from '../src/types/framework.js'; +import { fixture, crash, eventually } from './helpers/coalescing-fixture.js'; + +const conversations = { + templateAgent: 'agent', + bind: { channel: 'always' as const }, + trigger: { channel: 'always' as const }, + idleTtlMs: 60_000, +}; +type Fixture = Awaited>; + +async function setup(t: TestContext, options: Parameters[0] = {}) { + const f = await fixture({ + ...options, + framework: { conversations, ...options.framework }, + }); + t.after(f.close); + f.alwaysRespond(); + await f.register(); + return f; +} + +function plain(f: Fixture, id: string, text: string, extra: Record = {}) { + const { coalesce: _coalesce, ...message } = f.channel(id, text, {}, 'chat', id); + return { ...message, ...extra }; +} + +async function sendPlain(f: Fixture, id: string, text: string, extra: Record = {}) { + const result = await f.send('channels/incoming', { messages: [plain(f, id, text, extra)] }); + assert.equal(result.result.results[0].accepted, true); + await f.framework.runUntilIdle(); +} + +for (const [label, identity] of [ + ['self tag', { tags: ['chat:from-self'], author: { id: 'self', name: 'Self' } }], + ['matching botUserId', { tags: ['chat:from-bot'], author: { id: 'self', name: 'Self' }, metadata: { botUserId: 'self' } }], +] as const) { + test('conversation echoes identified by ' + label + ' keep context but do not wake, touch, or rebind', async (t) => { + const f = await setup(t); + await sendPlain(f, 'human', 'counterpart starts engagement'); + const router = f.framework.getConversationRouter()!; + const binding = router.getBinding('chat')!; + const oldActivity = Date.now() - 1000; + binding.lastActivity = oldActivity; + const calls = f.membrane.calls.length; + + await sendPlain(f, 'echo', 'self echo retained', identity); + assert.equal(binding.lastActivity, oldActivity, 'self speech must not renew idle lifetime'); + assert.equal(f.membrane.calls.length, calls, 'self echoes do not request inference'); + assert(f.context(binding.agentName).includes('self echo retained'), 'bound context retains accepted echoes'); + assert.deepEqual(router.expired(oldActivity + conversations.idleTtlMs + 1), [binding]); + + router.unbind('chat'); + await sendPlain(f, 'late-echo', 'self echo after engagement', identity); + assert.equal(router.getBinding('chat'), undefined, 'self echoes cannot create another engagement'); + assert.equal(router.exportGenerations().chat, 1, 'no generation consumed by the echo'); + assert.equal(f.membrane.calls.length, calls); + }); +} + +test('other bots and unknown self identity remain legitimate conversation activity', async (t) => { + const f = await setup(t); + await sendPlain(f, 'human', 'first'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + for (const [id, botUserId] of [['other-bot', 'self'], ['', '']] as const) { + binding.lastActivity = 1; + const calls = f.membrane.calls.length; + const before = Date.now(); + await sendPlain(f, 'message-' + id, 'counterpart ' + id, { + tags: ['chat:from-bot'], author: { id, name: 'Counterpart' }, metadata: { botUserId }, + }); + assert(binding.lastActivity >= before, 'being a bot or having an empty ID is not self identity'); + assert.equal(f.membrane.calls.length, calls + 1); + } +}); + +test('coalesced self replacement retains its receipt and fixed-audience context without renewing activity', async (t) => { + let filterCalls = 0; + const f = await setup(t, { server: { shouldTriggerInference: () => { filterCalls++; return false; } } }); + await f.send('channels/incoming', { messages: [f.channel('first', 'unread original', { initial: true })] }); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + binding.lastActivity = 1; + const echo = { + ...f.channel('edit', 'self replacement'), + tags: ['chat:from-self'], author: { id: 'self', name: 'Self' }, + }; + const result = await f.send('channels/incoming', { messages: [echo] }); + assert.equal(result.result.results[0].coalesce.outcome, 'replaced'); + assert.equal(binding.lastActivity, 1, 'fixed-audience delivery also skips self activity'); + assert(f.context(binding.agentName).includes('self replacement')); + assert(!f.context(binding.agentName).includes('unread original')); + const retry = await f.send('channels/incoming', { messages: [echo] }); + assert.deepEqual(retry.result, result.result, 'coalescing retry keeps its receipt'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0); + assert.equal(filterCalls, 1, 'self echoes bypass callbacks that can schedule delayed gate work'); +}); + +for (const [label, identity] of [ + ['self tag', { tags: ['chat:from-self'] }], + ['matching botUserId', { tags: ['chat:from-bot'], origin: { authorId: 'self', botUserId: 'self' } }], +] as const) { + test('deferred channel-scoped ' + label + ' cannot pre-spawn a fork or arm a trigger callback', async (t) => { + let filterCalls = 0; + const f = await setup(t, { server: { shouldTriggerInference: () => { filterCalls++; return true; } } }); + const result = await f.send('push/event', { + ...f.params('self', 'self fallback', { channelId: 'chat', key: 'self', deferred: true, initial: true }), + ...identity, + }); + assert.equal(result.result.accepted, true); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0); + assert.equal(filterCalls, 0); + + // Assembly for unrelated work also asks the coalescer for audiences. + f.framework.nudgeAgent('agent', 'test'); + await f.framework.runUntilIdle(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + assert.equal(f.renders.length, 0, 'an unbound self batch has no fork to render for'); + assert(!f.context().includes('self fallback')); + }); +} + +test('self batches restored with an old wake verdict cannot create a conversation fork', async (t) => { + const config: Partial = {}; + const f = await fixture({ framework: config, server: { shouldTriggerInference: () => true } }); + t.after(f.close); + f.alwaysRespond(); + await f.register(); + await f.send('push/event', { + ...f.params('old-self', 'old self fallback', { channelId: 'chat', key: 'self', deferred: true }), + tags: ['chat:from-self'], + }); + // This snapshot predates conversation self filtering and carries triggerInference=true. + await f.framework.stop(); + config.conversations = conversations; + await f.create(); + await f.register(); + await f.framework.runUntilIdle(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + assert.equal(f.membrane.calls.length, 0, 'replay cannot reuse the old self wake verdict'); + f.framework.nudgeAgent('agent', 'test'); + await f.framework.runUntilIdle(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + assert.equal(f.renders.length, 0); +}); + +test('a bound self batch renders on an independent turn without refreshing lifetime or adding a wake', async (t) => { + const f = await setup(t); + await sendPlain(f, 'human', 'first'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + binding.lastActivity = Date.now() - 1000; + const activity = binding.lastActivity; + const calls = f.membrane.calls.length; + await f.send('push/event', { + ...f.params('self', 'self fallback', { channelId: 'chat', key: 'self', deferred: true }), + tags: ['chat:from-self'], + }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'self does not wake the bound fork'); + assert.equal(f.renders.length, 0); + + f.framework.nudgeAgent(binding.agentName, 'test'); + await f.framework.runUntilIdle(); + assert.equal(f.renders.length, 1, 'accepted self context remains available to a later independent turn'); + assert(f.context(binding.agentName).includes('document_diff')); + assert.equal(binding.lastActivity, activity); + assert.equal(f.membrane.calls.length, calls + 1); +}); + + +test('a mixed deferred batch retains fresh counterpart activity, but old rendered text cannot revive a self-only batch', async (t) => { + const f = await setup(t); + await sendPlain(f, 'start', 'initial counterpart'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + binding.lastActivity = 1; + const calls = f.membrane.calls.length; + const before = Date.now(); + await f.send('push/event', { + ...f.params('counterpart', 'fresh counterpart', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + await f.send('push/event', { + ...f.params('self', 'self addition', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-self'], origin: { authorId: 'self' }, + }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls + 1, 'self must preserve the pending counterpart wake'); + assert(binding.lastActivity >= before, 'fresh counterpart activity in the batch still counts'); + + binding.lastActivity = 1; + const afterMixed = f.membrane.calls.length; + f.renderer(async () => ({ content: [{ type: 'text', text: 'older counterpart history plus self update' }] })); + await f.send('push/event', { + ...f.params('self-only', 'self fallback', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-self'], + }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, afterMixed); + f.framework.nudgeAgent(binding.agentName, 'test'); + await f.framework.runUntilIdle(); + assert(f.context(binding.agentName).includes('older counterpart history')); + assert.equal(binding.lastActivity, 1, 'rendered historical prose is not fresh counterpart activity'); +}); + +for (const recovery of ['snapshot', 'receipt bridge'] as const) { + test('mixed batch counterpart cause survives ' + recovery + ' recovery after a later self echo', async (t) => { + const f = await setup(t); + await sendPlain(f, 'start', 'initial counterpart'); + const calls = f.membrane.calls.length; + await f.send('push/event', { + ...f.params('counterpart', 'fresh counterpart', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + await f.send('push/event', { + ...f.params('self', 'self addition', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-self'], origin: { authorId: 'self' }, + }); + if (recovery === 'snapshot') await f.framework.stop(); + else await crash(f); + await f.create(); + await f.register(); + await eventually(() => !!f.framework.getConversationRouter()!.getBinding('chat'), 'mixed-batch recovery after registration'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls + 1, 'restart recovers the counterpart cause, not the last self verdict'); + assert(f.framework.getConversationRouter()!.getBinding('chat')); + // Receipt recovery takes the retained fallback rather than repeating a render. + assert.equal(f.renders.length, recovery === 'snapshot' ? 1 : 0); + }); +} + + +test('an unknown counterpart identity cannot become self when a later echo supplies the subject author', async (t) => { + const f = await setup(t); + await sendPlain(f, 'start', 'initial counterpart'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + binding.lastActivity = 1; + const calls = f.membrane.calls.length; + await f.send('push/event', { + ...f.params('unknown', 'fresh unknown counterpart', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-bot'], origin: { botUserId: 'self' }, + }); + await f.send('push/event', { + ...f.params('self', 'self addition', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-self'], origin: { authorId: 'self', botUserId: 'self' }, + }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls + 1, 'later identity must not reclassify the pending cause'); + assert(binding.lastActivity > 1); +}); + +test('a later genuine counterpart no-wake verdict supersedes the earlier cause, and self cannot revive it', async (t) => { + const f = await setup(t, { server: { shouldTriggerInference: (text: string) => text !== 'quiet counterpart' } }); + await sendPlain(f, 'start', 'initial counterpart'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + const activity = Date.now() - 1000; + binding.lastActivity = activity; + const calls = f.membrane.calls.length; + for (const [id, text, tags] of [ + ['wake', 'counterpart wake', ['chat:from-human']], + ['quiet', 'quiet counterpart', ['chat:from-human']], + ['self', 'self addition', ['chat:from-self']], + ] as const) { + await f.send('push/event', { ...f.params(id, text, { channelId: 'chat', key: 'mixed', deferred: true }), tags }); + } + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'the superseded wake is not sticky'); + assert.equal(binding.lastActivity, activity); + f.framework.nudgeAgent(binding.agentName, 'test'); + await f.framework.runUntilIdle(); + assert(binding.lastActivity > activity, 'the quiet counterpart is still fresh activity when delivered'); +}); + +for (const transition of ['withdrawal', 'plain replacement'] as const) { + test(transition + ' consumes the pending cause before a new self-only batch', async (t) => { + const f = await setup(t); + await sendPlain(f, 'start', 'initial counterpart'); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + const activity = Date.now() - 1000; + binding.lastActivity = activity; + const calls = f.membrane.calls.length; + await f.send('push/event', { + ...f.params('counterpart', 'fresh counterpart', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-human'], + }); + await f.send('push/event', { + ...f.params('clear', transition === 'withdrawal' ? '' : 'self replacement', { + channelId: 'chat', key: 'mixed', ...(transition === 'withdrawal' ? { retract: true } : {}), + }), + tags: ['chat:from-self'], + }); + await f.send('push/event', { + ...f.params('self-only', 'self fallback', { channelId: 'chat', key: 'mixed', deferred: true }), + tags: ['chat:from-self'], + }); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls); + f.framework.nudgeAgent(binding.agentName, 'test'); + await f.framework.runUntilIdle(); + assert.equal(binding.lastActivity, activity, 'cleared batch causes cannot leak into later self activity'); + }); +} + +test('self echoes cannot arm a debounced EventGate wake through either channel lane', async (t) => { + const f = await setup(t, { framework: { gate: { config: { + default: 'skip', + policies: [{ + name: 'debounce-all', + match: { scope: ['mcpl:channel-incoming', 'mcpl:push-event'] }, + behavior: { debounce: 20 }, + }], + } } } }); + await sendPlain(f, 'self-channel', 'self channel', { tags: ['chat:from-self'] }); + await f.send('push/event', { + ...f.params('self-push', 'self push', { channelId: 'chat', key: 'self', deferred: true }), + tags: ['chat:from-self'], + }); + await delay(60); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 0, 'self must be filtered before gate timers can be scheduled'); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); +}); + +test('non-channel self push traffic keeps its existing context and wake behavior', async (t) => { + const f = await setup(t); + await f.send('push/event', { ...f.params('status', 'background self status'), tags: ['chat:from-self'] }); + await f.framework.runUntilIdle(); + assert(f.context().includes('background self status')); + assert.equal(f.membrane.calls.length, 1); +}); + +test('without conversation routing, self channel messages keep existing behavior', async (t) => { + const f = await fixture(); + t.after(f.close); + f.alwaysRespond(); + await f.register(); + await sendPlain(f, 'self', 'ordinary primary self echo', { tags: ['chat:from-self'] }); + assert(f.context().includes('ordinary primary self echo')); + assert.equal(f.membrane.calls.length, 1); +}); + +for (const delivery of ['serial', 'concurrent'] as const) { + test('inherited self identity vetoes the trigger callback during ' + delivery + ' admission', async (t) => { + let filterCalls = 0; + const f = await setup(t, { server: { shouldTriggerInference: () => { filterCalls++; return true; } } }); + const first = { + ...f.params('first-self', 'self', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { authorId: 'self', botUserId: 'self' }, + }; + const next = { + ...f.params('next-self', 'self update', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { botUserId: 'self' }, + }; + if (delivery === 'serial') { + await f.send('push/event', first); + await f.send('push/event', next); + } else { + await Promise.all([f.send('push/event', first), f.send('push/event', next)]); + } + assert.equal(filterCalls, 0, 'the trigger boundary must use the admitted, inherited self author'); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + await f.send('push/event', { + ...f.params('other', 'other bot update', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { authorId: 'other-bot', botUserId: 'self' }, + }); + assert.equal(filterCalls, 1, 'a genuine counterpart still reaches the configured trigger policy'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 1); + }); +} + +test('a receipted self update never re-evaluates trigger policy using a newer subject author', async (t) => { + let filterCalls = 0; + const f = await setup(t, { server: { shouldTriggerInference: () => { filterCalls++; return true; } } }); + await f.send('push/event', { + ...f.params('first-self', 'self', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { authorId: 'self', botUserId: 'self' }, + }); + const selfUpdate = { + ...f.params('next-self', 'self update', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { botUserId: 'self' }, + }; + const accepted = await f.send('push/event', selfUpdate); + assert.equal(filterCalls, 0); + await f.send('push/event', { + ...f.params('other', 'other bot update', { channelId: 'chat', key: 'same-author', deferred: true }), + origin: { authorId: 'other-bot', botUserId: 'self' }, + }); + await f.framework.runUntilIdle(); + assert.equal(filterCalls, 1); + const replayed = await f.send('push/event', selfUpdate); + assert.deepEqual(replayed.result, accepted.result); + assert.equal(filterCalls, 1, 'receipt replay is not a new admission or a new identity decision'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, 1); +}); + +for (const recovery of ['snapshot', 'receipt bridge'] as const) { + for (const deferred of [false, true]) { + test('inherited self author survives ' + recovery + ' for deferred=' + deferred, async (t) => { + let filterCalls = 0; + const f = await setup(t, { server: { shouldTriggerInference: () => { filterCalls++; return true; } } }); + await f.send('push/event', { + ...f.params('first-self', 'self', { channelId: 'chat', key: 'same-author', deferred }), + origin: { authorId: 'self', botUserId: 'self' }, + }); + if (recovery === 'snapshot') await f.framework.stop(); + else await crash(f); + await f.create(); + await f.register(); + await f.send('push/event', { + ...f.params('next-self', 'self update', { channelId: 'chat', key: 'same-author', deferred }), + origin: { botUserId: 'self' }, + }); + assert.equal(filterCalls, 0, 'recovery must preserve the author used by inherited identity'); + await f.framework.runUntilIdle(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + assert.equal(f.membrane.calls.length, 0); + }); +} +} + +test('a published frozen fork batch is settled from its old namespace before self-only recovery can wake', async (t) => { + const f = await setup(t); + await sendPlain(f, 'start', 'initial counterpart'); + const originalFork = f.framework.getConversationRouter()!.getBinding('chat')!.agentName; + let release!: (value: unknown) => void; + const started = new Promise(resolve => { + f.renderer(() => { resolve(); return new Promise(r => { release = r; }); }); + }); + await f.send('push/event', { + ...f.params('active', 'older active fallback', { channelId: 'chat', key: 'overlap', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + const run = f.framework.runUntilIdle(); + await started; + await f.send('push/event', { + ...f.params('self-during-render', 'self fallback', { channelId: 'chat', key: 'overlap', deferred: true }), + tags: ['chat:from-self'], origin: { authorId: 'self' }, + }); + // Retain the pre-publication snapshot/receipts, as a crash in the checkpoint + // window would. Context publication itself still goes to the real store. + const internals = f.framework as unknown as { + flushCoalescingSnapshot(): void; + coalescingSaveTimer: ReturnType | null; + }; + if (internals.coalescingSaveTimer) clearTimeout(internals.coalescingSaveTimer); + internals.coalescingSaveTimer = null; + internals.flushCoalescingSnapshot = () => {}; + release({ content: [{ type: 'text', text: 'completed active render' }] }); + await run; + assert(f.context(originalFork).includes('completed active render')); + const calls = f.membrane.calls.length; + await crash(f); + await f.create(); + await f.register(); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'an already-published counterpart cause cannot wake a new fork'); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); +}); + +for (const lane of ['plain', 'deferred'] as const) { + test('a ' + lane + ' self update preserves a genuine counterpart debounce timer and its deadline', async (t) => { + const f = await setup(t, { framework: { gate: { config: { + default: 'skip', policies: [{ name: 'counterpart', match: { tagsAny: ['probe:wake'] }, behavior: { debounce: 5000 } }], + } } } }); + // Establish a fork without a pending wake. + await sendPlain(f, 'start', 'initial counterpart', { tags: [] }); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + const counterpart = { + ...f.params('counterpart', 'genuine counterpart', { channelId: 'chat', key: 'same', deferred: lane === 'deferred' }), + tags: ['probe:wake'], origin: { authorId: 'human' }, + }; + await f.send('push/event', counterpart); + const gate = (f.framework as unknown as { eventGate: { + debounceTimers: Map; events: unknown[] }>; + fireDebounce(name: string): void; + } }).eventGate; + const pending = gate.debounceTimers.get('counterpart')!; + assert(pending, 'the counterpart arms a delayed wake'); + const timer = pending.timer; + const events = [...pending.events]; + await f.send('push/event', { + ...f.params('self', 'self addition', { channelId: 'chat', key: 'same', deferred: lane === 'deferred' }), + tags: ['chat:from-self', 'probe:wake'], origin: { authorId: 'self' }, + }); + assert.equal(gate.debounceTimers.get('counterpart')?.timer, timer, 'self cannot cancel or extend the original timer'); + assert.deepEqual(pending.events, events, 'self creates no delayed gate cause'); + clearTimeout(timer); + gate.fireDebounce('counterpart'); + await f.framework.runUntilIdle(); + assert(f.membrane.calls.length > 0, 'the genuine counterpart wake is preserved'); + if (lane === 'deferred') { + assert.equal(f.renders.length, 1); + assert(f.context(binding.agentName).includes('document_diff')); + } else { + assert(f.context(binding.agentName).includes('self addition')); + } + }); +} + +for (const denial of ['push grant', 'channel grant', 'binding'] as const) { + test('recovered counterpart work cannot spawn or wake before current ' + denial + ' authorization', async (t) => { + const f = await setup(t); + const params = { + ...f.params('active', 'authorized original', { channelId: 'chat', key: 'recovery-authority', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }; + await f.send('push/event', params); + await f.framework.stop(); + const calls = f.membrane.calls.length; + if (denial === 'push grant') await f.create(false); + else { + // A restored snapshot bound to a different peer must not transfer its wake. + if (denial === 'binding') { + const { JsStore } = await import('@animalabs/chronicle'); + const { join } = await import('node:path'); + const store = JsStore.openOrCreate({ path: join(f.dir, 'store') }); + const snapshot = store.getStateJson('mcpl/coalescing') as { subjects: Array<{ batch?: { latest: { binding: string } } }> }; + for (const s of snapshot.subjects) if (s.batch) s.batch.latest.binding = 'a different peer'; + store.setStateJson('mcpl/coalescing', snapshot); + store.close(); + } + await f.create(); + } + if (denial === 'channel grant') { + const { CapabilityGrant } = await import('../src/mcpl/capability-grant.js'); + const connection = (f.framework as unknown as { mcplServerRegistry: { getServer(id: string): { establishGrant(grant: unknown): void } } }).mcplServerRegistry.getServer('editor'); + connection.establishGrant(new CapabilityGrant(new Set(['pushEvents']), [])); + } + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined, 'current authority must precede audience creation'); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'unapproved content cannot cause even an empty turn'); + assert.equal(f.renders.length, 0); + }); +} + +test('valid recovered work waits for channel registration, then wakes once', async (t) => { + const f = await setup(t); + await f.send('push/event', { + ...f.params('active', 'wait for registration', { channelId: 'chat', key: 'recovery-authority', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + await f.framework.stop(); + await f.create(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined); + f.framework.nudgeAgent('agent', 'unrelated startup work'); + await f.framework.runUntilIdle(); + assert.equal(f.framework.getConversationRouter()!.getBinding('chat'), undefined, 'unrelated assembly cannot spawn or discard unready work'); + await f.register(); + await eventually(() => !!f.framework.getConversationRouter()!.getBinding('chat'), 'registration recovery wake'); + await f.framework.runUntilIdle(); + const binding = f.framework.getConversationRouter()!.getBinding('chat'); + assert(binding, 'registration resumes the still-pending work'); + assert(f.context(binding.agentName).includes('document_diff')); + assert.equal(f.renders.length, 1); + const calls = f.membrane.calls.length; + await f.register(); + await f.framework.runUntilIdle(); + assert.equal(f.membrane.calls.length, calls, 'repeated readiness creates no duplicate turn'); +}); + +test('a transient fork creation failure preserves acknowledged recovered work for retry', async (t) => { + const f = await setup(t); + await f.send('push/event', { + ...f.params('active', 'recoverable fallback', { channelId: 'chat', key: 'spawn-failure', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + await f.framework.stop(); + await f.create(); + const router = f.framework.getConversationRouter()!; + // The unfixed recovery may already have spawned; isolate the subsequent + // audience-resolution attempt rather than relying on that separate defect. + router.unbind('chat'); + const internals = f.framework as unknown as { + createConversationAgent(name: string, channelId: string): Promise; + pushCoalescer: { snapshot(): unknown; restore(snapshot: unknown): void; pendingBatches(): number; options: { recoveryBackoffMs?: number } }; + pendingRequests: unknown[]; + }; + internals.pendingRequests.length = 0; + const snapshot = internals.pushCoalescer.snapshot() as { subjects: Array<{ batch?: { rendering?: boolean } }> }; + for (const s of snapshot.subjects) if (s.batch) s.batch.rendering = true; + internals.pushCoalescer.restore(snapshot); + internals.pushCoalescer.options.recoveryBackoffMs = 60_000; + const original = internals.createConversationAgent; + internals.createConversationAgent = async () => { throw new Error('transient spawn failure'); }; + await f.register(); + f.framework.nudgeAgent('agent', 'unrelated turn'); + await f.framework.runUntilIdle(); + assert(internals.pushCoalescer.pendingBatches() > 0, 'failed creation is not permanent audience disappearance'); + internals.createConversationAgent = original; + await f.register(); + await eventually(() => !!router.getBinding('chat'), 'retried audience resolution'); + await f.framework.runUntilIdle(); + const binding = router.getBinding('chat'); + assert(binding); + assert(f.context(binding.agentName).includes('recoverable fallback')); +}); + +test('a transient recovered spawn failure is retried by backoff without another message', async (t) => { + const f = await setup(t); + await f.send('push/event', { + ...f.params('active', 'retry fallback', { channelId: 'chat', key: 'spawn-retry', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human' }, + }); + await f.framework.stop(); + await f.create(); + const internals = f.framework as unknown as { + createConversationAgent(name: string, channelId: string): Promise; + pushCoalescer: { options: { recoveryBackoffMs?: number } }; + }; + const original = internals.createConversationAgent; + let attempts = 0; + internals.createConversationAgent = async function (name, channel) { + if (++attempts === 1) throw new Error('one transient creation failure'); + return original.call(this, name, channel); + }; + internals.pushCoalescer.options.recoveryBackoffMs = 20; + await f.register(); + await eventually(() => !!f.framework.getConversationRouter()!.getBinding('chat'), 'autonomous audience retry'); + await f.framework.runUntilIdle(); + assert(attempts >= 2); + const binding = f.framework.getConversationRouter()!.getBinding('chat')!; + assert(f.context(binding.agentName).includes('document_diff')); + assert.equal(f.membrane.calls.length, 1); +}); + +for (const bind of ['always', 'mention', 'dm'] as const) { +test('a mixed batch retains counterpart delivery under ' + bind + ' binding when its binding disappears during render', async (t) => { + const f = await setup(t, { framework: { conversations: { ...conversations, bind: { channel: bind === 'dm' ? 'never' : bind } } } }); + const router = f.framework.getConversationRouter()!; + await f.send('push/event', { + ...f.params('counterpart', 'fresh counterpart', { channelId: 'chat', key: 'lost-binding', deferred: true }), + tags: ['chat:from-human'], origin: { authorId: 'human', mentioned: bind !== 'dm', ...(bind === 'dm' ? { channel_type: 'im' } : {}) }, + }); + const first = router.getBinding('chat')!; + assert(first); + await f.send('push/event', { + ...f.params('echo', 'self addition', { channelId: 'chat', key: 'lost-binding', deferred: true }), + tags: ['chat:from-self'], origin: { authorId: 'self' }, + }); + f.renderer(async () => { + router.unbind('chat'); + return { content: [{ type: 'text', text: 'rendered counterpart and echo' }] }; + }); + await f.framework.runUntilIdle(); + const replacement = router.getBinding('chat'); + assert(replacement, 'retained counterpart activity permits routing even though the latest content is an echo'); + assert.equal(replacement.generation, first.generation + 1); + assert(f.context(replacement.agentName).includes('rendered counterpart and echo')); + assert.equal(f.renders.length, 1); +}); + +}